From ac126bc0788df7b38b99121050697b591cd10fc9 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 16 Jul 2026 08:31:41 +0000 Subject: [PATCH] nixos: add userspace outbound proxy options Add per-instance `proxy` (socks/http/both) and `proxyListenAddress`. "both" shares one address; tailscaled splits SOCKS from HTTP. Assert addresses are unique across instances and install `tailscale-proxies` to list them. --- nix/nixos/instance.nix | 32 ++++++++++++++++++++ nix/nixos/service.nix | 66 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/nix/nixos/instance.nix b/nix/nixos/instance.nix index 8c162feb5..61284d299 100644 --- a/nix/nixos/instance.nix +++ b/nix/nixos/instance.nix @@ -71,6 +71,38 @@ in { ''; }; + proxy = mkOption { + type = types.nullOr (types.enum ["off" "socks" "http" "both"]); + default = null; + example = "socks"; + description = '' + Run an outbound proxy for accessing the tailnet without a TUN + interface (the usual access method in userspace networking mode, + also works in tun mode). + + `null` or `"off"` disables it. `"socks"` runs a SOCKS5 server, + `"http"` an outbound HTTP proxy, `"both"` runs both on the same + `proxyListenAddress` (tailscaled routes SOCKS-looking connections + to SOCKS and the rest to HTTP). + + Run `tailscale-proxies` on the host to list the active proxy + addresses across all instances. + + See + ''; + }; + + proxyListenAddress = mkOption { + type = types.str; + default = "localhost:1055"; + example = "0.0.0.0:1055"; + description = '' + `[ip]:port` the proxy binds to. Both proxies share this address + when `proxy = "both"`. Defaults to localhost to keep it off the + wire. Must be unique across enabled instances. + ''; + }; + permitCertUid = mkOption { type = types.nullOr types.nonEmptyStr; default = null; diff --git a/nix/nixos/service.nix b/nix/nixos/service.nix index a90992270..777ff9f42 100644 --- a/nix/nixos/service.nix +++ b/nix/nixos/service.nix @@ -91,6 +91,10 @@ self: { "--statedir=/var/lib/${meta.stateDir}" "--socket=${meta.socketPath}" ] + ++ optional (cfg.proxy == "socks" || cfg.proxy == "both") + "--socks5-server=${cfg.proxyListenAddress}" + ++ optional (cfg.proxy == "http" || cfg.proxy == "both") + "--outbound-http-proxy-listen=${cfg.proxyListenAddress}" ++ cfg.extraDaemonFlags; # Build auth key query parameters string. @@ -315,6 +319,42 @@ self: { ''; }; + # Whether an instance runs a proxy (null and "off" both mean disabled). + proxyEnabled = cfg: cfg.proxy != null && cfg.proxy != "off"; + + # All enabled instances running a proxy, as {instance,proxy,address} records. + proxyList = let + entry = enabled: name: cfg: + optional (enabled && proxyEnabled cfg) { + instance = name; + inherit (cfg) proxy; + address = cfg.proxyListenAddress; + }; + in + entry singularCfg.enable "default" singularCfg + ++ concatMap (n: entry pluralCfg.${n}.enable n pluralCfg.${n}) + (attrNames pluralCfg); + + proxyListJson = pkgs.writeText "tailscale-proxies.json" (builtins.toJSON proxyList); + + # `tailscale-proxies [--json]`: list active proxy addresses across instances. + proxyListPkg = pkgs.writeShellScriptBin "tailscale-proxies" '' + if [ "$1" = "--json" ]; then + cat ${proxyListJson} + echo + exit 0 + fi + ${pkgs.util-linux}/bin/column -t <<'EOF' + INSTANCE PROXY ADDRESS + ${concatStringsSep "\n" (map (p: "${p.instance} ${p.proxy} ${p.address}") proxyList)} + EOF + cat <<'EOF' + + # SOCKS5: socks5://ADDRESS HTTP: http://ADDRESS + # e.g. curl --proxy socks5://localhost:1055 https://host.your-tailnet.ts.net + EOF + ''; + # Generate the JSON config file for tailscale serve set-config --all. # Prepends "svc:" to each service name and omits `advertised` when null # to match the Go opt.Bool semantics (unset = advertised by default). @@ -426,7 +466,30 @@ in { } ] else [] - ) (builtins.attrNames pluralCfg); + ) (builtins.attrNames pluralCfg) + ++ (let + addrOf = enabled: c: + optional + (enabled + && proxyEnabled c + && !(lib.hasSuffix ":0" c.proxyListenAddress)) + c.proxyListenAddress; + proxyAddrs = + addrOf singularCfg.enable singularCfg + ++ concatMap (n: addrOf pluralCfg.${n}.enable pluralCfg.${n}) + (attrNames pluralCfg); + dupes = lib.unique (lib.filter ( + a: lib.count (x: x == a) proxyAddrs > 1 + ) proxyAddrs); + in [ + { + assertion = proxyAddrs == lib.unique proxyAddrs; + message = '' + Multiple Tailscale instances share a proxy listen address: ${concatStringsSep ", " dupes}. + Each instance with a proxy enabled needs a unique `proxyListenAddress`. + ''; + } + ]); # ── Systemd services (singular + plural merged) ── systemd.services = mkMerge [ @@ -448,6 +511,7 @@ in { ([cfg.package] ++ attrValues (mkCliWrapper name cfg)) ) pluralCfg)) + (mkIf (proxyList != []) [proxyListPkg]) ]; # ── DHCP exclusion (TUN mode only) ──