remove global flags

Kim committed Mar 4, 2026 at 17:40 UTC 1485c8a9a0d915207fb92d2567ec4569814105e0
17 files changed +53 -1195
cmd/relay-server/admin.go
+9 -5
@@ -24,16 +24,20 @@ type Admin struct {
24 ipManager *manager.IPManager
25 authManager *manager.AuthManager
26 frontend *Frontend
27 + portalURL string
28 + trustProxy bool
29 settingsPath string
30 settingsMu sync.Mutex
31 }
32
31 -func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *manager.AuthManager) *Admin {
33 +func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *manager.AuthManager, portalURL string, trustProxy bool) *Admin {
34 bpsManager := manager.NewBPSManager()
35 if defaultLeaseBPS > 0 {
36 bpsManager.SetDefaultBPS(defaultLeaseBPS)
37 }
38 return &Admin{
39 + portalURL: strings.TrimSpace(portalURL),
40 + trustProxy: trustProxy,
41 settingsPath: "admin_settings.json",
42 approveManager: manager.NewApproveManager(),
43 bpsManager: bpsManager,
@@ -232,7 +236,7 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
236 case route == "":
237 a.frontend.ServeAppStatic(w, r, "", serv)
238 case route == "leases" && r.Method == http.MethodGet:
235 - writeAPIData(w, http.StatusOK, convertLeaseEntriesToRows(serv, a, true))
239 + writeAPIData(w, http.StatusOK, convertLeaseEntriesToRows(serv, a, true, a.portalURL))
240 case route == "leases/banned" && r.Method == http.MethodGet:
241 writeAPIData(w, http.StatusOK, serv.GetLeaseManager().GetBannedLeases())
242 case route == "stats" && r.Method == http.MethodGet:
@@ -257,7 +261,7 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
261
262 // handleLogin handles POST /admin/login.
263 func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
260 - clientIP := manager.ExtractClientIP(r, flagTrustProxyHeaders)
264 + clientIP := manager.ExtractClientIP(r, a.trustProxy)
265
266 // Check if IP is locked
267 if a.authManager.IsIPLocked(clientIP) {
@@ -299,7 +303,7 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
303 // Successful login
304 a.authManager.ResetFailedLogin(clientIP)
305 token := a.authManager.CreateSession()
302 - secureCookie := isSecureRequest(r)
306 + secureCookie := isSecureRequestWithPolicy(r, a.trustProxy)
307
308 http.SetCookie(w, &http.Cookie{
309 Name: adminCookieName,
@@ -321,7 +325,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
325 if err == nil && cookie.Value != "" {
326 a.authManager.DeleteSession(cookie.Value)
327 }
324 - secureCookie := isSecureRequest(r)
328 + secureCookie := isSecureRequestWithPolicy(r, a.trustProxy)
329
330 // Clear the cookie
331 http.SetCookie(w, &http.Cookie{
cmd/relay-server/admin_test.go deleted
-136
@@ -1,136 +0,0 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "encoding/base64"
6 - "encoding/json"
7 - "net/http"
8 - "net/http/httptest"
9 - "slices"
10 - "testing"
11 -
12 - "gosuda.org/portal/cmd/relay-server/manager"
13 - "gosuda.org/portal/portal"
14 - "gosuda.org/portal/types"
15 -)
16 -
17 -func encodeLeaseIDForAdminRoute(leaseID string) string {
18 - return base64.RawURLEncoding.EncodeToString([]byte(leaseID))
19 -}
20 -
21 -func TestParseLeaseActionRoute(t *testing.T) {
22 - encodedLeaseID := encodeLeaseIDForAdminRoute("lease-123")
23 -
24 - tests := []struct {
25 - name string
26 - route string
27 - wantLease string
28 - wantAction string
29 - wantStatus leaseActionRouteStatus
30 - }{
31 - {
32 - name: "ban action",
33 - route: "leases/" + encodedLeaseID + "/ban",
34 - wantLease: "lease-123",
35 - wantAction: "ban",
36 - wantStatus: leaseActionRouteOK,
37 - },
38 - {
39 - name: "bps action",
40 - route: "leases/" + encodedLeaseID + "/bps",
41 - wantLease: "lease-123",
42 - wantAction: "bps",
43 - wantStatus: leaseActionRouteOK,
44 - },
45 - {
46 - name: "approve action",
47 - route: "leases/" + encodedLeaseID + "/approve",
48 - wantLease: "lease-123",
49 - wantAction: "approve",
50 - wantStatus: leaseActionRouteOK,
51 - },
52 - {
53 - name: "deny action",
54 - route: "leases/" + encodedLeaseID + "/deny",
55 - wantLease: "lease-123",
56 - wantAction: "deny",
57 - wantStatus: leaseActionRouteOK,
58 - },
59 - {
60 - name: "unsupported action",
61 - route: "leases/" + encodedLeaseID + "/noop",
62 - wantStatus: leaseActionRouteNotFound,
63 - },
64 - {
65 - name: "invalid route shape",
66 - route: "leases/" + encodedLeaseID,
67 - wantStatus: leaseActionRouteNotFound,
68 - },
69 - {
70 - name: "invalid encoded lease id",
71 - route: "leases/not_base64!/ban",
72 - wantAction: "ban",
73 - wantStatus: leaseActionRouteInvalidLeaseID,
74 - },
75 - }
76 -
77 - for _, tt := range tests {
78 - t.Run(tt.name, func(t *testing.T) {
79 - gotLease, gotAction, gotStatus := parseLeaseActionRoute(tt.route)
80 -
81 - if gotStatus != tt.wantStatus {
82 - t.Fatalf("parseLeaseActionRoute(%q) status=%v, want %v", tt.route, gotStatus, tt.wantStatus)
83 - }
84 - if gotLease != tt.wantLease {
85 - t.Fatalf("parseLeaseActionRoute(%q) leaseID=%q, want %q", tt.route, gotLease, tt.wantLease)
86 - }
87 - if gotAction != tt.wantAction {
88 - t.Fatalf("parseLeaseActionRoute(%q) action=%q, want %q", tt.route, gotAction, tt.wantAction)
89 - }
90 - })
91 - }
92 -}
93 -
94 -func TestHandleAdminRequestBannedLeasesReturnsPlainIDs(t *testing.T) {
95 - serv, err := portal.NewRelayServer(context.Background(), nil, ":0", "portal.example.com", "", "")
96 - if err != nil {
97 - t.Fatalf("create relay server: %v", err)
98 - }
99 - authManager := manager.NewAuthManager("test-secret")
100 - admin := NewAdmin(0, NewFrontend(), authManager)
101 -
102 - serv.GetLeaseManager().BanLease("lease-a")
103 - serv.GetLeaseManager().BanLease("lease-b")
104 -
105 - req := httptest.NewRequest(http.MethodGet, "/admin/leases/banned", http.NoBody)
106 - req.AddCookie(&http.Cookie{
107 - Name: adminCookieName,
108 - Value: authManager.CreateSession(),
109 - Path: "/admin",
110 - })
111 - rec := httptest.NewRecorder()
112 -
113 - admin.HandleAdminRequest(rec, req, serv)
114 -
115 - if rec.Code != http.StatusOK {
116 - t.Fatalf("HandleAdminRequest status = %d, want %d", rec.Code, http.StatusOK)
117 - }
118 -
119 - var envelope types.APIRawEnvelope
120 - if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
121 - t.Fatalf("decode envelope: %v (body=%q)", err, rec.Body.String())
122 - }
123 - if !envelope.OK {
124 - t.Fatalf("expected success envelope, got %+v", envelope)
125 - }
126 -
127 - var banned []string
128 - if err := json.Unmarshal(envelope.Data, &banned); err != nil {
129 - t.Fatalf("decode banned leases: %v", err)
130 - }
131 - slices.Sort(banned)
132 - want := []string{"lease-a", "lease-b"}
133 - if !slices.Equal(banned, want) {
134 - t.Fatalf("banned leases = %v, want %v", banned, want)
135 - }
136 -}
cmd/relay-server/control_plane_admission.go
+3 -7
@@ -10,11 +10,7 @@ import (
10 "time"
11
12 "gosuda.org/portal/portal"
13 -)
14 -
15 -const (
16 - controlPlaneCertCNPrefix = "lease:"
17 - controlPlaneLeaseURIPfx = "spiffe://portal/lease/"
13 + "gosuda.org/portal/types"
14 )
15
16 type admissionConfig struct {
@@ -46,13 +42,13 @@ func extractLeaseIDFromPeerCertificate(cert *x509.Certificate) string {
42 continue
43 }
44 raw := strings.TrimSpace(uri.String())
49 - if after, ok := strings.CutPrefix(raw, controlPlaneLeaseURIPfx); ok {
45 + if after, ok := strings.CutPrefix(raw, types.ControlPlaneLeaseURIPrefix); ok {
46 return after
47 }
48 }
49
50 commonName := strings.TrimSpace(cert.Subject.CommonName)
55 - if after, ok := strings.CutPrefix(commonName, controlPlaneCertCNPrefix); ok {
51 + if after, ok := strings.CutPrefix(commonName, types.ControlPlaneCertCNPrefix); ok {
52 return after
53 }
54 return commonName
cmd/relay-server/frontend.go
+7 -4
@@ -23,14 +23,17 @@ type readDirFileFS interface {
23 type Frontend struct {
24 distFS readDirFileFS
25 admin *Admin
26 + // portalURL is injected runtime config used for OG metadata and SSR links.
27 + portalURL string
28
29 cachedPortalHTML []byte
30 cachedPortalHTMLOnce sync.Once
31 }
32
31 -func NewFrontend() *Frontend {
33 +func NewFrontend(portalURL string) *Frontend {
34 return &Frontend{
33 - distFS: distFS,
35 + distFS: distFS,
36 + portalURL: strings.TrimSpace(portalURL),
37 }
38 }
39
@@ -106,7 +109,7 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
109 }
110 if imageURL == "" {
111 // Use absolute URL if possible
109 - base := strings.TrimSuffix(flagPortalURL, "/")
112 + base := strings.TrimSuffix(f.portalURL, "/")
113 if !strings.HasPrefix(base, "http") {
114 base = "https://" + base
115 }
@@ -127,7 +130,7 @@ func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer
130 // Get server data from lease manager
131 rows := []leaseRow{}
132 if f.admin != nil {
130 - rows = convertLeaseEntriesToRows(serv, f.admin, false)
133 + rows = convertLeaseEntriesToRows(serv, f.admin, false, f.portalURL)
134 }
135
136 // Marshal to JSON
cmd/relay-server/main.go
+3 -9
@@ -27,10 +27,6 @@ const (
27 defaultKeylessDir = "/etc/portal/keyless"
28 )
29
30 -// flagPortalURL is kept for package-level consumers in other files.
31 -var flagPortalURL string
32 -var flagTrustProxyHeaders bool
33 -
30 type relayServerConfig struct {
31 AdminSecretKey string
32 PortalURL string
@@ -85,8 +81,6 @@ func main() {
81 log.Fatal().Err(err).Msg("parse trusted proxy CIDRs")
82 }
83 manager.SetTrustedProxyCIDRs(parsedTrustedProxyCIDRs)
88 - flagPortalURL = cfg.PortalURL
89 - flagTrustProxyHeaders = cfg.TrustProxyHeaders
84 if err := runServer(cfg); err != nil {
85 log.Fatal().Err(err).Msg("execute root command")
86 }
@@ -111,9 +105,9 @@ func runServer(cfg relayServerConfig) error {
105 return fmt.Errorf("create relay server: %w", err)
106 }
107
114 - frontend := NewFrontend()
108 + frontend := NewFrontend(cfg.PortalURL)
109 authManager := manager.NewAuthManager(cfg.AdminSecretKey)
116 - admin := NewAdmin(int64(cfg.LeaseBPS), frontend, authManager)
110 + admin := NewAdmin(int64(cfg.LeaseBPS), frontend, authManager, cfg.PortalURL, cfg.TrustProxyHeaders)
111 frontend.SetAdmin(admin)
112
113 // Load persisted admin settings (ban list, BPS limits, IP bans)
@@ -186,7 +180,7 @@ func runServer(cfg relayServerConfig) error {
180 }
181 defer serv.Stop()
182
189 - apiServ := serveAPI(fmt.Sprintf(":%d", cfg.AdminPort), serv, admin, frontend, stop)
183 + apiServ := serveAPI(fmt.Sprintf(":%d", cfg.AdminPort), serv, admin, frontend, cfg, stop)
184
185 <-ctx.Done()
186 log.Info().Msg("[server] shutting down...")
cmd/relay-server/main_test.go deleted
-199
@@ -1,199 +0,0 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "crypto/tls"
6 - "encoding/json"
7 - "net"
8 - "net/http"
9 - "net/http/httptest"
10 - "strings"
11 - "testing"
12 - "time"
13 -
14 - "gosuda.org/portal/cmd/relay-server/manager"
15 - "gosuda.org/portal/portal"
16 - "gosuda.org/portal/types"
17 -)
18 -
19 -func TestParseTrustedProxyCIDRs(t *testing.T) {
20 - t.Run("parses and deduplicates", func(t *testing.T) {
21 - cidrs, err := manager.ParseTrustedProxyCIDRs("10.0.0.0/8, 10.0.0.0/8, 2001:db8::/32")
22 - if err != nil {
23 - t.Fatalf("unexpected parse error: %v", err)
24 - }
25 - if len(cidrs) != 2 {
26 - t.Fatalf("expected 2 unique CIDRs, got %d", len(cidrs))
27 - }
28 - })
29 -
30 - t.Run("empty input", func(t *testing.T) {
31 - cidrs, err := manager.ParseTrustedProxyCIDRs(" ")
32 - if err != nil {
33 - t.Fatalf("unexpected parse error: %v", err)
34 - }
35 - if len(cidrs) != 0 {
36 - t.Fatalf("expected no CIDRs for empty input, got %d", len(cidrs))
37 - }
38 - })
39 -
40 - t.Run("invalid cidr", func(t *testing.T) {
41 - if _, err := manager.ParseTrustedProxyCIDRs("not-a-cidr"); err == nil {
42 - t.Fatal("expected parse error for invalid CIDR input")
43 - }
44 - })
45 -}
46 -
47 -func newTestRelayServer(t *testing.T) *portal.RelayServer {
48 - t.Helper()
49 -
50 - serv, err := portal.NewRelayServer(
51 - context.Background(),
52 - []string{"127.0.0.1:0"},
53 - ":0",
54 - "portal.example.com",
55 - "",
56 - "",
57 - )
58 - if err != nil {
59 - t.Fatalf("new relay server: %v", err)
60 - }
61 - return serv
62 -}
63 -
64 -func TestServeAPIRemovesLegacyCompatResponses(t *testing.T) {
65 - t.Parallel()
66 -
67 - serv := newTestRelayServer(t)
68 - srv := serveAPI(":0", serv, nil, NewFrontend(), func() {})
69 -
70 - legacyPaths := []string{
71 - "/frontend/manifest.json",
72 - "/service-worker.js",
73 - }
74 - for _, p := range legacyPaths {
75 - t.Run(p, func(t *testing.T) {
76 - req := httptest.NewRequest(http.MethodGet, p, http.NoBody)
77 - rr := httptest.NewRecorder()
78 - srv.Handler.ServeHTTP(rr, req)
79 -
80 - if rr.Code == http.StatusGone {
81 - t.Fatalf("legacy compat path %q should not return 410 compatibility shim", p)
82 - }
83 -
84 - body := strings.ToLower(rr.Body.String())
85 - if strings.Contains(body, "legacy webclient") || strings.Contains(body, "refresh required") {
86 - t.Fatalf("legacy compat marker should be removed for %q, got body %q", p, rr.Body.String())
87 - }
88 - })
89 - }
90 -}
91 -
92 -func TestSDKRegisterRejectsBannedIP(t *testing.T) {
93 - t.Parallel()
94 -
95 - serv := newTestRelayServer(t)
96 - ipManager := manager.NewIPManager()
97 - ipManager.BanIP("203.0.113.17")
98 -
99 - registry := &SDKRegistry{
100 - ipManager: ipManager,
101 - trustProxyHeaders: false,
102 - }
103 -
104 - reqBody := strings.NewReader(`{"lease_id":"lease-ban","name":"test-lease","tls":true,"reverse_token":"token-1"}`)
105 - req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, reqBody)
106 - req.TLS = &tls.ConnectionState{}
107 - req.RemoteAddr = "203.0.113.17:45678"
108 - rr := httptest.NewRecorder()
109 -
110 - registry.handleRegister(rr, req, serv)
111 -
112 - if rr.Code != http.StatusForbidden {
113 - t.Fatalf("unexpected status: got %d want %d", rr.Code, http.StatusForbidden)
114 - }
115 -
116 - var envelope types.APIRawEnvelope
117 - if err := json.NewDecoder(rr.Body).Decode(&envelope); err != nil {
118 - t.Fatalf("decode register envelope: %v", err)
119 - }
120 - if envelope.OK {
121 - t.Fatal("expected banned IP registration to fail")
122 - }
123 - if envelope.Error == nil || envelope.Error.Message != "ip is banned" {
124 - t.Fatalf("unexpected error payload: %+v", envelope.Error)
125 - }
126 - if _, ok := serv.GetLeaseManager().GetLeaseByID("lease-ban"); ok {
127 - t.Fatal("banned registration should not create a lease")
128 - }
129 -}
130 -
131 -func TestSDKUnregisterCleansRouteAndReversePoolImmediately(t *testing.T) {
132 - t.Parallel()
133 -
134 - serv := newTestRelayServer(t)
135 - registry := &SDKRegistry{}
136 -
137 - lease := &portal.Lease{
138 - ID: "lease-cleanup",
139 - Name: "cleanup",
140 - TLS: true,
141 - ReverseToken: "token-cleanup",
142 - Expires: time.Now().Add(time.Minute),
143 - }
144 - if !serv.GetLeaseManager().UpdateLease(lease) {
145 - t.Fatal("failed to seed lease")
146 - }
147 -
148 - sniName := types.BuildSNIName(lease.Name, serv.BaseHost)
149 - if sniName == "" {
150 - t.Fatal("expected non-empty SNI name")
151 - }
152 - if err := serv.GetSNIRouter().RegisterRoute(sniName, lease.ID, lease.Name); err != nil {
153 - t.Fatalf("register route: %v", err)
154 - }
155 -
156 - local, peer := net.Pipe()
157 - defer peer.Close()
158 - conn := portal.NewReverseConn(local)
159 - defer conn.Close()
160 - if !serv.GetReverseHub().Offer(lease.ID, conn) {
161 - t.Fatal("failed to seed reverse pool")
162 - }
163 -
164 - reqBody := strings.NewReader(`{"lease_id":"lease-cleanup","reverse_token":"token-cleanup"}`)
165 - req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, reqBody)
166 - attachPeerLeaseCertificate(req, lease.ID)
167 - rr := httptest.NewRecorder()
168 - registry.handleUnregister(rr, req, serv)
169 -
170 - if rr.Code != http.StatusOK {
171 - t.Fatalf("unexpected status: got %d want %d", rr.Code, http.StatusOK)
172 - }
173 -
174 - var envelope types.APIRawEnvelope
175 - if err := json.NewDecoder(rr.Body).Decode(&envelope); err != nil {
176 - t.Fatalf("decode unregister envelope: %v", err)
177 - }
178 - if !envelope.OK {
179 - t.Fatalf("expected successful unregister, got %+v", envelope)
180 - }
181 - if _, ok := serv.GetLeaseManager().GetLeaseByID(lease.ID); ok {
182 - t.Fatal("lease should be removed after unregister")
183 - }
184 - if _, ok := serv.GetSNIRouter().GetRouteByLeaseID(lease.ID); ok {
185 - t.Fatal("SNI route should be removed after unregister")
186 - }
187 -
188 - start := time.Now()
189 - _, err := serv.GetReverseHub().AcquireForTLS(lease.ID, 2*time.Second)
190 - if err == nil {
191 - t.Fatal("expected reverse pool to be removed after unregister")
192 - }
193 - if !strings.Contains(err.Error(), "no tunnel available") {
194 - t.Fatalf("unexpected acquire error after unregister: %v", err)
195 - }
196 - if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
197 - t.Fatalf("expected immediate cleanup, acquire took %v", elapsed)
198 - }
199 -}
cmd/relay-server/registry.go
+2 -1
@@ -17,6 +17,7 @@ import (
17 // SDKRegistry handles HTTP API for client lease registration.
18 type SDKRegistry struct {
19 ipManager *manager.IPManager
20 + portalURL string
21 trustProxyHeaders bool
22 }
23
@@ -215,7 +216,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
216 Bool("tls", true).
217 Msg("[Registry] Lease registered")
218
218 - publicURL := types.ServicePublicURL(flagPortalURL, registerReq.Name)
219 + publicURL := types.ServicePublicURL(r.portalURL, registerReq.Name)
220
221 writeAPIData(w, http.StatusOK, types.RegisterResponse{
222 LeaseID: registerReq.LeaseID,
cmd/relay-server/registry_test.go deleted
-524
@@ -1,524 +0,0 @@
1 -package main
2 -
3 -import (
4 - "bytes"
5 - "context"
6 - "crypto/tls"
7 - "crypto/x509"
8 - "crypto/x509/pkix"
9 - "encoding/json"
10 - "net/http"
11 - "net/http/httptest"
12 - "net/url"
13 - "strings"
14 - "testing"
15 - "time"
16 -
17 - "gosuda.org/portal/cmd/relay-server/manager"
18 - "gosuda.org/portal/portal"
19 - "gosuda.org/portal/types"
20 -)
21 -
22 -func attachPeerLeaseCertificate(req *http.Request, leaseID string) {
23 - leaseURI, _ := url.Parse(controlPlaneLeaseURIPfx + leaseID)
24 - req.TLS = &tls.ConnectionState{
25 - PeerCertificates: []*x509.Certificate{
26 - {
27 - NotBefore: time.Now().Add(-1 * time.Minute),
28 - NotAfter: time.Now().Add(1 * time.Hour),
29 - URIs: []*url.URL{leaseURI},
30 - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
31 - Subject: pkix.Name{
32 - CommonName: controlPlaneCertCNPrefix + leaseID,
33 - },
34 - },
35 - },
36 - }
37 -}
38 -
39 -func decodeAPIRawEnvelope(t *testing.T, rec *httptest.ResponseRecorder) types.APIRawEnvelope {
40 - t.Helper()
41 -
42 - var envelope types.APIRawEnvelope
43 - if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
44 - t.Fatalf("decode API envelope: %v (body=%q)", err, rec.Body.String())
45 - }
46 - return envelope
47 -}
48 -
49 -func newRegistryTestRelayServer(t *testing.T) *portal.RelayServer {
50 - t.Helper()
51 -
52 - serv, err := portal.NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
53 - if err != nil {
54 - t.Fatalf("create relay server: %v", err)
55 - }
56 - return serv
57 -}
58 -
59 -func TestSDKRegistryHandleRegisterTrimsReverseToken(t *testing.T) {
60 - serv := newRegistryTestRelayServer(t)
61 - registry := &SDKRegistry{}
62 -
63 - originalPortalURL := flagPortalURL
64 - flagPortalURL = "https://portal.example.com"
65 - t.Cleanup(func() {
66 - flagPortalURL = originalPortalURL
67 - })
68 -
69 - payload := types.RegisterRequest{
70 - LeaseID: "lease-register-token-trim",
71 - Name: "tenant",
72 - TLS: true,
73 - ReverseToken: " reverse-token ",
74 - }
75 - body, err := json.Marshal(payload)
76 - if err != nil {
77 - t.Fatalf("marshal register payload: %v", err)
78 - }
79 -
80 - req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, bytes.NewReader(body))
81 - attachPeerLeaseCertificate(req, payload.LeaseID)
82 - rec := httptest.NewRecorder()
83 - registry.handleRegister(rec, req, serv)
84 -
85 - var envelope types.APIRawEnvelope
86 - if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
87 - t.Fatalf("decode register envelope: %v", err)
88 - }
89 - if !envelope.OK {
90 - t.Fatalf("register response not successful: %+v", envelope)
91 - }
92 -
93 - var response types.RegisterResponse
94 - if err := json.Unmarshal(envelope.Data, &response); err != nil {
95 - t.Fatalf("decode register response data: %v", err)
96 - }
97 - if !response.Success {
98 - t.Fatalf("register response not successful: %+v", response)
99 - }
100 -
101 - entry, ok := serv.GetLeaseManager().GetLeaseByID(payload.LeaseID)
102 - if !ok || entry == nil || entry.Lease == nil {
103 - t.Fatalf("registered lease not found: %q", payload.LeaseID)
104 - }
105 - if got := entry.Lease.ReverseToken; got != "reverse-token" {
106 - t.Fatalf("stored reverse token mismatch: got %q want %q", got, "reverse-token")
107 - }
108 -}
109 -
110 -func TestSDKRegistryHandleRenewAcceptsTrimmedReverseToken(t *testing.T) {
111 - serv := newRegistryTestRelayServer(t)
112 - registry := &SDKRegistry{}
113 -
114 - lease := &portal.Lease{
115 - ID: "lease-renew-token-trim",
116 - Name: "tenant",
117 - TLS: true,
118 - ReverseToken: "reverse-token",
119 - Expires: time.Now().Add(30 * time.Second),
120 - }
121 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
122 - t.Fatal("failed to seed lease")
123 - }
124 -
125 - payload := types.RenewRequest{
126 - LeaseID: lease.ID,
127 - ReverseToken: " reverse-token ",
128 - }
129 - body, err := json.Marshal(payload)
130 - if err != nil {
131 - t.Fatalf("marshal renew payload: %v", err)
132 - }
133 -
134 - req := httptest.NewRequest(http.MethodPost, types.PathSDKRenew, bytes.NewReader(body))
135 - attachPeerLeaseCertificate(req, payload.LeaseID)
136 - rec := httptest.NewRecorder()
137 - registry.handleRenew(rec, req, serv)
138 -
139 - var envelope types.APIRawEnvelope
140 - if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
141 - t.Fatalf("decode renew envelope: %v", err)
142 - }
143 - if !envelope.OK {
144 - t.Fatalf("renew response not successful: %+v", envelope)
145 - }
146 -}
147 -
148 -func TestSDKRegistryHandleRenewRejectsBannedIP(t *testing.T) {
149 - serv := newRegistryTestRelayServer(t)
150 - ipManager := manager.NewIPManager()
151 - ipManager.BanIP("203.0.113.22")
152 - registry := &SDKRegistry{ipManager: ipManager}
153 -
154 - lease := &portal.Lease{
155 - ID: "lease-renew-ban",
156 - Name: "tenant",
157 - TLS: true,
158 - ReverseToken: "renew-token",
159 - Expires: time.Now().Add(30 * time.Second),
160 - }
161 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
162 - t.Fatal("failed to seed lease")
163 - }
164 -
165 - payload := types.RenewRequest{
166 - LeaseID: lease.ID,
167 - ReverseToken: lease.ReverseToken,
168 - }
169 - body, err := json.Marshal(payload)
170 - if err != nil {
171 - t.Fatalf("marshal renew payload: %v", err)
172 - }
173 -
174 - req := httptest.NewRequest(http.MethodPost, types.PathSDKRenew, bytes.NewReader(body))
175 - attachPeerLeaseCertificate(req, lease.ID)
176 - req.RemoteAddr = "203.0.113.22:45000"
177 - rec := httptest.NewRecorder()
178 -
179 - registry.handleRenew(rec, req, serv)
180 -
181 - if rec.Code != http.StatusForbidden {
182 - t.Fatalf("handleRenew status = %d, want %d", rec.Code, http.StatusForbidden)
183 - }
184 - envelope := decodeAPIRawEnvelope(t, rec)
185 - if envelope.Error == nil || envelope.Error.Code != "ip_banned" {
186 - t.Fatalf("unexpected renew ip_banned payload: %+v", envelope.Error)
187 - }
188 -}
189 -
190 -func TestSDKRegistryHandleRegisterRequiresClientCertificate(t *testing.T) {
191 - serv := newRegistryTestRelayServer(t)
192 - registry := &SDKRegistry{}
193 -
194 - payload := types.RegisterRequest{
195 - LeaseID: "lease-register-cert-required",
196 - Name: "tenant",
197 - TLS: true,
198 - ReverseToken: "reverse-token",
199 - }
200 - body, err := json.Marshal(payload)
201 - if err != nil {
202 - t.Fatalf("marshal register payload: %v", err)
203 - }
204 -
205 - req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, bytes.NewReader(body))
206 - req.TLS = &tls.ConnectionState{}
207 - rec := httptest.NewRecorder()
208 - registry.handleRegister(rec, req, serv)
209 -
210 - if rec.Code != http.StatusUnauthorized {
211 - t.Fatalf("handleRegister status = %d, want %d", rec.Code, http.StatusUnauthorized)
212 - }
213 - envelope := decodeAPIRawEnvelope(t, rec)
214 - if envelope.OK {
215 - t.Fatalf("expected register rejection, got %+v", envelope)
216 - }
217 - if envelope.Error == nil || envelope.Error.Code != "client_cert_required" {
218 - t.Fatalf("unexpected register rejection payload: %+v", envelope.Error)
219 - }
220 -}
221 -
222 -func TestSDKRegistryHandleUnregisterRequiresReverseToken(t *testing.T) {
223 - serv := newRegistryTestRelayServer(t)
224 - registry := &SDKRegistry{}
225 -
226 - lease := &portal.Lease{
227 - ID: "lease-unregister-token-required",
228 - Name: "tenant",
229 - TLS: true,
230 - ReverseToken: "reverse-token",
231 - Expires: time.Now().Add(30 * time.Second),
232 - }
233 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
234 - t.Fatal("failed to seed lease")
235 - }
236 -
237 - payload := types.UnregisterRequest{
238 - LeaseID: lease.ID,
239 - }
240 - body, err := json.Marshal(payload)
241 - if err != nil {
242 - t.Fatalf("marshal unregister payload: %v", err)
243 - }
244 -
245 - req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
246 - attachPeerLeaseCertificate(req, lease.ID)
247 - rec := httptest.NewRecorder()
248 - registry.handleUnregister(rec, req, serv)
249 -
250 - if rec.Code != http.StatusBadRequest {
251 - t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusBadRequest)
252 - }
253 - envelope := decodeAPIRawEnvelope(t, rec)
254 - if envelope.OK {
255 - t.Fatalf("expected unregister rejection, got %+v", envelope)
256 - }
257 - if envelope.Error == nil || envelope.Error.Code != "missing_reverse_token" {
258 - t.Fatalf("unexpected unregister rejection payload: %+v", envelope.Error)
259 - }
260 -}
261 -
262 -func TestSDKRegistryHandleUnregisterWithValidIdentity(t *testing.T) {
263 - serv := newRegistryTestRelayServer(t)
264 - registry := &SDKRegistry{}
265 -
266 - lease := &portal.Lease{
267 - ID: "lease-unregister-success",
268 - Name: "tenant",
269 - TLS: true,
270 - ReverseToken: "reverse-token",
271 - Expires: time.Now().Add(30 * time.Second),
272 - }
273 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
274 - t.Fatal("failed to seed lease")
275 - }
276 -
277 - payload := types.UnregisterRequest{
278 - LeaseID: lease.ID,
279 - ReverseToken: lease.ReverseToken,
280 - }
281 - body, err := json.Marshal(payload)
282 - if err != nil {
283 - t.Fatalf("marshal unregister payload: %v", err)
284 - }
285 -
286 - req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
287 - attachPeerLeaseCertificate(req, lease.ID)
288 - rec := httptest.NewRecorder()
289 - registry.handleUnregister(rec, req, serv)
290 -
291 - if rec.Code != http.StatusOK {
292 - t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusOK)
293 - }
294 - if _, ok := serv.GetLeaseManager().GetLeaseByID(lease.ID); ok {
295 - t.Fatalf("lease %q should be removed after unregister", lease.ID)
296 - }
297 -}
298 -
299 -func TestSDKRegistryHandleUnregisterRejectsTokenMismatch(t *testing.T) {
300 - serv := newRegistryTestRelayServer(t)
301 - registry := &SDKRegistry{}
302 -
303 - lease := &portal.Lease{
304 - ID: "lease-unregister-token-mismatch",
305 - Name: "tenant",
306 - TLS: true,
307 - ReverseToken: "correct-token",
308 - Expires: time.Now().Add(30 * time.Second),
309 - }
310 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
311 - t.Fatal("failed to seed lease")
312 - }
313 -
314 - payload := types.UnregisterRequest{
315 - LeaseID: lease.ID,
316 - ReverseToken: "wrong-token",
317 - }
318 - body, err := json.Marshal(payload)
319 - if err != nil {
320 - t.Fatalf("marshal unregister payload: %v", err)
321 - }
322 -
323 - req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, bytes.NewReader(body))
324 - attachPeerLeaseCertificate(req, lease.ID)
325 - rec := httptest.NewRecorder()
326 -
327 - registry.handleUnregister(rec, req, serv)
328 -
329 - if rec.Code != http.StatusUnauthorized {
330 - t.Fatalf("handleUnregister status = %d, want %d", rec.Code, http.StatusUnauthorized)
331 - }
332 - envelope := decodeAPIRawEnvelope(t, rec)
333 - if envelope.Error == nil || envelope.Error.Code != "unauthorized" {
334 - t.Fatalf("unexpected unregister mismatch payload: %+v", envelope.Error)
335 - }
336 -}
337 -
338 -func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
339 - serv := newRegistryTestRelayServer(t)
340 - ipManager := manager.NewIPManager()
341 - ipManager.BanIP("203.0.113.22")
342 - registry := &SDKRegistry{
343 - ipManager: ipManager,
344 - trustProxyHeaders: false,
345 - }
346 -
347 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-ban", http.NoBody)
348 - attachPeerLeaseCertificate(req, "lease-connect-ban")
349 - req.RemoteAddr = "203.0.113.22:45000"
350 - req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
351 - rec := httptest.NewRecorder()
352 -
353 - registry.handleConnect(rec, req, serv)
354 -
355 - if rec.Code != http.StatusForbidden {
356 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusForbidden)
357 - }
358 - envelope := decodeAPIRawEnvelope(t, rec)
359 - if envelope.OK {
360 - t.Fatalf("expected banned IP response to fail, got %+v", envelope)
361 - }
362 - if envelope.Error == nil || envelope.Error.Code != "ip_banned" || envelope.Error.Message != "ip is banned" {
363 - t.Fatalf("unexpected banned IP error payload: %+v", envelope.Error)
364 - }
365 -}
366 -
367 -func TestSDKRegistryHandleConnectRejectsMissingLease(t *testing.T) {
368 - serv := newRegistryTestRelayServer(t)
369 - registry := &SDKRegistry{}
370 -
371 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=missing-lease", http.NoBody)
372 - req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
373 - attachPeerLeaseCertificate(req, "missing-lease")
374 - rec := httptest.NewRecorder()
375 -
376 - registry.handleConnect(rec, req, serv)
377 -
378 - if rec.Code != http.StatusNotFound {
379 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusNotFound)
380 - }
381 - envelope := decodeAPIRawEnvelope(t, rec)
382 - if envelope.Error == nil || envelope.Error.Code != "lease_not_found" {
383 - t.Fatalf("unexpected lease_not_found payload: %+v", envelope.Error)
384 - }
385 -}
386 -
387 -func TestSDKRegistryHandleConnectRejectsCertLeaseMismatch(t *testing.T) {
388 - serv := newRegistryTestRelayServer(t)
389 - registry := &SDKRegistry{}
390 -
391 - lease := &portal.Lease{
392 - ID: "lease-cert-mismatch",
393 - Name: "tenant",
394 - TLS: true,
395 - ReverseToken: "reverse-token",
396 - Expires: time.Now().Add(30 * time.Second),
397 - }
398 - if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
399 - t.Fatal("failed to seed lease")
400 - }
401 -
402 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id="+lease.ID, http.NoBody)
403 - req.Header.Set(portal.ReverseConnectTokenHeader, lease.ReverseToken)
404 - attachPeerLeaseCertificate(req, "other-lease")
405 - rec := httptest.NewRecorder()
406 -
407 - registry.handleConnect(rec, req, serv)
408 -
409 - if rec.Code != http.StatusUnauthorized {
410 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUnauthorized)
411 - }
412 - envelope := decodeAPIRawEnvelope(t, rec)
413 - if envelope.Error == nil || envelope.Error.Code != "cert_lease_mismatch" {
414 - t.Fatalf("unexpected cert_lease_mismatch payload: %+v", envelope.Error)
415 - }
416 -}
417 -
418 -func TestSDKRegistryHandleConnectRequiresTLS(t *testing.T) {
419 - serv := newRegistryTestRelayServer(t)
420 - registry := &SDKRegistry{}
421 - serv.GetLeaseManager().UpdateLease(&portal.Lease{
422 - ID: "lease-connect-tls",
423 - Name: "lease-connect-tls",
424 - ReverseToken: "reverse-token",
425 - Expires: time.Now().Add(time.Hour),
426 - TLS: true,
427 - })
428 -
429 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-tls", http.NoBody)
430 - req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
431 - rec := httptest.NewRecorder()
432 -
433 - registry.handleConnect(rec, req, serv)
434 -
435 - if rec.Code != http.StatusUnauthorized {
436 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUnauthorized)
437 - }
438 - envelope := decodeAPIRawEnvelope(t, rec)
439 - if envelope.OK {
440 - t.Fatalf("expected client_cert_required response to fail, got %+v", envelope)
441 - }
442 - if envelope.Error == nil || envelope.Error.Code != "client_cert_required" {
443 - t.Fatalf("unexpected client_cert_required payload: %+v", envelope.Error)
444 - }
445 -}
446 -
447 -func TestSDKRegistryHandleConnectMissingLeaseIDReturnsEnvelope(t *testing.T) {
448 - serv := newRegistryTestRelayServer(t)
449 - registry := &SDKRegistry{}
450 -
451 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
452 - attachPeerLeaseCertificate(req, "lease-missing")
453 - req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
454 - rec := httptest.NewRecorder()
455 -
456 - registry.handleConnect(rec, req, serv)
457 -
458 - if rec.Code != http.StatusBadRequest {
459 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
460 - }
461 - envelope := decodeAPIRawEnvelope(t, rec)
462 - if envelope.OK {
463 - t.Fatalf("expected missing lease_id response to fail, got %+v", envelope)
464 - }
465 - if envelope.Error == nil || envelope.Error.Code != "missing_lease_id" || envelope.Error.Message != "lease_id is required" {
466 - t.Fatalf("unexpected missing lease_id payload: %+v", envelope.Error)
467 - }
468 -}
469 -
470 -func TestIsWebSocketUpgrade(t *testing.T) {
471 - t.Parallel()
472 -
473 - tests := []struct {
474 - name string
475 - header string
476 - want bool
477 - }{
478 - {name: "empty", header: "", want: false},
479 - {name: "websocket lowercase", header: "websocket", want: true},
480 - {name: "websocket mixed case", header: "WebSocket", want: true},
481 - {name: "other upgrade", header: "h2c", want: false},
482 - }
483 -
484 - for _, tt := range tests {
485 - t.Run(tt.name, func(t *testing.T) {
486 - t.Parallel()
487 -
488 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
489 - if tt.header != "" {
490 - req.Header.Set("Upgrade", tt.header)
491 - }
492 - if got := isWebSocketUpgrade(req); got != tt.want {
493 - t.Fatalf("isWebSocketUpgrade(%q)=%v, want %v", tt.header, got, tt.want)
494 - }
495 - })
496 - }
497 -}
498 -
499 -func TestSDKRegistryHandleConnectRejectsWebSocketUpgrade(t *testing.T) {
500 - serv := newRegistryTestRelayServer(t)
501 - registry := &SDKRegistry{}
502 -
503 - req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-websocket", http.NoBody)
504 - req.TLS = &tls.ConnectionState{}
505 - req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
506 - req.Header.Set("Upgrade", "websocket")
507 - rec := httptest.NewRecorder()
508 -
509 - registry.handleConnect(rec, req, serv)
510 -
511 - if rec.Code != http.StatusBadRequest {
512 - t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
513 - }
514 - envelope := decodeAPIRawEnvelope(t, rec)
515 - if envelope.OK {
516 - t.Fatalf("expected websocket rejection to fail, got %+v", envelope)
517 - }
518 - if envelope.Error == nil || envelope.Error.Code != "unsupported_transport" {
519 - t.Fatalf("unexpected websocket rejection payload: %+v", envelope.Error)
520 - }
521 - if !strings.Contains(strings.ToLower(envelope.Error.Message), "websocket") {
522 - t.Fatalf("unexpected websocket rejection message: %+v", envelope.Error)
523 - }
524 -}
cmd/relay-server/serve.go
+7 -6
@@ -27,7 +27,7 @@ const defaultHTTPSPort = "443"
27 var distFS embed.FS
28
29 // serveAPI builds the admin/API mux and returns the server.
30 -func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, cancel context.CancelFunc) *http.Server {
30 +func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, cfg relayServerConfig, cancel context.CancelFunc) *http.Server {
31 if addr == "" {
32 addr = ":0"
33 }
@@ -53,7 +53,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
53
54 // Tunnel installer script and binaries
55 appMux.HandleFunc(types.PathTunnelScript, func(w http.ResponseWriter, r *http.Request) {
56 - serveTunnelScript(w, r)
56 + serveTunnelScript(w, r, cfg.PortalURL)
57 })
58 appMux.HandleFunc(types.PathTunnelBinary, func(w http.ResponseWriter, r *http.Request) {
59 serveTunnelBinary(w, r)
@@ -66,7 +66,8 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
66 }
67 registry := &SDKRegistry{
68 ipManager: sdkIPManager,
69 - trustProxyHeaders: flagTrustProxyHeaders,
69 + portalURL: cfg.PortalURL,
70 + trustProxyHeaders: cfg.TrustProxyHeaders,
71 }
72 appMux.HandleFunc(types.PathSDKPrefix, func(w http.ResponseWriter, r *http.Request) {
73 registry.HandleSDKRequest(w, r, serv)
@@ -97,7 +98,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
98 })
99
100 // Create the main handler
100 - appDomain := types.DefaultAppPattern(flagPortalURL)
101 + appDomain := types.DefaultAppPattern(cfg.PortalURL)
102 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103 // Handle subdomain requests
104 if types.IsSubdomain(appDomain, r.Host) {
@@ -107,7 +108,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
108 Msg("[server] handling subdomain request")
109 // TLS-enabled subdomains should terminate on SNI passthrough.
110 // Redirect only insecure requests; secure requests here would loop.
110 - if !isSecureRequest(r) {
111 + if !isSecureRequestWithPolicy(r, cfg.TrustProxyHeaders) {
112 log.Debug().Str("host", r.Host).Msg("[server] redirecting to HTTPS")
113 redirectToHTTPS(w, r, serv.GetSNIRouter().GetAddr())
114 return
@@ -127,7 +128,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
128 TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
129 }
130 acmeManager := serv.GetACMEManager()
130 - rootHost := types.PortalRootHost(flagPortalURL)
131 + rootHost := types.PortalRootHost(cfg.PortalURL)
132 srv.TLSConfig = &tls.Config{
133 ClientAuth: tls.RequestClientCert,
134 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cmd/relay-server/serve_test.go deleted
-81
@@ -1,81 +0,0 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "net/http"
6 - "net/http/httptest"
7 - "strings"
8 - "testing"
9 - "time"
10 -
11 - "gosuda.org/portal/portal"
12 - "gosuda.org/portal/types"
13 -)
14 -
15 -func TestServeAPILegacyCompatPathsRemoved(t *testing.T) {
16 - prevPortalURL := flagPortalURL
17 - prevTrustProxyHeaders := flagTrustProxyHeaders
18 - flagPortalURL = "https://portal.example.com"
19 - flagTrustProxyHeaders = false
20 - t.Cleanup(func() {
21 - flagPortalURL = prevPortalURL
22 - flagTrustProxyHeaders = prevTrustProxyHeaders
23 - })
24 -
25 - serv, err := portal.NewRelayServer(
26 - context.Background(),
27 - nil,
28 - ":0",
29 - types.PortalRootHost(flagPortalURL),
30 - "",
31 - "",
32 - )
33 - if err != nil {
34 - t.Fatalf("create relay server: %v", err)
35 - }
36 -
37 - frontend := NewFrontend()
38 - apiSrv := serveAPI(":0", serv, nil, frontend, func() {})
39 - t.Cleanup(func() {
40 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
41 - defer cancel()
42 - _ = apiSrv.Shutdown(shutdownCtx)
43 - })
44 -
45 - cases := []struct {
46 - path string
47 - forbiddenBodies []string
48 - }{
49 - {
50 - path: "/frontend/manifest.json",
51 - forbiddenBodies: []string{"legacy webclient removed", "refresh required"},
52 - },
53 - {
54 - path: "/frontend/app.js",
55 - forbiddenBodies: []string{"legacy webclient assets removed", "refresh required"},
56 - },
57 - {
58 - path: "/service-worker.js",
59 - forbiddenBodies: []string{"portal-sw-cleanup-v2", "legacy sw cleanup worker"},
60 - },
61 - }
62 -
63 - for _, tc := range cases {
64 - req := httptest.NewRequest(http.MethodGet, tc.path, nil)
65 - req.Host = "portal.example.com"
66 - rec := httptest.NewRecorder()
67 -
68 - apiSrv.Handler.ServeHTTP(rec, req)
69 -
70 - if rec.Code == http.StatusGone {
71 - t.Fatalf("%s unexpectedly returned %d (legacy compatibility path)", tc.path, rec.Code)
72 - }
73 -
74 - body := strings.ToLower(rec.Body.String())
75 - for _, forbidden := range tc.forbiddenBodies {
76 - if strings.Contains(body, strings.ToLower(forbidden)) {
77 - t.Fatalf("%s response still contains legacy compatibility text %q", tc.path, forbidden)
78 - }
79 - }
80 - }
81 -}
cmd/relay-server/tunnel.go
+3 -3
@@ -164,7 +164,7 @@ try {
164 }
165 `
166
167 -func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
167 +func serveTunnelScript(w http.ResponseWriter, r *http.Request, portalURL string) {
168 setCORSHeaders(w)
169 if r.Method != http.MethodGet && r.Method != http.MethodHead {
170 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
@@ -187,11 +187,11 @@ func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
187 var filename string
188
189 if isWindows {
190 - script = fmt.Sprintf(tunnelPowerShellScriptTemplate, flagPortalURL)
190 + script = fmt.Sprintf(tunnelPowerShellScriptTemplate, portalURL)
191 contentType = "text/plain" // or application/x-powershell
192 filename = "tunnel.ps1"
193 } else {
194 - script = fmt.Sprintf(tunnelScriptTemplate, flagPortalURL)
194 + script = fmt.Sprintf(tunnelScriptTemplate, portalURL)
195 contentType = "text/x-shellscript"
196 filename = "tunnel.sh"
197 }
cmd/relay-server/tunnel_test.go deleted
-98
@@ -1,98 +0,0 @@
1 -package main
2 -
3 -import (
4 - "crypto/sha256"
5 - "encoding/hex"
6 - "net/http"
7 - "net/http/httptest"
8 - "strings"
9 - "testing"
10 -)
11 -
12 -func TestServeTunnelScriptIncludesShellChecksumVerification(t *testing.T) {
13 - req := httptest.NewRequest(http.MethodGet, "/tunnel?os=linux", http.NoBody)
14 - rec := httptest.NewRecorder()
15 -
16 - serveTunnelScript(rec, req)
17 -
18 - if rec.Code != http.StatusOK {
19 - t.Fatalf("serveTunnelScript status = %d, want %d", rec.Code, http.StatusOK)
20 - }
21 - body := rec.Body.String()
22 - if !strings.Contains(body, "CHECKSUM_URL=\"${BIN_URL}.sha256\"") {
23 - t.Fatalf("shell script missing checksum URL contract: %q", body)
24 - }
25 - if !strings.Contains(body, "sha256sum \"$BIN_PATH\"") {
26 - t.Fatalf("shell script missing sha256sum verification: %q", body)
27 - }
28 - if !strings.Contains(body, "fail-closed") {
29 - t.Fatalf("shell script missing fail-closed wording: %q", body)
30 - }
31 -}
32 -
33 -func TestServeTunnelScriptIncludesPowerShellChecksumVerification(t *testing.T) {
34 - req := httptest.NewRequest(http.MethodGet, "/tunnel?os=windows", http.NoBody)
35 - rec := httptest.NewRecorder()
36 -
37 - serveTunnelScript(rec, req)
38 -
39 - if rec.Code != http.StatusOK {
40 - t.Fatalf("serveTunnelScript status = %d, want %d", rec.Code, http.StatusOK)
41 - }
42 - body := rec.Body.String()
43 - if !strings.Contains(body, "$ChecksumUrl = \"$BinUrl.sha256\"") {
44 - t.Fatalf("powershell script missing checksum URL contract: %q", body)
45 - }
46 - if !strings.Contains(body, "Get-FileHash -Algorithm SHA256 -Path $BinPath") {
47 - t.Fatalf("powershell script missing SHA256 verification: %q", body)
48 - }
49 - if !strings.Contains(body, "fail-closed") {
50 - t.Fatalf("powershell script missing fail-closed wording: %q", body)
51 - }
52 -}
53 -
54 -func TestServeTunnelBinaryServesChecksumSidecarAndHeader(t *testing.T) {
55 - originalAssetMap := tunnelBinaryAssetBySlug
56 - tunnelBinaryAssetBySlug = map[string]string{
57 - "linux-amd64": "dist/.gitkeep",
58 - }
59 - t.Cleanup(func() {
60 - tunnelBinaryAssetBySlug = originalAssetMap
61 - })
62 -
63 - binaryReq := httptest.NewRequest(http.MethodGet, "/tunnel/bin/linux-amd64", http.NoBody)
64 - binaryRec := httptest.NewRecorder()
65 - serveTunnelBinary(binaryRec, binaryReq)
66 -
67 - if binaryRec.Code != http.StatusOK {
68 - t.Fatalf("serveTunnelBinary status = %d, want %d", binaryRec.Code, http.StatusOK)
69 - }
70 - sum := sha256.Sum256(binaryRec.Body.Bytes())
71 - wantChecksum := hex.EncodeToString(sum[:])
72 - if got := binaryRec.Header().Get("X-Checksum-Sha256"); got != wantChecksum {
73 - t.Fatalf("binary checksum header = %q, want %q", got, wantChecksum)
74 - }
75 -
76 - checksumReq := httptest.NewRequest(http.MethodGet, "/tunnel/bin/linux-amd64.sha256", http.NoBody)
77 - checksumRec := httptest.NewRecorder()
78 - serveTunnelBinary(checksumRec, checksumReq)
79 -
80 - if checksumRec.Code != http.StatusOK {
81 - t.Fatalf("serveTunnelBinary checksum status = %d, want %d", checksumRec.Code, http.StatusOK)
82 - }
83 - checksumBody := strings.TrimSpace(checksumRec.Body.String())
84 - if !strings.HasPrefix(checksumBody, wantChecksum+" portal-tunnel-linux-amd64") {
85 - t.Fatalf("checksum sidecar body = %q, want prefix %q", checksumBody, wantChecksum+" portal-tunnel-linux-amd64")
86 - }
87 -}
88 -
89 -func TestServeTunnelBinaryUnknownChecksumSlugReturnsNotFound(t *testing.T) {
90 - req := httptest.NewRequest(http.MethodGet, "/tunnel/bin/not-a-slug.sha256", http.NoBody)
91 - rec := httptest.NewRecorder()
92 -
93 - serveTunnelBinary(rec, req)
94 -
95 - if rec.Code != http.StatusNotFound {
96 - t.Fatalf("serveTunnelBinary status = %d, want %d", rec.Code, http.StatusNotFound)
97 - }
98 -}
cmd/relay-server/utils.go
+2 -6
@@ -21,10 +21,6 @@ const (
21 staleLeaseHideWindow = 3 * time.Minute
22 )
23
24 -func isSecureRequest(r *http.Request) bool {
25 - return isSecureRequestWithPolicy(r, flagTrustProxyHeaders)
26 -}
27 -
24 func isSecureRequestWithPolicy(r *http.Request, trustProxyHeaders bool) bool {
25 if r == nil {
26 return false
@@ -227,7 +223,7 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
223 // convertLeaseEntriesToRows converts LeaseEntry data to leaseRow format.
224 // If forAdmin is true, includes all leases with admin-only fields.
225 // If forAdmin is false, filters out banned, unapproved, hidden, and stale leases.
230 -func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin bool) []leaseRow {
226 +func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin bool, portalURL string) []leaseRow {
227 leaseEntries := serv.GetLeaseManager().GetAllLeaseEntries()
228 rows := []leaseRow{}
229 now := time.Now()
@@ -267,7 +263,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
263 }
264
265 var row leaseRow
270 - row.fromLeaseEntry(entry, admin, flagPortalURL)
266 + row.fromLeaseEntry(entry, admin, portalURL)
267 rows = append(rows, row)
268 }
269
cmd/relay-server/utils_test.go deleted
-106
@@ -1,106 +0,0 @@
1 -package main
2 -
3 -import (
4 - "crypto/tls"
5 - "net"
6 - "net/http"
7 - "net/http/httptest"
8 - "testing"
9 -
10 - "gosuda.org/portal/cmd/relay-server/manager"
11 -)
12 -
13 -func mustParseCIDR(t *testing.T, raw string) *net.IPNet {
14 - t.Helper()
15 -
16 - _, network, err := net.ParseCIDR(raw)
17 - if err != nil {
18 - t.Fatalf("parse CIDR %q: %v", raw, err)
19 - }
20 - return network
21 -}
22 -
23 -func TestIsSecureRequest(t *testing.T) {
24 - originalTrustProxyHeaders := flagTrustProxyHeaders
25 - t.Cleanup(func() {
26 - flagTrustProxyHeaders = originalTrustProxyHeaders
27 - manager.SetTrustedProxyCIDRs(nil)
28 - })
29 -
30 - cases := []struct {
31 - headers map[string]string
32 - name string
33 - remote string
34 - allowlist []*net.IPNet
35 - tls bool
36 - trust bool
37 - expected bool
38 - }{
39 - {
40 - name: "tls request is always secure",
41 - tls: true,
42 - trust: false,
43 - remote: "198.51.100.10:443",
44 - expected: true,
45 - },
46 - {
47 - name: "proxy headers ignored when trust flag disabled",
48 - trust: false,
49 - remote: "10.1.2.3:8080",
50 - headers: map[string]string{"X-Forwarded-Proto": "https"},
51 - allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
52 - expected: false,
53 - },
54 - {
55 - name: "proxy headers ignored with empty allowlist",
56 - trust: true,
57 - remote: "10.1.2.3:8080",
58 - headers: map[string]string{"X-Forwarded-Proto": "https"},
59 - expected: false,
60 - },
61 - {
62 - name: "trusted proxy with forwarded proto is secure",
63 - trust: true,
64 - remote: "10.1.2.3:8080",
65 - headers: map[string]string{"X-Forwarded-Proto": "https"},
66 - allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
67 - expected: true,
68 - },
69 - {
70 - name: "trusted proxy with forwarded ssl on is secure",
71 - trust: true,
72 - remote: "10.1.2.3:8080",
73 - headers: map[string]string{"X-Forwarded-Ssl": "on"},
74 - allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
75 - expected: true,
76 - },
77 - {
78 - name: "untrusted proxy headers are rejected",
79 - trust: true,
80 - remote: "198.51.100.99:8080",
81 - headers: map[string]string{"X-Forwarded-Proto": "https"},
82 - allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
83 - expected: false,
84 - },
85 - }
86 -
87 - for _, tc := range cases {
88 - t.Run(tc.name, func(t *testing.T) {
89 - flagTrustProxyHeaders = tc.trust
90 - manager.SetTrustedProxyCIDRs(tc.allowlist)
91 -
92 - req := httptest.NewRequest(http.MethodGet, "http://localhost/admin", http.NoBody)
93 - req.RemoteAddr = tc.remote
94 - if tc.tls {
95 - req.TLS = &tls.ConnectionState{}
96 - }
97 - for key, value := range tc.headers {
98 - req.Header.Set(key, value)
99 - }
100 -
101 - if got := isSecureRequest(req); got != tc.expected {
102 - t.Fatalf("isSecureRequest() = %v, want %v", got, tc.expected)
103 - }
104 - })
105 - }
106 -}
sdk/controlplane_identity.go
+3 -6
@@ -14,11 +14,8 @@ import (
14 "net/url"
15 "strings"
16 "time"
17 -)
17
19 -const (
20 - controlPlaneCertCNPrefix = "lease:"
21 - controlPlaneLeaseURIPfx = "spiffe://portal/lease/"
18 + "gosuda.org/portal/types"
19 )
20
21 func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
@@ -39,7 +36,7 @@ func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
36 notBefore := time.Now().Add(-1 * time.Minute)
37 notAfter := notBefore.Add(24 * time.Hour)
38
42 - leaseURI, err := url.Parse(controlPlaneLeaseURIPfx + leaseID)
39 + leaseURI, err := url.Parse(types.ControlPlaneLeaseURIPrefix + leaseID)
40 if err != nil {
41 return tls.Certificate{}, fmt.Errorf("build lease URI: %w", err)
42 }
@@ -47,7 +44,7 @@ func issueControlPlaneIdentity(leaseID string) (tls.Certificate, error) {
44 template := &x509.Certificate{
45 SerialNumber: serial,
46 Subject: pkix.Name{
50 - CommonName: controlPlaneCertCNPrefix + leaseID,
47 + CommonName: types.ControlPlaneCertCNPrefix + leaseID,
48 },
49 NotBefore: notBefore,
50 NotAfter: notAfter,
sdk/controlplane_identity_test.go
+6 -4
@@ -5,6 +5,8 @@ import (
5 "strings"
6 "testing"
7 "time"
8 +
9 + "gosuda.org/portal/types"
10 )
11
12 func TestIssueControlPlaneIdentity(t *testing.T) {
@@ -22,11 +24,11 @@ func TestIssueControlPlaneIdentity(t *testing.T) {
24 if err != nil {
25 t.Fatalf("parse issued certificate: %v", err)
26 }
25 - if got := strings.TrimSpace(leaf.Subject.CommonName); got != controlPlaneCertCNPrefix+"lease-identity" {
26 - t.Fatalf("certificate common name = %q, want %q", got, controlPlaneCertCNPrefix+"lease-identity")
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 }
28 - if len(leaf.URIs) == 0 || leaf.URIs[0].String() != controlPlaneLeaseURIPfx+"lease-identity" {
29 - t.Fatalf("certificate lease URI = %v, want %q", leaf.URIs, controlPlaneLeaseURIPfx+"lease-identity")
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")
types/controlplane.go new
+8
@@ -0,0 +1,8 @@
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 +)