Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ COPY . .

RUN go mod tidy && go build \
-o headscale-api-wrapper \
main.go
.

FROM alpine:latest

Expand Down
73 changes: 73 additions & 0 deletions identity.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 38 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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).
Expand All @@ -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) {
Expand All @@ -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
}

Expand Down
Loading