Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 28 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: <this>`. 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: <this>` |
| `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.
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions apps/backend/src/__tests__/envs.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
24 changes: 17 additions & 7 deletions apps/backend/src/lib/envs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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'),
Expand All @@ -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: <this>`.
// 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<typeof Envs>

Expand Down
20 changes: 12 additions & 8 deletions apps/ui/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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 \
Expand All @@ -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
```
4 changes: 4 additions & 0 deletions examples/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 5 additions & 5 deletions examples/compose-with-otel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,18 @@ 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
# Enables the management API (on MANAGEMENT_PORT, default 5226) that the
# 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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions examples/config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions examples/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down