diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index 3ae0e2e..903f7a6 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -13,6 +13,7 @@ local testTailscaleIngress = import 'milky-way/lib/test-tailscale-operator-ingre local testTailscaleL3 = import 'milky-way/lib/test-tailscale-operator-network-L3.libsonnet'; local openclaw = import 'milky-way/lib/openclaw.libsonnet'; local qbittorrent = import 'milky-way/lib/qbittorrent.libsonnet'; +local wgConf = import 'milky-way/lib/wireguard-conf.libsonnet'; local sftp = import 'milky-way/lib/sftp.libsonnet'; local grandCentral = import 'milky-way/lib/grand-central.libsonnet'; local gluetunLeakTest = import 'milky-way/lib/gluetun-leak-test.libsonnet'; @@ -132,11 +133,14 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; }, }, - // Headless qbittorrent whose traffic is forced through a NordVPN/WireGuard tunnel by an embedded - // gluetun sidecar killswitch (lib/gluetun.libsonnet). WebUI via Tailscale L7 ingress. Downloads - // land in downloads/qbittorrent/ on the shared mdata volume (mounted at /data). + // Headless qbittorrent whose traffic is forced through a ProtonVPN/WireGuard tunnel by an embedded + // gluetun sidecar killswitch (lib/gluetun.libsonnet), with NAT-PMP port forwarding so it's + // connectable for inbound peers (ProtonVPN supports PF; NordVPN does not). WebUI via Tailscale L7 + // ingress. Downloads land in downloads/qbittorrent/ on the shared mdata volume (mounted at /data). + // The WireGuard key is read straight from the sops-managed ProtonVPN .conf (only Interface. + // PrivateKey is used; gluetun selects its own PF-capable P2P server). qbittorrent: qbittorrent.new( - wireguardPrivateKey = secrets.vpn.wireguard[0].privateKey, + wireguardPrivateKey = wgConf.privateKeyOf(importstr 'milky-way/secrets/qbt-gluetun.conf'), tailscaleHostname = "qbittorrent", serverCountries = "United States", volumeClaimName = this.mdataPvc.metadata.name, diff --git a/milky-way/lib/gluetun.libsonnet b/milky-way/lib/gluetun.libsonnet index 34ff732..236098d 100644 --- a/milky-way/lib/gluetun.libsonnet +++ b/milky-way/lib/gluetun.libsonnet @@ -37,6 +37,15 @@ local images = import 'milky-way/lib/images.libsonnet'; // the methanol cluster: pod 10.42.0.0/16, service 10.43.0.0/16). Re-check if networking changes. firewallOutboundSubnets='10.42.0.0/16,10.43.0.0/16', firewallInputPorts=[controlPort], // host adds its app port (e.g. WebUI) too + // VPN-side port forwarding (NAT-PMP). Off by default; only some providers support it (NOT + // NordVPN -- ProtonVPN/PIA/etc do). When on, gluetun asks the VPN for an inbound-reachable port + // so a P2P app can ACCEPT incoming connections (be connectable/seed), and auto-opens that port + // on the VPN-interface firewall (no FIREWALL_VPN_INPUT_PORTS needed). The forwarded port is + // DYNAMIC and gluetun re-runs the up command on each (re)assignment, so the host wires the app's + // listen port to it via portForwardingUpCommand (gluetun substitutes {{PORT}}/{{VPN_INTERFACE}}). + portForwarding=false, + portForwardingUpCommand=null, + portForwardingDownCommand=null, image=images.gluetun.fullyQualifiedImageReferencePinned, ):: { local this = self, @@ -93,7 +102,32 @@ local images = import 'milky-way/lib/images.libsonnet'; { name: 'HTTP_CONTROL_SERVER_ADDRESS', value: ':%d' % controlPort }, // Restart the tunnel if connectivity dies. { name: 'HEALTH_TARGET_ADDRESS', value: 'cloudflare.com:443' }, - ], + ] + ( + // VPN-side NAT-PMP port forwarding -- see the portForwarding param comment above. + if portForwarding then [ + { name: 'VPN_PORT_FORWARDING', value: 'on' }, + // Needed for WireGuard, where gluetun can't infer the provider's PF code from the tunnel. + { name: 'VPN_PORT_FORWARDING_PROVIDER', value: vpnProvider }, + { name: 'VPN_PORT_FORWARDING_STATUS_FILE', value: '/tmp/gluetun/forwarded_port' }, + // Only connect to servers that actually support P2P + port forwarding, and skip free + // servers (which don't offer PF on paid providers like ProtonVPN). + { name: 'PORT_FORWARD_ONLY', value: 'on' }, + { name: 'FREE_ONLY', value: 'off' }, + ] + ( + // gluetun pipes the up command's stderr to its own logger at ERROR level, so a `wget -nv` + // up command emits one benign `ERROR [port forwarding] ... [0/0] -> "-" [1]` line at + // startup -- that's wget's normal success output (0-byte body to stdout), NOT a failure. + // The port is still set; don't chase it. (Use `wget -q` to silence it, at the cost of also + // hiding wget's real error output.) + if portForwardingUpCommand != null + then [{ name: 'VPN_PORT_FORWARDING_UP_COMMAND', value: portForwardingUpCommand }] + else [] + ) + ( + if portForwardingDownCommand != null + then [{ name: 'VPN_PORT_FORWARDING_DOWN_COMMAND', value: portForwardingDownCommand }] + else [] + ) else [] + ), envFrom: [{ secretRef: { name: this.secret.metadata.name } }], // WG key or OVPN creds ports: [{ name: 'gluetun-ctrl', containerPort: controlPort }], securityContext: { diff --git a/milky-way/lib/qbittorrent.libsonnet b/milky-way/lib/qbittorrent.libsonnet index bcdb452..d9c0b9b 100644 --- a/milky-way/lib/qbittorrent.libsonnet +++ b/milky-way/lib/qbittorrent.libsonnet @@ -7,6 +7,13 @@ local images = import 'milky-way/lib/images.libsonnet'; // gluetun's killswitch makes it impossible for qbittorrent to egress except through the tunnel -- // the only thing reachable from outside is the WebUI, exposed via Tailscale L7 ingress. // +// Inbound peering / seeding: with no inbound path to the BitTorrent listen port, qbittorrent can +// only dial OUT to peers -- so as a seed it serves nobody (swarm leechers can't connect in). To be +// connectable we use a VPN provider that supports NAT-PMP port forwarding (ProtonVPN; NordVPN does +// NOT). gluetun requests a forwarded port and, on each (re)assignment, runs portForwardingUpCommand +// to push that port into qbittorrent's listen_port via the WebUI API. The forwarded port is DYNAMIC, +// so the seeded Session\Port below is just an initial value -- the live listen port follows gluetun. +// // Storage: config on iSCSI (RWO) -- qbittorrent rewrites qBittorrent.conf at runtime, so it's // seeded once (only-if-empty) into the PVC. Downloads live on a SHARED volume (the external // `mdata` RWX-NFS PVC, mounted at /data) under downloads/qbittorrent/ -- other apps mount the same @@ -14,12 +21,13 @@ local images = import 'milky-way/lib/images.libsonnet'; // owned by qbittorrent here. { new( - wireguardPrivateKey, // positional, required -> gluetun (NordVPN/WireGuard) + wireguardPrivateKey, // positional, required -> gluetun (ProtonVPN/WireGuard) name='qbittorrent', namespace='default', image=images.qbittorrent.fullyQualifiedImageReferencePinned, webuiPort=8080, tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net + vpnProvider='protonvpn', // must support port forwarding for inbound peers (see header) serverCountries='United States', configStorageClassName='my-custom-zfs-generic-iscsi', // RWO configStorageSize='5Gi', @@ -37,16 +45,36 @@ local images = import 'milky-way/lib/images.libsonnet'; local podCidr = '10.42.0.0/16', local svcCidr = '10.43.0.0/16', + // Commands gluetun runs when the forwarded port comes up / goes down: set qbittorrent's + // listen_port to gluetun's {{PORT}} via the WebUI API. They run inside the gluetun container, + // which shares qbittorrent's netns, so 127.0.0.1:webui reaches qbittorrent; and 127.0.0.0/8 is + // in AuthSubnetWhitelist below, so the API call needs no credentials. gluetun's image ships + // /bin/sh + wget. `sq` is a backslash-escaped double-quote: the JSON body must stay double-quoted + // for `sh -c` (the {..,..} would otherwise trigger brace expansion / word splitting). + local sq = '\\"', + local setPrefsUrl = 'http://127.0.0.1:%d/api/v2/app/setPreferences' % webuiPort, + local upBody = 'json={' + sq + 'listen_port' + sq + ':{{PORT}},' + + sq + 'random_port' + sq + ':false,' + sq + 'upnp' + sq + ':false}', + local downBody = 'json={' + sq + 'listen_port' + sq + ':0}', + local pfUpCommand = "/bin/sh -c 'wget -O- -nv --retry-connrefused --post-data \"" + + upBody + "\" " + setPrefsUrl + "'", + local pfDownCommand = "/bin/sh -c 'wget -O- -nv --retry-connrefused --post-data \"" + + downBody + "\" " + setPrefsUrl + "'", + // The VPN sidecar fragments, embedded into this pod below. vpn:: gluetun.new( wireguardPrivateKey=wireguardPrivateKey, name=name + '-gluetun', namespace=namespace, + vpnProvider=vpnProvider, vpnType='wireguard', serverCountries=serverCountries, controlPort=controlPort, firewallOutboundSubnets='%s,%s' % [podCidr, svcCidr], firewallInputPorts=[webuiPort, controlPort], + portForwarding=true, + portForwardingUpCommand=pfUpCommand, + portForwardingDownCommand=pfDownCommand, ), // Seed qBittorrent.conf. WebUI keys make the WebUI work behind the Tailscale proxy: @@ -62,7 +90,8 @@ local images = import 'milky-way/lib/images.libsonnet'; '', '[BitTorrent]', 'Session\\DefaultSavePath=%s' % downloadsPath, - 'Session\\Port=6881', + 'Session\\Port=6881', // initial only -- gluetun's portForwardingUpCommand overwrites this at runtime + '', '[Preferences]', 'WebUI\\Address=*', diff --git a/milky-way/lib/wireguard-conf.libsonnet b/milky-way/lib/wireguard-conf.libsonnet new file mode 100644 index 0000000..9dc3efb --- /dev/null +++ b/milky-way/lib/wireguard-conf.libsonnet @@ -0,0 +1,24 @@ +local utils = import 'milky-way/lib/utils.libsonnet'; + +// Minimal reader for a WireGuard `.conf` (INI format). Jsonnet has no std.parseIni, so we hand-parse +// the one field we need. We extract ONLY [Interface] PrivateKey -- a provider's generated .conf also +// carries a [Peer] block (server PublicKey/Endpoint) and an Address/DNS, but for gluetun's managed +// providers (e.g. protonvpn) gluetun builds the WireGuard config and selects the server itself, so +// those fields are deliberately ignored. The .conf is the single source of truth for just the key. +{ + // privateKeyOf(conf): the base64 WireGuard private key from the `PrivateKey = ...` line. + // NOTE: split on the FIRST '=' only -- a base64 key ends in '=' padding, so std.split(line, '=') + // would truncate it; std.splitLimit(line, '=', 1) keeps the padding intact. + privateKeyOf(conf):: + local vals = [ + std.stripChars(std.splitLimit(line, '=', 1)[1], ' \t\r') + for line in std.split(conf, '\n') + // Comment lines ("# ...") and the [Peer] block never start with PrivateKey, so they're skipped. + if std.startsWith(std.stripChars(line, ' \t'), 'PrivateKey') + ]; + utils.assertAndReturn( + vals, + function(v) std.length(v) == 1 && std.length(v[0]) > 0, + 'wireguard-conf: expected exactly one non-empty Interface.PrivateKey line in the .conf', + )[0], +}