diff --git a/modules/hosts/nix-server/configuration.nix b/modules/hosts/nix-server/configuration.nix index 1dac5e8..93ddb60 100644 --- a/modules/hosts/nix-server/configuration.nix +++ b/modules/hosts/nix-server/configuration.nix @@ -50,6 +50,9 @@ }; sops.secrets."users/server/hashedPassword".neededForUsers = true; + sops.secrets."tailscale/auth-key" = { + mode = "0400"; + }; security.sudo.wheelNeedsPassword = true; @@ -63,6 +66,11 @@ networking = { hostName = "nix-server"; networkManager.enable = true; + tailscale = { + enable = true; + authKeyFile = config.sops.secrets."tailscale/auth-key".path; + subnetRouter.enable = true; + }; }; caching.attic = { diff --git a/modules/hosts/t2mbp/configuration.nix b/modules/hosts/t2mbp/configuration.nix index 57596b7..7f08250 100644 --- a/modules/hosts/t2mbp/configuration.nix +++ b/modules/hosts/t2mbp/configuration.nix @@ -38,6 +38,9 @@ group = "users"; mode = "0400"; }; + sops.secrets."tailscale/auth-key" = { + mode = "0400"; + }; chiasson.system.librepods.enable = true; chiasson.system.palera1n.enable = true; @@ -106,6 +109,11 @@ networking = { hostName = "t2mbp"; networkManager.enable = true; + tailscale = { + enable = true; + authKeyFile = config.sops.secrets."tailscale/auth-key".path; + acceptRoutes = true; + }; }; }; diff --git a/modules/system/default.nix b/modules/system/default.nix index 89af1f8..9b634fb 100644 --- a/modules/system/default.nix +++ b/modules/system/default.nix @@ -5,6 +5,7 @@ self.nixosModules.systemLocalization self.nixosModules.systemFonts self.nixosModules.systemNetworking + self.nixosModules.systemTailscale self.nixosModules.systemLocalsend self.nixosModules.systemChromiumHevcVaapi self.nixosModules.systemMonitorInput diff --git a/modules/system/tailscale.nix b/modules/system/tailscale.nix new file mode 100644 index 0000000..b415110 --- /dev/null +++ b/modules/system/tailscale.nix @@ -0,0 +1,110 @@ +{ ... }: { + flake.nixosModules.systemTailscale = + { config, lib, ... }: + let + cfg = config.chiasson.system.networking.tailscale; + tsCfg = config.services.tailscale; + in + { + options.chiasson.system.networking.tailscale = { + enable = lib.mkEnableOption '' + Tailscale mesh VPN. Joins this host to your tailnet for encrypted access + from anywhere without opening inbound ports on your router. + ''; + + authKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/tailscale/auth-key"; + description = '' + Reusable Tailscale auth key (sops path). Create one at + https://login.tailscale.com/admin/settings/keys — enable Reusable and + Pre-approved. Store under `tailscale/auth-key` in secrets/secrets.yaml. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Allow Tailscale UDP 41641 through the host firewall."; + }; + + acceptRoutes = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Accept subnet routes advertised by other tailnet nodes (e.g. home LAN + via nix-server). Enable on portable clients like t2mbp. + ''; + }; + + subnetRouter = { + enable = lib.mkEnableOption '' + Advertise the home LAN through this node so other tailnet devices can + reach local IPs (SSH, Attic, Gitea, Jellyfin, etc.). Enable on an + always-on home host (nix-server). Approve the route in the Tailscale + admin console after first connect. + ''; + + routes = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "192.168.2.0/24" ]; + example = [ "192.168.2.0/24" ]; + description = "Subnets to advertise to the tailnet."; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + services.tailscale = { + enable = true; + openFirewall = cfg.openFirewall; + authKeyFile = cfg.authKeyFile; + }; + + networking.firewall.trustedInterfaces = lib.mkAfter [ "tailscale0" ]; + } + (lib.mkIf cfg.acceptRoutes { + services.tailscale.useRoutingFeatures = "client"; + services.tailscale.extraUpFlags = [ "--accept-routes" ]; + }) + (lib.mkIf cfg.subnetRouter.enable { + services.tailscale.useRoutingFeatures = "both"; + services.tailscale.extraUpFlags = + map (route: "--advertise-routes=${route}") cfg.subnetRouter.routes; + }) + (lib.mkIf (cfg.authKeyFile != null) { + # Upstream uses `$(cat $authKeyFile)` which breaks when sops leaves a trailing newline. + systemd.services.tailscaled-autoconnect.script = lib.mkOverride 50 '' + getState() { + tailscale status --json --peers=false | jq -r '.BackendState' + } + + lastState="" + while state="$(getState)"; do + if [[ "$state" != "$lastState" ]]; then + case "$state" in + NeedsLogin|NeedsMachineAuth|Stopped) + echo "Server needs authentication, sending auth key" + tailscale up --auth-key "$(tr -d '\n\r' < ${cfg.authKeyFile})" ${lib.escapeShellArgs tsCfg.extraUpFlags} + ;; + 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 + ''; + }) + ]); + }; +} diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index aa9ebab..edf5baf 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -10,6 +10,8 @@ cachix: caching: attic: token: ENC[AES256_GCM,data:8omssG3GwCFIegfz+8IAGGhFGj01RB3dqqHeFpmZOzMJUshIDvSRuTTpGFhUBC7Xue8h09hAhpirIHmqzyG3I+e2Se/VZZoByXmpyIKesl3+NqOXDkJvgImqhvFVkTiSe5p/vSN3slWDylfkThQ0hZYw5mB9J13M5965iUnWcRbg+1fYFdTuSgrHY8Rxt4da0287A0YGnsN63k7j32XOJndxsRoOLoo+IQ+X+hiPOJkfGYxY0MglnxaxhPwH8SP1V+p78N75Z2npOtMdEikdHmj/NmKbqUXN2P0+IXthxV17WePCulZVsKC1Jw+clgbyAvHcQeVG/yyrcb1CRRQpszHtq1Pz7DHvfAG+gxyPNyP7D6oQNT8foX4C6CwuHgYQtM1x0D6oAL+lppQWJ0kEV/GDlJSXQnp/aBbVAqDmqS0TCx40nVmQ0PvMcjtsiZJigkRJRNLCg6n+qmhc5Rh9RhslPN5JXU0orWs9QYAoLXzdDDGP/R9tlEhwQBxwGrFAp016iilqPavMdI8txrWWdvezQuAh//eeW5LQSa6t363VCjX8phnXeJltOgXYlyuKnCCmv0a6XwhmT0PA+32/F0BxTf9lcZConpurlvOHdznaVeUXcFOEwKouDC7smPIZZqcRU8OIbWs7YXqMgatgb/bJVtB0P0Avsj9t9Uz8Dv8xBV+90U5qwM7HV16FIERorDquzgKFcvtb8/QfjTINoswpHZKNCbmQPxfJYPheJFwMQGFn+b+ecv+Z7qng9JEujJSNtEPv2CIuVmSxZJaU5g2CMu3rFGIA3qF81Bf1Ri8n+KYWgOKpQt11nClouv2XePO8JKI6fslF411zJ8zD4E/6Qg95UWhLh0RG2cXzYSXXvrpXDlIe9spc4OLuj4tFtXkiZZvfM5MgRTtoh93soypUpEbswTji2UprC3OPikjIIW49YysGVsH2100/67HbtinRoazM1M+DjaD2pMryx7kW/oVpyaW61wiqtHk9nq8vqROLWBhQxzGSh9157z/46AT+8PN89gFh5uNdFuhFz8e8/HIV3HtIrzrtR+flJfHJ1ZT5dhTDicSMiC/DhG/hupX4GHGX6zlaMgBqB8bKxxvs+v0iHfSkDIkuenZ+nTD72DP5yuIQVIwGV16CZA6rusjb1zLn6QYpvQtCuqlih+epsGNEYP6B3rvNMc/N7JcwY4YMTK+C46EC9mXhpfPn0a5OdD4kQ6s=,iv:+g9W5MzgtLppD1K3dZ/tCuaMxaa194W3Lf23/jUmDvk=,tag:5uVwIOkB2+MRHPGlKGQtGg==,type:str] +tailscale: + auth-key: ENC[AES256_GCM,data:ffBLc/LIm67P/DUSvDUek/qWHLfQbmSE3jQL1/TLHrZEEGk+HqEO4Prlj5bGGAU37tiHaD7Ksj1wqX3U+Q==,iv:Uw1tnuxMPZVwqvrRkZZusJM/a7QeoggR047+CKx9fnY=,tag:kS1IixpXYm1QVBIAmmgQ6Q==,type:str] sops: age: - enc: | @@ -75,7 +77,7 @@ sops: 6POXxpxjGhlWJaV47jqeN+7mQY2oTHE/x4raoX/KA2ouXL29K8QpmA== -----END AGE ENCRYPTED FILE----- recipient: age1pewusvlcgzcnk0kpskgc9qr6jlq8s2yzwnqrnh84p7v5z0kj3qhsya8h2x - lastmodified: "2026-03-24T00:15:02Z" - mac: ENC[AES256_GCM,data:dYTwO5DtkKinTKfBXGuvXRFxl8yavxXMKTw27M5/GcK/kkstHBG119IRk9B9KC6s6IHTY81U3MeUxE9XwdBiE7q4m15+ZO2vmdBVhN8wAh+82P9BP0HSaxLkjWLeKWBfULyLX/YXmQVsr09/NUEVSZcugJ6m40Ta+X9AQgO+cyA=,iv:FmsznsKTuIr61s3Zn0QZKSKvb/e2AljEB1ijKE52RKk=,tag:rHF2Xi4iP9VF33rxpBr5pg==,type:str] + lastmodified: "2026-06-10T03:00:15Z" + mac: ENC[AES256_GCM,data:F1DU7Sj9lsTOHWlyuY4L4N+urrBXv2GLKhlzohMA9iF6/bphRABa9we3WZX9MFNIc2Te+2SCh3f6Rv5eHwWSsZVdD4iwN/6HTabdNPTEOZyk/OVsap/F374MLu8ys3OVZ5vzg2PYCGmhlf4PQMrjvaz+CV3t8UezB6E4U0N3RuU=,iv:wT2ieUON5VInJhDtZFLENzyaty5TqcisLmYePnCHGM4=,tag:z9CYpCWa/XdSqPKMG3M41A==,type:str] unencrypted_suffix: _unencrypted - version: 3.12.1 + version: 3.13.1