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