{ config, lib, pkgs, ... }: with lib; let cfg = config.services.tailscale-netns; instanceOptions = { name, ... }: { options = { authKeyFile = mkOption { type = types.nullOr types.path; default = null; description = "Path to file containing a Tailscale auth key."; }; hostname = mkOption { type = types.nullOr types.str; default = null; description = '' Tailscale hostname for this instance. Defaults to "-". ''; }; advertiseRoutes = mkOption { type = types.listOf types.str; default = [ ]; description = "Subnet routes to advertise (e.g. [ \"10.0.0.0/24\" ])."; }; dns = mkOption { type = types.nullOr types.str; default = null; description = '' Bootstrap DNS server for the namespace before Tailscale is connected. Defaults to `1.1.1.1`. Only used for the initial coordination server connection; once Tailscale is up it manages DNS inside the namespace. ''; }; advertiseExitNode = mkOption { type = types.bool; default = false; description = "Whether to advertise this node as an exit node."; }; acceptRoutes = mkOption { type = types.bool; default = false; description = "Whether to accept routes advertised by other nodes."; }; exitNode = mkOption { type = types.nullOr types.str; default = null; description = "Tailscale IP or hostname of exit node to use."; }; extraUpFlags = mkOption { type = types.listOf types.str; default = [ ]; description = "Extra flags passed to `tailscale up`."; }; extraDaemonFlags = mkOption { type = types.listOf types.str; default = [ ]; description = "Extra flags passed to `tailscaled`."; }; }; }; mkUpFlags = instanceCfg: let hostFlag = optional ( instanceCfg.hostname != null ) "--hostname=${escapeShellArg instanceCfg.hostname}"; routeFlags = map (r: "--advertise-routes=${escapeShellArg r}") instanceCfg.advertiseRoutes; exitFlag = optional instanceCfg.advertiseExitNode "--advertise-exit-node"; acceptFlag = optional instanceCfg.acceptRoutes "--accept-routes"; exitNodeFlag = optional ( instanceCfg.exitNode != null ) "--exit-node=${escapeShellArg instanceCfg.exitNode}"; in hostFlag ++ routeFlags ++ exitFlag ++ acceptFlag ++ exitNodeFlag ++ instanceCfg.extraUpFlags; mkAutoconnectScript = name: instanceCfg: pkgs.writers.writeBash "tailscale-autoconnect-${name}" '' set -euo pipefail TS="${pkgs.tailscale}/bin/tailscale" SOCKET="/run/tailscale-${name}/tailscaled.sock" TS_ARGS="--socket $SOCKET" get_state() { $TS $TS_ARGS status --json --peers=false 2>/dev/null | ${pkgs.jq}/bin/jq -r '.BackendState // "NoState"' } last_state="" while state="$(get_state)"; do if [[ "$state" != "$last_state" ]]; then case "$state" in NeedsLogin|NeedsMachineAuth|Stopped) echo "[tailscale-netns:${name}] server needs authentication, sending auth key" $TS $TS_ARGS up \ --auth-key "$(cat ${instanceCfg.authKeyFile})" \ ${escapeShellArgs (mkUpFlags instanceCfg)} ;; Running) echo "[tailscale-netns:${name}] tailscale is running" ${pkgs.systemd}/bin/systemd-notify --ready exit 0 ;; NoState) echo "[tailscale-netns:${name}] tailscaled not ready yet" ;; *) echo "[tailscale-netns:${name}] waiting for Running (state=$state)" ;; esac fi last_state="$state" sleep 1 done ''; mkNetnsSetup = index: name: instanceCfg: let ns = "ts-${name}ns"; hostIP = "10.201.${toString index}.1/24"; nsIP = "10.201.${toString index}.2/24"; vethHost = "veth-ts-${name}"; vethNS = "veth-ts-${name}-ns"; in { "netns@ts-${name}" = { description = "Network namespace for Tailscale instance ${name}"; before = [ "network.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = pkgs.writers.writeBash "ts-${name}-netns-up" '' set -euo pipefail ${pkgs.coreutils}/bin/mkdir -p /etc/netns/${ns} # Build resolv.conf for the netns. Skip loopback addresses # (systemd-resolved stub at 127.0.0.53 is not reachable from the netns). NS_DNS="${if instanceCfg.dns != null then instanceCfg.dns else "1.1.1.1"}" { ${pkgs.gawk}/bin/awk '$1 == "nameserver" && $2 !~ /^127\./ && $2 != "::1" {print}' /etc/resolv.conf echo "nameserver $NS_DNS" } > /etc/netns/${ns}/resolv.conf ${pkgs.iproute2}/bin/ip netns add ${ns} ${pkgs.iproute2}/bin/ip link add ${vethHost} type veth peer name ${vethNS} ${pkgs.iproute2}/bin/ip link set ${vethNS} netns ${ns} ${pkgs.iproute2}/bin/ip addr add ${hostIP} dev ${vethHost} ${pkgs.iproute2}/bin/ip link set ${vethHost} up ${pkgs.iproute2}/bin/ip -n ${ns} addr add ${nsIP} dev ${vethNS} ${pkgs.iproute2}/bin/ip -n ${ns} link set ${vethNS} up ${pkgs.iproute2}/bin/ip -n ${ns} link set lo up ${pkgs.iptables}/bin/iptables -t nat -C POSTROUTING -s ${nsIP} -j MASQUERADE 2>/dev/null \ || ${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -s ${nsIP} -j MASQUERADE ''; ExecStop = pkgs.writers.writeBash "ts-${name}-netns-down" '' set -euo pipefail ${pkgs.iptables}/bin/iptables -t nat -D POSTROUTING -s ${nsIP} -j MASQUERADE 2>/dev/null || true ${pkgs.iproute2}/bin/ip link del ${vethHost} 2>/dev/null || true ${pkgs.iproute2}/bin/ip netns del ${ns} 2>/dev/null || true ''; }; }; "tailscaled-${name}" = { description = "Tailscale daemon for instance ${name}"; bindsTo = [ "netns@ts-${name}.service" ]; after = [ "netns@ts-${name}.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = pkgs.writers.writeBash "tailscaled-${name}-start" '' exec ${pkgs.iproute2}/bin/ip netns exec ${ns} \ ${pkgs.tailscale}/bin/tailscaled \ --statedir=/var/lib/tailscale-${name} \ --socket=/run/tailscale-${name}/tailscaled.sock \ --tun=ts-${name} \ --port=41641 \ ${escapeShellArgs instanceCfg.extraDaemonFlags} ''; ExecStopPost = pkgs.writers.writeBash "tailscaled-${name}-stop" '' ${pkgs.iproute2}/bin/ip netns exec ${ns} \ ${pkgs.tailscale}/bin/tailscaled --cleanup \ --statedir=/var/lib/tailscale-${name} \ --socket=/run/tailscale-${name}/tailscaled.sock \ 2>/dev/null || true ''; Restart = "on-failure"; RuntimeDirectory = "tailscale-${name}"; RuntimeDirectoryMode = "0755"; StateDirectory = "tailscale-${name}"; StateDirectoryMode = "0700"; CacheDirectory = "tailscale-${name}"; CacheDirectoryMode = "0750"; AmbientCapabilities = "CAP_NET_ADMIN CAP_NET_RAW"; LimitNOFILE = "infinity"; }; }; }; in { options.services.tailscale-netns = { enable = mkEnableOption "Tailscale instances in isolated network namespaces"; instances = mkOption { type = types.attrsOf (types.submodule instanceOptions); default = { }; description = "Tailscale instances configured in separate network namespaces."; example = literalExpression '' { work = { authKeyFile = "/run/secrets/ts-work/authkey"; acceptRoutes = true; }; } ''; }; }; config = mkIf cfg.enable { systemd.services = listToAttrs ( flatten ( imap1 ( index: elem: let name = elem.name; instanceCfg = elem.value; units = mkNetnsSetup index name instanceCfg; in [ units."netns@ts-${name}" units."tailscaled-${name}" ] ++ optional (instanceCfg.authKeyFile != null) { "tailscaled-autoconnect-${name}" = { description = "Tailscale autoconnect for instance ${name}"; after = [ "tailscaled-${name}.service" ]; requires = [ "tailscaled-${name}.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "notify"; }; script = "${mkAutoconnectScript name instanceCfg}"; }; } ) (attrsToList cfg.instances) ) ); }; }