1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.tailscale;
12 isNetworkd = config.networking.useNetworkd;
13in
14{
15 meta.maintainers = with maintainers; [
16 mbaillie
17 mfrw
18 ];
19
20 options.services.tailscale = {
21 enable = mkEnableOption "Tailscale client daemon";
22
23 port = mkOption {
24 type = types.port;
25 default = 41641;
26 description = "The port to listen on for tunnel traffic (0=autoselect).";
27 };
28
29 interfaceName = mkOption {
30 type = types.str;
31 default = "tailscale0";
32 description = ''The interface name for tunnel traffic. Use "userspace-networking" (beta) to not use TUN.'';
33 };
34
35 permitCertUid = mkOption {
36 type = types.nullOr types.nonEmptyStr;
37 default = null;
38 description = "Username or user ID of the user allowed to to fetch Tailscale TLS certificates for the node.";
39 };
40
41 disableTaildrop = mkOption {
42 default = false;
43 type = types.bool;
44 description = "Whether to disable the Taildrop feature for sending files between nodes.";
45 };
46
47 disableUpstreamLogging = mkOption {
48 default = false;
49 type = types.bool;
50 description = "Whether to disable Tailscaled from sending debug logging upstream.";
51 };
52
53 package = lib.mkPackageOption pkgs "tailscale" { };
54
55 openFirewall = mkOption {
56 default = false;
57 type = types.bool;
58 description = "Whether to open the firewall for the specified port.";
59 };
60
61 useRoutingFeatures = mkOption {
62 type = types.enum [
63 "none"
64 "client"
65 "server"
66 "both"
67 ];
68 default = "none";
69 example = "server";
70 description = ''
71 Enables settings required for Tailscale's routing features like subnet routers and exit nodes.
72
73 To use these these features, you will still need to call `sudo tailscale up` with the relevant flags like `--advertise-exit-node` and `--exit-node`.
74
75 When set to `client` or `both`, reverse path filtering will be set to loose instead of strict.
76 When set to `server` or `both`, IP forwarding will be enabled.
77 '';
78 };
79
80 authKeyFile = mkOption {
81 type = types.nullOr types.path;
82 default = null;
83 example = "/run/secrets/tailscale_key";
84 description = ''
85 A file containing the auth key.
86 Tailscale will be automatically started if provided.
87
88 Services that bind to Tailscale IPs should order using {option}`systemd.services.<name>.after` `tailscaled-autoconnect.service`.
89 '';
90 };
91
92 authKeyParameters = mkOption {
93 type = types.submodule {
94 options = {
95 ephemeral = mkOption {
96 type = types.nullOr types.bool;
97 default = null;
98 description = "Whether to register as an ephemeral node.";
99 };
100 preauthorized = mkOption {
101 type = types.nullOr types.bool;
102 default = null;
103 description = "Whether to skip manual device approval.";
104 };
105 baseURL = mkOption {
106 type = types.nullOr types.str;
107 default = null;
108 description = "Base URL for the Tailscale API.";
109 };
110 };
111 };
112 default = { };
113 description = ''
114 Extra parameters to pass after the auth key.
115 See <https://tailscale.com/kb/1215/oauth-clients#registering-new-nodes-using-oauth-credentials>
116 '';
117 };
118
119 extraUpFlags = mkOption {
120 description = ''
121 Extra flags to pass to {command}`tailscale up`. Only applied if {option}`services.tailscale.authKeyFile` is specified.
122 '';
123 type = types.listOf types.str;
124 default = [ ];
125 example = [ "--ssh" ];
126 };
127
128 extraSetFlags = mkOption {
129 description = "Extra flags to pass to {command}`tailscale set`.";
130 type = types.listOf types.str;
131 default = [ ];
132 example = [ "--advertise-exit-node" ];
133 };
134
135 extraDaemonFlags = mkOption {
136 description = "Extra flags to pass to {command}`tailscaled`.";
137 type = types.listOf types.str;
138 default = [ ];
139 example = [ "--no-logs-no-support" ];
140 };
141 };
142
143 config = mkIf cfg.enable {
144 environment.systemPackages = [ cfg.package ]; # for the CLI
145 systemd.packages = [ cfg.package ];
146 systemd.services.tailscaled = {
147 after = lib.mkIf (config.networking.networkmanager.enable) [ "NetworkManager-wait-online.service" ];
148 wantedBy = [ "multi-user.target" ];
149 path = [
150 (builtins.dirOf config.security.wrapperDir) # for `su` to use taildrive with correct access rights
151 pkgs.procps # for collecting running services (opt-in feature)
152 pkgs.getent # for `getent` to look up user shells
153 pkgs.kmod # required to pass tailscale's v6nat check
154 ]
155 ++ lib.optional config.networking.resolvconf.enable config.networking.resolvconf.package;
156 serviceConfig.Environment = [
157 "PORT=${toString cfg.port}"
158 ''"FLAGS=--tun ${lib.escapeShellArg cfg.interfaceName} ${lib.concatStringsSep " " cfg.extraDaemonFlags}"''
159 ]
160 ++ (lib.optionals (cfg.permitCertUid != null) [
161 "TS_PERMIT_CERT_UID=${cfg.permitCertUid}"
162 ])
163 ++ (lib.optionals (cfg.disableTaildrop) [
164 "TS_DISABLE_TAILDROP=true"
165 ])
166 ++ (lib.optionals (cfg.disableUpstreamLogging) [
167 "TS_NO_LOGS_NO_SUPPORT=true"
168 ]);
169 # Restart tailscaled with a single `systemctl restart` at the
170 # end of activation, rather than a `stop` followed by a later
171 # `start`. Activation over Tailscale can hang for tens of
172 # seconds in the stop+start setup, if the activation script has
173 # a significant delay between the stop and start phases
174 # (e.g. script blocked on another unit with a slow shutdown).
175 #
176 # Tailscale is aware of the correctness tradeoff involved, and
177 # already makes its upstream systemd unit robust against unit
178 # version mismatches on restart for compatibility with other
179 # linux distros.
180 stopIfChanged = false;
181 };
182
183 systemd.services.tailscaled-autoconnect = mkIf (cfg.authKeyFile != null) {
184 after = [ "tailscaled.service" ];
185 wants = [ "tailscaled.service" ];
186 wantedBy = [ "multi-user.target" ];
187 serviceConfig = {
188 Type = "notify";
189 };
190 path = [
191 cfg.package
192 pkgs.jq
193 ];
194 enableStrictShellChecks = true;
195 script =
196 let
197 paramToString = v: if (builtins.isBool v) then (lib.boolToString v) else (toString v);
198 params = lib.pipe cfg.authKeyParameters [
199 (lib.filterAttrs (_: v: v != null))
200 (lib.mapAttrsToList (k: v: "${k}=${paramToString v}"))
201 (builtins.concatStringsSep "&")
202 (params: if params != "" then "?${params}" else "")
203 ];
204 in
205 # bash
206 ''
207 getState() {
208 tailscale status --json --peers=false | jq -r '.BackendState'
209 }
210
211 lastState=""
212 while state="$(getState)"; do
213 if [[ "$state" != "$lastState" ]]; then
214 # https://github.com/tailscale/tailscale/blob/v1.72.1/ipn/backend.go#L24-L32
215 case "$state" in
216 NeedsLogin)
217 echo "Server needs authentication, sending auth key"
218 tailscale up --auth-key "$(cat ${cfg.authKeyFile})${params}" ${escapeShellArgs cfg.extraUpFlags}
219 ;;
220 Running)
221 echo "Tailscale is running"
222 systemd-notify --ready
223 exit 0
224 ;;
225 *)
226 echo "Waiting for Tailscale State = Running or systemd timeout"
227 ;;
228 esac
229 fi
230 echo "State = $state"
231 lastState="$state"
232 sleep .5
233 done
234 '';
235 };
236
237 systemd.services.tailscaled-set = mkIf (cfg.extraSetFlags != [ ]) {
238 after = [ "tailscaled.service" ];
239 wants = [ "tailscaled.service" ];
240 wantedBy = [ "multi-user.target" ];
241 serviceConfig = {
242 Type = "oneshot";
243 };
244 script = ''
245 ${lib.getExe cfg.package} set ${escapeShellArgs cfg.extraSetFlags}
246 '';
247 };
248
249 boot.kernel.sysctl = mkIf (cfg.useRoutingFeatures == "server" || cfg.useRoutingFeatures == "both") {
250 "net.ipv4.conf.all.forwarding" = mkOverride 97 true;
251 "net.ipv6.conf.all.forwarding" = mkOverride 97 true;
252 };
253
254 networking.firewall.allowedUDPPorts = mkIf cfg.openFirewall [ cfg.port ];
255
256 networking.firewall.checkReversePath = mkIf (
257 cfg.useRoutingFeatures == "client" || cfg.useRoutingFeatures == "both"
258 ) "loose";
259
260 networking.dhcpcd.denyInterfaces = [ cfg.interfaceName ];
261
262 systemd.network.networks."50-tailscale" = mkIf isNetworkd {
263 matchConfig = {
264 Name = cfg.interfaceName;
265 };
266 linkConfig = {
267 Unmanaged = true;
268 ActivationPolicy = "manual";
269 };
270 };
271 };
272}