From 75719c4fb0ed5799e3515945bd9a40d998d879fa Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 5 Mar 2026 16:57:35 +0000 Subject: [PATCH] nixos: add Tailscale Services support Add a `services` option to the per-instance submodule for declarative Tailscale Services configuration. Service names are specified without the `svc:` prefix (added automatically in the generated JSON). The module generates a JSON config file matching the v0.0.1 spec and applies it via a oneshot systemd service that runs: tailscale serve set-config --all tailscale serve advertise svc: # for each service The `advertised` field uses nullOr bool to match the Go opt.Bool semantics: omitted from JSON when unset (meaning advertised by default), included only when explicitly set. The serve-config service orders after both the daemon and autoconnect services, polls until tailscaled is ready, uses restartTriggers to re-apply on config changes, and enables strict shell checks. --- nixos/instance.nix | 60 +++++++++++++++++++++++++++++++++++++++++ nixos/service.nix | 66 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/nixos/instance.nix b/nixos/instance.nix index 8748fea4d..8c162feb5 100644 --- a/nixos/instance.nix +++ b/nixos/instance.nix @@ -202,5 +202,65 @@ in { iptables rules, routes, and the TUN device. ''; }; + + services = mkOption { + type = types.attrsOf (types.submodule { + options = { + endpoints = mkOption { + type = types.attrsOf types.str; + description = '' + Endpoint mappings for this service. + + Keys use the format `"tcp:PORT"` or `"tcp:START-END"` for + port ranges. Only TCP is supported as the transport protocol. + + Values specify the local target using a URI: + `"http://host:port"`, `"https://host:port"`, + `"https+insecure://host:port"`, `"tcp://host:port"`, + or `"tls-terminated-tcp://host:port"`. + ''; + example = { + "tcp:443" = "https://localhost:8443"; + "tcp:5432" = "tcp://localhost:5432"; + }; + }; + + advertised = mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + Whether the service accepts new connections. + When null (the default), the service is advertised. + Set to false to configure endpoints without advertising. + ''; + }; + }; + }); + default = {}; + description = '' + Tailscale Services configuration. Service names are specified + without the `svc:` prefix (it is added automatically). + + When using `services.tailscales` (multi-instance), any services + defined under `services.tailscale.services` are automatically + merged into all plural instances as shared services. Per-instance + services with the same name take precedence over shared ones. + + Services must be pre-defined in the Tailscale admin console. + The host must use tag-based identity. + + See + ''; + example = literalExpression '' + { + prometheus = { + endpoints."tcp:443" = "http://localhost:9090"; + }; + postgres = { + endpoints."tcp:5432" = "tcp://localhost:5432"; + }; + } + ''; + }; }; } diff --git a/nixos/service.nix b/nixos/service.nix index 17ecc01c6..a90992270 100644 --- a/nixos/service.nix +++ b/nixos/service.nix @@ -20,17 +20,21 @@ self: { }: let inherit (lib) any + attrNames attrValues boolToString concatMap + concatMapStringsSep concatStringsSep escapeShellArg escapeShellArgs filterAttrs + mapAttrs' mapAttrsToList mkIf mkMerge mkOverride + nameValuePair optional optionalAttrs optionals @@ -258,7 +262,48 @@ self: { tailscale --socket=${escapeShellArg meta.socketPath} set ${escapeShellArgs cfg.extraSetFlags} ''; }; - }; + } + # ── Serve config service (when services are defined) ── + // optionalAttrs (effServices != {}) (let + configFile = mkServeConfigFile name effServices; + socket = escapeShellArg meta.socketPath; + svcNames = attrNames effServices; + in { + "${meta.svcName}-serve-config" = { + description = + "Tailscale serve config" + + optionalString (!meta.isDefault) " (${name})"; + after = [ + "${meta.svcName}.service" + "${meta.svcName}-autoconnect.service" + ]; + wants = ["${meta.svcName}.service"]; + wantedBy = ["multi-user.target"]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + + path = [cfg.package]; + + enableStrictShellChecks = true; + + restartTriggers = [configFile]; + + script = '' + until tailscale --socket=${socket} status &>/dev/null; do + sleep 1 + done + + tailscale --socket=${socket} serve set-config --all ${configFile} + + ${concatMapStringsSep "\n" (svcName: + "tailscale --socket=${socket} serve advertise svc:${escapeShellArg svcName}") + svcNames} + ''; + }; + }); # Generate a CLI wrapper package for a named instance. mkCliWrapper = name: cfg: let @@ -269,6 +314,25 @@ self: { exec ${cfg.package}/bin/tailscale --socket=${escapeShellArg meta.socketPath} "$@" ''; }; + + # 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). + mkServeConfigFile = name: servicesAttr: let + servicesJson = { + version = "0.0.1"; + services = mapAttrs' (svcName: svcCfg: + nameValuePair "svc:${svcName}" ( + {inherit (svcCfg) endpoints;} + // optionalAttrs (svcCfg.advertised != null) { + inherit (svcCfg) advertised; + } + )) + servicesAttr; + }; + in + pkgs.writeText "tailscale-services-${name}.json" + (builtins.toJSON servicesJson); in { config = mkMerge [ # ── Option paths are statically visible here ──