Skip to content

Commit 3dc8cc9

Browse files
authored
Merge pull request #1 from LavX/feat/vpn-nordvpn-state-bazarr
fix(vpn): make gluetun VPN work end-to-end + add NordVPN
2 parents 1d3e2d9 + e8b0bd2 commit 3dc8cc9

24 files changed

Lines changed: 799 additions & 83 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ The README is a tour. The full user guide lives under `docs/`.
6969
- Caddy reverse proxy with three remote-access modes: LAN (plain HTTP), DuckDNS (Let's Encrypt), Cloudflare DNS-01 wildcard Let's Encrypt
7070
- Two LAN hostname modes: install dnsmasq for LAN-wide resolution, or print a single `/etc/hosts` line
7171
- TRaSH-compliant shared `/data` mount so hardlinks work across `torrents/` and `media/`
72-
- Optional gluetun + WireGuard VPN container in front of qBittorrent
72+
- 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)
7373
- Per-service log rotation capped at 50 MB
7474

7575
## Quickstart

docs/guide/06-vpn.md

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 06. VPN (gluetun + WireGuard)
22

3-
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.
3+
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.
44

55
## TL;DR
66

@@ -18,6 +18,14 @@ docker exec qbittorrent curl -s ifconfig.me
1818

1919
## What routes where
2020

21+
> **Only qBittorrent is routed through the VPN.** Every other service (Sonarr,
22+
> Radarr, Prowlarr, Bazarr+, Jellyfin, Jellyseerr, FlareSolverr, Recyclarr,
23+
> Trailarr, and the rest) uses your host's **normal internet connection**, not
24+
> NordVPN. This is intentional: the arr apps and media server work better (and in
25+
> some cases only work) on your real connection, and the torrent client is the
26+
> only thing that needs an anonymizing exit. To confirm it on your own box, see
27+
> [Verifying routing](#verifying-the-split-which-service-uses-which-network) below.
28+
2129
| Service | Network | Outbound IP |
2230
|---------------|----------------------|-------------|
2331
| qBittorrent | `network_mode: service:gluetun` | VPN exit |
@@ -30,6 +38,33 @@ docker exec qbittorrent curl -s ifconfig.me
3038

3139
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.
3240

41+
## Verifying the split (which service uses which network)
42+
43+
You can prove exactly where each service exits. qBittorrent should report your VPN
44+
exit IP; every other service should report your normal (ISP) IP.
45+
46+
```bash
47+
# qBittorrent -> should be your NordVPN exit IP
48+
docker exec qbittorrent curl -s https://ifconfig.me; echo
49+
50+
# Sonarr (or any other arr/media service) -> should be your normal/ISP IP
51+
docker exec sonarr curl -s https://ifconfig.me; echo
52+
53+
# Your host's own public IP, for comparison with Sonarr's
54+
curl -s https://ifconfig.me; echo
55+
```
56+
57+
If qBittorrent's IP differs from the other two (a NordVPN address) while Sonarr
58+
matches your host, the split is working as designed. If qBittorrent's IP equals
59+
your ISP IP, the tunnel is not up, check `arrstack logs gluetun`.
60+
61+
This is structural, not luck: only qBittorrent is rendered with
62+
`network_mode: service:gluetun`, so its *only* possible route is gluetun's tunnel
63+
(that is also the kill switch). Every other service sits on the `arrstack` bridge
64+
and egresses through the host, so it cannot use the VPN even if the tunnel is up.
65+
To route something else through the VPN you would have to add it to gluetun's
66+
network namespace too; arrstack does not do this by default.
67+
3368
## Kill-switch behavior
3469

3570
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:
5388
| Field | Options |
5489
|----------------------|---------|
5590
| Enable gluetun | on / off |
56-
| Provider | `mullvad`, `protonvpn`, `custom` |
91+
| Provider | `mullvad`, `protonvpn`, `nordvpn`, `custom` |
5792
| Protocol | `wireguard` (only protocol wired end-to-end today) |
5893
| Private key | `WIREGUARD_PRIVATE_KEY` from your provider config |
5994
| Addresses | Tunnel IP/CIDR, e.g. `10.64.222.21/32` |
@@ -93,6 +128,25 @@ Endpoint = 185.65.134.66:51820
93128

94129
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.
95130

131+
### NordVPN
132+
133+
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.
134+
135+
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.
136+
2. In the wizard, pick provider `nordvpn` and paste the token into the **NordVPN token** field.
137+
3. Leave **WG addresses** blank. gluetun fills in NordLynx's default tunnel address automatically.
138+
4. Optionally set **Countries** (e.g. `Netherlands`), which maps to gluetun's `SERVER_COUNTRIES`.
139+
140+
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.
141+
142+
Already extracted the NordLynx key yourself? Running
143+
144+
```bash
145+
curl -s -u token:YOUR_TOKEN https://api.nordvpn.com/v1/users/services/credentials
146+
```
147+
148+
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).
149+
96150
### AirVPN and other providers (use `custom`)
97151

98152
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.

docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ <h2 id="services-heading">The twelve services, grouped by role.</h2>
212212
<div class="svc-icon svc-icon-fallback" aria-hidden="true">Gt</div>
213213
<div class="svc-name">Gluetun <span class="svc-port">network</span></div>
214214
<span class="svc-chip optional">optional</span>
215-
<p class="svc-desc"><span class="svc-role">vpn</span> 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.</p>
215+
<p class="svc-desc"><span class="svc-role">vpn</span> 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.</p>
216216
</article>
217217
<article class="svc">
218218
<div class="svc-icon" aria-hidden="true"><img class="service-logo" src="assets/service-logos/prowlarr.svg" alt="" width="48" height="48"></div>

src/catalog/services.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,16 @@ services:
309309
mounts: {}
310310
envVars: {}
311311
dependsOn: []
312+
# The installer's host-side health gate only probes http services. gluetun's
313+
# control server (:8000) isn't published to the host, so an http probe would
314+
# always time out; a tcp type makes the installer skip it. Boot ordering for
315+
# the VPN-routed qBittorrent is enforced at the docker layer instead, via the
316+
# compose-level healthcheck + `depends_on: { gluetun: service_healthy }`.
312317
health:
313-
type: http
314-
path: /v1/openvpn/status
318+
type: tcp
315319
port: 8000
316320
default: false
317321
requiresAdminAuth: false
318-
networkMode: host
319322
# gluetun builds its own kill-switch with iptables/nftables on boot. Without
320323
# NET_ADMIN it exits with "Could not fetch rule set generation id: Permission
321324
# denied (you must be root)" before reaching the healthcheck. /dev/net/tun

src/renderer/caddy.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@ export interface CaddyOptions {
1313
enabled: boolean;
1414
tld: string;
1515
};
16+
// When VPN is on, qBittorrent shares gluetun's netns and has no container
17+
// name of its own, so Caddy must proxy its vhost to gluetun instead.
18+
vpn?: { enabled: boolean };
1619
}
1720

1821
interface CaddyServiceEntry {
1922
id: string;
2023
port: number;
24+
// Docker network host Caddy reverse-proxies to. Usually the same as `id`;
25+
// becomes "gluetun" for qBittorrent when VPN routing is on.
26+
upstream: string;
2127
}
2228

2329
interface CaddyContext {
@@ -29,9 +35,14 @@ interface CaddyContext {
2935
}
3036

3137
export function buildCaddyContext(services: Service[], opts: CaddyOptions): CaddyContext {
38+
const vpnEnabled = opts.vpn?.enabled ?? false;
3239
const entries: CaddyServiceEntry[] = services
3340
.filter((svc) => svc.adminPort !== undefined)
34-
.map((svc) => ({ id: svc.id, port: svc.adminPort as number }));
41+
.map((svc) => ({
42+
id: svc.id,
43+
port: svc.adminPort as number,
44+
upstream: vpnEnabled && svc.id === "qbittorrent" ? "gluetun" : svc.id,
45+
}));
3546

3647
return {
3748
mode: opts.mode,

src/renderer/compose.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ interface PortBinding {
4848
binding: string; // e.g. "127.0.0.1:8989:8989" or "0.0.0.0:443:443"
4949
}
5050

51+
// A depends_on edge. `condition` upgrades it to compose long-form
52+
// (`gluetun: { condition: service_healthy }`); without it the template emits
53+
// the short-form list entry (`- gluetun`).
54+
interface DependsOnEntry {
55+
service: string;
56+
condition?: string;
57+
}
58+
59+
interface Healthcheck {
60+
test: string;
61+
interval: string;
62+
timeout: string;
63+
retries: number;
64+
start_period: string;
65+
}
66+
5167
interface ServiceContext {
5268
id: string;
5369
image: string;
@@ -62,7 +78,12 @@ interface ServiceContext {
6278
devices: string[];
6379
capAdd: string[];
6480
groupAdd: string[];
65-
dependsOn: string[];
81+
dependsOn: DependsOnEntry[];
82+
// When any dependsOn entry carries a condition, the whole block must be
83+
// rendered in compose long-form (a map of service -> {condition}); the two
84+
// forms can't be mixed on one service.
85+
dependsOnLongForm: boolean;
86+
healthcheck?: Healthcheck;
6687
vpnNetwork: boolean;
6788
}
6889

@@ -168,6 +189,19 @@ function isVpnRouted(svc: Service, vpn: ComposeOptions["vpn"]): boolean {
168189
return svc.id === "qbittorrent" && vpn.enabled;
169190
}
170191

192+
// gluetun ships a built-in HEALTHCHECK, but we render one explicitly so that
193+
// `depends_on: { gluetun: { condition: service_healthy } }` keeps working even
194+
// if a future image drops it. `/gluetun-entrypoint healthcheck` pings the
195+
// tunnel; start_period covers the WireGuard handshake + firewall setup before
196+
// the first probe counts against retries.
197+
const GLUETUN_HEALTHCHECK: Healthcheck = {
198+
test: '["CMD", "/gluetun-entrypoint", "healthcheck"]',
199+
interval: "10s",
200+
timeout: "10s",
201+
retries: 6,
202+
start_period: "30s",
203+
};
204+
171205
// Translates the wizard's VPN state into the env vars gluetun expects
172206
// (VPN_SERVICE_PROVIDER / VPN_TYPE / WIREGUARD_* / SERVER_COUNTRIES / custom
173207
// endpoint tuple). Only emitted for the gluetun service and only when VPN
@@ -193,6 +227,14 @@ function buildGluetunEnv(vpn: ComposeOptions["vpn"]): EnvEntry[] {
193227
}
194228

195229
export function buildComposeContext(services: Service[], opts: ComposeOptions): ComposeContext {
230+
// Every VPN-routed service runs inside gluetun's netns and gets `ports: []`,
231+
// so its WebUI port has to be published by gluetun instead. Collect those
232+
// ports once and hand them to the gluetun service below. Generic, so any
233+
// future routed service (not just qBittorrent) is exposed automatically.
234+
const vpnRoutedPorts: number[] = opts.vpn.enabled
235+
? services.filter((svc) => isVpnRouted(svc, opts.vpn)).flatMap((svc) => svc.ports)
236+
: [];
237+
196238
const serviceContexts: ServiceContext[] = services.map((svc) => {
197239
const vpnNetwork = isVpnRouted(svc, opts.vpn);
198240
const apiKeyEnv = svc.apiKeyEnv;
@@ -206,9 +248,24 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions):
206248
extraEnv.push(...buildGluetunEnv(opts.vpn));
207249
}
208250

209-
const ports: PortBinding[] = vpnNetwork
210-
? []
211-
: svc.ports.map((p) => ({ binding: `0.0.0.0:${p}:${p}` }));
251+
// gluetun publishes its own declared ports plus every VPN-routed service's
252+
// ports. VPN-routed services themselves publish nothing (they share
253+
// gluetun's network). Everything else binds its declared ports directly.
254+
const ownPorts =
255+
svc.id === "gluetun" ? [...svc.ports, ...vpnRoutedPorts] : vpnNetwork ? [] : svc.ports;
256+
const ports: PortBinding[] = ownPorts.map((p) => ({ binding: `0.0.0.0:${p}:${p}` }));
257+
258+
// VPN-routed services must wait for gluetun to be *healthy* (tunnel up)
259+
// before they attach to its netns, otherwise a gluetun restart mid-boot
260+
// leaves a stale namespace path and `docker compose up` aborts with the
261+
// cryptic `lstat /proc/<pid>/ns/net: no such file or directory`.
262+
const dependsOn: DependsOnEntry[] = svc.dependsOn.map((service) => ({ service }));
263+
if (vpnNetwork) {
264+
dependsOn.push({ service: "gluetun", condition: "service_healthy" });
265+
}
266+
const dependsOnLongForm = dependsOn.some((d) => d.condition !== undefined);
267+
268+
const healthcheck = svc.id === "gluetun" ? GLUETUN_HEALTHCHECK : undefined;
212269

213270
const caddyImage = resolveCaddyImage(svc, opts.remoteMode);
214271

@@ -239,7 +296,9 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions):
239296
devices: buildDevices(svc, opts.gpu),
240297
capAdd: svc.capAdd,
241298
groupAdd: buildGroupAdd(svc, opts.gpu),
242-
dependsOn: svc.dependsOn,
299+
dependsOn,
300+
dependsOnLongForm,
301+
healthcheck,
243302
vpnNetwork,
244303
};
245304
});

src/ui/wizard/Form.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export function Form({ initial, isReconfigure, onSubmit, onCancel }: FormProps)
195195
return;
196196
}
197197
if (activeSectionIndex === SEC_VPN && activeFieldIndex === 1 && ws.vpnMode === "gluetun") {
198-
const providers = ["mullvad", "protonvpn", "custom"] as const;
198+
const providers = ["mullvad", "protonvpn", "nordvpn", "custom"] as const;
199199
const idx = providers.indexOf(ws.vpnProvider as (typeof providers)[number]);
200200
const next = isForward
201201
? (idx + 1) % providers.length
@@ -280,7 +280,7 @@ export function Form({ initial, isReconfigure, onSubmit, onCancel }: FormProps)
280280
}
281281
// VPN provider radio: cycle to next (only visible when mode=gluetun)
282282
if (activeSectionIndex === SEC_VPN && activeFieldIndex === 1 && ws.vpnMode === "gluetun") {
283-
const providers = ["mullvad", "protonvpn", "custom"] as const;
283+
const providers = ["mullvad", "protonvpn", "nordvpn", "custom"] as const;
284284
const idx = providers.indexOf(ws.vpnProvider as (typeof providers)[number]);
285285
ws.setVpnProvider(providers[(idx + 1) % providers.length]);
286286
return;

src/ui/wizard/VpnField.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Radio, RadioOption } from "../shared/Radio.js";
77
import { colors, LABEL_WIDTH } from "../shared/theme.js";
88

99
export type VpnMode = "none" | "gluetun";
10-
export type VpnProvider = "mullvad" | "protonvpn" | "custom";
10+
export type VpnProvider = "mullvad" | "protonvpn" | "nordvpn" | "custom";
1111

1212
interface VpnFieldProps {
1313
mode: VpnMode;
@@ -42,6 +42,7 @@ const MODE_OPTIONS: RadioOption[] = [
4242
const PROVIDER_OPTIONS: RadioOption[] = [
4343
{ value: "mullvad", label: "mullvad" },
4444
{ value: "protonvpn", label: "protonvpn" },
45+
{ value: "nordvpn", label: "nordvpn" },
4546
{ value: "custom", label: "custom" },
4647
];
4748

@@ -65,6 +66,7 @@ export function VpnField({
6566
}: VpnFieldProps) {
6667
const enabled = mode === "gluetun";
6768
const isCustom = provider === "custom";
69+
const isNord = provider === "nordvpn";
6870

6971
return (
7072
<SectionBox title="VPN" isFocused={isFocused}>
@@ -97,17 +99,29 @@ export function VpnField({
9799
</Box>
98100

99101
<TextInput
100-
label="WG private key"
102+
label={isNord ? "NordVPN token" : "WG private key"}
101103
value={privateKey}
102104
onChange={onPrivateKeyChange}
103-
hint="from your provider's WireGuard config"
105+
hint={
106+
isNord
107+
? "access token, the WireGuard key is derived for you"
108+
: "from your provider's WireGuard config"
109+
}
104110
isFocused={focusedField === 2}
105111
/>
112+
{isNord && (
113+
<Box>
114+
<Text>{"".padEnd(LABEL_WIDTH)}</Text>
115+
<Text color={colors.muted}>
116+
create one: https://my.nordaccount.com/dashboard/nordvpn/access-tokens/
117+
</Text>
118+
</Box>
119+
)}
106120
<TextInput
107121
label="WG addresses"
108122
value={addresses}
109123
onChange={onAddressesChange}
110-
hint="e.g. 10.64.222.21/32"
124+
hint={isNord ? "optional, leave blank for NordVPN" : "e.g. 10.64.222.21/32"}
111125
isFocused={focusedField === 3}
112126
/>
113127
<TextInput

0 commit comments

Comments
 (0)