refactor: add controlplane in portal subpackage
Kim committed
Mar 4, 2026 at 18:54 UTC
f975b84ea3b0069883f2767da2a2d7b928534af8
11 files changed
+409
-338
cmd/relay-server/admin.go
+1
-1
@@ -25,8 +25,8 @@ type Admin struct {
25
authManager *manager.AuthManager
26
frontend *Frontend
27
portalURL string
28
- trustProxy bool
28
settingsPath string
29
+ trustProxy bool
30
settingsMu sync.Mutex
31
}
32
cmd/relay-server/control_plane_admission.go
deleted
-119
@@ -1,119 +0,0 @@
1
-package main
2
-
3
-import (
4
- "crypto/subtle"
5
- "crypto/x509"
6
- "fmt"
7
- "net/http"
8
- "slices"
9
- "strings"
10
- "time"
11
-
12
- "gosuda.org/portal/portal"
13
- "gosuda.org/portal/types"
14
-)
15
-
16
-type admissionConfig struct {
17
- requireExistingLease bool
18
-}
19
-
20
-type admissionContext struct {
21
- entry *portal.LeaseEntry
22
- clientIP string
23
- leaseID string
24
- token string
25
-}
26
-
27
-func constantLeaseMatch(expected, provided string) bool {
28
- expected = strings.TrimSpace(expected)
29
- provided = strings.TrimSpace(provided)
30
- if expected == "" || provided == "" {
31
- return false
32
- }
33
- return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
34
-}
35
-
36
-func extractLeaseIDFromPeerCertificate(cert *x509.Certificate) string {
37
- if cert == nil {
38
- return ""
39
- }
40
- for _, uri := range cert.URIs {
41
- if uri == nil {
42
- continue
43
- }
44
- raw := strings.TrimSpace(uri.String())
45
- if after, ok := strings.CutPrefix(raw, types.ControlPlaneLeaseURIPrefix); ok {
46
- return after
47
- }
48
- }
49
-
50
- commonName := strings.TrimSpace(cert.Subject.CommonName)
51
- if after, ok := strings.CutPrefix(commonName, types.ControlPlaneCertCNPrefix); ok {
52
- return after
53
- }
54
- return commonName
55
-}
56
-
57
-func validatePeerLeaseCertificate(req *http.Request, leaseID string) (string, string, bool) {
58
- if req == nil || req.TLS == nil || len(req.TLS.PeerCertificates) == 0 {
59
- return "client_cert_required", "client certificate is required", false
60
- }
61
-
62
- leaf := req.TLS.PeerCertificates[0]
63
- now := time.Now()
64
- if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
65
- return "client_cert_invalid", "client certificate is outside validity window", false
66
- }
67
-
68
- if len(leaf.ExtKeyUsage) > 0 {
69
- hasClientAuth := slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
70
- if !hasClientAuth {
71
- return "client_cert_invalid", "client certificate does not allow client authentication", false
72
- }
73
- }
74
-
75
- certLeaseID := strings.TrimSpace(extractLeaseIDFromPeerCertificate(leaf))
76
- if certLeaseID == "" {
77
- return "cert_lease_missing", "client certificate does not include lease identity", false
78
- }
79
- if !constantLeaseMatch(leaseID, certLeaseID) {
80
- return "cert_lease_mismatch", fmt.Sprintf("client certificate lease identity mismatch: requested=%s cert=%s", leaseID, certLeaseID), false
81
- }
82
- return "", "", true
83
-}
84
-
85
-func (r *SDKRegistry) admitControlPlane(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer, rawLeaseID, rawToken string, cfg admissionConfig) (*admissionContext, bool) {
86
- leaseID, token := normalizeLeaseCredentials(rawLeaseID, rawToken)
87
- if !r.validateLeaseCredentials(w, leaseID, token) {
88
- return nil, false
89
- }
90
-
91
- clientIP := r.extractClientIP(req)
92
- if r.isClientIPBanned(clientIP) {
93
- writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
94
- return nil, false
95
- }
96
-
97
- entry, exists := lookupLeaseEntry(serv, leaseID)
98
- if cfg.requireExistingLease && !exists {
99
- writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
100
- return nil, false
101
- }
102
-
103
- if code, message, ok := validatePeerLeaseCertificate(req, leaseID); !ok {
104
- writeAPIError(w, http.StatusUnauthorized, code, message)
105
- return nil, false
106
- }
107
-
108
- if exists && !constantLeaseMatch(entry.Lease.ReverseToken, token) {
109
- writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
110
- return nil, false
111
- }
112
-
113
- return &admissionContext{
114
- clientIP: clientIP,
115
- leaseID: leaseID,
116
- token: token,
117
- entry: entry,
118
- }, true
119
-}
cmd/relay-server/registry.go
+82
-83
@@ -11,6 +11,7 @@ import (
11
12
"gosuda.org/portal/cmd/relay-server/manager"
13
"gosuda.org/portal/portal"
14
+ "gosuda.org/portal/portal/controlplane"
15
"gosuda.org/portal/types"
16
)
17
@@ -23,71 +24,6 @@ type SDKRegistry struct {
24
25
const sdkLeaseTTL = 30 * time.Second
26
26
-func normalizeLeaseID(raw string) string {
27
- return strings.TrimSpace(raw)
28
-}
29
-
30
-func normalizeLeaseCredentials(leaseID, reverseToken string) (string, string) {
31
- return normalizeLeaseID(leaseID), strings.TrimSpace(reverseToken)
32
-}
33
-
34
-func lookupLeaseEntry(serv *portal.RelayServer, leaseID string) (*portal.LeaseEntry, bool) {
35
- if serv == nil {
36
- return nil, false
37
- }
38
- entry, ok := serv.GetLeaseManager().GetLeaseByID(normalizeLeaseID(leaseID))
39
- if !ok || entry == nil || entry.Lease == nil {
40
- return nil, false
41
- }
42
- return entry, true
43
-}
44
-
45
-func (r *SDKRegistry) extractClientIP(req *http.Request) string {
46
- return manager.ExtractClientIP(req, r.trustProxyHeaders)
47
-}
48
-
49
-func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
50
- return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
51
-}
52
-
53
-func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
54
- if req.Method == method {
55
- return true
56
- }
57
-
58
- w.Header().Set("Allow", method)
59
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
60
- return false
61
-}
62
-
63
-func (r *SDKRegistry) decodeRequestBody(w http.ResponseWriter, req *http.Request, dst any, logMessage string) bool {
64
- if err := json.NewDecoder(req.Body).Decode(dst); err != nil {
65
- log.Error().Err(err).Msg(logMessage)
66
- writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
67
- return false
68
- }
69
- return true
70
-}
71
-
72
-func (r *SDKRegistry) validateLeaseCredentials(w http.ResponseWriter, leaseID, reverseToken string) bool {
73
- if leaseID == "" {
74
- writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
75
- return false
76
- }
77
- if reverseToken == "" {
78
- writeAPIError(w, http.StatusBadRequest, "missing_reverse_token", "reverse_token is required")
79
- return false
80
- }
81
- return true
82
-}
83
-
84
-func isWebSocketUpgrade(req *http.Request) bool {
85
- if req == nil {
86
- return false
87
- }
88
- return hasForwardedToken(req.Header.Get("Upgrade"), "websocket")
89
-}
90
-
27
// HandleSDKRequest routes /sdk/* requests.
28
func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
29
path := strings.TrimSuffix(req.URL.Path, "/")
@@ -121,9 +57,7 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
57
58
leaseID := req.URL.Query().Get("lease_id")
59
token := req.Header.Get(portal.ReverseConnectTokenHeader)
124
- ctx, ok := r.admitControlPlane(w, req, serv, leaseID, token, admissionConfig{
125
- requireExistingLease: true,
126
- })
60
+ leaseID, token, clientIP, _, ok := r.admitControlPlane(w, req, serv, leaseID, token, true)
61
if !ok {
62
return
63
}
@@ -151,7 +85,7 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
85
return
86
}
87
154
- serv.GetReverseHub().HandleConnect(conn, ctx.leaseID, ctx.token, ctx.clientIP)
88
+ serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
89
}
90
91
// handleRegister handles SDK lease registration requests.
@@ -174,12 +108,12 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
108
writeAPIError(w, http.StatusBadRequest, "tls_required", "tls must be enabled")
109
return
110
}
177
- ctx, ok := r.admitControlPlane(w, req, serv, registerReq.LeaseID, registerReq.ReverseToken, admissionConfig{})
111
+ leaseID, token, _, _, ok := r.admitControlPlane(w, req, serv, registerReq.LeaseID, registerReq.ReverseToken, false)
112
if !ok {
113
return
114
}
181
- registerReq.LeaseID = ctx.leaseID
182
- registerReq.ReverseToken = ctx.token
115
+ registerReq.LeaseID = leaseID
116
+ registerReq.ReverseToken = token
117
118
// Create lease
119
lease := &portal.Lease{
@@ -235,20 +169,18 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
169
if !r.decodeRequestBody(w, req, &unregisterReq, "[Registry] Failed to decode unregistration request") {
170
return
171
}
238
- ctx, ok := r.admitControlPlane(w, req, serv, unregisterReq.LeaseID, unregisterReq.ReverseToken, admissionConfig{
239
- requireExistingLease: true,
240
- })
172
+ leaseID, _, _, _, ok := r.admitControlPlane(w, req, serv, unregisterReq.LeaseID, unregisterReq.ReverseToken, true)
173
if !ok {
174
return
175
}
176
245
- if serv.GetLeaseManager().DeleteLease(ctx.leaseID) {
177
+ if serv.GetLeaseManager().DeleteLease(leaseID) {
178
log.Info().
247
- Str("lease_id", ctx.leaseID).
179
+ Str("lease_id", leaseID).
180
Msg("[Registry] Lease unregistered")
181
}
250
- serv.GetSNIRouter().UnregisterRouteByLeaseID(ctx.leaseID)
251
- serv.GetReverseHub().DropLease(ctx.leaseID)
182
+ serv.GetSNIRouter().UnregisterRouteByLeaseID(leaseID)
183
+ serv.GetReverseHub().DropLease(leaseID)
184
185
writeAPIOK(w, http.StatusOK)
186
}
@@ -264,14 +196,11 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
196
return
197
}
198
267
- ctx, ok := r.admitControlPlane(w, req, serv, renewReq.LeaseID, renewReq.ReverseToken, admissionConfig{
268
- requireExistingLease: true,
269
- })
199
+ _, _, _, entry, ok := r.admitControlPlane(w, req, serv, renewReq.LeaseID, renewReq.ReverseToken, true)
200
if !ok {
201
return
202
}
203
274
- entry := ctx.entry
204
entry.Lease.Expires = time.Now().Add(sdkLeaseTTL)
205
if !serv.GetLeaseManager().UpdateLease(entry.Lease) {
206
writeAPIError(w, http.StatusInternalServerError, "renew_failed", "failed to renew lease")
@@ -307,3 +236,73 @@ func (r *SDKRegistry) handleDomain(w http.ResponseWriter, _ *http.Request, serv
236
BaseDomain: serv.BaseHost,
237
})
238
}
239
+
240
+func (r *SDKRegistry) admitControlPlane(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer, rawLeaseID, rawToken string, requireExistingLease bool) (leaseID, token, clientIP string, entry *portal.LeaseEntry, ok bool) {
241
+ leaseID, token = normalizeLeaseCredentials(rawLeaseID, rawToken)
242
+ if !r.validateLeaseCredentials(w, leaseID, token) {
243
+ return "", "", "", nil, false
244
+ }
245
+
246
+ clientIP = r.extractClientIP(req)
247
+ if r.isClientIPBanned(clientIP) {
248
+ writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
249
+ return "", "", "", nil, false
250
+ }
251
+
252
+ entry, exists := lookupLeaseEntry(serv, leaseID)
253
+ if requireExistingLease && !exists {
254
+ writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
255
+ return "", "", "", nil, false
256
+ }
257
+
258
+ if code, message, ok := controlplane.ValidatePeerLeaseCertificate(req.TLS, leaseID); !ok {
259
+ writeAPIError(w, http.StatusUnauthorized, code, message)
260
+ return "", "", "", nil, false
261
+ }
262
+
263
+ if exists && !controlplane.MatchLeaseToken(entry.Lease.ReverseToken, token) {
264
+ writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
265
+ return "", "", "", nil, false
266
+ }
267
+
268
+ return leaseID, token, clientIP, entry, true
269
+}
270
+
271
+func (r *SDKRegistry) extractClientIP(req *http.Request) string {
272
+ return manager.ExtractClientIP(req, r.trustProxyHeaders)
273
+}
274
+
275
+func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
276
+ return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
277
+}
278
+
279
+func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
280
+ if req.Method == method {
281
+ return true
282
+ }
283
+
284
+ w.Header().Set("Allow", method)
285
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
286
+ return false
287
+}
288
+
289
+func (r *SDKRegistry) decodeRequestBody(w http.ResponseWriter, req *http.Request, dst any, logMessage string) bool {
290
+ if err := json.NewDecoder(req.Body).Decode(dst); err != nil {
291
+ log.Error().Err(err).Msg(logMessage)
292
+ writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
293
+ return false
294
+ }
295
+ return true
296
+}
297
+
298
+func (r *SDKRegistry) validateLeaseCredentials(w http.ResponseWriter, leaseID, reverseToken string) bool {
299
+ if leaseID == "" {
300
+ writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
301
+ return false
302
+ }
303
+ if reverseToken == "" {
304
+ writeAPIError(w, http.StatusBadRequest, "missing_reverse_token", "reverse_token is required")
305
+ return false
306
+ }
307
+ return true
308
+}
cmd/relay-server/utils.go
+26
@@ -46,6 +46,32 @@ func hasForwardedToken(raw, target string) bool {
46
return false
47
}
48
49
+func normalizeLeaseID(raw string) string {
50
+ return strings.TrimSpace(raw)
51
+}
52
+
53
+func normalizeLeaseCredentials(leaseID, reverseToken string) (string, string) {
54
+ return normalizeLeaseID(leaseID), strings.TrimSpace(reverseToken)
55
+}
56
+
57
+func lookupLeaseEntry(serv *portal.RelayServer, leaseID string) (*portal.LeaseEntry, bool) {
58
+ if serv == nil {
59
+ return nil, false
60
+ }
61
+ entry, ok := serv.GetLeaseManager().GetLeaseByID(normalizeLeaseID(leaseID))
62
+ if !ok || entry == nil || entry.Lease == nil {
63
+ return nil, false
64
+ }
65
+ return entry, true
66
+}
67
+
68
+func isWebSocketUpgrade(req *http.Request) bool {
69
+ if req == nil {
70
+ return false
71
+ }
72
+ return hasForwardedToken(req.Header.Get("Upgrade"), "websocket")
73
+}
74
+
75
// getContentType returns the MIME type for a file extension.
76
func getContentType(ext string) string {
77
switch ext {
portal/controlplane/controlplane.go
new
+177
@@ -0,0 +1,177 @@
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
new
+119
@@ -0,0 +1,119 @@
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 TestExtractLeaseIDFromPeerCertificateRejectsUnprefixedCN(t *testing.T) {
111
+ t.Parallel()
112
+
113
+ leaseID := ExtractLeaseIDFromPeerCertificate(&x509.Certificate{
114
+ Subject: pkix.Name{CommonName: "lease-identity"},
115
+ })
116
+ if leaseID != "" {
117
+ t.Fatalf("expected empty lease id for unprefixed CN, got %q", leaseID)
118
+ }
119
+}
portal/relay.go
+2
-8
@@ -2,7 +2,6 @@ package portal
2
3
import (
4
"context"
5
- "crypto/subtle"
5
"fmt"
6
"net"
7
"strings"
@@ -12,6 +11,7 @@ import (
11
"github.com/rs/zerolog/log"
12
13
"gosuda.org/portal/portal/acme"
14
+ "gosuda.org/portal/portal/controlplane"
15
"gosuda.org/portal/portal/keyless"
16
"gosuda.org/portal/portal/sni"
17
)
@@ -110,13 +110,7 @@ func (g *RelayServer) authorizeReverseConnect(leaseID, token string) bool {
110
return false
111
}
112
113
- expected := strings.TrimSpace(entry.Lease.ReverseToken)
114
- provided := strings.TrimSpace(token)
115
- if expected == "" || provided == "" {
116
- return false
117
- }
118
-
119
- return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
113
+ return controlplane.MatchLeaseToken(entry.Lease.ReverseToken, token)
114
}
115
116
// GetLeaseManager returns the lease manager instance.
sdk/client.go
+2
-1
@@ -15,6 +15,7 @@ import (
15
"github.com/rs/zerolog/log"
16
17
"gosuda.org/portal/portal"
18
+ "gosuda.org/portal/portal/controlplane"
19
"gosuda.org/portal/portal/keyless"
20
"gosuda.org/portal/types"
21
)
@@ -91,7 +92,7 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
92
if err != nil {
93
return nil, err
94
}
94
- controlPlaneIdentity, err := issueControlPlaneIdentity(lease.ID)
95
+ controlPlaneIdentity, err := controlplane.IssueIdentity(lease.ID)
96
if err != nil {
97
return nil, err
98
}
sdk/controlplane_identity.go
deleted
-74
@@ -1,74 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "crypto/ecdsa"
5
- "crypto/elliptic"
6
- "crypto/rand"
7
- "crypto/tls"
8
- "crypto/x509"
9
- "crypto/x509/pkix"
10
- "encoding/pem"
11
- "errors"
12
- "fmt"
13
- "math/big"
14
- "net/url"
15
- "strings"
16
- "time"
17
-
18
- "gosuda.org/portal/types"
19
-)
20
-
21
-func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
22
- leaseID = strings.TrimSpace(leaseID)
23
- if leaseID == "" {
24
- return tls.Certificate{}, errors.New("lease id is required")
25
- }
26
-
27
- priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
28
- if err != nil {
29
- return tls.Certificate{}, fmt.Errorf("generate identity key: %w", err)
30
- }
31
-
32
- serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
33
- if err != nil {
34
- return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
35
- }
36
- notBefore := time.Now().Add(-1 * time.Minute)
37
- notAfter := notBefore.Add(24 * time.Hour)
38
-
39
- leaseURI, err := url.Parse(types.ControlPlaneLeaseURIPrefix + leaseID)
40
- if err != nil {
41
- return tls.Certificate{}, fmt.Errorf("build lease URI: %w", err)
42
- }
43
-
44
- template := &x509.Certificate{
45
- SerialNumber: serial,
46
- Subject: pkix.Name{
47
- CommonName: types.ControlPlaneCertCNPrefix + leaseID,
48
- },
49
- NotBefore: notBefore,
50
- NotAfter: notAfter,
51
- KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
52
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
53
- BasicConstraintsValid: true,
54
- URIs: []*url.URL{leaseURI},
55
- }
56
-
57
- der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
58
- if err != nil {
59
- return tls.Certificate{}, fmt.Errorf("create lease identity certificate: %w", err)
60
- }
61
-
62
- certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
63
- keyDER, err := x509.MarshalECPrivateKey(priv)
64
- if err != nil {
65
- return tls.Certificate{}, fmt.Errorf("marshal identity key: %w", err)
66
- }
67
- keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
68
-
69
- cert, err := tls.X509KeyPair(certPEM, keyPEM)
70
- if err != nil {
71
- return tls.Certificate{}, fmt.Errorf("load identity key pair: %w", err)
72
- }
73
- return cert, nil
74
-}
sdk/controlplane_identity_test.go
deleted
-44
@@ -1,44 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "crypto/x509"
5
- "strings"
6
- "testing"
7
- "time"
8
-
9
- "gosuda.org/portal/types"
10
-)
11
-
12
-func TestIssueControlPlaneIdentity(t *testing.T) {
13
- t.Parallel()
14
-
15
- identity, err := issueControlPlaneIdentity("lease-identity")
16
- if err != nil {
17
- t.Fatalf("issueControlPlaneIdentity returned error: %v", err)
18
- }
19
- if len(identity.Certificate) == 0 {
20
- t.Fatal("identity certificate chain is empty")
21
- }
22
-
23
- leaf, err := x509.ParseCertificate(identity.Certificate[0])
24
- if err != nil {
25
- t.Fatalf("parse issued certificate: %v", err)
26
- }
27
- if got := strings.TrimSpace(leaf.Subject.CommonName); got != types.ControlPlaneCertCNPrefix+"lease-identity" {
28
- t.Fatalf("certificate common name = %q, want %q", got, types.ControlPlaneCertCNPrefix+"lease-identity")
29
- }
30
- if len(leaf.URIs) == 0 || leaf.URIs[0].String() != types.ControlPlaneLeaseURIPrefix+"lease-identity" {
31
- t.Fatalf("certificate lease URI = %v, want %q", leaf.URIs, types.ControlPlaneLeaseURIPrefix+"lease-identity")
32
- }
33
- if time.Now().Before(leaf.NotBefore) || time.Now().After(leaf.NotAfter) {
34
- t.Fatalf("issued certificate validity window does not include current time")
35
- }
36
-}
37
-
38
-func TestIssueControlPlaneIdentityRejectsEmptyLeaseID(t *testing.T) {
39
- t.Parallel()
40
-
41
- if _, err := issueControlPlaneIdentity(" "); err == nil {
42
- t.Fatal("expected error for empty lease ID")
43
- }
44
-}
types/controlplane.go
deleted
-8
@@ -1,8 +0,0 @@
1
-package types
2
-
3
-const (
4
- // ControlPlaneCertCNPrefix is the CN prefix used for lease-bound client identity certs.
5
- ControlPlaneCertCNPrefix = "lease:"
6
- // ControlPlaneLeaseURIPrefix is the URI prefix used in lease-bound SPIFFE-like identities.
7
- ControlPlaneLeaseURIPrefix = "spiffe://portal/lease/"
8
-)