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 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 87ead43..6439e39 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, err := authenticatedUser(c.Request) + if err != nil { + c.JSON(http.StatusUnauthorized, response{ + Code: requestHeadscaleError, + Message: err.Error(), + }) + return + } + + uid, err := resolveUserIDString(username) if err != nil { c.JSON(http.StatusInternalServerError, response{ Code: requestHeadscaleError, @@ -138,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(24 * time.Hour).Format(time.RFC3339), } fmt.Println(data) @@ -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,27 @@ 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, createErr := resty.New().R().SetHeaders(headers). + SetBody(map[string]string{"name": username}). + Post(url + "/user") + + // 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 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 "", lookupErr } func createPreAuthKey(data *createPreAuthKeyRequest, urlSuffix string) (interface{}, error) { @@ -486,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 }