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