mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
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.<name> (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-<name>). 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 <mikeo@tailscale.com>
This commit is contained in:
parent
54606a0a89
commit
150ea3f08d
32
flake.nix
32
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; [
|
||||
|
||||
12
nixos/default.nix
Normal file
12
nixos/default.nix
Normal file
@ -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.<name>) Tailscale daemon configuration.
|
||||
self: {
|
||||
imports = [
|
||||
(import ./options.nix self)
|
||||
(import ./service.nix self)
|
||||
];
|
||||
}
|
||||
206
nixos/instance.nix
Normal file
206
nixos/instance.nix
Normal file
@ -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.<n> (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 <https://tailscale.com/kb/1112/userspace-networking>
|
||||
'';
|
||||
};
|
||||
|
||||
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 <https://tailscale.com/kb/1019/subnets#enable-ip-forwarding>
|
||||
See <https://github.com/tailscale/tailscale/issues/3310>
|
||||
'';
|
||||
};
|
||||
|
||||
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 <https://tailscale.com/kb/1215/oauth-clients#registering-new-nodes-using-oauth-credentials>
|
||||
'';
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
66
nixos/options.nix
Normal file
66
nixos/options.nix
Normal file
@ -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" ];
|
||||
};
|
||||
};
|
||||
```
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
487
nixos/service.nix
Normal file
487
nixos/service.nix
Normal file
@ -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";
|
||||
})
|
||||
];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user