From cfa2b81539a6340c4cf8d5223ad60a30bef7432f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:27:30 -0500 Subject: [PATCH] fix(computers): allocate listeners outside guest ephemeral ports Co-Authored-By: Paperclip --- .../modules/computers/adapters/boat.test.ts | 18 ++++++++- server/src/modules/computers/adapters/boat.ts | 23 +++++++++++ .../modules/computers/application/ports.ts | 2 + .../computers/application/service.test.ts | 38 +++++++++++++++++++ .../modules/computers/application/service.ts | 15 +++++++- server/src/modules/computers/domain/ledger.ts | 20 +++++++++- 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index fc08211e16b..8ad2fee0f96 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { remoteProgram } from "./remote-program.js"; import { afterEach, describe, it, expect, vi } from "vitest"; -import { boatBackend, createBoatTransportAdmission, desktopReadinessProgram, processProgram } from "./boat.js"; +import { boatBackend, createBoatTransportAdmission, desktopReadinessProgram, processProgram, runnerPortInventoryProgram } from "./boat.js"; import { buildGitAuthInvocation } from "../../../services/git-credentials.js"; import type { ComputerRecord } from "../domain/ledger.js"; const sshFactory = vi.hoisted(() => vi.fn()); @@ -44,6 +44,22 @@ const json = (value: unknown, status = 200, headers?: Record) => headers: { "content-type": "application/json", ...headers }, }); describe("Boat transport validation", () => { + it("reads outgoing and listening TCP ports across IPv4 and IPv6", () => { + const root = mkdtempSync(join(tmpdir(), "boat-port-inventory-")); + try { + writeFileSync(join(root, "range"), "32768 60999\n"); + writeFileSync(join(root, "tcp"), "header\n0: 00000000:3F00 00000000:0000 0A\n"); + writeFileSync(join(root, "tcp6"), "header\n0: 00000000000000000000000000000000:A87A 00000000:01BB 08\n"); + const program = runnerPortInventoryProgram + .replace("/proc/sys/net/ipv4/ip_local_port_range", join(root, "range")) + .replace("/proc/net/tcp6", join(root, "tcp6")) + .replace("/proc/net/tcp", join(root, "tcp")); + const result = spawnSync("python3", ["-c", program], { encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [16128, 43130] }); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + it("recognizes idle as an already running computer", async () => { const fetcher = vi.fn(async () => json({ diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index 030fde7af36..c578dc17b81 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -475,6 +475,15 @@ print('{}') }); } }, + async runnerPorts(record) { + const value = await execute(record, runnerPortInventoryProgram, {}); + if (!Number.isInteger(value.ephemeralStart) || !Number.isInteger(value.ephemeralEnd) + || value.ephemeralStart < 1 || value.ephemeralEnd > 65535 + || value.ephemeralStart > value.ephemeralEnd || !Array.isArray(value.occupied) + || value.occupied.some((port: unknown) => !Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535)) + throw new ComputerError("provider_error", "Computer returned invalid listener port availability"); + return value; + }, async advance(record, owner) { await execute(record, processProgram, { action: "advance", owner }); }, @@ -564,3 +573,17 @@ print('{}') }, }; } + +/** Read-only Linux socket inventory; all states matter, not only LISTEN. */ +export const runnerPortInventoryProgram = String.raw` +import json +with open('/proc/sys/net/ipv4/ip_local_port_range') as source: + start,end=map(int,source.read().split()) +occupied=set() +for path in ['/proc/net/tcp','/proc/net/tcp6']: + with open(path) as source: + for line in source.readlines()[1:]: + port=int(line.split()[1].rsplit(':',1)[1],16) + if port:occupied.add(port) +print(json.dumps({'ephemeralStart':start,'ephemeralEnd':end,'occupied':sorted(occupied)})) +`; diff --git a/server/src/modules/computers/application/ports.ts b/server/src/modules/computers/application/ports.ts index c8c7d478dcb..b04c386eada 100644 --- a/server/src/modules/computers/application/ports.ts +++ b/server/src/modules/computers/application/ports.ts @@ -4,6 +4,7 @@ import type { Ledger, Owner, ProcessClaim, + RunnerPortAvailability, } from "../domain/ledger.js"; export interface ComputerRepository { create(record: ComputerRecord): Promise; @@ -31,6 +32,7 @@ export interface ComputerBackend { ready(record: ComputerRecord): Promise; claim(record: ComputerRecord): Promise; advance(record: ComputerRecord, owner: Owner): Promise; + runnerPorts(record: ComputerRecord): Promise; runner(record: ComputerRecord, options?: { control?: boolean }): Promise; launch( record: ComputerRecord, diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index fe44159f256..69106e41edd 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -50,6 +50,7 @@ function fixture() { }), claim: vi.fn(async () => {}), advance: vi.fn(async () => {}), + runnerPorts: vi.fn(async () => ({ ephemeralStart: 32768, ephemeralEnd: 60999, occupied: [] })), runner: vi.fn(async () => ({ execute: vi.fn() })), launch: vi.fn(async (_record, owner) => ({ ...owner.process!, @@ -114,6 +115,43 @@ function fixture() { }; } describe("computer ownership", () => { + it("allocates outside guest ephemeral and occupied ports while reserving live owners", async () => { + const f = fixture(); + await f.attach(); + vi.mocked(f.backend.runnerPorts).mockResolvedValue({ + ephemeralStart: 16127, ephemeralEnd: 16128, occupied: [16129, 43130], + }); + const first = await f.admit(); + const second = await f.admit("other-agent", "other-session"); + expect(first.listenerPort).toBe(16130); + expect(second.listenerPort).toBe(16131); + expect(f.backend.advance).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ port: 16131 })); + for (const port of [first.listenerPort, 43130]) { + await expect(f.service.preview({ ...f.scope, owner: first.owner, port })).rejects.toMatchObject({ code: "forbidden" }); + } + }); + + it("fails closed when the guest reserves the entire fixed listener range", async () => { + const f = fixture(); + await f.attach(); + vi.mocked(f.backend.runnerPorts).mockResolvedValue({ ephemeralStart: 1024, ephemeralEnd: 65535, occupied: [] }); + await expect(f.admit()).rejects.toMatchObject({ code: "conflict", message: "Computer has no available runner ports" }); + expect(f.backend.advance).not.toHaveBeenCalled(); + }); + + it("preserves historical warm listener identity without reallocating an occupied own port", async () => { + const f = fixture(); + await f.attach(); + const first = await f.admit(); + await first.launch({ command: "runnerd" }); + await f.repository.update(f.scope, record => { record.ledger.owners.find(owner => owner.id === first.owner.ownerId)!.port = 43130; }); + await f.service.retainWarm({ ...f.scope, owner: first.owner, idleTimeoutMs: 60_000 }); + vi.mocked(f.backend.runnerPorts).mockClear(); + const next = await f.admit(); + expect(next.listenerPort).toBe(43130); + expect(f.backend.runnerPorts).not.toHaveBeenCalled(); + }); + it("passes checkout credentials transiently without retaining them in computer state", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index d5c5548e73f..54450301cc7 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -7,6 +7,7 @@ import { expired, liveOwners, nextPort, + isRunnerPort, segment, timeout, type ComputerRecord, @@ -455,6 +456,18 @@ finally: }); await backend.ready(result.record); await backend.claim(result.record); + if (result.owner.generation === 1 && !result.owner.process) { + const availability = await backend.runnerPorts(result.record); + const allocated = await repository.update(input, (record) => { + const owner = exactOwner(record, ref(result.record, result.owner)); + if (owner.phase !== "starting") + throw new ComputerError("conflict", "Computer admission was retired"); + owner.port = nextPort(record.ledger.owners.filter((other) => other.id !== owner.id), availability); + return { record: structuredClone(record), owner: structuredClone(owner) }; + }); + result.record = allocated.record; + result.owner = allocated.owner; + } await backend.advance(result.record, result.owner); await backend.renew(result.record); if (input.probeId) { @@ -661,7 +674,7 @@ finally: expired(owner, now()) ) throw new ComputerError("conflict", "Preview owner is not live"); - if (input.port >= 43127 && input.port < 44127) + if (isRunnerPort(input.port)) throw new ComputerError( "forbidden", "Runner ports are not application previews", diff --git a/server/src/modules/computers/domain/ledger.ts b/server/src/modules/computers/domain/ledger.ts index c433e576b81..2ef59796be3 100644 --- a/server/src/modules/computers/domain/ledger.ts +++ b/server/src/modules/computers/domain/ledger.ts @@ -87,12 +87,28 @@ export function exactOwner(record: ComputerRecord, ref: OwnerRef): Owner { throw new ComputerError("conflict", "Computer owner has been superseded"); return owner; } -export function nextPort(owners: Owner[]): number { +export interface RunnerPortAvailability { + ephemeralStart: number; + ephemeralEnd: number; + occupied: number[]; +} +// Keep fixed listeners below the usual Linux ephemeral range, and still check +// the actual guest policy: custom images can configure a different range. +export const RUNNER_PORT_START = 16127; +export const RUNNER_PORT_END = 17127; +export function isRunnerPort(port: number): boolean { + return (port >= RUNNER_PORT_START && port < RUNNER_PORT_END) + || (port >= 43127 && port < 44127); // Existing warm owners retain their port. +} +export function nextPort(owners: Owner[], availability?: RunnerPortAvailability): number { const occupied = new Set( owners.filter((o) => o.phase !== "retired").map((o) => o.port), ); - for (let port = 43127; port < 44127; port++) + for (const port of availability?.occupied ?? []) occupied.add(port); + for (let port = RUNNER_PORT_START; port < RUNNER_PORT_END; port++) { + if (availability && port >= availability.ephemeralStart && port <= availability.ephemeralEnd) continue; if (!occupied.has(port)) return port; + } throw new ComputerError("conflict", "Computer has no available runner ports"); } export function liveOwners(ledger: Ledger): Owner[] {