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 <config>
  tailscale serve advertise svc:<name>  # 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.
This commit is contained in:
Kristoffer Dalby 2026-03-05 16:57:35 +00:00 committed by Kristoffer Dalby
parent 08f84bad46
commit 75719c4fb0
No known key found for this signature in database
2 changed files with 125 additions and 1 deletions

View File

@ -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 <https://tailscale.com/docs/features/tailscale-services>
'';
example = literalExpression ''
{
prometheus = {
endpoints."tcp:443" = "http://localhost:9090";
};
postgres = {
endpoints."tcp:5432" = "tcp://localhost:5432";
};
}
'';
};
};
}

View File

@ -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 ──