From 150ea3f08db202a77aff676c972247947d6e0ce7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 5 Mar 2026 12:02:27 +0000 Subject: [PATCH] nixos: add multi-instance tailscale NixOS module Rewrite the NixOS module to support running multiple Tailscale daemons simultaneously on the same host (e.g., one connected to Tailscale SaaS, another to a Headscale instance). The module provides two interfaces: - services.tailscale (singular, backward-compatible, TUN mode default) - services.tailscales. (plural, userspace mode default) Both share a common submodule definition (instance.nix). Per-instance isolation is achieved through separate systemd services, state dirs, runtime dirs, sockets, and CLI wrapper scripts (tailscale-). Only one TUN-mode instance is allowed at a time due to hardcoded routing table 52, fwmarks, and iptables chain names in the Go source. The architecture follows the services.github-runners pattern from nixpkgs: a shared submodule reused by both singular and plural option paths. Co-authored-by: Mike O'Driscoll --- flake.nix | 32 ++- nixos/default.nix | 12 ++ nixos/instance.nix | 206 +++++++++++++++++++ nixos/options.nix | 66 ++++++ nixos/service.nix | 487 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 nixos/default.nix create mode 100644 nixos/instance.nix create mode 100644 nixos/options.nix create mode 100644 nixos/service.nix diff --git a/flake.nix b/flake.nix index e32cf3866..ef5b43f9c 100644 --- a/flake.nix +++ b/flake.nix @@ -1,3 +1,5 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause # flake.nix describes a Nix source repository that provides # development builds of Tailscale and the fork of the Go compiler # toolchain that Tailscale maintains. It also provides a development @@ -67,7 +69,12 @@ }) ]; })); + + # tailscaleRev is the git commit at which this flake was imported, + # or the empty string when building from a local checkout of the + # tailscale repo. tailscaleRev = self.rev or ""; + lib = nixpkgs.lib; in { # tailscale takes a nixpkgs package set, and builds Tailscale from # the same commit as this flake. IOW, it provides "tailscale built @@ -90,6 +97,8 @@ default = pkgs.buildGo126Module { name = "tailscale"; pname = "tailscale"; + meta.mainProgram = "tailscaled"; + src = ./.; vendorHash = pkgs.lib.fileContents ./go.mod.sri; nativeBuildInputs = [pkgs.makeWrapper pkgs.installShellFiles]; @@ -98,7 +107,6 @@ subPackages = [ "cmd/tailscale" "cmd/tailscaled" - "cmd/tsidp" ]; doCheck = false; @@ -131,6 +139,28 @@ tailscale = default; }); + overlays.default = final: prev: { + tailscale = self.packages.${prev.stdenv.hostPlatform.system}.default; + }; + + nixosModules = { + tailscale = import ./nixos/default.nix self; + # Module that disables upstream nixpkgs tailscale and uses this one. + # This is the recommended import for most users. + override = { + imports = [(import ./nixos/default.nix self)]; + # Disable both upstream modules: tailscale.nix defines individual + # options under services.tailscale.* that conflict with our submodule, + # and tailscale-derper.nix nests its options under services.tailscale.derper + # which forces evaluation of our submodule and causes infinite recursion. + disabledModules = [ + "services/networking/tailscale.nix" + "services/networking/tailscale-derper.nix" + ]; + }; + default = self.nixosModules.override; + }; + devShells = eachSystem (pkgs: { default = pkgs.mkShell { packages = with pkgs; [ diff --git a/nixos/default.nix b/nixos/default.nix new file mode 100644 index 000000000..fb32a5ddc --- /dev/null +++ b/nixos/default.nix @@ -0,0 +1,12 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Tailscale NixOS module entry point. +# Provides both single-instance (services.tailscale) and multi-instance +# (services.tailscales.) Tailscale daemon configuration. +self: { + imports = [ + (import ./options.nix self) + (import ./service.nix self) + ]; +} diff --git a/nixos/instance.nix b/nixos/instance.nix new file mode 100644 index 000000000..8748fea4d --- /dev/null +++ b/nixos/instance.nix @@ -0,0 +1,206 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Per-instance option definitions for a Tailscale daemon. +# Used as a submodule by both: +# services.tailscale (singular, backward-compatible) +# services.tailscales. (plural, attrsOf submodule) +# +# Receives `self`, `lib`, and `pkgs` from the importing module +# via closure (see options.nix). +{ + self, + lib, + pkgs, +}: let + inherit (lib) + literalExpression + mkEnableOption + mkOption + types + ; +in { + options = { + enable = mkEnableOption "Tailscale client daemon"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.tailscale; + defaultText = literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tailscale"; + description = "The Tailscale package to use."; + }; + + port = mkOption { + type = types.port; + default = 41641; + description = '' + UDP port for incoming WireGuard tunnel traffic. + Set to 0 for automatic port selection. + When running multiple instances, each must use a unique port or 0. + ''; + }; + + interfaceName = mkOption { + type = types.str; + default = "tailscale0"; + description = '' + Name of the TUN network interface. + Only used when `networkingMode` is `"tun"`. + Each TUN-mode instance must have a unique interface name. + ''; + }; + + networkingMode = mkOption { + type = types.enum ["tun" "userspace"]; + default = "tun"; + description = '' + Networking mode for this Tailscale instance. + + `"tun"` creates a TUN device and manages routes via the system + routing table. This provides full functionality (exit nodes, subnet + routers, MagicDNS, etc.) but only **one** TUN-mode instance can + run at a time due to hardcoded routing table number (52), fwmark + values, and iptables/nftables chain names in the Tailscale daemon. + + `"userspace"` does not create a network interface. Multiple + userspace instances can coexist safely. Userspace mode provides a + SOCKS5/HTTP proxy for accessing the tailnet instead of system-level + routing. + + See + ''; + }; + + permitCertUid = mkOption { + type = types.nullOr types.nonEmptyStr; + default = null; + description = '' + Username or UID allowed to fetch Tailnet TLS certificates + via `tailscale cert`. + ''; + }; + + disableTaildrop = mkOption { + type = types.bool; + default = false; + description = "Disable Tailscale Taildrop file sending/receiving."; + }; + + disableUpstreamLogging = mkOption { + type = types.bool; + default = false; + description = '' + Disable upstream debug logging to Tailscale's log server. + Equivalent to passing `--no-logs-no-support` to the daemon. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open the firewall for Tailscale's UDP port. + Recommended to allow direct WireGuard connections. + ''; + }; + + useRoutingFeatures = mkOption { + type = types.enum [ + "none" + "client" + "server" + "both" + ]; + default = "none"; + example = "server"; + description = '' + Enables settings required for Tailscale's routing features like + subnet routers and exit nodes. + + To use these features, you will still need to call + `tailscale up --advertise-exit-node` or similar. + + When set to `"client"` or `"both"`, reverse path filtering will be + set to loose instead of strict. + + When set to `"server"` or `"both"`, IP forwarding will be enabled. + + Only effective when `networkingMode` is `"tun"`. + + See + See + ''; + }; + + authKeyFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/tailscale_key"; + description = '' + Path to a file containing a Tailscale auth key. When set, the + instance will automatically authenticate on startup via a + `tailscaled-autoconnect` oneshot service. + ''; + }; + + authKeyParameters = mkOption { + description = '' + Extra parameters to append to the auth key. + See + ''; + type = types.submodule { + options = { + ephemeral = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to register as an ephemeral node."; + }; + preauthorized = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to skip manual device approval."; + }; + baseURL = mkOption { + type = types.nullOr types.str; + default = null; + description = "Base URL of the coordination server."; + }; + }; + }; + default = {}; + }; + + extraUpFlags = mkOption { + type = types.listOf types.str; + default = []; + example = ["--ssh" "--accept-routes"]; + description = '' + Extra flags to pass to `tailscale up`. + Only used when `authKeyFile` is set. + ''; + }; + + extraSetFlags = mkOption { + type = types.listOf types.str; + default = []; + example = ["--advertise-exit-node" "--shields-up"]; + description = "Extra flags to pass to `tailscale set`."; + }; + + extraDaemonFlags = mkOption { + type = types.listOf types.str; + default = []; + example = ["--verbose=1"]; + description = "Extra flags to pass to the `tailscaled` daemon."; + }; + + cleanup = mkOption { + type = types.bool; + default = true; + description = '' + Whether to run `tailscaled --cleanup` on service stop to remove + iptables rules, routes, and the TUN device. + ''; + }; + }; +} diff --git a/nixos/options.nix b/nixos/options.nix new file mode 100644 index 000000000..27b384bd1 --- /dev/null +++ b/nixos/options.nix @@ -0,0 +1,66 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Top-level option declarations for the Tailscale NixOS module. +# Declares both services.tailscale (singular) and services.tailscales (plural). +self: { + lib, + pkgs, + ... +}: let + inherit (lib) mkDefault mkOption types; + + instanceModule = import ./instance.nix {inherit self lib pkgs;}; +in { + options = { + services.tailscale = mkOption { + type = types.submodule { + imports = [instanceModule]; + }; + default = {}; + description = '' + Tailscale VPN client daemon (single instance). + Backward-compatible with the upstream nixpkgs module. + For running multiple Tailscale instances, use + `services.tailscales` instead. + ''; + }; + + services.tailscales = mkOption { + type = types.attrsOf (types.submodule ({name, ...}: { + imports = [instanceModule]; + # Override defaults for multi-instance safety. + config = { + port = mkDefault 0; + networkingMode = mkDefault "userspace"; + interfaceName = mkDefault "ts-${name}"; + }; + })); + default = {}; + description = '' + Multiple Tailscale VPN instances. Each attribute name becomes the + instance identifier, used to derive systemd service names, state + directories, and CLI wrapper names. + + Instances default to userspace networking mode to avoid conflicts + with resources shared by the TUN interface (routing table 52, + fwmarks, iptables chains). + + Example: + ```nix + services.tailscales = { + personal = { + enable = true; + authKeyFile = "/run/secrets/personal-ts-key"; + }; + work = { + enable = true; + authKeyFile = "/run/secrets/work-ts-key"; + extraUpFlags = [ "--login-server=https://hs.work.com" ]; + }; + }; + ``` + ''; + }; + }; +} diff --git a/nixos/service.nix b/nixos/service.nix new file mode 100644 index 000000000..17ecc01c6 --- /dev/null +++ b/nixos/service.nix @@ -0,0 +1,487 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# Config generation for the Tailscale NixOS module. +# Generates systemd services, networking config, and CLI wrappers +# for all enabled Tailscale instances (both singular and plural). +# +# IMPORTANT: This module's config must NOT use `mkMerge (mapAttrsToList ...)` +# at the top-level config structure, as that causes infinite recursion with +# the NixOS module system. Instead, each option path (systemd.services, +# environment.systemPackages, etc.) is assigned directly, with `mkMerge` +# used INSIDE the values to combine singular and plural instance contributions. +# This lets the module system determine which option paths are defined without +# evaluating any thunks that reference config.services.tailscale(s). +self: { + config, + lib, + pkgs, + ... +}: let + inherit (lib) + any + attrValues + boolToString + concatMap + concatStringsSep + escapeShellArg + escapeShellArgs + filterAttrs + mapAttrsToList + mkIf + mkMerge + mkOverride + optional + optionalAttrs + optionals + optionalString + pipe + ; + + singularCfg = config.services.tailscale; + pluralCfg = config.services.tailscales; + + # Derive all paths and names for an instance. + instanceMeta = name: let + isDefault = name == "default"; + in { + svcName = + if isDefault + then "tailscaled" + else "tailscaled-${name}"; + stateDir = + if isDefault + then "tailscale" + else "tailscale-${name}"; + runtimeDir = + if isDefault + then "tailscale" + else "tailscale-${name}"; + cacheDir = + if isDefault + then "tailscale" + else "tailscale-${name}"; + socketPath = + if isDefault + then "/run/tailscale/tailscaled.sock" + else "/run/tailscale-${name}/tailscaled.sock"; + wrapperName = + if isDefault + then null + else "tailscale-${name}"; + inherit isDefault; + }; + + # Build the --tun argument based on networking mode. + tunArg = cfg: + if cfg.networkingMode == "userspace" + then "userspace-networking" + else cfg.interfaceName; + + # Build the full daemon flags list. + daemonFlags = name: cfg: let + meta = instanceMeta name; + in + [ + "--tun=${tunArg cfg}" + "--statedir=/var/lib/${meta.stateDir}" + "--socket=${meta.socketPath}" + ] + ++ cfg.extraDaemonFlags; + + # Build auth key query parameters string. + # Only includes parameters the user explicitly set (non-null). + authKeyParams = cfg: + pipe cfg.authKeyParameters [ + (filterAttrs (_: v: v != null)) + (mapAttrsToList (k: v: + "${k}=${ + if (builtins.isBool v) + then (boolToString v) + else (toString v) + }")) + (builtins.concatStringsSep "&") + (params: + if params != "" + then "?${params}" + else "") + ]; + + # Generate systemd services for a single instance. + mkInstanceServices = name: cfg: let + meta = instanceMeta name; + flags = daemonFlags name cfg; + # For plural instances, merge shared services from the singular config. + # Per-instance services override shared definitions with the same name. + effServices = + if meta.isDefault + then cfg.services + else singularCfg.services // cfg.services; + in + { + # ── Main tailscaled daemon ── + ${meta.svcName} = { + description = + "Tailscale node agent" + + optionalString (!meta.isDefault) " (${name})"; + wantedBy = ["multi-user.target"]; + wants = ["network-pre.target"]; + after = + [ + "network-pre.target" + "NetworkManager.service" + "systemd-resolved.service" + ] + ++ optional + config.networking.networkmanager.enable + "NetworkManager-wait-online.service"; + + path = + [ + (builtins.dirOf config.security.wrapperDir) + pkgs.iproute2 + pkgs.iptables + pkgs.procps + pkgs.getent + pkgs.shadow + pkgs.kmod + ] + ++ optional + config.networking.resolvconf.enable + config.networking.resolvconf.package; + + serviceConfig = + { + ExecStart = concatStringsSep " " ([ + "${cfg.package}/bin/tailscaled" + "--port=${toString cfg.port}" + ] + ++ flags); + + Environment = + optionals (cfg.permitCertUid != null) [ + "TS_PERMIT_CERT_UID=${cfg.permitCertUid}" + ] + ++ optionals cfg.disableTaildrop [ + "TS_DISABLE_TAILDROP=true" + ] + ++ optionals cfg.disableUpstreamLogging [ + "TS_NO_LOGS_NO_SUPPORT=true" + ]; + + Restart = "on-failure"; + RuntimeDirectory = meta.runtimeDir; + RuntimeDirectoryMode = "0755"; + StateDirectory = meta.stateDir; + StateDirectoryMode = "0700"; + CacheDirectory = meta.cacheDir; + CacheDirectoryMode = "0750"; + Type = "notify"; + } + // optionalAttrs cfg.cleanup { + ExecStopPost = "${cfg.package}/bin/tailscaled --cleanup"; + }; + + stopIfChanged = false; + }; + } + # ── Auto-connect service (when authKeyFile is set) ── + // optionalAttrs (cfg.authKeyFile != null) { + "${meta.svcName}-autoconnect" = { + description = + "Tailscale auto-connect" + + optionalString (!meta.isDefault) " (${name})"; + after = ["${meta.svcName}.service"]; + wants = ["${meta.svcName}.service"]; + wantedBy = ["multi-user.target"]; + + serviceConfig = { + Type = "notify"; + }; + + path = [cfg.package pkgs.jq]; + + enableStrictShellChecks = true; + + script = let + socket = escapeShellArg meta.socketPath; + params = authKeyParams cfg; + flagsStr = escapeShellArgs cfg.extraUpFlags; + in '' + getState() { + tailscale --socket=${socket} status --json --peers=false | jq -r '.BackendState' + } + + lastState="" + while state="$(getState)"; do + if [[ "$state" != "$lastState" ]]; then + # https://github.com/tailscale/tailscale/blob/v1.72.1/ipn/backend.go#L24-L32 + case "$state" in + NeedsLogin|NeedsMachineAuth|Stopped) + echo "Server needs authentication, sending auth key" + tailscale --socket=${socket} up --auth-key "$(cat ${escapeShellArg (toString cfg.authKeyFile)})${params}" ${flagsStr} + ;; + Running) + echo "Tailscale is running" + systemd-notify --ready + exit 0 + ;; + *) + echo "Waiting for Tailscale State = Running or systemd timeout" + ;; + esac + echo "State = $state" + fi + lastState="$state" + sleep .5 + done + ''; + }; + } + # ── Set service (when extraSetFlags is set) ── + // optionalAttrs (cfg.extraSetFlags != []) { + "${meta.svcName}-set" = { + description = + "Tailscale set" + + optionalString (!meta.isDefault) " (${name})"; + after = [ + "${meta.svcName}.service" + "${meta.svcName}-autoconnect.service" + ]; + wants = ["${meta.svcName}.service"]; + wantedBy = ["multi-user.target"]; + + serviceConfig.Type = "oneshot"; + path = [cfg.package]; + + script = '' + tailscale --socket=${escapeShellArg meta.socketPath} set ${escapeShellArgs cfg.extraSetFlags} + ''; + }; + }; + + # Generate a CLI wrapper package for a named instance. + mkCliWrapper = name: cfg: let + meta = instanceMeta name; + in + optionalAttrs (meta.wrapperName != null) { + ${meta.wrapperName} = pkgs.writeShellScriptBin meta.wrapperName '' + exec ${cfg.package}/bin/tailscale --socket=${escapeShellArg meta.socketPath} "$@" + ''; + }; +in { + config = mkMerge [ + # ── Option paths are statically visible here ── + # The module system can see keys (assertions, systemd, environment, + # networking) without evaluating any thunks that reference + # config.services.tailscale(s), avoiding infinite recursion. + { + # ── Assertions ── + # All assertion VALUES are lazy thunks; only evaluated when + # config.assertions is accessed (after all options are resolved). + assertions = let + singularTun = + singularCfg.enable + && singularCfg.networkingMode == "tun"; + pluralTunNames = lib.filter ( + name: let + inst = pluralCfg.${name}; + in + inst.enable && inst.networkingMode == "tun" + ) (builtins.attrNames pluralCfg); + allTunNames = + ( + if singularTun + then ["default"] + else [] + ) + ++ pluralTunNames; + hasConflictingDefault = + singularCfg.enable + && ( + if pluralCfg ? "default" + then pluralCfg.default.enable + else false + ); + in + [ + { + assertion = builtins.length allTunNames <= 1; + message = '' + At most one Tailscale instance can use TUN networking mode. + Configured TUN-mode instances: ${concatStringsSep ", " allTunNames}. + + The Tailscale daemon uses hardcoded routing table 52, fwmark + values (0x40000/0x80000), and iptables chain names (ts-forward, + ts-input, ts-postrouting) that conflict when multiple + TUN-mode instances run simultaneously. + + Set `networkingMode = "userspace"` for additional instances. + ''; + } + { + assertion = !hasConflictingDefault; + message = '' + Cannot enable both `services.tailscale` and + `services.tailscales.default` simultaneously. They create + conflicting instances with the same service name and paths. + ''; + } + ] + ++ ( + if singularCfg.enable + then [ + { + assertion = + singularCfg.networkingMode == "tun" + || singularCfg.useRoutingFeatures == "none"; + message = '' + services.tailscale: `useRoutingFeatures` requires + `networkingMode = "tun"`. Userspace networking does not + support IP forwarding or subnet routing. + ''; + } + ] + else [] + ) + ++ concatMap ( + name: let + cfg = pluralCfg.${name}; + in + if cfg.enable + then [ + { + assertion = + cfg.networkingMode == "tun" + || cfg.useRoutingFeatures == "none"; + message = '' + services.tailscales.${name}: `useRoutingFeatures` requires + `networkingMode = "tun"`. Userspace networking does not + support IP forwarding or subnet routing. + ''; + } + ] + else [] + ) (builtins.attrNames pluralCfg); + + # ── Systemd services (singular + plural merged) ── + systemd.services = mkMerge [ + (mkIf singularCfg.enable + (mkInstanceServices "default" singularCfg)) + (mkMerge (mapAttrsToList ( + name: cfg: + mkIf cfg.enable (mkInstanceServices name cfg) + ) + pluralCfg)) + ]; + + # ── Packages ── + environment.systemPackages = mkMerge [ + (mkIf singularCfg.enable [singularCfg.package]) + (mkMerge (mapAttrsToList ( + name: cfg: + mkIf cfg.enable + ([cfg.package] ++ attrValues (mkCliWrapper name cfg)) + ) + pluralCfg)) + ]; + + # ── DHCP exclusion (TUN mode only) ── + networking.dhcpcd.denyInterfaces = mkMerge [ + (mkIf + (singularCfg.enable && singularCfg.networkingMode == "tun") + [singularCfg.interfaceName]) + (mkMerge (mapAttrsToList ( + name: cfg: + mkIf (cfg.enable && cfg.networkingMode == "tun") + [cfg.interfaceName] + ) + pluralCfg)) + ]; + + # ── Firewall ── + networking.firewall.allowedUDPPorts = mkMerge [ + (mkIf + (singularCfg.enable && singularCfg.openFirewall && singularCfg.port != 0) + [singularCfg.port]) + (mkMerge (mapAttrsToList ( + name: cfg: + mkIf (cfg.enable && cfg.openFirewall && cfg.port != 0) + [cfg.port] + ) + pluralCfg)) + ]; + + # ── systemd-networkd (TUN mode only) ── + systemd.network.networks = mkMerge [ + (mkIf + (singularCfg.enable + && config.networking.useNetworkd + && singularCfg.networkingMode == "tun") + { + "50-tailscale-default" = { + matchConfig.Name = singularCfg.interfaceName; + linkConfig = { + Unmanaged = true; + ActivationPolicy = "manual"; + }; + }; + }) + (mkMerge (mapAttrsToList ( + name: cfg: + mkIf + (cfg.enable + && config.networking.useNetworkd + && cfg.networkingMode == "tun") + { + "50-tailscale-${name}" = { + matchConfig.Name = cfg.interfaceName; + linkConfig = { + Unmanaged = true; + ActivationPolicy = "manual"; + }; + }; + } + ) + pluralCfg)) + ]; + } + + # ── Global IP forwarding (conditional on any instance needing it) ── + (mkIf (let + singularNeeds = + singularCfg.enable + && (singularCfg.useRoutingFeatures == "server" + || singularCfg.useRoutingFeatures == "both"); + pluralNeeds = any + (inst: + inst.enable + && (inst.useRoutingFeatures == "server" + || inst.useRoutingFeatures == "both")) + (attrValues pluralCfg); + in + singularNeeds || pluralNeeds) { + boot.kernel.sysctl = { + "net.ipv4.conf.all.forwarding" = mkOverride 97 true; + "net.ipv6.conf.all.forwarding" = mkOverride 97 true; + }; + }) + + # ── Reverse path filtering (conditional) ── + (mkIf (let + singularNeeds = + singularCfg.enable + && (singularCfg.useRoutingFeatures == "client" + || singularCfg.useRoutingFeatures == "both"); + pluralNeeds = any + (inst: + inst.enable + && (inst.useRoutingFeatures == "client" + || inst.useRoutingFeatures == "both")) + (attrValues pluralCfg); + in + singularNeeds || pluralNeeds) { + networking.firewall.checkReversePath = "loose"; + }) + ]; +}