|
6 | 6 | "cmp" |
7 | 7 | "context" |
8 | 8 | "crypto/tls" |
| 9 | + "crypto/x509" |
9 | 10 | "encoding/json" |
10 | 11 | "errors" |
11 | 12 | "fmt" |
@@ -1023,6 +1024,102 @@ func (t *HeadscaleInContainer) GetEndpoint() string { |
1023 | 1024 | return t.getEndpoint(false) |
1024 | 1025 | } |
1025 | 1026 |
|
| 1027 | +var errOAuthSecretMissing = errors.New(`OAuth client response missing secret in "key" field`) |
| 1028 | + |
| 1029 | +// CreateOAuthClient mints an admin API key and uses it to create an OAuth client |
| 1030 | +// via the v2 keys HTTP API (POST /api/v2/tailnet/-/keys, keyType=client), |
| 1031 | +// returning the client id and secret. The secret is only returned once, in the |
| 1032 | +// "key" field. It is a reusable building block for tests that need OAuth client |
| 1033 | +// credentials (such as the Kubernetes operator). |
| 1034 | +func (t *HeadscaleInContainer) CreateOAuthClient( |
| 1035 | + ctx context.Context, |
| 1036 | + scopes, tags []string, |
| 1037 | +) (string, string, error) { |
| 1038 | + apiKey, err := t.Execute([]string{"headscale", "apikeys", "create", "--expiration", "24h"}) |
| 1039 | + if err != nil { |
| 1040 | + return "", "", fmt.Errorf("creating admin api key: %w", err) |
| 1041 | + } |
| 1042 | + |
| 1043 | + apiKey = strings.TrimSpace(apiKey) |
| 1044 | + |
| 1045 | + reqBody, err := json.Marshal(map[string]any{ |
| 1046 | + "keyType": "client", |
| 1047 | + "scopes": scopes, |
| 1048 | + "tags": tags, |
| 1049 | + }) |
| 1050 | + if err != nil { |
| 1051 | + return "", "", fmt.Errorf("marshalling key request: %w", err) |
| 1052 | + } |
| 1053 | + |
| 1054 | + req, err := http.NewRequestWithContext( |
| 1055 | + ctx, http.MethodPost, t.GetEndpoint()+"/api/v2/tailnet/-/keys", bytes.NewReader(reqBody)) |
| 1056 | + if err != nil { |
| 1057 | + return "", "", fmt.Errorf("building key request: %w", err) |
| 1058 | + } |
| 1059 | + |
| 1060 | + req.Header.Set("Authorization", "Bearer "+apiKey) |
| 1061 | + req.Header.Set("Content-Type", "application/json") |
| 1062 | + |
| 1063 | + resp, err := t.httpClient().Do(req) |
| 1064 | + if err != nil { |
| 1065 | + return "", "", fmt.Errorf("posting key request: %w", err) |
| 1066 | + } |
| 1067 | + defer resp.Body.Close() |
| 1068 | + |
| 1069 | + if resp.StatusCode != http.StatusOK { |
| 1070 | + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) |
| 1071 | + |
| 1072 | + return "", "", fmt.Errorf( //nolint:err113 |
| 1073 | + "creating OAuth client: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) |
| 1074 | + } |
| 1075 | + |
| 1076 | + var out struct { |
| 1077 | + ID string `json:"id"` |
| 1078 | + Key string `json:"key"` |
| 1079 | + } |
| 1080 | + |
| 1081 | + err = json.NewDecoder(resp.Body).Decode(&out) |
| 1082 | + if err != nil { |
| 1083 | + return "", "", fmt.Errorf("decoding key response: %w", err) |
| 1084 | + } |
| 1085 | + |
| 1086 | + if out.Key == "" { |
| 1087 | + return "", "", errOAuthSecretMissing |
| 1088 | + } |
| 1089 | + |
| 1090 | + // The operator expects clientId and clientSecret as separate values. When the |
| 1091 | + // server returns a single opaque credential, the client-credentials grant |
| 1092 | + // splits it on "-" (id-secret), matching the Tailscale SaaS shape. Fall back |
| 1093 | + // to the whole key as the secret when no id is given. |
| 1094 | + clientID, clientSecret := out.ID, out.Key |
| 1095 | + |
| 1096 | + if clientID == "" { |
| 1097 | + if id, secret, ok := strings.Cut(out.Key, "-"); ok { |
| 1098 | + clientID, clientSecret = id, secret |
| 1099 | + } |
| 1100 | + } |
| 1101 | + |
| 1102 | + return clientID, clientSecret, nil |
| 1103 | +} |
| 1104 | + |
| 1105 | +// httpClient returns an HTTP client that trusts this Headscale's TLS CA when TLS |
| 1106 | +// is enabled, or a default client when it serves plain HTTP. |
| 1107 | +func (t *HeadscaleInContainer) httpClient() *http.Client { |
| 1108 | + if !t.hasTLS() { |
| 1109 | + return &http.Client{Timeout: 30 * time.Second} |
| 1110 | + } |
| 1111 | + |
| 1112 | + pool := x509.NewCertPool() |
| 1113 | + pool.AppendCertsFromPEM(t.tlsCACert) |
| 1114 | + |
| 1115 | + return &http.Client{ |
| 1116 | + Timeout: 30 * time.Second, |
| 1117 | + Transport: &http.Transport{ |
| 1118 | + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, |
| 1119 | + }, |
| 1120 | + } |
| 1121 | +} |
| 1122 | + |
1026 | 1123 | // GetIPEndpoint returns the Headscale endpoint using IP address instead of hostname. |
1027 | 1124 | func (t *HeadscaleInContainer) GetIPEndpoint() string { |
1028 | 1125 | return t.getEndpoint(true) |
|
0 commit comments