diff --git a/README.md b/README.md index ad8e7e4..d89e125 100644 --- a/README.md +++ b/README.md @@ -1241,7 +1241,11 @@ Retained native-session API jobs do not yet enter this one-shot capture scope. The bearer-protected `/admin/pools/status` endpoint reports explicit adopted pool identities and durable roll receipts. Maintenance requires `BRIDGE_BEARER`, even -on loopback. It does not restart other bridge services or stop their jobs. +on loopback. Adoption requires Linux with the existing `ss` socket inventory tool; +unavailable or ambiguous listener evidence refuses ownership transfer. Run adoption +from a native Linux bridge service in the same host network namespace as the +legacy controller; a container-local socket inventory does not prove host ports +are unused. It does not restart other bridge services or stop their jobs. For an inactive historical pool, `POST /admin/pools/adopt` accepts `prefix`, `legacyDataDir`, `legacyPorts`, immutable `expectedImage`, and ordered contiguous diff --git a/scripts/verify-pool-adoption-guards.ts b/scripts/verify-pool-adoption-guards.ts new file mode 100644 index 0000000..fad69f0 --- /dev/null +++ b/scripts/verify-pool-adoption-guards.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env tsx +/** Real Linux IPv6 listener + Docker startup refusal proof. No models or mocks. */ +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { dockerCli } from '../src/executors/docker-cli.js' +import { PoolMaintenance } from '../src/runtime/pool-maintenance.js' +import { acquireDataDirectoryLock } from '../src/runtime/single-instance.js' +import { loadConfig } from '../src/config.js' +import { buildApp } from '../src/server.js' + +const image = process.env.CLI_ROLL_IMAGE +if (!image || !/^sha256:[a-f0-9]{64}$/u.test(image)) throw new Error('existing immutable CLI_ROLL_IMAGE required') +const prefix = `cli-bridge-guard-${randomBytes(5).toString('hex')}` +const root = mkdtempSync(join(tmpdir(), `${prefix}-`)) +const legacy = join(root, 'legacy', 'data'), workspace = join(root, 'legacy', 'workspace') +const data = join(root, 'owner'), badData = join(root, 'bad-startup') +for (const dir of [legacy, workspace, data, badData]) mkdirSync(dir, { recursive: true, mode: 0o700 }) +let id = '' +let owner: PoolMaintenance | undefined +const listener = createServer() +try { + await new Promise((resolve, reject) => { + listener.once('error', reject) + listener.listen({ host: '::1', port: 0, ipv6Only: true }, resolve) + }) + const port = (listener.address() as { port: number }).port + const result = await dockerCli(['run', '-d', '--name', `${prefix}-0`, '--mount', + `type=bind,source=${workspace},target=${workspace}`, image, 'tail', '-f', '/dev/null']) + assert.equal(result.code, 0); id = result.stdout.trim() + owner = new PoolMaintenance(data) + const manifest = { prefix, legacyDataDir: legacy, legacyPorts: [port], expectedImage: image, + slots: [{ index: 0, id }] } + await assert.rejects(owner.adopt(manifest), /legacy controller still listens/) + const lock = acquireDataDirectoryLock(legacy); lock.release() + await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())) + await owner.adopt(manifest) + await owner.close(); owner = undefined + + writeFileSync(join(badData, 'pool-maintenance.json'), '{malformed', { mode: 0o600 }) + const startupPrefix = `${prefix}-startup` + await assert.rejects(buildApp(loadConfig({ BRIDGE_BACKENDS: 'opencode', BRIDGE_BEARER: randomBytes(24).toString('hex'), + BRIDGE_DATA_DIR: badData, OPENCODE_EXECUTOR: 'docker', OPENCODE_DOCKER_IMAGE: image, + OPENCODE_DOCKER_POOL_SIZE: '2', OPENCODE_DOCKER_NAME_PREFIX: startupPrefix })), /maintenance journal needs operator reconciliation/) + const containers = await dockerCli(['ps', '-aq', '--filter', `name=${startupPrefix}`]) + assert.equal(containers.code, 0); assert.equal(containers.stdout.trim(), '') + console.log(JSON.stringify({ complete: true, ipv6_listener_refused: true, refused_adoption_lock_released: true, + adoption_after_listener_close: true, malformed_journal_refused_before_docker_provision: true, + configured_startup_containers_created: 0, image, no_model_calls: true })) +} finally { + if (listener.listening) await new Promise((resolve) => listener.close(() => resolve())) + await owner?.close() + if (id) await dockerCli(['rm', '-f', '-v', id]) + rmSync(root, { recursive: true, force: true }) +} diff --git a/src/runtime/pool-maintenance.ts b/src/runtime/pool-maintenance.ts index cf90553..85eb339 100644 --- a/src/runtime/pool-maintenance.ts +++ b/src/runtime/pool-maintenance.ts @@ -2,7 +2,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, constants, fsyncSync, fstatSync, openSync, readFileSync, renameSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { createConnection } from 'node:net' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import { z } from 'zod' import { ContainerPool } from '../executors/container-pool.js' import { inspectIdle, replaceRuntime, type RollReceipt } from '../executors/docker-runtime-roll.js' @@ -23,17 +24,15 @@ const rollSchema = z.object({ oldId: identity, imageId: z.string().regex(/^sha25 const recordSchema = adoptionRequest.extend({ rolls: z.record(z.string(), rollSchema), image: z.string() }).strict() type Record = z.infer +/** Linux's kernel socket inventory covers IPv4, IPv6 and non-loopback binds. */ async function requireNoListener(port: number): Promise { - await new Promise((resolve, reject) => { - const socket = createConnection({ host: '127.0.0.1', port }) - socket.setTimeout(1000) - socket.once('connect', () => { socket.destroy(); reject(new Error('legacy controller still listens')) }) - socket.once('timeout', () => { socket.destroy(); reject(new Error('legacy controller state unknown')) }) - socket.once('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'ECONNREFUSED') resolve() - else reject(new Error('legacy controller state unknown')) - }) - }) + if (process.platform !== 'linux') throw new Error('pool adoption requires Linux listener inventory') + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid legacy port') + // Missing ss, timeout, nonzero exit, or excessive output all reject adoption. + const { stdout, stderr } = await promisify(execFile)('ss', ['-H', '-ltn', `sport = :${port}`], + { timeout: 2000, maxBuffer: 65536, killSignal: 'SIGKILL' }) + if (stderr.trim()) throw new Error('legacy listener inventory is ambiguous') + if (stdout.trim()) throw new Error('legacy controller still listens on a declared port') } export class PoolMaintenance { diff --git a/src/server.ts b/src/server.ts index 2b50b00..fd9cc87 100644 --- a/src/server.ts +++ b/src/server.ts @@ -352,6 +352,9 @@ export async function buildApp(config: Config): Promise<{ maxLifetimeMs: parseEnvNonNegativeInt('BRIDGE_RUN_MAX_LIFETIME_MS', 0), }) const extras: BuildAppExtras = { shutdownHooks: [], netJail: new Map() } + // Validate the durable maintenance owner before any backend provisions Docker slots. + const poolMaintenance = new PoolMaintenance(config.dataDir) + extras.shutdownHooks.push(async () => await poolMaintenance.close()) const durableWriter = config.asyncDurableWrites ? new DurableRunWriter(config.dataDir) : null if (durableWriter) { await durableWriter.open() @@ -537,8 +540,6 @@ export async function buildApp(config: Config): Promise<{ mountCadRender(app) mountImagesGenerate(app) mountMetrics(app) - const poolMaintenance = new PoolMaintenance(config.dataDir) - extras.shutdownHooks.push(async () => await poolMaintenance.close()) mountPoolMaintenance(app, poolMaintenance, Boolean(config.bearer)) app.get('/', (c) => c.json({