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) {