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..65b40d4 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
@@ -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.
@@ -53,7 +88,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 +128,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/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.
Gt
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/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 [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(
@@ -288,7 +305,9 @@ export function useWizardState(existingState?: Partial | 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..86045e4 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 { isNordVpnToken, resolveVpnWireguardKey } 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,23 @@ 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 () => {
+ effectiveVpn = await resolveVpnWireguardKey(state.vpn);
+ });
+ }
+
// Step 5: Render docker-compose.yml
await runStep("Render docker-compose.yml", onStep, log, async () => {
const content = renderCompose(services, {
@@ -230,7 +250,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 +262,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 +373,31 @@ 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,
+ });
+ // 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
// plugins). Try the prebuilt ghcr image first, fall back to local xcaddy
// build when the pull fails or returns unauthorized.
@@ -534,6 +580,7 @@ export async function runInstall(
qbitUser: state.admin.username,
qbitPass: adminPassword,
category: "tv",
+ qbitHost: effectiveVpn.enabled ? "gluetun" : "qbittorrent",
});
});
}
@@ -547,6 +594,7 @@ export async function runInstall(
qbitUser: state.admin.username,
qbitPass: adminPassword,
category: "movies",
+ qbitHost: effectiveVpn.enabled ? "gluetun" : "qbittorrent",
});
});
}
@@ -613,6 +661,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/usecase/update.ts b/src/usecase/update.ts
index 94e3f08..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";
@@ -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,14 +161,33 @@ 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);
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`. 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,
@@ -177,7 +197,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,11 +208,26 @@ 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}`);
}
+// 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"],
diff --git a/src/wiring/nordvpn.ts b/src/wiring/nordvpn.ts
new file mode 100644
index 0000000..d14a5da
--- /dev/null
+++ b/src/wiring/nordvpn.ts
@@ -0,0 +1,71 @@
+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;
+}
+
+// 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/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..4973151 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" };
@@ -49,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 === "qbittorrent"
- )
- );
+ // 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: "qbittorrent" },
- { 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 bb09cf7..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}}
@@ -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/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/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..eddc67e
--- /dev/null
+++ b/tests/wiring/nordvpn.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, test, afterEach } from "bun:test";
+import {
+ isNordVpnToken,
+ deriveNordVpnPrivateKey,
+ resolveVpnWireguardKey,
+} from "../../src/wiring/nordvpn";
+
+const A_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+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/,
+ );
+ });
+});
+
+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/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/,
+ );
+ });
+});
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);
+ });
+});