From 63fcfb34dc5c338e9968e385b8966850559db9ff Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Thu, 25 Jun 2026 01:40:23 +0200 Subject: [PATCH 1/3] fix(vpn): make gluetun VPN work end-to-end + add NordVPN The gluetun VPN path was broken in several ways that made every provider unusable. NordVPN is added on top of the now-working path. - compose: gluetun publishes the routed service's ports (qBittorrent's WebUI was never exposed, so the installer's health gate timed out after 180s); add depends_on service_healthy + an explicit gluetun healthcheck to kill the netns race (lstat /proc//ns/net: no such file); switch gluetun health to tcp so the host gate skips its unpublished control port; drop the dead networkMode: host. - NordVPN: new provider. Users paste a NordVPN access token and the NordLynx WireGuard key is derived via NordVPN's API at install time (a value that already looks like a WG key is used as-is). Wizard field, README, and VPN guide updated with the access-tokens URL. - qBittorrent 5.x: accept a 204 login and the renamed QBT_SID_ cookie (was failing with "login rejected"). - Sonarr/Radarr download client and Caddy upstream reach qBittorrent at gluetun (its netns owner) when VPN is on, not qbittorrent. - install: write state.json before the docker steps so a failed install is resumable instead of forcing a full re-type. - Bazarr+: set general.setup_complete so the new onboarding wizard does not hijack the first visit. Verified: 262 tests pass, tsc clean, and a full clean install with NordVPN comes up healthy with qBittorrent exiting through the VPN. --- README.md | 2 +- docs/guide/06-vpn.md | 23 +++++++- src/catalog/services.yaml | 9 ++-- src/renderer/caddy.ts | 13 ++++- src/renderer/compose.ts | 69 ++++++++++++++++++++++-- src/ui/wizard/Form.tsx | 4 +- src/ui/wizard/VpnField.tsx | 22 ++++++-- src/ui/wizard/useWizardState.ts | 6 ++- src/usecase/install.ts | 42 ++++++++++++++- src/wiring/nordvpn.ts | 52 +++++++++++++++++++ src/wiring/qbittorrent.ts | 22 ++++---- src/wiring/sonarr-radarr.ts | 10 +++- templates/Caddyfile.hbs | 4 +- templates/bazarr-config.yaml.hbs | 6 +++ templates/compose.yml.hbs | 17 +++++- tests/renderer/compose.test.ts | 72 ++++++++++++++++++++++++-- tests/renderer/configs.test.ts | 5 ++ tests/ui/wizard/useWizardState.test.ts | 18 +++++++ tests/wiring/nordvpn.test.ts | 61 ++++++++++++++++++++++ tests/wiring/qbittorrent.test.ts | 37 ++++++++++++- 20 files changed, 455 insertions(+), 39 deletions(-) create mode 100644 src/wiring/nordvpn.ts create mode 100644 tests/wiring/nordvpn.test.ts diff --git a/README.md b/README.md index 1fb9ae2..6a7d7b1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The README is a tour. The full user guide lives under `docs/`. - Caddy reverse proxy with three remote-access modes: LAN (plain HTTP), DuckDNS (Let's Encrypt), Cloudflare DNS-01 wildcard Let's Encrypt - Two LAN hostname modes: install dnsmasq for LAN-wide resolution, or print a single `/etc/hosts` line - TRaSH-compliant shared `/data` mount so hardlinks work across `torrents/` and `media/` -- Optional gluetun + WireGuard VPN container in front of qBittorrent +- Optional gluetun + WireGuard VPN container in front of qBittorrent (Mullvad, Proton, NordVPN, or any custom WireGuard provider; for NordVPN you paste an access token and the WireGuard key is derived for you) - Per-service log rotation capped at 50 MB ## Quickstart diff --git a/docs/guide/06-vpn.md b/docs/guide/06-vpn.md index f14ab5f..8d5c660 100644 --- a/docs/guide/06-vpn.md +++ b/docs/guide/06-vpn.md @@ -1,6 +1,6 @@ # 06. VPN (gluetun + WireGuard) -arrstack routes **qBittorrent only** through a VPN by default. Prowlarr, Sonarr, Radarr, and the rest use your normal internet connection. This page covers enabling gluetun, pasting a WireGuard config from Mullvad or Proton (or any provider via the custom path), and understanding the kill-switch behavior so your torrent traffic never leaks. +arrstack routes **qBittorrent only** through a VPN by default. Prowlarr, Sonarr, Radarr, and the rest use your normal internet connection. This page covers enabling gluetun, pasting a WireGuard config from Mullvad, Proton, or NordVPN (or any other provider via the custom path), and understanding the kill-switch behavior so your torrent traffic never leaks. ## TL;DR @@ -53,7 +53,7 @@ On the VPN screen: | Field | Options | |----------------------|---------| | Enable gluetun | on / off | -| Provider | `mullvad`, `protonvpn`, `custom` | +| Provider | `mullvad`, `protonvpn`, `nordvpn`, `custom` | | Protocol | `wireguard` (only protocol wired end-to-end today) | | Private key | `WIREGUARD_PRIVATE_KEY` from your provider config | | Addresses | Tunnel IP/CIDR, e.g. `10.64.222.21/32` | @@ -93,6 +93,25 @@ Endpoint = 185.65.134.66:51820 ProtonVPN's free tier does not allow P2P. You need Plus or higher. Port forwarding works but requires `natpmpc` inside the container, which gluetun handles. +### NordVPN + +NordVPN uses WireGuard via its NordLynx protocol. You do not paste a `.conf` file or hunt for a private key, you paste a **NordVPN access token** and arrstack derives the WireGuard key for you. + +1. Create an access token at **https://my.nordaccount.com/dashboard/nordvpn/access-tokens/** ("Generate new token", then copy the 64-character value). The wizard prints this same link right under the token field. +2. In the wizard, pick provider `nordvpn` and paste the token into the **NordVPN token** field. +3. Leave **WG addresses** blank. gluetun fills in NordLynx's default tunnel address automatically. +4. Optionally set **Countries** (e.g. `Netherlands`), which maps to gluetun's `SERVER_COUNTRIES`. + +At install time arrstack calls NordVPN's credentials API with your token, pulls the NordLynx private key, and writes it into gluetun's config as `WIREGUARD_PRIVATE_KEY`. The **token** is what gets saved in `state.json` (so reconfigure and `--resume` keep working); the derived key only lives in the generated `docker-compose.yml`. gluetun ships a built-in NordVPN server list, so unlike the `custom` path you never provide an endpoint IP, port, or server public key. NordVPN allows P2P and gluetun picks a P2P-capable server when you torrent. + +Already extracted the NordLynx key yourself? Running + +```bash +curl -s -u token:YOUR_TOKEN https://api.nordvpn.com/v1/users/services/credentials +``` + +returns a `nordlynx_private_key`. You can paste that 44-character key into the field instead of the token and arrstack will use it as-is (it only auto-derives when the value looks like a 64-character token). + ### AirVPN and other providers (use `custom`) AirVPN, PrivateInternetAccess, and any other WireGuard provider that hands you a `.conf` file go through the `custom` path. Gluetun has a built-in server list for Mullvad and Proton only; for everything else you feed it the endpoint yourself. diff --git a/src/catalog/services.yaml b/src/catalog/services.yaml index ad1d321..159cc7b 100644 --- a/src/catalog/services.yaml +++ b/src/catalog/services.yaml @@ -309,13 +309,16 @@ services: mounts: {} envVars: {} dependsOn: [] + # The installer's host-side health gate only probes http services. gluetun's + # control server (:8000) isn't published to the host, so an http probe would + # always time out; a tcp type makes the installer skip it. Boot ordering for + # the VPN-routed qBittorrent is enforced at the docker layer instead, via the + # compose-level healthcheck + `depends_on: { gluetun: service_healthy }`. health: - type: http - path: /v1/openvpn/status + type: tcp port: 8000 default: false requiresAdminAuth: false - networkMode: host # gluetun builds its own kill-switch with iptables/nftables on boot. Without # NET_ADMIN it exits with "Could not fetch rule set generation id: Permission # denied (you must be root)" before reaching the healthcheck. /dev/net/tun diff --git a/src/renderer/caddy.ts b/src/renderer/caddy.ts index 52767ea..1bca404 100644 --- a/src/renderer/caddy.ts +++ b/src/renderer/caddy.ts @@ -13,11 +13,17 @@ export interface CaddyOptions { enabled: boolean; tld: string; }; + // When VPN is on, qBittorrent shares gluetun's netns and has no container + // name of its own, so Caddy must proxy its vhost to gluetun instead. + vpn?: { enabled: boolean }; } interface CaddyServiceEntry { id: string; port: number; + // Docker network host Caddy reverse-proxies to. Usually the same as `id`; + // becomes "gluetun" for qBittorrent when VPN routing is on. + upstream: string; } interface CaddyContext { @@ -29,9 +35,14 @@ interface CaddyContext { } export function buildCaddyContext(services: Service[], opts: CaddyOptions): CaddyContext { + const vpnEnabled = opts.vpn?.enabled ?? false; const entries: CaddyServiceEntry[] = services .filter((svc) => svc.adminPort !== undefined) - .map((svc) => ({ id: svc.id, port: svc.adminPort as number })); + .map((svc) => ({ + id: svc.id, + port: svc.adminPort as number, + upstream: vpnEnabled && svc.id === "qbittorrent" ? "gluetun" : svc.id, + })); return { mode: opts.mode, diff --git a/src/renderer/compose.ts b/src/renderer/compose.ts index d726e9d..06efd8c 100644 --- a/src/renderer/compose.ts +++ b/src/renderer/compose.ts @@ -48,6 +48,22 @@ interface PortBinding { binding: string; // e.g. "127.0.0.1:8989:8989" or "0.0.0.0:443:443" } +// A depends_on edge. `condition` upgrades it to compose long-form +// (`gluetun: { condition: service_healthy }`); without it the template emits +// the short-form list entry (`- gluetun`). +interface DependsOnEntry { + service: string; + condition?: string; +} + +interface Healthcheck { + test: string; + interval: string; + timeout: string; + retries: number; + start_period: string; +} + interface ServiceContext { id: string; image: string; @@ -62,7 +78,12 @@ interface ServiceContext { devices: string[]; capAdd: string[]; groupAdd: string[]; - dependsOn: string[]; + dependsOn: DependsOnEntry[]; + // When any dependsOn entry carries a condition, the whole block must be + // rendered in compose long-form (a map of service -> {condition}); the two + // forms can't be mixed on one service. + dependsOnLongForm: boolean; + healthcheck?: Healthcheck; vpnNetwork: boolean; } @@ -168,6 +189,19 @@ function isVpnRouted(svc: Service, vpn: ComposeOptions["vpn"]): boolean { return svc.id === "qbittorrent" && vpn.enabled; } +// gluetun ships a built-in HEALTHCHECK, but we render one explicitly so that +// `depends_on: { gluetun: { condition: service_healthy } }` keeps working even +// if a future image drops it. `/gluetun-entrypoint healthcheck` pings the +// tunnel; start_period covers the WireGuard handshake + firewall setup before +// the first probe counts against retries. +const GLUETUN_HEALTHCHECK: Healthcheck = { + test: '["CMD", "/gluetun-entrypoint", "healthcheck"]', + interval: "10s", + timeout: "10s", + retries: 6, + start_period: "30s", +}; + // Translates the wizard's VPN state into the env vars gluetun expects // (VPN_SERVICE_PROVIDER / VPN_TYPE / WIREGUARD_* / SERVER_COUNTRIES / custom // endpoint tuple). Only emitted for the gluetun service and only when VPN @@ -193,6 +227,14 @@ function buildGluetunEnv(vpn: ComposeOptions["vpn"]): EnvEntry[] { } export function buildComposeContext(services: Service[], opts: ComposeOptions): ComposeContext { + // Every VPN-routed service runs inside gluetun's netns and gets `ports: []`, + // so its WebUI port has to be published by gluetun instead. Collect those + // ports once and hand them to the gluetun service below. Generic, so any + // future routed service (not just qBittorrent) is exposed automatically. + const vpnRoutedPorts: number[] = opts.vpn.enabled + ? services.filter((svc) => isVpnRouted(svc, opts.vpn)).flatMap((svc) => svc.ports) + : []; + const serviceContexts: ServiceContext[] = services.map((svc) => { const vpnNetwork = isVpnRouted(svc, opts.vpn); const apiKeyEnv = svc.apiKeyEnv; @@ -206,9 +248,24 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): extraEnv.push(...buildGluetunEnv(opts.vpn)); } - const ports: PortBinding[] = vpnNetwork - ? [] - : svc.ports.map((p) => ({ binding: `0.0.0.0:${p}:${p}` })); + // gluetun publishes its own declared ports plus every VPN-routed service's + // ports. VPN-routed services themselves publish nothing (they share + // gluetun's network). Everything else binds its declared ports directly. + const ownPorts = + svc.id === "gluetun" ? [...svc.ports, ...vpnRoutedPorts] : vpnNetwork ? [] : svc.ports; + const ports: PortBinding[] = ownPorts.map((p) => ({ binding: `0.0.0.0:${p}:${p}` })); + + // VPN-routed services must wait for gluetun to be *healthy* (tunnel up) + // before they attach to its netns, otherwise a gluetun restart mid-boot + // leaves a stale namespace path and `docker compose up` aborts with the + // cryptic `lstat /proc//ns/net: no such file or directory`. + const dependsOn: DependsOnEntry[] = svc.dependsOn.map((service) => ({ service })); + if (vpnNetwork) { + dependsOn.push({ service: "gluetun", condition: "service_healthy" }); + } + const dependsOnLongForm = dependsOn.some((d) => d.condition !== undefined); + + const healthcheck = svc.id === "gluetun" ? GLUETUN_HEALTHCHECK : undefined; const caddyImage = resolveCaddyImage(svc, opts.remoteMode); @@ -239,7 +296,9 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): devices: buildDevices(svc, opts.gpu), capAdd: svc.capAdd, groupAdd: buildGroupAdd(svc, opts.gpu), - dependsOn: svc.dependsOn, + dependsOn, + dependsOnLongForm, + healthcheck, vpnNetwork, }; }); diff --git a/src/ui/wizard/Form.tsx b/src/ui/wizard/Form.tsx index 42ee7ec..d93a01d 100644 --- a/src/ui/wizard/Form.tsx +++ b/src/ui/wizard/Form.tsx @@ -195,7 +195,7 @@ export function Form({ initial, isReconfigure, onSubmit, onCancel }: FormProps) return; } if (activeSectionIndex === SEC_VPN && activeFieldIndex === 1 && ws.vpnMode === "gluetun") { - const providers = ["mullvad", "protonvpn", "custom"] as const; + const providers = ["mullvad", "protonvpn", "nordvpn", "custom"] as const; const idx = providers.indexOf(ws.vpnProvider as (typeof providers)[number]); const next = isForward ? (idx + 1) % providers.length @@ -280,7 +280,7 @@ export function Form({ initial, isReconfigure, onSubmit, onCancel }: FormProps) } // VPN provider radio: cycle to next (only visible when mode=gluetun) if (activeSectionIndex === SEC_VPN && activeFieldIndex === 1 && ws.vpnMode === "gluetun") { - const providers = ["mullvad", "protonvpn", "custom"] as const; + const providers = ["mullvad", "protonvpn", "nordvpn", "custom"] as const; const idx = providers.indexOf(ws.vpnProvider as (typeof providers)[number]); ws.setVpnProvider(providers[(idx + 1) % providers.length]); return; diff --git a/src/ui/wizard/VpnField.tsx b/src/ui/wizard/VpnField.tsx index a81ad9e..17b9999 100644 --- a/src/ui/wizard/VpnField.tsx +++ b/src/ui/wizard/VpnField.tsx @@ -7,7 +7,7 @@ import { Radio, RadioOption } from "../shared/Radio.js"; import { colors, LABEL_WIDTH } from "../shared/theme.js"; export type VpnMode = "none" | "gluetun"; -export type VpnProvider = "mullvad" | "protonvpn" | "custom"; +export type VpnProvider = "mullvad" | "protonvpn" | "nordvpn" | "custom"; interface VpnFieldProps { mode: VpnMode; @@ -42,6 +42,7 @@ const MODE_OPTIONS: RadioOption[] = [ const PROVIDER_OPTIONS: RadioOption[] = [ { value: "mullvad", label: "mullvad" }, { value: "protonvpn", label: "protonvpn" }, + { value: "nordvpn", label: "nordvpn" }, { value: "custom", label: "custom" }, ]; @@ -65,6 +66,7 @@ export function VpnField({ }: VpnFieldProps) { const enabled = mode === "gluetun"; const isCustom = provider === "custom"; + const isNord = provider === "nordvpn"; return ( @@ -97,17 +99,29 @@ export function VpnField({ + {isNord && ( + + {"".padEnd(LABEL_WIDTH)} + + create one: https://my.nordaccount.com/dashboard/nordvpn/access-tokens/ + + + )} | null) { }); const [vpnProvider, setVpnProvider] = useState(() => { const p = existingState?.vpn?.provider; - return p === "mullvad" || p === "protonvpn" || p === "custom" ? p : "mullvad"; + return p === "mullvad" || p === "protonvpn" || p === "nordvpn" || p === "custom" + ? p + : "mullvad"; }); const [vpnPrivateKey, setVpnPrivateKey] = useState(existingState?.vpn?.private_key ?? ""); const [vpnAddresses, setVpnAddresses] = useState(existingState?.vpn?.addresses ?? ""); diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 4dbd68d..0e25229 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -34,6 +34,7 @@ import { seedArrAdmin } from "../wiring/arr-auth.js"; import { configureTrailarr } from "../wiring/trailarr.js"; import { configureArr } from "../wiring/sonarr-radarr.js"; import { configureQbit } from "../wiring/qbittorrent.js"; +import { deriveNordVpnPrivateKey, isNordVpnToken } from "../wiring/nordvpn.js"; import { setupJellyfin } from "../wiring/jellyfin.js"; import { linkJellyseerr } from "../wiring/jellyseerr.js"; import { configureBazarrLanguages } from "../wiring/bazarr.js"; @@ -163,6 +164,8 @@ export async function runInstall( // Mutable install-time state const apiKeys: Record = { ...state.api_keys }; + // Captured once so the pre-docker snapshot and the final state agree on it. + const installStartedAt = new Date().toISOString(); let bcryptPassword = ""; let pbkdf2Hash = ""; let bazarrHash = ""; @@ -219,6 +222,24 @@ export async function runInstall( writeFileSync(join(installDir, ".env"), content, { mode: 0o600 }); }); + // Step 4b: NordVPN token -> WireGuard key. The wizard lets users paste their + // NordVPN access token (easier and safer than extracting the key by hand); + // gluetun needs the derived NordLynx private key, so resolve it before + // rendering compose. A value that already looks like a WG key is passed + // through unchanged, so power users who extracted it themselves still work. + let effectiveVpn = state.vpn; + if ( + state.vpn.enabled && + state.vpn.provider === "nordvpn" && + state.vpn.private_key && + isNordVpnToken(state.vpn.private_key) + ) { + await runStep("Derive NordVPN WireGuard key", onStep, log, async () => { + const key = await deriveNordVpnPrivateKey(state.vpn.private_key!); + effectiveVpn = { ...state.vpn, private_key: key }; + }); + } + // Step 5: Render docker-compose.yml await runStep("Render docker-compose.yml", onStep, log, async () => { const content = renderCompose(services, { @@ -230,7 +251,7 @@ export async function runInstall( timezone: state.timezone, apiKeys, gpu: state.gpu, - vpn: state.vpn, + vpn: effectiveVpn, remoteMode: state.remote_access.mode, }); writeFileSync(join(installDir, "docker-compose.yml"), content); @@ -242,6 +263,7 @@ export async function runInstall( mode: state.remote_access.mode === "none" ? "none" : state.remote_access.mode, domain: state.remote_access.domain, localDns: state.local_dns, + vpn: { enabled: effectiveVpn.enabled }, }); writeFileSync(join(installDir, "Caddyfile"), content); }); @@ -352,6 +374,21 @@ export async function runInstall( hostIp = await getHostIp(); } + // Step 8b: Persist a resumable state snapshot BEFORE any docker work. The + // docker steps below (image pull/build, compose up, health gate) are where + // installs most often fail (bad VPN creds, image pull errors, a service that + // never goes healthy). Writing state.json here means a failed run leaves a + // complete, resumable file, so `arrstack install` repopulates the wizard from + // it instead of making the user retype every answer. All API keys are + // generated by this point. The final write at the end stamps completion. + await runStep("Write state snapshot", onStep, log, async () => { + writeState(installDir, { + ...state, + api_keys: apiKeys, + install_started_at: installStartedAt, + }); + }); + // Step 9a: Prepare custom Caddy image (only when remote mode needs DNS // plugins). Try the prebuilt ghcr image first, fall back to local xcaddy // build when the pull fails or returns unauthorized. @@ -534,6 +571,7 @@ export async function runInstall( qbitUser: state.admin.username, qbitPass: adminPassword, category: "tv", + qbitHost: effectiveVpn.enabled ? "gluetun" : "qbittorrent", }); }); } @@ -547,6 +585,7 @@ export async function runInstall( qbitUser: state.admin.username, qbitPass: adminPassword, category: "movies", + qbitHost: effectiveVpn.enabled ? "gluetun" : "qbittorrent", }); }); } @@ -613,6 +652,7 @@ export async function runInstall( const finalState: State = { ...state, api_keys: apiKeys, + install_started_at: installStartedAt, install_completed_at: new Date().toISOString(), last_updated_at: new Date().toISOString(), }; diff --git a/src/wiring/nordvpn.ts b/src/wiring/nordvpn.ts new file mode 100644 index 0000000..329b906 --- /dev/null +++ b/src/wiring/nordvpn.ts @@ -0,0 +1,52 @@ +import { withRetry } from "../lib/retry.js"; + +// Where users create a NordVPN access token. Surfaced in the wizard hint and the +// VPN docs so people don't have to hunt for it. +export const NORDVPN_TOKEN_URL = + "https://my.nordaccount.com/dashboard/nordvpn/access-tokens/"; + +const NORD_CREDENTIALS_URL = "https://api.nordvpn.com/v1/users/services/credentials"; + +// NordVPN access tokens are 64 hex characters. A WireGuard (NordLynx) private +// key is 44-character base64 ending in '='. We use this to decide whether the +// value the user pasted is a token to derive from, or an already-extracted WG +// key to pass through unchanged (power users who ran the curl themselves). +export function isNordVpnToken(value: string): boolean { + return /^[0-9a-f]{64}$/i.test(value.trim()); +} + +// NordVPN does not expose the WireGuard private key in its dashboard; it is only +// returned by the authenticated credentials endpoint. gluetun needs that key as +// WIREGUARD_PRIVATE_KEY. Users paste their access token (created at +// NORDVPN_TOKEN_URL) and the installer turns it into the WG key, so they never +// have to run curl by hand. HTTP Basic auth is `token:` per NordVPN's API. +export async function deriveNordVpnPrivateKey( + token: string, + base = NORD_CREDENTIALS_URL, +): Promise { + const auth = "Basic " + btoa(`token:${token.trim()}`); + const res = await withRetry(() => + fetch(base, { + headers: { Authorization: auth }, + signal: AbortSignal.timeout(15000), + }), + ); + if (res.status === 401 || res.status === 403) { + throw new Error( + `NordVPN rejected the access token (HTTP ${res.status}). Create a fresh ` + + `token at ${NORDVPN_TOKEN_URL} and paste it again.`, + ); + } + if (!res.ok) { + throw new Error(`NordVPN credentials request failed: HTTP ${res.status}`); + } + const data = (await res.json()) as { nordlynx_private_key?: string }; + const key = data.nordlynx_private_key?.trim(); + if (!key) { + throw new Error( + "NordVPN credentials response did not include a nordlynx_private_key. " + + "Make sure the token belongs to an active NordVPN subscription.", + ); + } + return key; +} diff --git a/src/wiring/qbittorrent.ts b/src/wiring/qbittorrent.ts index d9442bc..8e2adc9 100644 --- a/src/wiring/qbittorrent.ts +++ b/src/wiring/qbittorrent.ts @@ -33,19 +33,23 @@ export async function configureQbit( throw new Error(`qBittorrent login failed: ${loginRes.status} ${body}${hint}`); } - const body = await loginRes.text(); - if (body.trim() !== "Ok.") { - throw new Error(`qBittorrent login rejected: ${body.trim()}`); + // qBittorrent 5.x returns 204 No Content (empty body) on a successful login; + // older builds returned 200 with the body "Ok.". Wrong credentials still come + // back with loginRes.ok true and the body "Fails.", so reject on that rather + // than requiring an exact "Ok." (which 5.x never sends). + const body = (await loginRes.text()).trim(); + if (body === "Fails.") { + throw new Error("qBittorrent login failed: incorrect username or password"); } - // 2. Extract SID from Set-Cookie header + // 2. Reuse the session cookie verbatim. qBittorrent 5.x renamed it from `SID` + // to `QBT_SID_` (e.g. QBT_SID_8080), so take the first name=value pair + // from Set-Cookie instead of matching a hard-coded cookie name. const setCookie = loginRes.headers.get("set-cookie") ?? ""; - const sidMatch = setCookie.match(/SID=([^;]+)/); - if (!sidMatch) { - throw new Error("qBittorrent login did not return a SID cookie"); + const cookieHeader = setCookie.split(";")[0]?.trim() ?? ""; + if (!/sid/i.test(cookieHeader)) { + throw new Error("qBittorrent login did not return a session cookie"); } - const sid = sidMatch[1]; - const cookieHeader = `SID=${sid}`; // 3. Create categories for (const cat of CATEGORIES) { diff --git a/src/wiring/sonarr-radarr.ts b/src/wiring/sonarr-radarr.ts index 67d51b3..98ca134 100644 --- a/src/wiring/sonarr-radarr.ts +++ b/src/wiring/sonarr-radarr.ts @@ -9,8 +9,14 @@ export async function configureArr( qbitUser: string; qbitPass: string; category: string; + // Docker network host Sonarr/Radarr use to reach qBittorrent. Normally the + // "qbittorrent" container, but when qBittorrent is routed through the VPN it + // shares gluetun's network namespace and has no name of its own, so it is + // reachable as "gluetun". Defaults to "qbittorrent" for the non-VPN case. + qbitHost?: string; } ): Promise { + const qbitHost = opts.qbitHost ?? "qbittorrent"; const port = service === "sonarr" ? 8989 : 7878; const base = `http://localhost:${port}`; const headers = { "X-Api-Key": apiKey, "Content-Type": "application/json" }; @@ -55,7 +61,7 @@ export async function configureArr( (c) => c.implementation === "QBittorrent" && c.fields.some( - (f) => f.name === "host" && f.value === "qbittorrent" + (f) => f.name === "host" && f.value === qbitHost ) ); @@ -68,7 +74,7 @@ export async function configureArr( implementation: "QBittorrent", configContract: "QBittorrentSettings", fields: [ - { name: "host", value: "qbittorrent" }, + { name: "host", value: qbitHost }, { name: "port", value: 8080 }, { name: "useSsl", value: false }, { name: "urlBase", value: "" }, diff --git a/templates/Caddyfile.hbs b/templates/Caddyfile.hbs index bb09cf7..7bac325 100644 --- a/templates/Caddyfile.hbs +++ b/templates/Caddyfile.hbs @@ -26,7 +26,7 @@ http://{{id}}.{{../localDnsTld}} { {{#each services}} @{{id}} host {{id}}.{{../domain}} handle @{{id}} { - reverse_proxy {{id}}:{{port}} + reverse_proxy {{upstream}}:{{port}} } {{/each}} # Unknown subdomain: don't leak a random service, just 404. @@ -48,7 +48,7 @@ http://{{id}}.{{../localDnsTld}} { {{#each services}} @{{id}} host {{id}}.{{../domain}} handle @{{id}} { - reverse_proxy {{id}}:{{port}} + reverse_proxy {{upstream}}:{{port}} } {{/each}} # Unknown subdomain: don't leak a random service, just 404. diff --git a/templates/bazarr-config.yaml.hbs b/templates/bazarr-config.yaml.hbs index 8e4d96d..6e1a855 100644 --- a/templates/bazarr-config.yaml.hbs +++ b/templates/bazarr-config.yaml.hbs @@ -6,6 +6,12 @@ auth: password: {{passwordHash}} general: auto_update: false + # The installer pre-wires Sonarr/Radarr, providers, and a language profile via + # the API, so the instance is already configured. Mark Bazarr+'s first-run + # onboarding wizard complete (general.setup_complete in bazarr/app/config.py) + # so a fresh visit lands on the dashboard instead of being redirected to /setup. + setup_complete: true + instance_name: 'Bazarr+' base_url: '' branch: master concurrent_jobs: 4 diff --git a/templates/compose.yml.hbs b/templates/compose.yml.hbs index cd81ec3..35f78e5 100644 --- a/templates/compose.yml.hbs +++ b/templates/compose.yml.hbs @@ -18,6 +18,14 @@ services: options: max-size: "50m" max-file: "3" +{{#if healthcheck}} + healthcheck: + test: {{healthcheck.test}} + interval: {{healthcheck.interval}} + timeout: {{healthcheck.timeout}} + retries: {{healthcheck.retries}} + start_period: {{healthcheck.start_period}} +{{/if}} {{#if vpnNetwork}} network_mode: "service:gluetun" {{else}} @@ -65,8 +73,15 @@ services: {{/if}} {{#if dependsOn}} depends_on: +{{#if dependsOnLongForm}} {{#each dependsOn}} - - {{this}} + {{service}}: + condition: {{#if condition}}{{condition}}{{else}}service_started{{/if}} +{{/each}} +{{else}} +{{#each dependsOn}} + - {{service}} {{/each}} {{/if}} +{{/if}} {{/each}} diff --git a/tests/renderer/compose.test.ts b/tests/renderer/compose.test.ts index 376c6e6..130fccb 100644 --- a/tests/renderer/compose.test.ts +++ b/tests/renderer/compose.test.ts @@ -47,12 +47,78 @@ describe("renderCompose", () => { expect(output).toContain("driver: bridge"); }); - test("qbittorrent with VPN uses service network mode and no ports", () => { + test("qbittorrent with VPN routes through gluetun and publishes no ports of its own", () => { const services = getServices(["gluetun", "qbittorrent"]); const opts = { ...baseOpts, vpn: { enabled: true, provider: "mullvad" } }; + const ctx = buildComposeContext(services, opts); + const qbit = ctx.services.find((s) => s.id === "qbittorrent")!; + expect(qbit.vpnNetwork).toBe(true); + expect(qbit.ports).toEqual([]); + expect(renderCompose(services, opts)).toContain('network_mode: "service:gluetun"'); + }); + + test("VPN: gluetun publishes the routed qBittorrent WebUI port so host/LAN can reach it", () => { + // qBittorrent has no IP of its own inside gluetun's netns; the 8080 WebUI + // port must be published BY gluetun or the WebUI (and the installer's health + // gate on localhost:8080) is unreachable. This was the "did not become + // healthy within 180s" bug. + const services = getServices(["gluetun", "qbittorrent"]); + const opts = { ...baseOpts, vpn: { enabled: true, provider: "mullvad" } }; + const ctx = buildComposeContext(services, opts); + const glue = ctx.services.find((s) => s.id === "gluetun")!; + expect(glue.ports).toContainEqual({ binding: "0.0.0.0:8080:8080" }); + expect(renderCompose(services, opts)).toContain("0.0.0.0:8080:8080"); + }); + + test("VPN: qBittorrent depends on a HEALTHY gluetun (kills the netns race)", () => { + const services = getServices(["gluetun", "qbittorrent"]); + const opts = { ...baseOpts, vpn: { enabled: true, provider: "mullvad" } }; + const ctx = buildComposeContext(services, opts); + const qbit = ctx.services.find((s) => s.id === "qbittorrent")!; + expect(qbit.dependsOn).toContainEqual({ + service: "gluetun", + condition: "service_healthy", + }); + expect(renderCompose(services, opts)).toContain("condition: service_healthy"); + }); + + test("VPN: gluetun gets a compose-level healthcheck so service_healthy can resolve", () => { + const services = getServices(["gluetun", "qbittorrent"]); + const opts = { ...baseOpts, vpn: { enabled: true, provider: "mullvad" } }; + const ctx = buildComposeContext(services, opts); + const glue = ctx.services.find((s) => s.id === "gluetun")!; + expect(glue.healthcheck).toBeDefined(); const output = renderCompose(services, opts); - expect(output).toContain('network_mode: "service:gluetun"'); - expect(output).not.toContain("8080:8080"); + expect(output).toContain("healthcheck:"); + expect(output).toContain("/gluetun-entrypoint"); + expect(output).toContain("start_period:"); + }); + + test("VPN off: qBittorrent publishes its own 8080 and has no gluetun dependency", () => { + const services = getServices(["qbittorrent"]); + const output = renderCompose(services, baseOpts); + expect(output).toContain("0.0.0.0:8080:8080"); + expect(output).not.toContain("service:gluetun"); + }); + + test("gluetun nordvpn provider emits provider env, no custom endpoint tuple", () => { + const services = getServices(["gluetun"]); + const output = renderCompose(services, { + ...baseOpts, + vpn: { + enabled: true, + provider: "nordvpn", + type: "wireguard", + private_key: "NORDKEY==", + countries: "Netherlands", + }, + }); + expect(output).toContain("VPN_SERVICE_PROVIDER=nordvpn"); + expect(output).toContain("VPN_TYPE=wireguard"); + expect(output).toContain("WIREGUARD_PRIVATE_KEY=NORDKEY=="); + expect(output).toContain("SERVER_COUNTRIES=Netherlands"); + expect(output).not.toContain("VPN_ENDPOINT_IP"); + expect(output).not.toContain("WIREGUARD_PUBLIC_KEY"); }); test("PUID and PGID are in environment", () => { diff --git a/tests/renderer/configs.test.ts b/tests/renderer/configs.test.ts index d8e1c53..8d38c56 100644 --- a/tests/renderer/configs.test.ts +++ b/tests/renderer/configs.test.ts @@ -71,6 +71,11 @@ describe("bazarr config.yaml", () => { const output = renderBazarrConfig(opts); expect(output).toContain("openrouter_encryption_key: 'abcdef0123456789'"); }); + + test("marks Bazarr+ onboarding complete so first visit lands on the dashboard, not /setup", () => { + const output = renderBazarrConfig(opts); + expect(output).toContain("setup_complete: true"); + }); }); describe("qbittorrent.conf", () => { diff --git a/tests/ui/wizard/useWizardState.test.ts b/tests/ui/wizard/useWizardState.test.ts index 844a9f9..b1385ed 100644 --- a/tests/ui/wizard/useWizardState.test.ts +++ b/tests/ui/wizard/useWizardState.test.ts @@ -223,6 +223,24 @@ describe("buildStateFromWizard", () => { expect(state.vpn.addresses).toBe("10.64.222.21/32"); }); + test("nordvpn provider round-trips through state (built-in provider, no endpoint tuple)", () => { + const state = buildStateFromWizard( + makeWizardState({ + vpnMode: "gluetun", + vpnProvider: "nordvpn", + vpnPrivateKey: "NORDKEY==", + vpnCountries: "Netherlands", + }) + ); + expect(state.vpn.enabled).toBe(true); + expect(state.vpn.provider).toBe("nordvpn"); + expect(state.vpn.type).toBe("wireguard"); + expect(state.vpn.private_key).toBe("NORDKEY=="); + expect(state.vpn.countries).toBe("Netherlands"); + expect(state.vpn.endpoint_ip).toBeUndefined(); + expect(state.vpn.server_public_key).toBeUndefined(); + }); + test("custom provider carries endpoint tuple and server pubkey", () => { const state = buildStateFromWizard( makeWizardState({ diff --git a/tests/wiring/nordvpn.test.ts b/tests/wiring/nordvpn.test.ts new file mode 100644 index 0000000..7b4cd43 --- /dev/null +++ b/tests/wiring/nordvpn.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { isNordVpnToken, deriveNordVpnPrivateKey } from "../../src/wiring/nordvpn"; + +describe("isNordVpnToken", () => { + test("true for a 64-char hex access token", () => { + expect( + isNordVpnToken("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + ).toBe(true); + }); + + test("trims surrounding whitespace before matching", () => { + expect( + isNordVpnToken(" 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef "), + ).toBe(true); + }); + + test("false for a base64 WireGuard key (so power users can paste it directly)", () => { + expect(isNordVpnToken("Tf6pAbcd1234EFGHijklMNOPqrstUVWXyz0123456JYo=")).toBe(false); + }); + + test("false for empty / short / non-hex values", () => { + expect(isNordVpnToken("")).toBe(false); + expect(isNordVpnToken("deadbeef")).toBe(false); + expect(isNordVpnToken("z".repeat(64))).toBe(false); + }); +}); + +describe("deriveNordVpnPrivateKey", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + test("returns nordlynx_private_key from a 200 response", async () => { + let sentAuth = ""; + globalThis.fetch = (async (_url: any, init: any) => { + sentAuth = init?.headers?.Authorization ?? ""; + return new Response(JSON.stringify({ nordlynx_private_key: "DERIVEDKEY==" }), { + status: 200, + }); + }) as any; + const key = await deriveNordVpnPrivateKey("mytoken", "http://nord.test/creds"); + expect(key).toBe("DERIVEDKEY=="); + // Basic auth is token: + expect(sentAuth).toBe("Basic " + btoa("token:mytoken")); + }); + + test("throws a token-specific, link-bearing error on 401", async () => { + globalThis.fetch = (async () => new Response("", { status: 401 })) as any; + await expect(deriveNordVpnPrivateKey("bad", "http://nord.test/creds")).rejects.toThrow( + /rejected the access token/, + ); + }); + + test("throws when the response has no nordlynx_private_key", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({}), { status: 200 })) as any; + await expect(deriveNordVpnPrivateKey("tok", "http://nord.test/creds")).rejects.toThrow( + /nordlynx_private_key/, + ); + }); +}); diff --git a/tests/wiring/qbittorrent.test.ts b/tests/wiring/qbittorrent.test.ts index f0c47c2..4a1e7b5 100644 --- a/tests/wiring/qbittorrent.test.ts +++ b/tests/wiring/qbittorrent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, afterEach } from "bun:test"; import { configureQbit } from "../../src/wiring/qbittorrent"; // NEVER call configureQbit() without an explicit base URL in these tests. @@ -33,3 +33,38 @@ describe("configureQbit", () => { await expect(configureQbit("baduser", "badpass", UNREACHABLE)).rejects.toThrow(); }); }); + +// These mock fetch entirely, so they never touch the network (no ban risk) and +// lock in qBittorrent 5.x's login contract: 204 No Content + a QBT_SID_ +// cookie on success, 200 "Fails." on bad credentials. +describe("configureQbit login handling (qBittorrent 5.x)", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + test("accepts a 204 login with a QBT_SID_ cookie and wires categories/prefs", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url: any) => { + const u = String(url); + calls.push(u); + if (u.endsWith("/auth/login")) { + return new Response("", { + status: 204, + headers: { "set-cookie": "QBT_SID_8080=sessioncookie; HttpOnly; SameSite=Lax; path=/" }, + }); + } + return new Response("Ok.", { status: 200 }); + }) as any; + await expect(configureQbit("admin", "pw", "http://qbit.test")).resolves.toBeUndefined(); + expect(calls.some((c) => c.includes("createCategory"))).toBe(true); + expect(calls.some((c) => c.includes("setPreferences"))).toBe(true); + }); + + test("rejects on bad credentials (200 'Fails.')", async () => { + globalThis.fetch = (async () => new Response("Fails.", { status: 200 })) as any; + await expect(configureQbit("admin", "wrong", "http://qbit.test")).rejects.toThrow( + /incorrect username or password/, + ); + }); +}); From cbd591a57fd35cd1d46f05e15e6b95a5de803546 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Thu, 25 Jun 2026 09:51:16 +0200 Subject: [PATCH 2/3] fix(vpn): address Codex review + document qBittorrent-only routing - caddy: the local-DNS vhost now uses the VPN upstream too, so qbittorrent. proxies to gluetun instead of the unreachable qbittorrent:8080 (an incomplete find/replace had missed this stanza). - update: `arrstack update` re-derives the NordVPN WireGuard key from the stored access token and passes vpn to the compose + Caddy renderers, so an update no longer writes the token as WIREGUARD_PRIVATE_KEY or reverts the qBittorrent upstream to a broken host. Shared resolveVpnWireguardKey() is now used by both install and update. - sonarr/radarr: when the VPN host changes (qbittorrent <-> gluetun), update the existing qBittorrent download client in place via PUT instead of POSTing a duplicate the arr APIs reject. - docs: 06-vpn.md states plainly that only qBittorrent is VPN-routed and adds a "verifying the split" section with per-service egress checks. 270 tests pass, tsc clean. --- docs/guide/06-vpn.md | 35 ++++++++++ src/usecase/install.ts | 5 +- src/usecase/update.ts | 18 +++-- src/wiring/nordvpn.ts | 19 ++++++ src/wiring/sonarr-radarr.ts | 103 ++++++++++++++++++----------- templates/Caddyfile.hbs | 2 +- tests/renderer/caddy.test.ts | 35 ++++++++++ tests/wiring/nordvpn.test.ts | 43 +++++++++++- tests/wiring/sonarr-radarr.test.ts | 80 ++++++++++++++++++++++ 9 files changed, 294 insertions(+), 46 deletions(-) diff --git a/docs/guide/06-vpn.md b/docs/guide/06-vpn.md index 8d5c660..65b40d4 100644 --- a/docs/guide/06-vpn.md +++ b/docs/guide/06-vpn.md @@ -18,6 +18,14 @@ docker exec qbittorrent curl -s ifconfig.me ## What routes where +> **Only qBittorrent is routed through the VPN.** Every other service (Sonarr, +> Radarr, Prowlarr, Bazarr+, Jellyfin, Jellyseerr, FlareSolverr, Recyclarr, +> Trailarr, and the rest) uses your host's **normal internet connection**, not +> NordVPN. This is intentional: the arr apps and media server work better (and in +> some cases only work) on your real connection, and the torrent client is the +> only thing that needs an anonymizing exit. To confirm it on your own box, see +> [Verifying routing](#verifying-the-split-which-service-uses-which-network) below. + | Service | Network | Outbound IP | |---------------|----------------------|-------------| | qBittorrent | `network_mode: service:gluetun` | VPN exit | @@ -30,6 +38,33 @@ docker exec qbittorrent curl -s ifconfig.me qBittorrent has no IP of its own, it uses gluetun's network namespace. If gluetun is down, qBittorrent has no network at all. That is the kill switch. +## Verifying the split (which service uses which network) + +You can prove exactly where each service exits. qBittorrent should report your VPN +exit IP; every other service should report your normal (ISP) IP. + +```bash +# qBittorrent -> should be your NordVPN exit IP +docker exec qbittorrent curl -s https://ifconfig.me; echo + +# Sonarr (or any other arr/media service) -> should be your normal/ISP IP +docker exec sonarr curl -s https://ifconfig.me; echo + +# Your host's own public IP, for comparison with Sonarr's +curl -s https://ifconfig.me; echo +``` + +If qBittorrent's IP differs from the other two (a NordVPN address) while Sonarr +matches your host, the split is working as designed. If qBittorrent's IP equals +your ISP IP, the tunnel is not up, check `arrstack logs gluetun`. + +This is structural, not luck: only qBittorrent is rendered with +`network_mode: service:gluetun`, so its *only* possible route is gluetun's tunnel +(that is also the kill switch). Every other service sits on the `arrstack` bridge +and egresses through the host, so it cannot use the VPN even if the tunnel is up. +To route something else through the VPN you would have to add it to gluetun's +network namespace too; arrstack does not do this by default. + ## Kill-switch behavior gluetun sets strict firewall rules: the only egress allowed is through the WireGuard tunnel. If the tunnel drops, packets are rejected. qBittorrent, living inside the same netns, cannot talk to anything. diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 0e25229..046b72a 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -34,7 +34,7 @@ import { seedArrAdmin } from "../wiring/arr-auth.js"; import { configureTrailarr } from "../wiring/trailarr.js"; import { configureArr } from "../wiring/sonarr-radarr.js"; import { configureQbit } from "../wiring/qbittorrent.js"; -import { deriveNordVpnPrivateKey, isNordVpnToken } from "../wiring/nordvpn.js"; +import { isNordVpnToken, resolveVpnWireguardKey } from "../wiring/nordvpn.js"; import { setupJellyfin } from "../wiring/jellyfin.js"; import { linkJellyseerr } from "../wiring/jellyseerr.js"; import { configureBazarrLanguages } from "../wiring/bazarr.js"; @@ -235,8 +235,7 @@ export async function runInstall( isNordVpnToken(state.vpn.private_key) ) { await runStep("Derive NordVPN WireGuard key", onStep, log, async () => { - const key = await deriveNordVpnPrivateKey(state.vpn.private_key!); - effectiveVpn = { ...state.vpn, private_key: key }; + effectiveVpn = await resolveVpnWireguardKey(state.vpn); }); } diff --git a/src/usecase/update.ts b/src/usecase/update.ts index 94e3f08..7205a77 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -7,6 +7,7 @@ import type { Service } from "../catalog/schema.js"; import type { State } from "../state/schema.js"; import { renderCaddyfile } from "../renderer/caddy.js"; import { renderCompose } from "../renderer/compose.js"; +import { resolveVpnWireguardKey } from "../wiring/nordvpn.js"; export interface UpdateDeps { runStreaming: (argv: string[], onLine: (line: string) => void) => Promise<{ ok: boolean; code: number | null }>; @@ -74,7 +75,7 @@ export async function runUpdate(installDir: string, deps?: Partial): // key) is intentionally left alone. const renderStart = d.now(); logAndEcho("[update] rendering docker-compose.yml + Caddyfile from current templates"); - regenerateInstallerConfig(installDir, state, logAndEcho); + await regenerateInstallerConfig(installDir, state, logAndEcho); phaseTimes["render-config"] = d.now() - renderStart; const buildStart = d.now(); @@ -160,13 +161,19 @@ export async function runUpdate(installDir: string, deps?: Partial): } } -function regenerateInstallerConfig( +async function regenerateInstallerConfig( installDir: string, state: State, log: (line: string) => void -): void { +): Promise { const services = getServicesByIds(state.services_enabled); + // state.vpn holds the NordVPN access token (not the WG key) for nordvpn + // installs; resolve it to the real key before re-rendering compose, or the + // regenerated file would carry the token as WIREGUARD_PRIVATE_KEY and gluetun + // would reject it on the next `up`. + const effectiveVpn = await resolveVpnWireguardKey(state.vpn); + const composePath = join(installDir, "docker-compose.yml"); const composeContent = renderCompose(services, { installDir, @@ -177,7 +184,7 @@ function regenerateInstallerConfig( timezone: state.timezone, apiKeys: state.api_keys, gpu: state.gpu, - vpn: state.vpn, + vpn: effectiveVpn, remoteMode: state.remote_access.mode, }); writeFileSync(composePath, composeContent); @@ -188,6 +195,9 @@ function regenerateInstallerConfig( mode: state.remote_access.mode, domain: state.remote_access.domain, localDns: state.local_dns, + // Without this, an update re-renders qBittorrent's vhost back to + // `reverse_proxy qbittorrent:8080`, which is unreachable under VPN. + vpn: { enabled: state.vpn.enabled }, }); writeFileSync(caddyfilePath, caddyContent); log(`[update] wrote ${caddyfilePath}`); diff --git a/src/wiring/nordvpn.ts b/src/wiring/nordvpn.ts index 329b906..d14a5da 100644 --- a/src/wiring/nordvpn.ts +++ b/src/wiring/nordvpn.ts @@ -50,3 +50,22 @@ export async function deriveNordVpnPrivateKey( } return key; } + +// Resolve the WireGuard private key just before rendering compose. For a NordVPN +// install the persisted state holds the access *token*, not the WG key, so both +// the installer and `arrstack update` must turn it into the real key here (the +// state file deliberately keeps the token so reconfigure/--resume stay valid). +// A value that already looks like a WG key is returned unchanged. +export async function resolveVpnWireguardKey< + T extends { enabled: boolean; provider?: string; private_key?: string }, +>(vpn: T): Promise { + if ( + vpn.enabled && + vpn.provider === "nordvpn" && + vpn.private_key && + isNordVpnToken(vpn.private_key) + ) { + return { ...vpn, private_key: await deriveNordVpnPrivateKey(vpn.private_key) }; + } + return vpn; +} diff --git a/src/wiring/sonarr-radarr.ts b/src/wiring/sonarr-radarr.ts index 98ca134..4973151 100644 --- a/src/wiring/sonarr-radarr.ts +++ b/src/wiring/sonarr-radarr.ts @@ -55,51 +55,80 @@ export async function configureArr( `${service}: GET /api/v3/downloadclient failed: ${dcRes.status}` ); } - const existingClients = (await dcRes.json()) as Array<{ implementation: string; fields: Array<{ name: string; value: unknown }> }>; + const existingClients = (await dcRes.json()) as Array<{ + id: number; + name: string; + implementation: string; + fields: Array<{ name: string; value: unknown }>; + }>; - const alreadyConfigured = existingClients.some( - (c) => - c.implementation === "QBittorrent" && - c.fields.some( - (f) => f.name === "host" && f.value === qbitHost - ) - ); + // Force host (and credentials) onto a field list, leaving everything else as-is. + const withHostAndCreds = (fields: Array<{ name: string; value: unknown }>) => + fields.map((f) => { + if (f.name === "host") return { ...f, value: qbitHost }; + if (f.name === "username") return { ...f, value: opts.qbitUser }; + if (f.name === "password") return { ...f, value: opts.qbitPass }; + return f; + }); - if (!alreadyConfigured) { - const clientPayload = { - enable: true, - protocol: "torrent", - priority: 1, - name: "qBittorrent", - implementation: "QBittorrent", - configContract: "QBittorrentSettings", - fields: [ - { name: "host", value: qbitHost }, - { name: "port", value: 8080 }, - { name: "useSsl", value: false }, - { name: "urlBase", value: "" }, - { name: "username", value: opts.qbitUser }, - { name: "password", value: opts.qbitPass }, - { name: "category", value: opts.category }, - { name: "recentMoviePriority", value: 0 }, - { name: "olderMoviePriority", value: 0 }, - { name: "initialState", value: 0 }, - { name: "sequentialOrder", value: false }, - { name: "firstAndLast", value: false }, - ], - }; + const existing = existingClients.find((c) => c.implementation === "QBittorrent"); - const addDcRes = await withRetry(() => - fetch(`${base}/api/v3/downloadclient`, { - method: "POST", + if (existing) { + // A qBittorrent client already exists. If its host already matches, we are + // done. Otherwise the VPN mode was toggled (host moves between "qbittorrent" + // and "gluetun"), so UPDATE this client in place — POSTing a second one with + // the same name is rejected by the arr APIs as a duplicate. + const host = existing.fields.find((f) => f.name === "host")?.value; + if (host === qbitHost) return; + const updated = { ...existing, fields: withHostAndCreds(existing.fields) }; + const putRes = await withRetry(() => + fetch(`${base}/api/v3/downloadclient/${existing.id}`, { + method: "PUT", headers, - body: JSON.stringify(clientPayload), + body: JSON.stringify(updated), }) ); - if (!addDcRes.ok) { + if (!putRes.ok) { throw new Error( - `${service}: POST /api/v3/downloadclient failed: ${addDcRes.status}` + `${service}: PUT /api/v3/downloadclient/${existing.id} failed: ${putRes.status}` ); } + return; + } + + const clientPayload = { + enable: true, + protocol: "torrent", + priority: 1, + name: "qBittorrent", + implementation: "QBittorrent", + configContract: "QBittorrentSettings", + fields: [ + { name: "host", value: qbitHost }, + { name: "port", value: 8080 }, + { name: "useSsl", value: false }, + { name: "urlBase", value: "" }, + { name: "username", value: opts.qbitUser }, + { name: "password", value: opts.qbitPass }, + { name: "category", value: opts.category }, + { name: "recentMoviePriority", value: 0 }, + { name: "olderMoviePriority", value: 0 }, + { name: "initialState", value: 0 }, + { name: "sequentialOrder", value: false }, + { name: "firstAndLast", value: false }, + ], + }; + + const addDcRes = await withRetry(() => + fetch(`${base}/api/v3/downloadclient`, { + method: "POST", + headers, + body: JSON.stringify(clientPayload), + }) + ); + if (!addDcRes.ok) { + throw new Error( + `${service}: POST /api/v3/downloadclient failed: ${addDcRes.status}` + ); } } diff --git a/templates/Caddyfile.hbs b/templates/Caddyfile.hbs index 7bac325..56fc6d6 100644 --- a/templates/Caddyfile.hbs +++ b/templates/Caddyfile.hbs @@ -9,7 +9,7 @@ # LE at all). Without it, hitting http://{svc}.{tld} would fall through to # Caddy's default welcome page instead of the service. http://{{id}}.{{../localDnsTld}} { - reverse_proxy {{id}}:{{port}} + reverse_proxy {{upstream}}:{{port}} } {{/each}} {{/if}} diff --git a/tests/renderer/caddy.test.ts b/tests/renderer/caddy.test.ts index a5fab4c..bd4784d 100644 --- a/tests/renderer/caddy.test.ts +++ b/tests/renderer/caddy.test.ts @@ -94,4 +94,39 @@ describe("renderCaddyfile", () => { }); expect(output).not.toContain("DUCKDNS_TOKEN"); }); + + test("VPN: qBittorrent's local-DNS vhost proxies to gluetun, not qbittorrent", () => { + // qBittorrent is in gluetun's netns under VPN, so there is no reachable + // `qbittorrent` endpoint; the vhost must target gluetun. + const svc = getServicesByIds(["qbittorrent", "sonarr"]); + const output = renderCaddyfile(svc, { + mode: "none", + localDns: { enabled: true, tld: "arrstack.local" }, + vpn: { enabled: true }, + }); + expect(output).toContain("http://qbittorrent.arrstack.local"); + expect(output).toContain("reverse_proxy gluetun:8080"); + expect(output).not.toContain("reverse_proxy qbittorrent:8080"); + expect(output).toContain("reverse_proxy sonarr:8989"); // others unaffected + }); + + test("VPN: qBittorrent proxies to gluetun in remote (duckdns) mode too", () => { + const svc = getServicesByIds(["qbittorrent"]); + const output = renderCaddyfile(svc, { + mode: "duckdns", + domain: "myhome.duckdns.org", + vpn: { enabled: true }, + }); + expect(output).toContain("reverse_proxy gluetun:8080"); + expect(output).not.toContain("reverse_proxy qbittorrent:8080"); + }); + + test("no VPN: qBittorrent's vhost proxies to itself", () => { + const svc = getServicesByIds(["qbittorrent"]); + const output = renderCaddyfile(svc, { + mode: "none", + localDns: { enabled: true, tld: "arrstack.local" }, + }); + expect(output).toContain("reverse_proxy qbittorrent:8080"); + }); }); diff --git a/tests/wiring/nordvpn.test.ts b/tests/wiring/nordvpn.test.ts index 7b4cd43..eddc67e 100644 --- a/tests/wiring/nordvpn.test.ts +++ b/tests/wiring/nordvpn.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test, afterEach } from "bun:test"; -import { isNordVpnToken, deriveNordVpnPrivateKey } from "../../src/wiring/nordvpn"; +import { + isNordVpnToken, + deriveNordVpnPrivateKey, + resolveVpnWireguardKey, +} from "../../src/wiring/nordvpn"; + +const A_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; describe("isNordVpnToken", () => { test("true for a 64-char hex access token", () => { @@ -59,3 +65,38 @@ describe("deriveNordVpnPrivateKey", () => { ); }); }); + +describe("resolveVpnWireguardKey", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + test("derives the WG key for a NordVPN token (used by install AND update)", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ nordlynx_private_key: "WGKEY==" }), { status: 200 })) as any; + const out = await resolveVpnWireguardKey({ + enabled: true, + provider: "nordvpn", + private_key: A_TOKEN, + }); + expect(out.private_key).toBe("WGKEY=="); + }); + + test("leaves an already-extracted WG key untouched (no API call)", async () => { + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response("{}", { status: 200 }); + }) as any; + const vpn = { enabled: true, provider: "nordvpn", private_key: "AlreadyAWgKeyNot64Hex=" }; + const out = await resolveVpnWireguardKey(vpn); + expect(out.private_key).toBe(vpn.private_key); + expect(called).toBe(false); + }); + + test("leaves non-nordvpn providers unchanged", async () => { + const vpn = { enabled: true, provider: "mullvad", private_key: A_TOKEN }; + expect(await resolveVpnWireguardKey(vpn)).toEqual(vpn); + }); +}); diff --git a/tests/wiring/sonarr-radarr.test.ts b/tests/wiring/sonarr-radarr.test.ts index bfc4256..7994b48 100644 --- a/tests/wiring/sonarr-radarr.test.ts +++ b/tests/wiring/sonarr-radarr.test.ts @@ -53,3 +53,83 @@ describe("configureArr", () => { ).rejects.toThrow(); }); }); + +// Mock fetch so these never touch the network. They lock in that toggling VPN +// (host moves between "qbittorrent" and "gluetun") UPDATES the existing +// qBittorrent client in place instead of POSTing a duplicate. +describe("configureArr download client host changes (VPN toggle)", () => { + const origFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const optsFor = (qbitHost: string) => ({ + rootFolder: "/data/media/tv", + extraFolders: [], + qbitUser: "admin", + qbitPass: "pw", + category: "tv", + qbitHost, + }); + + test("updates (PUT) the existing client when the host changed; never POSTs a duplicate", async () => { + const calls: Array<{ method: string; url: string; body?: string }> = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ method, url: u, body: init?.body }); + if (u.endsWith("/api/v3/rootfolder")) + return new Response(JSON.stringify([{ path: "/data/media/tv" }]), { status: 200 }); + if (u.endsWith("/api/v3/downloadclient") && method === "GET") + return new Response( + JSON.stringify([ + { + id: 5, + name: "qBittorrent", + implementation: "QBittorrent", + fields: [ + { name: "host", value: "qbittorrent" }, + { name: "username", value: "admin" }, + { name: "password", value: "old" }, + ], + }, + ]), + { status: 200 }, + ); + if (u.includes("/api/v3/downloadclient/5") && method === "PUT") + return new Response("{}", { status: 202 }); + return new Response("{}", { status: 200 }); + }) as any; + + await configureArr("sonarr", "key", optsFor("gluetun")); + + const put = calls.find((c) => c.method === "PUT" && c.url.includes("/downloadclient/5")); + expect(put).toBeDefined(); + expect(put!.body).toContain('"value":"gluetun"'); + expect( + calls.some((c) => c.method === "POST" && c.url.endsWith("/api/v3/downloadclient")), + ).toBe(false); + }); + + test("no-ops when the existing client host already matches", async () => { + const calls: Array<{ method: string; url: string }> = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ method, url: u }); + if (u.endsWith("/api/v3/rootfolder")) + return new Response(JSON.stringify([{ path: "/data/media/tv" }]), { status: 200 }); + if (u.endsWith("/api/v3/downloadclient") && method === "GET") + return new Response( + JSON.stringify([ + { id: 5, name: "qBittorrent", implementation: "QBittorrent", fields: [{ name: "host", value: "gluetun" }] }, + ]), + { status: 200 }, + ); + return new Response("{}", { status: 200 }); + }) as any; + + await configureArr("sonarr", "key", optsFor("gluetun")); + expect(calls.some((c) => c.method === "PUT" || c.method === "POST")).toBe(false); + }); +}); From e8b0bd21320ff5f26c1c83bfae23b08c0e18f997 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Thu, 25 Jun 2026 10:54:49 +0200 Subject: [PATCH 3/3] fix(vpn): address Codex re-review (resume password, update token expiry) + site - install + wizard: persist the admin password to admin.txt during the early state snapshot and have the wizard reuse it, so reconfigure and `--resume` no longer rotate the password out from under containers already running from a failed attempt (which broke the post-boot resume case). - update: if refreshing the NordVPN key fails (e.g. an expired temporary token, which NordVPN rotates after ~30 days), fall back to the WireGuard key already in docker-compose.yml instead of aborting the whole update. - site: docs/index.html now lists NordVPN and mentions the paste-a-token flow. 270 tests pass, tsc clean. --- docs/index.html | 2 +- src/ui/wizard/useWizardState.ts | 21 ++++++++++++++++++-- src/usecase/install.ts | 10 ++++++++++ src/usecase/update.ts | 35 ++++++++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/index.html b/docs/index.html index 505cffe..2fdc63d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -212,7 +212,7 @@

The twelve services, grouped by role.

Gluetun network
optional -

vpn Wraps the download client in a killswitched VPN tunnel. Enabled when you pick a provider (Mullvad, Proton, or custom) and provide WireGuard credentials in the wizard.

+

vpn Wraps the download client in a killswitched VPN tunnel. Enabled when you pick a provider (Mullvad, Proton, NordVPN, or custom) in the wizard. NordVPN just needs an access token, the WireGuard key is derived for you.

diff --git a/src/ui/wizard/useWizardState.ts b/src/ui/wizard/useWizardState.ts index 2fa4b33..4956a4b 100644 --- a/src/ui/wizard/useWizardState.ts +++ b/src/ui/wizard/useWizardState.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import os from "node:os"; -import { statfsSync } from "node:fs"; +import { statfsSync, readFileSync } from "node:fs"; import { detectGpus, type GpuInfo } from "../../platform/gpu.js"; import { resolveRenderVideoGids } from "../../platform/groups.js"; import { isDockerInstalled, isDockerRunning, isComposeV2 } from "../../platform/docker.js"; @@ -187,6 +187,18 @@ function detectTimezone(): string { } } +// Read the admin password from a previous install/attempt (admin.txt, mode 600) +// so the wizard reuses it instead of generating a fresh one. Returns null when +// there is no readable admin.txt. +function readExistingAdminPassword(installDir: string): string | null { + try { + const m = readFileSync(`${installDir}/admin.txt`, "utf-8").match(/^password:\s*(.+)$/m); + return m ? m[1].trim() : null; + } catch { + return null; + } +} + // Services managed automatically by the installer, hidden from the user grid // Infrastructure: caddy, ddns containers, dnsmasq // Bazarr+ deps: flaresolverr, opensubtitles-scraper, ai-subtitle-translator (bundled with Bazarr+) @@ -228,7 +240,12 @@ export function useWizardState(existingState?: Partial | null) { const [adminUsername, setAdminUsername] = useState( existingState?.admin?.username ?? "admin" ); - const [adminPassword, setAdminPassword] = useState(generatePassword); + const [adminPassword, setAdminPassword] = useState(() => { + // Reuse the password from a prior install/attempt so reconfigure and + // --resume don't rotate it out from under already-running containers. + const dir = existingState?.install_dir ?? `${process.env.HOME ?? "."}/arrstack`; + return readExistingAdminPassword(dir) ?? generatePassword(); + }); const [detectedGpus, setDetectedGpus] = useState([]); const [gpuVendor, setGpuVendor] = useState( diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 046b72a..86045e4 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -386,6 +386,16 @@ export async function runInstall( api_keys: apiKeys, install_started_at: installStartedAt, }); + // Persist the admin password early (mode 600). state.json never stores it, + // so without this a resumed install would generate a *new* password, rewrite + // qBittorrent.conf with a new hash, and then fail to log into the qbit + // container still running from the failed attempt (compose up does not + // recreate an unchanged container). The wizard reads this back on resume. + writeFileSync( + join(installDir, "admin.txt"), + `username: ${state.admin.username}\npassword: ${adminPassword}\ngenerated: ${installStartedAt}\n`, + { mode: 0o600 } + ); }); // Step 9a: Prepare custom Caddy image (only when remote mode needs DNS diff --git a/src/usecase/update.ts b/src/usecase/update.ts index 7205a77..ad94c84 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -1,4 +1,4 @@ -import { existsSync, openSync, writeSync, closeSync, fsyncSync, writeFileSync } from "node:fs"; +import { existsSync, openSync, writeSync, closeSync, fsyncSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { readState } from "../state/store.js"; import { exec } from "../lib/exec.js"; @@ -168,13 +168,26 @@ async function regenerateInstallerConfig( ): Promise { const services = getServicesByIds(state.services_enabled); + const composePath = join(installDir, "docker-compose.yml"); + // state.vpn holds the NordVPN access token (not the WG key) for nordvpn // installs; resolve it to the real key before re-rendering compose, or the // regenerated file would carry the token as WIREGUARD_PRIVATE_KEY and gluetun - // would reject it on the next `up`. - const effectiveVpn = await resolveVpnWireguardKey(state.vpn); - - const composePath = join(installDir, "docker-compose.yml"); + // would reject it on the next `up`. NordVPN's temporary tokens expire after + // ~30 days, so if the refresh fails, fall back to the WireGuard key already + // baked into the current compose file rather than aborting the whole update. + let effectiveVpn = state.vpn; + try { + effectiveVpn = await resolveVpnWireguardKey(state.vpn); + } catch (err) { + const existing = existingWireguardKey(composePath); + if (!existing) throw err; + effectiveVpn = { ...state.vpn, private_key: existing }; + log( + `[update] warning: could not refresh the NordVPN key ` + + `(${(err as Error).message}); reusing the existing WireGuard key`, + ); + } const composeContent = renderCompose(services, { installDir, storageRoot: state.storage_root, @@ -203,6 +216,18 @@ async function regenerateInstallerConfig( log(`[update] wrote ${caddyfilePath}`); } +// Recover the WireGuard private key already written into the current compose +// file, so an update can keep working when a NordVPN token can no longer be +// refreshed (e.g. an expired temporary token). Returns undefined if absent. +function existingWireguardKey(composePath: string): string | undefined { + try { + const m = readFileSync(composePath, "utf-8").match(/WIREGUARD_PRIVATE_KEY=(\S+)/); + return m?.[1]; + } catch { + return undefined; + } +} + async function runHealthChecks( services: Service[], checkHealth: UpdateDeps["checkHealth"],