diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs index 144a4e9d..5733573b 100644 --- a/scripts/lib/cli.mjs +++ b/scripts/lib/cli.mjs @@ -7,6 +7,7 @@ * any of these re-spelled outside this module. */ import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { access, mkdir, readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' @@ -31,6 +32,44 @@ export const loadCliEnv = () => { /** Local database hosts — including the docker-compose service name. THE one list. */ export const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres']) +/** + * Gateway of the default route, read from /proc/net/route (Linux). The + * self-hosted Forgejo runner publishes each job's `services:` Postgres on the + * bridge gateway IP (192.168.x.1 — differs per job), so a job reaches it via + * that IP instead of localhost. The same octet order the ci-pr.yml awk + * produces (the /proc fields are little-endian). Returns null when there is + * no default route or the file is unreadable (macOS/Windows dev boxes). + */ +export const defaultGatewayHost = () => { + try { + const routes = readFileSync('/proc/net/route', 'utf8').split('\n') + for (const line of routes) { + const fields = line.trim().split(/\s+/) + if (fields[1] !== '00000000') continue + const hex = fields[2] ?? '' + if (hex === '00000000' || hex.length < 8) return null + const octets = [ + parseInt(hex.slice(6, 8), 16), + parseInt(hex.slice(4, 6), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(0, 2), 16), + ] + const isRfc1918 = + octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) + if (!isRfc1918) return null + return octets.join('.') + } + } catch { + // /proc/net/route is Linux-only; fall through to the static list. + } + return null +} + +const gatewayHost = defaultGatewayHost() +if (gatewayHost) LOCAL_HOSTS.add(gatewayHost) + export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB' export const isRemoteDbOverrideSet = () => diff --git a/tests/helpers/assertTestDatabase.ts b/tests/helpers/assertTestDatabase.ts index cbd83a38..38b2bd3d 100644 --- a/tests/helpers/assertTestDatabase.ts +++ b/tests/helpers/assertTestDatabase.ts @@ -12,6 +12,11 @@ /** `teqo_test`, `teqo_wt15_test`, … — the _test suffix is the hard contract. */ const TEST_DATABASE_NAME = /^teqo(_[a-z0-9]+)?_test$/ +// Self-hosted Forgejo runner: the job's postgres service is published on the +// bridge gateway IP (RFC1918), not localhost. Same source as the CLI guard so +// both guards admit exactly the same hosts (OPS50). +import { defaultGatewayHost } from '../../scripts/lib/cli.mjs' + export function assertTestDatabase(databaseUrl: string | undefined): void { if (!databaseUrl) { throw new Error( @@ -29,6 +34,8 @@ export function assertTestDatabase(databaseUrl: string | undefined): void { const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, '')) const localHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + const gateway = defaultGatewayHost() + if (gateway) localHosts.add(gateway) if ( parsedUrl.protocol !== 'postgresql:' || diff --git a/tests/unit/localHosts.unit.spec.ts b/tests/unit/localHosts.unit.spec.ts new file mode 100644 index 00000000..a15331d2 --- /dev/null +++ b/tests/unit/localHosts.unit.spec.ts @@ -0,0 +1,28 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { defaultGatewayHost, LOCAL_HOSTS } from '../../scripts/lib/cli.mjs' + +describe('LOCAL_HOSTS (OPS50 — self-hosted runner reaches services via the bridge gateway)', () => { + it('still contains the canonical static hosts', () => { + for (const host of ['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres']) { + expect(LOCAL_HOSTS.has(host)).toBe(true) + } + }) + + it('admits the machine default gateway when it is RFC1918 (Linux CI bridge)', () => { + const gateway = defaultGatewayHost() + if (gateway) { + // The Forgejo runner publishes the job's postgres service on the bridge + // gateway IP; assertLocalDatabase must accept it so seeds run in CI. + expect(LOCAL_HOSTS.has(gateway)).toBe(true) + } + }) + + it('never admits public or loopback hosts', () => { + expect(LOCAL_HOSTS.has('8.8.8.8')).toBe(false) + expect(LOCAL_HOSTS.has('127.0.0.2')).toBe(false) + expect(LOCAL_HOSTS.has('203.0.113.1')).toBe(false) + }) +})