Add secure native runtime installation

This commit is contained in:
reaper
2026-08-22 03:47:09 -05:00
parent bd21884925
commit 6fc33cc8a4
12 changed files with 556 additions and 41 deletions
+7 -1
View File
@@ -1,5 +1,9 @@
import { ipcMain } from 'electron'
import { getRuntimeStatus } from '../nativeRuntime'
import {
getRuntimeStatus,
installBundledNativeRuntime,
pickAndInstallNativeRuntime
} from '../nativeRuntime'
import { getRuntimeUpdates } from '../runtimeCatalog'
export function registerRuntimeIpc(): void {
@@ -8,4 +12,6 @@ export function registerRuntimeIpc(): void {
const status = await getRuntimeStatus()
return getRuntimeUpdates(status.native.components)
})
ipcMain.handle('runtime:installBundled', () => installBundledNativeRuntime())
ipcMain.handle('runtime:pickAndInstall', () => pickAndInstallNativeRuntime())
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { renderMariaDbConfig, renderNginxConfig, renderPhpFpmConfig } from './nativeProject'
const project = {
name: 'demo',
root: '/tmp/aurora demo',
docroot: 'public',
ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
}
describe('native project configuration', () => {
it('isolates PHP-FPM on its allocated loopback port', () =>
expect(renderPhpFpmConfig(project)).toContain('listen = 127.0.0.1:41002'))
it('routes nginx PHP requests to the project PHP-FPM service', () => {
const config = renderNginxConfig(project)
expect(config).toContain('listen 127.0.0.1:41001')
expect(config).toContain('fastcgi_pass 127.0.0.1:41002')
expect(config).toContain('location ~ \\.php$')
expect(config).toContain('aurora demo/public')
})
it('isolates MariaDB data and networking', () => {
const config = renderMariaDbConfig(project, '/tmp/aurora-runtime')
expect(config).toContain('bind-address=127.0.0.1')
expect(config).toContain('port=41003')
expect(config).toContain('/.aurora/native/data/mariadb')
})
})
+173
View File
@@ -0,0 +1,173 @@
import { execFile } from 'child_process'
import { access, mkdir, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { promisify } from 'util'
import type { AuroraNativePorts } from './portAllocator'
import { NativeProcessSupervisor, type NativeServiceSpec } from './processSupervisor'
import { runtimeRoot } from '../nativeRuntime'
const execFileAsync = promisify(execFile)
const supervisor = new NativeProcessSupervisor()
export interface NativeProjectDefinition {
name: string
root: string
docroot: string
ports: AuroraNativePorts
}
function nativeDirectory(root: string): string {
return join(root, '.aurora', 'native')
}
function quoteNginx(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
export function renderPhpFpmConfig(project: NativeProjectDefinition): string {
const directory = nativeDirectory(project.root)
return `[global]
daemonize = no
pid = ${join(directory, 'pids', 'php.pid')}
error_log = ${join(directory, 'logs', 'php.log')}
[www]
listen = 127.0.0.1:${project.ports.php}
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
clear_env = no
catch_workers_output = yes
chdir = ${project.root}
`
}
export function renderNginxConfig(project: NativeProjectDefinition): string {
const directory = nativeDirectory(project.root)
const webroot = resolve(project.root, project.docroot || '.')
return `daemon off;
pid "${quoteNginx(join(directory, 'pids', 'nginx.pid'))}";
error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info;
events { worker_connections 256; }
http {
access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}";
server {
listen 127.0.0.1:${project.ports.http};
server_name localhost;
root "${quoteNginx(webroot)}";
index index.php index.html;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \\.php$ {
fastcgi_pass 127.0.0.1:${project.ports.php};
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
}
}
}
`
}
export function renderMariaDbConfig(
project: NativeProjectDefinition,
installedRuntimeRoot = runtimeRoot()
): string {
const directory = nativeDirectory(project.root)
return `[mariadbd]
basedir=${join(installedRuntimeRoot, 'root', 'usr')}
datadir=${join(directory, 'data', 'mariadb')}
tmpdir=${join(directory, 'tmp')}
bind-address=127.0.0.1
port=${project.ports.database}
socket=${join(directory, 'mariadb.sock')}
pid-file=${join(directory, 'pids', 'mariadb.pid')}
log-error=${join(directory, 'logs', 'mariadb.log')}
skip-name-resolve
`
}
export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> {
const directory = nativeDirectory(project.root)
for (const child of ['config', 'data/mariadb', 'logs', 'pids', 'tmp'])
await mkdir(join(directory, child), { recursive: true })
await Promise.all([
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(project)),
writeFile(join(directory, 'config', 'mariadb.cnf'), renderMariaDbConfig(project))
])
try {
await access(join(directory, 'data', 'mariadb', 'mysql'))
} catch {
await execFileAsync(
join(runtimeRoot(), 'bin', 'mariadb-install-db'),
[
'--no-defaults',
`--datadir=${join(directory, 'data', 'mariadb')}`,
`--tmpdir=${join(directory, 'tmp')}`,
'--auth-root-authentication-method=normal',
'--skip-test-db'
],
{ cwd: project.root, maxBuffer: 16 * 1024 * 1024 }
)
}
}
export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServiceSpec[] {
const directory = nativeDirectory(project.root)
const common = (id: string): Pick<NativeServiceSpec, 'id' | 'cwd' | 'logPath' | 'pidPath'> => ({
id: `${project.name}:${id}`,
cwd: project.root,
logPath: join(directory, 'logs', `${id}-process.log`),
pidPath: join(directory, 'pids', `${id}-process.json`)
})
return [
{
...common('database'),
command: join(runtimeRoot(), 'bin', 'mariadbd'),
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
ready: { port: project.ports.database, timeoutMs: 30000 }
},
{
...common('php'),
command: join(runtimeRoot(), 'bin', 'php-fpm'),
args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')],
ready: { port: project.ports.php }
},
{
...common('web'),
command: join(runtimeRoot(), 'bin', 'nginx'),
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
ready: { port: project.ports.http }
}
]
}
export async function startNativeProject(project: NativeProjectDefinition): Promise<void> {
await provisionNativeProject(project)
for (const spec of nativeServiceSpecs(project)) await supervisor.start(spec)
}
export async function stopNativeProject(project: NativeProjectDefinition): Promise<void> {
for (const spec of nativeServiceSpecs(project).reverse()) await supervisor.stop(spec)
}
export async function nativeProjectStatus(
project: NativeProjectDefinition
): Promise<{ running: boolean; services: Record<string, 'running' | 'stopped'> }> {
const entries = await Promise.all(
nativeServiceSpecs(project).map(
async (spec) => [spec.id.split(':').at(-1)!, await supervisor.status(spec)] as const
)
)
const services = Object.fromEntries(entries)
return {
running: entries.length > 0 && entries.every(([, status]) => status === 'running'),
services
}
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { validateNativeRuntimeManifest } from './nativeRuntime'
import { validateArchiveEntries, validateNativeRuntimeManifest } from './nativeRuntime'
const valid = {
schema: 1,
@@ -28,4 +28,13 @@ describe('Aurora Native runtime manifest', () => {
components: [{ ...valid.components[0], sha256: 'bad' }]
})
).toThrow(/checksum/))
it('rejects archive entries that escape the installation directory', () => {
expect(() => validateArchiveEntries('./runtime.json\n./bin/php\n')).not.toThrow()
expect(() => validateArchiveEntries('./runtime.json\n../outside\n')).toThrow(
/Unsafe runtime archive entry/
)
expect(() => validateArchiveEntries('/absolute/runtime.json\n')).toThrow(
/Unsafe runtime archive entry/
)
})
})
+111 -18
View File
@@ -1,9 +1,9 @@
import { app } from 'electron'
import { app, dialog } from 'electron'
import { execFile } from 'child_process'
import { createHash } from 'crypto'
import { createReadStream } from 'fs'
import { access, readFile } from 'fs/promises'
import { isAbsolute, join, resolve, sep } from 'path'
import { access, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'fs/promises'
import { basename, isAbsolute, join, resolve, sep } from 'path'
import { promisify } from 'util'
import type { AuroraNativeRuntimeManifest, AuroraRuntimeStatus } from '../shared/types'
@@ -65,7 +65,7 @@ export function validateNativeRuntimeManifest(value: unknown): AuroraNativeRunti
}
}
function runtimeRoot(): string {
export function runtimeRoot(): string {
return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`)
}
@@ -79,6 +79,112 @@ async function sha256(path: string): Promise<string> {
})
}
async function verifyRuntimeAt(root: string): Promise<AuroraNativeRuntimeManifest> {
const manifest = validateNativeRuntimeManifest(
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
)
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
throw new Error(
`This runtime targets ${manifest.platform}-${manifest.arch}, not ${process.platform}-${process.arch}.`
)
const canonicalRoot = resolve(root)
for (const component of manifest.components) {
const executable = resolve(root, component.executable)
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
throw new Error(`Unsafe executable path for ${component.id}.`)
await access(executable)
if ((await sha256(executable)) !== component.sha256.toLowerCase())
throw new Error(`Checksum verification failed for ${component.id}.`)
}
return manifest
}
async function rejectLinks(root: string, current = root): Promise<void> {
for (const entry of await readdir(current, { withFileTypes: true })) {
const path = join(current, entry.name)
const stat = await lstat(path)
if (stat.isSymbolicLink())
throw new Error(`Runtime archive contains a symbolic link: ${path.slice(root.length + 1)}`)
if (stat.isDirectory()) await rejectLinks(root, path)
}
}
export function validateArchiveEntries(output: string): void {
const entries = output.split(/\r?\n/).filter(Boolean)
if (!entries.length || entries.length > 50000)
throw new Error('Runtime archive has an invalid file count.')
for (const entry of entries) {
const normalized = entry.replace(/^\.\//, '')
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..'))
throw new Error(`Unsafe runtime archive entry: ${entry}`)
}
}
export async function installNativeRuntimeArchive(source: string): Promise<AuroraRuntimeStatus> {
if (!source.endsWith('.tar.gz'))
throw new Error('Aurora Native runtimes must be .tar.gz archives.')
const runtimes = join(app.getPath('userData'), 'runtimes')
await mkdir(runtimes, { recursive: true })
const staging = await mkdtemp(join(runtimes, '.install-'))
const target = runtimeRoot()
const backup = `${target}.previous`
try {
const { stdout } = await execFileAsync('tar', ['-tzf', source], { maxBuffer: 16 * 1024 * 1024 })
validateArchiveEntries(stdout)
await execFileAsync(
'tar',
['-xzf', source, '--no-same-owner', '--no-same-permissions', '-C', staging],
{ maxBuffer: 16 * 1024 * 1024 }
)
await rejectLinks(staging)
await verifyRuntimeAt(staging)
await rm(backup, { recursive: true, force: true })
try {
await rename(target, backup)
} catch {
/* first installation */
}
try {
await rename(staging, target)
await rm(backup, { recursive: true, force: true })
} catch (error) {
try {
await rename(backup, target)
} catch {
/* no previous runtime */
}
throw error
}
return getRuntimeStatus()
} finally {
await rm(staging, { recursive: true, force: true })
}
}
export async function pickAndInstallNativeRuntime(): Promise<AuroraRuntimeStatus | null> {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Aurora Native Runtime', extensions: ['gz'] }]
})
return result.canceled || !result.filePaths[0]
? null
: installNativeRuntimeArchive(result.filePaths[0])
}
export async function installBundledNativeRuntime(): Promise<AuroraRuntimeStatus> {
const directory = join(process.resourcesPath, 'native-runtime')
const expected = `aurora-native-0.1.0-${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}.tar.gz`
const candidates = await readdir(directory)
const archive =
candidates.find((entry) => entry === expected) ??
candidates.find((entry) => entry.endsWith(`-${process.platform}-${process.arch}.tar.gz`))
if (!archive)
throw new Error(
`No bundled Aurora Native runtime is available for ${process.platform}-${process.arch}.`
)
return installNativeRuntimeArchive(join(directory, basename(archive)))
}
async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch))
return {
@@ -90,20 +196,7 @@ async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
}
const root = runtimeRoot()
try {
const manifest = validateNativeRuntimeManifest(
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
)
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
throw new Error('The installed runtime targets a different platform.')
const canonicalRoot = resolve(root)
for (const component of manifest.components) {
const executable = resolve(root, component.executable)
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
throw new Error(`Unsafe executable path for ${component.id}.`)
await access(executable)
if ((await sha256(executable)) !== component.sha256.toLowerCase())
throw new Error(`Checksum verification failed for ${component.id}.`)
}
const manifest = await verifyRuntimeAt(root)
return {
available: true,
platform: process.platform,