remove mtls and controlplane

Kim committed Mar 5, 2026 at 14:16 UTC 3a49139554ee126a1dea4de34f4edd74550266c3
19 files changed +467 -1629
AGENTS.md
+4 -4
@@ -43,10 +43,10 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
43 1. **Relay holds the TLS private key for admin/API (root domain) only.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for admin/API TLS termination. For SNI-passthrough routes, the relay peeks the ClientHello for SNI then bridges the raw encrypted connection — the backend/tunnel endpoint terminates TLS and holds those keys, not the relay.
44 - Why: admin/API key material stays on the relay; tenant TLS passthrough avoids key distribution to the relay entirely.
45
46 -2. **mTLS is implicit (optional) for `/sdk/*` control-plane paths.** When a client cert is presented, the relay validates it (CertBind stage). When absent, CertBind is skipped and token auth alone is used.
47 - - `KEYLESS_DIR` env var presence triggers SDK lifecycle identity issuance and client cert presentation. When unset, the SDK operates in token-only mode.
48 - - Keyless TLS (`RemoteSigner` for `/v1/sign`) is independent of mTLS — always used for admin/API TLS termination regardless of client cert presence.
49 - - Why: ADR-0003 admission order is IP ban → Lease → [CertBind if cert present] → Token. Invalid certs are still rejected; absent certs skip CertBind.
46 +2. **/sdk/* control-plane auth is token.** Admission order is IP ban -> Lease -> Token.
47 + - Admin/API TLS listener does not request client certificates.
48 + - Keyless TLS (RemoteSigner for /v1/sign) remains independent and is still used for admin/API TLS termination.
49 + - Why: removes browser client-cert prompt side effects while keeping centralized token/IP policy enforcement.
50
51 3. **All relay URLs must be `https://`.** `NormalizeRelayAPIURL` rejects non-HTTPS. SDK and tunnel hard-fail on `http://`.
52 - Why: enforces transport security without opt-out.
cmd/portal-tunnel/README.md
+4 -4
@@ -26,12 +26,12 @@ Portal tunnel always runs in TLS reverse-connect mode:
26 - Traffic is proxied from tunnel to local `--host` over TCP.
27 - Public access is `https://<service>.<portal-root-host>/`.
28
29 -### Lifecycle Identity
29 +### Control-Plane Admission
30
31 -The tunnel automatically acquires a per-lease mTLS identity via the relay's control plane. Identity materials are managed by `keyless_tls/keyless/lifecycle` and stored encrypted on disk under `KEYLESS_DIR/lifecycle-identities/`.
31 +Portal tunnel authenticates control-plane operations with lease token headers.
32
33 -- `KEYLESS_DIR` defaults to `/etc/portal/keyless`. The tunnel must have read/write access to this directory.
34 -- If the relay's issuer certificate or key is unavailable, the tunnel fails at startup.
33 +- No client certificate setup is required for `/sdk/*` requests.
34 +- The relay enforces token and policy checks before accepting reverse connections.
35
36 ## Flags
37
cmd/relay-server/registry.go
+29 -124
@@ -2,21 +2,16 @@ package main
2
3 import (
4 "encoding/json"
5 - "errors"
6 - "net"
5 "net/http"
6 "strings"
7
8 "github.com/rs/zerolog/log"
9
10 "gosuda.org/portal/portal"
13 - controlplaneregistry "gosuda.org/portal/portal/controlplane/registry"
11 "gosuda.org/portal/portal/policy"
12 "gosuda.org/portal/types"
13 )
14
18 -var errRegistryBackendUnavailable = errors.New("registry backend unavailable")
19 -
15 // SDKRegistry handles HTTP API for client lease registration.
16 type SDKRegistry struct {
17 ipManager *policy.IPFilter
@@ -26,8 +21,7 @@ type SDKRegistry struct {
21
22 // HandleSDKRequest routes /sdk/* requests.
23 func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
29 - registryService, err := r.newService(serv)
30 - if err != nil {
24 + if serv == nil {
25 writeAPIError(w, http.StatusInternalServerError, "registry_unavailable", "registry service unavailable")
26 return
27 }
@@ -35,21 +29,21 @@ func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request,
29 path := strings.TrimSuffix(req.URL.Path, "/")
30 switch path {
31 case types.PathSDKRegister:
38 - r.handleRegister(w, req, registryService)
32 + r.handleRegister(w, req, serv)
33 case types.PathSDKUnregister:
40 - r.handleUnregister(w, req, registryService)
34 + r.handleUnregister(w, req, serv)
35 case types.PathSDKRenew:
42 - r.handleRenew(w, req, registryService)
36 + r.handleRenew(w, req, serv)
37 case types.PathSDKDomain:
44 - r.handleDomain(w, registryService)
38 + r.handleDomain(w, serv)
39 case types.PathSDKConnect:
46 - r.handleConnect(w, req, registryService)
40 + r.handleConnect(w, req, serv)
41 default:
42 http.NotFound(w, req)
43 }
44 }
45
52 -func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
46 +func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
47 if req.Method != http.MethodGet {
48 w.Header().Set("Allow", http.MethodGet)
49 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -63,7 +57,7 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, re
57 admission, ok := r.admitControlPlane(
58 w,
59 req,
66 - registryService,
60 + serv,
61 req.URL.Query().Get("lease_id"),
62 req.Header.Get(types.ReverseConnectTokenHeader),
63 true,
@@ -95,11 +89,11 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, re
89 return
90 }
91
98 - registryService.HandleConnect(conn, admission)
92 + serv.HandleRegistryConnect(conn, admission)
93 }
94
95 // handleRegister handles SDK lease registration requests.
102 -func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
96 +func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
97 if !r.requireMethod(w, req, http.MethodPost) {
98 return
99 }
@@ -112,7 +106,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, r
106 admission, ok := r.admitControlPlane(
107 w,
108 req,
115 - registryService,
109 + serv,
110 registerReq.LeaseID,
111 registerReq.ReverseToken,
112 false,
@@ -121,7 +115,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, r
115 return
116 }
117
124 - registerResp, apiErr := registryService.Register(controlplaneregistry.RegisterInput{
118 + registerResp, apiErr := serv.RegisterLease(portal.RegistryRegisterInput{
119 LeaseID: admission.LeaseID,
120 ReverseToken: admission.ReverseToken,
121 Name: registerReq.Name,
@@ -137,7 +131,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, r
131 }
132
133 // handleUnregister handles SDK lease unregistration requests.
140 -func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
134 +func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
135 if !r.requireMethod(w, req, http.MethodPost) {
136 return
137 }
@@ -150,7 +144,7 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
144 admission, ok := r.admitControlPlane(
145 w,
146 req,
153 - registryService,
147 + serv,
148 unregisterReq.LeaseID,
149 unregisterReq.ReverseToken,
150 true,
@@ -159,12 +153,12 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
153 return
154 }
155
162 - registryService.Unregister(admission.LeaseID)
156 + serv.UnregisterLease(admission.LeaseID)
157 writeAPIOK(w, http.StatusOK)
158 }
159
160 // handleRenew handles SDK lease renewal requests (keepalive).
167 -func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
161 +func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
162 if !r.requireMethod(w, req, http.MethodPost) {
163 return
164 }
@@ -177,7 +171,7 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, regi
171 admission, ok := r.admitControlPlane(
172 w,
173 req,
180 - registryService,
174 + serv,
175 renewReq.LeaseID,
176 renewReq.ReverseToken,
177 true,
@@ -186,15 +180,15 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, regi
180 return
181 }
182
189 - if !writeRegistryError(w, registryService.Renew(admission.Entry)) {
183 + if !writeRegistryError(w, serv.RenewLease(admission.Entry)) {
184 return
185 }
186 writeAPIOK(w, http.StatusOK)
187 }
188
189 // handleDomain returns the relay's base domain for TLS certificate construction.
196 -func (r *SDKRegistry) handleDomain(w http.ResponseWriter, registryService *controlplaneregistry.Service) {
197 - domainResp, apiErr := registryService.Domain()
190 +func (r *SDKRegistry) handleDomain(w http.ResponseWriter, serv *portal.RelayServer) {
191 + domainResp, apiErr := serv.RegistryDomain()
192 if !writeRegistryError(w, apiErr) {
193 return
194 }
@@ -204,37 +198,24 @@ func (r *SDKRegistry) handleDomain(w http.ResponseWriter, registryService *contr
198 func (r *SDKRegistry) admitControlPlane(
199 w http.ResponseWriter,
200 req *http.Request,
207 - registryService *controlplaneregistry.Service,
201 + serv *portal.RelayServer,
202 rawLeaseID, rawToken string,
203 requireExistingLease bool,
210 -) (controlplaneregistry.AdmissionResult, bool) {
204 +) (portal.RegistryAdmissionResult, bool) {
205 clientIP := policy.ExtractClientIP(req, r.trustProxyHeaders)
212 - admission, apiErr := registryService.Admit(controlplaneregistry.AdmissionInput{
213 - RawLeaseID: rawLeaseID,
214 - RawReverseToken: rawToken,
215 - ClientIP: clientIP,
216 - IsClientIPBanned: policy.IsIPBannedByPolicy(r.ipManager, clientIP),
217 - RequireExisting: requireExistingLease,
218 - ConnectionTLSState: req.TLS,
206 + admission, apiErr := serv.AdmitControlPlane(portal.RegistryAdmissionInput{
207 + RawLeaseID: rawLeaseID,
208 + RawReverseToken: rawToken,
209 + ClientIP: clientIP,
210 + IsClientIPBanned: policy.IsIPBannedByPolicy(r.ipManager, clientIP),
211 + RequireExisting: requireExistingLease,
212 })
213 if !writeRegistryError(w, apiErr) {
221 - return controlplaneregistry.AdmissionResult{}, false
214 + return portal.RegistryAdmissionResult{}, false
215 }
216 return admission, true
217 }
218
226 -func (r *SDKRegistry) newService(serv *portal.RelayServer) (*controlplaneregistry.Service, error) {
227 - if serv == nil {
228 - return nil, errRegistryBackendUnavailable
229 - }
230 - return controlplaneregistry.NewService(
231 - newRelayRegistryBackend(serv),
232 - controlplaneregistry.Options{
233 - LeaseTTL: controlplaneregistry.DefaultLeaseTTL,
234 - },
235 - )
236 -}
237 -
219 func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
220 if req.Method == method {
221 return true
@@ -261,79 +242,3 @@ func writeRegistryError(w http.ResponseWriter, apiErr *types.APIError) bool {
242 writeAPIError(w, apiErr.StatusCode, apiErr.Code, apiErr.Message)
243 return false
244 }
264 -
265 -type relayRegistryBackend struct {
266 - serv *portal.RelayServer
267 -}
268 -
269 -func newRelayRegistryBackend(serv *portal.RelayServer) *relayRegistryBackend {
270 - return &relayRegistryBackend{serv: serv}
271 -}
272 -
273 -func (b *relayRegistryBackend) BaseHost() string {
274 - if b.serv == nil {
275 - return ""
276 - }
277 - return b.serv.BaseHost
278 -}
279 -
280 -func (b *relayRegistryBackend) UpdateLease(lease *types.Lease) bool {
281 - if b.serv == nil || b.serv.GetLeaseManager() == nil {
282 - return false
283 - }
284 - return b.serv.GetLeaseManager().UpdateLease(lease)
285 -}
286 -
287 -func (b *relayRegistryBackend) DeleteLease(leaseID string) bool {
288 - if b.serv == nil || b.serv.GetLeaseManager() == nil {
289 - return false
290 - }
291 - return b.serv.GetLeaseManager().DeleteLease(leaseID)
292 -}
293 -
294 -func (b *relayRegistryBackend) GetLeaseByID(leaseID string) (*types.LeaseEntry, bool) {
295 - if b.serv == nil || b.serv.GetLeaseManager() == nil {
296 - return nil, false
297 - }
298 - return b.serv.GetLeaseManager().GetLeaseByID(leaseID)
299 -}
300 -
301 -func (b *relayRegistryBackend) ClearDropped(leaseID string) {
302 - if b.serv == nil || b.serv.GetReverseHub() == nil {
303 - return
304 - }
305 - b.serv.GetReverseHub().ClearDropped(leaseID)
306 -}
307 -
308 -func (b *relayRegistryBackend) DropLease(leaseID string) {
309 - if b.serv == nil || b.serv.GetReverseHub() == nil {
310 - return
311 - }
312 - b.serv.GetReverseHub().DropLease(leaseID)
313 -}
314 -
315 -func (b *relayRegistryBackend) RegisterRoute(sniName, leaseID, name string) error {
316 - if b.serv == nil || b.serv.GetSNIRouter() == nil {
317 - return errRegistryBackendUnavailable
318 - }
319 - return b.serv.GetSNIRouter().RegisterRoute(sniName, leaseID, name)
320 -}
321 -
322 -func (b *relayRegistryBackend) UnregisterRouteByLeaseID(leaseID string) {
323 - if b.serv == nil || b.serv.GetSNIRouter() == nil {
324 - return
325 - }
326 - b.serv.GetSNIRouter().UnregisterRouteByLeaseID(leaseID)
327 -}
328 -
329 -func (b *relayRegistryBackend) HandleConnect(conn net.Conn, leaseID, token, clientIP string) {
330 - if b.serv == nil || b.serv.GetReverseHub() == nil {
331 - if conn != nil {
332 - if err := conn.Close(); err != nil {
333 - log.Debug().Err(err).Msg("[Registry] failed to close reverse connection after backend lookup failure")
334 - }
335 - }
336 - return
337 - }
338 - b.serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
339 -}
cmd/relay-server/serve.go
+1 -1
@@ -139,7 +139,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
139 rootHost := types.PortalRootHost(cfg.PortalURL)
140 srv.TLSConfig = &tls.Config{
141 MinVersion: tls.VersionTLS12,
142 - ClientAuth: tls.RequestClientCert,
142 + ClientAuth: tls.NoClientCert,
143 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
144 serverName := strings.TrimSpace(strings.ToLower(hello.ServerName))
145 if serverName != "" && !strings.EqualFold(serverName, rootHost) {
docs/adr/0003-security-and-anti-abuse-hardening.md
+2 -2
@@ -15,8 +15,8 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
15 - Enforce lease-token validation before bridging reverse connections.
16 - Keep root-domain and tenant-subdomain traffic split through SNI routing rules to prevent accidental cross-path handling.
17 - Standardize SDK endpoint handling: `/sdk/register` (and related SDK APIs) and `/sdk/connect` validation failures return JSON envelopes (`{ ok, error }`) with explicit error codes prior to connection hijack, and `/sdk/connect` remains subject to `ReverseHub` authorization before pooling.
18 -- Require lease-bound client mTLS identity on `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` with deterministic admission order `IP -> Lease -> CertBind -> Token`.
19 -- Enforce hard-break behavior for control-plane identity: non-mTLS clients fail admission deterministically and there is no token-only fallback mode.
18 +- Use token-only admission on `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` with deterministic order `IP -> Lease -> Token`.
19 +- Do not request or validate client certificates for `/sdk/*` runtime admission; authorization is enforced by lease token and policy checks.
20 - Enforce installer binary integrity with mandatory SHA256 sidecar verification (`${BIN_URL}.sha256`) and fail-closed behavior on verification errors.
21
22 Operator setup remains unchanged: no new relay flags/env vars are introduced for anti-abuse behavior.
docs/architecture.md
+12 -13
@@ -20,9 +20,9 @@ Client (Browser)
20 - Data-plane TLS behavior remains end-to-end between client and app/tunnel host.
21 - Relay forwards tenant traffic and does not replace app identity policy.
22 - Conn #2 (`relay -> tunnel`) is the control plane.
23 - - `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
24 - - Control-plane admission order is strict and deterministic: `IP -> Lease -> CertBind -> Token`.
25 - - Legacy non-mTLS clients are rejected at admission (hard-break behavior).
23 + - `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based control-plane admission.
24 + - Control-plane admission order is strict and deterministic: `IP -> Lease -> Token`.
25 + - Client certificates are not part of runtime control-plane admission.
26
27 ## Core Components
28
@@ -78,27 +78,26 @@ Result: the relay handles SNI-based routing and transparent raw TCP forwarding,
78 - `reverse_token`
79 - Relay stores lease and (TLS only) registers SNI route.
80 - Route hostnames are generated from normalized lease + normalized `PORTAL_URL` host (extract host from URL without scheme/port/path); path segments are ignored, so `https://portal.example.com:8443/admin` and `https://portal.example.com` both map to `portal.example.com`.
81 -- `/sdk/register` admission uses strict order: `IP -> Lease -> CertBind -> Token`.
81 +- `/sdk/register` admission uses strict order: `IP -> Lease -> Token`.
82
83 ### 2. Reverse Connect
84
85 - Backend opens a raw TCP reverse connection to `GET /sdk/connect` and streams traffic over that long-lived connection
86 - - `/sdk/connect` requires lease-bound client mTLS and applies strict admission order before hijacking:
87 - - `IP -> Lease -> CertBind -> Token`
88 - - Cert binding validates lease identity in client cert SAN/subject against request lease context.
86 + - `/sdk/connect` applies strict admission order before hijacking:
87 + - `IP -> Lease -> Token`
88 - `X-Portal-Reverse-Token` is validated at HTTP precheck, then validated again in `ReverseHub` with centralized policy callbacks before the connection is pooled.
89 - Connection is pooled in `ReverseHub` only after token/IP checks pass.
90
91 ### 3. Renew
92
93 - Backend sends `POST /sdk/renew` keepalive.
95 -- `/sdk/renew` requires both lease-bound mTLS identity and `reverse_token`.
94 +- `/sdk/renew` requires `reverse_token`.
95 - Relay refreshes lease TTL and keeps route state current.
96
97 ### 4. Unregister
98
99 - Backend sends `POST /sdk/unregister`.
101 -- `/sdk/unregister` validates normalized `lease_id`, lease-bound mTLS identity, and token before deletion.
100 +- `/sdk/unregister` validates normalized `lease_id` and token before deletion.
101 - Relay removes lease, route, and reverse pool.
102
103 ## Admin Lease ID Contract
@@ -132,15 +131,15 @@ Note: wildcard does not match the portal root host itself (`example.com` or `por
131 - Per-lease reverse token authorization
132 - Separation of control plane (`/sdk/*`) and data plane (SNI + raw TCP forwarding)
133 - Single relay/tunnel transport policy: raw TCP reverse-connect only
135 -- Mandatory control-plane identity policy: lease-bound mTLS with deterministic admission order
134 +- Control-plane identity policy: token-based admission with deterministic order
135 - Unified lease abstraction for routing, metadata, and lifecycle
136 - Shared anti-abuse path: admin-managed bans and lease authorization are enforced both in SDK registration and reverse admission
137
138 ## Breaking-Change Upgrade Expectations
139
141 -- This architecture wave is a hard-break for control-plane identity.
142 -- Tunnels/SDK clients that do not present valid lease-bound mTLS identity are rejected deterministically.
143 -- There is no token-only fallback mode after cutover.
140 +- Control-plane admission is token-only.
141 +- Tunnels/SDK clients must provide valid lease tokens for `/sdk/*` admission.
142 +- Client certificates are not required for `/sdk/*` admission.
143
144 ## ADRs
145
docs/deployment.md
+4 -5
@@ -50,12 +50,11 @@ Portal normalizes `PORTAL_URL` to its host for routing, so public service hosts
50 Requests to the exact `PORTAL_URL` host (for example, `portal.example.com`) are not wildcard-matched; the router uses no-route fallback and forwards them to the admin/API listener.
51 Relay/tunnel traffic for reverse admission stays raw TCP on `/sdk/connect`.
52
53 -### 2.4 Control-Plane Identity Requirements (Mandatory Upgrade)
53 +### 2.4 Control-Plane Admission (Token-Only)
54
55 -- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
56 -- Control-plane admission order is fixed: `IP -> Lease -> CertBind -> Token`.
57 -- Clients without valid lease-bound mTLS identity are rejected; there is no token-only runtime fallback.
58 -- Identity material must be stored under `KEYLESS_DIR` with owner-only file permissions and encrypted-at-rest policy enabled in your environment.
55 +- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based admission.
56 +- Control-plane admission order is fixed: `IP -> Lease -> Token`.
57 +- Clients without valid lease token are rejected.
58
59 ### 2.3 Create Cloudflare API Token
60
docs/glossary.md
+2 -2
@@ -25,7 +25,7 @@ This connection keeps existing data-plane TLS behavior.
25 ## Conn #2 (Control Plane)
26
27 Relay-to-tunnel control path used by `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister`.
28 -This connection requires lease-bound client mTLS identity with admission order `IP -> Lease -> CertBind -> Token`.
28 +This connection uses token-based admission with order `IP -> Lease -> Token`.
29
30 ## Tunnel
31
@@ -49,7 +49,7 @@ The human-readable identifier used for subdomain routing (for example, `myapp` -
49 ## Reverse Token
50
51 A per-lease secret used to authenticate reverse connections (`/sdk/connect`) from backend to relay.
52 -Token validation is a required admission stage, but only after lease-bound mTLS cert binding passes.
52 +Token validation is a required admission stage after lease and policy checks.
53
54 ## ReverseHub
55
frontend/README.md
+5 -5
@@ -139,8 +139,8 @@ Admin lease ID contract:
139 The relay enforces a consistent anti-abuse gate for both control APIs and reverse admission:
140
141 - `/sdk/register`, `/sdk/unregister`, `/sdk/renew`, and `/sdk/domain` return JSON envelopes (`{ ok, data, error }`).
142 -- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
143 -- Control-plane admission order is deterministic: `IP -> Lease -> CertBind -> Token`.
142 +- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based admission.
143 +- Control-plane admission order is deterministic: `IP -> Lease -> Token`.
144 - `/sdk/connect` is additionally re-validated inside `ReverseHub` before pooling so token and IP authorization are applied at both admission layers.
145
146 ### Run with Relay Server
@@ -170,12 +170,12 @@ npm run serve
170 ### Connection Responsibilities
171
172 - Conn #1 (`browser -> app`) is the data plane and keeps existing tenant-facing TLS behavior.
173 -- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease-bound mTLS identity.
173 +- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease token admission.
174
175 ### Breaking-Change Expectation
176
177 -- Non-mTLS control-plane clients are expected to fail admission after cutover.
178 -- There is no token-only fallback mode.
177 +- Clients with invalid lease tokens are expected to fail admission.
178 +- Client certificates are not required for `/sdk/*` admission.
179
180 ### Radix Select Values
181
portal/controlplane/controlplane.go deleted
-177
@@ -1,177 +0,0 @@
1 -package controlplane
2 -
3 -import (
4 - "crypto/ed25519"
5 - "crypto/rand"
6 - "crypto/subtle"
7 - "crypto/tls"
8 - "crypto/x509"
9 - "crypto/x509/pkix"
10 - "encoding/pem"
11 - "errors"
12 - "fmt"
13 - "math/big"
14 - "net/url"
15 - "slices"
16 - "strings"
17 - "time"
18 -)
19 -
20 -const (
21 - // ControlPlaneCertCNPrefix is the CN prefix used for lease-bound client identity certs.
22 - ControlPlaneCertCNPrefix = "lease:"
23 - // ControlPlaneLeaseURIPrefix is the URI prefix used in lease-bound SPIFFE-like identities.
24 - ControlPlaneLeaseURIPrefix = "spiffe://portal/lease/"
25 - // DefaultIdentityBackdate offsets notBefore to tolerate small clock skew.
26 - DefaultIdentityBackdate = 1 * time.Minute
27 - // DefaultIdentityTTL is the default issued identity lifetime.
28 - DefaultIdentityTTL = 24 * time.Hour
29 -)
30 -
31 -// IssuePolicy configures control-plane identity validity windows.
32 -type IssuePolicy struct {
33 - Backdate time.Duration
34 - TTL time.Duration
35 -}
36 -
37 -var defaultIssuePolicy = IssuePolicy{
38 - Backdate: DefaultIdentityBackdate,
39 - TTL: DefaultIdentityTTL,
40 -}
41 -
42 -// IssueIdentity issues a self-signed lease-bound client identity certificate
43 -// for control-plane mTLS.
44 -func IssueIdentity(leaseID string) (tls.Certificate, error) {
45 - return IssueIdentityWithPolicy(leaseID, defaultIssuePolicy)
46 -}
47 -
48 -// IssueIdentityWithPolicy issues a self-signed lease-bound client identity
49 -// certificate using explicit validity policy.
50 -func IssueIdentityWithPolicy(leaseID string, policy IssuePolicy) (tls.Certificate, error) {
51 - leaseID = strings.TrimSpace(leaseID)
52 - if leaseID == "" {
53 - return tls.Certificate{}, errors.New("lease id is required")
54 - }
55 - if policy.Backdate <= 0 {
56 - return tls.Certificate{}, errors.New("identity backdate must be greater than zero")
57 - }
58 - if policy.TTL <= 0 {
59 - return tls.Certificate{}, errors.New("identity ttl must be greater than zero")
60 - }
61 -
62 - pub, priv, err := ed25519.GenerateKey(rand.Reader)
63 - if err != nil {
64 - return tls.Certificate{}, fmt.Errorf("generate identity key: %w", err)
65 - }
66 -
67 - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
68 - if err != nil {
69 - return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
70 - }
71 - notBefore := time.Now().Add(-policy.Backdate)
72 - notAfter := notBefore.Add(policy.TTL)
73 -
74 - leaseURI, err := url.Parse(ControlPlaneLeaseURIPrefix + leaseID)
75 - if err != nil {
76 - return tls.Certificate{}, fmt.Errorf("build lease URI: %w", err)
77 - }
78 -
79 - template := &x509.Certificate{
80 - SerialNumber: serial,
81 - Subject: pkix.Name{
82 - CommonName: ControlPlaneCertCNPrefix + leaseID,
83 - },
84 - NotBefore: notBefore,
85 - NotAfter: notAfter,
86 - KeyUsage: x509.KeyUsageDigitalSignature,
87 - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
88 - BasicConstraintsValid: true,
89 - URIs: []*url.URL{leaseURI},
90 - }
91 -
92 - der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv)
93 - if err != nil {
94 - return tls.Certificate{}, fmt.Errorf("create lease identity certificate: %w", err)
95 - }
96 -
97 - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
98 - keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
99 - if err != nil {
100 - return tls.Certificate{}, fmt.Errorf("marshal identity key: %w", err)
101 - }
102 - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
103 -
104 - cert, err := tls.X509KeyPair(certPEM, keyPEM)
105 - if err != nil {
106 - return tls.Certificate{}, fmt.Errorf("load identity key pair: %w", err)
107 - }
108 - return cert, nil
109 -}
110 -
111 -// MatchLeaseToken compares lease-bound values in constant time.
112 -func MatchLeaseToken(expected, provided string) bool {
113 - expected = strings.TrimSpace(expected)
114 - provided = strings.TrimSpace(provided)
115 - if expected == "" || provided == "" {
116 - return false
117 - }
118 - return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
119 -}
120 -
121 -// ExtractLeaseIDFromPeerCertificate extracts lease identity from URI SAN first,
122 -// then CN as fallback.
123 -func ExtractLeaseIDFromPeerCertificate(cert *x509.Certificate) string {
124 - if cert == nil {
125 - return ""
126 - }
127 - for _, uri := range cert.URIs {
128 - if uri == nil {
129 - continue
130 - }
131 - raw := strings.TrimSpace(uri.String())
132 - if after, ok := strings.CutPrefix(raw, ControlPlaneLeaseURIPrefix); ok {
133 - return after
134 - }
135 - }
136 -
137 - commonName := strings.TrimSpace(cert.Subject.CommonName)
138 - if after, ok := strings.CutPrefix(commonName, ControlPlaneCertCNPrefix); ok {
139 - return after
140 - }
141 - return ""
142 -}
143 -
144 -// ValidatePeerLeaseCertificate validates lease-bound client certificate
145 -// material from an incoming TLS connection state.
146 -func ValidatePeerLeaseCertificate(state *tls.ConnectionState, leaseID string) (string, string, bool) {
147 - leaseID = strings.TrimSpace(leaseID)
148 - if leaseID == "" {
149 - return "missing_lease_id", "lease id is required", false
150 - }
151 - if state == nil || len(state.PeerCertificates) == 0 {
152 - return "client_cert_required", "client certificate is required", false
153 - }
154 -
155 - leaf := state.PeerCertificates[0]
156 - now := time.Now()
157 - if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
158 - return "client_cert_invalid", "client certificate is outside validity window", false
159 - }
160 -
161 - if len(leaf.ExtKeyUsage) == 0 {
162 - return "client_cert_invalid", "client certificate must include client authentication extended key usage", false
163 - }
164 - hasClientAuth := slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
165 - if !hasClientAuth {
166 - return "client_cert_invalid", "client certificate does not allow client authentication", false
167 - }
168 -
169 - certLeaseID := strings.TrimSpace(ExtractLeaseIDFromPeerCertificate(leaf))
170 - if certLeaseID == "" {
171 - return "cert_lease_missing", "client certificate does not include lease identity", false
172 - }
173 - if !MatchLeaseToken(leaseID, certLeaseID) {
174 - return "cert_lease_mismatch", fmt.Sprintf("client certificate lease identity mismatch: requested=%s cert=%s", leaseID, certLeaseID), false
175 - }
176 - return "", "", true
177 -}
portal/controlplane/controlplane_test.go deleted
-194
@@ -1,194 +0,0 @@
1 -package controlplane
2 -
3 -import (
4 - "crypto/ed25519"
5 - "crypto/tls"
6 - "crypto/x509"
7 - "crypto/x509/pkix"
8 - "net/url"
9 - "strings"
10 - "testing"
11 - "time"
12 -)
13 -
14 -func TestIssueIdentity(t *testing.T) {
15 - t.Parallel()
16 -
17 - identity, err := IssueIdentity("lease-identity")
18 - if err != nil {
19 - t.Fatalf("IssueIdentity returned error: %v", err)
20 - }
21 - if len(identity.Certificate) == 0 {
22 - t.Fatal("identity certificate chain is empty")
23 - }
24 -
25 - leaf, err := x509.ParseCertificate(identity.Certificate[0])
26 - if err != nil {
27 - t.Fatalf("parse issued certificate: %v", err)
28 - }
29 - if got := strings.TrimSpace(leaf.Subject.CommonName); got != ControlPlaneCertCNPrefix+"lease-identity" {
30 - t.Fatalf("certificate common name = %q, want %q", got, ControlPlaneCertCNPrefix+"lease-identity")
31 - }
32 - if len(leaf.URIs) == 0 || leaf.URIs[0].String() != ControlPlaneLeaseURIPrefix+"lease-identity" {
33 - t.Fatalf("certificate lease URI = %v, want %q", leaf.URIs, ControlPlaneLeaseURIPrefix+"lease-identity")
34 - }
35 - if time.Now().Before(leaf.NotBefore) || time.Now().After(leaf.NotAfter) {
36 - t.Fatalf("issued certificate validity window does not include current time")
37 - }
38 - if leaf.PublicKeyAlgorithm != x509.Ed25519 {
39 - t.Fatalf("public key algorithm = %v, want %v", leaf.PublicKeyAlgorithm, x509.Ed25519)
40 - }
41 - if _, ok := identity.PrivateKey.(ed25519.PrivateKey); !ok {
42 - t.Fatalf("private key type = %T, want ed25519.PrivateKey", identity.PrivateKey)
43 - }
44 -}
45 -
46 -func TestIssueIdentityRejectsEmptyLeaseID(t *testing.T) {
47 - t.Parallel()
48 -
49 - if _, err := IssueIdentity(" "); err == nil {
50 - t.Fatal("expected error for empty lease ID")
51 - }
52 -}
53 -
54 -func TestIssueIdentityWithPolicyRejectsInvalidTTL(t *testing.T) {
55 - t.Parallel()
56 -
57 - if _, err := IssueIdentityWithPolicy("lease-identity", IssuePolicy{
58 - Backdate: DefaultIdentityBackdate,
59 - TTL: 0,
60 - }); err == nil {
61 - t.Fatal("expected error for invalid ttl")
62 - }
63 -}
64 -
65 -func TestValidatePeerLeaseCertificate(t *testing.T) {
66 - t.Parallel()
67 -
68 - identity, err := IssueIdentity("lease-identity")
69 - if err != nil {
70 - t.Fatalf("IssueIdentity returned error: %v", err)
71 - }
72 - leaf, err := x509.ParseCertificate(identity.Certificate[0])
73 - if err != nil {
74 - t.Fatalf("parse issued certificate: %v", err)
75 - }
76 -
77 - state := &tls.ConnectionState{
78 - PeerCertificates: []*x509.Certificate{leaf},
79 - }
80 - if code, msg, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); !ok {
81 - t.Fatalf("ValidatePeerLeaseCertificate failed: code=%s msg=%s", code, msg)
82 - }
83 -}
84 -
85 -func TestValidatePeerLeaseCertificateRequiresClientAuthEKU(t *testing.T) {
86 - t.Parallel()
87 -
88 - leaseURI, err := url.Parse(ControlPlaneLeaseURIPrefix + "lease-identity")
89 - if err != nil {
90 - t.Fatalf("parse lease uri: %v", err)
91 - }
92 - state := &tls.ConnectionState{
93 - PeerCertificates: []*x509.Certificate{
94 - {
95 - NotBefore: time.Now().Add(-1 * time.Minute),
96 - NotAfter: time.Now().Add(1 * time.Minute),
97 - Subject: pkix.Name{
98 - CommonName: ControlPlaneCertCNPrefix + "lease-identity",
99 - },
100 - URIs: []*url.URL{leaseURI},
101 - },
102 - },
103 - }
104 -
105 - if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "client_cert_invalid" {
106 - t.Fatalf("expected client_cert_invalid for missing EKU, got code=%s ok=%v", code, ok)
107 - }
108 -}
109 -
110 -func TestValidatePeerLeaseCertificateRejectsMissingLeaseID(t *testing.T) {
111 - t.Parallel()
112 -
113 - state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{mustIssuedLeafCertificate(t, "lease-identity")}}
114 - if code, _, ok := ValidatePeerLeaseCertificate(state, " \t "); ok || code != "missing_lease_id" {
115 - t.Fatalf("expected missing_lease_id, got code=%s ok=%v", code, ok)
116 - }
117 -}
118 -
119 -func TestValidatePeerLeaseCertificateRequiresPeerCertificates(t *testing.T) {
120 - t.Parallel()
121 -
122 - if code, _, ok := ValidatePeerLeaseCertificate(nil, "lease-identity"); ok || code != "client_cert_required" {
123 - t.Fatalf("expected client_cert_required, got code=%s ok=%v", code, ok)
124 - }
125 -}
126 -
127 -func TestValidatePeerLeaseCertificateRejectsCertificateOutsideValidityWindow(t *testing.T) {
128 - t.Parallel()
129 -
130 - leaf := *mustIssuedLeafCertificate(t, "lease-identity")
131 - leaf.NotBefore = time.Now().Add(-2 * time.Minute)
132 - leaf.NotAfter = time.Now().Add(-1 * time.Minute)
133 - state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
134 - if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "client_cert_invalid" {
135 - t.Fatalf("expected client_cert_invalid for expired certificate, got code=%s ok=%v", code, ok)
136 - }
137 -}
138 -
139 -func TestValidatePeerLeaseCertificateRejectsNonClientAuthEKU(t *testing.T) {
140 - t.Parallel()
141 -
142 - leaf := *mustIssuedLeafCertificate(t, "lease-identity")
143 - leaf.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
144 - state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
145 - if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "client_cert_invalid" {
146 - t.Fatalf("expected client_cert_invalid for non-client-auth EKU, got code=%s ok=%v", code, ok)
147 - }
148 -}
149 -
150 -func TestValidatePeerLeaseCertificateRejectsMissingLeaseIdentity(t *testing.T) {
151 - t.Parallel()
152 -
153 - leaf := *mustIssuedLeafCertificate(t, "lease-identity")
154 - leaf.Subject = pkix.Name{CommonName: "unrelated"}
155 - leaf.URIs = nil
156 - state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
157 - if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "cert_lease_missing" {
158 - t.Fatalf("expected cert_lease_missing, got code=%s ok=%v", code, ok)
159 - }
160 -}
161 -
162 -func TestValidatePeerLeaseCertificateRejectsLeaseIDMismatch(t *testing.T) {
163 - t.Parallel()
164 -
165 - state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{mustIssuedLeafCertificate(t, "lease-identity")}}
166 - if code, _, ok := ValidatePeerLeaseCertificate(state, "different-lease"); ok || code != "cert_lease_mismatch" {
167 - t.Fatalf("expected cert_lease_mismatch, got code=%s ok=%v", code, ok)
168 - }
169 -}
170 -
171 -func TestExtractLeaseIDFromPeerCertificateRejectsUnprefixedCN(t *testing.T) {
172 - t.Parallel()
173 -
174 - leaseID := ExtractLeaseIDFromPeerCertificate(&x509.Certificate{
175 - Subject: pkix.Name{CommonName: "lease-identity"},
176 - })
177 - if leaseID != "" {
178 - t.Fatalf("expected empty lease id for unprefixed CN, got %q", leaseID)
179 - }
180 -}
181 -
182 -func mustIssuedLeafCertificate(t *testing.T, leaseID string) *x509.Certificate {
183 - t.Helper()
184 -
185 - identity, err := IssueIdentity(leaseID)
186 - if err != nil {
187 - t.Fatalf("IssueIdentity returned error: %v", err)
188 - }
189 - leaf, err := x509.ParseCertificate(identity.Certificate[0])
190 - if err != nil {
191 - t.Fatalf("parse issued certificate: %v", err)
192 - }
193 - return leaf
194 -}
portal/controlplane/registry/service.go deleted
-274
@@ -1,274 +0,0 @@
1 -package registry
2 -
3 -import (
4 - "crypto/tls"
5 - "errors"
6 - "fmt"
7 - "net"
8 - "strings"
9 - "time"
10 -
11 - "github.com/rs/zerolog/log"
12 -
13 - "gosuda.org/portal/portal/controlplane"
14 - "gosuda.org/portal/types"
15 -)
16 -
17 -// DefaultLeaseTTL defines the lease lifetime used by SDK register/renew flows.
18 -const DefaultLeaseTTL = 30 * time.Second
19 -
20 -// Backend provides the relay operations needed by the control-plane registry.
21 -type Backend interface {
22 - BaseHost() string
23 - UpdateLease(lease *types.Lease) bool
24 - DeleteLease(leaseID string) bool
25 - GetLeaseByID(leaseID string) (*types.LeaseEntry, bool)
26 - ClearDropped(leaseID string)
27 - DropLease(leaseID string)
28 - RegisterRoute(sniName, leaseID, name string) error
29 - UnregisterRouteByLeaseID(leaseID string)
30 - HandleConnect(conn net.Conn, leaseID, token, clientIP string)
31 -}
32 -
33 -// Options configures service behavior.
34 -type Options struct {
35 - Now func() time.Time
36 - LeaseTTL time.Duration
37 -}
38 -
39 -// AdmissionInput describes runtime context for control-plane admission checks.
40 -type AdmissionInput struct {
41 - ConnectionTLSState *tls.ConnectionState
42 - RawLeaseID string
43 - RawReverseToken string
44 - ClientIP string
45 - IsClientIPBanned bool
46 - RequireExisting bool
47 -}
48 -
49 -// AdmissionResult returns normalized, validated admission context.
50 -type AdmissionResult struct {
51 - Entry *types.LeaseEntry
52 - LeaseID string
53 - ReverseToken string
54 - ClientIP string
55 -}
56 -
57 -// RegisterInput describes a lease registration request.
58 -type RegisterInput struct {
59 - LeaseID string
60 - ReverseToken string
61 - Name string
62 - Metadata *types.Metadata
63 - PortalURL string
64 - TLS bool
65 -}
66 -
67 -// Service encapsulates control-plane registry business logic.
68 -type Service struct {
69 - backend Backend
70 - now func() time.Time
71 - leaseTTL time.Duration
72 -}
73 -
74 -// NewService constructs a control-plane registry service.
75 -func NewService(backend Backend, opts Options) (*Service, error) {
76 - if backend == nil {
77 - return nil, errors.New("registry backend is required")
78 - }
79 - leaseTTL := opts.LeaseTTL
80 - if leaseTTL <= 0 {
81 - leaseTTL = DefaultLeaseTTL
82 - }
83 - now := opts.Now
84 - if now == nil {
85 - now = time.Now
86 - }
87 - return &Service{
88 - backend: backend,
89 - leaseTTL: leaseTTL,
90 - now: now,
91 - }, nil
92 -}
93 -
94 -// Admit validates and normalizes control-plane credentials before SDK operations.
95 -func (s *Service) Admit(input AdmissionInput) (AdmissionResult, *types.APIError) {
96 - leaseID, reverseToken := normalizeLeaseCredentials(input.RawLeaseID, input.RawReverseToken)
97 - if err := validateLeaseCredentials(leaseID, reverseToken); err != nil {
98 - return AdmissionResult{}, err
99 - }
100 -
101 - if input.IsClientIPBanned {
102 - return AdmissionResult{}, apiError(httpStatusForbidden, "ip_banned", "ip is banned")
103 - }
104 -
105 - entry, exists := s.backend.GetLeaseByID(leaseID)
106 - if input.RequireExisting && !exists {
107 - return AdmissionResult{}, apiError(httpStatusNotFound, "lease_not_found", "lease not found")
108 - }
109 -
110 - if input.ConnectionTLSState != nil && len(input.ConnectionTLSState.PeerCertificates) > 0 {
111 - if code, message, ok := controlplane.ValidatePeerLeaseCertificate(input.ConnectionTLSState, leaseID); !ok {
112 - return AdmissionResult{}, apiError(httpStatusUnauthorized, code, message)
113 - }
114 - }
115 -
116 - if exists && !controlplane.MatchLeaseToken(entry.Lease.ReverseToken, reverseToken) {
117 - return AdmissionResult{}, apiError(httpStatusUnauthorized, "unauthorized", "unauthorized reverse connect")
118 - }
119 -
120 - return AdmissionResult{
121 - LeaseID: leaseID,
122 - ReverseToken: reverseToken,
123 - ClientIP: strings.TrimSpace(input.ClientIP),
124 - Entry: entry,
125 - }, nil
126 -}
127 -
128 -// Register creates a new lease and associated SNI route.
129 -func (s *Service) Register(input RegisterInput) (types.RegisterResponse, *types.APIError) {
130 - name := strings.TrimSpace(input.Name)
131 - if !types.IsValidServiceName(name) {
132 - return types.RegisterResponse{}, apiError(httpStatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
133 - }
134 - if !input.TLS {
135 - return types.RegisterResponse{}, apiError(httpStatusBadRequest, "tls_required", "tls must be enabled")
136 - }
137 -
138 - metadata := types.Metadata{}
139 - if input.Metadata != nil {
140 - metadata = *input.Metadata
141 - }
142 -
143 - lease := &types.Lease{
144 - ID: input.LeaseID,
145 - Name: name,
146 - Metadata: metadata,
147 - Expires: s.now().Add(s.leaseTTL),
148 - TLS: true,
149 - ReverseToken: input.ReverseToken,
150 - }
151 -
152 - if !s.backend.UpdateLease(lease) {
153 - return types.RegisterResponse{}, apiError(httpStatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
154 - }
155 - s.backend.ClearDropped(input.LeaseID)
156 -
157 - sniName := types.BuildSNIName(name, s.backend.BaseHost())
158 - if sniName == "" {
159 - s.backend.DeleteLease(input.LeaseID)
160 - return types.RegisterResponse{}, apiError(httpStatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
161 - }
162 - if err := s.backend.RegisterRoute(sniName, input.LeaseID, name); err != nil {
163 - s.backend.DeleteLease(input.LeaseID)
164 - return types.RegisterResponse{}, apiError(httpStatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
165 - }
166 -
167 - log.Info().
168 - Str("lease_id", input.LeaseID).
169 - Str("name", name).
170 - Bool("tls", true).
171 - Msg("[Registry] Lease registered")
172 -
173 - return types.RegisterResponse{
174 - LeaseID: input.LeaseID,
175 - PublicURL: types.ServicePublicURL(strings.TrimSpace(input.PortalURL), name),
176 - Success: true,
177 - }, nil
178 -}
179 -
180 -// Unregister removes lease state, route state, and reverse-connection state.
181 -func (s *Service) Unregister(leaseID string) {
182 - leaseID = strings.TrimSpace(leaseID)
183 - if leaseID == "" {
184 - return
185 - }
186 -
187 - if s.backend.DeleteLease(leaseID) {
188 - log.Info().
189 - Str("lease_id", leaseID).
190 - Msg("[Registry] Lease unregistered")
191 - }
192 - s.backend.UnregisterRouteByLeaseID(leaseID)
193 - s.backend.DropLease(leaseID)
194 -}
195 -
196 -// Renew extends lease expiry and opportunistically refreshes SNI routing.
197 -func (s *Service) Renew(entry *types.LeaseEntry) *types.APIError {
198 - if entry == nil || entry.Lease == nil {
199 - return apiError(httpStatusNotFound, "lease_not_found", "lease not found")
200 - }
201 -
202 - entry.Lease.Expires = s.now().Add(s.leaseTTL)
203 - if !s.backend.UpdateLease(entry.Lease) {
204 - return apiError(httpStatusInternalServerError, "renew_failed", "failed to renew lease")
205 - }
206 -
207 - sniName := types.BuildSNIName(entry.Lease.Name, s.backend.BaseHost())
208 - if sniName == "" {
209 - log.Warn().
210 - Str("lease_id", entry.Lease.ID).
211 - Str("name", entry.Lease.Name).
212 - Str("base_host", s.backend.BaseHost()).
213 - Msg("[Registry] Skipping SNI route refresh due to invalid SNI name")
214 - return nil
215 - }
216 - if err := s.backend.RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
217 - log.Warn().
218 - Err(err).
219 - Str("lease_id", entry.Lease.ID).
220 - Str("name", entry.Lease.Name).
221 - Msg("[Registry] Failed to refresh SNI route on renew")
222 - }
223 - return nil
224 -}
225 -
226 -// Domain returns the configured relay base domain.
227 -func (s *Service) Domain() (types.DomainResponse, *types.APIError) {
228 - baseHost := strings.TrimSpace(s.backend.BaseHost())
229 - if baseHost == "" {
230 - return types.DomainResponse{}, apiError(httpStatusServiceUnavailable, "base_domain_missing", "base domain not configured")
231 - }
232 - return types.DomainResponse{
233 - Success: true,
234 - BaseDomain: baseHost,
235 - }, nil
236 -}
237 -
238 -// HandleConnect admits reverse traffic into the reverse hub.
239 -func (s *Service) HandleConnect(conn net.Conn, admission AdmissionResult) {
240 - s.backend.HandleConnect(conn, admission.LeaseID, admission.ReverseToken, admission.ClientIP)
241 -}
242 -
243 -func normalizeLeaseCredentials(rawLeaseID, rawReverseToken string) (leaseID, reverseToken string) {
244 - return strings.TrimSpace(rawLeaseID), strings.TrimSpace(rawReverseToken)
245 -}
246 -
247 -func validateLeaseCredentials(leaseID, reverseToken string) *types.APIError {
248 - if leaseID == "" {
249 - return apiError(httpStatusBadRequest, "missing_lease_id", "lease_id is required")
250 - }
251 - if reverseToken == "" {
252 - return apiError(httpStatusBadRequest, "missing_reverse_token", "reverse_token is required")
253 - }
254 - return nil
255 -}
256 -
257 -func apiError(statusCode int, code, message string) *types.APIError {
258 - return &types.APIError{
259 - StatusCode: statusCode,
260 - Code: code,
261 - Message: message,
262 - }
263 -}
264 -
265 -// Local status code constants avoid pulling net/http into this package API.
266 -const (
267 - httpStatusBadRequest = 400
268 - httpStatusUnauthorized = 401
269 - httpStatusForbidden = 403
270 - httpStatusNotFound = 404
271 - httpStatusConflict = 409
272 - httpStatusInternalServerError = 500
273 - httpStatusServiceUnavailable = 503
274 -)
portal/controlplane/registry/service_test.go deleted
-534
@@ -1,534 +0,0 @@
1 -package registry
2 -
3 -import (
4 - "crypto/tls"
5 - "crypto/x509"
6 - "errors"
7 - "net"
8 - "testing"
9 - "time"
10 -
11 - "gosuda.org/portal/portal/controlplane"
12 - "gosuda.org/portal/types"
13 -)
14 -
15 -type fakeBackend struct {
16 - registerRouteErr error
17 - leases map[string]*types.LeaseEntry
18 - baseHost string
19 - connectLeaseID string
20 - connectToken string
21 - connectClientIP string
22 - unregisteredLeases []string
23 - droppedLeases []string
24 - updateLeaseAllowed bool
25 -}
26 -
27 -func newFakeBackend(baseHost string) *fakeBackend {
28 - return &fakeBackend{
29 - baseHost: baseHost,
30 - updateLeaseAllowed: true,
31 - leases: make(map[string]*types.LeaseEntry),
32 - }
33 -}
34 -
35 -func (f *fakeBackend) BaseHost() string {
36 - return f.baseHost
37 -}
38 -
39 -func (f *fakeBackend) UpdateLease(lease *types.Lease) bool {
40 - if !f.updateLeaseAllowed {
41 - return false
42 - }
43 - f.leases[lease.ID] = &types.LeaseEntry{
44 - Lease: lease,
45 - Expires: lease.Expires,
46 - }
47 - return true
48 -}
49 -
50 -func (f *fakeBackend) DeleteLease(leaseID string) bool {
51 - if _, ok := f.leases[leaseID]; !ok {
52 - return false
53 - }
54 - delete(f.leases, leaseID)
55 - return true
56 -}
57 -
58 -func (f *fakeBackend) GetLeaseByID(leaseID string) (*types.LeaseEntry, bool) {
59 - entry, ok := f.leases[leaseID]
60 - return entry, ok
61 -}
62 -
63 -func (f *fakeBackend) ClearDropped(string) {}
64 -
65 -func (f *fakeBackend) DropLease(leaseID string) {
66 - f.droppedLeases = append(f.droppedLeases, leaseID)
67 -}
68 -
69 -func (f *fakeBackend) RegisterRoute(_, _, _ string) error {
70 - return f.registerRouteErr
71 -}
72 -
73 -func (f *fakeBackend) UnregisterRouteByLeaseID(leaseID string) {
74 - f.unregisteredLeases = append(f.unregisteredLeases, leaseID)
75 -}
76 -
77 -func (f *fakeBackend) HandleConnect(_ net.Conn, leaseID, token, clientIP string) {
78 - f.connectLeaseID = leaseID
79 - f.connectToken = token
80 - f.connectClientIP = clientIP
81 -}
82 -
83 -func mustTLSState(t *testing.T, leaseID string) *tls.ConnectionState {
84 - t.Helper()
85 -
86 - identity, err := controlplane.IssueIdentity(leaseID)
87 - if err != nil {
88 - t.Fatalf("IssueIdentity returned error: %v", err)
89 - }
90 -
91 - leaf, err := x509.ParseCertificate(identity.Certificate[0])
92 - if err != nil {
93 - t.Fatalf("ParseCertificate returned error: %v", err)
94 - }
95 - return &tls.ConnectionState{
96 - PeerCertificates: []*x509.Certificate{leaf},
97 - }
98 -}
99 -
100 -func TestNewServiceRequiresBackend(t *testing.T) {
101 - t.Parallel()
102 -
103 - if _, err := NewService(nil, Options{}); err == nil {
104 - t.Fatal("expected error for nil backend")
105 - }
106 -}
107 -
108 -func TestAdmitRejectsMissingLeaseID(t *testing.T) {
109 - t.Parallel()
110 -
111 - svc, err := NewService(newFakeBackend("example.com"), Options{})
112 - if err != nil {
113 - t.Fatalf("NewService returned error: %v", err)
114 - }
115 -
116 - _, apiErr := svc.Admit(AdmissionInput{
117 - RawLeaseID: " ",
118 - RawReverseToken: "token",
119 - ConnectionTLSState: &tls.ConnectionState{},
120 - })
121 - if apiErr == nil {
122 - t.Fatal("expected admission error")
123 - }
124 - if apiErr.Code != "missing_lease_id" {
125 - t.Fatalf("error code = %q, want missing_lease_id", apiErr.Code)
126 - }
127 -}
128 -
129 -func TestAdmitRejectsBannedIP(t *testing.T) {
130 - t.Parallel()
131 -
132 - svc, err := NewService(newFakeBackend("example.com"), Options{})
133 - if err != nil {
134 - t.Fatalf("NewService returned error: %v", err)
135 - }
136 -
137 - _, apiErr := svc.Admit(AdmissionInput{
138 - RawLeaseID: "lease-1",
139 - RawReverseToken: "token",
140 - IsClientIPBanned: true,
141 - ConnectionTLSState: &tls.ConnectionState{},
142 - })
143 - if apiErr == nil {
144 - t.Fatal("expected admission error")
145 - }
146 - if apiErr.Code != "ip_banned" {
147 - t.Fatalf("error code = %q, want ip_banned", apiErr.Code)
148 - }
149 -}
150 -
151 -func TestAdmitRequiresExistingLeaseWhenRequested(t *testing.T) {
152 - t.Parallel()
153 -
154 - svc, err := NewService(newFakeBackend("example.com"), Options{})
155 - if err != nil {
156 - t.Fatalf("NewService returned error: %v", err)
157 - }
158 -
159 - _, apiErr := svc.Admit(AdmissionInput{
160 - RawLeaseID: "lease-1",
161 - RawReverseToken: "token",
162 - RequireExisting: true,
163 - ConnectionTLSState: mustTLSState(t, "lease-1"),
164 - })
165 - if apiErr == nil {
166 - t.Fatal("expected admission error")
167 - }
168 - if apiErr.Code != "lease_not_found" {
169 - t.Fatalf("error code = %q, want lease_not_found", apiErr.Code)
170 - }
171 -}
172 -
173 -func TestAdmitSuccessWithMatchingToken(t *testing.T) {
174 - t.Parallel()
175 -
176 - backend := newFakeBackend("example.com")
177 - backend.leases["lease-1"] = &types.LeaseEntry{
178 - Lease: &types.Lease{
179 - ID: "lease-1",
180 - ReverseToken: "token-1",
181 - },
182 - }
183 -
184 - svc, err := NewService(backend, Options{})
185 - if err != nil {
186 - t.Fatalf("NewService returned error: %v", err)
187 - }
188 -
189 - result, apiErr := svc.Admit(AdmissionInput{
190 - RawLeaseID: " lease-1 ",
191 - RawReverseToken: " token-1 ",
192 - ClientIP: " 198.51.100.9 ",
193 - RequireExisting: true,
194 - ConnectionTLSState: mustTLSState(t, "lease-1"),
195 - })
196 - if apiErr != nil {
197 - t.Fatalf("Admit returned error: %+v", apiErr)
198 - }
199 - if result.LeaseID != "lease-1" {
200 - t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
201 - }
202 - if result.ReverseToken != "token-1" {
203 - t.Fatalf("reverse token = %q, want token-1", result.ReverseToken)
204 - }
205 - if result.ClientIP != "198.51.100.9" {
206 - t.Fatalf("client ip = %q, want 198.51.100.9", result.ClientIP)
207 - }
208 -}
209 -
210 -func TestAdmitRejectsInvalidTokenWithValidCertificate(t *testing.T) {
211 - t.Parallel()
212 -
213 - backend := newFakeBackend("example.com")
214 - backend.leases["lease-1"] = &types.LeaseEntry{
215 - Lease: &types.Lease{
216 - ID: "lease-1",
217 - ReverseToken: "token-1",
218 - },
219 - }
220 -
221 - svc, err := NewService(backend, Options{})
222 - if err != nil {
223 - t.Fatalf("NewService returned error: %v", err)
224 - }
225 -
226 - _, apiErr := svc.Admit(AdmissionInput{
227 - RawLeaseID: "lease-1",
228 - RawReverseToken: "wrong-token",
229 - RequireExisting: true,
230 - ConnectionTLSState: mustTLSState(t, "lease-1"),
231 - })
232 - if apiErr == nil {
233 - t.Fatal("expected admission error")
234 - }
235 - if apiErr.Code != "unauthorized" {
236 - t.Fatalf("error code = %q, want unauthorized", apiErr.Code)
237 - }
238 -}
239 -
240 -func TestAdmit_ValidCert_Passes(t *testing.T) {
241 - t.Parallel()
242 -
243 - backend := newFakeBackend("example.com")
244 - backend.leases["lease-1"] = &types.LeaseEntry{
245 - Lease: &types.Lease{
246 - ID: "lease-1",
247 - ReverseToken: "token-1",
248 - },
249 - }
250 -
251 - svc, err := NewService(backend, Options{})
252 - if err != nil {
253 - t.Fatalf("NewService returned error: %v", err)
254 - }
255 -
256 - result, apiErr := svc.Admit(AdmissionInput{
257 - RawLeaseID: "lease-1",
258 - RawReverseToken: "token-1",
259 - RequireExisting: true,
260 - ConnectionTLSState: mustTLSState(t, "lease-1"),
261 - })
262 - if apiErr != nil {
263 - t.Fatalf("Admit returned error: %+v", apiErr)
264 - }
265 - if result.LeaseID != "lease-1" {
266 - t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
267 - }
268 -}
269 -
270 -func TestAdmit_InvalidCert_Rejected(t *testing.T) {
271 - t.Parallel()
272 -
273 - backend := newFakeBackend("example.com")
274 - backend.leases["lease-1"] = &types.LeaseEntry{
275 - Lease: &types.Lease{
276 - ID: "lease-1",
277 - ReverseToken: "token-1",
278 - },
279 - }
280 -
281 - svc, err := NewService(backend, Options{})
282 - if err != nil {
283 - t.Fatalf("NewService returned error: %v", err)
284 - }
285 -
286 - // Present a cert bound to a different lease ID.
287 - _, apiErr := svc.Admit(AdmissionInput{
288 - RawLeaseID: "lease-1",
289 - RawReverseToken: "token-1",
290 - RequireExisting: true,
291 - ConnectionTLSState: mustTLSState(t, "lease-other"),
292 - })
293 - if apiErr == nil {
294 - t.Fatal("expected admission error for mismatched cert")
295 - }
296 - if apiErr.Code != "cert_lease_mismatch" {
297 - t.Fatalf("error code = %q, want cert_lease_mismatch", apiErr.Code)
298 - }
299 -}
300 -
301 -func TestAdmit_NoCert_TokenValid_Passes(t *testing.T) {
302 - t.Parallel()
303 -
304 - backend := newFakeBackend("example.com")
305 - backend.leases["lease-1"] = &types.LeaseEntry{
306 - Lease: &types.Lease{
307 - ID: "lease-1",
308 - ReverseToken: "token-1",
309 - },
310 - }
311 -
312 - svc, err := NewService(backend, Options{})
313 - if err != nil {
314 - t.Fatalf("NewService returned error: %v", err)
315 - }
316 -
317 - // Nil TLS state ??CertBind skipped, token validation still applies.
318 - result, apiErr := svc.Admit(AdmissionInput{
319 - RawLeaseID: "lease-1",
320 - RawReverseToken: "token-1",
321 - RequireExisting: true,
322 - ConnectionTLSState: nil,
323 - })
324 - if apiErr != nil {
325 - t.Fatalf("Admit returned error: %+v", apiErr)
326 - }
327 - if result.LeaseID != "lease-1" {
328 - t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
329 - }
330 -}
331 -
332 -func TestAdmit_NoCert_TokenInvalid_Rejected(t *testing.T) {
333 - t.Parallel()
334 -
335 - backend := newFakeBackend("example.com")
336 - backend.leases["lease-1"] = &types.LeaseEntry{
337 - Lease: &types.Lease{
338 - ID: "lease-1",
339 - ReverseToken: "token-1",
340 - },
341 - }
342 -
343 - svc, err := NewService(backend, Options{})
344 - if err != nil {
345 - t.Fatalf("NewService returned error: %v", err)
346 - }
347 -
348 - // Nil TLS state ??CertBind skipped, but token does not match.
349 - _, apiErr := svc.Admit(AdmissionInput{
350 - RawLeaseID: "lease-1",
351 - RawReverseToken: "wrong-token",
352 - RequireExisting: true,
353 - ConnectionTLSState: nil,
354 - })
355 - if apiErr == nil {
356 - t.Fatal("expected admission error for invalid token")
357 - }
358 - if apiErr.Code != "unauthorized" {
359 - t.Fatalf("error code = %q, want unauthorized", apiErr.Code)
360 - }
361 -}
362 -
363 -func TestRegisterSuccess(t *testing.T) {
364 - t.Parallel()
365 -
366 - now := time.Date(2026, time.March, 4, 0, 0, 0, 0, time.UTC)
367 - backend := newFakeBackend("example.com")
368 - svc, err := NewService(backend, Options{
369 - LeaseTTL: 30 * time.Second,
370 - Now: func() time.Time { return now },
371 - })
372 - if err != nil {
373 - t.Fatalf("NewService returned error: %v", err)
374 - }
375 -
376 - resp, apiErr := svc.Register(RegisterInput{
377 - LeaseID: "lease-1",
378 - ReverseToken: "token-1",
379 - Name: "demo",
380 - Metadata: &types.Metadata{Owner: "owner"},
381 - TLS: true,
382 - PortalURL: "https://portal.example.com",
383 - })
384 - if apiErr != nil {
385 - t.Fatalf("Register returned error: %+v", apiErr)
386 - }
387 - if !resp.Success {
388 - t.Fatal("expected success response")
389 - }
390 - if resp.LeaseID != "lease-1" {
391 - t.Fatalf("lease id = %q, want lease-1", resp.LeaseID)
392 - }
393 - entry, ok := backend.GetLeaseByID("lease-1")
394 - if !ok {
395 - t.Fatal("expected lease to be persisted")
396 - }
397 - if got := entry.Lease.Expires; !got.Equal(now.Add(30 * time.Second)) {
398 - t.Fatalf("lease expiry = %v, want %v", got, now.Add(30*time.Second))
399 - }
400 -}
401 -
402 -func TestRegisterDeletesLeaseWhenRouteRegistrationFails(t *testing.T) {
403 - t.Parallel()
404 -
405 - backend := newFakeBackend("example.com")
406 - backend.registerRouteErr = errors.New("register failed")
407 - svc, err := NewService(backend, Options{})
408 - if err != nil {
409 - t.Fatalf("NewService returned error: %v", err)
410 - }
411 -
412 - _, apiErr := svc.Register(RegisterInput{
413 - LeaseID: "lease-1",
414 - ReverseToken: "token-1",
415 - Name: "demo",
416 - TLS: true,
417 - PortalURL: "https://portal.example.com",
418 - })
419 - if apiErr == nil {
420 - t.Fatal("expected register error")
421 - }
422 - if apiErr.Code != "sni_register_failed" {
423 - t.Fatalf("error code = %q, want sni_register_failed", apiErr.Code)
424 - }
425 - if _, ok := backend.GetLeaseByID("lease-1"); ok {
426 - t.Fatal("expected lease to be deleted after route registration failure")
427 - }
428 -}
429 -
430 -func TestRenewExtendsLease(t *testing.T) {
431 - t.Parallel()
432 -
433 - now := time.Date(2026, time.March, 4, 1, 0, 0, 0, time.UTC)
434 - backend := newFakeBackend("example.com")
435 - entry := &types.LeaseEntry{
436 - Lease: &types.Lease{
437 - ID: "lease-1",
438 - Name: "demo",
439 - Expires: now,
440 - },
441 - }
442 - backend.leases["lease-1"] = entry
443 -
444 - svc, err := NewService(backend, Options{
445 - LeaseTTL: 30 * time.Second,
446 - Now: func() time.Time { return now },
447 - })
448 - if err != nil {
449 - t.Fatalf("NewService returned error: %v", err)
450 - }
451 -
452 - if apiErr := svc.Renew(entry); apiErr != nil {
453 - t.Fatalf("Renew returned error: %+v", apiErr)
454 - }
455 - if got := entry.Lease.Expires; !got.Equal(now.Add(30 * time.Second)) {
456 - t.Fatalf("lease expiry = %v, want %v", got, now.Add(30*time.Second))
457 - }
458 -}
459 -
460 -func TestRenewResetsFutureExpiryFromNow(t *testing.T) {
461 - t.Parallel()
462 -
463 - now := time.Date(2026, time.March, 4, 1, 0, 0, 0, time.UTC)
464 - originalExpiry := now.Add(5 * time.Minute)
465 - backend := newFakeBackend("example.com")
466 - entry := &types.LeaseEntry{
467 - Lease: &types.Lease{
468 - ID: "lease-1",
469 - Name: "demo",
470 - Expires: originalExpiry,
471 - },
472 - }
473 - backend.leases["lease-1"] = entry
474 -
475 - svc, err := NewService(backend, Options{
476 - LeaseTTL: 30 * time.Second,
477 - Now: func() time.Time { return now },
478 - })
479 - if err != nil {
480 - t.Fatalf("NewService returned error: %v", err)
481 - }
482 -
483 - if apiErr := svc.Renew(entry); apiErr != nil {
484 - t.Fatalf("Renew returned error: %+v", apiErr)
485 - }
486 -
487 - want := now.Add(30 * time.Second)
488 - if got := entry.Lease.Expires; !got.Equal(want) {
489 - t.Fatalf("lease expiry = %v, want %v", got, want)
490 - }
491 - if !entry.Lease.Expires.Before(originalExpiry) {
492 - t.Fatalf("lease expiry = %v, want a value before original future expiry %v", entry.Lease.Expires, originalExpiry)
493 - }
494 -}
495 -
496 -func TestUnregisterDropsLeaseAndRoutes(t *testing.T) {
497 - t.Parallel()
498 -
499 - backend := newFakeBackend("example.com")
500 - backend.leases["lease-1"] = &types.LeaseEntry{Lease: &types.Lease{ID: "lease-1"}}
501 - svc, err := NewService(backend, Options{})
502 - if err != nil {
503 - t.Fatalf("NewService returned error: %v", err)
504 - }
505 -
506 - svc.Unregister("lease-1")
507 -
508 - if _, ok := backend.GetLeaseByID("lease-1"); ok {
509 - t.Fatal("expected lease to be removed")
510 - }
511 - if len(backend.unregisteredLeases) != 1 || backend.unregisteredLeases[0] != "lease-1" {
512 - t.Fatalf("unregistered leases = %v, want [lease-1]", backend.unregisteredLeases)
513 - }
514 - if len(backend.droppedLeases) != 1 || backend.droppedLeases[0] != "lease-1" {
515 - t.Fatalf("dropped leases = %v, want [lease-1]", backend.droppedLeases)
516 - }
517 -}
518 -
519 -func TestDomainRequiresBaseHost(t *testing.T) {
520 - t.Parallel()
521 -
522 - svc, err := NewService(newFakeBackend(""), Options{})
523 - if err != nil {
524 - t.Fatalf("NewService returned error: %v", err)
525 - }
526 -
527 - _, apiErr := svc.Domain()
528 - if apiErr == nil {
529 - t.Fatal("expected domain error")
530 - }
531 - if apiErr.Code != "base_domain_missing" {
532 - t.Fatalf("error code = %q, want base_domain_missing", apiErr.Code)
533 - }
534 -}
portal/registry.go new
+252
@@ -0,0 +1,252 @@
1 +package portal
2 +
3 +import (
4 + "crypto/subtle"
5 + "fmt"
6 + "net"
7 + "strings"
8 + "time"
9 +
10 + "github.com/rs/zerolog/log"
11 +
12 + "gosuda.org/portal/types"
13 +)
14 +
15 +// DefaultLeaseTTL defines the default lease lifetime across relay components.
16 +const DefaultLeaseTTL = 30 * time.Second
17 +
18 +// RegistryAdmissionInput describes runtime context for control-plane admission checks.
19 +type RegistryAdmissionInput struct {
20 + RawLeaseID string
21 + RawReverseToken string
22 + ClientIP string
23 + IsClientIPBanned bool
24 + RequireExisting bool
25 +}
26 +
27 +// RegistryAdmissionResult returns normalized, validated admission context.
28 +type RegistryAdmissionResult struct {
29 + Entry *types.LeaseEntry
30 + LeaseID string
31 + ReverseToken string
32 + ClientIP string
33 +}
34 +
35 +// RegistryRegisterInput describes a lease registration request.
36 +type RegistryRegisterInput struct {
37 + LeaseID string
38 + ReverseToken string
39 + Name string
40 + Metadata *types.Metadata
41 + PortalURL string
42 + TLS bool
43 +}
44 +
45 +// AdmitControlPlane validates and normalizes control-plane credentials before SDK operations.
46 +func (g *RelayServer) AdmitControlPlane(input RegistryAdmissionInput) (RegistryAdmissionResult, *types.APIError) {
47 + leaseID, reverseToken := normalizeRegistryCredentials(input.RawLeaseID, input.RawReverseToken)
48 + if err := validateRegistryCredentials(leaseID, reverseToken); err != nil {
49 + return RegistryAdmissionResult{}, err
50 + }
51 +
52 + if input.IsClientIPBanned {
53 + return RegistryAdmissionResult{}, registryAPIError(httpStatusForbidden, "ip_banned", "ip is banned")
54 + }
55 +
56 + if g == nil || g.leaseManager == nil {
57 + return RegistryAdmissionResult{}, registryAPIError(httpStatusInternalServerError, "registry_unavailable", "registry service unavailable")
58 + }
59 +
60 + entry, exists := g.leaseManager.GetLeaseByID(leaseID)
61 + if input.RequireExisting && !exists {
62 + return RegistryAdmissionResult{}, registryAPIError(httpStatusNotFound, "lease_not_found", "lease not found")
63 + }
64 +
65 + if exists && !matchLeaseToken(entry.Lease.ReverseToken, reverseToken) {
66 + return RegistryAdmissionResult{}, registryAPIError(httpStatusUnauthorized, "unauthorized", "unauthorized reverse connect")
67 + }
68 +
69 + return RegistryAdmissionResult{
70 + LeaseID: leaseID,
71 + ReverseToken: reverseToken,
72 + ClientIP: strings.TrimSpace(input.ClientIP),
73 + Entry: entry,
74 + }, nil
75 +}
76 +
77 +// RegisterLease creates a new lease and associated SNI route.
78 +func (g *RelayServer) RegisterLease(input RegistryRegisterInput) (types.RegisterResponse, *types.APIError) {
79 + if g == nil || g.leaseManager == nil || g.reverseHub == nil || g.sniRouter == nil {
80 + return types.RegisterResponse{}, registryAPIError(httpStatusInternalServerError, "registry_unavailable", "registry service unavailable")
81 + }
82 +
83 + name := strings.TrimSpace(input.Name)
84 + if !types.IsValidServiceName(name) {
85 + return types.RegisterResponse{}, registryAPIError(httpStatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
86 + }
87 + if !input.TLS {
88 + return types.RegisterResponse{}, registryAPIError(httpStatusBadRequest, "tls_required", "tls must be enabled")
89 + }
90 +
91 + metadata := types.Metadata{}
92 + if input.Metadata != nil {
93 + metadata = *input.Metadata
94 + }
95 +
96 + lease := &types.Lease{
97 + ID: input.LeaseID,
98 + Name: name,
99 + Metadata: metadata,
100 + Expires: time.Now().Add(DefaultLeaseTTL),
101 + TLS: true,
102 + ReverseToken: input.ReverseToken,
103 + }
104 +
105 + if !g.leaseManager.UpdateLease(lease) {
106 + return types.RegisterResponse{}, registryAPIError(httpStatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
107 + }
108 + g.reverseHub.ClearDropped(input.LeaseID)
109 +
110 + sniName := types.BuildSNIName(name, g.BaseHost)
111 + if sniName == "" {
112 + g.leaseManager.DeleteLease(input.LeaseID)
113 + return types.RegisterResponse{}, registryAPIError(httpStatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
114 + }
115 + if err := g.sniRouter.RegisterRoute(sniName, input.LeaseID, name); err != nil {
116 + g.leaseManager.DeleteLease(input.LeaseID)
117 + return types.RegisterResponse{}, registryAPIError(httpStatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
118 + }
119 +
120 + log.Info().
121 + Str("lease_id", input.LeaseID).
122 + Str("name", name).
123 + Bool("tls", true).
124 + Msg("[Registry] Lease registered")
125 +
126 + return types.RegisterResponse{
127 + LeaseID: input.LeaseID,
128 + PublicURL: types.ServicePublicURL(strings.TrimSpace(input.PortalURL), name),
129 + Success: true,
130 + }, nil
131 +}
132 +
133 +// UnregisterLease removes lease state, route state, and reverse-connection state.
134 +func (g *RelayServer) UnregisterLease(leaseID string) {
135 + leaseID = strings.TrimSpace(leaseID)
136 + if leaseID == "" || g == nil {
137 + return
138 + }
139 +
140 + if g.leaseManager != nil && g.leaseManager.DeleteLease(leaseID) {
141 + log.Info().
142 + Str("lease_id", leaseID).
143 + Msg("[Registry] Lease unregistered")
144 + }
145 + if g.sniRouter != nil {
146 + g.sniRouter.UnregisterRouteByLeaseID(leaseID)
147 + }
148 + if g.reverseHub != nil {
149 + g.reverseHub.DropLease(leaseID)
150 + }
151 +}
152 +
153 +// RenewLease extends lease expiry and opportunistically refreshes SNI routing.
154 +func (g *RelayServer) RenewLease(entry *types.LeaseEntry) *types.APIError {
155 + if entry == nil || entry.Lease == nil {
156 + return registryAPIError(httpStatusNotFound, "lease_not_found", "lease not found")
157 + }
158 + if g == nil || g.leaseManager == nil || g.sniRouter == nil {
159 + return registryAPIError(httpStatusInternalServerError, "registry_unavailable", "registry service unavailable")
160 + }
161 +
162 + entry.Lease.Expires = time.Now().Add(DefaultLeaseTTL)
163 + if !g.leaseManager.UpdateLease(entry.Lease) {
164 + return registryAPIError(httpStatusInternalServerError, "renew_failed", "failed to renew lease")
165 + }
166 +
167 + sniName := types.BuildSNIName(entry.Lease.Name, g.BaseHost)
168 + if sniName == "" {
169 + log.Warn().
170 + Str("lease_id", entry.Lease.ID).
171 + Str("name", entry.Lease.Name).
172 + Str("base_host", g.BaseHost).
173 + Msg("[Registry] Skipping SNI route refresh due to invalid SNI name")
174 + return nil
175 + }
176 + if err := g.sniRouter.RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
177 + log.Warn().
178 + Err(err).
179 + Str("lease_id", entry.Lease.ID).
180 + Str("name", entry.Lease.Name).
181 + Msg("[Registry] Failed to refresh SNI route on renew")
182 + }
183 + return nil
184 +}
185 +
186 +// RegistryDomain returns the configured relay base domain.
187 +func (g *RelayServer) RegistryDomain() (types.DomainResponse, *types.APIError) {
188 + if g == nil {
189 + return types.DomainResponse{}, registryAPIError(httpStatusServiceUnavailable, "base_domain_missing", "base domain not configured")
190 + }
191 + baseHost := strings.TrimSpace(g.BaseHost)
192 + if baseHost == "" {
193 + return types.DomainResponse{}, registryAPIError(httpStatusServiceUnavailable, "base_domain_missing", "base domain not configured")
194 + }
195 + return types.DomainResponse{
196 + Success: true,
197 + BaseDomain: baseHost,
198 + }, nil
199 +}
200 +
201 +// HandleRegistryConnect admits reverse traffic into the reverse hub.
202 +func (g *RelayServer) HandleRegistryConnect(conn net.Conn, admission RegistryAdmissionResult) {
203 + if g == nil || g.reverseHub == nil {
204 + if conn != nil {
205 + _ = conn.Close()
206 + }
207 + return
208 + }
209 + g.reverseHub.HandleConnect(conn, admission.LeaseID, admission.ReverseToken, admission.ClientIP)
210 +}
211 +
212 +// matchLeaseToken compares lease-bound values in constant time.
213 +func matchLeaseToken(expected, provided string) bool {
214 + expected = strings.TrimSpace(expected)
215 + provided = strings.TrimSpace(provided)
216 + if expected == "" || provided == "" {
217 + return false
218 + }
219 + return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
220 +}
221 +
222 +func normalizeRegistryCredentials(rawLeaseID, rawReverseToken string) (leaseID, reverseToken string) {
223 + return strings.TrimSpace(rawLeaseID), strings.TrimSpace(rawReverseToken)
224 +}
225 +
226 +func validateRegistryCredentials(leaseID, reverseToken string) *types.APIError {
227 + if leaseID == "" {
228 + return registryAPIError(httpStatusBadRequest, "missing_lease_id", "lease_id is required")
229 + }
230 + if reverseToken == "" {
231 + return registryAPIError(httpStatusBadRequest, "missing_reverse_token", "reverse_token is required")
232 + }
233 + return nil
234 +}
235 +
236 +func registryAPIError(statusCode int, code, message string) *types.APIError {
237 + return &types.APIError{
238 + StatusCode: statusCode,
239 + Code: code,
240 + Message: message,
241 + }
242 +}
243 +
244 +const (
245 + httpStatusBadRequest = 400
246 + httpStatusUnauthorized = 401
247 + httpStatusForbidden = 403
248 + httpStatusNotFound = 404
249 + httpStatusConflict = 409
250 + httpStatusInternalServerError = 500
251 + httpStatusServiceUnavailable = 503
252 +)
portal/registry_test.go new
+144
@@ -0,0 +1,144 @@
1 +package portal
2 +
3 +import (
4 + "testing"
5 + "time"
6 +
7 + "gosuda.org/portal/portal/sni"
8 + "gosuda.org/portal/types"
9 +)
10 +
11 +func newTestRegistryRelay(baseHost string) *RelayServer {
12 + return &RelayServer{
13 + BaseHost: baseHost,
14 + leaseManager: NewLeaseManager(DefaultLeaseTTL),
15 + reverseHub: NewReverseHub(),
16 + sniRouter: sni.NewRouter(":0"),
17 + }
18 +}
19 +
20 +func newTestLease(id, name, token string) *types.Lease {
21 + return &types.Lease{
22 + ID: id,
23 + Name: name,
24 + ReverseToken: token,
25 + TLS: true,
26 + Expires: time.Now().Add(2 * time.Minute),
27 + }
28 +}
29 +
30 +func TestMatchLeaseToken(t *testing.T) {
31 + t.Parallel()
32 +
33 + tests := []struct {
34 + name string
35 + expected string
36 + provided string
37 + want bool
38 + }{
39 + {name: "exact match", expected: "token-1", provided: "token-1", want: true},
40 + {name: "trimmed match", expected: " token-1 ", provided: "\ttoken-1\n", want: true},
41 + {name: "mismatch", expected: "token-1", provided: "token-2", want: false},
42 + {name: "empty expected", expected: "", provided: "token-1", want: false},
43 + {name: "empty provided", expected: "token-1", provided: " ", want: false},
44 + }
45 +
46 + for _, tt := range tests {
47 + t.Run(tt.name, func(t *testing.T) {
48 + t.Parallel()
49 + if got := matchLeaseToken(tt.expected, tt.provided); got != tt.want {
50 + t.Fatalf("matchLeaseToken(%q, %q)=%t, want %t", tt.expected, tt.provided, got, tt.want)
51 + }
52 + })
53 + }
54 +}
55 +
56 +func TestRegisterLease(t *testing.T) {
57 + t.Parallel()
58 +
59 + serv := newTestRegistryRelay("example.com")
60 +
61 + resp, apiErr := serv.RegisterLease(RegistryRegisterInput{
62 + LeaseID: "lease-1",
63 + ReverseToken: "token-1",
64 + Name: "demo",
65 + TLS: true,
66 + PortalURL: "https://portal.example.com",
67 + })
68 + if apiErr != nil {
69 + t.Fatalf("RegisterLease returned error: %+v", apiErr)
70 + }
71 + if !resp.Success {
72 + t.Fatal("expected success response")
73 + }
74 + if _, ok := serv.leaseManager.GetLeaseByID("lease-1"); !ok {
75 + t.Fatal("expected lease to be persisted")
76 + }
77 + sniName := types.BuildSNIName("demo", "example.com")
78 + if _, ok := serv.sniRouter.GetRoute(sniName); !ok {
79 + t.Fatalf("expected SNI route %q to be registered", sniName)
80 + }
81 +
82 + _, apiErr = serv.RegisterLease(RegistryRegisterInput{
83 + LeaseID: "lease-2",
84 + ReverseToken: "token-2",
85 + Name: "demo2",
86 + TLS: false,
87 + })
88 + if apiErr == nil || apiErr.Code != "tls_required" {
89 + t.Fatalf("expected tls_required error, got %+v", apiErr)
90 + }
91 +}
92 +
93 +func TestRenewAndUnregisterLease(t *testing.T) {
94 + t.Parallel()
95 +
96 + serv := newTestRegistryRelay("example.com")
97 + lease := newTestLease("lease-1", "demo", "token-1")
98 + if !serv.leaseManager.UpdateLease(lease) {
99 + t.Fatal("failed to seed lease")
100 + }
101 + if err := serv.sniRouter.RegisterRoute(types.BuildSNIName("demo", "example.com"), "lease-1", "demo"); err != nil {
102 + t.Fatalf("seed route: %v", err)
103 + }
104 + entry, _ := serv.leaseManager.GetLeaseByID("lease-1")
105 + oldExpires := entry.Lease.Expires
106 +
107 + if apiErr := serv.RenewLease(entry); apiErr != nil {
108 + t.Fatalf("RenewLease returned error: %+v", apiErr)
109 + }
110 + if entry.Lease.Expires.Equal(oldExpires) {
111 + t.Fatalf("renewed expiry did not change: %v", entry.Lease.Expires)
112 + }
113 + remaining := time.Until(entry.Lease.Expires)
114 + if remaining < 20*time.Second || remaining > 40*time.Second {
115 + t.Fatalf("renewed expiry remaining=%v, want around %v", remaining, DefaultLeaseTTL)
116 + }
117 +
118 + serv.UnregisterLease("lease-1")
119 + if _, ok := serv.leaseManager.GetLeaseByID("lease-1"); ok {
120 + t.Fatal("expected lease to be removed")
121 + }
122 + if _, ok := serv.sniRouter.GetRouteByLeaseID("lease-1"); ok {
123 + t.Fatal("expected SNI route to be removed")
124 + }
125 +}
126 +
127 +func TestRegistryDomain(t *testing.T) {
128 + t.Parallel()
129 +
130 + serv := newTestRegistryRelay("example.com")
131 + resp, apiErr := serv.RegistryDomain()
132 + if apiErr != nil {
133 + t.Fatalf("RegistryDomain returned error: %+v", apiErr)
134 + }
135 + if !resp.Success || resp.BaseDomain != "example.com" {
136 + t.Fatalf("unexpected domain response: %+v", resp)
137 + }
138 +
139 + serv.BaseHost = ""
140 + _, apiErr = serv.RegistryDomain()
141 + if apiErr == nil || apiErr.Code != "base_domain_missing" {
142 + t.Fatalf("expected base_domain_missing error, got %+v", apiErr)
143 + }
144 +}
portal/relay.go
+2 -3
@@ -11,7 +11,6 @@ import (
11 "github.com/rs/zerolog/log"
12
13 "gosuda.org/portal/portal/acme"
14 - "gosuda.org/portal/portal/controlplane"
14 "gosuda.org/portal/portal/keyless"
15 "gosuda.org/portal/portal/sni"
16 )
@@ -39,7 +38,7 @@ func NewRelayServer(
38 server := &RelayServer{
39 BaseHost: baseHost,
40 address: address,
42 - leaseManager: NewLeaseManager(30 * time.Second),
41 + leaseManager: NewLeaseManager(DefaultLeaseTTL),
42 reverseHub: NewReverseHub(),
43 sniRouter: sni.NewRouter(sniPort),
44 }
@@ -108,7 +107,7 @@ func (g *RelayServer) authorizeReverseConnect(leaseID, token string) bool {
107 return false
108 }
109
111 - return controlplane.MatchLeaseToken(entry.Lease.ReverseToken, token)
110 + return matchLeaseToken(entry.Lease.ReverseToken, token)
111 }
112
113 // GetLeaseManager returns the lease manager instance.
sdk/client.go
+2 -186
@@ -2,38 +2,22 @@
2 package sdk
3
4 import (
5 - "context"
5 "crypto/rand"
7 - "crypto/sha256"
6 "crypto/tls"
9 - "crypto/x509"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "net"
11 "net/url"
15 - "os"
16 - "path/filepath"
17 - "strings"
12 "sync"
13 "time"
14
15 "github.com/rs/zerolog/log"
16
23 - "github.com/gosuda/keyless_tls/keyless/lifecycle"
24 -
17 "gosuda.org/portal/portal/keyless"
18 "gosuda.org/portal/types"
19 )
20
29 -const (
30 - keylessDirEnvVar = "KEYLESS_DIR"
31 - defaultKeylessDir = "/etc/portal/keyless"
32 - keylessFullChainFile = "fullchain.pem"
33 - keylessPrivateKeyFile = "privatekey.pem"
34 - keylessLifecycleStateSubdir = "lifecycle-identities"
35 -)
36 -
21 // SDK-specific errors.
22 var (
23 ErrNoAvailableRelay = errors.New("no available relay")
@@ -106,13 +90,6 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
90 if err != nil {
91 return nil, err
92 }
109 - var controlPlaneIdentity tls.Certificate
110 - if strings.TrimSpace(os.Getenv(keylessDirEnvVar)) != "" {
111 - controlPlaneIdentity, err = acquireLifecycleIdentity(lease.ID)
112 - if err != nil {
113 - return nil, err
114 - }
115 - }
93
94 listeners := make([]net.Listener, 0, len(relayAddrs))
95 closeActiveListeners := func() {
@@ -137,7 +114,7 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
114 }
115
116 leaseCopy := *lease
140 - listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, controlPlaneIdentity, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
117 + listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
118 if listenerErr != nil {
119 runCloseFns(listenerCloseFns)
120 closeActiveListeners()
@@ -200,167 +177,6 @@ func (c *Client) newLease(name string, options ...types.MetadataOption) (*types.
177 return lease, nil
178 }
179
203 -func acquireLifecycleIdentity(leaseID string) (tls.Certificate, error) {
204 - manager, err := newLifecycleManager()
205 - if err != nil {
206 - return tls.Certificate{}, fmt.Errorf("initialize keyless lifecycle manager: %w", err)
207 - }
208 -
209 - ctx := context.Background()
210 - bundle, err := loadOrAcquireLifecycleIdentityBundle(ctx, manager, leaseID)
211 - if err != nil {
212 - return tls.Certificate{}, fmt.Errorf("acquire lifecycle identity for lease %s: %w", leaseID, err)
213 - }
214 -
215 - cert, leaf, _, err := decodeLifecycleIdentityBundleWithReissue(ctx, manager, leaseID, bundle)
216 - if err != nil {
217 - return tls.Certificate{}, err
218 - }
219 -
220 - if _, err := manager.ValidateIdentity(leaseID, leaf); err != nil {
221 - bundle, err = repairLifecycleIdentityBundle(ctx, manager, leaseID, err)
222 - if err != nil {
223 - return tls.Certificate{}, err
224 - }
225 -
226 - cert, leaf, err = tlsCertificateFromLifecycleBundle(bundle)
227 - if err != nil {
228 - return tls.Certificate{}, err
229 - }
230 - if _, err := manager.ValidateIdentity(leaseID, leaf); err != nil {
231 - return tls.Certificate{}, fmt.Errorf("validate renewed lifecycle identity for lease %s: %w", leaseID, err)
232 - }
233 - }
234 -
235 - return cert, nil
236 -}
237 -
238 -func loadOrAcquireLifecycleIdentityBundle(ctx context.Context, manager *lifecycle.Manager, leaseID string) (*lifecycle.IdentityBundle, error) {
239 - bundle, err := manager.LoadIdentity(ctx, leaseID)
240 - switch {
241 - case errors.Is(err, lifecycle.ErrLeaseNotFound):
242 - bundle, err = manager.IssueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, nil)
243 - case errors.Is(err, lifecycle.ErrCorruptStore):
244 - bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "corrupt_store")
245 - }
246 - return bundle, err
247 -}
248 -
249 -func decodeLifecycleIdentityBundleWithReissue(
250 - ctx context.Context,
251 - manager *lifecycle.Manager,
252 - leaseID string,
253 - bundle *lifecycle.IdentityBundle,
254 -) (tls.Certificate, *x509.Certificate, *lifecycle.IdentityBundle, error) {
255 - cert, leaf, err := tlsCertificateFromLifecycleBundle(bundle)
256 - if err == nil {
257 - return cert, leaf, bundle, nil
258 - }
259 -
260 - reissued, reissueErr := manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "bundle_parse_failure")
261 - if reissueErr != nil {
262 - return tls.Certificate{}, nil, nil, fmt.Errorf("decode lifecycle identity for lease %s: %w", leaseID, err)
263 - }
264 -
265 - cert, leaf, err = tlsCertificateFromLifecycleBundle(reissued)
266 - if err != nil {
267 - return tls.Certificate{}, nil, nil, fmt.Errorf("decode reissued lifecycle identity for lease %s: %w", leaseID, err)
268 - }
269 - return cert, leaf, reissued, nil
270 -}
271 -
272 -func repairLifecycleIdentityBundle(
273 - ctx context.Context,
274 - manager *lifecycle.Manager,
275 - leaseID string,
276 - validateErr error,
277 -) (*lifecycle.IdentityBundle, error) {
278 - var (
279 - bundle *lifecycle.IdentityBundle
280 - err error
281 - )
282 -
283 - switch {
284 - case errors.Is(validateErr, lifecycle.ErrCorruptStore):
285 - bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "validate_corrupt_store")
286 - case errors.Is(validateErr, lifecycle.ErrInvalidCert), errors.Is(validateErr, lifecycle.ErrOverlapExpired):
287 - bundle, err = manager.RenewIdentity(ctx, leaseID)
288 - if errors.Is(err, lifecycle.ErrCorruptStore) {
289 - bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "renew_corrupt_store")
290 - }
291 - default:
292 - return nil, fmt.Errorf("validate lifecycle identity for lease %s: %w", leaseID, validateErr)
293 - }
294 - if err != nil {
295 - return nil, fmt.Errorf("repair lifecycle identity for lease %s: %w", leaseID, err)
296 - }
297 -
298 - return bundle, nil
299 -}
300 -
301 -func newLifecycleManager() (*lifecycle.Manager, error) {
302 - keylessDir := strings.TrimSpace(os.Getenv(keylessDirEnvVar))
303 - if keylessDir == "" {
304 - keylessDir = defaultKeylessDir
305 - }
306 -
307 - certPath := filepath.Join(keylessDir, keylessFullChainFile)
308 - keyPath := filepath.Join(keylessDir, keylessPrivateKeyFile)
309 - certPEM, err := os.ReadFile(certPath)
310 - if err != nil {
311 - return nil, fmt.Errorf("read keyless issuer certificate %q: %w", certPath, err)
312 - }
313 - keyPEM, err := os.ReadFile(keyPath)
314 - if err != nil {
315 - return nil, fmt.Errorf("read keyless issuer private key %q: %w", keyPath, err)
316 - }
317 - if _, err = tls.X509KeyPair(certPEM, keyPEM); err != nil {
318 - return nil, fmt.Errorf("load keyless issuer key pair from %q and %q: %w", certPath, keyPath, err)
319 - }
320 -
321 - secret := sha256.Sum256(keyPEM)
322 - storeDir := filepath.Join(keylessDir, keylessLifecycleStateSubdir)
323 - store, err := lifecycle.NewDiskStore(storeDir, secret[:])
324 - if err != nil {
325 - return nil, fmt.Errorf("create keyless lifecycle store %q: %w", storeDir, err)
326 - }
327 -
328 - manager, err := lifecycle.NewManager(lifecycle.ManagerConfig{
329 - Store: store,
330 - IssuerCertPEM: certPEM,
331 - IssuerKeyPEM: keyPEM,
332 - })
333 - if err != nil {
334 - return nil, fmt.Errorf("create keyless lifecycle manager: %w", err)
335 - }
336 - return manager, nil
337 -}
338 -
339 -func tlsCertificateFromLifecycleBundle(bundle *lifecycle.IdentityBundle) (tls.Certificate, *x509.Certificate, error) {
340 - if bundle == nil {
341 - return tls.Certificate{}, nil, errors.New("lifecycle identity bundle is required")
342 - }
343 - if len(bundle.ChainPEM) == 0 {
344 - return tls.Certificate{}, nil, errors.New("lifecycle identity certificate chain is empty")
345 - }
346 - if len(bundle.KeyPEM) == 0 {
347 - return tls.Certificate{}, nil, errors.New("lifecycle identity private key is empty")
348 - }
349 -
350 - cert, err := tls.X509KeyPair(bundle.ChainPEM, bundle.KeyPEM)
351 - if err != nil {
352 - return tls.Certificate{}, nil, fmt.Errorf("load lifecycle identity key pair for lease %s: %w", bundle.LeaseID, err)
353 - }
354 - if len(cert.Certificate) == 0 {
355 - return tls.Certificate{}, nil, fmt.Errorf("lifecycle identity certificate chain missing leaf for lease %s", bundle.LeaseID)
356 - }
357 - leaf, err := x509.ParseCertificate(cert.Certificate[0])
358 - if err != nil {
359 - return tls.Certificate{}, nil, fmt.Errorf("parse lifecycle identity leaf certificate for lease %s: %w", bundle.LeaseID, err)
360 - }
361 - return cert, leaf, nil
362 -}
363 -
180 func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []func(), error) {
181 parsed, err := url.Parse(relayAddr)
182 if err != nil {
@@ -383,7 +199,7 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
199 return tlsConfig, []func(){closeFn}, nil
200 }
201
386 -// Close keeps SDK lifecycle parity with callers that defer cleanup.
202 +// Close is a no-op kept for caller compatibility.
203 func (c *Client) Close() error {
204 return nil
205 }
sdk/listener.go
+1 -13
@@ -38,10 +38,6 @@ var fatalReverseConnectRejectionCodes = map[string]struct{}{
38 "tls_required": {},
39 "unauthorized": {},
40 "unsupported_transport": {},
41 - "client_cert_required": {},
42 - "client_cert_invalid": {},
43 - "cert_lease_missing": {},
44 - "cert_lease_mismatch": {},
41 }
42
43 type reverseConnectRejectionError struct {
@@ -84,7 +80,6 @@ func (e *reverseConnectRejectionError) IsFatal() bool {
80 // The relay connects to this listener after SNI routing resolves the lease.
81 type Listener struct {
82 tlsConfig *tls.Config
87 - controlPlaneCert tls.Certificate
83 lease *types.Lease
84 httpClient *http.Client
85 stopCh chan struct{}
@@ -103,7 +98,7 @@ var _ net.Listener = (*Listener)(nil)
98
99 // NewListener creates a relay-backed listener.
100 // If tlsConfig is provided, reverse workers complete TLS handshakes before enqueueing connections.
106 -func NewListener(relayAddr string, lease *types.Lease, tlsConfig *tls.Config, controlPlaneCert tls.Certificate, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
101 +func NewListener(relayAddr string, lease *types.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
102 if lease == nil {
103 return nil, errors.New("lease is required")
104 }
@@ -130,9 +125,6 @@ func NewListener(relayAddr string, lease *types.Lease, tlsConfig *tls.Config, co
125 ServerName: host,
126 InsecureSkipVerify: types.IsLocalhost(host),
127 }
133 - if len(controlPlaneCert.Certificate) > 0 {
134 - transportTLSConfig.Certificates = []tls.Certificate{controlPlaneCert}
135 - }
128 clientTransport.TLSClientConfig = transportTLSConfig
129
130 if reverseWorkers <= 0 {
@@ -151,7 +143,6 @@ func NewListener(relayAddr string, lease *types.Lease, tlsConfig *tls.Config, co
143 Transport: clientTransport,
144 },
145 tlsConfig: tlsConfig,
154 - controlPlaneCert: controlPlaneCert,
146 closeFns: closeFns,
147 stopCh: make(chan struct{}),
148 acceptCh: make(chan net.Conn, 128),
@@ -408,9 +399,6 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
399 ServerName: serverName,
400 InsecureSkipVerify: types.IsLocalhost(serverName),
401 }
411 - if len(l.controlPlaneCert.Certificate) > 0 {
412 - reverseTLSConfig.Certificates = []tls.Certificate{l.controlPlaneCert}
413 - }
402 tlsConn := tls.Client(rawConn, reverseTLSConfig)
403 err = tlsConn.HandshakeContext(ctx)
404 if err != nil {
sdk/listener_test.go
+3 -88
@@ -8,7 +8,6 @@ import (
8 "net"
9 "net/http"
10 "net/url"
11 - "reflect"
11 "strings"
12 "testing"
13 "time"
@@ -16,7 +15,7 @@ import (
15 "gosuda.org/portal/types"
16 )
17
19 -func TestNewListener_ZeroCert_Succeeds(t *testing.T) {
18 +func TestNewListener_Succeeds(t *testing.T) {
19 t.Parallel()
20
21 relayAddr := "https://localhost:4017"
@@ -26,98 +25,14 @@ func TestNewListener_ZeroCert_Succeeds(t *testing.T) {
25 ReverseToken: "test-token",
26 }
27 tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
29 - zeroCert := tls.Certificate{}
30 -
31 - listener, err := NewListener(relayAddr, lease, tlsConfig, zeroCert, 0, 0)
28 + listener, err := NewListener(relayAddr, lease, tlsConfig, 0, 0)
29 if err != nil {
33 - t.Fatalf("NewListener with zero cert failed: %v", err)
30 + t.Fatalf("NewListener failed: %v", err)
31 }
32 if listener == nil {
33 t.Fatal("NewListener returned nil listener")
34 }
35 defer listener.Close()
39 -
40 - // Use reflection to access unexported controlPlaneCert field
41 - lValue := reflect.ValueOf(listener).Elem()
42 - certField := lValue.FieldByName("controlPlaneCert")
43 - if !certField.IsValid() {
44 - t.Fatal("could not access controlPlaneCert field")
45 - }
46 - // Access the Certificate slice field directly (it's exported from tls.Certificate)
47 - certSlice := certField.FieldByName("Certificate")
48 - if !certSlice.IsValid() {
49 - t.Fatal("could not access Certificate field")
50 - }
51 - if certSlice.Len() != 0 {
52 - t.Fatalf("controlPlaneCert.Certificate has len %d, want 0", certSlice.Len())
53 - }
54 -}
55 -
56 -func TestOpenReverseConnection_NoCert_NoClientCertPresented(t *testing.T) {
57 - t.Parallel()
58 -
59 - // Test the exact condition we care about: when controlPlaneCert is zero-value,
60 - // the TLS config does NOT include the cert. This is what prevents client cert presentation.
61 -
62 - tests := []struct {
63 - cert tls.Certificate
64 - name string
65 - wantCertSet bool
66 - }{
67 - {
68 - name: "zero cert → no client cert in TLS config",
69 - cert: tls.Certificate{},
70 - wantCertSet: false,
71 - },
72 - {
73 - name: "non-zero cert → client cert in TLS config",
74 - cert: tls.Certificate{Certificate: [][]byte{{0x01, 0x02, 0x03}}},
75 - wantCertSet: true,
76 - },
77 - }
78 -
79 - for _, tt := range tests {
80 - t.Run(tt.name, func(t *testing.T) {
81 - t.Parallel()
82 -
83 - l := &Listener{
84 - controlPlaneCert: tt.cert,
85 - }
86 -
87 - // Duplicate the exact logic from listener.go (both HTTP transport and reverse connection)
88 - // First check the HTTP transport logic (lines 134-136)
89 - transportTLSConfig := &tls.Config{}
90 - if len(l.controlPlaneCert.Certificate) > 0 {
91 - transportTLSConfig.Certificates = []tls.Certificate{l.controlPlaneCert}
92 - }
93 -
94 - if tt.wantCertSet {
95 - if len(transportTLSConfig.Certificates) != 1 {
96 - t.Fatalf("transportTLSConfig.Certificates has len %d, want 1", len(transportTLSConfig.Certificates))
97 - }
98 - } else {
99 - if len(transportTLSConfig.Certificates) != 0 {
100 - t.Fatalf("transportTLSConfig.Certificates has len %d, want 0", len(transportTLSConfig.Certificates))
101 - }
102 - }
103 -
104 - // Then check the reverse connection logic (lines 412-414)
105 - reverseTLSConfig := &tls.Config{}
106 - if len(l.controlPlaneCert.Certificate) > 0 {
107 - reverseTLSConfig.Certificates = []tls.Certificate{l.controlPlaneCert}
108 - }
109 -
110 - if tt.wantCertSet {
111 - if len(reverseTLSConfig.Certificates) != 1 {
112 - t.Fatalf("reverseTLSConfig.Certificates has len %d, want 1", len(reverseTLSConfig.Certificates))
113 - }
114 - } else {
115 - if len(reverseTLSConfig.Certificates) != 0 {
116 - t.Fatalf("reverseTLSConfig.Certificates has len %d, want 0", len(reverseTLSConfig.Certificates))
117 - }
118 - }
119 - })
120 - }
36 }
37
38 func TestNormalizeRelayAPIURL(t *testing.T) {