Merge pull request #1265 from bilawalsidhu/codex/fix-docker-trusted-peers

fix(docker): honor trusted-peer environment overrides
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-22 23:15:17 +01:00
commit 5bb0a210b7
9 files changed
+223 -6

No files matched your search

+10
View File
@@ -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.
<details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
+5
View File
@@ -100,6 +100,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
+3
View File
@@ -0,0 +1,3 @@
- Honor Docker Compose trusted-peer and allowed-host settings from the shell
or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway
access to Provider Settings before Vite starts (asturksever, #1265).
+5
View File
@@ -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
+9
View File
@@ -9,6 +9,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery.
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,
+8 -4
View File
@@ -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.');
}
+2 -1
View File
@@ -1087,5 +1087,6 @@
"src/ui/styles/status-badge.css",
"src/ui/statusBadge.test.mjs",
"src/ui/cctvBadges.test.mjs",
"scripts/qa-status-badges.mjs"
"scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs"
]
+2 -1
View File
@@ -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');
});
+179
View File
@@ -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,
'',
);
});