diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..174f7990a
--- /dev/null
+++ b/.dockerignore
@@ -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.
+._*
diff --git a/.env.example b/.env.example
index 6d3ee3f6f..4e12a8eca 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 111d4945f..c885e2caa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..d34f4887a
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index 68da35027..578ccb185 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
+Running on Linux, a NAS, or behind a reverse proxy
+
+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)).
+
+
+
+
+IPv6-only networks, rebuilding, and the test suites
+
+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.
+
+
+
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
diff --git a/SECURITY.md b/SECURITY.md
index bf88cafb7..cc3f27cfe 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 000000000..7826bf376
--- /dev/null
+++ b/compose.yaml
@@ -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
diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs
new file mode 100644
index 000000000..3935f2b3b
--- /dev/null
+++ b/scripts/docker-start.mjs
@@ -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;
+ });
+}
diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs
new file mode 100644
index 000000000..ae9987b01
--- /dev/null
+++ b/src/dockerLauncher.test.mjs
@@ -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');
+});
diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs
index 60910a66c..d455f1ee4 100644
--- a/src/keySetupCore.mjs
+++ b/src/keySetupCore.mjs
@@ -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,
diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs
index 235deaa36..c734fe766 100644
--- a/src/keySetupCore.test.mjs
+++ b/src/keySetupCore.test.mjs
@@ -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');