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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 56 additions & 2 deletions docs/guide/06-vpn.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 |
Expand All @@ -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.
Expand All @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ <h2 id="services-heading">The twelve services, grouped by role.</h2>
<div class="svc-icon svc-icon-fallback" aria-hidden="true">Gt</div>
<div class="svc-name">Gluetun <span class="svc-port">network</span></div>
<span class="svc-chip optional">optional</span>
<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>
<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>
</article>
<article class="svc">
<div class="svc-icon" aria-hidden="true"><img class="service-logo" src="assets/service-logos/prowlarr.svg" alt="" width="48" height="48"></div>
Expand Down
9 changes: 6 additions & 3 deletions src/catalog/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/caddy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,9 +35,14 @@ interface CaddyContext {
}

export function buildCaddyContext(services: Service[], opts: CaddyOptions): CaddyContext {
const vpnEnabled = opts.vpn?.enabled ?? false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve VPN upstreams during arrstack update

The renderer defaults omitted vpn options to false, but arrstack update regenerates the Caddyfile without passing state.vpn. For existing VPN installs in DuckDNS/Cloudflare mode, an update therefore rewrites the qBittorrent vhost back to reverse_proxy qbittorrent:8080 even though compose still puts qBittorrent in gluetun's network namespace, breaking remote qBittorrent access after every update.

Useful? React with 👍 / 👎.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the VPN upstream for local DNS vhosts too

For VPN installs with local DNS enabled, this computes upstream: "gluetun" for qBittorrent, but the local-DNS stanza in templates/Caddyfile.hbs still renders reverse_proxy {{id}}:{{port}}. Since qBittorrent has network_mode: service:gluetun and no reachable qbittorrent network endpoint, http://qbittorrent.<tld> fails while the remote blocks work; the local DNS block needs to use the same upstream field.

Useful? React with 👍 / 👎.

}));

return {
mode: opts.mode,
Expand Down
69 changes: 64 additions & 5 deletions src/renderer/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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/<pid>/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);

Expand Down Expand Up @@ -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,
};
});
Expand Down
4 changes: 2 additions & 2 deletions src/ui/wizard/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 18 additions & 4 deletions src/ui/wizard/VpnField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" },
];

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

return (
<SectionBox title="VPN" isFocused={isFocused}>
Expand Down Expand Up @@ -97,17 +99,29 @@ export function VpnField({
</Box>

<TextInput
label="WG private key"
label={isNord ? "NordVPN token" : "WG private key"}
value={privateKey}
onChange={onPrivateKeyChange}
hint="from your provider's WireGuard config"
hint={
isNord
? "access token, the WireGuard key is derived for you"
: "from your provider's WireGuard config"
}
isFocused={focusedField === 2}
/>
{isNord && (
<Box>
<Text>{"".padEnd(LABEL_WIDTH)}</Text>
<Text color={colors.muted}>
create one: https://my.nordaccount.com/dashboard/nordvpn/access-tokens/
</Text>
</Box>
)}
<TextInput
label="WG addresses"
value={addresses}
onChange={onAddressesChange}
hint="e.g. 10.64.222.21/32"
hint={isNord ? "optional, leave blank for NordVPN" : "e.g. 10.64.222.21/32"}
isFocused={focusedField === 3}
/>
<TextInput
Expand Down
Loading
Loading