mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Merge pull request #208 from SM-26/docker-support
feat: add a Docker install path
This commit is contained in:
11 files changed
+470
-2
No files matched your search
@@ -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.
|
||||||
|
._*
|
||||||
@@ -96,6 +96,13 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
|||||||
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
||||||
# Limits (usage limits).
|
# Limits (usage limits).
|
||||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
# 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
|
# Optional: Mapillary imagery for the Street Level layer. Free client token from
|
||||||
# https://www.mapillary.com/dashboard/developers (register an app, copy its
|
# https://www.mapillary.com/dashboard/developers (register an app, copy its
|
||||||
|
|||||||
@@ -290,6 +290,21 @@
|
|||||||
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
|
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
|
||||||
from #653.
|
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
|
- 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
|
process can make requests through another. Concurrent malformed-key repairs
|
||||||
now select one winner; unavailable storage or a busy repair retains the
|
now select one winner; unavailable storage or a busy repair retains the
|
||||||
|
|||||||
+45
@@ -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"]
|
||||||
@@ -153,6 +153,72 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
|
|||||||
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
||||||
configured keys straight from the Keychain. It starts keyless too.
|
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
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||||
|
|||||||
+11
@@ -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.
|
- **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.
|
- **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**.
|
- **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.
|
- **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 LAN and Cloudflare sharing are refused.** The current supported
|
||||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -126,6 +126,32 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|||||||
/** Socket addresses that count as this machine. */
|
/** Socket addresses that count as this machine. */
|
||||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
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. */
|
/** Parse an exact local request authority from a Host header. */
|
||||||
function localAuthority(hostHeader, protocol) {
|
function localAuthority(hostHeader, protocol) {
|
||||||
const raw = String(hostHeader || '')
|
const raw = String(hostHeader || '')
|
||||||
@@ -224,7 +250,9 @@ export function isSharingEnabled(env = {}) {
|
|||||||
* outright — a credential-writing endpoint has no business existing on a
|
* outright — a credential-writing endpoint has no business existing on a
|
||||||
* shared instance, and tunnel traffic reaches the server FROM loopback, so
|
* shared instance, and tunnel traffic reaches the server FROM loopback, so
|
||||||
* the socket check below cannot carry that boundary alone;
|
* 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
|
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
|
||||||
* Host even when the socket says loopback;
|
* Host even when the socket says loopback;
|
||||||
* - exact same Origin on POST: a hostile web page can make a browser POST to
|
* - 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',
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
|
|||||||
@@ -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');
|
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
|
||||||
// A LAN peer reaching a wide-bound server.
|
// A LAN peer reaching a wide-bound server.
|
||||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
|
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.
|
// 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: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
|
||||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
|
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
|
||||||
|
|||||||
Reference in new issue
Block a user