Skip to content

Commit 60b7229

Browse files
committed
integration: run the k8s operator over HTTP with reusable k3sic blocks
HTTP-by-IP drops the CA-baking and containerd image import (recipe kept in tls-ca-baking.md). Operator/workload steps become reusable k3sic methods; proxies reach the embedded non-TLS DERP over plain-HTTP websockets. Adds hsic.CreateOAuthClient and tsic.WithDERPOverHTTP; test covers connector, ingress connectivity and proxy groups. Updates #1202
1 parent b68d71c commit 60b7229

7 files changed

Lines changed: 720 additions & 606 deletions

File tree

integration/control.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package integration
22

33
import (
4+
"context"
45
"net/netip"
56

67
clientv1 "github.com/juanfont/headscale/gen/client/v1"
@@ -22,6 +23,8 @@ type ControlServer interface {
2223
ConnectToNetwork(network *dockertest.Network) error
2324
GetHealthEndpoint() string
2425
GetEndpoint() string
26+
GetIPEndpoint() string
27+
CreateOAuthClient(ctx context.Context, scopes, tags []string) (string, string, error)
2528
WaitForRunning() error
2629
Restart() error
2730
CreateUser(user string) (*clientv1.User, error)

integration/hsic/hsic.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"cmp"
77
"context"
88
"crypto/tls"
9+
"crypto/x509"
910
"encoding/json"
1011
"errors"
1112
"fmt"
@@ -1023,6 +1024,102 @@ func (t *HeadscaleInContainer) GetEndpoint() string {
10231024
return t.getEndpoint(false)
10241025
}
10251026

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+
10261123
// GetIPEndpoint returns the Headscale endpoint using IP address instead of hostname.
10271124
func (t *HeadscaleInContainer) GetIPEndpoint() string {
10281125
return t.getEndpoint(true)

0 commit comments

Comments
 (0)