Merge pull request #208 from SM-26/docker-support

feat: add a Docker install path
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-15 14:10:36 -07:00
commit 79b9de07d8
11 files changed
+470 -2

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Keep keys and local state out of the build context: compose bind-mounts the
# checkout at run time, so the image never needs them.
.git
node_modules
dist
.env
.env.*
!.env.example
**/ENVIRONMENT
pinokio/ENVIRONMENT
pinokio/.installed
.gev-cache
.gev-logs
qa-artifacts
qa-shots
screenshots
output
3d-models
.gstack
*.log
.DS_Store
# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise
# collect `._*.test.mjs` as test files.
._*
+7
View File
@@ -96,6 +96,13 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
# Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
# Optional: exact socket addresses (comma-separated, no wildcards or prefixes)
# that Provider Settings treats as this machine in addition to loopback. Leave
# unset. The Docker launcher sets it to the container's gateway at start,
# because that is where Docker delivers your own browser from; it is safe only
# while the port is published on 127.0.0.1. Read SECURITY.md before setting it
# by hand.
# GEV_KEY_SETUP_TRUSTED_PEERS=
# Optional: Mapillary imagery for the Street Level layer. Free client token from
# https://www.mapillary.com/dashboard/developers (register an app, copy its
+15
View File
@@ -290,6 +290,21 @@
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
from #653.
- **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image,
pinned by digest, and serves the app on `http://localhost:4173` with no local
Node install. The repo is bind-mounted and the container runs as the host user
(never root), so `.env`,
`.gev-cache`, and `.gev-logs` persist on the host exactly as they do under
`npm run dev` — Provider Settings included. The launcher
(`scripts/docker-start.mjs`) trusts the container's gateway address for that
panel, since that is where Docker delivers the host's own browser from; the
opt-in behind it, `GEV_KEY_SETUP_TRUSTED_PEERS`, takes exact addresses only
and is unset (so loopback-only, unchanged) under every other launcher.
Thanks to [jklaze](https://github.com/jklaze) for finding and diagnosing the
container-to-host 403, and to [14traits](https://github.com/14traits),
[PoBruno](https://github.com/PoBruno) and [milu70hub](https://github.com/milu70hub)
for testing on Apple silicon, Docker Engine on Linux, and NAS hosts.
- Stdio servers from one install share a panel key so a page read from one
process can make requests through another. Concurrent malformed-key repairs
now select one winner; unavailable storage or a busy repair retains the
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
#
# The dev server is the whole app: Vite serves the client AND runs the API
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
# is nothing else to containerise. See compose.yaml for the usual entry point.
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
# `npm test` builds fixtures with `git init`, so the suite cannot run in a
# container without it (71 failures on node:24-slim, all ENOENT on git).
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# The repo is bind-mounted, so inside the container it can show an owner other
# than the user the container runs as - always, on Docker Desktop. git then
# refuses it as "dubious ownership", which fails the tests that shell out to
# git. It is the user's own checkout, so trust that one path.
RUN git config --system --add safe.directory /app
# Never run as root. The image's own user is `node` (1000); compose overrides
# it with the host user so bind-mounted files stay yours.
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
# node_modules belongs to `node`, but compose may run as another UID. Vite only
# needs to write its dep-optimizer cache, so open up those two directories
# rather than chowning a few hundred MB to a UID we cannot know at build time.
RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite
COPY --chown=node:node . .
# Bind to all interfaces, otherwise the published port hits nothing. This does
# not widen the Host allowlist: IP addresses and localhost are accepted, other
# names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js).
ENV HOST=0.0.0.0
ENV PORT=4173
EXPOSE 4173
# The launcher is `npm run dev` plus one step: it trusts the container's
# gateway address for Provider Settings, because that is where Docker delivers
# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs.
CMD ["node", "scripts/docker-start.mjs"]
+66
View File
@@ -153,6 +153,72 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
configured keys straight from the Keychain. It starts keyless too.
### Path 3 — Docker
No Node install needed; the container publishes the same dev server.
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
docker compose up
```
Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything:
- `.env` - your API keys, whether saved from Provider Settings or typed in by hand
- `.gev-cache/` - downloaded map and satellite data, so later starts are faster
- `.gev-logs/` - logs
Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one.
The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice.
Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail.
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
<details>
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
Run it as the user who should own the files it writes. `id` shows the numbers; for yourself on Linux:
```bash
env UID=$(id -u) GID=$(id -g) docker compose up
```
On a NAS, clone onto the share you want the data on and use the share owner's ids:
```bash
cd /volume1/docker
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
env UID=1026 GID=100 docker compose up -d
```
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
</details>
<details>
<summary>IPv6-only networks, rebuilding, and the test suites</summary>
The launcher reads the IPv4 default gateway, so an IPv6-only Docker network
finds none: it logs `No default gateway; Provider Settings stays loopback-only`
and the panel refuses every save. Put your keys in `.env` by hand there, or name
the gateway yourself with `GEV_KEY_SETUP_TRUSTED_PEERS` after reading
[SECURITY.md](SECURITY.md).
`node_modules` deliberately stays inside the image, so it survives in an
anonymous volume across restarts: after a `git pull` that changes dependencies,
recreate it with `docker compose down -v && docker compose up --build`. The
image also skips Puppeteer's Chromium download, which keeps it small but leaves
`npm run test:track` to the host — `npm run build` and `npm test` run fine in
the container.
</details>
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
+11
View File
@@ -87,6 +87,17 @@ The dev server is a **key broker**: every server-side key above is spendable by
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
- **Docker trusts one address, and only because the port is on loopback.**
Docker delivers the host's own browser to the container from the container
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
Settings refuses the machine running it. `scripts/docker-start.mjs` therefore
sets `GEV_KEY_SETUP_TRUSTED_PEERS` to exactly that gateway address. The list
takes exact addresses only (no wildcards, prefixes or hostnames), is unset
under every other launcher, and relaxes none of the sharing, Host, Origin,
Content-Type or proxy-header checks. It is safe precisely because
`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.
- **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
+21
View File
@@ -0,0 +1,21 @@
services:
gods-eye-view:
build: .
# Everything the app writes (.env with your keys, .gev-cache, .gev-logs)
# 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}"
ports:
# Publish to loopback only. Docker's default ("4173:4173") would bind
# every host interface, which quietly turns the LAN warning in the README
# into the default: this server brokers whatever provider keys you have
# configured to anyone who can reach it. Widen this deliberately, not by
# accident.
- "127.0.0.1:4173:4173"
volumes:
# Bind the source so edits hot-reload and so keys pasted into the setup
# UI land in the .env on your host instead of vanishing with the
# container. node_modules stays in the image: sharp and friends are
# compiled for Linux and must not be shadowed by a host install.
- .:/app
- /app/node_modules
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Docker launcher — the container's entry point (see Dockerfile).
*
* `compose.yaml` publishes the port on the host's loopback, but Docker still
* delivers the host's own browser to the container FROM the container
* network's gateway address, never from 127.0.0.1. Provider Settings admits
* loopback only, so without this the POWER UP panel refuses the very machine
* running the container. This launcher reads the gateway out of the kernel
* routing table and declares exactly that one address trusted, then starts
* Vite the way scripts/pinokio-start.mjs does.
*/
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDirectInvocation } from './pinokio-install.mjs';
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
const MODULE_PATH = fileURLToPath(import.meta.url);
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
const ROUTE_TABLE = '/proc/net/route';
/**
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
* addresses there are little-endian hex and only the default route (a
* destination of 00000000 on an interface flagged up) names a gateway.
* Returns null when there is none — the `--network none` case, which must
* never fabricate an address.
* @param {string} text Raw routing-table contents.
* @returns {string|null} Dotted-quad gateway, or null.
*/
export function defaultGatewayFromRouteTable(text) {
const lines = String(text || '').split(/\r?\n/).slice(1);
for (const line of lines) {
const fields = line.trim().split(/\s+/);
if (fields.length < 4) continue;
const [, destination, gateway, flags] = fields;
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
const octets = [6, 4, 2, 0].map((offset) =>
Number.parseInt(gateway.slice(offset, offset + 2), 16),
);
if (octets.every((octet) => octet === 0)) continue;
return octets.join('.');
}
return null;
}
/**
* Decide this launch's trusted-peer list. An operator value always wins, so a
* compose override stays authoritative; otherwise 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
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
*/
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
if (explicit) return explicit;
return defaultGatewayFromRouteTable(routeTable) ?? '';
}
/**
* A checkout the container cannot write fails quietly: Provider Settings
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
* usual cause is a clone made as root on a NAS or Proxmox host while the
* container runs as 1000:1000. Say so at start, naming both sets of ids.
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
* @returns {string|null} A warning, or null when the checkout is writable.
*/
export function unwritableRootWarning({ root, writable, owner, runAs }) {
if (writable) return null;
return (
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
);
}
function readRouteTable() {
try {
return readFileSync(ROUTE_TABLE, 'utf8');
} catch {
return '';
}
}
async function start() {
let writable = true;
try {
accessSync(ROOT, constants.W_OK);
} catch {
writable = false;
}
if (!writable) {
const { uid, gid } = statSync(ROOT);
console.warn(
unwritableRootWarning({
root: ROOT,
writable,
owner: { uid, gid },
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
}),
);
}
const fromOperator = Boolean(
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
);
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
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 {
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
}
const port = Number.parseInt(process.env.PORT, 10) || 4173;
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
const server = await createServer({
root: ROOT,
server: {
// The container's own namespace. compose publishes this to the host's
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
host: '0.0.0.0',
port,
strictPort: true,
},
});
await server.listen();
server.printUrls();
console.log(`[Docker] Ready at http://localhost:${port}/`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
await server.close();
process.exit(0);
});
}
}
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
start().catch((error) => {
console.error(`[Docker] Start refused: ${error.message}`);
process.exitCode = 1;
});
}
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
defaultGatewayFromRouteTable,
unwritableRootWarning,
resolveTrustedPeers,
} from '../scripts/docker-start.mjs';
// The shape of /proc/net/route inside a bridge-networked container:
// little-endian hex addresses, the default route first, an interface route
// after.
const BRIDGE_ROUTES = [
'Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
'eth0\t00000000\t0100A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
'eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0',
'',
].join('\n');
test('the container gateway is decoded from the kernel routing table', () => {
assert.equal(defaultGatewayFromRouteTable(BRIDGE_ROUTES), '192.168.0.1', 'little-endian hex, default route only');
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '011FAC')),
null,
'a malformed gateway field is not an address',
);
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('\t0003\t', '\t0002\t')),
null,
'a default route that is not up is ignored',
);
assert.equal(
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '00000000')),
null,
'a zero gateway is not an address',
);
assert.equal(
defaultGatewayFromRouteTable(
BRIDGE_ROUTES.split('\n').filter((line) => !line.includes('00000000\t0100A8C0')).join('\n'),
),
null,
'--network none has no default route and must not invent one',
);
assert.equal(defaultGatewayFromRouteTable(''), null);
assert.equal(defaultGatewayFromRouteTable(undefined), null);
});
test('an operator list wins, and no gateway trusts nothing', () => {
assert.equal(resolveTrustedPeers({ env: {}, routeTable: BRIDGE_ROUTES }), '192.168.0.1');
assert.equal(resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.9.8.7 ' }, routeTable: BRIDGE_ROUTES }), '10.9.8.7');
assert.equal(
resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' ' }, routeTable: BRIDGE_ROUTES }),
'192.168.0.1',
'a blank override falls through to the gateway',
);
assert.equal(resolveTrustedPeers({ env: {}, routeTable: '' }), '', 'loopback-only when there is no gateway');
});
test('an unwritable checkout is named at start instead of failing quietly', () => {
const ids = { owner: { uid: 0, gid: 0 }, runAs: { uid: 1000, gid: 1000 } };
assert.equal(unwritableRootWarning({ root: '/app', writable: true, ...ids }), null, 'a writable checkout says nothing');
const warning = unwritableRootWarning({ root: '/app', writable: false, ...ids });
assert.match(warning, /owned by 0:0/, 'names the owner');
assert.match(warning, /runs as 1000:1000/, 'names the ids the container runs as');
assert.match(warning, /chown -R 1000:1000/, 'offers handing the checkout to the container user first');
assert.match(warning, /env UID=0 GID=0 docker compose up/, 'or the env form, which bash does not ignore');
});
test('the Docker install path keeps its loopback-only, launcher-owned contract', () => {
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8');
assert.match(dockerfile, /^CMD \["node", "scripts\/docker-start\.mjs"\]$/m, 'the launcher is the container entry point');
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.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
});
+33 -2
View File
@@ -126,6 +126,32 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/** Socket addresses that count as this machine. */
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Whether a socket address is one the operator has explicitly declared to be
* this machine through GEV_KEY_SETUP_TRUSTED_PEERS: a comma-separated list of
* EXACT addresses, never wildcards, prefixes or hostnames. Unset trusts
* nothing, which is every launcher but one.
*
* The shipped use is the Docker launcher (scripts/docker-start.mjs). A port
* published on the host's loopback still reaches the container FROM the
* container network's gateway, never from 127.0.0.1, so the loopback check
* alone refuses the very machine running the server. Admitting that one
* address relaxes nothing else: the sharing, Host, Origin, Content-Type and
* proxy-header checks all still apply to it.
*/
function isTrustedPeerAddress(remoteAddress, env) {
const normalize = (value) =>
String(value || '')
.trim()
.toLowerCase()
.replace(/^::ffff:/, '');
const address = normalize(remoteAddress);
if (!address) return false;
return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '')
.split(',')
.some((entry) => entry.trim() !== '' && normalize(entry) === address);
}
/** Parse an exact local request authority from a Host header. */
function localAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '')
@@ -224,7 +250,9 @@ export function isSharingEnabled(env = {}) {
* outright — a credential-writing endpoint has no business existing on a
* shared instance, and tunnel traffic reaches the server FROM loopback, so
* the socket check below cannot carry that boundary alone;
* - loopback socket: refuses LAN peers when the server is bound wide;
* - loopback socket: refuses LAN peers when the server is bound wide. The
* only exception is an exact address listed in
* GEV_KEY_SETUP_TRUSTED_PEERS, and every check below still applies to it;
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
* Host even when the socket says loopback;
* - exact same Origin on POST: a hostile web page can make a browser POST to
@@ -274,7 +302,10 @@ export function admitKeySetupRequest({
error: 'Provider Settings is disabled while sharing is enabled',
};
}
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
if (
!LOOPBACK_ADDRESSES.has(String(remoteAddress || '')) &&
!isTrustedPeerAddress(remoteAddress, env)
) {
return {
ok: false,
status: 403,
+17
View File
@@ -259,6 +259,23 @@ test('the admission gate refuses every non-local shape, one assertion per refusa
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
// A LAN peer reaching a wide-bound server.
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
// A container publishes its port on the host's loopback, yet the host's own
// browser still arrives from the container network's gateway. Only an exact
// address the operator listed admits it, and nothing else is relaxed.
const trusted = { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.0.0.9 , 172.29.73.1 ' };
const gateway = { ...local, remoteAddress: '172.29.73.1' };
assert.equal(admitKeySetupRequest(gateway).ok, false, 'container gateway refused without the opt-in');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted }).ok, true, 'listed peer admitted');
assert.equal(admitKeySetupRequest({ ...gateway, remoteAddress: '::ffff:172.29.73.1', env: trusted }).ok, true, 'IPv4-mapped form of a listed peer admitted');
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20', env: trusted }).ok, false, 'unlisted peer still refused');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '*' } }).ok, false, 'wildcards are not addresses');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '172.29.73.0/24' } }).ok, false, 'prefixes are not addresses');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '' } }).ok, false, 'an empty list trusts nothing');
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' , , ' } }).ok, false, 'blank entries trust nothing');
assert.equal(admitKeySetupRequest({ ...gateway, env: { ...trusted, PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'sharing still disables the surface for a listed peer');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'a listed peer still needs a local Host');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, origin: 'https://evil.example' }).ok, false, 'a listed peer still needs the exact Origin');
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, proxyHeaders: { 'x-forwarded-for': '203.0.113.5' } }).ok, false, 'a listed peer still refuses proxied requests');
// Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket.
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');