Skip to content

Commit 1a61eb5

Browse files
authored
Merge pull request #192 from aojea/refactor_control_plane
Fix postgres volume mounting on GKE and switch E2E tests to dynamic b…
2 parents bc5b309 + 6c89927 commit 1a61eb5

12 files changed

Lines changed: 357 additions & 131 deletions

File tree

.github/k8s/sam-control-plane-template.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ spec:
4141
labels:
4242
app: sam-db-${ENV_NAME}
4343
spec:
44+
securityContext:
45+
fsGroup: 999
4446
containers:
4547
- name: postgres
4648
image: postgres:16-alpine
@@ -51,6 +53,8 @@ spec:
5153
value: sam
5254
- name: POSTGRES_PASSWORD
5355
value: sam-secret-password
56+
- name: PGDATA
57+
value: /var/lib/postgresql/data/pgdata
5458
ports:
5559
- containerPort: 5432
5660
protocol: TCP
@@ -81,9 +85,20 @@ spec:
8185
labels:
8286
app: sam-control-plane-${ENV_NAME}
8387
spec:
88+
initContainers:
89+
- name: wait-for-db
90+
image: busybox:1.36.1
91+
command: ['sh', '-c', 'until nc -z -w3 sam-db-${ENV_NAME} 5432; do echo waiting for db; sleep 1; done']
8492
containers:
8593
- name: sam-control-plane
8694
image: ghcr.io/google/sam-control-plane:${IMAGE_TAG}
95+
env:
96+
- name: ADMIN_TOKEN
97+
valueFrom:
98+
secretKeyRef:
99+
name: sam-control-plane-secret-${ENV_NAME}
100+
key: admin-token
101+
optional: true
87102
ports:
88103
- containerPort: 8080
89104
protocol: TCP
@@ -109,6 +124,7 @@ spec:
109124
- "--issuer=https://auth.sam-mesh.dev,https://container.googleapis.com/v1/projects/${GCP_PROJECT_ID}/locations/${CLUSTER_REGION}/clusters/${CLUSTER_NAME}"
110125
- "--allowed-audiences=sam-mesh-audience,sam-hub-audience"
111126
- "--policy-file=/etc/sam/policies/policies.yaml"
127+
- "--admin-token=$(ADMIN_TOKEN)"
112128
resources:
113129
requests:
114130
cpu: 100m

.github/workflows/deploy.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ jobs:
222222
223223
224224
225+
- name: Provision Control Plane Secrets
226+
run: |
227+
export ENV_NAME="${{ vars.ENV_NAME }}"
228+
export NAMESPACE="sam-${ENV_NAME}"
229+
if ! kubectl get secret sam-control-plane-secret-${ENV_NAME} -n ${NAMESPACE} >/dev/null 2>&1; then
230+
echo "Generating a new random 32-byte hex key for admin-token..."
231+
ADMIN_TOKEN=$(openssl rand -hex 32)
232+
kubectl create secret generic sam-control-plane-secret-${ENV_NAME} \
233+
--namespace=${NAMESPACE} \
234+
--from-literal=admin-token="${ADMIN_TOKEN}"
235+
else
236+
echo "sam-control-plane-secret-${ENV_NAME} already exists in namespace ${NAMESPACE}. Skipping generation."
237+
fi
238+
225239
- name: Deploy Control Plane and Routers
226240
run: |
227241
print_rollout_diagnostics() {

cmd/sam-control-plane/main.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ var (
3838
keyGracePeriod time.Duration
3939
leaseDuration time.Duration
4040
adminToken string
41-
bootstrapToken string
4241
insecureSkipTLSVerify bool
4342
logLevel string
4443
autoApproveEnrollment bool
@@ -101,7 +100,6 @@ func main() {
101100
InsecureSkipTLSVerify: insecureSkipTLSVerify,
102101
PolicyPath: policyFile,
103102
BiscuitTimeout: 10 * time.Second,
104-
BootstrapToken: bootstrapToken,
105103
AdminToken: adminToken,
106104
AutoApproveEnrollment: autoApproveEnrollment,
107105
}
@@ -135,7 +133,6 @@ func main() {
135133
rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.")
136134
rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.")
137135
rootCmd.Flags().StringVar(&adminToken, "admin-token", "", "Token for authenticating policy REST API requests")
138-
rootCmd.Flags().StringVar(&bootstrapToken, "bootstrap-token", "", "Pre-shared bootstrap token for bypassing OIDC verification")
139136
rootCmd.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS verification for OIDC providers")
140137
rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
141138
rootCmd.Flags().BoolVar(&autoApproveEnrollment, "auto-approve-enrollment", false, "Auto-approve valid bootstrap token enrollment requests")

internal/controlplane/config.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ type Options struct {
3232
InsecureSkipTLSVerify bool
3333
BiscuitTimeout time.Duration
3434
PolicyPath string // Optional: path to bootstrap policy configuration
35-
BootstrapToken string // Optional: pre-shared bootstrap token for bypassing OIDC verification (e.g. for router enrollment)
3635
AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs
3736
AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate
3837
}

internal/controlplane/server.go

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -123,30 +123,7 @@ func (s *Server) Start() error {
123123
}
124124
}
125125

126-
// Seed the initial command-line bootstrap token if provided
127-
if s.config.BootstrapToken != "" {
128-
hash := sha256.Sum256([]byte(s.config.BootstrapToken))
129-
tokenID := fmt.Sprintf("%x", hash)
130-
_, err := s.store.GetBootstrapToken(ctx, tokenID)
131-
if err == storage.ErrNotFound {
132-
logger.Infof("Seeding initial bootstrap token into database (ID: %s)...", tokenID)
133-
tokenRecord := &storage.BootstrapToken{
134-
ID: tokenID,
135-
TokenHash: tokenID,
136-
Role: api.RoleRouter,
137-
MaxUsages: 10000,
138-
UsagesCount: 0,
139-
Description: "Command Line Seeded Token",
140-
CreatedAt: time.Now(),
141-
ExpiresAt: time.Now().Add(365 * 24 * time.Hour),
142-
}
143-
if err := s.store.SaveBootstrapToken(ctx, tokenRecord); err != nil {
144-
return fmt.Errorf("failed to save initial bootstrap token: %w", err)
145-
}
146-
} else if err != nil {
147-
return fmt.Errorf("failed to check initial bootstrap token state: %w", err)
148-
}
149-
}
126+
150127

151128
// Initialize OIDC Providers
152129
if err := s.discoverProviders(); err != nil {
@@ -928,9 +905,19 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) {
928905
existingReq, err := s.store.GetEnrollmentRequest(ctx, req.PeerId)
929906
if err == nil {
930907
// Request already exists, return status
931-
resp := &api.BootstrapEnrollResponse{
932-
Status: existingReq.Status,
933-
BiscuitToken: existingReq.BiscuitToken,
908+
var resp *api.BootstrapEnrollResponse
909+
if existingReq.Status == api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED {
910+
resp, err = s.buildApprovedBootstrapEnrollResponse(ctx, existingReq.BiscuitToken, existingReq.ResolvedAt)
911+
if err != nil {
912+
logger.Errorf("Failed to build approved response: %v", err)
913+
http.Error(w, "Internal server error", http.StatusInternalServerError)
914+
return
915+
}
916+
} else {
917+
resp = &api.BootstrapEnrollResponse{
918+
Status: existingReq.Status,
919+
BiscuitToken: existingReq.BiscuitToken,
920+
}
934921
}
935922
s.writeEnrollResponse(w, resp)
936923
return
@@ -1006,9 +993,11 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) {
1006993
logger.Errorf("Failed to increment token usage: %v", err)
1007994
}
1008995

1009-
resp := &api.BootstrapEnrollResponse{
1010-
Status: api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED,
1011-
BiscuitToken: biscuitBytes,
996+
resp, err := s.buildApprovedBootstrapEnrollResponse(ctx, biscuitBytes, enrollReq.ResolvedAt)
997+
if err != nil {
998+
logger.Errorf("Failed to build approved response: %v", err)
999+
http.Error(w, "Internal server error", http.StatusInternalServerError)
1000+
return
10121001
}
10131002
s.writeEnrollResponse(w, resp)
10141003
return
@@ -1052,12 +1041,22 @@ func (s *Server) HandleEnrollStatus(w http.ResponseWriter, r *http.Request) {
10521041
return
10531042
}
10541043

1055-
resp := &api.BootstrapEnrollResponse{
1056-
Status: enrollReq.Status,
1057-
BiscuitToken: enrollReq.BiscuitToken,
1058-
}
1059-
if enrollReq.Status == api.EnrollmentStatus_ENROLLMENT_STATUS_PENDING {
1060-
resp.PollIntervalSeconds = 30
1044+
var resp *api.BootstrapEnrollResponse
1045+
if enrollReq.Status == api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED {
1046+
resp, err = s.buildApprovedBootstrapEnrollResponse(ctx, enrollReq.BiscuitToken, enrollReq.ResolvedAt)
1047+
if err != nil {
1048+
logger.Errorf("Failed to build approved response: %v", err)
1049+
http.Error(w, "Internal server error", http.StatusInternalServerError)
1050+
return
1051+
}
1052+
} else {
1053+
resp = &api.BootstrapEnrollResponse{
1054+
Status: enrollReq.Status,
1055+
BiscuitToken: enrollReq.BiscuitToken,
1056+
}
1057+
if enrollReq.Status == api.EnrollmentStatus_ENROLLMENT_STATUS_PENDING {
1058+
resp.PollIntervalSeconds = 30
1059+
}
10611060
}
10621061
s.writeEnrollResponse(w, resp)
10631062
}
@@ -1350,3 +1349,34 @@ func (s *Server) HandleAdminRevoke(w http.ResponseWriter, r *http.Request) {
13501349
w.WriteHeader(http.StatusOK)
13511350
_, _ = w.Write(respData)
13521351
}
1352+
1353+
func (s *Server) buildApprovedBootstrapEnrollResponse(ctx context.Context, biscuitToken []byte, resolvedAt *time.Time) (*api.BootstrapEnrollResponse, error) {
1354+
_, pubKey, err := s.store.GetCurrentKey(ctx)
1355+
if err != nil {
1356+
return nil, fmt.Errorf("failed to retrieve signing key: %w", err)
1357+
}
1358+
1359+
activeRouters, err := s.store.GetActiveRouters(ctx)
1360+
if err != nil {
1361+
return nil, fmt.Errorf("failed to retrieve active routers: %w", err)
1362+
}
1363+
1364+
var routerAddrs []string
1365+
for _, r := range activeRouters {
1366+
routerAddrs = append(routerAddrs, r.Addresses...)
1367+
}
1368+
1369+
expiration := time.Now().Add(api.BiscuitTokenTTL).Unix()
1370+
if resolvedAt != nil {
1371+
expiration = resolvedAt.Add(api.BiscuitTokenTTL).Unix()
1372+
}
1373+
1374+
return &api.BootstrapEnrollResponse{
1375+
Status: api.EnrollmentStatus_ENROLLMENT_STATUS_APPROVED,
1376+
BiscuitToken: biscuitToken,
1377+
HubPublicKey: pubKey,
1378+
HubAddresses: routerAddrs,
1379+
Expiration: expiration,
1380+
}, nil
1381+
}
1382+

internal/controlplane/server_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func setupTestServer(t *testing.T, oidcIssuer string) (*Server, storage.Store, s
128128
KeyRotationInterval: 12 * time.Hour,
129129
KeyGracePeriod: 10 * time.Minute,
130130
InsecureSkipTLSVerify: true,
131-
BiscuitTimeout: 1 * time.Second,
131+
BiscuitTimeout: 10 * time.Second,
132132
}
133133

134134
srv, err := NewServer(opts, store)
@@ -583,6 +583,9 @@ func TestEnrollmentWorkflow(t *testing.T) {
583583
if len(statusResp.BiscuitToken) == 0 {
584584
t.Fatalf("biscuit token is empty")
585585
}
586+
if len(statusResp.HubPublicKey) == 0 {
587+
t.Fatalf("hub public key is empty")
588+
}
586589

587590
// Verify Biscuit router rights
588591
_, cpPub, err := store.GetCurrentKey(context.Background())

site/content/docs/quickstart.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,18 @@ Expand-Archive -Path "sam.zip" -DestinationPath "$env:ProgramFiles\sam"
4040

4141
## 2. Join the Mesh
4242

43-
To register your node with the mesh and obtain a cryptographic identity token (Biscuit), run the OIDC authorization flow.
43+
To register your node with the mesh and obtain a cryptographic identity token (Biscuit), you can use either the interactive OIDC authorization flow or the non-interactive bootstrap token flow.
4444

45-
### Using the Binary
45+
### Option A: Interactive OIDC Flow (Default)
46+
47+
The interactive flow uses your browser to authenticate your identity against Dex (OIDC):
48+
49+
#### Using the Binary
4650
```bash
4751
sam-node join https://bananas.sam-mesh.dev
4852
```
4953

50-
### Using Docker
51-
Create a local directory to persist your node identity:
54+
#### Using Docker
5255
```bash
5356
mkdir -p $(pwd)/sam-data
5457
docker run -it \
@@ -57,7 +60,26 @@ docker run -it \
5760
join --data-dir /data https://bananas.sam-mesh.dev
5861
```
5962

60-
The CLI will output a Device Authorization URL (if headless/Docker) or open your browser (if using the binary natively). Once authenticated, the node registers and saves the identity to `~/.config/sam-mesh/agent.db` (or `/data/agent.db` in Docker).
63+
The CLI will output a Device Authorization URL (if headless/Docker) or open your browser natively. Once authenticated, the node registers and saves the identity database.
64+
65+
### Option B: Non-Interactive Bootstrap Flow (Headless)
66+
67+
If you are deploying a headless server or router and have a generated bootstrap token from the Control Plane API:
68+
69+
#### Using the Binary
70+
```bash
71+
sam-node join --bootstrap-token <your-token> https://bananas.sam-mesh.dev
72+
```
73+
74+
#### Using Docker
75+
```bash
76+
docker run -it \
77+
-v $(pwd)/sam-data:/data \
78+
ghcr.io/google/sam-node:latest \
79+
join --data-dir /data --bootstrap-token <your-token> https://bananas.sam-mesh.dev
80+
```
81+
82+
*Note: In non-interactive mode, unless the Hub runs with `--auto-approve-enrollment`, the enrollment request remains **PENDING** until approved manually by a network administrator.*
6183

6284
---
6385

site/content/docs/user/control-plane-configuration.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,61 @@ Administrators can immediately revoke any active session to disable a node's abi
153153
}
154154
```
155155
* **Enforcement**: Revoked nodes are marked as banned in the database. When the node next attempts a proactive `/refresh` handshake, the request is denied with a `403 Forbidden` status, and the node's local daemon immediately terminates.
156+
157+
---
158+
159+
## 6. Headless Node Enrollment (Bootstrap Token Flow)
160+
161+
To enroll a headless server, router, or background daemon that cannot complete interactive OIDC authentication, SAM supports a **Bootstrap Token** flow.
162+
163+
### Step 1: Generate a Bootstrap Token
164+
165+
An administrator with the `admin-token` can dynamically generate a time-bounded, single-use bootstrap token:
166+
167+
```bash
168+
curl -X POST \
169+
-H "Authorization: Bearer <your-admin-token>" \
170+
-H "Content-Type: application/json" \
171+
-d '{"role": "sam:role:node", "ttl_hours": 24, "max_usages": 1, "description": "Headless node deployment token"}' \
172+
http://<control-plane-ip>:8080/admin/bootstrap-tokens
173+
```
174+
175+
This returns a JSON response containing the plaintext token:
176+
```json
177+
{
178+
"id": "62e92ffca...",
179+
"token": "sam-bt-72fb0175788dee0...",
180+
"role": "sam:role:node",
181+
"expires_at": "2026-07-12T15:00:00Z"
182+
}
183+
```
184+
185+
### Step 2: Request Enrollment on the Node
186+
187+
Run the node `join` command with the generated token:
188+
189+
```bash
190+
sam-node join --bootstrap-token sam-bt-72fb0175788dee0... http://<control-plane-ip>:8080
191+
```
192+
193+
The node submits its enrollment request and waits (polls) for approval.
194+
195+
### Step 3: Approve the Enrollment
196+
197+
Administrators can review pending enrollment requests:
198+
199+
```bash
200+
# List all pending enrollments
201+
curl -H "Authorization: Bearer <your-admin-token>" http://<control-plane-ip>:8080/admin/enrollments
202+
```
203+
204+
To approve the request and issue the node its identity Biscuit:
205+
206+
```bash
207+
curl -X POST \
208+
-H "Authorization: Bearer <your-admin-token>" \
209+
http://<control-plane-ip>:8080/admin/enrollments/<request-id>/approve
210+
```
211+
212+
Alternatively, you can boot the control plane with `--auto-approve-enrollment` to automatically approve all valid bootstrap token requests without manual gates.
213+

0 commit comments

Comments
 (0)