From d4c7d22151ce4f2b3f0ac1c94f1a10b3d95aa13c Mon Sep 17 00:00:00 2001 From: 0x7fffff92 <0x7fffff92@example.com> Date: Mon, 21 Sep 2026 16:34:29 +0800 Subject: [PATCH 1/4] feat: support per-user auth keys with unified headscale --- main.go | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 87ead43..bf2616b 100644 --- a/main.go +++ b/main.go @@ -126,7 +126,16 @@ func main() { router.GET(proxyPrefix+preauthkeyStr, func(c *gin.Context) { - uid, err := resolveUserIDString(user) + username := strings.TrimSpace(c.GetHeader("X-BFL-USER")) + if username == "" { + c.JSON(http.StatusBadRequest, response{ + Code: requestHeadscaleError, + Message: "missing X-BFL-USER header", + }) + return + } + + uid, err := resolveUserIDString(username) if err != nil { c.JSON(http.StatusInternalServerError, response{ Code: requestHeadscaleError, @@ -440,6 +449,10 @@ func newDevice(key, urlSuffix string) (interface{}, error) { } func resolveUserIDString(username string) (string, error) { + return resolveUserIDStringWithCreate(username, true) +} + +func resolveUserIDStringWithCreate(username string, createIfMissing bool) (string, error) { var parsed listUsersAPIResponse resp, err := resty.New().R().SetHeaders(headers). SetResult(&parsed). @@ -462,7 +475,21 @@ func resolveUserIDString(username string) (string, error) { return s, nil } } - return "", fmt.Errorf("no user with name %q (check headscale users)", username) + if !createIfMissing { + return "", fmt.Errorf("user %q was created but could not be found", username) + } + + resp, err = resty.New().R().SetHeaders(headers). + SetBody(map[string]string{"name": username}). + Post(url + "/user") + if err != nil { + return "", fmt.Errorf("create user failed, err: %s, data: %s", err, resp.String()) + } + if resp.StatusCode() != 200 { + return "", fmt.Errorf("create user failed, data: %s", resp.String()) + } + + return resolveUserIDStringWithCreate(username, false) } func createPreAuthKey(data *createPreAuthKeyRequest, urlSuffix string) (interface{}, error) { From 4a491c8a05668ee51ffb35269f9746066c908d93 Mon Sep 17 00:00:00 2001 From: 0x7fffff92 <0x7fffff92@example.com> Date: Tue, 22 Sep 2026 18:00:14 +0800 Subject: [PATCH 2/4] feat: bind preauth keys to verified Olares users --- identity.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 32 +++++++++++++---------- 2 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 identity.go diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..297444e --- /dev/null +++ b/identity.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + resty "github.com/go-resty/resty/v2" +) + +const defaultTokenVerifyURL = "http://lldap-service.os-platform:17170/auth/token/verify" + +type tokenClaims struct { + Username string `json:"username"` +} + +var verifySessionToken = verifyOlaresSessionToken + +// authenticatedUser returns the identity asserted by the verified Olares +// session. X-BFL-USER is diagnostic-only; it never selects the Headscale +// user or causes an otherwise valid request to be rejected. +func authenticatedUser(r *http.Request) (string, error) { + token := strings.TrimSpace(r.Header.Get("X-Authorization")) + token = strings.TrimPrefix(token, "Bearer ") + if token == "" { + return "", errors.New("missing X-Authorization header") + } + + claims, err := verifySessionToken(token) + if err != nil { + return "", err + } + username := strings.TrimSpace(claims.Username) + if username == "" { + return "", errors.New("Olares session has no username") + } + + if asserted := strings.TrimSpace(r.Header.Get("X-BFL-USER")); asserted != "" && asserted != username { + log.Printf("warning: X-BFL-USER=%q does not match token username=%q", asserted, username) + } + return username, nil +} + +func verifyOlaresSessionToken(token string) (tokenClaims, error) { + verifyURL := strings.TrimSpace(os.Getenv("TOKEN_VERIFY_URL")) + if verifyURL == "" { + verifyURL = defaultTokenVerifyURL + } + resp, err := resty.New().SetTimeout(10*time.Second).R(). + SetHeader("Content-Type", "application/json"). + SetAuthToken(token). + SetBody(map[string]string{"access_token": token}). + Post(verifyURL) + if err != nil { + return tokenClaims{}, fmt.Errorf("verify Olares session: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return tokenClaims{}, fmt.Errorf("verify Olares session: status %d", resp.StatusCode()) + } + var claims tokenClaims + if err := json.Unmarshal(resp.Body(), &claims); err != nil { + return tokenClaims{}, fmt.Errorf("decode Olares verification response: %w", err) + } + if strings.TrimSpace(claims.Username) == "" { + return tokenClaims{}, errors.New("Olares verification response has no username") + } + return claims, nil +} diff --git a/main.go b/main.go index bf2616b..00e1c53 100644 --- a/main.go +++ b/main.go @@ -126,11 +126,11 @@ func main() { router.GET(proxyPrefix+preauthkeyStr, func(c *gin.Context) { - username := strings.TrimSpace(c.GetHeader("X-BFL-USER")) - if username == "" { - c.JSON(http.StatusBadRequest, response{ + username, err := authenticatedUser(c.Request) + if err != nil { + c.JSON(http.StatusUnauthorized, response{ Code: requestHeadscaleError, - Message: "missing X-BFL-USER header", + Message: err.Error(), }) return } @@ -147,7 +147,7 @@ func main() { User: uid, Reusable: true, Ephemeral: false, - Expiration: time.Now().UTC().AddDate(10, 0, 0).Format(time.RFC3339), + Expiration: time.Now().UTC().Add(time.Hour).Format(time.RFC3339), } fmt.Println(data) @@ -479,17 +479,23 @@ func resolveUserIDStringWithCreate(username string, createIfMissing bool) (strin return "", fmt.Errorf("user %q was created but could not be found", username) } - resp, err = resty.New().R().SetHeaders(headers). + resp, createErr := resty.New().R().SetHeaders(headers). SetBody(map[string]string{"name": username}). Post(url + "/user") - if err != nil { - return "", fmt.Errorf("create user failed, err: %s, data: %s", err, resp.String()) + + // Another concurrent request may have created the user first. Treat the + // create operation as idempotent and use Headscale as the source of truth. + uid, lookupErr := resolveUserIDStringWithCreate(username, false) + if lookupErr == nil { + return uid, nil } - if resp.StatusCode() != 200 { + if createErr != nil { + return "", fmt.Errorf("create user failed, err: %s", createErr) + } + if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { return "", fmt.Errorf("create user failed, data: %s", resp.String()) } - - return resolveUserIDStringWithCreate(username, false) + return "", lookupErr } func createPreAuthKey(data *createPreAuthKeyRequest, urlSuffix string) (interface{}, error) { @@ -513,14 +519,14 @@ func getControlURL() (string, error) { source, err := ioutil.ReadFile(config) if err != nil { - fmt.Println("failed reading config file: %v", err) + fmt.Printf("failed reading config file: %v\n", err) return "", err } data := make(map[interface{}]interface{}) err = yaml.Unmarshal(source, &data) if err != nil { - fmt.Println("unmarshal error: %v", err) + fmt.Printf("unmarshal error: %v\n", err) return "", err } From 768fc8c34f394c040038ab1b28f50e32ce960262 Mon Sep 17 00:00:00 2001 From: 0x7fffff92 <0x7fffff92@example.com> Date: Tue, 22 Sep 2026 18:05:14 +0800 Subject: [PATCH 3/4] fix: build the complete wrapper package --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bd0486f..fbc507b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY . . RUN go mod tidy && go build \ -o headscale-api-wrapper \ - main.go + . FROM alpine:latest From d46577a6ec439885c1ef6e82a4d5e42e00a96ebd Mon Sep 17 00:00:00 2001 From: 0x7fffff92 <0x7fffff92@example.com> Date: Tue, 22 Sep 2026 18:31:44 +0800 Subject: [PATCH 4/4] fix: extend preauth key lifetime to 24 hours --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 00e1c53..6439e39 100644 --- a/main.go +++ b/main.go @@ -147,7 +147,7 @@ func main() { User: uid, Reusable: true, Ephemeral: false, - Expiration: time.Now().UTC().Add(time.Hour).Format(time.RFC3339), + Expiration: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339), } fmt.Println(data)