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