diff --git a/README.md b/README.md index 156ee90..e1f0e63 100644 --- a/README.md +++ b/README.md @@ -125,16 +125,18 @@ jack day to day: - **Servers** — add, edit, and remove your Radarr/Sonarr connectors. It's a Nuxt **BFF** (backend-for-frontend): it serves the SPA and proxies every -call to jack's **management API**, injecting the management key so the browser -never handles it. The management API is a *separate* listener from the public -peer/Torznab port — it only starts when the backend has `MANAGEMENT_KEY` set -(see [Environment variables](#environment-variables)), and the UI talks only to -it, never to the public peer API. +call to jack's **management API**. In injected mode the BFF adds the key without +exposing it to the browser; in cookie mode the browser submits it once and the +BFF keeps it in a sealed `HttpOnly` cookie. The management API is a *separate* +listener from the public peer/Torznab port — it only starts when the backend has +`JACK_MANAGEMENT_KEY` or the legacy `MANAGEMENT_KEY` set (see +[Environment variables](#environment-variables)), and the UI talks only to it, +never to the public peer API. With the [Quick start](#quick-start-docker-compose) compose file the UI runs as the `jack-ui` service and comes up at **http://localhost:3000** (override the host port with `JACK_UI_PORT`). Don't want it? Delete the `jack-ui` service and -the backend's `MANAGEMENT_KEY` line to run jack headless. +the backend's `JACK_MANAGEMENT_KEY` line to run jack headless. ### Access control @@ -259,10 +261,12 @@ So keys flow in two directions: ### Sharing with friends (peering) -Peering is symmetric — you each run jack and exchange two things: your -**`internalUrl`** and a **peer API key**. +Peering is symmetric — you each run jack and exchange two things: a URL where +the other instance can reach your peer API and a **peer API key**. This public or +LAN-reachable peer URL is independent of `jack.internalUrl`, which is the address +your own Radarr/Sonarr use to reach jack. -- **You give a friend** your `jack.internalUrl` plus a peer API key you issue them +- **You give a friend** your reachable peer URL plus a peer API key you issue them (management UI → *API keys*). They add you under `peers` in *their* config: ```jsonc @@ -292,15 +296,14 @@ jack reads a [JSONC](https://github.com/microsoft/node-jsonc-parser) file the file doesn't exist, jack writes a default one on first boot. Copy [`examples/config.jsonc`](examples/config.jsonc) as a starting point. -Every top-level block is optional — configure only what you need for what -you're doing. +The `jack` block is required. `downloads`, `servers`, and `peers` are optional — +configure only what you need for what you're doing. ```jsonc { - // This instance's identity. Needed to expose a Torznab indexer and to be - // reachable by peers. + // This instance's identity. internalUrl is where your own *arr apps reach jack. "jack": { - "internalUrl": "http://jack:5225" // URL your *arr apps / peers reach you at + "internalUrl": "http://jack:5225" // URL your own *arr apps use to reach jack }, // Downloads. Needed to *consume* (download) from peers — jack registers @@ -348,9 +351,9 @@ you're doing. Field notes: -- **`jack.internalUrl`** must be reachable by your *arr apps (and by peers, if you're - sharing). On a shared Docker network use the container name; otherwise the host - IP/domain. +- **`jack.internalUrl`** must be reachable by your own *arr apps. On a shared + Docker network use the container name; otherwise the host IP/domain. Peers use + the separate URL configured in their `peers[].url` entry for your instance. - **`peers[].apiKey`** is the peer API key that peer issued *you* (see [API keys](#api-keys)), not your own. - **`servers[].name` / `peers[].name`** are required display names used in logs, @@ -416,13 +419,18 @@ refuses to load that config. | `LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal` | | `ENVIRONMENT` | `development` | `production` switches logs to JSON (no pretty-print) | | `APP_CONFIG_PATH` | `/config/config.jsonc` | Path to the config file | -| `MANAGEMENT_KEY` | unset | Enables the management API (the UI's backend). When set, the management API starts on `MANAGEMENT_PORT` and every request must carry `X-Management-Key: `. Unset → the management listener is never started | -| `MANAGEMENT_PORT` | `5226` | Port for the management API listener, separate from `PORT` so the peer-facing port never exposes management. Only used when `MANAGEMENT_KEY` is set | +| `JACK_MANAGEMENT_KEY` | unset | Preferred management API key. When set, the management API starts on `MANAGEMENT_PORT` and every request must carry `X-Management-Key: ` | +| `MANAGEMENT_KEY` | unset | Backwards-compatible alias for `JACK_MANAGEMENT_KEY`; ignored when the prefixed variable is also set | +| `MANAGEMENT_PORT` | `5226` | Port for the management API listener, separate from `PORT` so the peer-facing port never exposes management. Only used when a management key is set | | `HTTP_TIMEOUT_MS` | `30000` | Default timeout, in milliseconds, for outbound connector requests | | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OpenTelemetry traces and logs and sends OTLP/HTTP data to this base endpoint | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | unset | Also enables OpenTelemetry when set; useful if traces use a signal-specific endpoint | | `OTEL_SERVICE_NAME` | `jack-backend` | Service name attached to emitted telemetry | | `ENABLE_LOGS` | `true` | Set false to disable pino logging; logs are also disabled automatically when `NODE_ENV=test` | +| `LOG_TO_FILE` | `true` | Persist logs to rotating NDJSON files for the management API and UI; disabled automatically when `NODE_ENV=test` | +| `LOG_DIR` | `logs` next to `APP_CONFIG_PATH` | Directory for persisted log files | +| `LOG_MAX_FILE_BYTES` | `10485760` | Rotate the active log file after this many bytes (10 MiB by default) | +| `LOG_MAX_FILES` | `5` | Number of rotated files to retain, in addition to the active file | > Set `LOG_LEVEL=trace` to log every HTTP request — method, path, response > status, and duration — as it completes. @@ -473,7 +481,7 @@ Operational endpoints (peer-facing app): Connector, status and download views are **not** on the peer-facing app — they would leak peer/server names and URLs to peers. They live on the management API (separate port, authenticated with the management key): `GET /config/servers`, `GET /config/peers`, -`GET /status/overview`, and `GET /status/downloads`. +`GET /overview`, and `GET /downloads`. ## Running without Docker diff --git a/apps/backend/src/__tests__/envs.test.ts b/apps/backend/src/__tests__/envs.test.ts new file mode 100644 index 0000000..13a431e --- /dev/null +++ b/apps/backend/src/__tests__/envs.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { Envs } from '../lib/envs' + +describe('management key environment variables', () => { + test('accepts JACK_MANAGEMENT_KEY', () => { + const envs = Envs.parse({ JACK_MANAGEMENT_KEY: 'prefixed-key' }) + + expect(envs.MANAGEMENT_KEY).toBe('prefixed-key') + }) + + test('accepts MANAGEMENT_KEY', () => { + const envs = Envs.parse({ MANAGEMENT_KEY: 'legacy-key' }) + + expect(envs.MANAGEMENT_KEY).toBe('legacy-key') + }) + + test('omits MANAGEMENT_KEY when neither name is set', () => { + const envs = Envs.parse({}) + + expect(envs).not.toHaveProperty('MANAGEMENT_KEY') + }) + + test('ignores an invalid legacy value when JACK_MANAGEMENT_KEY is set', () => { + const envs = Envs.parse({ + JACK_MANAGEMENT_KEY: 'prefixed-key', + MANAGEMENT_KEY: '', + }) + + expect(envs.MANAGEMENT_KEY).toBe('prefixed-key') + }) + + test('prefers JACK_MANAGEMENT_KEY when both are set', () => { + const envs = Envs.parse({ + JACK_MANAGEMENT_KEY: 'prefixed-key', + MANAGEMENT_KEY: 'legacy-key', + }) + + expect(envs.MANAGEMENT_KEY).toBe('prefixed-key') + }) +}) diff --git a/apps/backend/src/lib/envs.ts b/apps/backend/src/lib/envs.ts index 0e79164..c62c61a 100644 --- a/apps/backend/src/lib/envs.ts +++ b/apps/backend/src/lib/envs.ts @@ -1,6 +1,17 @@ import { z } from 'zod' -export const Envs = z.object({ +function preferPrefixedManagementKey(input: unknown) { + if (!input || typeof input !== 'object' || Array.isArray(input)) + return input + + const vars = input as Record + if (vars.JACK_MANAGEMENT_KEY === undefined) + return vars + + return { ...vars, MANAGEMENT_KEY: vars.JACK_MANAGEMENT_KEY } +} + +export const Envs = z.preprocess(preferPrefixedManagementKey, z.object({ PORT: z.coerce.number().int().default(5225), LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), ENVIRONMENT: z.enum(['development', 'production']).default('development'), @@ -26,20 +37,19 @@ export const Envs = z.object({ LOG_MAX_FILE_BYTES: z.coerce.number().int().positive().default(10_485_760), // Keep this many rotated files (plus the active one); older ones are pruned. LOG_MAX_FILES: z.coerce.number().int().min(0).default(5), - // Management API credential. When set, the management surface starts on its OWN - // port (MANAGEMENT_PORT) and every request must carry `X-Management-Key: `. - // When unset, the management listener is not started at all. + // Management API credential. The preprocess above maps JACK_MANAGEMENT_KEY here + // when present; MANAGEMENT_KEY remains supported for backwards compatibility. MANAGEMENT_KEY: z.string().min(1).optional(), // Port for the management API listener (separate from the public PORT so the - // peer-facing port never exposes management at all). Only used when MANAGEMENT_KEY - // is set. + // peer-facing port never exposes management at all). Only used when a management + // key is set. MANAGEMENT_PORT: z.coerce.number().int().default(5226), }).transform(vars => ({ ...vars, ENABLE_LOGS: vars.NODE_ENV !== 'test' && vars.ENABLE_LOGS, // Never write log files during tests (they construct their own temp sinks). LOG_TO_FILE: vars.NODE_ENV !== 'test' && vars.LOG_TO_FILE, -})) +}))) export type Envs = z.infer diff --git a/apps/ui/README.md b/apps/ui/README.md index b098e11..8116a1d 100644 --- a/apps/ui/README.md +++ b/apps/ui/README.md @@ -1,8 +1,9 @@ # @jack/ui — management console A Nuxt 4 app that is also the **BFF** (backend-for-frontend) for jack's management -API. It serves the SPA and proxies every data call to the management API, injecting -the `X-Management-Key` so the browser never sees it. +API. It serves the SPA and proxies every data call to the management API. An +environment-injected key never reaches the browser; in cookie mode the browser +submits the key once and the BFF keeps it in a sealed `HttpOnly` cookie. It talks **only** to the management API (never the public peer API). The management API exposes richer, UI-oriented endpoints (`/overview`, `/downloads`) on top of the @@ -11,7 +12,8 @@ config CRUD (`/config/peers`, `/config/servers`). ## How it relates to the server The jack backend starts its management API on `MANAGEMENT_PORT` (default `5226`) -**only when `MANAGEMENT_KEY` is set**. Point this UI at that port. +**only when `JACK_MANAGEMENT_KEY` or the legacy `MANAGEMENT_KEY` is set**. If both +are present, the backend uses `JACK_MANAGEMENT_KEY`. Point this UI at that port. ## Auth modes @@ -25,11 +27,12 @@ Both are supported on one axis — where the key comes from: 2. **Cookie prompt.** Leave `JACK_MANAGEMENT_KEY` unset. The browser hits `/api/ping`, gets `needs-key`, and prompts. The key is validated against the management API and - stored in an `HttpOnly` + `Secure` + `SameSite=Strict` sealed cookie. CSRF is - closed by `SameSite=Strict` plus a same-origin check on the BFF. + stored in an `HttpOnly` + `SameSite=Strict` sealed cookie (`Secure` when served + over HTTPS). CSRF is closed by `SameSite=Strict` plus a same-origin check on the + BFF. `/api/ping` resolves to one of: `ok`, `needs-key`, `disabled` (management API -unreachable — server has no `MANAGEMENT_KEY`), or `error`. +unreachable or disabled), or `error`. ## Environment @@ -54,7 +57,8 @@ reverse proxy terminating TLS. mise run ui # http://localhost:3000 ``` -Run the backend with `MANAGEMENT_KEY` set first, then point the UI at it: +Run the backend with `JACK_MANAGEMENT_KEY` (preferred) or `MANAGEMENT_KEY` set +first, then point the UI at it: ```sh JACK_MANAGEMENT_API_URL=http://localhost:5226 \ @@ -66,5 +70,5 @@ mise run ui ```sh bun run --cwd apps/ui build # outputs .output/ (Nitro) -node apps/ui/.output/server/index.mjs +bun apps/ui/.output/server/index.mjs ``` diff --git a/examples/.env.example b/examples/.env.example index d960303..5f6ba89 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -1,6 +1,10 @@ # Copy to ".env" (same folder) and fill in. Used by compose-with-otel.yml. # The real .env is gitignored — never commit your actual password/token. +# Management API key shared by the backend and UI containers. +# Generate one with: openssl rand -base64 32 +JACK_MANAGEMENT_KEY=replace-with-a-long-random-string + # OpenObserve root login (also what you use to log into the UI at :5080). O2_USER=root@example.com O2_PASS=change-me-please diff --git a/examples/compose-with-otel.yml b/examples/compose-with-otel.yml index dfb4dd1..69b5db7 100644 --- a/examples/compose-with-otel.yml +++ b/examples/compose-with-otel.yml @@ -40,10 +40,10 @@ services: ports: - '${JACK_PORT:-5225}:5225' environment: - # production turns off pino-pretty and structured-logs JSON + # production disables pino-pretty and emits structured JSON logs ENVIRONMENT: production - # Request logs ("Request completed") are emitted at trace level; bump this - # to trace (or debug) if you want them showing up in OpenObserve too. + # Request logs ("Request completed") are emitted at trace level; set this + # to trace if you want them showing up in OpenObserve too. LOG_LEVEL: info PORT: 5225 APP_CONFIG_PATH: /config/config.jsonc @@ -51,7 +51,7 @@ services: # jack-ui service talks to. The UI injects this same value as its # X-Management-Key, so both read one variable and always match. To run jack # headless, drop the jack-ui service below and this line. - MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} + JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} # --- OpenTelemetry --- # Telemetry turns on as soon as an OTLP endpoint is set. The exporter # appends /v1/traces and /v1/logs to this base. "default" is the org. @@ -109,7 +109,7 @@ services: # 5226 is published — the UI is the only thing that needs it. JACK_MANAGEMENT_API_URL: http://jack:5226 # Inject mode: the BFF adds X-Management-Key on every call, so the browser is - # never prompted. Same variable as jack's MANAGEMENT_KEY above, so they match. + # never prompted. Same variable as jack's JACK_MANAGEMENT_KEY above, so they match. JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} # Only used in cookie-prompt mode (when JACK_MANAGEMENT_KEY is unset): seals # the session cookie and must be >= 32 chars. Left unset here because this diff --git a/examples/config.jsonc b/examples/config.jsonc index 5f06a94..1ea17f1 100644 --- a/examples/config.jsonc +++ b/examples/config.jsonc @@ -7,8 +7,9 @@ // Env/file forms keep secrets out of this file (recommended for deployments). // File paths must be absolute. All forms resolve to the same string. - // Identity of THIS jack instance. Required for the Torznab indexer and - // for peers to reach you. `internalUrl` must be reachable from your *arr apps. + // Identity of THIS jack instance. Required for the Torznab indexer. + // `internalUrl` must be reachable from your own *arr apps; peers use the + // separate URL they configure for this instance in their `peers` list. "jack": { "internalUrl": "http://jack:5225" // Peers authenticate with per-peer API keys (managed in the UI's "API keys" @@ -25,9 +26,9 @@ // can delete any line to keep the default. (Note the comma after // "completedPath" above is required once any field follows it.) "maxConcurrentDownloads": 3, - "maxDownloadAttempts": 5, + "maxDownloadAttempts": 13, "retryBaseDelayMs": 1000, - "retryMaxDelayMs": 60000 + "retryMaxDelayMs": 1800000 }, // Your Radarr/Sonarr servers. apiKey is the *arr API key: exactly 32 hex diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index 59bbebc..dd7181a 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - '${JACK_PORT:-5225}:5225' environment: - # production turns off pino-pretty and structured-logs JSON + # production disables pino-pretty and emits structured JSON logs ENVIRONMENT: production LOG_LEVEL: info PORT: 5225 @@ -16,7 +16,7 @@ services: # jack-ui service talks to. The UI injects this same value as its # X-Management-Key, so both read one variable and always match. To run jack # headless, drop the jack-ui service below and this line. - MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} + JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} volumes: # App config. Drop your config.jsonc in a ./config dir next to this file # (start from the config.jsonc template in this examples/ folder). @@ -65,7 +65,7 @@ services: # 5226 is published — the UI is the only thing that needs it. JACK_MANAGEMENT_API_URL: http://jack:5226 # Inject mode: the BFF adds X-Management-Key on every call, so the browser is - # never prompted. Same variable as jack's MANAGEMENT_KEY above, so they match. + # never prompted. Same variable as jack's JACK_MANAGEMENT_KEY above, so they match. JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)} # Only used in cookie-prompt mode (when JACK_MANAGEMENT_KEY is unset): seals # the session cookie and must be >= 32 chars. Left unset here because this