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.

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/ + + + )}