From 2e50836b080c00ef20894aa940bfa63c75f23c54 Mon Sep 17 00:00:00 2001 From: SM-26 <39513660+SM-26@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:43:36 +0200 Subject: [PATCH 1/5] feat: add a Docker install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite's dev server is the whole app here — it serves the client and runs every API proxy as middleware — so one container running `npm run dev` covers it, and HOST/PORT are already env-driven. Three details are deliberate: - The port publishes to 127.0.0.1. Docker's usual "4173:4173" binds every host interface, which would silently make the README's LAN warning the default for a server that brokers configured provider keys. - The repo is bind-mounted, so keys saved from Provider Settings, .gev-cache and .gev-logs persist on the host exactly as they do under `npm run dev`. node_modules stays in the image so a host install cannot shadow the Linux-built native deps. - The container runs as the host user, since everything it writes lands in that bind mount. Vite's dep optimizer still needs to write inside the image's node_modules, hence the two loosened directories. Puppeteer's Chromium download is skipped: it is only needed by `npm run test:track`, which the README points at the host. Co-Authored-By: Claude Opus 5 --- .dockerignore | 6 ++++++ CHANGELOG.md | 6 ++++++ Dockerfile | 28 ++++++++++++++++++++++++++++ README.md | 24 ++++++++++++++++++++++++ compose.yaml | 21 +++++++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..493b503c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +.env +.gev-cache +.gev-logs +dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..d5d88e9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,12 @@ [TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling from #653. +- **Docker install path.** `docker compose up` builds the pinned Node image 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, so `.env`, + `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under + `npm run dev`. + - 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..b4706ffdb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# 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. +FROM node:24-slim + +# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. +ENV PUPPETEER_SKIP_DOWNLOAD=1 +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci +# compose runs the container as the host user so bind-mounted files stay +# yours, but node_modules is baked in as root. 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 . . + +# Bind to all interfaces, otherwise the published port hits nothing. Vite also +# relaxes allowedHosts when HOST is 0.0.0.0, which is what lets the browser +# reach it as "localhost". +ENV HOST=0.0.0.0 +ENV PORT=4173 +EXPOSE 4173 +CMD ["npm", "run", "dev"] diff --git a/README.md b/README.md index 5d97dfb2f..3a21bd189 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,30 @@ 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. The repo is bind-mounted, so `.env`, +`.gev-cache` and `.gev-logs` live on the host and source edits still hot-reload. +The container binds `0.0.0.0` *inside* the container, but the port is published +to `127.0.0.1` only, so the default matches the native one: nobody else can +reach your server. Widening that mapping puts you under +[Sharing an instance](#-sharing-an-instance) — keys and all. + +`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/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 From 1c6c291774e0efd479ad9b54d71e6813f87d73cf Mon Sep 17 00:00:00 2001 From: SM-26 <39513660+SM-26@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:31:07 +0200 Subject: [PATCH 2/5] fix(docker): let Provider Settings admit the host through the published port Docker delivers the host's own browser to the container from the container network's gateway, never from 127.0.0.1, so the loopback-only admission gate answered every POWER UP request with 403 and no key could be saved. The README and CHANGELOG claimed otherwise; both are corrected here. - keySetupCore: GEV_KEY_SETUP_TRUSTED_PEERS, an exact-address list (no wildcards, prefixes or hostnames) admitted alongside loopback. Unset trusts nothing, and the sharing, Host, Origin, Content-Type and proxy-header checks all still apply to a listed address. Twelve assertions pin each refusal. - scripts/docker-start.mjs: the container entry point, shaped after scripts/pinokio-start.mjs. It reads the default gateway from /proc/net/route, trusts exactly that address, and starts Vite. No gateway (--network none) trusts nothing. The pure helpers are unit-tested and a contract test pins the Dockerfile CMD and the loopback-only publish. - Dockerfile: install git. `npm test` builds fixtures with `git init`, so the suite failed 71 tests inside the image and the README's claim that it runs in the container was wrong. - SECURITY.md and .env.example document the setting and why it is safe only behind a 127.0.0.1 publish; README gains the Linux/NAS id note and .dockerignore drops macOS AppleDouble sidecars, both reported on the PR. Verified in Docker on a clean build: saving a key from the panel writes the bind-mounted .env (0600 in the container), it survives `compose down`/`up`, and REMOVE comments it back out. A foreign Origin, a foreign Host, a proxy header and a sibling container on the same network are each refused. `npm run doctor`, `npm test` (5067 passed, 1 skipped, 0 failed) and `npm run build` are green inside the container. Reported-by: @jklaze Co-Authored-By: Bogdan Petrut Co-Authored-By: Claude Opus 5 --- .dockerignore | 3 + .env.example | 7 ++ CHANGELOG.md | 10 ++- Dockerfile | 9 ++- README.md | 35 ++++++++-- SECURITY.md | 11 +++ scripts/docker-start.mjs | 132 ++++++++++++++++++++++++++++++++++++ src/dockerLauncher.test.mjs | 81 ++++++++++++++++++++++ src/keySetupCore.mjs | 32 ++++++++- src/keySetupCore.test.mjs | 17 +++++ 10 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 scripts/docker-start.mjs create mode 100644 src/dockerLauncher.test.mjs diff --git a/.dockerignore b/.dockerignore index 493b503c9..3f5bc8a9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,6 @@ node_modules .gev-cache .gev-logs dist +# 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 c89680b30..825910d3d 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,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 d5d88e9dc..658749625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,7 +111,15 @@ 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, so `.env`, `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under - `npm run dev`. + `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 diff --git a/Dockerfile b/Dockerfile index b4706ffdb..de89347f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,10 @@ FROM node:24-slim ENV PUPPETEER_SKIP_DOWNLOAD=1 WORKDIR /app +# `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/* + COPY package.json package-lock.json ./ RUN npm ci # compose runs the container as the host user so bind-mounted files stay @@ -25,4 +29,7 @@ COPY . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 -CMD ["npm", "run", "dev"] +# 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 3a21bd189..5ab3ae693 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,34 @@ docker compose up ``` Open **`http://localhost:4173`** as above. The repo is bind-mounted, so `.env`, -`.gev-cache` and `.gev-logs` live on the host and source edits still hot-reload. -The container binds `0.0.0.0` *inside* the container, but the port is published -to `127.0.0.1` only, so the default matches the native one: nobody else can -reach your server. Widening that mapping puts you under -[Sharing an instance](#-sharing-an-instance) — keys and all. +`.gev-cache` and `.gev-logs` live on the host, edits still hot-reload, and keys +you save from Provider Settings survive `docker compose down`. The port is +published to `127.0.0.1` only, so the default matches the native one. + +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. + +
+File ownership on Linux and NAS hosts + +On Linux, export your ids first so the files the container writes stay yours: +`export UID GID` before `docker compose up`. On a NAS or anywhere the share has +its own owner, set them explicitly instead (`UID=1026 GID=100 docker compose +up`) — Docker Desktop on macOS and Windows maps ownership for you. + +
+ +
+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, @@ -169,6 +192,8 @@ 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 53437a83c..23dfee4e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -84,6 +84,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/scripts/docker-start.mjs b/scripts/docker-start.mjs new file mode 100644 index 000000000..25ddf76ba --- /dev/null +++ b/scripts/docker-start.mjs @@ -0,0 +1,132 @@ +#!/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 { readFileSync, realpathSync } 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'; +/** Host names the native launcher accepts; the container matches it. */ +const LOCAL_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1', '.local']); + +/** + * 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) ?? ''; +} + +/** + * Host headers this container answers to. A wildcard bind is unavoidable + * inside a container, but build/vite.js reads that as "allow every Host" + * (allowedHosts: true), dropping the DNS-rebinding protection `npm run dev` + * keeps. The published port only ever delivers the host's own browser, so pin + * the same names the native launcher uses, and still honour an operator's + * GEV_ALLOWED_HOSTS so a LAN hostname stays addable here too. See issue #21. + * @param {NodeJS.ProcessEnv} [env] + * @returns {string[]} + */ +export function resolveAllowedHosts(env = process.env) { + const configured = String(env.GEV_ALLOWED_HOSTS ?? '') + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + return [...new Set([...LOCAL_ALLOWED_HOSTS, ...configured])]; +} + +function readRouteTable() { + try { + return readFileSync(ROUTE_TABLE, 'utf8'); + } catch { + return ''; + } +} + +async function start() { + 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(`[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, + allowedHosts: resolveAllowedHosts(), + }, + }); + 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..85ac525a4 --- /dev/null +++ b/src/dockerLauncher.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + defaultGatewayFromRouteTable, + resolveAllowedHosts, + 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('the container answers the same host names as the native launcher', () => { + assert.deepEqual(resolveAllowedHosts({}), ['localhost', '127.0.0.1', '.local'], 'a wildcard bind never means "any Host header"'); + assert.deepEqual( + resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' globe.lan , nas.internal ' }), + ['localhost', '127.0.0.1', '.local', 'globe.lan', 'nas.internal'], + 'an operator can still add a LAN hostname', + ); + assert.deepEqual( + resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' , localhost , ' }), + ['localhost', '127.0.0.1', '.local'], + 'blank and duplicate entries change nothing', + ); +}); + +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 6c3cecb2e..73044f003 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -126,6 +126,29 @@ 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 +247,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 +299,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 05f406009..0b66e59b8 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -258,6 +258,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'); From 79a6a0db5cd86f130f73e2c4d29e32bcd47cf343 Mon Sep 17 00:00:00 2001 From: SM-26 <39513660+SM-26@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:39:32 +0200 Subject: [PATCH 3/5] fix(docker): defer to main's Host allowlist and name an unwritable checkout Two findings from PoBruno's Docker Engine on Linux run (#208), and one from re-running the suite in the container after rebasing: - The launcher pinned its own allowedHosts, including '.local'. Vite reads a leading dot as a suffix match, so `Host: anything.local` reached the page and the data proxies. Main has since moved the policy into build/allowedHosts.js, which drops suffix entries on purpose and applies before the app's routes, so the launcher's override is deleted rather than patched. As a side effect GEV_ALLOWED_HOSTS now also works from `.env`, as it does under `npm run dev`. - A checkout the container cannot write - typically a clone made as root on a NAS or Proxmox host - made Save Keys answer 500 and never created .gev-cache, with nothing logged. The launcher now warns at start, naming the owner, the ids it runs as, and the two fixes. - On Docker Desktop the bind-mounted repo always shows an owner other than the container user, so git refuses it as "dubious ownership" and the tests that shell out to git fail (src/data/bundledJson.test.mjs, new on main). The image now marks /app, and only /app, as a safe directory. Verified in a container on this base: `anything.local` 403 on the page and on /api routes, localhost 200, GEV_ALLOWED_HOSTS honoured from the environment and from .env, the warning on a root-owned /app run as 1000:1000, and bundledJson.test.mjs 6/6. Reported-by: PoBruno Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 5 ++++ scripts/docker-start.mjs | 51 ++++++++++++++++++++++++------------- src/dockerLauncher.test.mjs | 22 +++++++--------- src/keySetupCore.mjs | 5 +++- 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index de89347f9..b1b0876d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ WORKDIR /app # `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 COPY package.json package-lock.json ./ RUN npm ci diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs index 25ddf76ba..47519af97 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -10,7 +10,7 @@ * routing table and declares exactly that one address trusted, then starts * Vite the way scripts/pinokio-start.mjs does. */ -import { readFileSync, realpathSync } from 'node:fs'; +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'; @@ -19,8 +19,6 @@ 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'; -/** Host names the native launcher accepts; the container matches it. */ -const LOCAL_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1', '.local']); /** * Extract the IPv4 default gateway from Linux `/proc/net/route` text. The @@ -63,21 +61,21 @@ export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) } /** - * Host headers this container answers to. A wildcard bind is unavoidable - * inside a container, but build/vite.js reads that as "allow every Host" - * (allowedHosts: true), dropping the DNS-rebinding protection `npm run dev` - * keeps. The published port only ever delivers the host's own browser, so pin - * the same names the native launcher uses, and still honour an operator's - * GEV_ALLOWED_HOSTS so a LAN hostname stays addable here too. See issue #21. - * @param {NodeJS.ProcessEnv} [env] - * @returns {string[]} + * 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 resolveAllowedHosts(env = process.env) { - const configured = String(env.GEV_ALLOWED_HOSTS ?? '') - .split(',') - .map((host) => host.trim()) - .filter(Boolean); - return [...new Set([...LOCAL_ALLOWED_HOSTS, ...configured])]; +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() { @@ -89,6 +87,24 @@ function readRouteTable() { } 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 trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); if (trustedPeers) { // Set before Vite is imported: the dev server snapshots process.env during @@ -109,7 +125,6 @@ async function start() { host: '0.0.0.0', port, strictPort: true, - allowedHosts: resolveAllowedHosts(), }, }); await server.listen(); diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index 85ac525a4..ae9987b01 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { defaultGatewayFromRouteTable, - resolveAllowedHosts, + unwritableRootWarning, resolveTrustedPeers, } from '../scripts/docker-start.mjs'; @@ -56,18 +56,14 @@ test('an operator list wins, and no gateway trusts nothing', () => { assert.equal(resolveTrustedPeers({ env: {}, routeTable: '' }), '', 'loopback-only when there is no gateway'); }); -test('the container answers the same host names as the native launcher', () => { - assert.deepEqual(resolveAllowedHosts({}), ['localhost', '127.0.0.1', '.local'], 'a wildcard bind never means "any Host header"'); - assert.deepEqual( - resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' globe.lan , nas.internal ' }), - ['localhost', '127.0.0.1', '.local', 'globe.lan', 'nas.internal'], - 'an operator can still add a LAN hostname', - ); - assert.deepEqual( - resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' , localhost , ' }), - ['localhost', '127.0.0.1', '.local'], - 'blank and duplicate entries change nothing', - ); +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', () => { diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 73044f003..7ecc58eb8 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -141,7 +141,10 @@ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); */ function isTrustedPeerAddress(remoteAddress, env) { const normalize = (value) => - String(value || '').trim().toLowerCase().replace(/^::ffff:/, ''); + String(value || '') + .trim() + .toLowerCase() + .replace(/^::ffff:/, ''); const address = normalize(remoteAddress); if (!address) return false; return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '') From 3ade710ce0b783b7e1dd36433f45eaf9c7ac40e4 Mon Sep 17 00:00:00 2001 From: SM-26 <39513660+SM-26@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:53:08 +0200 Subject: [PATCH 4/5] docs(docker): say where data lives, and fix the UID/GID commands The Path 3 section assumed readers already knew how a bind-mounted container persists state (14traits, #208). It now lists what the app saves and where, with the NAS and reverse-proxy detail in the collapsed block that was already there, so the open part stays about the same length. It also fixes two commands that did not do what they said in bash, confirmed in bash and dash by PoBruno: `UID=1026 GID=100 docker compose up` prints "UID: readonly variable" and still runs as the caller's UID, and `export UID GID` exports an empty GID because bash does not define one. Both are now `env UID=... GID=...`, which `docker compose config` resolves to the requested ids. Suggested-by: 14traits Co-Authored-By: Claude Opus 5.5 --- README.md | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5ab3ae693..7ac0b8e3e 100644 --- a/README.md +++ b/README.md @@ -155,24 +155,37 @@ cd gods-eye-view docker compose up ``` -Open **`http://localhost:4173`** as above. The repo is bind-mounted, so `.env`, -`.gev-cache` and `.gev-logs` live on the host, edits still hot-reload, and keys -you save from Provider Settings survive `docker compose down`. The port is -published to `127.0.0.1` only, so the default matches the native one. +Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything: -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. +- `.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. + +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.
-File ownership on Linux and NAS hosts +Running on Linux, a NAS, or behind a reverse proxy -On Linux, export your ids first so the files the container writes stay yours: -`export UID GID` before `docker compose up`. On a NAS or anywhere the share has -its own owner, set them explicitly instead (`UID=1026 GID=100 docker compose -up`) — Docker Desktop on macOS and Windows maps ownership for you. +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)).
From 3f6fe69c57d34e89378e8fbca2b44ff78f52b93e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 13:07:00 -0700 Subject: [PATCH 5/5] build(docker): keep keys out of the image and run as non-root by default The build context excluded only .env, so .env.local and the like or pinokio/ENVIRONMENT would be baked into image layers; exclude every dotenv file except .env.example, plus local state and QA output. Pin the base to CI's Node 24.14.0 by digest, default to the image's node user, and correct the HOST comment. Log the trusted peers as the operator's when set. README notes that MCP and ChatGPT sign-in voice are unavailable under Docker, and how to trust no gateway when widening the publish. --- .dockerignore | 19 +++++++++++++++++-- CHANGELOG.md | 7 ++++--- Dockerfile | 27 ++++++++++++++++----------- README.md | 4 ++++ scripts/docker-start.mjs | 9 ++++++++- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.dockerignore b/.dockerignore index 3f5bc8a9d..174f7990a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,24 @@ -node_modules +# 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 -dist +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/CHANGELOG.md b/CHANGELOG.md index 2e2cea294..2a7fc91d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -235,9 +235,10 @@ [TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling from #653. -- **Docker install path.** `docker compose up` builds the pinned Node image 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, so `.env`, +- **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 diff --git a/Dockerfile b/Dockerfile index b1b0876d1..d34f4887a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,11 @@ # 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. -FROM node:24-slim +# 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 -WORKDIR /app # `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). @@ -18,19 +18,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends git && rm - # git. It is the user's own checkout, so trust that one path. RUN git config --system --add safe.directory /app -COPY package.json package-lock.json ./ +# 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 -# compose runs the container as the host user so bind-mounted files stay -# yours, but node_modules is baked in as root. 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. +# 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 . . +COPY --chown=node:node . . -# Bind to all interfaces, otherwise the published port hits nothing. Vite also -# relaxes allowedHosts when HOST is 0.0.0.0, which is what lets the browser -# reach it as "localhost". +# 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 diff --git a/README.md b/README.md index b90b37ab9..46007e4c2 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,12 @@ Open **`http://localhost:4173`** as above. Everything the app saves stays in the 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 diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs index 47519af97..3935f2b3b 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -105,12 +105,19 @@ async function start() { ); } + 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(`[Docker] Provider Settings trusts the container gateway (${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.'); }