Your one-stop-cake-shop for everything Freshly Baked has to offer

feat(headscale): defer auth to device registration

When we start up headscale, it tries to connect to OIDC. If our OIDC
server is down, there were previously two options:
- We can fail to start the server altogether
- We can fallback to CLI auth and lose the ability to use OIDC until
headscale is restarted

Neither of these are what I want: I want us to start up anyway but not
allow registration until OIDC successfully connects

I've made a patch to do just that! I made it ontop of main, so I've also
had to upgrade headscale to allow use of this patch.

I had some troubles with nilla, which can't properly interpret the
headscale flake (missing rev/shortRev attributes) - therefore I've taken
the nix package definition from the headscale repo and updated it to use
the patch and the correct source manually

Refs: juanfont/headscale#1873

authored by a.starrysky.fyi and committed by

Skyler Grey f74c52fe a4d8b2a8

+287 -17
+11
packetmix/LICENSES/BSD-3-Clause.txt
··· 1 + Copyright (c) <year> <owner>. 2 + 3 + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 + 5 + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 + 7 + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 + 9 + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 + 11 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+13
packetmix/npins/sources.json
··· 94 94 "url": "https://api.github.com/repos/9001/copyparty/tarball/refs/tags/v1.19.8", 95 95 "hash": "sha256-S7HDDX3ndcMo++d9mEBw74LJqwoivP3XB42RgeqYX+E=" 96 96 }, 97 + "headscale": { 98 + "type": "Git", 99 + "repository": { 100 + "type": "GitHub", 101 + "owner": "juanfont", 102 + "repo": "headscale" 103 + }, 104 + "branch": "main", 105 + "submodules": false, 106 + "revision": "30d12dafed210316431a57349adfdb2128078feb", 107 + "url": "https://github.com/juanfont/headscale/archive/30d12dafed210316431a57349adfdb2128078feb.tar.gz", 108 + "hash": "sha256-q2Ac/KfHv9WWZVHo+AM4opt3P7iFqO4XsbtRTpGIPU0=" 109 + }, 97 110 "home-manager": { 98 111 "type": "Git", 99 112 "repository": {
+1
packetmix/packages/default.nix
··· 9 9 ./beancount-beancount_share 10 10 ./beancount-smart_importer 11 11 ./collabora-gtimelog 12 + ./headscale 12 13 ./jujutsu 13 14 ./lua-multipart 14 15 ./OpenLinkHub
+50
packetmix/packages/headscale/default.nix
··· 1 + # SPDX-FileCopyrightText: 2020 Juan Font 2 + # SPDX-FileCopyrightText: 2025 FreshlyBakedCake 3 + # 4 + # SPDX-License-Identifier: MIT and BSD-3-Clause 5 + 6 + { config, ... }: 7 + { 8 + config.packages.headscale = { 9 + systems = [ "x86_64-linux" ]; 10 + package = 11 + { 12 + lib, 13 + buildGo124Module, 14 + ... 15 + }: 16 + let 17 + vendorHash = "sha256-hIY6asY3rOIqf/5P6lFmnNCDWcqNPJaj+tqJuOvGJlo="; 18 + commitHash = config.inputs.headscale.src.revision; 19 + headscaleVersion = commitHash; 20 + in 21 + buildGo124Module { 22 + pname = "headscale"; 23 + version = headscaleVersion; 24 + src = config.inputs.headscale.src; 25 + 26 + # Only run unit tests when testing a build 27 + checkFlags = [ "-short" ]; 28 + 29 + # When updating go.mod or go.sum, a new sha will need to be calculated, 30 + # update this if you have a mismatch after doing a change to those files. 31 + inherit vendorHash; 32 + 33 + subPackages = [ "cmd/headscale" ]; 34 + 35 + patches = [ ./deferred-auth.patch ]; 36 + 37 + ldflags = [ 38 + "-s" 39 + "-w" 40 + "-X github.com/juanfont/headscale/hscontrol/types.Version=${headscaleVersion}" 41 + "-X github.com/juanfont/headscale/hscontrol/types.GitCommitHash=${commitHash}" 42 + ]; 43 + 44 + meta = { 45 + mainProgram = "headscale"; 46 + maintainer = [ lib.maintainers.minion3665 ]; 47 + }; 48 + }; 49 + }; 50 + }
+188
packetmix/packages/headscale/deferred-auth.patch
··· 1 + Commit ID: 9803b8ce2460772ba0eaae7b245d10009ba6a00b 2 + Change ID: rmzrrlvnpvlukyuotstptzuvnkvkssps 3 + Bookmarks: private/minion/push-rmzrrlvnpvlu private/minion/push-rmzrrlvnpvlu@git private/minion/push-rmzrrlvnpvlu@origin 4 + Author : Skyler Grey <minion@freshlybakedca.ke> (2025-09-13 14:04:34) 5 + Committer: Skyler Grey <minion@freshlybakedca.ke> (2025-09-13 14:41:03) 6 + Signature: good signature by sky@a.starrysky.fyi 7 + 8 + oidc: allow deferring auth provider setup 9 + 10 + Currently, if Headscale fails to set up OIDC when it is starting up 11 + there are two cases. Either: 12 + - only_start_if_oidc_is_available is true and Headscale fails to start 13 + - only_start_if_oidc_is_available is false and Headscale disables OIDC 14 + until it is restarted 15 + 16 + In our setup, we rely on Headscale for any remote SSH access. As most 17 + users don't have any physical access we can't afford to have Headscale 18 + fail to start. This, however, means that if Headscale starts without our 19 + auth provider having yet started (as frequently happens) we will not be 20 + able to authenticate via OIDC until we restart headscale. 21 + 22 + This patch adds a new config option ('auth_setup_allow_defer') to soft 23 + fail on setting up OIDC until we attempt registration. At that point if 24 + OIDC still doesn't work authentication will fail (i.e. there will not be 25 + the option to login via the command line) 26 + 27 + fixes: juanfont/headscale#1873 28 + 29 + diff --git a/hscontrol/app.go b/hscontrol/app.go 30 + index 6880c6bed8..152d014a69 100644 31 + --- a/hscontrol/app.go 32 + +++ b/hscontrol/app.go 33 + @@ -112,6 +112,40 @@ 34 + dumpConfig = envknob.Bool("HEADSCALE_DEBUG_DUMP_CONFIG") 35 + ) 36 + 37 + +func (h *Headscale) InitOrGetAuthProvider(ctx context.Context) (*AuthProvider, error) { 38 + + if h.authProvider != nil { 39 + + return &h.authProvider, nil 40 + + } 41 + + 42 + + var authProvider AuthProvider 43 + + authProvider = NewAuthProviderWeb(h.cfg.ServerURL) 44 + + if h.cfg.OIDC.Issuer != "" { 45 + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 46 + + defer cancel() 47 + + oidcProvider, err := NewAuthProviderOIDC( 48 + + ctx, 49 + + h, 50 + + h.cfg.ServerURL, 51 + + &h.cfg.OIDC, 52 + + ) 53 + + if err != nil { 54 + + if h.cfg.OIDC.OnlyStartIfOIDCIsAvailable { 55 + + return nil, err 56 + + } else if h.cfg.AuthSetupAllowDefer { 57 + + log.Warn().Err(err).Msg("failed to set up OIDC provider, deferring until I get an auth request") 58 + + authProvider = nil 59 + + } else { 60 + + log.Warn().Err(err).Msg("failed to set up OIDC provider, falling back to CLI based authentication") 61 + + } 62 + + } else { 63 + + authProvider = oidcProvider 64 + + } 65 + + } 66 + + h.authProvider = authProvider 67 + + 68 + + return &h.authProvider, nil 69 + +} 70 + + 71 + func NewHeadscale(cfg *types.Config) (*Headscale, error) { 72 + var err error 73 + if profilingEnabled { 74 + @@ -155,28 +189,10 @@ 75 + }) 76 + app.ephemeralGC = ephemeralGC 77 + 78 + - var authProvider AuthProvider 79 + - authProvider = NewAuthProviderWeb(cfg.ServerURL) 80 + - if cfg.OIDC.Issuer != "" { 81 + - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 82 + - defer cancel() 83 + - oidcProvider, err := NewAuthProviderOIDC( 84 + - ctx, 85 + - &app, 86 + - cfg.ServerURL, 87 + - &cfg.OIDC, 88 + - ) 89 + - if err != nil { 90 + - if cfg.OIDC.OnlyStartIfOIDCIsAvailable { 91 + - return nil, err 92 + - } else { 93 + - log.Warn().Err(err).Msg("failed to set up OIDC provider, falling back to CLI based authentication") 94 + - } 95 + - } else { 96 + - authProvider = oidcProvider 97 + - } 98 + + _, err = app.InitOrGetAuthProvider(context.Background()) 99 + + if err != nil { 100 + + return nil, err 101 + } 102 + - app.authProvider = authProvider 103 + 104 + if app.cfg.TailcfgDNSConfig != nil && app.cfg.TailcfgDNSConfig.Proxied { // if MagicDNS 105 + // TODO(kradalby): revisit why this takes a list. 106 + @@ -455,12 +471,37 @@ 107 + router.HandleFunc("/robots.txt", h.RobotsHandler).Methods(http.MethodGet) 108 + router.HandleFunc("/health", h.HealthHandler).Methods(http.MethodGet) 109 + router.HandleFunc("/key", h.KeyHandler).Methods(http.MethodGet) 110 + - router.HandleFunc("/register/{registration_id}", h.authProvider.RegisterHandler). 111 + + router.HandleFunc("/register/{registration_id}", func(writer http.ResponseWriter, req *http.Request) { 112 + + authProvider, err := h.InitOrGetAuthProvider(req.Context()) 113 + + if authProvider == nil { 114 + + log.Warn().Err(err).Msg("failed to setup auth on registration request") 115 + + writer.WriteHeader(http.StatusInternalServerError) 116 + + return 117 + + } 118 + + 119 + + h.authProvider.RegisterHandler(writer, req) 120 + + }). 121 + Methods(http.MethodGet) 122 + 123 + - if provider, ok := h.authProvider.(*AuthProviderOIDC); ok { 124 + - router.HandleFunc("/oidc/callback", provider.OIDCCallbackHandler).Methods(http.MethodGet) 125 + - } 126 + + router.HandleFunc("/oidc/callback", func(writer http.ResponseWriter, req *http.Request) { 127 + + if h.cfg.OIDC.Issuer == "" { 128 + + writer.WriteHeader(http.StatusNotFound) 129 + + return 130 + + } 131 + + 132 + + authProvider, err := h.InitOrGetAuthProvider(req.Context()) 133 + + if authProvider == nil { 134 + + log.Warn().Err(err).Msg("failed to setup auth on registration request") 135 + + return 136 + + } 137 + + 138 + + if provider, ok := (*authProvider).(*AuthProviderOIDC); ok { 139 + + provider.OIDCCallbackHandler(writer, req) 140 + + return 141 + + } 142 + + 143 + + writer.WriteHeader(http.StatusInternalServerError) 144 + + }).Methods(http.MethodGet) 145 + router.HandleFunc("/apple", h.AppleConfigMessage).Methods(http.MethodGet) 146 + router.HandleFunc("/apple/{platform}", h.ApplePlatformConfig). 147 + Methods(http.MethodGet) 148 + diff --git a/hscontrol/auth.go b/hscontrol/auth.go 149 + index 81032640ce..9c018da908 100644 150 + --- a/hscontrol/auth.go 151 + +++ b/hscontrol/auth.go 152 + @@ -266,7 +266,13 @@ 153 + 154 + log.Info().Msgf("Starting node registration using key: %s", registrationId) 155 + 156 + + authProvider, err := h.InitOrGetAuthProvider(context.Background()) 157 + + if authProvider == nil { 158 + + log.Error().Err(err).Msg("Failed to get auth provider when registering node") 159 + + return nil, fmt.Errorf("getting auth provider when registering node: %w", err) 160 + + } 161 + + 162 + return &tailcfg.RegisterResponse{ 163 + - AuthURL: h.authProvider.AuthURL(registrationId), 164 + + AuthURL: (*authProvider).AuthURL(registrationId), 165 + }, nil 166 + } 167 + diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go 168 + index 4a0a366eb2..18227d391f 100644 169 + --- a/hscontrol/types/config.go 170 + +++ b/hscontrol/types/config.go 171 + @@ -88,7 +88,8 @@ 172 + UnixSocket string 173 + UnixSocketPermission fs.FileMode 174 + 175 + - OIDC OIDCConfig 176 + + AuthSetupAllowDefer bool 177 + + OIDC OIDCConfig 178 + 179 + LogTail LogTailConfig 180 + RandomizeClientPort bool 181 + @@ -941,6 +942,7 @@ 182 + UnixSocket: viper.GetString("unix_socket"), 183 + UnixSocketPermission: util.GetFileMode("unix_socket_permission"), 184 + 185 + + AuthSetupAllowDefer: viper.GetBool("auth_setup_allow_defer"), 186 + OIDC: OIDCConfig{ 187 + OnlyStartIfOIDCIsAvailable: viper.GetBool( 188 + "oidc.only_start_if_oidc_is_available",
+4
packetmix/packages/headscale/deferred-auth.patch.license
··· 1 + SPDX-FileCopyrightText: 2020 Juan Font 2 + SPDX-FileCopyrightText: 2025 FreshlyBakedCake 3 + 4 + SPDX-License-Identifier: BSD-3-Clause
+20 -17
packetmix/systems/teal/headscale.nix
··· 3 3 # 4 4 # SPDX-License-Identifier: MIT 5 5 6 - { pkgs, ... }: 6 + { project, pkgs, ... }: 7 7 let 8 8 groups = { 9 9 /** ··· 20 20 different permission level. Servers can only access other servers 21 21 */ 22 22 "group:users" = [ 23 - "coded" 24 - "matei" 25 - "minion" 26 - "mostlyturquoise" 27 - "pinea" 28 - "zanderp25" 23 + "coded@" 24 + "matei@" 25 + "minion@" 26 + "mostlyturquoise@" 27 + "pinea@" 28 + "zanderp25@" 29 + "testminion@" 29 30 ]; 30 31 31 32 /** ··· 36 37 tailnet 37 38 */ 38 39 "group:friends" = [ 39 - "sirdigalot" 40 + "sirdigalot@" 40 41 ]; 41 42 }; 42 43 43 44 exceptional_acls = [ 44 45 { 45 46 action = "accept"; 46 - src = [ "zulu" ]; 47 - dst = [ "zanderp25:3000" ]; 48 - } # Used to let Zan reverse proxy to their personal machine for development - port-locked so probably OK 49 - { 50 - action = "accept"; 51 47 src = [ 52 - "mostlyturquoise" 53 - "starrylee" 48 + "mostlyturquoise@" 49 + "starrylee@" 54 50 ]; 55 51 dst = [ "tag:mostlyturquoise-minecraft-server:*" ]; 56 52 } # Used to let mostlyturquoise and their friends access their minecraft servers without giving people too many permissions ··· 85 81 86 82 tagOwners = { 87 83 "tag:server" = [ "group:users" ]; 88 - "tag:mostlyturquoise-minecraft-server" = [ "mostlyturquoise" ]; 84 + "tag:mostlyturquoise-minecraft-server" = [ "mostlyturquoise@" ]; 89 85 }; 90 86 in 91 87 { 88 + disabledModules = [ "services/networking/headscale.nix" ]; 89 + imports = [ 90 + "${project.inputs.nixos-unstable.src}/nixos/modules/services/networking/headscale.nix" 91 + ]; 92 + 92 93 # Headscale service 93 94 services.headscale = { 94 95 enable = true; 96 + 97 + package = project.packages.headscale.result.x86_64-linux; 95 98 96 99 address = "127.0.0.1"; 97 100 port = 1024; ··· 116 119 ]; 117 120 base_domain = "clicks.domains"; 118 121 }; 122 + auth_setup_allow_defer = true; # Otherwise we'll fall back to CLI auth 119 123 oidc = { 120 124 only_start_if_oidc_is_available = false; # Otherwise we can end up locking ourselves out... 121 - strip_email_domain = true; 122 125 123 126 issuer = "https://idm.freshly.space/oauth2/openid/headscale"; 124 127