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.
This commit is contained in:
Kristoffer Dalby 2026-07-16 08:31:41 +00:00
parent ae78ca5433
commit ac126bc078
2 changed files with 97 additions and 1 deletions

View File

@ -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 <https://tailscale.com/kb/1112/userspace-networking>
'';
};
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;

View File

@ -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) ──