From 37acb4842f65c6725146216367eb54b31cf2cadc Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:23:52 +0100 Subject: [PATCH] fix(docker): honor trusted-peer environment overrides --- README.md | 10 ++ SECURITY.md | 5 + compose.yaml | 5 + docs/CURRENT-STATE.md | 9 ++ scripts/docker-start.mjs | 12 ++- scripts/format-scope.json | 3 +- src/dockerLauncher.test.mjs | 3 +- src/dockerTrustedPeers.test.mjs | 179 ++++++++++++++++++++++++++++++++ 8 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/dockerTrustedPeers.test.mjs diff --git a/README.md b/README.md index 3b005c8dc..eb00b9635 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. +
Running on Linux, a NAS, or behind a reverse proxy diff --git a/SECURITY.md b/SECURITY.md index b694d8dcd..aa2f5a397 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -98,6 +98,11 @@ The dev server is a **key broker**: every server-side key above is spendable by `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN client arrives from the same gateway address, which hands them Provider Settings too.** Treat publishing beyond loopback as the LAN opt-in above. + Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from + the shell or project `.env` before the launcher runs. Set trusted peers to + `none` when widening the publish: it becomes an explicit empty list before + Vite loads configuration, so the gateway cannot regain Provider Settings + access. Unset or blank values preserve automatic gateway discovery. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/compose.yaml b/compose.yaml index 7826bf376..c2d3bf3d4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,11 @@ services: # lands in the bind-mounted repo, so run as you, not root. UID/GID are # unset on Docker Desktop, where the 1000 fallback is ignored anyway. user: "${UID:-1000}:${GID:-1000}" + # Forward explicit shell/.env policy before the launcher discovers a gateway. + # Unset values stay unset so the default loopback-only install still works. + environment: + - GEV_KEY_SETUP_TRUSTED_PEERS + - GEV_ALLOWED_HOSTS ports: # Publish to loopback only. Docker's default ("4173:4173") would bind # every host interface, which quietly turns the LAN warning in the README diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60937dd05..300ee9279 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Docker Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` +from the shell or project `.env` before `scripts/docker-start.mjs` runs; shell +values take precedence. The launcher's `none` policy resolves to an empty trusted +peer list and is assigned before importing Vite, preventing later dotenv loading +from replacing it. Unset or blank values retain automatic gateway discovery. +Startup logs distinguish an explicit `none` policy from a missing default route. +This changes neither loopback-only port publishing nor the Provider Settings +Host, Origin, proxy-header and sharing checks. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs index 3935f2b3b..c77d6335f 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) { /** * Decide this launch's trusted-peer list. An operator value always wins, so a - * compose override stays authoritative; otherwise the gateway alone is + * compose override stays authoritative; `none` trusts no additional peers. + * An unset or blank value means the gateway alone is * trusted. No gateway trusts nothing and Provider Settings stays loopback-only, * exactly as under `npm run dev`. * @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input @@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) { */ export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) { const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(); + if (explicit.toLowerCase() === 'none') return ''; if (explicit) return explicit; return defaultGatewayFromRouteTable(routeTable) ?? ''; } @@ -109,15 +111,17 @@ async function start() { String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(), ); const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); + // Set even an empty list before Vite loads .env: the resolved launcher policy + // must remain authoritative when there is no trusted gateway. + process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; if (trustedPeers) { - // Set before Vite is imported: the dev server snapshots process.env during - // configuration, and the admission gate reads it per request. - process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; console.log( fromOperator ? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).` : `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`, ); + } else if (fromOperator) { + console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).'); } else { console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..faac60584 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "src/dockerTrustedPeers.test.mjs" ] diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index ae9987b01..213fe59dc 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8'); assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only'); - assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded'); + assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); + assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); }); diff --git a/src/dockerTrustedPeers.test.mjs b/src/dockerTrustedPeers.test.mjs new file mode 100644 index 000000000..74343bd04 --- /dev/null +++ b/src/dockerTrustedPeers.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { resolveTrustedPeers } from '../scripts/docker-start.mjs'; + +const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n'; +const launcher = fileURLToPath( + new URL('../scripts/docker-start.mjs', import.meta.url), +); +const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href; + +test('none resolves to an empty trust list even with a valid container gateway', () => { + for (const value of ['none', ' NONE ', 'None']) { + assert.equal( + resolveTrustedPeers({ + env: { GEV_KEY_SETUP_TRUSTED_PEERS: value }, + routeTable: ROUTES, + }), + '', + ); + } +}); + +function launchWithPolicy(value, routeTable = ROUTES) { + // Run the real entry point and routing-table reader. Replace only the Linux + // route file and Vite's listening server so this works on macOS and CI too. + const fakeVite = ` + import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)}; + const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS; + export async function createServer() { + const request = { + method: 'POST', hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', contentType: 'application/json', + env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS } + }; + console.log('POLICY ' + JSON.stringify({ + trustedAtImport, + allowedHosts: process.env.GEV_ALLOWED_HOSTS, + gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }), + loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }), + foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' }) + })); + return { async listen() {}, printUrls() {}, async close() {} }; + } + `; + const preload = ` + import fs from 'node:fs'; + import { registerHooks, syncBuiltinESMExports } from 'node:module'; + const originalRead = fs.readFileSync; + fs.readFileSync = (file, ...args) => file === '/proc/net/route' + ? ${JSON.stringify(routeTable)} : originalRead(file, ...args); + syncBuiltinESMExports(); + registerHooks({ resolve(specifier, context, next) { + if (specifier === 'vite') return { + url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true + }; + return next(specifier, context); + }}); + `; + const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' }; + delete env.GEV_KEY_SETUP_TRUSTED_PEERS; + if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value; + const child = spawnSync( + process.execPath, + [ + '--import', + `data:text/javascript,${encodeURIComponent(preload)}`, + launcher, + ], + { env, encoding: 'utf8', timeout: 10000 }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + const line = child.stdout + .split('\n') + .find((line) => line.startsWith('POLICY ')); + assert.ok(line, child.stdout); + return { ...JSON.parse(line.slice(7)), output: child.stdout }; +} + +test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => { + const result = launchWithPolicy('none'); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.equal(result.gateway.code, 'not-loopback'); + assert.equal(result.loopback.ok, true); + assert.equal(result.allowedHosts, 'globe.example.lan'); + assert.match(result.output, /trusts no additional peers/); + assert.doesNotMatch(result.output, /No default gateway/); +}); + +test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => { + for (const value of [undefined, '', ' ', '192.168.0.1']) { + const result = launchWithPolicy(value); + assert.equal(result.trustedAtImport, '192.168.0.1'); + assert.equal(result.gateway.ok, true); + assert.equal(result.foreignOrigin.ok, false); + } + const result = launchWithPolicy(undefined, ''); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.match(result.output, /No default gateway/); +}); + +test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => { + const version = spawnSync('docker', ['compose', 'version'], { + encoding: 'utf8', + timeout: 10000, + }); + if (version.error || version.status !== 0) + return t.skip('Docker Compose CLI is not installed'); + const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + writeFileSync( + path.join(root, 'compose.yaml'), + readFileSync(new URL('../compose.yaml', import.meta.url)), + ); + const baseEnv = { ...process.env }; + for (const key of Object.keys(baseEnv)) { + if ( + key.startsWith('COMPOSE_') || + ['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key) + ) + delete baseEnv[key]; + } + function config(dotenv = '', overrides = {}) { + writeFileSync(path.join(root, '.env'), dotenv); + const child = spawnSync( + 'docker', + [ + 'compose', + '-f', + 'compose.yaml', + '--env-file', + '.env', + 'config', + '--format', + 'json', + ], + { + cwd: root, + env: { ...baseEnv, ...overrides }, + encoding: 'utf8', + timeout: 10000, + }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout).services['gods-eye-view'].environment || {}; + } + const unset = config(); + assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null); + assert.ok(unset.GEV_ALLOWED_HOSTS == null); + const dotenv = + 'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n'; + assert.deepEqual(config(dotenv), { + GEV_KEY_SETUP_TRUSTED_PEERS: 'none', + GEV_ALLOWED_HOSTS: 'globe.example.lan', + }); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '10.9.8.7', + ); + assert.equal( + config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + 'none', + ); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '', + ); +});