fix(registry): consolidate control plane mTLS
cognitive committed
Mar 4, 2026 at 06:26 UTC
a7f577609deb847f191484fef11d049f2ed7f67b
3 files changed
+406
-140
cmd/relay-server/control_plane_admission.go
new
+123
@@ -0,0 +1,123 @@
1
+package main
2
+
3
+import (
4
+ "crypto/subtle"
5
+ "crypto/x509"
6
+ "fmt"
7
+ "net/http"
8
+ "slices"
9
+ "strings"
10
+ "time"
11
+
12
+ "gosuda.org/portal/portal"
13
+)
14
+
15
+const (
16
+ controlPlaneCertCNPrefix = "lease:"
17
+ controlPlaneLeaseURIPfx = "spiffe://portal/lease/"
18
+)
19
+
20
+type admissionConfig struct {
21
+ requireExistingLease bool
22
+}
23
+
24
+type admissionContext struct {
25
+ entry *portal.LeaseEntry
26
+ clientIP string
27
+ leaseID string
28
+ token string
29
+}
30
+
31
+func constantLeaseMatch(expected, provided string) bool {
32
+ expected = strings.TrimSpace(expected)
33
+ provided = strings.TrimSpace(provided)
34
+ if expected == "" || provided == "" {
35
+ return false
36
+ }
37
+ return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
38
+}
39
+
40
+func extractLeaseIDFromPeerCertificate(cert *x509.Certificate) string {
41
+ if cert == nil {
42
+ return ""
43
+ }
44
+ for _, uri := range cert.URIs {
45
+ if uri == nil {
46
+ continue
47
+ }
48
+ raw := strings.TrimSpace(uri.String())
49
+ if after, ok := strings.CutPrefix(raw, controlPlaneLeaseURIPfx); ok {
50
+ return after
51
+ }
52
+ }
53
+
54
+ commonName := strings.TrimSpace(cert.Subject.CommonName)
55
+ if after, ok := strings.CutPrefix(commonName, controlPlaneCertCNPrefix); ok {
56
+ return after
57
+ }
58
+ return commonName
59
+}
60
+
61
+func validatePeerLeaseCertificate(req *http.Request, leaseID string) (string, string, bool) {
62
+ if req == nil || req.TLS == nil || len(req.TLS.PeerCertificates) == 0 {
63
+ return "client_cert_required", "client certificate is required", false
64
+ }
65
+
66
+ leaf := req.TLS.PeerCertificates[0]
67
+ now := time.Now()
68
+ if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
69
+ return "client_cert_invalid", "client certificate is outside validity window", false
70
+ }
71
+
72
+ if len(leaf.ExtKeyUsage) > 0 {
73
+ hasClientAuth := slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
74
+ if !hasClientAuth {
75
+ return "client_cert_invalid", "client certificate does not allow client authentication", false
76
+ }
77
+ }
78
+
79
+ certLeaseID := strings.TrimSpace(extractLeaseIDFromPeerCertificate(leaf))
80
+ if certLeaseID == "" {
81
+ return "cert_lease_missing", "client certificate does not include lease identity", false
82
+ }
83
+ if !constantLeaseMatch(leaseID, certLeaseID) {
84
+ return "cert_lease_mismatch", fmt.Sprintf("client certificate lease identity mismatch: requested=%s cert=%s", leaseID, certLeaseID), false
85
+ }
86
+ return "", "", true
87
+}
88
+
89
+func (r *SDKRegistry) admitControlPlane(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer, rawLeaseID, rawToken string, cfg admissionConfig) (*admissionContext, bool) {
90
+ leaseID, token := normalizeLeaseCredentials(rawLeaseID, rawToken)
91
+ if !r.validateLeaseCredentials(w, leaseID, token) {
92
+ return nil, false
93
+ }
94
+
95
+ clientIP := r.extractClientIP(req)
96
+ if r.isClientIPBanned(clientIP) {
97
+ writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
98
+ return nil, false
99
+ }
100
+
101
+ entry, exists := lookupLeaseEntry(serv, leaseID)
102
+ if cfg.requireExistingLease && !exists {
103
+ writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
104
+ return nil, false
105
+ }
106
+
107
+ if code, message, ok := validatePeerLeaseCertificate(req, leaseID); !ok {
108
+ writeAPIError(w, http.StatusUnauthorized, code, message)
109
+ return nil, false
110
+ }
111
+
112
+ if exists && !constantLeaseMatch(entry.Lease.ReverseToken, token) {
113
+ writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
114
+ return nil, false
115
+ }
116
+
117
+ return &admissionContext{
118
+ clientIP: clientIP,
119
+ leaseID: leaseID,
120
+ token: token,
121
+ entry: entry,
122
+ }, true
123
+}
cmd/relay-server/registry.go
+30
-90
@@ -1,7 +1,6 @@
1
package main
2
3
import (
4
- "crypto/subtle"
4
"encoding/json"
5
"fmt"
6
"net/http"
@@ -23,15 +22,6 @@ type SDKRegistry struct {
22
23
const sdkLeaseTTL = 30 * time.Second
24
26
-func reverseTokenMatches(expected, provided string) bool {
27
- expected = strings.TrimSpace(expected)
28
- provided = strings.TrimSpace(provided)
29
- if expected == "" || provided == "" {
30
- return false
31
- }
32
- return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
33
-}
34
-
25
func normalizeLeaseID(raw string) string {
26
return strings.TrimSpace(raw)
27
}
@@ -55,10 +45,6 @@ func (r *SDKRegistry) extractClientIP(req *http.Request) string {
45
return manager.ExtractClientIP(req, r.trustProxyHeaders)
46
}
47
58
-func (r *SDKRegistry) isSecureConnectRequest(req *http.Request) bool {
59
- return isSecureRequestWithPolicy(req, r.trustProxyHeaders)
60
-}
61
-
48
func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
49
return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
50
}
@@ -127,41 +113,17 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
113
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
114
return
115
}
130
- if !r.isSecureConnectRequest(req) {
131
- writeAPIError(w, http.StatusUpgradeRequired, "tls_required", "tls reverse connect required")
132
- return
133
- }
134
-
135
- leaseID, token := normalizeLeaseCredentials(
136
- req.URL.Query().Get("lease_id"),
137
- req.Header.Get(portal.ReverseConnectTokenHeader),
138
- )
139
- if leaseID == "" {
140
- writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
141
- return
142
- }
143
- if token == "" {
144
- writeAPIError(w, http.StatusUnauthorized, "missing_reverse_token", "reverse_token is required")
145
- return
146
- }
116
if isWebSocketUpgrade(req) {
117
writeAPIError(w, http.StatusBadRequest, "unsupported_transport", "websocket transport is not supported")
118
return
119
}
120
152
- clientIP := r.extractClientIP(req)
153
- if r.isClientIPBanned(clientIP) {
154
- writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
155
- return
156
- }
157
-
158
- entry, ok := lookupLeaseEntry(serv, leaseID)
121
+ leaseID := req.URL.Query().Get("lease_id")
122
+ token := req.Header.Get(portal.ReverseConnectTokenHeader)
123
+ ctx, ok := r.admitControlPlane(w, req, serv, leaseID, token, admissionConfig{
124
+ requireExistingLease: true,
125
+ })
126
if !ok {
160
- writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
161
- return
162
- }
163
- if !reverseTokenMatches(entry.Lease.ReverseToken, token) {
164
- writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
127
return
128
}
129
@@ -188,7 +150,7 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
150
return
151
}
152
191
- serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
153
+ serv.GetReverseHub().HandleConnect(conn, ctx.leaseID, ctx.token, ctx.clientIP)
154
}
155
156
// handleRegister handles SDK lease registration requests.
@@ -202,14 +164,8 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
164
return
165
}
166
205
- registerReq.LeaseID, registerReq.ReverseToken = normalizeLeaseCredentials(registerReq.LeaseID, registerReq.ReverseToken)
167
registerReq.Name = strings.TrimSpace(registerReq.Name)
207
-
208
- if !r.validateLeaseCredentials(w, registerReq.LeaseID, registerReq.ReverseToken) {
209
- return
210
- }
211
- name := registerReq.Name
212
- if !types.IsValidLeaseName(name) {
168
+ if !types.IsValidLeaseName(registerReq.Name) {
169
writeAPIError(w, http.StatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
170
return
171
}
@@ -217,46 +173,37 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
173
writeAPIError(w, http.StatusBadRequest, "tls_required", "tls must be enabled")
174
return
175
}
220
- if r.isClientIPBanned(r.extractClientIP(req)) {
221
- writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
176
+ ctx, ok := r.admitControlPlane(w, req, serv, registerReq.LeaseID, registerReq.ReverseToken, admissionConfig{})
177
+ if !ok {
178
return
179
}
224
-
225
- // Ownership semantics: re-registration of an existing lease ID requires the same reverse token.
226
- if entry, ok := lookupLeaseEntry(serv, registerReq.LeaseID); ok {
227
- if !reverseTokenMatches(entry.Lease.ReverseToken, registerReq.ReverseToken) {
228
- writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized lease registration")
229
- return
230
- }
231
- }
180
+ registerReq.LeaseID = ctx.leaseID
181
+ registerReq.ReverseToken = ctx.token
182
183
// Create lease
184
lease := &portal.Lease{
185
ID: registerReq.LeaseID,
236
- Name: name,
186
+ Name: registerReq.Name,
187
Metadata: registerReq.Metadata,
188
Expires: time.Now().Add(sdkLeaseTTL),
189
TLS: true,
190
ReverseToken: registerReq.ReverseToken,
191
}
192
243
- // Register with lease manager
193
if !serv.GetLeaseManager().UpdateLease(lease) {
194
writeAPIError(w, http.StatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
195
return
196
}
197
249
- // Clear dropped state in case this is a re-registration after disconnect
198
serv.GetReverseHub().ClearDropped(registerReq.LeaseID)
199
252
- sniName := types.BuildSNIName(name, serv.BaseHost)
200
+ sniName := types.BuildSNIName(registerReq.Name, serv.BaseHost)
201
if sniName == "" {
202
serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
203
writeAPIError(w, http.StatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
204
return
205
}
258
- if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, name); err != nil {
259
- // Keep lease and route state consistent on partial failure.
206
+ if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, registerReq.Name); err != nil {
207
serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
208
writeAPIError(w, http.StatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
209
return
@@ -264,12 +211,11 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
211
212
log.Info().
213
Str("lease_id", registerReq.LeaseID).
267
- Str("name", name).
214
+ Str("name", registerReq.Name).
215
Bool("tls", true).
216
Msg("[Registry] Lease registered")
217
271
- // Build public URL
272
- publicURL := types.ServicePublicURL(flagPortalURL, name)
218
+ publicURL := types.ServicePublicURL(flagPortalURL, registerReq.Name)
219
220
writeAPIData(w, http.StatusOK, types.RegisterResponse{
221
LeaseID: registerReq.LeaseID,
@@ -288,16 +234,20 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
234
if !r.decodeRequestBody(w, req, &unregisterReq, "[Registry] Failed to decode unregistration request") {
235
return
236
}
291
- unregisterReq.LeaseID = normalizeLeaseID(unregisterReq.LeaseID)
237
+ ctx, ok := r.admitControlPlane(w, req, serv, unregisterReq.LeaseID, unregisterReq.ReverseToken, admissionConfig{
238
+ requireExistingLease: true,
239
+ })
240
+ if !ok {
241
+ return
242
+ }
243
293
- // Delete from lease manager
294
- if serv.GetLeaseManager().DeleteLease(unregisterReq.LeaseID) {
244
+ if serv.GetLeaseManager().DeleteLease(ctx.leaseID) {
245
log.Info().
296
- Str("lease_id", unregisterReq.LeaseID).
246
+ Str("lease_id", ctx.leaseID).
247
Msg("[Registry] Lease unregistered")
248
}
299
- serv.GetSNIRouter().UnregisterRouteByLeaseID(unregisterReq.LeaseID)
300
- serv.GetReverseHub().DropLease(unregisterReq.LeaseID)
249
+ serv.GetSNIRouter().UnregisterRouteByLeaseID(ctx.leaseID)
250
+ serv.GetReverseHub().DropLease(ctx.leaseID)
251
252
writeAPIOK(w, http.StatusOK)
253
}
@@ -313,30 +263,20 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
263
return
264
}
265
316
- renewReq.LeaseID, renewReq.ReverseToken = normalizeLeaseCredentials(renewReq.LeaseID, renewReq.ReverseToken)
317
- if !r.validateLeaseCredentials(w, renewReq.LeaseID, renewReq.ReverseToken) {
318
- return
319
- }
320
-
321
- // Get existing lease
322
- entry, ok := lookupLeaseEntry(serv, renewReq.LeaseID)
266
+ ctx, ok := r.admitControlPlane(w, req, serv, renewReq.LeaseID, renewReq.ReverseToken, admissionConfig{
267
+ requireExistingLease: true,
268
+ })
269
if !ok {
324
- writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
325
- return
326
- }
327
- if !reverseTokenMatches(entry.Lease.ReverseToken, renewReq.ReverseToken) {
328
- writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized lease renewal")
270
return
271
}
272
332
- // Update expiration
273
+ entry := ctx.entry
274
entry.Lease.Expires = time.Now().Add(sdkLeaseTTL)
275
if !serv.GetLeaseManager().UpdateLease(entry.Lease) {
276
writeAPIError(w, http.StatusInternalServerError, "renew_failed", "failed to renew lease")
277
return
278
}
279
339
- // Transport is TLS reverse-connect only; keep SNI route refreshed on renew.
280
sniName := types.BuildSNIName(entry.Lease.Name, serv.BaseHost)
281
if sniName == "" {
282
log.Warn().
cmd/relay-server/registry_test.go
+253
-50
@@ -4,10 +4,12 @@ import (
4
"bytes"
5
"context"
6
"crypto/tls"
7
+ "crypto/x509"
8
+ "crypto/x509/pkix"
9
"encoding/json"
8
- "net"
10
"net/http"
11
"net/http/httptest"
12
+ "net/url"
13
"strings"
14
"testing"
15
"time"
@@ -17,6 +19,23 @@ import (
19
"gosuda.org/portal/types"
20
)
21
22
+func attachPeerLeaseCertificate(req *http.Request, leaseID string) {
23
+ leaseURI, _ := url.Parse(controlPlaneLeaseURIPfx + leaseID)
24
+ req.TLS = &tls.ConnectionState{
25
+ PeerCertificates: []*x509.Certificate{
26
+ {
27
+ NotBefore: time.Now().Add(-1 * time.Minute),
28
+ NotAfter: time.Now().Add(1 * time.Hour),
29
+ URIs: []*url.URL{leaseURI},
30
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
31
+ Subject: pkix.Name{
32
+ CommonName: controlPlaneCertCNPrefix + leaseID,
33
+ },
34
+ },
35
+ },
36
+ }
37
+}
38
+
39
func decodeAPIRawEnvelope(t *testing.T, rec *httptest.ResponseRecorder) types.APIRawEnvelope {
40
t.Helper()
41
@@ -59,6 +78,7 @@ func TestSDKRegistryHandleRegisterTrimsReverseToken(t *testing.T) {
78
}
79
80
req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, bytes.NewReader(body))
81
+ attachPeerLeaseCertificate(req, payload.LeaseID)
82
rec := httptest.NewRecorder()
83
registry.handleRegister(rec, req, serv)
84
@@ -112,6 +132,7 @@ func TestSDKRegistryHandleRenewAcceptsTrimmedReverseToken(t *testing.T) {
132
}
133
134
req := httptest.NewRequest(http.MethodPost, types.PathSDKRenew, bytes.NewReader(body))
135
+ attachPeerLeaseCertificate(req, payload.LeaseID)
136
rec := httptest.NewRecorder()
137
registry.handleRenew(rec, req, serv)
138
@@ -124,6 +145,196 @@ func TestSDKRegistryHandleRenewAcceptsTrimmedReverseToken(t *testing.T) {
145
}
146
}
147
148
+func TestSDKRegistryHandleRenewRejectsBannedIP(t *testing.T) {
149
+ serv := newRegistryTestRelayServer(t)
150
+ ipManager := manager.NewIPManager()
151
+ ipManager.BanIP("203.0.113.22")
152
+ registry := &SDKRegistry{ipManager: ipManager}
153
+
154
+ lease := &portal.Lease{
155
+ ID: "lease-renew-ban",
156
+ Name: "tenant",
157
+ TLS: true,
158
+ ReverseToken: "renew-token",
159
+ Expires: time.Now().Add(30 * time.Second),
160
+ }
161
+ if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
162
+ t.Fatal("failed to seed lease")
163
+ }
164
+
165
+ payload := types.RenewRequest{
166
+ LeaseID: lease.ID,
167
+ ReverseToken: lease.ReverseToken,
168
+ }
169
+ body, err := json.Marshal(payload)
170
+ if err != nil {
171
+ t.Fatalf("marshal renew payload: %v", err)
172
+ }
173
+
174
+ req := httptest.NewRequest(http.MethodPost, types.PathSDKRenew, bytes.NewReader(body))
175
+ attachPeerLeaseCertificate(req, lease.ID)
176
+ req.RemoteAddr = "203.0.113.22:45000"
177
+ rec := httptest.NewRecorder()
178
+
179
+ registry.handleRenew(rec, req, serv)
180
+
181
+ if rec.Code != http.StatusForbidden {
182
+ t.Fatalf("handleRenew status = %d, want %d", rec.Code, http.StatusForbidden)
183
+ }
184
+ envelope := decodeAPIRawEnvelope(t, rec)
185
+ if envelope.Error == nil || envelope.Error.Code != "ip_banned" {
186
+ t.Fatalf("unexpected renew ip_banned payload: %+v", envelope.Error)
187
+ }
188
+}
189
+
190
+func TestSDKRegistryHandleRegisterRequiresClientCertificate(t *testing.T) {
191
+ serv := newRegistryTestRelayServer(t)
192
+ registry := &SDKRegistry{}
193
+
194
+ payload := types.RegisterRequest{
195
+ LeaseID: "lease-register-cert-required",
196
+ Name: "tenant",
197
+ TLS: true,
198
+ ReverseToken: "reverse-token",
199
+ }
200
+ body, err := json.Marshal(payload)
201
+ if err != nil {
202
+ t.Fatalf("marshal register payload: %v", err)
203
+ }
204
+
205
+ req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, bytes.NewReader(body))
206
+ req.TLS = &tls.ConnectionState{}
207
+ rec := httptest.NewRecorder()
208
+ registry.handleRegister(rec, req, serv)
209
+
210
+ if rec.Code != http.StatusUnauthorized {
211
+ t.Fatalf("handleRegister status = %d, want %d", rec.Code, http.StatusUnauthorized)
212
+ }
213
+ envelope := decodeAPIRawEnvelope(t, rec)
214
+ if envelope.OK {
215
+ t.Fatalf("expected register rejection, got %+v", envelope)
216
+ }
217
+ if envelope.Error == nil || envelope.Error.Code != "client_cert_required" {
218
+ t.Fatalf("unexpected register rejection payload: %+v", envelope.Error)
219
+ }
220
+}
221
+
222
+func TestSDKRegistryHandleUnregisterRequiresReverseToken(t *testing.T) {
223
+ serv := newRegistryTestRelayServer(t)
224
+ registry := &SDKRegistry{}
225
+
226
+ lease := &portal.Lease{
227
+ ID: "lease-unregister-token-required",
228
+ Name: "tenant",
229
+ TLS: true,
230
+ ReverseToken: "reverse-token",
231
+ Expires: time.Now().Add(30 * time.Second),
232
+ }
233
+ if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
234
+ t.Fatal("failed to seed lease")
235
+ }
236
+
237
+ payload := types.UnregisterRequest{
238
+ LeaseID: lease.ID,
239
+ }
240
+ body, err := json.Marshal(payload)
241
+ if err != nil {
242
+ t.Fatalf("marshal unregister payload: %v", err)
243
+ }
244
+
245
+ req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
246
+ attachPeerLeaseCertificate(req, lease.ID)
247
+ rec := httptest.NewRecorder()
248
+ registry.handleUnregister(rec, req, serv)
249
+
250
+ if rec.Code != http.StatusBadRequest {
251
+ t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusBadRequest)
252
+ }
253
+ envelope := decodeAPIRawEnvelope(t, rec)
254
+ if envelope.OK {
255
+ t.Fatalf("expected unregister rejection, got %+v", envelope)
256
+ }
257
+ if envelope.Error == nil || envelope.Error.Code != "missing_reverse_token" {
258
+ t.Fatalf("unexpected unregister rejection payload: %+v", envelope.Error)
259
+ }
260
+}
261
+
262
+func TestSDKRegistryHandleUnregisterWithValidIdentity(t *testing.T) {
263
+ serv := newRegistryTestRelayServer(t)
264
+ registry := &SDKRegistry{}
265
+
266
+ lease := &portal.Lease{
267
+ ID: "lease-unregister-success",
268
+ Name: "tenant",
269
+ TLS: true,
270
+ ReverseToken: "reverse-token",
271
+ Expires: time.Now().Add(30 * time.Second),
272
+ }
273
+ if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
274
+ t.Fatal("failed to seed lease")
275
+ }
276
+
277
+ payload := types.UnregisterRequest{
278
+ LeaseID: lease.ID,
279
+ ReverseToken: lease.ReverseToken,
280
+ }
281
+ body, err := json.Marshal(payload)
282
+ if err != nil {
283
+ t.Fatalf("marshal unregister payload: %v", err)
284
+ }
285
+
286
+ req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
287
+ attachPeerLeaseCertificate(req, lease.ID)
288
+ rec := httptest.NewRecorder()
289
+ registry.handleUnregister(rec, req, serv)
290
+
291
+ if rec.Code != http.StatusOK {
292
+ t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusOK)
293
+ }
294
+ if _, ok := serv.GetLeaseManager().GetLeaseByID(lease.ID); ok {
295
+ t.Fatalf("lease %q should be removed after unregister", lease.ID)
296
+ }
297
+}
298
+
299
+func TestSDKRegistryHandleUnregisterRejectsTokenMismatch(t *testing.T) {
300
+ serv := newRegistryTestRelayServer(t)
301
+ registry := &SDKRegistry{}
302
+
303
+ lease := &portal.Lease{
304
+ ID: "lease-unregister-token-mismatch",
305
+ Name: "tenant",
306
+ TLS: true,
307
+ ReverseToken: "correct-token",
308
+ Expires: time.Now().Add(30 * time.Second),
309
+ }
310
+ if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
311
+ t.Fatal("failed to seed lease")
312
+ }
313
+
314
+ payload := types.UnregisterRequest{
315
+ LeaseID: lease.ID,
316
+ ReverseToken: "wrong-token",
317
+ }
318
+ body, err := json.Marshal(payload)
319
+ if err != nil {
320
+ t.Fatalf("marshal unregister payload: %v", err)
321
+ }
322
+
323
+ req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
324
+ attachPeerLeaseCertificate(req, lease.ID)
325
+ rec := httptest.NewRecorder()
326
+
327
+ registry.handleUnregister(rec, req, serv)
328
+
329
+ if rec.Code != http.StatusUnauthorized {
330
+ t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusUnauthorized)
331
+ }
332
+ envelope := decodeAPIRawEnvelope(t, rec)
333
+ if envelope.Error == nil || envelope.Error.Code != "unauthorized" {
334
+ t.Fatalf("unexpected unregister mismatch payload: %+v", envelope.Error)
335
+ }
336
+}
337
+
338
func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
339
serv := newRegistryTestRelayServer(t)
340
ipManager := manager.NewIPManager()
@@ -134,7 +345,7 @@ func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
345
}
346
347
req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-ban", http.NoBody)
137
- req.TLS = &tls.ConnectionState{}
348
+ attachPeerLeaseCertificate(req, "lease-connect-ban")
349
req.RemoteAddr = "203.0.113.22:45000"
350
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
351
rec := httptest.NewRecorder()
@@ -153,91 +364,83 @@ func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
364
}
365
}
366
156
-func TestSDKRegistryHandleConnectRequiresTLS(t *testing.T) {
367
+func TestSDKRegistryHandleConnectRejectsMissingLease(t *testing.T) {
368
serv := newRegistryTestRelayServer(t)
369
registry := &SDKRegistry{}
370
160
- req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-tls", http.NoBody)
371
+ req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=missing-lease", http.NoBody)
372
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
373
+ attachPeerLeaseCertificate(req, "missing-lease")
374
rec := httptest.NewRecorder()
375
376
registry.handleConnect(rec, req, serv)
377
166
- if rec.Code != http.StatusUpgradeRequired {
167
- t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUpgradeRequired)
378
+ if rec.Code != http.StatusNotFound {
379
+ t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusNotFound)
380
}
381
envelope := decodeAPIRawEnvelope(t, rec)
170
- if envelope.OK {
171
- t.Fatalf("expected tls_required response to fail, got %+v", envelope)
172
- }
173
- if envelope.Error == nil || envelope.Error.Code != "tls_required" || envelope.Error.Message != "tls reverse connect required" {
174
- t.Fatalf("unexpected tls_required payload: %+v", envelope.Error)
382
+ if envelope.Error == nil || envelope.Error.Code != "lease_not_found" {
383
+ t.Fatalf("unexpected lease_not_found payload: %+v", envelope.Error)
384
}
385
}
386
178
-func TestSDKRegistryHandleConnectAcceptsTrustedProxyHTTPS(t *testing.T) {
387
+func TestSDKRegistryHandleConnectRejectsCertLeaseMismatch(t *testing.T) {
388
serv := newRegistryTestRelayServer(t)
180
- registry := &SDKRegistry{trustProxyHeaders: true}
389
+ registry := &SDKRegistry{}
390
182
- _, trustedCIDR, err := net.ParseCIDR("10.0.0.0/8")
183
- if err != nil {
184
- t.Fatalf("parse trusted proxy cidr: %v", err)
391
+ lease := &portal.Lease{
392
+ ID: "lease-cert-mismatch",
393
+ Name: "tenant",
394
+ TLS: true,
395
+ ReverseToken: "reverse-token",
396
+ Expires: time.Now().Add(30 * time.Second),
397
+ }
398
+ if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
399
+ t.Fatal("failed to seed lease")
400
}
186
- manager.SetTrustedProxyCIDRs([]*net.IPNet{trustedCIDR})
187
- t.Cleanup(func() {
188
- manager.SetTrustedProxyCIDRs(nil)
189
- })
401
191
- req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
192
- req.RemoteAddr = "10.1.2.3:443"
193
- req.Header.Set("X-Forwarded-Proto", "https")
194
- req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
402
+ req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id="+lease.ID, http.NoBody)
403
+ req.Header.Set(portal.ReverseConnectTokenHeader, lease.ReverseToken)
404
+ attachPeerLeaseCertificate(req, "other-lease")
405
rec := httptest.NewRecorder()
406
407
registry.handleConnect(rec, req, serv)
408
199
- if rec.Code != http.StatusBadRequest {
200
- t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
409
+ if rec.Code != http.StatusUnauthorized {
410
+ t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUnauthorized)
411
}
412
envelope := decodeAPIRawEnvelope(t, rec)
203
- if envelope.OK {
204
- t.Fatalf("expected missing_lease_id response to fail, got %+v", envelope)
205
- }
206
- if envelope.Error == nil || envelope.Error.Code != "missing_lease_id" || envelope.Error.Message != "lease_id is required" {
207
- t.Fatalf("unexpected missing_lease_id payload: %+v", envelope.Error)
413
+ if envelope.Error == nil || envelope.Error.Code != "cert_lease_mismatch" {
414
+ t.Fatalf("unexpected cert_lease_mismatch payload: %+v", envelope.Error)
415
}
416
}
417
211
-func TestSDKRegistryHandleConnectRejectsUntrustedProxyHTTPS(t *testing.T) {
418
+func TestSDKRegistryHandleConnectRequiresTLS(t *testing.T) {
419
serv := newRegistryTestRelayServer(t)
213
- registry := &SDKRegistry{trustProxyHeaders: true}
214
-
215
- _, trustedCIDR, err := net.ParseCIDR("10.0.0.0/8")
216
- if err != nil {
217
- t.Fatalf("parse trusted proxy cidr: %v", err)
218
- }
219
- manager.SetTrustedProxyCIDRs([]*net.IPNet{trustedCIDR})
220
- t.Cleanup(func() {
221
- manager.SetTrustedProxyCIDRs(nil)
420
+ registry := &SDKRegistry{}
421
+ serv.GetLeaseManager().UpdateLease(&portal.Lease{
422
+ ID: "lease-connect-tls",
423
+ Name: "lease-connect-tls",
424
+ ReverseToken: "reverse-token",
425
+ Expires: time.Now().Add(time.Hour),
426
+ TLS: true,
427
})
428
224
- req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
225
- req.RemoteAddr = "198.51.100.44:443"
226
- req.Header.Set("X-Forwarded-Proto", "https")
429
+ req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-tls", http.NoBody)
430
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
431
rec := httptest.NewRecorder()
432
433
registry.handleConnect(rec, req, serv)
434
232
- if rec.Code != http.StatusUpgradeRequired {
233
- t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUpgradeRequired)
435
+ if rec.Code != http.StatusUnauthorized {
436
+ t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUnauthorized)
437
}
438
envelope := decodeAPIRawEnvelope(t, rec)
439
if envelope.OK {
237
- t.Fatalf("expected tls_required response to fail, got %+v", envelope)
440
+ t.Fatalf("expected client_cert_required response to fail, got %+v", envelope)
441
}
239
- if envelope.Error == nil || envelope.Error.Code != "tls_required" || envelope.Error.Message != "tls reverse connect required" {
240
- t.Fatalf("unexpected tls_required payload: %+v", envelope.Error)
442
+ if envelope.Error == nil || envelope.Error.Code != "client_cert_required" {
443
+ t.Fatalf("unexpected client_cert_required payload: %+v", envelope.Error)
444
}
445
}
446
@@ -246,7 +449,7 @@ func TestSDKRegistryHandleConnectMissingLeaseIDReturnsEnvelope(t *testing.T) {
449
registry := &SDKRegistry{}
450
451
req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
249
- req.TLS = &tls.ConnectionState{}
452
+ attachPeerLeaseCertificate(req, "lease-missing")
453
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
454
rec := httptest.NewRecorder()
455