refactor admin and lease
Kim committed
Mar 17, 2026 at 18:00 UTC
8b225e99d2ef5e5bba163a94e55857fe621f705f
36 files changed
+1230
-1862
cmd/relay-server/admin.go
new
+419
@@ -0,0 +1,419 @@
1
+package main
2
+
3
+import (
4
+ "crypto/subtle"
5
+ "encoding/json"
6
+ "errors"
7
+ "net"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/rs/zerolog/log"
16
+
17
+ "github.com/gosuda/portal/v2/portal/policy"
18
+ "github.com/gosuda/portal/v2/types"
19
+ "github.com/gosuda/portal/v2/utils"
20
+)
21
+
22
+const cookieName = "portal_admin"
23
+const adminSettingsPath = "admin_settings.json"
24
+
25
+type adminAuth struct {
26
+ sessions map[string]time.Time
27
+ secretKey string
28
+ mu sync.RWMutex
29
+}
30
+
31
+func newAdminAuth(secretKey string) *adminAuth {
32
+ secretKey = strings.TrimSpace(secretKey)
33
+ if secretKey == "" {
34
+ generated, err := utils.RandomHex(16)
35
+ if err != nil {
36
+ log.Fatal().Err(err).Msg("generate admin secret key")
37
+ }
38
+ secretKey = generated
39
+ log.Warn().
40
+ Str("component", "relay-server-admin").
41
+ Str("admin_secret_key", secretKey).
42
+ Msg("generated random admin secret key because ADMIN_SECRET_KEY was empty")
43
+ }
44
+
45
+ return &adminAuth{
46
+ secretKey: secretKey,
47
+ sessions: make(map[string]time.Time),
48
+ }
49
+}
50
+
51
+func (a *adminAuth) AuthEnabled() bool {
52
+ return a != nil && a.secretKey != ""
53
+}
54
+
55
+func (a *adminAuth) ValidateKey(key string) bool {
56
+ if !a.AuthEnabled() {
57
+ return false
58
+ }
59
+ return subtle.ConstantTimeCompare([]byte(a.secretKey), []byte(key)) == 1
60
+}
61
+
62
+func (a *adminAuth) CreateSession() (string, error) {
63
+ token, err := utils.RandomHex(32)
64
+ if err != nil {
65
+ return "", err
66
+ }
67
+
68
+ a.mu.Lock()
69
+ defer a.mu.Unlock()
70
+ a.sessions[token] = time.Now().Add(24 * time.Hour)
71
+ a.cleanupExpiredSessionsLocked()
72
+ return token, nil
73
+}
74
+
75
+func (a *adminAuth) ValidateSession(token string) bool {
76
+ if token == "" {
77
+ return false
78
+ }
79
+
80
+ a.mu.RLock()
81
+ defer a.mu.RUnlock()
82
+
83
+ expiry, ok := a.sessions[token]
84
+ return ok && time.Now().Before(expiry)
85
+}
86
+
87
+func (a *adminAuth) DeleteSession(token string) {
88
+ a.mu.Lock()
89
+ defer a.mu.Unlock()
90
+ delete(a.sessions, token)
91
+}
92
+
93
+func (a *adminAuth) cleanupExpiredSessionsLocked() {
94
+ now := time.Now()
95
+ for token, expiry := range a.sessions {
96
+ if now.After(expiry) {
97
+ delete(a.sessions, token)
98
+ }
99
+ }
100
+}
101
+
102
+func loadAdminState(runtime *policy.Runtime) error {
103
+ root, name, err := openSettingsRoot(adminSettingsPath)
104
+ if err != nil {
105
+ return err
106
+ }
107
+ defer root.Close()
108
+
109
+ data, err := root.ReadFile(name)
110
+ if err != nil {
111
+ if errors.Is(err, os.ErrNotExist) {
112
+ return nil
113
+ }
114
+ return err
115
+ }
116
+
117
+ var payload persistedAdminState
118
+ if err := json.Unmarshal(data, &payload); err != nil {
119
+ return err
120
+ }
121
+ return payload.apply(runtime)
122
+}
123
+
124
+func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
125
+ path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
126
+ if path == "" {
127
+ path = types.PathRoot
128
+ }
129
+
130
+ switch path {
131
+ case types.PathAdmin:
132
+ if r.Method == http.MethodGet {
133
+ f.ServeAppStatic(w, r, "")
134
+ return
135
+ }
136
+ http.NotFound(w, r)
137
+ return
138
+ case types.PathAdminLogin:
139
+ if r.Method == http.MethodGet {
140
+ f.ServeAppStatic(w, r, "")
141
+ return
142
+ }
143
+ if r.Method == http.MethodPost {
144
+ f.handleLogin(w, r)
145
+ return
146
+ }
147
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
148
+ return
149
+ case types.PathAdminLogout:
150
+ if r.Method != http.MethodPost {
151
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
152
+ return
153
+ }
154
+ if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
155
+ f.auth.DeleteSession(cookie.Value)
156
+ }
157
+ http.SetCookie(w, &http.Cookie{
158
+ Name: cookieName,
159
+ Value: "",
160
+ Path: types.PathAdmin,
161
+ HttpOnly: true,
162
+ Secure: policy.IsSecureForwardedRequest(r, f.trustProxy, f.trustedCIDRs),
163
+ SameSite: http.SameSiteStrictMode,
164
+ MaxAge: -1,
165
+ })
166
+ utils.WriteAPIOK(w, http.StatusOK)
167
+ return
168
+ case types.PathAdminAuthStatus:
169
+ if r.Method != http.MethodGet {
170
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
171
+ return
172
+ }
173
+ utils.WriteAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
174
+ Authenticated: f.isAuthenticated(r),
175
+ AuthEnabled: f.auth.AuthEnabled(),
176
+ })
177
+ return
178
+ }
179
+
180
+ if !f.isAuthenticated(r) {
181
+ utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
182
+ return
183
+ }
184
+
185
+ runtime := f.server.PolicyRuntime()
186
+ methodNotAllowed := func() {
187
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
188
+ }
189
+ writeOK := func() {
190
+ saveAdminState(runtime)
191
+ utils.WriteAPIOK(w, http.StatusOK)
192
+ }
193
+
194
+ switch path {
195
+ case types.PathAdminSnapshot:
196
+ if r.Method != http.MethodGet {
197
+ methodNotAllowed()
198
+ return
199
+ }
200
+ utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
201
+ ApprovalMode: string(runtime.Approver().Mode()),
202
+ Leases: f.adminLeaseSnapshots(),
203
+ })
204
+ case types.PathAdminApproval:
205
+ if r.Method != http.MethodPost {
206
+ methodNotAllowed()
207
+ return
208
+ }
209
+ var req types.AdminApprovalModeRequest
210
+ if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
211
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
212
+ return
213
+ }
214
+ if err := runtime.Approver().SetMode(policy.Mode(strings.TrimSpace(req.Mode))); err != nil {
215
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
216
+ return
217
+ }
218
+ saveAdminState(runtime)
219
+ utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
220
+ ApprovalMode: string(runtime.Approver().Mode()),
221
+ })
222
+ default:
223
+ switch {
224
+ case strings.HasPrefix(path, types.PathAdminLeasesPrefix):
225
+ rest := strings.TrimPrefix(path, types.PathAdminLeasesPrefix)
226
+ parts := strings.Split(rest, "/")
227
+ if len(parts) != 2 {
228
+ http.NotFound(w, r)
229
+ return
230
+ }
231
+
232
+ leaseID, err := utils.DecodeBase64URLString(parts[0])
233
+ if err != nil {
234
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
235
+ return
236
+ }
237
+
238
+ switch parts[1] {
239
+ case "ban":
240
+ switch r.Method {
241
+ case http.MethodPost:
242
+ runtime.BanLease(leaseID)
243
+ case http.MethodDelete:
244
+ runtime.UnbanLease(leaseID)
245
+ default:
246
+ methodNotAllowed()
247
+ return
248
+ }
249
+ writeOK()
250
+ case "bps":
251
+ utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
252
+ case "approve":
253
+ approver := runtime.Approver()
254
+ switch r.Method {
255
+ case http.MethodPost:
256
+ approver.Approve(leaseID)
257
+ approver.Undeny(leaseID)
258
+ case http.MethodDelete:
259
+ approver.Revoke(leaseID)
260
+ default:
261
+ methodNotAllowed()
262
+ return
263
+ }
264
+ writeOK()
265
+ case "deny":
266
+ approver := runtime.Approver()
267
+ switch r.Method {
268
+ case http.MethodPost:
269
+ approver.Deny(leaseID)
270
+ case http.MethodDelete:
271
+ approver.Undeny(leaseID)
272
+ default:
273
+ methodNotAllowed()
274
+ return
275
+ }
276
+ writeOK()
277
+ default:
278
+ http.NotFound(w, r)
279
+ }
280
+ case strings.HasPrefix(path, types.PathAdminIPsPrefix):
281
+ if !strings.HasSuffix(path, "/ban") {
282
+ http.NotFound(w, r)
283
+ return
284
+ }
285
+
286
+ rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
287
+ rawIP = strings.Trim(rawIP, "/")
288
+ if net.ParseIP(rawIP) == nil {
289
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
290
+ return
291
+ }
292
+
293
+ filter := runtime.IPFilter()
294
+ switch r.Method {
295
+ case http.MethodPost:
296
+ filter.BanIP(rawIP)
297
+ case http.MethodDelete:
298
+ filter.UnbanIP(rawIP)
299
+ default:
300
+ methodNotAllowed()
301
+ return
302
+ }
303
+ writeOK()
304
+ default:
305
+ http.NotFound(w, r)
306
+ }
307
+ }
308
+}
309
+
310
+func (f *Frontend) handleLogin(w http.ResponseWriter, r *http.Request) {
311
+ if r.Method != http.MethodPost {
312
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
313
+ return
314
+ }
315
+ if !f.auth.AuthEnabled() {
316
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
317
+ return
318
+ }
319
+
320
+ var req types.AdminLoginRequest
321
+ if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
322
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
323
+ return
324
+ }
325
+ if !f.auth.ValidateKey(req.Key) {
326
+ utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key")
327
+ return
328
+ }
329
+ token, err := f.auth.CreateSession()
330
+ if err != nil {
331
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
332
+ return
333
+ }
334
+
335
+ http.SetCookie(w, &http.Cookie{
336
+ Name: cookieName,
337
+ Value: token,
338
+ Path: types.PathAdmin,
339
+ HttpOnly: true,
340
+ Secure: policy.IsSecureForwardedRequest(r, f.trustProxy, f.trustedCIDRs),
341
+ SameSite: http.SameSiteStrictMode,
342
+ MaxAge: 86400,
343
+ })
344
+ utils.WriteAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
345
+}
346
+
347
+func (f *Frontend) isAuthenticated(r *http.Request) bool {
348
+ if !f.auth.AuthEnabled() {
349
+ return false
350
+ }
351
+ cookie, err := r.Cookie(cookieName)
352
+ if err != nil {
353
+ return false
354
+ }
355
+ return f.auth.ValidateSession(cookie.Value)
356
+}
357
+
358
+func saveAdminState(runtime *policy.Runtime) {
359
+ payload := persistedStateFromRuntime(runtime)
360
+ data, err := json.MarshalIndent(payload, "", " ")
361
+ if err != nil {
362
+ return
363
+ }
364
+
365
+ root, name, err := openSettingsRoot(adminSettingsPath)
366
+ if err != nil {
367
+ return
368
+ }
369
+ defer root.Close()
370
+ _ = root.WriteFile(name, data, 0o600)
371
+}
372
+
373
+type persistedAdminState struct {
374
+ ApprovalMode string `json:"approval_mode"`
375
+ ApprovedLeases []string `json:"approved_leases,omitempty"`
376
+ DeniedLeases []string `json:"denied_leases,omitempty"`
377
+ BannedLeases []string `json:"banned_leases,omitempty"`
378
+ BannedIPs []string `json:"banned_ips,omitempty"`
379
+}
380
+
381
+func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
382
+ approver := runtime.Approver()
383
+ return persistedAdminState{
384
+ ApprovalMode: string(approver.Mode()),
385
+ ApprovedLeases: approver.ApprovedLeases(),
386
+ DeniedLeases: approver.DeniedLeases(),
387
+ BannedLeases: runtime.BannedLeases(),
388
+ BannedIPs: runtime.IPFilter().BannedIPs(),
389
+ }
390
+}
391
+
392
+func (s persistedAdminState) apply(runtime *policy.Runtime) error {
393
+ if runtime == nil {
394
+ return nil
395
+ }
396
+ if mode := strings.TrimSpace(s.ApprovalMode); mode != "" {
397
+ if err := runtime.Approver().SetMode(policy.Mode(mode)); err != nil {
398
+ return err
399
+ }
400
+ }
401
+ runtime.Approver().SetDecisions(s.ApprovedLeases, s.DeniedLeases)
402
+ runtime.SetBannedLeases(s.BannedLeases)
403
+ runtime.IPFilter().SetBannedIPs(s.BannedIPs)
404
+ return nil
405
+}
406
+
407
+func openSettingsRoot(path string) (*os.Root, string, error) {
408
+ dir := filepath.Dir(path)
409
+ name := filepath.Base(path)
410
+ if dir == "" {
411
+ dir = "."
412
+ }
413
+
414
+ root, err := os.OpenRoot(dir)
415
+ if err != nil {
416
+ return nil, "", err
417
+ }
418
+ return root, name, nil
419
+}
cmd/relay-server/frontend.go
+126
-12
@@ -3,16 +3,18 @@ package main
3
import (
4
"embed"
5
"encoding/json"
6
+ "errors"
7
"html"
8
"io/fs"
9
"mime"
10
+ "net"
11
"net/http"
12
"path"
13
"strings"
14
"sync"
15
+ "time"
16
17
"github.com/gosuda/portal/v2/portal"
15
- "github.com/gosuda/portal/v2/portal/admin"
18
"github.com/gosuda/portal/v2/types"
19
)
20
@@ -25,23 +27,71 @@ type readDirFileFS interface {
27
var embeddedDistFS embed.FS
28
29
type Frontend struct {
28
- distFS readDirFileFS
29
- portalURL string
30
- server *portal.Server
30
+ distFS readDirFileFS
31
+ portalURL string
32
+ server *portal.Server
33
+ auth *adminAuth
34
+ trustProxy bool
35
+ trustedCIDRs []*net.IPNet
36
37
cachedPortalHTML []byte
38
cachedPortalHTMLOnce sync.Once
39
}
40
36
-func NewFrontend(portalURL string) *Frontend {
37
- return &Frontend{
38
- distFS: embeddedDistFS,
39
- portalURL: strings.TrimSpace(portalURL),
41
+func NewFrontend(portalURL string, server *portal.Server, adminSecret string, trustedProxyCIDRs []*net.IPNet, trustProxy bool) (*Frontend, error) {
42
+ if server == nil {
43
+ return nil, errors.New("frontend requires portal server")
44
+ }
45
+ runtime := server.PolicyRuntime()
46
+ if runtime == nil {
47
+ return nil, errors.New("frontend requires policy runtime")
48
+ }
49
+ if err := loadAdminState(runtime); err != nil {
50
+ return nil, err
51
}
52
+
53
+ return &Frontend{
54
+ distFS: embeddedDistFS,
55
+ portalURL: strings.TrimSpace(portalURL),
56
+ server: server,
57
+ auth: newAdminAuth(adminSecret),
58
+ trustProxy: trustProxy,
59
+ trustedCIDRs: trustedProxyCIDRs,
60
+ }, nil
61
}
62
43
-func (f *Frontend) Bind(server *portal.Server) {
44
- f.server = server
63
+func (f *Frontend) Handler() *http.ServeMux {
64
+ mux := http.NewServeMux()
65
+
66
+ mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
67
+ f.ServeAppStatic(w, r, "")
68
+ })
69
+ mux.HandleFunc(types.PathApp, func(w http.ResponseWriter, r *http.Request) {
70
+ f.ServeAppStatic(w, r, "")
71
+ })
72
+ mux.HandleFunc(types.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
73
+ f.ServeAppStatic(w, r, strings.TrimPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix))
74
+ })
75
+ mux.HandleFunc(types.PathAssetsPrefix, func(w http.ResponseWriter, r *http.Request) {
76
+ f.ServeAsset(w, r, strings.TrimPrefix(r.URL.Path, "/"), "")
77
+ })
78
+ for _, assetPath := range frontendRootAssetPaths() {
79
+ mux.HandleFunc(assetPath, func(w http.ResponseWriter, r *http.Request) {
80
+ f.ServeAsset(w, r, strings.TrimPrefix(assetPath, "/"), "")
81
+ })
82
+ }
83
+
84
+ mux.HandleFunc(types.PathAdmin, f.serveAdmin)
85
+ mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin)
86
+ mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
87
+ serveInstallScript(w, r, f.portalURL, false)
88
+ })
89
+ mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
90
+ serveInstallScript(w, r, f.portalURL, true)
91
+ })
92
+ mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
93
+
94
+ return mux
95
}
96
97
func (f *Frontend) ServeAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
@@ -135,8 +185,11 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) {
185
}
186
187
func (f *Frontend) injectServerData(htmlContent string) string {
138
- rows := admin.BuildLeaseRows(f.server, false)
139
- jsonData, err := json.Marshal(rows)
188
+ var snapshots []types.Lease
189
+ if f.server != nil {
190
+ snapshots = f.publicLeaseSnapshots()
191
+ }
192
+ jsonData, err := json.Marshal(snapshots)
193
if err != nil {
194
jsonData = []byte("[]")
195
}
@@ -168,6 +221,55 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
221
return replacer.Replace(htmlContent)
222
}
223
224
+func (f *Frontend) adminLeaseSnapshots() []types.Lease {
225
+ snapshots := f.server.LeaseSnapshots()
226
+ if len(snapshots) == 0 {
227
+ return nil
228
+ }
229
+ now := time.Now()
230
+ filtered := make([]types.Lease, 0, len(snapshots))
231
+ for _, snapshot := range snapshots {
232
+ if now.After(snapshot.ExpiresAt) {
233
+ continue
234
+ }
235
+ filtered = append(filtered, snapshot)
236
+ }
237
+ return filtered
238
+}
239
+
240
+func (f *Frontend) publicLeaseSnapshots() []types.Lease {
241
+ snapshots := f.server.LeaseSnapshots()
242
+ if len(snapshots) == 0 {
243
+ return nil
244
+ }
245
+
246
+ now := time.Now()
247
+ filtered := make([]types.Lease, 0, len(snapshots))
248
+ for _, snapshot := range snapshots {
249
+ if now.After(snapshot.ExpiresAt) {
250
+ continue
251
+ }
252
+ since := time.Duration(0)
253
+ if !snapshot.LastSeenAt.IsZero() {
254
+ since = max(now.Sub(snapshot.LastSeenAt), 0)
255
+ }
256
+ if snapshot.IsBanned || snapshot.IsDenied || !snapshot.IsApproved || snapshot.Metadata.Hide {
257
+ continue
258
+ }
259
+ if snapshot.Ready == 0 && since >= 3*time.Minute {
260
+ continue
261
+ }
262
+
263
+ snapshot.ClientIP = ""
264
+ snapshot.IsApproved = false
265
+ snapshot.IsBanned = false
266
+ snapshot.IsDenied = false
267
+ snapshot.IsIPBanned = false
268
+ filtered = append(filtered, snapshot)
269
+ }
270
+ return filtered
271
+}
272
+
273
func getContentType(ext string) string {
274
ext = strings.TrimSpace(ext)
275
if ext == "" {
@@ -196,3 +298,15 @@ func getContentType(ext string) string {
298
return ""
299
}
300
}
301
+
302
+func frontendRootAssetPaths() []string {
303
+ return []string{
304
+ "/favicon.ico",
305
+ "/favicon.svg",
306
+ "/favicon-96x96.png",
307
+ "/apple-touch-icon.png",
308
+ "/web-app-manifest-192x192.png",
309
+ "/web-app-manifest-512x512.png",
310
+ "/portal.jpg",
311
+ }
312
+}
cmd/relay-server/main.go
+59
@@ -1,15 +1,20 @@
1
package main
2
3
import (
4
+ "context"
5
"flag"
6
"fmt"
7
"os"
8
+ "os/signal"
9
"strings"
10
+ "syscall"
11
"time"
12
13
"github.com/rs/zerolog"
14
"github.com/rs/zerolog/log"
15
16
+ "github.com/gosuda/portal/v2/portal"
17
+ "github.com/gosuda/portal/v2/portal/acme"
18
"github.com/gosuda/portal/v2/types"
19
"github.com/gosuda/portal/v2/utils"
20
)
@@ -99,6 +104,60 @@ func main() {
104
}
105
}
106
107
+func runServer(cfg relayServerConfig) error {
108
+ logger := log.With().Str("component", "relay-server").Logger()
109
+
110
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
111
+ defer stop()
112
+
113
+ rootHost := utils.PortalRootHost(cfg.PortalURL)
114
+ apiListenAddr := fmt.Sprintf(":%d", cfg.APIPort)
115
+ sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
116
+ trustedProxyCIDRs, err := utils.ParseCIDRs(cfg.TrustedProxyCIDRs)
117
+ if err != nil {
118
+ return fmt.Errorf("parse trusted proxy cidrs: %w", err)
119
+ }
120
+ server, err := portal.NewServer(portal.ServerConfig{
121
+ PortalURL: cfg.PortalURL,
122
+ ACME: acme.Config{
123
+ KeyDir: cfg.KeylessDir,
124
+ DNSProvider: cfg.ACMEDNSProvider,
125
+ CloudflareToken: cfg.CloudflareToken,
126
+ AWSAccessKeyID: cfg.AWSAccessKeyID,
127
+ AWSSecretAccessKey: cfg.AWSSecretAccessKey,
128
+ AWSSessionToken: cfg.AWSSessionToken,
129
+ AWSRegion: cfg.AWSRegion,
130
+ AWSHostedZoneID: cfg.AWSHostedZoneID,
131
+ },
132
+ APIListenAddr: apiListenAddr,
133
+ SNIListenAddr: sniListenAddr,
134
+ TrustedProxyCIDRs: trustedProxyCIDRs,
135
+ TrustProxyHeaders: cfg.TrustProxyHeaders,
136
+ })
137
+ if err != nil {
138
+ return fmt.Errorf("create relay server: %w", err)
139
+ }
140
+
141
+ frontend, err := NewFrontend(cfg.PortalURL, server, cfg.AdminSecretKey, trustedProxyCIDRs, cfg.TrustProxyHeaders)
142
+ if err != nil {
143
+ return fmt.Errorf("create frontend: %w", err)
144
+ }
145
+
146
+ if err := server.Start(ctx, frontend.Handler()); err != nil {
147
+ return fmt.Errorf("start relay server: %w", err)
148
+ }
149
+
150
+ logger.Info().
151
+ Str("api_addr", utils.HostPortOrLoopback(server.APIAddr())).
152
+ Str("sni_addr", server.SNIAddr()).
153
+ Str("root_host", rootHost).
154
+ Str("acme_dns_provider", cfg.ACMEDNSProvider).
155
+ Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1").
156
+ Msg("relay server started")
157
+
158
+ return server.Wait()
159
+}
160
+
161
func trimmedEnv(name string) string {
162
return strings.TrimSpace(os.Getenv(name))
163
}
cmd/relay-server/serve.go
deleted
-120
@@ -1,120 +0,0 @@
1
-package main
2
-
3
-import (
4
- "context"
5
- "fmt"
6
- "net/http"
7
- "os"
8
- "os/signal"
9
- "strings"
10
- "syscall"
11
-
12
- "github.com/rs/zerolog/log"
13
-
14
- "github.com/gosuda/portal/v2/portal"
15
- "github.com/gosuda/portal/v2/portal/acme"
16
- "github.com/gosuda/portal/v2/portal/admin"
17
- "github.com/gosuda/portal/v2/types"
18
- "github.com/gosuda/portal/v2/utils"
19
-)
20
-
21
-func runServer(cfg relayServerConfig) error {
22
- logger := log.With().Str("component", "relay-server").Logger()
23
-
24
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
25
- defer stop()
26
-
27
- rootHost := utils.PortalRootHost(cfg.PortalURL)
28
- apiListenAddr := fmt.Sprintf(":%d", cfg.APIPort)
29
- sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
30
- server, err := portal.NewServer(portal.ServerConfig{
31
- PortalURL: cfg.PortalURL,
32
- ACME: acme.Config{
33
- KeyDir: cfg.KeylessDir,
34
- DNSProvider: cfg.ACMEDNSProvider,
35
- CloudflareToken: cfg.CloudflareToken,
36
- AWSAccessKeyID: cfg.AWSAccessKeyID,
37
- AWSSecretAccessKey: cfg.AWSSecretAccessKey,
38
- AWSSessionToken: cfg.AWSSessionToken,
39
- AWSRegion: cfg.AWSRegion,
40
- AWSHostedZoneID: cfg.AWSHostedZoneID,
41
- },
42
- APIListenAddr: apiListenAddr,
43
- SNIListenAddr: sniListenAddr,
44
- TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
45
- TrustProxyHeaders: cfg.TrustProxyHeaders,
46
- })
47
- if err != nil {
48
- return fmt.Errorf("create relay server: %w", err)
49
- }
50
-
51
- frontend := NewFrontend(cfg.PortalURL)
52
- adminHandler := admin.NewHandler(cfg.AdminSecretKey, "admin_settings.json", cfg.TrustProxyHeaders, func(w http.ResponseWriter, r *http.Request, appPath string) {
53
- frontend.ServeAppStatic(w, r, appPath)
54
- })
55
- frontend.Bind(server)
56
- adminHandler.Bind(server)
57
- if loadErr := adminHandler.LoadSettings(); loadErr != nil {
58
- logger.Warn().Err(loadErr).Msg("load admin settings")
59
- }
60
-
61
- if err := server.Start(ctx, newAPIMux(frontend, adminHandler, cfg)); err != nil {
62
- return fmt.Errorf("start relay server: %w", err)
63
- }
64
-
65
- logger.Info().
66
- Str("api_addr", utils.HostPortOrLoopback(server.APIAddr())).
67
- Str("sni_addr", server.SNIAddr()).
68
- Str("root_host", rootHost).
69
- Str("acme_dns_provider", cfg.ACMEDNSProvider).
70
- Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1").
71
- Msg("relay server started")
72
-
73
- return server.Wait()
74
-}
75
-
76
-func newAPIMux(frontend *Frontend, adminHandler *admin.Handler, cfg relayServerConfig) *http.ServeMux {
77
- mux := http.NewServeMux()
78
-
79
- mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
80
- frontend.ServeAppStatic(w, r, "")
81
- })
82
- mux.HandleFunc(types.PathApp, func(w http.ResponseWriter, r *http.Request) {
83
- frontend.ServeAppStatic(w, r, "")
84
- })
85
- mux.HandleFunc(types.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
86
- frontend.ServeAppStatic(w, r, strings.TrimPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix))
87
- })
88
- mux.HandleFunc(types.PathAssetsPrefix, func(w http.ResponseWriter, r *http.Request) {
89
- frontend.ServeAsset(w, r, strings.TrimPrefix(r.URL.Path, "/"), "")
90
- })
91
- for _, assetPath := range frontendRootAssetPaths() {
92
- mux.HandleFunc(assetPath, func(w http.ResponseWriter, r *http.Request) {
93
- frontend.ServeAsset(w, r, strings.TrimPrefix(assetPath, "/"), "")
94
- })
95
- }
96
-
97
- mux.HandleFunc(types.PathAdmin, adminHandler.HandleRequest)
98
- mux.HandleFunc(types.PathAdminPrefix, adminHandler.HandleRequest)
99
- mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
100
- serveInstallScript(w, r, cfg.PortalURL, false)
101
- })
102
- mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
103
- serveInstallScript(w, r, cfg.PortalURL, true)
104
- })
105
- mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
106
-
107
- return mux
108
-}
109
-
110
-func frontendRootAssetPaths() []string {
111
- return []string{
112
- "/favicon.ico",
113
- "/favicon.svg",
114
- "/favicon-96x96.png",
115
- "/apple-touch-icon.png",
116
- "/web-app-manifest-192x192.png",
117
- "/web-app-manifest-512x512.png",
118
- "/portal.jpg",
119
- }
120
-}
frontend/AGENTS.md
+3
-3
@@ -4,8 +4,8 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
4
5
## Frontend-Backend Contracts (Manually Synced)
6
7
-1. **SSR data shape is a 3-way contract.**
8
- Go `types.LeaseRow` (`../types/api.go`) + row builder (`../portal/admin/rows.go`) -> TS `ServerData` (`src/hooks/useSSRData.ts`) -> `<script id="__SSR_DATA__">` injection (`cmd/relay-server/frontend.go`).
7
+1. **SSR data shape is a 4-way contract.**
8
+ Go lease contracts (`../types/lease.go`) + portal snapshot producer (`../portal/lease.go`) + relay-server frontend filtering/injection (`../cmd/relay-server/frontend.go`) -> TS `ServerData` (`src/hooks/useSSRData.ts`).
9
- Why: no shared schema or codegen. Field drift silently breaks SSR hydration. The script tag ID `__SSR_DATA__` is hardcoded in all three locations.
10
11
2. **API path constants require dual maintenance.**
@@ -27,7 +27,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
27
- Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
28
29
6. **Admin state reads are aggregated through `/admin/snapshot`.**
30
- `src/hooks/useAdmin.ts` expects one payload carrying `leases`, `banned_leases`, and `approval_mode`.
30
+ `src/hooks/useAdmin.ts` expects one payload carrying `leases` and `approval_mode`.
31
- Why: splitting those reads across multiple endpoints reintroduces extra request coordination and drift in the admin bootstrap path.
32
33
## Frontend Conventions
frontend/src/hooks/useAdmin.test.ts
+11
-17
@@ -40,29 +40,24 @@ vi.mock("@/lib/apiClient", async () => {
40
41
function buildLease(peer: string): ServerData {
42
return {
43
- Peer: peer,
43
+ ExpiresAt: "2026-03-03T01:00:00Z",
44
+ FirstSeenAt: "2026-03-02T00:00:00Z",
45
+ LastSeenAt: "2026-03-03T00:00:00Z",
46
+ ID: peer,
47
Name: "relay-1",
45
- Kind: "relay",
46
- Connected: true,
47
- DNS: "relay.example.com",
48
- LastSeen: "2026-03-03T00:00:00Z",
49
- LastSeenISO: "2026-03-03T00:00:00Z",
50
- FirstSeenISO: "2026-03-02T00:00:00Z",
51
- TTL: "1h",
52
- Link: "https://relay.example.com",
53
- StaleRed: false,
54
- Hide: false,
55
- Metadata: JSON.stringify({
48
+ ClientIP: "203.0.113.10",
49
+ Hostname: "relay.example.com",
50
+ Metadata: {
51
description: "relay",
52
tags: ["core"],
53
thumbnail: "",
54
owner: "ops",
55
hide: false,
61
- }),
62
- BPS: 1024,
56
+ },
57
+ Ready: 1,
58
IsApproved: true,
59
+ IsBanned: peer === "peer-a",
60
IsDenied: false,
65
- IP: "203.0.113.10",
61
IsIPBanned: false,
62
};
63
}
@@ -85,7 +80,6 @@ describe("useAdmin", () => {
80
if (path === API_PATHS.admin.snapshot) {
81
return {
82
leases: [buildLease("peer-a")],
88
- banned_leases: ["peer-a", "peer-a", "peer-b"],
83
approval_mode: "not-a-mode",
84
} as never;
85
}
@@ -103,8 +97,8 @@ describe("useAdmin", () => {
97
98
expect(result.current.error).toBe("");
99
expect(result.current.approvalMode).toBe("auto");
106
- expect(result.current.bannedLeases).toEqual(["peer-a", "peer-b"]);
100
expect(result.current.servers[0]?.peerId).toBe("peer-a");
101
+ expect(result.current.servers[0]?.isBanned).toBe(true);
102
});
103
104
it("surfaces fetchData API errors", async () => {
frontend/src/hooks/useAdmin.ts
+14
-30
@@ -21,7 +21,6 @@ type ApprovalModeResponse = {
21
22
type AdminSnapshotResponse = {
23
approval_mode?: ApprovalMode;
24
- banned_leases?: string[];
24
leases?: ServerData[];
25
};
26
@@ -74,29 +73,29 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
73
74
function toAdminServer(
75
row: ServerData,
77
- index: number,
78
- bannedLeases: Set<string>
76
+ index: number
77
): AdminServer {
78
const metadata = parseLeaseMetadata(row.Metadata);
79
+ const hostname = row.Hostname || "";
80
81
return {
82
id: index + 1,
84
- name: row.Name || row.DNS || "(unnamed)",
83
+ name: row.Name || hostname || "(unnamed)",
84
description: metadata.description,
85
tags: metadata.tags,
86
thumbnail: metadata.thumbnail,
87
owner: metadata.owner,
89
- online: row.Connected,
90
- dns: row.DNS || "",
91
- link: row.Link,
92
- lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
93
- firstSeen: row.FirstSeenISO || undefined,
94
- peerId: row.Peer,
95
- isBanned: bannedLeases.has(row.Peer),
96
- bps: row.BPS || 0,
88
+ online: (row.Ready || 0) > 0,
89
+ dns: hostname,
90
+ link: hostname ? `https://${hostname}/` : "",
91
+ lastUpdated: row.LastSeenAt || undefined,
92
+ firstSeen: row.FirstSeenAt || undefined,
93
+ peerId: row.ID,
94
+ isBanned: row.IsBanned || false,
95
+ bps: 0,
96
isApproved: row.IsApproved || false,
97
isDenied: row.IsDenied || false,
99
- ip: row.IP || "",
98
+ ip: row.ClientIP || "",
99
isIPBanned: row.IsIPBanned || false,
100
};
101
}
@@ -122,27 +121,21 @@ function dedupeStrings(values: string[]): string[] {
121
122
interface AdminSnapshot {
123
serverData: ServerData[];
125
- bannedLeases: string[];
124
approvalMode: ApprovalMode;
125
}
126
127
async function loadAdminSnapshot(): Promise<AdminSnapshot> {
128
const snapshot = await apiClient.get<AdminSnapshotResponse>(API_PATHS.admin.snapshot);
129
const normalizedLeases = Array.isArray(snapshot?.leases) ? snapshot.leases : [];
132
- const normalizedBans = (Array.isArray(snapshot?.banned_leases) ? snapshot.banned_leases : []).filter(
133
- (leaseID): leaseID is string => typeof leaseID === "string"
134
- );
130
131
return {
132
serverData: normalizedLeases,
138
- bannedLeases: dedupeStrings(normalizedBans),
133
approvalMode: normalizeApprovalMode(snapshot?.approval_mode),
134
};
135
}
136
137
export function useAdmin() {
138
const [serverData, setServerData] = useState<ServerData[]>([]);
145
- const [bannedLeases, setBannedLeases] = useState<string[]>([]);
139
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
140
const [loading, setLoading] = useState(true);
141
const [error, setError] = useState("");
@@ -151,7 +144,6 @@ export function useAdmin() {
144
145
const applySnapshot = (snapshot: AdminSnapshot) => {
146
setServerData(snapshot.serverData);
154
- setBannedLeases(snapshot.bannedLeases);
147
setApprovalMode(snapshot.approvalMode);
148
};
149
@@ -197,16 +189,9 @@ export function useAdmin() {
189
};
190
}, []);
191
200
- const bannedLeaseSet = useMemo(
201
- () => new Set(bannedLeases),
202
- [bannedLeases]
203
- );
204
-
192
const servers: AdminServer[] = useMemo(() => {
206
- return serverData.map((row, index) =>
207
- toAdminServer(row, index, bannedLeaseSet)
208
- );
209
- }, [serverData, bannedLeaseSet]);
193
+ return serverData.map((row, index) => toAdminServer(row, index));
194
+ }, [serverData]);
195
196
const additionalFilter = (server: AdminServer) => {
197
switch (banFilter) {
@@ -344,7 +329,6 @@ export function useAdmin() {
329
330
return {
331
serverData,
347
- bannedLeases,
332
servers,
333
...listState,
334
banFilter,
frontend/src/hooks/useAuth.ts
+38
-177
@@ -1,16 +1,7 @@
1
-import { useCallback, useEffect, useState } from "react";
1
+import { useEffect, useState } from "react";
2
import { API_PATHS } from "@/lib/apiPaths";
3
import { APIClientError, apiClient } from "@/lib/apiClient";
4
5
-const STORAGE_KEY = "admin_login_attempts";
6
-const MAX_ATTEMPTS = 3;
7
-const LOCK_DURATION_MS = 60 * 1000; // 1 minute
8
-
9
-interface LoginAttempts {
10
- count: number;
11
- lockedUntil: number | null;
12
-}
13
-
5
interface AuthState {
6
isAuthenticated: boolean;
7
isLoading: boolean;
@@ -20,8 +11,6 @@ interface AuthState {
11
interface LoginResult {
12
success: boolean;
13
error?: string;
23
- locked?: boolean;
24
- remaining_seconds?: number;
14
}
15
16
interface AdminAuthStatusPayload {
@@ -31,28 +20,23 @@ interface AdminAuthStatusPayload {
20
21
interface AdminLoginPayload {
22
success?: boolean;
34
- locked?: boolean;
35
- remaining_seconds?: number;
23
}
24
38
-function getStoredAttempts(): LoginAttempts {
25
+async function fetchAuthState(): Promise<AuthState> {
26
try {
40
- const stored = localStorage.getItem(STORAGE_KEY);
41
- if (stored) {
42
- return JSON.parse(stored);
43
- }
27
+ const data = await apiClient.get<AdminAuthStatusPayload>(API_PATHS.admin.authStatus);
28
+ return {
29
+ isAuthenticated: data.authenticated,
30
+ isLoading: false,
31
+ authEnabled: data.auth_enabled,
32
+ };
33
} catch {
45
- // Ignore parse errors
34
+ return {
35
+ isAuthenticated: false,
36
+ isLoading: false,
37
+ authEnabled: true,
38
+ };
39
}
47
- return { count: 0, lockedUntil: null };
48
-}
49
-
50
-function setStoredAttempts(attempts: LoginAttempts): void {
51
- localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts));
52
-}
53
-
54
-function clearStoredAttempts(): void {
55
- localStorage.removeItem(STORAGE_KEY);
40
}
41
42
export function useAuth() {
@@ -62,173 +46,50 @@ export function useAuth() {
46
authEnabled: true,
47
});
48
65
- const [clientLock, setClientLock] = useState<{
66
- isLocked: boolean;
67
- remainingSeconds: number;
68
- }>({
69
- isLocked: false,
70
- remainingSeconds: 0,
71
- });
72
-
73
- // Check browser-side lock status.
74
- const checkClientLock = useCallback(() => {
75
- const attempts = getStoredAttempts();
76
- if (attempts.lockedUntil) {
77
- const remaining = attempts.lockedUntil - Date.now();
78
- if (remaining > 0) {
79
- setClientLock({
80
- isLocked: true,
81
- remainingSeconds: Math.ceil(remaining / 1000),
82
- });
83
- return true;
84
- } else {
85
- // Lock expired.
86
- clearStoredAttempts();
87
- setClientLock({ isLocked: false, remainingSeconds: 0 });
88
- }
89
- }
90
- return false;
91
- }, []);
49
+ const checkAuth = async () => {
50
+ setAuthState(await fetchAuthState());
51
+ };
52
93
- // Keep lock countdown in sync.
53
useEffect(() => {
95
- if (!clientLock.isLocked) return;
96
-
97
- const interval = setInterval(() => {
98
- const attempts = getStoredAttempts();
99
- if (attempts.lockedUntil) {
100
- const remaining = attempts.lockedUntil - Date.now();
101
- if (remaining > 0) {
102
- setClientLock({
103
- isLocked: true,
104
- remainingSeconds: Math.ceil(remaining / 1000),
105
- });
106
- } else {
107
- clearStoredAttempts();
108
- setClientLock({ isLocked: false, remainingSeconds: 0 });
109
- }
110
- }
111
- }, 1000);
112
-
113
- return () => clearInterval(interval);
114
- }, [clientLock.isLocked]);
115
-
116
- // Load current auth state from server.
117
- const checkAuth = useCallback(async () => {
118
- try {
119
- const data = await apiClient.get<AdminAuthStatusPayload>(API_PATHS.admin.authStatus);
120
- setAuthState({
121
- isAuthenticated: data.authenticated,
122
- isLoading: false,
123
- authEnabled: data.auth_enabled,
124
- });
125
- } catch {
126
- setAuthState({
127
- isAuthenticated: false,
128
- isLoading: false,
129
- authEnabled: true,
130
- });
131
- }
54
+ void (async () => {
55
+ setAuthState(await fetchAuthState());
56
+ })();
57
}, []);
58
134
- useEffect(() => {
135
- checkAuth();
136
- checkClientLock();
137
- }, [checkAuth, checkClientLock]);
138
-
139
- // Try admin sign-in.
140
- const login = useCallback(
141
- async (key: string): Promise<LoginResult> => {
142
- // Apply local lock before calling server.
143
- if (checkClientLock()) {
144
- const attempts = getStoredAttempts();
145
- const remaining = attempts.lockedUntil
146
- ? Math.ceil((attempts.lockedUntil - Date.now()) / 1000)
147
- : 60;
148
- return {
149
- success: false,
150
- error: "Too many failed attempts. Try again in 1 minute.",
151
- locked: true,
152
- remaining_seconds: remaining,
153
- };
59
+ const login = async (key: string): Promise<LoginResult> => {
60
+ try {
61
+ const data = await apiClient.post<AdminLoginPayload>(API_PATHS.admin.login, { key });
62
+ if (data?.success) {
63
+ setAuthState((prev) => ({ ...prev, isAuthenticated: true }));
64
+ return { success: true };
65
}
155
-
156
- try {
157
- const data = await apiClient.post<AdminLoginPayload>(API_PATHS.admin.login, { key });
158
-
159
- if (data?.success) {
160
- // Reset local lock state on success.
161
- clearStoredAttempts();
162
- setClientLock({ isLocked: false, remainingSeconds: 0 });
163
- setAuthState((prev) => ({ ...prev, isAuthenticated: true }));
164
- return { success: true };
165
- }
166
-
167
- return { success: false, error: "Secret key is invalid." };
168
- } catch (err: unknown) {
169
- if (err instanceof APIClientError) {
170
- const payload =
171
- typeof err.details === "object" && err.details !== null
172
- ? (err.details as AdminLoginPayload)
173
- : undefined;
174
-
175
- const lockedByServer =
176
- err.code === "auth_locked" || payload?.locked === true;
177
-
178
- if (lockedByServer) {
179
- const remainingSeconds = payload?.remaining_seconds ?? 60;
180
- const attempts = getStoredAttempts();
181
- attempts.lockedUntil = Date.now() + remainingSeconds * 1000;
182
- setStoredAttempts(attempts);
183
- setClientLock({ isLocked: true, remainingSeconds });
184
-
185
- return {
186
- success: false,
187
- error: err.message,
188
- locked: true,
189
- remaining_seconds: remainingSeconds,
190
- };
191
- }
192
-
193
- // Record failed attempt locally for invalid-key style failures.
194
- const attempts = getStoredAttempts();
195
- attempts.count++;
196
- if (attempts.count >= MAX_ATTEMPTS) {
197
- attempts.lockedUntil = Date.now() + LOCK_DURATION_MS;
198
- setClientLock({ isLocked: true, remainingSeconds: 60 });
199
- }
200
- setStoredAttempts(attempts);
201
-
202
- return {
203
- success: false,
204
- error: err.message || "Secret key is invalid.",
205
- locked: attempts.count >= MAX_ATTEMPTS,
206
- remaining_seconds: payload?.remaining_seconds || 60,
207
- };
208
- }
209
-
66
+ return { success: false, error: "Secret key is invalid." };
67
+ } catch (err: unknown) {
68
+ if (err instanceof APIClientError) {
69
return {
70
success: false,
212
- error: err instanceof Error ? err.message : "Could not sign in.",
71
+ error: err.message || "Secret key is invalid.",
72
};
73
}
215
- },
216
- [checkClientLock]
217
- );
74
219
- // End current admin session.
220
- const logout = useCallback(async () => {
75
+ return {
76
+ success: false,
77
+ error: err instanceof Error ? err.message : "Could not sign in.",
78
+ };
79
+ }
80
+ };
81
+
82
+ const logout = async () => {
83
try {
84
await apiClient.post<unknown>(API_PATHS.admin.logout);
85
} catch {
86
// Ignore errors
87
}
88
setAuthState((prev) => ({ ...prev, isAuthenticated: false }));
227
- }, []);
89
+ };
90
91
return {
92
...authState,
231
- ...clientLock,
93
login,
94
logout,
95
checkAuth,
frontend/src/hooks/useSSRData.ts
+12
-17
@@ -9,24 +9,19 @@ export interface Metadata {
9
}
10
11
export interface ServerData {
12
- Peer: string;
12
+ ExpiresAt: string;
13
+ FirstSeenAt: string;
14
+ LastSeenAt: string;
15
+ ID: string;
16
Name: string;
14
- Kind: string;
15
- Connected: boolean;
16
- DNS: string;
17
- LastSeen: string;
18
- LastSeenISO: string;
19
- FirstSeenISO: string;
20
- TTL: string;
21
- Link: string;
22
- StaleRed: boolean;
23
- Hide: boolean;
24
- Metadata: string;
25
- BPS?: number; // bytes-per-second limit (0 = unlimited), admin only
26
- IsApproved?: boolean; // whether lease is approved (for manual mode), admin only
27
- IsDenied?: boolean; // whether lease is denied (for manual mode), admin only
28
- IP?: string; // client IP address (for IP-based ban), admin only
29
- IsIPBanned?: boolean; // whether the IP is banned, admin only
17
+ ClientIP: string;
18
+ Hostname: string;
19
+ Metadata: unknown;
20
+ Ready: number;
21
+ IsApproved?: boolean;
22
+ IsBanned?: boolean;
23
+ IsDenied?: boolean;
24
+ IsIPBanned?: boolean;
25
}
26
27
/**
frontend/src/hooks/useServerList.ts
+7
-6
@@ -12,19 +12,20 @@ export type ClientServer = BaseServer;
12
function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
13
return ssrData.map((row, index) => {
14
const metadata = parseLeaseMetadata(row.Metadata);
15
+ const hostname = row.Hostname || "";
16
17
return {
18
id: index + 1,
18
- name: row.Name || row.DNS || "(unnamed)",
19
+ name: row.Name || hostname || "(unnamed)",
20
description: metadata.description || "",
21
tags: metadata.tags,
22
thumbnail: metadata.thumbnail || "",
23
owner: metadata.owner || "",
23
- online: row.Connected,
24
- dns: row.DNS || "",
25
- link: row.Link,
26
- lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
27
- firstSeen: row.FirstSeenISO || undefined,
24
+ online: (row.Ready || 0) > 0,
25
+ dns: hostname,
26
+ link: hostname ? `https://${hostname}/` : "",
27
+ lastUpdated: row.LastSeenAt || undefined,
28
+ firstSeen: row.FirstSeenAt || undefined,
29
};
30
});
31
}
frontend/src/lib/metadata.ts
+21
-3
@@ -12,13 +12,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
12
return typeof value === "object" && value !== null && !Array.isArray(value);
13
}
14
15
-export function parseLeaseMetadata(metadataJSON: string): Metadata {
16
- if (!metadataJSON) {
15
+export function parseLeaseMetadata(metadataValue: unknown): Metadata {
16
+ if (!metadataValue) {
17
+ return EMPTY_METADATA;
18
+ }
19
+
20
+ if (isRecord(metadataValue)) {
21
+ return {
22
+ description: typeof metadataValue.description === "string" ? metadataValue.description : "",
23
+ tags: Array.isArray(metadataValue.tags)
24
+ ? metadataValue.tags
25
+ .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
26
+ .filter(Boolean)
27
+ : [],
28
+ thumbnail: typeof metadataValue.thumbnail === "string" ? metadataValue.thumbnail : "",
29
+ owner: typeof metadataValue.owner === "string" ? metadataValue.owner : "",
30
+ hide: typeof metadataValue.hide === "boolean" ? metadataValue.hide : false,
31
+ };
32
+ }
33
+
34
+ if (typeof metadataValue !== "string") {
35
return EMPTY_METADATA;
36
}
37
38
try {
21
- const parsed = JSON.parse(metadataJSON);
39
+ const parsed = JSON.parse(metadataValue);
40
if (!isRecord(parsed)) {
41
return EMPTY_METADATA;
42
}
frontend/src/pages/AdminLogin.tsx
+4
-20
@@ -10,8 +10,6 @@ export function AdminLogin() {
10
isAuthenticated,
11
isLoading,
12
authEnabled,
13
- isLocked,
14
- remainingSeconds,
13
login,
14
} = useAuth();
15
@@ -38,7 +36,7 @@ export function AdminLogin() {
36
37
const handleSubmit = async (e: FormEvent) => {
38
e.preventDefault();
41
- if (!key.trim() || isLocked || submitting) return;
39
+ if (!key.trim() || submitting) return;
40
41
setSubmitting(true);
42
setError("");
@@ -130,7 +128,7 @@ export function AdminLogin() {
128
placeholder="Enter your secret key"
129
value={key}
130
onChange={(e) => setKey(e.target.value)}
133
- disabled={isLocked || submitting || !authEnabled}
131
+ disabled={submitting || !authEnabled}
132
autoFocus
133
className="h-12 w-full rounded-lg border-none bg-secondary pl-10 pr-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
134
/>
@@ -144,28 +142,14 @@ export function AdminLogin() {
142
</div>
143
)}
144
147
- {/* Lock Message */}
148
- {isLocked && (
149
- <div className="text-amber-500 text-sm text-center bg-amber-500/10 p-3 rounded-md">
150
- Too many failed attempts. Please wait {remainingSeconds}{" "}
151
- seconds.
152
- </div>
153
- )}
154
-
145
{/* Submit Button */}
146
<button
147
type="submit"
158
- disabled={
159
- !key.trim() || isLocked || submitting || !authEnabled
160
- }
148
+ disabled={!key.trim() || submitting || !authEnabled}
149
className="flex h-12 w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-primary text-base font-bold text-white transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
150
>
151
<span className="truncate">
164
- {submitting
165
- ? "Authenticating..."
166
- : isLocked
167
- ? `Wait ${remainingSeconds}s`
168
- : "Login"}
152
+ {submitting ? "Authenticating..." : "Login"}
153
</span>
154
</button>
155
</form>
portal/admin/handler.go
deleted
-367
@@ -1,367 +0,0 @@
1
-package admin
2
-
3
-import (
4
- "encoding/base64"
5
- "encoding/json"
6
- "net"
7
- "net/http"
8
- "strings"
9
-
10
- "github.com/gosuda/portal/v2/portal"
11
- "github.com/gosuda/portal/v2/portal/policy"
12
- "github.com/gosuda/portal/v2/types"
13
- "github.com/gosuda/portal/v2/utils"
14
-)
15
-
16
-const cookieName = "portal_admin"
17
-
18
-type Handler struct {
19
- auth *policy.Authenticator
20
- server *portal.Server
21
- settings *stateStore
22
- serveAppStatic func(http.ResponseWriter, *http.Request, string)
23
- buildLeaseRows func(*portal.Server, bool) []types.LeaseRow
24
- trustProxy bool
25
-}
26
-
27
-func NewHandler(secret, settingsPath string, trustProxy bool, serveAppStatic func(http.ResponseWriter, *http.Request, string)) *Handler {
28
- h := &Handler{
29
- auth: policy.NewAuthenticator(strings.TrimSpace(secret)),
30
- settings: newStateStore(settingsPath),
31
- buildLeaseRows: func(serv *portal.Server, includeAdmin bool) []types.LeaseRow {
32
- return BuildLeaseRows(serv, includeAdmin)
33
- },
34
- trustProxy: trustProxy,
35
- }
36
- if serveAppStatic != nil {
37
- h.serveAppStatic = serveAppStatic
38
- } else {
39
- h.serveAppStatic = func(w http.ResponseWriter, r *http.Request, _ string) {
40
- http.NotFound(w, r)
41
- }
42
- }
43
- return h
44
-}
45
-
46
-func (h *Handler) Bind(server *portal.Server) {
47
- h.server = server
48
-}
49
-
50
-func (h *Handler) LoadSettings() error {
51
- return h.settings.Load(h.policyRuntime())
52
-}
53
-
54
-func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
55
- path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
56
- if path == "" {
57
- path = types.PathRoot
58
- }
59
-
60
- switch path {
61
- case types.PathAdmin:
62
- h.serveAppStatic(w, r, "")
63
- return
64
- case types.PathAdminLogin:
65
- if r.Method == http.MethodGet {
66
- h.serveAppStatic(w, r, "")
67
- return
68
- }
69
- if r.Method == http.MethodPost {
70
- h.handleLogin(w, r)
71
- return
72
- }
73
- case types.PathAdminLogout:
74
- h.handleLogout(w, r)
75
- return
76
- case types.PathAdminAuthStatus:
77
- h.handleAuthStatus(w, r)
78
- return
79
- }
80
-
81
- if !h.isAuthenticated(r) {
82
- utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
83
- return
84
- }
85
- if h.server == nil {
86
- utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeFeatureUnavailable, "admin handler is not bound to a server")
87
- return
88
- }
89
-
90
- switch path {
91
- case types.PathAdminSnapshot:
92
- h.handleSnapshot(w, r)
93
- case types.PathAdminApproval:
94
- h.handleApprovalMode(w, r)
95
- default:
96
- switch {
97
- case strings.HasPrefix(path, types.PathAdminLeasesPrefix):
98
- h.handleLeaseAction(w, r, path)
99
- case strings.HasPrefix(path, types.PathAdminIPsPrefix):
100
- h.handleIPBan(w, r, path)
101
- default:
102
- http.NotFound(w, r)
103
- }
104
- }
105
-}
106
-
107
-func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
108
- if r.Method != http.MethodPost {
109
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
110
- return
111
- }
112
- if !h.auth.AuthEnabled() {
113
- utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
114
- return
115
- }
116
-
117
- clientIP := policy.ExtractClientIP(r, h.trustProxy)
118
- if h.auth.IsIPLocked(clientIP) {
119
- utils.WriteAPIErrorWithData(w, http.StatusTooManyRequests, types.APIErrorCodeAuthLocked, "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
120
- Locked: true,
121
- RemainingSeconds: h.auth.LockRemainingSeconds(clientIP),
122
- })
123
- return
124
- }
125
-
126
- var req types.AdminLoginRequest
127
- if err := decodeJSON(w, r, &req); err != nil {
128
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
129
- return
130
- }
131
- if !h.auth.ValidateKey(req.Key) {
132
- locked := h.auth.RecordFailedLogin(clientIP)
133
- resp := types.AdminLoginResponse{Locked: locked}
134
- if locked {
135
- resp.RemainingSeconds = h.auth.LockRemainingSeconds(clientIP)
136
- }
137
- utils.WriteAPIErrorWithData(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key", resp)
138
- return
139
- }
140
-
141
- h.auth.ResetFailedLogin(clientIP)
142
- token, err := h.auth.CreateSession()
143
- if err != nil {
144
- utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
145
- return
146
- }
147
-
148
- http.SetCookie(w, &http.Cookie{
149
- Name: cookieName,
150
- Value: token,
151
- Path: types.PathAdmin,
152
- HttpOnly: true,
153
- Secure: policy.IsSecureForwardedRequest(r, h.trustProxy),
154
- SameSite: http.SameSiteStrictMode,
155
- MaxAge: 86400,
156
- })
157
- utils.WriteAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
158
-}
159
-
160
-func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
161
- if r.Method != http.MethodPost {
162
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
163
- return
164
- }
165
-
166
- if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
167
- h.auth.DeleteSession(cookie.Value)
168
- }
169
- http.SetCookie(w, &http.Cookie{
170
- Name: cookieName,
171
- Value: "",
172
- Path: types.PathAdmin,
173
- HttpOnly: true,
174
- Secure: policy.IsSecureForwardedRequest(r, h.trustProxy),
175
- SameSite: http.SameSiteStrictMode,
176
- MaxAge: -1,
177
- })
178
- utils.WriteAPIOK(w, http.StatusOK)
179
-}
180
-
181
-func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
182
- if r.Method != http.MethodGet {
183
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
184
- return
185
- }
186
- utils.WriteAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
187
- Authenticated: h.isAuthenticated(r),
188
- AuthEnabled: h.auth.AuthEnabled(),
189
- })
190
-}
191
-
192
-func (h *Handler) handleSnapshot(w http.ResponseWriter, r *http.Request) {
193
- if r.Method != http.MethodGet {
194
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
195
- return
196
- }
197
-
198
- approver := h.policyRuntime().Approver()
199
- utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
200
- ApprovalMode: string(approver.Mode()),
201
- BannedLeases: h.policyRuntime().BannedLeases(),
202
- Leases: h.buildLeaseRows(h.server, true),
203
- })
204
-}
205
-
206
-func (h *Handler) handleApprovalMode(w http.ResponseWriter, r *http.Request) {
207
- runtime := h.policyRuntime()
208
- approver := runtime.Approver()
209
- switch r.Method {
210
- case http.MethodPost:
211
- var req types.AdminApprovalModeRequest
212
- if err := decodeJSON(w, r, &req); err != nil {
213
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
214
- return
215
- }
216
- if err := approver.SetMode(policy.Mode(req.Mode)); err != nil {
217
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
218
- return
219
- }
220
- _ = h.settings.Save(runtime)
221
- utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
222
- default:
223
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
224
- }
225
-}
226
-
227
-func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path string) {
228
- rest := strings.TrimPrefix(path, types.PathAdminLeasesPrefix)
229
- parts := strings.Split(rest, "/")
230
- if len(parts) != 2 {
231
- http.NotFound(w, r)
232
- return
233
- }
234
-
235
- leaseID, ok := decodeLeaseID(parts[0])
236
- if !ok {
237
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
238
- return
239
- }
240
-
241
- switch parts[1] {
242
- case "ban":
243
- h.handleLeaseBan(w, r, leaseID)
244
- case "bps":
245
- utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
246
- case "approve":
247
- h.handleLeaseApproval(w, r, leaseID)
248
- case "deny":
249
- h.handleLeaseDenial(w, r, leaseID)
250
- default:
251
- http.NotFound(w, r)
252
- }
253
-}
254
-
255
-func (h *Handler) handleLeaseBan(w http.ResponseWriter, r *http.Request, leaseID string) {
256
- runtime := h.policyRuntime()
257
- switch r.Method {
258
- case http.MethodPost:
259
- runtime.BanLease(leaseID)
260
- _ = h.settings.Save(runtime)
261
- utils.WriteAPIOK(w, http.StatusOK)
262
- case http.MethodDelete:
263
- runtime.UnbanLease(leaseID)
264
- _ = h.settings.Save(runtime)
265
- utils.WriteAPIOK(w, http.StatusOK)
266
- default:
267
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
268
- }
269
-}
270
-
271
-func (h *Handler) handleLeaseApproval(w http.ResponseWriter, r *http.Request, leaseID string) {
272
- runtime := h.policyRuntime()
273
- approver := runtime.Approver()
274
- switch r.Method {
275
- case http.MethodPost:
276
- approver.Approve(leaseID)
277
- approver.Undeny(leaseID)
278
- _ = h.settings.Save(runtime)
279
- utils.WriteAPIOK(w, http.StatusOK)
280
- case http.MethodDelete:
281
- approver.Revoke(leaseID)
282
- _ = h.settings.Save(runtime)
283
- utils.WriteAPIOK(w, http.StatusOK)
284
- default:
285
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
286
- }
287
-}
288
-
289
-func (h *Handler) handleLeaseDenial(w http.ResponseWriter, r *http.Request, leaseID string) {
290
- runtime := h.policyRuntime()
291
- approver := runtime.Approver()
292
- switch r.Method {
293
- case http.MethodPost:
294
- approver.Deny(leaseID)
295
- _ = h.settings.Save(runtime)
296
- utils.WriteAPIOK(w, http.StatusOK)
297
- case http.MethodDelete:
298
- approver.Undeny(leaseID)
299
- _ = h.settings.Save(runtime)
300
- utils.WriteAPIOK(w, http.StatusOK)
301
- default:
302
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
303
- }
304
-}
305
-
306
-func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path string) {
307
- if !strings.HasSuffix(path, "/ban") {
308
- http.NotFound(w, r)
309
- return
310
- }
311
- rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
312
- rawIP = strings.Trim(rawIP, "/")
313
- if net.ParseIP(rawIP) == nil {
314
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
315
- return
316
- }
317
-
318
- runtime := h.policyRuntime()
319
- ipFilter := runtime.IPFilter()
320
- switch r.Method {
321
- case http.MethodPost:
322
- ipFilter.BanIP(rawIP)
323
- _ = h.settings.Save(runtime)
324
- utils.WriteAPIOK(w, http.StatusOK)
325
- case http.MethodDelete:
326
- ipFilter.UnbanIP(rawIP)
327
- _ = h.settings.Save(runtime)
328
- utils.WriteAPIOK(w, http.StatusOK)
329
- default:
330
- utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
331
- }
332
-}
333
-
334
-func (h *Handler) isAuthenticated(r *http.Request) bool {
335
- if !h.auth.AuthEnabled() {
336
- return false
337
- }
338
- cookie, err := r.Cookie(cookieName)
339
- if err != nil {
340
- return false
341
- }
342
- return h.auth.ValidateSession(cookie.Value)
343
-}
344
-
345
-func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
346
- r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
347
- defer r.Body.Close()
348
- return json.NewDecoder(r.Body).Decode(dst)
349
-}
350
-
351
-func decodeLeaseID(encoded string) (string, bool) {
352
- idBytes, err := base64.URLEncoding.DecodeString(encoded)
353
- if err != nil {
354
- idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
355
- if err != nil {
356
- return "", false
357
- }
358
- }
359
- return string(idBytes), true
360
-}
361
-
362
-func (h *Handler) policyRuntime() *policy.Runtime {
363
- if h.server == nil {
364
- return nil
365
- }
366
- return h.server.PolicyRuntime()
367
-}
portal/admin/handler_test.go
deleted
-143
@@ -1,143 +0,0 @@
1
-package admin
2
-
3
-import (
4
- "bytes"
5
- "encoding/json"
6
- "net/http"
7
- "net/http/httptest"
8
- "path/filepath"
9
- "testing"
10
-
11
- "github.com/gosuda/portal/v2/portal"
12
- "github.com/gosuda/portal/v2/portal/policy"
13
- "github.com/gosuda/portal/v2/types"
14
-)
15
-
16
-func TestLoginAndProtectedActions(t *testing.T) {
17
- t.Parallel()
18
-
19
- handler := NewHandler("secret-key", filepath.Join(t.TempDir(), "admin_settings.json"), false, func(w http.ResponseWriter, _ *http.Request, _ string) {
20
- w.WriteHeader(http.StatusOK)
21
- })
22
- server, err := portal.NewServer(portal.ServerConfig{PortalURL: "https://portal.example.com"})
23
- if err != nil {
24
- t.Fatalf("NewServer() error = %v", err)
25
- }
26
- handler.Bind(server)
27
-
28
- loginRecorder := httptest.NewRecorder()
29
- loginRequest := httptest.NewRequest(http.MethodPost, types.PathAdminLogin, bytes.NewBufferString(`{"key":"secret-key"}`))
30
- loginRequest.RemoteAddr = "127.0.0.1:1234"
31
- handler.HandleRequest(loginRecorder, loginRequest)
32
-
33
- if loginRecorder.Code != http.StatusOK {
34
- t.Fatalf("login status = %d, want %d", loginRecorder.Code, http.StatusOK)
35
- }
36
- loginResponse := decodeEnvelope[types.AdminLoginResponse](t, loginRecorder)
37
- if !loginResponse.Success {
38
- t.Fatalf("login success = false, want true")
39
- }
40
- cookies := loginRecorder.Result().Cookies()
41
- if len(cookies) == 0 {
42
- t.Fatalf("login cookies = 0, want at least 1")
43
- }
44
-
45
- authRecorder := httptest.NewRecorder()
46
- authRequest := httptest.NewRequest(http.MethodGet, types.PathAdminAuthStatus, nil)
47
- authRequest.RemoteAddr = "127.0.0.1:1234"
48
- authRequest.AddCookie(cookies[0])
49
- handler.HandleRequest(authRecorder, authRequest)
50
- authStatus := decodeEnvelope[types.AdminAuthStatusResponse](t, authRecorder)
51
- if !authStatus.Authenticated || !authStatus.AuthEnabled {
52
- t.Fatalf("auth status = %+v, want authenticated + auth enabled", authStatus)
53
- }
54
-
55
- snapshotRecorder := httptest.NewRecorder()
56
- snapshotRequest := httptest.NewRequest(http.MethodGet, types.PathAdminSnapshot, nil)
57
- snapshotRequest.RemoteAddr = "127.0.0.1:1234"
58
- snapshotRequest.AddCookie(cookies[0])
59
- handler.HandleRequest(snapshotRecorder, snapshotRequest)
60
- if snapshotRecorder.Code != http.StatusOK {
61
- t.Fatalf("snapshot status = %d, want %d", snapshotRecorder.Code, http.StatusOK)
62
- }
63
- snapshot := decodeEnvelope[types.AdminSnapshotResponse](t, snapshotRecorder)
64
- if snapshot.ApprovalMode != string(policy.ModeAuto) {
65
- t.Fatalf("snapshot approval mode = %q, want %q", snapshot.ApprovalMode, policy.ModeAuto)
66
- }
67
- if len(snapshot.Leases) != 0 {
68
- t.Fatalf("snapshot leases len = %d, want 0", len(snapshot.Leases))
69
- }
70
-
71
- legacyLeasesRecorder := httptest.NewRecorder()
72
- legacyLeasesRequest := httptest.NewRequest(http.MethodGet, types.PathAdminLeases, nil)
73
- legacyLeasesRequest.RemoteAddr = "127.0.0.1:1234"
74
- legacyLeasesRequest.AddCookie(cookies[0])
75
- handler.HandleRequest(legacyLeasesRecorder, legacyLeasesRequest)
76
- if legacyLeasesRecorder.Code != http.StatusNotFound {
77
- t.Fatalf("legacy leases status = %d, want %d", legacyLeasesRecorder.Code, http.StatusNotFound)
78
- }
79
-
80
- legacyBannedRecorder := httptest.NewRecorder()
81
- legacyBannedRequest := httptest.NewRequest(http.MethodGet, "/admin/leases/banned", nil)
82
- legacyBannedRequest.RemoteAddr = "127.0.0.1:1234"
83
- legacyBannedRequest.AddCookie(cookies[0])
84
- handler.HandleRequest(legacyBannedRecorder, legacyBannedRequest)
85
- if legacyBannedRecorder.Code != http.StatusNotFound {
86
- t.Fatalf("legacy banned status = %d, want %d", legacyBannedRecorder.Code, http.StatusNotFound)
87
- }
88
-
89
- legacySettingsRecorder := httptest.NewRecorder()
90
- legacySettingsRequest := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
91
- legacySettingsRequest.RemoteAddr = "127.0.0.1:1234"
92
- legacySettingsRequest.AddCookie(cookies[0])
93
- handler.HandleRequest(legacySettingsRecorder, legacySettingsRequest)
94
- if legacySettingsRecorder.Code != http.StatusNotFound {
95
- t.Fatalf("legacy settings status = %d, want %d", legacySettingsRecorder.Code, http.StatusNotFound)
96
- }
97
-
98
- legacyApprovalGetRecorder := httptest.NewRecorder()
99
- legacyApprovalGetRequest := httptest.NewRequest(http.MethodGet, types.PathAdminApproval, nil)
100
- legacyApprovalGetRequest.RemoteAddr = "127.0.0.1:1234"
101
- legacyApprovalGetRequest.AddCookie(cookies[0])
102
- handler.HandleRequest(legacyApprovalGetRecorder, legacyApprovalGetRequest)
103
- if legacyApprovalGetRecorder.Code != http.StatusMethodNotAllowed {
104
- t.Fatalf("legacy approval GET status = %d, want %d", legacyApprovalGetRecorder.Code, http.StatusMethodNotAllowed)
105
- }
106
-
107
- approvalRecorder := httptest.NewRecorder()
108
- approvalRequest := httptest.NewRequest(http.MethodPost, types.PathAdminApproval, bytes.NewBufferString(`{"mode":"manual"}`))
109
- approvalRequest.RemoteAddr = "127.0.0.1:1234"
110
- approvalRequest.AddCookie(cookies[0])
111
- handler.HandleRequest(approvalRecorder, approvalRequest)
112
- if approvalRecorder.Code != http.StatusOK {
113
- t.Fatalf("approval status = %d, want %d", approvalRecorder.Code, http.StatusOK)
114
- }
115
- if got := server.PolicyRuntime().Approver().Mode(); got != policy.ModeManual {
116
- t.Fatalf("approval mode = %q, want %q", got, policy.ModeManual)
117
- }
118
-
119
- ipBanRecorder := httptest.NewRecorder()
120
- ipBanRequest := httptest.NewRequest(http.MethodPost, types.PathAdminIPsPrefix+"203.0.113.10/ban", nil)
121
- ipBanRequest.RemoteAddr = "127.0.0.1:1234"
122
- ipBanRequest.AddCookie(cookies[0])
123
- handler.HandleRequest(ipBanRecorder, ipBanRequest)
124
- if ipBanRecorder.Code != http.StatusOK {
125
- t.Fatalf("ip ban status = %d, want %d", ipBanRecorder.Code, http.StatusOK)
126
- }
127
- if !server.PolicyRuntime().IPFilter().IsIPBanned("203.0.113.10") {
128
- t.Fatalf("IsIPBanned() = false, want true")
129
- }
130
-}
131
-
132
-func decodeEnvelope[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
133
- t.Helper()
134
-
135
- var envelope types.APIEnvelope[T]
136
- if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
137
- t.Fatalf("Decode envelope error = %v", err)
138
- }
139
- if !envelope.OK {
140
- t.Fatalf("envelope not OK: %+v", envelope)
141
- }
142
- return envelope.Data
143
-}
portal/admin/rows.go
deleted
-118
@@ -1,118 +0,0 @@
1
-package admin
2
-
3
-import (
4
- "encoding/json"
5
- "fmt"
6
- "strings"
7
- "time"
8
-
9
- "github.com/gosuda/portal/v2/portal"
10
- "github.com/gosuda/portal/v2/types"
11
-)
12
-
13
-const staleLeaseHideWindow = 3 * time.Minute
14
-
15
-func BuildLeaseRows(serv *portal.Server, includeAdmin bool) []types.LeaseRow {
16
- if serv == nil {
17
- return nil
18
- }
19
-
20
- now := time.Now()
21
- snapshots := serv.ListLeases()
22
- rows := make([]types.LeaseRow, 0, len(snapshots))
23
- for _, snapshot := range snapshots {
24
- if now.After(snapshot.ExpiresAt) {
25
- continue
26
- }
27
-
28
- since := time.Duration(0)
29
- if !snapshot.LastSeenAt.IsZero() {
30
- since = max(now.Sub(snapshot.LastSeenAt), 0)
31
- }
32
- connected := snapshot.Ready > 0
33
- if !includeAdmin {
34
- if snapshot.IsBanned || snapshot.IsDenied || !snapshot.IsApproved || snapshot.Metadata.Hide {
35
- continue
36
- }
37
- if !connected && since >= staleLeaseHideWindow {
38
- continue
39
- }
40
- }
41
-
42
- metadataJSON, _ := json.Marshal(snapshot.Metadata)
43
- host := snapshot.Hostname
44
-
45
- rows = append(rows, types.LeaseRow{
46
- TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
47
- Metadata: string(metadataJSON),
48
- Kind: "https",
49
- IP: snapshot.ClientIP,
50
- DNS: host,
51
- LastSeen: formatLastSeen(since),
52
- LastSeenISO: formatISOTime(snapshot.LastSeenAt),
53
- FirstSeenISO: formatISOTime(snapshot.FirstSeenAt),
54
- Name: strings.TrimSpace(snapshot.Name),
55
- Peer: snapshot.ID,
56
- Link: leaseLink(host),
57
- BPS: 0,
58
- Hide: snapshot.Metadata.Hide,
59
- StaleRed: !connected && since >= staleLeaseHideWindow,
60
- IsApproved: snapshot.IsApproved,
61
- IsDenied: snapshot.IsDenied,
62
- Connected: connected,
63
- IsIPBanned: snapshot.IsIPBanned,
64
- })
65
- }
66
- return rows
67
-}
68
-
69
-func formatDuration(d time.Duration) string {
70
- if d <= 0 {
71
- return ""
72
- }
73
- if d > time.Hour {
74
- return fmt.Sprintf("%.0fh", d.Hours())
75
- }
76
- if d > time.Minute {
77
- return fmt.Sprintf("%.0fm", d.Minutes())
78
- }
79
- return fmt.Sprintf("%.0fs", d.Seconds())
80
-}
81
-
82
-func formatLastSeen(d time.Duration) string {
83
- if d <= 0 {
84
- return ""
85
- }
86
- if d >= time.Hour {
87
- hours := int(d / time.Hour)
88
- minutes := int((d % time.Hour) / time.Minute)
89
- if minutes > 0 {
90
- return fmt.Sprintf("%dh %dm", hours, minutes)
91
- }
92
- return fmt.Sprintf("%dh", hours)
93
- }
94
- if d >= time.Minute {
95
- minutes := int(d / time.Minute)
96
- seconds := int((d % time.Minute) / time.Second)
97
- if seconds > 0 {
98
- return fmt.Sprintf("%dm %ds", minutes, seconds)
99
- }
100
- return fmt.Sprintf("%dm", minutes)
101
- }
102
- return fmt.Sprintf("%ds", int(d/time.Second))
103
-}
104
-
105
-func formatISOTime(ts time.Time) string {
106
- if ts.IsZero() {
107
- return ""
108
- }
109
- return ts.UTC().Format(time.RFC3339)
110
-}
111
-
112
-func leaseLink(host string) string {
113
- host = strings.TrimSpace(host)
114
- if host == "" {
115
- return ""
116
- }
117
- return "https://" + host + "/"
118
-}
portal/admin/state.go
deleted
-114
@@ -1,114 +0,0 @@
1
-package admin
2
-
3
-import (
4
- "encoding/json"
5
- "errors"
6
- "os"
7
- "path/filepath"
8
-
9
- "github.com/gosuda/portal/v2/portal/policy"
10
-)
11
-
12
-type stateStore struct {
13
- path string
14
-}
15
-
16
-type settings struct {
17
- ApprovalMode policy.Mode `json:"approval_mode"`
18
- ApprovedLeases []string `json:"approved_leases,omitempty"`
19
- DeniedLeases []string `json:"denied_leases,omitempty"`
20
- BannedLeases []string `json:"banned_leases,omitempty"`
21
- BannedIPs []string `json:"banned_ips,omitempty"`
22
-}
23
-
24
-func newStateStore(path string) *stateStore {
25
- if path == "" {
26
- path = "admin_settings.json"
27
- }
28
- return &stateStore{path: path}
29
-}
30
-
31
-func (s *stateStore) Load(runtime *policy.Runtime) error {
32
- if s == nil || runtime == nil {
33
- return nil
34
- }
35
-
36
- root, name, err := s.openRoot()
37
- if err != nil {
38
- return err
39
- }
40
- defer root.Close()
41
-
42
- data, err := root.ReadFile(name)
43
- if err != nil {
44
- if errors.Is(err, os.ErrNotExist) {
45
- return nil
46
- }
47
- return err
48
- }
49
-
50
- var payload settings
51
- if err := json.Unmarshal(data, &payload); err != nil {
52
- return err
53
- }
54
- if payload.ApprovalMode != "" {
55
- if err := runtime.Approver().SetMode(payload.ApprovalMode); err != nil {
56
- return err
57
- }
58
- }
59
- for _, leaseID := range payload.ApprovedLeases {
60
- runtime.Approver().Approve(leaseID)
61
- }
62
- for _, leaseID := range payload.DeniedLeases {
63
- runtime.Approver().Deny(leaseID)
64
- }
65
- for _, leaseID := range payload.BannedLeases {
66
- runtime.BanLease(leaseID)
67
- }
68
- runtime.IPFilter().SetBannedIPs(payload.BannedIPs)
69
- return nil
70
-}
71
-
72
-func (s *stateStore) Save(runtime *policy.Runtime) error {
73
- if s == nil || runtime == nil {
74
- return nil
75
- }
76
-
77
- payload := settings{
78
- ApprovalMode: runtime.Approver().Mode(),
79
- ApprovedLeases: runtime.Approver().ApprovedLeases(),
80
- DeniedLeases: runtime.Approver().DeniedLeases(),
81
- BannedLeases: runtime.BannedLeases(),
82
- BannedIPs: runtime.IPFilter().BannedIPs(),
83
- }
84
- data, err := json.MarshalIndent(payload, "", " ")
85
- if err != nil {
86
- return err
87
- }
88
-
89
- root, name, err := s.openRoot()
90
- if err != nil {
91
- return err
92
- }
93
- defer root.Close()
94
- return root.WriteFile(name, data, 0o600)
95
-}
96
-
97
-func (s *stateStore) openRoot() (*os.Root, string, error) {
98
- path := s.path
99
- if path == "" {
100
- path = "admin_settings.json"
101
- }
102
-
103
- dir := filepath.Dir(path)
104
- name := filepath.Base(path)
105
- if dir == "" {
106
- dir = "."
107
- }
108
-
109
- root, err := os.OpenRoot(dir)
110
- if err != nil {
111
- return nil, "", err
112
- }
113
- return root, name, nil
114
-}
portal/admin/state_test.go
deleted
-51
@@ -1,51 +0,0 @@
1
-package admin
2
-
3
-import (
4
- "os"
5
- "path/filepath"
6
- "testing"
7
-
8
- "github.com/gosuda/portal/v2/portal/policy"
9
-)
10
-
11
-func TestStateStoreRoundTrip(t *testing.T) {
12
- tempDir := t.TempDir()
13
- runtime := policy.NewRuntime()
14
- if err := runtime.Approver().SetMode(policy.ModeManual); err != nil {
15
- t.Fatalf("SetMode() error = %v", err)
16
- }
17
- runtime.Approver().Approve("lease-approved")
18
- runtime.Approver().Deny("lease-denied")
19
- runtime.BanLease("lease-banned")
20
- runtime.IPFilter().BanIP("203.0.113.10")
21
-
22
- settingsPath := filepath.Join(tempDir, "admin_settings.json")
23
- store := newStateStore(settingsPath)
24
- if err := store.Save(runtime); err != nil {
25
- t.Fatalf("Save() error = %v", err)
26
- }
27
- if _, err := os.Stat(settingsPath); err != nil {
28
- t.Fatalf("Stat(admin_settings.json) error = %v", err)
29
- }
30
-
31
- loaded := policy.NewRuntime()
32
- if err := store.Load(loaded); err != nil {
33
- t.Fatalf("Load() error = %v", err)
34
- }
35
-
36
- if got := loaded.Approver().Mode(); got != policy.ModeManual {
37
- t.Fatalf("loaded approval mode = %q, want %q", got, policy.ModeManual)
38
- }
39
- if !loaded.Approver().IsApproved("lease-approved") {
40
- t.Fatalf("loaded runtime missing approved lease")
41
- }
42
- if !loaded.Approver().IsDenied("lease-denied") {
43
- t.Fatalf("loaded runtime missing denied lease")
44
- }
45
- if !loaded.IsLeaseBanned("lease-banned") {
46
- t.Fatalf("loaded runtime missing banned lease")
47
- }
48
- if !loaded.IPFilter().IsIPBanned("203.0.113.10") {
49
- t.Fatalf("loaded runtime missing banned IP")
50
- }
51
-}
portal/api_server.go
+20
-34
@@ -105,8 +105,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
105
return
106
}
107
108
- clientIP := s.clientIPFromRequest(r)
109
- if s.isClientIPBanned(clientIP) {
108
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
109
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
110
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
111
return
112
}
@@ -139,8 +139,8 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
139
return
140
}
141
142
- clientIP := s.clientIPFromRequest(r)
143
- if s.isClientIPBanned(clientIP) {
142
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
143
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
144
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
145
return
146
}
@@ -209,8 +209,8 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
209
210
leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
211
token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
212
- clientIP := s.clientIPFromRequest(r)
213
- if s.isClientIPBanned(clientIP) {
212
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
213
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
214
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
215
return
216
}
@@ -220,7 +220,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
220
utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
221
return
222
}
223
- if !s.registry.IsRoutable(lease) {
223
+ if !s.registry.policy.IsLeaseRoutable(lease.ID) {
224
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeLeaseRejected, "lease is not approved for routing")
225
return
226
}
@@ -281,7 +281,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
281
if strings.TrimSpace(req.ReverseToken) == "" {
282
return types.RegisterResponse{}, errors.New("reverse token is required")
283
}
284
- if s.isClientIPBanned(clientIP) {
284
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
285
return types.RegisterResponse{}, errIPBanned
286
}
287
hostname, err := utils.LeaseHostname(name, s.rootHost)
@@ -298,15 +298,17 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
298
now := time.Now()
299
expiresAt := now.Add(ttl)
300
record := &leaseRecord{
301
- ID: leaseID,
302
- Name: name,
303
- Hostname: hostname,
304
- Metadata: req.Metadata,
301
+ Lease: types.Lease{
302
+ ID: leaseID,
303
+ Name: name,
304
+ Hostname: hostname,
305
+ Metadata: req.Metadata,
306
+ ExpiresAt: expiresAt,
307
+ FirstSeenAt: now,
308
+ LastSeenAt: now,
309
+ ClientIP: clientIP,
310
+ },
311
ReverseToken: req.ReverseToken,
306
- ExpiresAt: expiresAt,
307
- FirstSeenAt: now,
308
- LastSeenAt: now,
309
- ClientIP: clientIP,
312
Broker: newLeaseBroker(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
313
}
314
@@ -319,12 +321,12 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
321
Hostname: hostname,
322
Metadata: record.Metadata,
323
ExpiresAt: expiresAt,
322
- ConnectURL: s.connectURL(),
324
+ ConnectURL: strings.TrimRight(s.cfg.PortalURL, "/") + types.PathSDKConnect,
325
}, nil
326
}
327
328
func (s *Server) renewLease(req types.RenewRequest, clientIP string) (types.RenewResponse, error) {
327
- if s.isClientIPBanned(clientIP) {
329
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
330
return types.RenewResponse{}, errIPBanned
331
}
332
@@ -371,22 +373,6 @@ func (s *Server) runAPIServer() error {
373
return err
374
}
375
374
-func (s *Server) connectURL() string {
375
- base := strings.TrimRight(s.cfg.PortalURL, "/")
376
- return base + types.PathSDKConnect
377
-}
378
-
379
-func (s *Server) clientIPFromRequest(r *http.Request) string {
380
- if r == nil {
381
- return ""
382
- }
383
- return policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders)
384
-}
385
-
386
-func (s *Server) isClientIPBanned(clientIP string) bool {
387
- return s.registry.IsClientIPBanned(clientIP)
388
-}
389
-
376
func newKeylessSignerHandler(apiTLS keyless.TLSMaterialConfig) (http.Handler, error) {
377
if len(apiTLS.KeyPEM) == 0 {
378
return nil, nil
portal/broker_test.go
+9
-7
@@ -11,6 +11,8 @@ import (
11
"github.com/gosuda/portal/v2/types"
12
)
13
14
+const brokerAsyncTestTimeout = 5 * time.Second
15
+
16
func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
17
t.Parallel()
18
@@ -37,7 +39,7 @@ func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
39
markerCh <- marker[0]
40
}()
41
40
- claimCtx, cancel := context.WithTimeout(context.Background(), time.Second)
42
+ claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
43
defer cancel()
44
45
claimed, err := broker.Claim(claimCtx)
@@ -55,7 +57,7 @@ func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
57
if marker != types.MarkerTLSStart {
58
t.Fatalf("marker = 0x%02x, want 0x%02x", marker, types.MarkerTLSStart)
59
}
58
- case <-time.After(time.Second):
60
+ case <-time.After(brokerAsyncTestTimeout):
61
t.Fatal("timed out waiting for activation marker")
62
}
63
}
@@ -83,7 +85,7 @@ func TestLeaseBrokerCloseUnblocksClaim(t *testing.T) {
85
t.Parallel()
86
87
broker := newLeaseBroker("lease-test", time.Hour, 2)
86
- claimCtx, cancel := context.WithTimeout(context.Background(), time.Second)
88
+ claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
89
defer cancel()
90
91
started := make(chan struct{})
@@ -108,7 +110,7 @@ func TestLeaseBrokerCloseUnblocksClaim(t *testing.T) {
110
if !errors.Is(err, errBrokerClosed) {
111
t.Fatalf("Claim() error = %v, want %v", err, errBrokerClosed)
112
}
111
- case <-time.After(time.Second):
113
+ case <-time.After(brokerAsyncTestTimeout):
114
t.Fatal("timed out waiting for closed claim")
115
}
116
}
@@ -125,7 +127,7 @@ func TestLeaseBrokerClaimWaitsForLateOffer(t *testing.T) {
127
})
128
129
session := newReverseSession(serverConn, time.Hour)
128
- claimCtx, cancel := context.WithTimeout(context.Background(), time.Second)
130
+ claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
131
defer cancel()
132
133
markerCh := make(chan byte, 1)
@@ -177,10 +179,10 @@ func TestLeaseBrokerClaimWaitsForLateOffer(t *testing.T) {
179
if marker != types.MarkerTLSStart {
180
t.Fatalf("marker = 0x%02x, want 0x%02x", marker, types.MarkerTLSStart)
181
}
180
- case <-time.After(time.Second):
182
+ case <-time.After(brokerAsyncTestTimeout):
183
t.Fatal("timed out waiting for activation marker")
184
}
183
- case <-time.After(time.Second):
185
+ case <-time.After(brokerAsyncTestTimeout):
186
t.Fatal("timed out waiting for claim")
187
}
188
}
portal/lease.go
+14
-77
@@ -31,13 +31,6 @@ func newLeaseRegistry(runtime *policy.Runtime) *leaseRegistry {
31
}
32
}
33
34
-func (r *leaseRegistry) PolicyRuntime() *policy.Runtime {
35
- if r == nil {
36
- return nil
37
- }
38
- return r.policy
39
-}
40
-
34
func (r *leaseRegistry) CloseAll() []*leaseRecord {
35
r.mu.Lock()
36
defer r.mu.Unlock()
@@ -65,22 +58,13 @@ func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration)
58
case <-ctx.Done():
59
return nil
60
case <-ticker.C:
68
- r.cleanupExpired(time.Now())
61
+ for _, lease := range r.removeExpired(time.Now()) {
62
+ lease.Broker.Close()
63
+ }
64
}
65
}
66
}
67
73
-func (r *leaseRegistry) List() []*leaseRecord {
74
- r.mu.RLock()
75
- defer r.mu.RUnlock()
76
-
77
- out := make([]*leaseRecord, 0, len(r.leaseByID))
78
- for _, record := range r.leaseByID {
79
- out = append(out, record)
80
- }
81
- return out
82
-}
83
-
68
func (r *leaseRegistry) Get(leaseID string) (*leaseRecord, bool) {
69
r.mu.RLock()
70
defer r.mu.RUnlock()
@@ -204,12 +188,6 @@ func (r *leaseRegistry) Touch(leaseID, clientIP string, now time.Time) *leaseRec
188
return record
189
}
190
207
-func (r *leaseRegistry) cleanupExpired(now time.Time) {
208
- for _, lease := range r.removeExpired(now) {
209
- lease.Broker.Close()
210
- }
211
-}
212
-
191
func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
192
r.mu.Lock()
193
defer r.mu.Unlock()
@@ -226,67 +204,26 @@ func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
204
return expired
205
}
206
229
-func (r *leaseRegistry) IsClientIPBanned(clientIP string) bool {
230
- return r.policy.IPFilter().IsIPBanned(clientIP)
231
-}
232
-
233
-func (r *leaseRegistry) IsRoutable(record *leaseRecord) bool {
207
+func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
208
if record == nil {
235
- return false
236
- }
237
- return r.policy.IsLeaseRoutable(record.ID)
238
-}
239
-
240
-func (r *leaseRegistry) Snapshot(record *leaseRecord) LeaseSnapshot {
241
- if record == nil {
242
- return LeaseSnapshot{}
209
+ return types.Lease{}
210
}
211
212
+ snapshot := record.Lease
213
+ snapshot.Metadata = snapshot.Metadata.Copy()
214
clientIP := record.ClientIP
246
- return LeaseSnapshot{
247
- ID: record.ID,
248
- Name: record.Name,
249
- ClientIP: clientIP,
250
- Hostname: record.Hostname,
251
- Metadata: record.Metadata,
252
- ExpiresAt: record.ExpiresAt,
253
- FirstSeenAt: record.FirstSeenAt,
254
- LastSeenAt: record.LastSeenAt,
255
- Ready: record.Broker.ReadyCount(),
256
- IsApproved: r.policy.EffectiveApproval(record.ID),
257
- IsBanned: r.policy.IsLeaseBanned(record.ID),
258
- IsDenied: r.policy.IsLeaseDenied(record.ID),
259
- IsIPBanned: r.policy.IPFilter().IsIPBanned(clientIP),
260
- }
215
+ snapshot.Ready = record.Broker.ReadyCount()
216
+ snapshot.IsApproved = r.policy.EffectiveApproval(record.ID)
217
+ snapshot.IsBanned = r.policy.IsLeaseBanned(record.ID)
218
+ snapshot.IsDenied = r.policy.IsLeaseDenied(record.ID)
219
+ snapshot.IsIPBanned = r.policy.IPFilter().IsIPBanned(clientIP)
220
+ return snapshot
221
}
222
223
type leaseRecord struct {
264
- ExpiresAt time.Time
265
- FirstSeenAt time.Time
266
- LastSeenAt time.Time
224
+ types.Lease
225
Broker *leaseBroker
268
- ID string
269
- Name string
226
ReverseToken string
271
- ClientIP string
272
- Hostname string
273
- Metadata types.LeaseMetadata
274
-}
275
-
276
-type LeaseSnapshot struct {
277
- ExpiresAt time.Time
278
- FirstSeenAt time.Time
279
- LastSeenAt time.Time
280
- ID string
281
- Name string
282
- ClientIP string
283
- Hostname string
284
- Metadata types.LeaseMetadata
285
- Ready int
286
- IsApproved bool
287
- IsBanned bool
288
- IsDenied bool
289
- IsIPBanned bool
227
}
228
229
type routeTable struct {
portal/lease_test.go
+37
-24
@@ -7,6 +7,7 @@ import (
7
"time"
8
9
"github.com/gosuda/portal/v2/portal/policy"
10
+ "github.com/gosuda/portal/v2/types"
11
)
12
13
func TestLeaseRegistryLifecycle(t *testing.T) {
@@ -15,10 +16,12 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
16
runtime := policy.NewRuntime()
17
registry := newLeaseRegistry(runtime)
18
record := &leaseRecord{
18
- ID: "lease_1",
19
- Hostname: "demo.example.com",
19
+ Lease: types.Lease{
20
+ ID: "lease_1",
21
+ Hostname: "demo.example.com",
22
+ ExpiresAt: time.Now().Add(30 * time.Second),
23
+ },
24
ReverseToken: "tok_1",
21
- ExpiresAt: time.Now().Add(30 * time.Second),
25
}
26
27
if err := registry.Register(record); err != nil {
@@ -62,10 +65,12 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
65
66
registry := newLeaseRegistry(policy.NewRuntime())
67
wildcardLease := &leaseRecord{
65
- ID: "lease_wildcard",
66
- Hostname: "*.example.com",
68
+ Lease: types.Lease{
69
+ ID: "lease_wildcard",
70
+ Hostname: "*.example.com",
71
+ ExpiresAt: time.Now().Add(30 * time.Second),
72
+ },
73
ReverseToken: "tok_wildcard",
68
- ExpiresAt: time.Now().Add(30 * time.Second),
74
}
75
if err := registry.Register(wildcardLease); err != nil {
76
t.Fatalf("Register(wildcard) error = %v", err)
@@ -79,10 +84,12 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
84
}
85
86
conflict := &leaseRecord{
82
- ID: "lease_conflict",
83
- Hostname: "*.example.com",
87
+ Lease: types.Lease{
88
+ ID: "lease_conflict",
89
+ Hostname: "*.example.com",
90
+ ExpiresAt: time.Now().Add(30 * time.Second),
91
+ },
92
ReverseToken: "tok_conflict",
85
- ExpiresAt: time.Now().Add(30 * time.Second),
93
}
94
err := registry.Register(conflict)
95
if !errors.Is(err, errHostnameConflict) {
@@ -100,20 +107,22 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
107
108
registry := newLeaseRegistry(runtime)
109
record := &leaseRecord{
103
- ID: "lease_policy",
104
- Name: "demo",
105
- Hostname: "demo.example.com",
110
+ Lease: types.Lease{
111
+ ID: "lease_policy",
112
+ Name: "demo",
113
+ Hostname: "demo.example.com",
114
+ ExpiresAt: time.Now().Add(30 * time.Second),
115
+ ClientIP: "203.0.113.20",
116
+ },
117
ReverseToken: "tok_policy",
107
- ExpiresAt: time.Now().Add(30 * time.Second),
108
- ClientIP: "203.0.113.20",
118
Broker: newLeaseBroker("lease_policy", time.Minute, 1),
119
}
120
if err := registry.Register(record); err != nil {
121
t.Fatalf("Register() error = %v", err)
122
}
123
115
- if registry.IsRoutable(record) {
116
- t.Fatal("IsRoutable() = true, want false before approval")
124
+ if registry.policy.IsLeaseRoutable(record.ID) {
125
+ t.Fatal("policy.IsLeaseRoutable() = true, want false before approval")
126
}
127
128
snapshot := registry.Snapshot(record)
@@ -125,8 +134,8 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
134
}
135
136
runtime.Approver().Approve(record.ID)
128
- if !registry.IsRoutable(record) {
129
- t.Fatal("IsRoutable() = false, want true after approval")
137
+ if !registry.policy.IsLeaseRoutable(record.ID) {
138
+ t.Fatal("policy.IsLeaseRoutable() = false, want true after approval")
139
}
140
141
snapshot = registry.Snapshot(record)
@@ -140,23 +149,27 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
149
150
registry := newLeaseRegistry(policy.NewRuntime())
151
record := &leaseRecord{
143
- ID: "lease_expired",
144
- Hostname: "expired.example.com",
152
+ Lease: types.Lease{
153
+ ID: "lease_expired",
154
+ Hostname: "expired.example.com",
155
+ ExpiresAt: time.Now().Add(-time.Second),
156
+ },
157
ReverseToken: "tok_expired",
146
- ExpiresAt: time.Now().Add(-time.Second),
158
Broker: newLeaseBroker("lease_expired", time.Minute, 1),
159
}
160
if err := registry.Register(record); err != nil {
161
t.Fatalf("Register() error = %v", err)
162
}
163
153
- registry.cleanupExpired(time.Now())
164
+ for _, lease := range registry.removeExpired(time.Now()) {
165
+ lease.Broker.Close()
166
+ }
167
168
if _, ok := registry.Lookup("expired.example.com"); ok {
156
- t.Fatal("Lookup() after cleanupExpired() = true, want false")
169
+ t.Fatal("Lookup() after removeExpired() = true, want false")
170
}
171
if _, err := record.Broker.Claim(context.Background()); !errors.Is(err, errBrokerClosed) {
159
- t.Fatalf("Claim() after cleanupExpired() error = %v, want %v", err, errBrokerClosed)
172
+ t.Fatalf("Claim() after removeExpired() error = %v, want %v", err, errBrokerClosed)
173
}
174
}
175
portal/policy/approver.go
+31
@@ -2,6 +2,7 @@ package policy
2
3
import (
4
"fmt"
5
+ "strings"
6
"sync"
7
)
8
@@ -102,3 +103,33 @@ func (a *Approver) DeniedLeases() []string {
103
}
104
return out
105
}
106
+
107
+func (a *Approver) SetDecisions(approvedLeases, deniedLeases []string) {
108
+ if a == nil {
109
+ return
110
+ }
111
+
112
+ approved := make(map[string]struct{}, len(approvedLeases))
113
+ for _, leaseID := range approvedLeases {
114
+ leaseID = strings.TrimSpace(leaseID)
115
+ if leaseID == "" {
116
+ continue
117
+ }
118
+ approved[leaseID] = struct{}{}
119
+ }
120
+
121
+ denied := make(map[string]struct{}, len(deniedLeases))
122
+ for _, leaseID := range deniedLeases {
123
+ leaseID = strings.TrimSpace(leaseID)
124
+ if leaseID == "" {
125
+ continue
126
+ }
127
+ delete(approved, leaseID)
128
+ denied[leaseID] = struct{}{}
129
+ }
130
+
131
+ a.mu.Lock()
132
+ a.approvedLeases = approved
133
+ a.deniedLeases = denied
134
+ a.mu.Unlock()
135
+}
portal/policy/authenticator.go
deleted
-232
@@ -1,232 +0,0 @@
1
-package policy
2
-
3
-import (
4
- "crypto/rand"
5
- "crypto/subtle"
6
- "encoding/hex"
7
- "fmt"
8
- "io"
9
- "sort"
10
- "strings"
11
- "sync"
12
- "time"
13
-
14
- "github.com/rs/zerolog/log"
15
-)
16
-
17
-const (
18
- maxFailedAttempts = 3
19
- lockDuration = 1 * time.Minute
20
- sessionDuration = 24 * time.Hour
21
- failedLoginRetention = 15 * time.Minute
22
- failedLoginSweepWindow = 1 * time.Minute
23
- maxFailedLoginEntries = 4096
24
-)
25
-
26
-type Authenticator struct {
27
- lastSweepAt time.Time
28
- failedLogins map[string]*loginAttempt
29
- sessions map[string]time.Time
30
- secretKey string
31
- mu sync.RWMutex
32
-}
33
-
34
-type loginAttempt struct {
35
- lockedAt time.Time
36
- lastSeenAt time.Time
37
- count int
38
-}
39
-
40
-func NewAuthenticator(secretKey string) *Authenticator {
41
- secretKey = strings.TrimSpace(secretKey)
42
- if secretKey == "" {
43
- generated, err := generateSecretKey()
44
- if err != nil {
45
- log.Fatal().Err(err).Msg("generate admin secret key")
46
- }
47
- secretKey = generated
48
- log.Warn().
49
- Str("component", "portal-admin").
50
- Str("admin_secret_key", secretKey).
51
- Msg("generated random admin secret key because ADMIN_SECRET_KEY was empty")
52
- }
53
-
54
- return &Authenticator{
55
- secretKey: secretKey,
56
- failedLogins: make(map[string]*loginAttempt),
57
- sessions: make(map[string]time.Time),
58
- }
59
-}
60
-
61
-func (a *Authenticator) AuthEnabled() bool {
62
- return a != nil && a.secretKey != ""
63
-}
64
-
65
-func (a *Authenticator) ValidateKey(key string) bool {
66
- if !a.AuthEnabled() {
67
- return false
68
- }
69
- return subtle.ConstantTimeCompare([]byte(a.secretKey), []byte(key)) == 1
70
-}
71
-
72
-func (a *Authenticator) IsIPLocked(ip string) bool {
73
- a.mu.RLock()
74
- defer a.mu.RUnlock()
75
- attempt := a.failedLogins[ip]
76
- return lockRemaining(attempt, time.Now()) > 0
77
-}
78
-
79
-func (a *Authenticator) LockRemainingSeconds(ip string) int {
80
- a.mu.RLock()
81
- defer a.mu.RUnlock()
82
- return int(lockRemaining(a.failedLogins[ip], time.Now()).Seconds())
83
-}
84
-
85
-func (a *Authenticator) RecordFailedLogin(ip string) bool {
86
- a.mu.Lock()
87
- defer a.mu.Unlock()
88
-
89
- now := time.Now()
90
- a.maybeSweepFailedLoginsLocked(now)
91
-
92
- attempt := a.failedLogins[ip]
93
- if attempt == nil {
94
- attempt = &loginAttempt{}
95
- a.failedLogins[ip] = attempt
96
- }
97
- if attempt.count >= maxFailedAttempts && now.Sub(attempt.lockedAt) >= lockDuration {
98
- attempt.count = 0
99
- }
100
-
101
- attempt.count++
102
- attempt.lastSeenAt = now
103
- locked := false
104
- if attempt.count >= maxFailedAttempts {
105
- attempt.lockedAt = now
106
- locked = true
107
- }
108
-
109
- a.enforceFailedLoginCapLocked()
110
- return locked
111
-}
112
-
113
-func (a *Authenticator) ResetFailedLogin(ip string) {
114
- a.mu.Lock()
115
- defer a.mu.Unlock()
116
- delete(a.failedLogins, ip)
117
-}
118
-
119
-func (a *Authenticator) CreateSession() (string, error) {
120
- token, err := generateToken()
121
- if err != nil {
122
- return "", err
123
- }
124
-
125
- a.mu.Lock()
126
- defer a.mu.Unlock()
127
- a.sessions[token] = time.Now().Add(sessionDuration)
128
- a.cleanupExpiredSessionsLocked()
129
- return token, nil
130
-}
131
-
132
-func (a *Authenticator) ValidateSession(token string) bool {
133
- if token == "" {
134
- return false
135
- }
136
-
137
- a.mu.RLock()
138
- defer a.mu.RUnlock()
139
-
140
- expiry, ok := a.sessions[token]
141
- return ok && time.Now().Before(expiry)
142
-}
143
-
144
-func (a *Authenticator) DeleteSession(token string) {
145
- a.mu.Lock()
146
- defer a.mu.Unlock()
147
- delete(a.sessions, token)
148
-}
149
-
150
-func (a *Authenticator) maybeSweepFailedLoginsLocked(now time.Time) {
151
- if !a.lastSweepAt.IsZero() && now.Sub(a.lastSweepAt) < failedLoginSweepWindow {
152
- return
153
- }
154
- for ip, attempt := range a.failedLogins {
155
- lastSeenAt := attempt.lastSeenAt
156
- if lastSeenAt.IsZero() {
157
- lastSeenAt = attempt.lockedAt
158
- }
159
- if lastSeenAt.IsZero() || now.Sub(lastSeenAt) >= failedLoginRetention {
160
- delete(a.failedLogins, ip)
161
- }
162
- }
163
- a.lastSweepAt = now
164
-}
165
-
166
-func (a *Authenticator) enforceFailedLoginCapLocked() {
167
- if len(a.failedLogins) <= maxFailedLoginEntries {
168
- return
169
- }
170
-
171
- type failedEntry struct {
172
- lastSeenAt time.Time
173
- ip string
174
- }
175
-
176
- entries := make([]failedEntry, 0, len(a.failedLogins))
177
- for ip, attempt := range a.failedLogins {
178
- lastSeenAt := attempt.lastSeenAt
179
- if lastSeenAt.IsZero() {
180
- lastSeenAt = attempt.lockedAt
181
- }
182
- entries = append(entries, failedEntry{ip: ip, lastSeenAt: lastSeenAt})
183
- }
184
- sort.Slice(entries, func(i, j int) bool {
185
- return entries[i].lastSeenAt.Before(entries[j].lastSeenAt)
186
- })
187
-
188
- for i := range len(a.failedLogins) - maxFailedLoginEntries {
189
- delete(a.failedLogins, entries[i].ip)
190
- }
191
-}
192
-
193
-func (a *Authenticator) cleanupExpiredSessionsLocked() {
194
- now := time.Now()
195
- for token, expiry := range a.sessions {
196
- if now.After(expiry) {
197
- delete(a.sessions, token)
198
- }
199
- }
200
-}
201
-
202
-func generateToken() (string, error) {
203
- return generateTokenFromReader(rand.Reader)
204
-}
205
-
206
-func generateSecretKey() (string, error) {
207
- buf := make([]byte, 16)
208
- if _, err := io.ReadFull(rand.Reader, buf); err != nil {
209
- return "", fmt.Errorf("read random admin secret key bytes: %w", err)
210
- }
211
- return hex.EncodeToString(buf), nil
212
-}
213
-
214
-func generateTokenFromReader(reader io.Reader) (string, error) {
215
- buf := make([]byte, 32)
216
- if _, err := io.ReadFull(reader, buf); err != nil {
217
- return "", fmt.Errorf("read random session token bytes: %w", err)
218
- }
219
- return hex.EncodeToString(buf), nil
220
-}
221
-
222
-func lockRemaining(attempt *loginAttempt, now time.Time) time.Duration {
223
- if attempt == nil || attempt.count < maxFailedAttempts {
224
- return 0
225
- }
226
-
227
- remaining := lockDuration - now.Sub(attempt.lockedAt)
228
- if remaining <= 0 {
229
- return 0
230
- }
231
- return remaining
232
-}
portal/policy/authenticator_test.go
deleted
-18
@@ -1,18 +0,0 @@
1
-package policy
2
-
3
-import "testing"
4
-
5
-func TestNewAuthenticatorGeneratesSecretWhenEmpty(t *testing.T) {
6
- t.Parallel()
7
-
8
- auth := NewAuthenticator("")
9
- if auth == nil {
10
- t.Fatal("NewAuthenticator() = nil")
11
- }
12
- if !auth.AuthEnabled() {
13
- t.Fatal("AuthEnabled() = false, want true")
14
- }
15
- if auth.secretKey == "" {
16
- t.Fatal("secretKey = empty, want generated value")
17
- }
18
-}
portal/policy/ip_filter.go
-171
@@ -1,20 +1,11 @@
1
package policy
2
3
import (
4
- "fmt"
5
- "net"
6
- "net/http"
4
"slices"
5
"strings"
6
"sync"
7
)
8
12
-const (
13
- xForwardedForHeader = "X-Forwarded-For"
14
- xRealIPHeader = "X-Real-IP"
15
- xForwardedProto = "X-Forwarded-Proto"
16
-)
17
-
9
type IPFilter struct {
10
bannedIPs map[string]struct{}
11
leaseToIP map[string]string
@@ -22,22 +13,6 @@ type IPFilter struct {
13
mu sync.RWMutex
14
}
15
25
-var (
26
- trustedProxyMu sync.RWMutex
27
- trustedProxyCIDRs []*net.IPNet
28
- defaultProxyCIDRs = mustParseProxyCIDRs(
29
- "127.0.0.0/8",
30
- "10.0.0.0/8",
31
- "172.16.0.0/12",
32
- "192.168.0.0/16",
33
- "169.254.0.0/16",
34
- "100.64.0.0/10",
35
- "::1/128",
36
- "fc00::/7",
37
- "fe80::/10",
38
- )
39
-)
40
-
16
func NewIPFilter() *IPFilter {
17
return &IPFilter{
18
bannedIPs: make(map[string]struct{}),
@@ -46,109 +21,6 @@ func NewIPFilter() *IPFilter {
21
}
22
}
23
49
-func ParseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) {
50
- raw = strings.TrimSpace(raw)
51
- if raw == "" {
52
- return nil, nil
53
- }
54
-
55
- parts := strings.Split(raw, ",")
56
- cidrs := make([]*net.IPNet, 0, len(parts))
57
- seen := make(map[string]struct{}, len(parts))
58
- for _, part := range parts {
59
- part = strings.TrimSpace(part)
60
- if part == "" {
61
- continue
62
- }
63
- _, network, err := net.ParseCIDR(part)
64
- if err != nil {
65
- return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", part, err)
66
- }
67
- key := network.String()
68
- if _, ok := seen[key]; ok {
69
- continue
70
- }
71
- seen[key] = struct{}{}
72
- cidrs = append(cidrs, network)
73
- }
74
- return cidrs, nil
75
-}
76
-
77
-func SetTrustedProxyCIDRs(cidrs []*net.IPNet) {
78
- trustedProxyMu.Lock()
79
- defer trustedProxyMu.Unlock()
80
- if len(cidrs) == 0 {
81
- trustedProxyCIDRs = nil
82
- return
83
- }
84
- trustedProxyCIDRs = append(make([]*net.IPNet, 0, len(cidrs)), cidrs...)
85
-}
86
-
87
-func IsTrustedProxyRemoteAddr(remoteAddr string) bool {
88
- remoteIP := parseRemoteAddrIP(remoteAddr)
89
- if remoteIP == nil {
90
- return false
91
- }
92
-
93
- trustedProxyMu.RLock()
94
- defer trustedProxyMu.RUnlock()
95
- networks := trustedProxyCIDRs
96
- if len(networks) == 0 {
97
- networks = defaultProxyCIDRs
98
- }
99
- for _, network := range networks {
100
- if network != nil && network.Contains(remoteIP) {
101
- return true
102
- }
103
- }
104
- return false
105
-}
106
-
107
-func ExtractClientIP(r *http.Request, trustProxyHeaders bool) string {
108
- if r == nil {
109
- return ""
110
- }
111
-
112
- if trustProxyHeaders && IsTrustedProxyRemoteAddr(r.RemoteAddr) {
113
- if xff := r.Header.Get(xForwardedForHeader); xff != "" {
114
- if before, _, ok := strings.Cut(xff, ","); ok {
115
- if ip := normalizeClientIPCandidate(before); ip != "" {
116
- return ip
117
- }
118
- } else if ip := normalizeClientIPCandidate(xff); ip != "" {
119
- return ip
120
- }
121
- }
122
- if xri := r.Header.Get(xRealIPHeader); xri != "" {
123
- if ip := normalizeClientIPCandidate(xri); ip != "" {
124
- return ip
125
- }
126
- }
127
- }
128
-
129
- host, _, err := net.SplitHostPort(r.RemoteAddr)
130
- if err != nil {
131
- return strings.TrimSpace(r.RemoteAddr)
132
- }
133
- if normalized := normalizeClientIPCandidate(host); normalized != "" {
134
- return normalized
135
- }
136
- return strings.TrimSpace(host)
137
-}
138
-
139
-func IsSecureForwardedRequest(r *http.Request, trustProxyHeaders bool) bool {
140
- if r == nil {
141
- return false
142
- }
143
- if r.TLS != nil {
144
- return true
145
- }
146
- if !trustProxyHeaders || !IsTrustedProxyRemoteAddr(r.RemoteAddr) {
147
- return false
148
- }
149
- return strings.EqualFold(strings.TrimSpace(r.Header.Get(xForwardedProto)), "https")
150
-}
151
-
24
func (f *IPFilter) BanIP(ip string) {
25
f.mu.Lock()
26
defer f.mu.Unlock()
@@ -246,46 +118,3 @@ func (f *IPFilter) removeLeaseFromIPLocked(leaseID, ip string) {
118
delete(f.ipToLeases, ip)
119
}
120
}
249
-
250
-func normalizeClientIPCandidate(raw string) string {
251
- candidate := strings.TrimSpace(raw)
252
- if candidate == "" {
253
- return ""
254
- }
255
- if ip := net.ParseIP(candidate); ip != nil {
256
- return candidate
257
- }
258
- host, _, err := net.SplitHostPort(candidate)
259
- if err != nil {
260
- return ""
261
- }
262
- host = strings.TrimSpace(host)
263
- if host == "" || net.ParseIP(host) == nil {
264
- return ""
265
- }
266
- return host
267
-}
268
-
269
-func parseRemoteAddrIP(remoteAddr string) net.IP {
270
- remoteAddr = strings.TrimSpace(remoteAddr)
271
- if remoteAddr == "" {
272
- return nil
273
- }
274
- host := remoteAddr
275
- if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
276
- host = parsedHost
277
- }
278
- return net.ParseIP(strings.TrimSpace(host))
279
-}
280
-
281
-func mustParseProxyCIDRs(values ...string) []*net.IPNet {
282
- cidrs := make([]*net.IPNet, 0, len(values))
283
- for _, value := range values {
284
- _, network, err := net.ParseCIDR(value)
285
- if err != nil {
286
- panic(err)
287
- }
288
- cidrs = append(cidrs, network)
289
- }
290
- return cidrs
291
-}
portal/policy/proxy_trust.go
new
+125
@@ -0,0 +1,125 @@
1
+package policy
2
+
3
+import (
4
+ "net"
5
+ "net/http"
6
+ "strings"
7
+)
8
+
9
+var defaultTrustedProxyCIDRs = mustParseTrustedProxyCIDRs(
10
+ "127.0.0.0/8",
11
+ "10.0.0.0/8",
12
+ "172.16.0.0/12",
13
+ "192.168.0.0/16",
14
+ "169.254.0.0/16",
15
+ "100.64.0.0/10",
16
+ "::1/128",
17
+ "fc00::/7",
18
+ "fe80::/10",
19
+)
20
+
21
+func IsTrustedProxyRemoteAddr(remoteAddr string, trustedProxyCIDRs []*net.IPNet) bool {
22
+ remoteIP := parseRemoteAddrIP(remoteAddr)
23
+ if remoteIP == nil {
24
+ return false
25
+ }
26
+
27
+ networks := trustedProxyCIDRs
28
+ if len(networks) == 0 {
29
+ networks = defaultTrustedProxyCIDRs
30
+ }
31
+ for _, network := range networks {
32
+ if network != nil && network.Contains(remoteIP) {
33
+ return true
34
+ }
35
+ }
36
+ return false
37
+}
38
+
39
+func ExtractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxyCIDRs []*net.IPNet) string {
40
+ if r == nil {
41
+ return ""
42
+ }
43
+
44
+ if trustProxyHeaders && IsTrustedProxyRemoteAddr(r.RemoteAddr, trustedProxyCIDRs) {
45
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
46
+ if before, _, ok := strings.Cut(xff, ","); ok {
47
+ if ip := normalizeClientIPCandidate(before); ip != "" {
48
+ return ip
49
+ }
50
+ } else if ip := normalizeClientIPCandidate(xff); ip != "" {
51
+ return ip
52
+ }
53
+ }
54
+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
55
+ if ip := normalizeClientIPCandidate(xri); ip != "" {
56
+ return ip
57
+ }
58
+ }
59
+ }
60
+
61
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
62
+ if err != nil {
63
+ return strings.TrimSpace(r.RemoteAddr)
64
+ }
65
+ if normalized := normalizeClientIPCandidate(host); normalized != "" {
66
+ return normalized
67
+ }
68
+ return strings.TrimSpace(host)
69
+}
70
+
71
+func IsSecureForwardedRequest(r *http.Request, trustProxyHeaders bool, trustedProxyCIDRs []*net.IPNet) bool {
72
+ if r == nil {
73
+ return false
74
+ }
75
+ if r.TLS != nil {
76
+ return true
77
+ }
78
+ if !trustProxyHeaders || !IsTrustedProxyRemoteAddr(r.RemoteAddr, trustedProxyCIDRs) {
79
+ return false
80
+ }
81
+ return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
82
+}
83
+
84
+func parseRemoteAddrIP(remoteAddr string) net.IP {
85
+ remoteAddr = strings.TrimSpace(remoteAddr)
86
+ if remoteAddr == "" {
87
+ return nil
88
+ }
89
+ host := remoteAddr
90
+ if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
91
+ host = parsedHost
92
+ }
93
+ return net.ParseIP(strings.TrimSpace(host))
94
+}
95
+
96
+func mustParseTrustedProxyCIDRs(values ...string) []*net.IPNet {
97
+ cidrs := make([]*net.IPNet, 0, len(values))
98
+ for _, value := range values {
99
+ _, network, err := net.ParseCIDR(value)
100
+ if err != nil {
101
+ panic(err)
102
+ }
103
+ cidrs = append(cidrs, network)
104
+ }
105
+ return cidrs
106
+}
107
+
108
+func normalizeClientIPCandidate(raw string) string {
109
+ candidate := strings.TrimSpace(raw)
110
+ if candidate == "" {
111
+ return ""
112
+ }
113
+ if ip := net.ParseIP(candidate); ip != nil {
114
+ return candidate
115
+ }
116
+ host, _, err := net.SplitHostPort(candidate)
117
+ if err != nil {
118
+ return ""
119
+ }
120
+ host = strings.TrimSpace(host)
121
+ if host == "" || net.ParseIP(host) == nil {
122
+ return ""
123
+ }
124
+ return host
125
+}
portal/policy/runtime.go
+19
@@ -80,6 +80,25 @@ func (r *Runtime) BannedLeases() []string {
80
return out
81
}
82
83
+func (r *Runtime) SetBannedLeases(leaseIDs []string) {
84
+ if r == nil {
85
+ return
86
+ }
87
+
88
+ bannedLeases := make(map[string]struct{}, len(leaseIDs))
89
+ for _, leaseID := range leaseIDs {
90
+ leaseID = strings.TrimSpace(leaseID)
91
+ if leaseID == "" {
92
+ continue
93
+ }
94
+ bannedLeases[leaseID] = struct{}{}
95
+ }
96
+
97
+ r.mu.Lock()
98
+ r.bannedLeases = bannedLeases
99
+ r.mu.Unlock()
100
+}
101
+
102
func (r *Runtime) EffectiveApproval(leaseID string) bool {
103
if r == nil || r.approver == nil {
104
return true
portal/server.go
+18
-22
@@ -16,6 +16,7 @@ import (
16
"github.com/gosuda/portal/v2/portal/acme"
17
"github.com/gosuda/portal/v2/portal/keyless"
18
"github.com/gosuda/portal/v2/portal/policy"
19
+ "github.com/gosuda/portal/v2/types"
20
"github.com/gosuda/portal/v2/utils"
21
)
22
@@ -34,7 +35,7 @@ type ServerConfig struct {
35
ACME acme.Config
36
APIListenAddr string
37
SNIListenAddr string
37
- TrustedProxyCIDRs string
38
+ TrustedProxyCIDRs []*net.IPNet
39
LeaseTTL time.Duration
40
ClaimTimeout time.Duration
41
IdleKeepaliveInterval time.Duration
@@ -69,20 +70,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
70
cfg.IdleKeepaliveInterval = utils.DurationOrDefault(cfg.IdleKeepaliveInterval, defaultIdleKeepalive)
71
cfg.ReadyQueueLimit = utils.IntOrDefault(cfg.ReadyQueueLimit, defaultReadyQueueLimit)
72
cfg.ClientHelloTimeout = utils.DurationOrDefault(cfg.ClientHelloTimeout, defaultClientHelloWait)
72
- trustedProxyCIDRs, err := policy.ParseTrustedProxyCIDRs(cfg.TrustedProxyCIDRs)
73
- if err != nil {
74
- return nil, fmt.Errorf("parse trusted proxy cidrs: %w", err)
75
- }
76
- policy.SetTrustedProxyCIDRs(trustedProxyCIDRs)
73
rootHost := utils.PortalRootHost(cfg.PortalURL)
74
if rootHost == "" {
75
return nil, errors.New("root host is required")
76
}
81
-
77
return &Server{
78
cfg: cfg,
79
rootHost: rootHost,
85
- registry: newLeaseRegistry(policy.NewRuntime()),
80
+ registry: newLeaseRegistry(nil),
81
}, nil
82
}
83
@@ -177,7 +172,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
172
}
173
174
func (s *Server) PolicyRuntime() *policy.Runtime {
180
- return s.registry.PolicyRuntime()
175
+ if s == nil || s.registry == nil {
176
+ return nil
177
+ }
178
+ return s.registry.policy
179
}
180
181
func (s *Server) APIAddr() string {
@@ -194,21 +192,19 @@ func (s *Server) SNIAddr() string {
192
return s.sniListener.Addr().String()
193
}
194
197
-func (s *Server) GetLease(leaseID string) (LeaseSnapshot, bool) {
198
- record, ok := s.registry.Get(leaseID)
199
- if !ok {
200
- return LeaseSnapshot{}, false
201
- }
202
- return s.registry.Snapshot(record), true
203
-}
195
+func (s *Server) LeaseSnapshots() []types.Lease {
196
+ s.registry.mu.RLock()
197
+ defer s.registry.mu.RUnlock()
198
205
-func (s *Server) ListLeases() []LeaseSnapshot {
206
- records := s.registry.List()
207
- out := make([]LeaseSnapshot, 0, len(records))
199
+ records := make([]*leaseRecord, 0, len(s.registry.leaseByID))
200
+ for _, record := range s.registry.leaseByID {
201
+ records = append(records, record)
202
+ }
203
+ snapshots := make([]types.Lease, 0, len(records))
204
for _, record := range records {
209
- out = append(out, s.registry.Snapshot(record))
205
+ snapshots = append(snapshots, s.registry.Snapshot(record))
206
}
211
- return out
207
+ return snapshots
208
}
209
210
func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
@@ -288,7 +284,7 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
284
_ = wrappedConn.Close()
285
return
286
}
291
- if !s.registry.IsRoutable(record) {
287
+ if !s.registry.policy.IsLeaseRoutable(record.ID) {
288
_ = wrappedConn.Close()
289
return
290
}
portal/server_test.go
+5
-19
@@ -97,21 +97,6 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
97
}
98
}
99
100
-func TestNewServerRejectsInvalidTrustedProxyCIDRs(t *testing.T) {
101
- t.Parallel()
102
-
103
- _, err := NewServer(ServerConfig{
104
- PortalURL: "https://portal.example.com",
105
- TrustedProxyCIDRs: "not-a-cidr",
106
- })
107
- if err == nil {
108
- t.Fatal("NewServer() error = nil, want invalid trusted proxy cidr error")
109
- }
110
- if !strings.Contains(err.Error(), "parse trusted proxy cidrs") {
111
- t.Fatalf("NewServer() error = %v, want trusted proxy parse error", err)
112
- }
113
-}
114
-
100
func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
101
t.Parallel()
102
@@ -135,15 +120,16 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
120
t.Fatalf("registerLease() hostname = %q, want %q", resp.Hostname, wantHostname)
121
}
122
138
- snapshot, ok := server.GetLease(resp.LeaseID)
123
+ record, ok := server.registry.Get(resp.LeaseID)
124
if !ok {
140
- t.Fatal("GetLease() = false, want registered lease")
125
+ t.Fatal("registry.Get() = false, want registered lease")
126
}
127
+ snapshot := server.registry.Snapshot(record)
128
if snapshot.Name != "demo-app" {
143
- t.Fatalf("GetLease().Name = %q, want %q", snapshot.Name, "demo-app")
129
+ t.Fatalf("Snapshot().Name = %q, want %q", snapshot.Name, "demo-app")
130
}
131
if snapshot.Hostname != wantHostname {
146
- t.Fatalf("GetLease().Hostname = %q, want %q", snapshot.Hostname, wantHostname)
132
+ t.Fatalf("Snapshot().Hostname = %q, want %q", snapshot.Hostname, wantHostname)
133
}
134
}
135
sdk/expose.go
+4
-4
@@ -20,9 +20,9 @@ import (
20
// Exposure owns the lifecycle of one or more relay listeners and accepts
21
// traffic from all of them through one net.Listener.
22
type Exposure struct {
23
- listener net.Listener
23
+ listener net.Listener
24
listeners []*Listener
25
- done chan struct{}
25
+ done chan struct{}
26
27
closeOnce sync.Once
28
connSeq atomic.Uint64
@@ -73,9 +73,9 @@ func Expose(ctx context.Context, relayUrls []string, name string, metadata types
73
}
74
75
exposure := &Exposure{
76
- listener: merged,
76
+ listener: merged,
77
listeners: listeners,
78
- done: make(chan struct{}),
78
+ done: make(chan struct{}),
79
}
80
go exposure.monitorStartupCounts(ctx)
81
types/api.go
+3
-47
@@ -53,47 +53,6 @@ func (e *APIRequestError) Is(target error) bool {
53
return true
54
}
55
56
-type LeaseMetadata struct {
57
- Description string `json:"description,omitempty"`
58
- Owner string `json:"owner,omitempty"`
59
- Thumbnail string `json:"thumbnail,omitempty"`
60
- Tags []string `json:"tags,omitempty"`
61
- Hide bool `json:"hide,omitempty"`
62
-}
63
-
64
-func (m LeaseMetadata) Copy() LeaseMetadata {
65
- return LeaseMetadata{
66
- Description: m.Description,
67
- Owner: m.Owner,
68
- Thumbnail: m.Thumbnail,
69
- Tags: append([]string(nil), m.Tags...),
70
- Hide: m.Hide,
71
- }
72
-}
73
-
74
-// LeaseRow is the shared lease-list contract used by the relay SSR bootstrap
75
-// payload and the admin lease API.
76
-type LeaseRow struct {
77
- TTL string
78
- Metadata string
79
- Kind string
80
- IP string
81
- DNS string
82
- LastSeen string
83
- LastSeenISO string
84
- FirstSeenISO string
85
- Name string
86
- Peer string
87
- Link string
88
- BPS int64
89
- Hide bool
90
- StaleRed bool
91
- IsApproved bool
92
- IsDenied bool
93
- Connected bool
94
- IsIPBanned bool
95
-}
96
-
56
type RegisterRequest struct {
57
Name string `json:"name"`
58
ReverseToken string `json:"reverse_token"`
@@ -134,9 +93,7 @@ type AdminLoginRequest struct {
93
}
94
95
type AdminLoginResponse struct {
137
- Success bool `json:"success,omitempty"`
138
- Locked bool `json:"locked,omitempty"`
139
- RemainingSeconds int `json:"remaining_seconds,omitempty"`
96
+ Success bool `json:"success,omitempty"`
97
}
98
99
type AdminAuthStatusResponse struct {
@@ -145,9 +102,8 @@ type AdminAuthStatusResponse struct {
102
}
103
104
type AdminSnapshotResponse struct {
148
- ApprovalMode string `json:"approval_mode"`
149
- BannedLeases []string `json:"banned_leases,omitempty"`
150
- Leases []LeaseRow `json:"leases,omitempty"`
105
+ ApprovalMode string `json:"approval_mode"`
106
+ Leases []Lease `json:"leases,omitempty"`
107
}
108
109
type AdminApprovalModeRequest struct {
types/error.go
-1
@@ -2,7 +2,6 @@ package types
2
3
const (
4
APIErrorCodeAuthDisabled = "auth_disabled"
5
- APIErrorCodeAuthLocked = "auth_locked"
5
APIErrorCodeFeatureUnavailable = "feature_unavailable"
6
APIErrorCodeHijackFailed = "hijack_failed"
7
APIErrorCodeHijackUnsupported = "hijack_unsupported"
types/lease.go
new
+37
@@ -0,0 +1,37 @@
1
+package types
2
+
3
+import "time"
4
+
5
+type LeaseMetadata struct {
6
+ Description string `json:"description,omitempty"`
7
+ Owner string `json:"owner,omitempty"`
8
+ Thumbnail string `json:"thumbnail,omitempty"`
9
+ Tags []string `json:"tags,omitempty"`
10
+ Hide bool `json:"hide,omitempty"`
11
+}
12
+
13
+func (m LeaseMetadata) Copy() LeaseMetadata {
14
+ return LeaseMetadata{
15
+ Description: m.Description,
16
+ Owner: m.Owner,
17
+ Thumbnail: m.Thumbnail,
18
+ Tags: append([]string(nil), m.Tags...),
19
+ Hide: m.Hide,
20
+ }
21
+}
22
+
23
+type Lease struct {
24
+ ExpiresAt time.Time
25
+ FirstSeenAt time.Time
26
+ LastSeenAt time.Time
27
+ ID string
28
+ Name string
29
+ ClientIP string
30
+ Hostname string
31
+ Metadata LeaseMetadata
32
+ Ready int
33
+ IsApproved bool
34
+ IsBanned bool
35
+ IsDenied bool
36
+ IsIPBanned bool
37
+}
utils/api.go
-8
@@ -31,14 +31,6 @@ func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
31
})
32
}
33
34
-func WriteAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
35
- WriteAPIEnvelope(w, status, types.APIEnvelope[any]{
36
- OK: false,
37
- Data: data,
38
- Error: &types.APIError{Code: code, Message: message},
39
- })
40
-}
41
-
34
func DecodeAPIEnvelope[T any](r io.Reader) (types.APIEnvelope[T], error) {
35
var envelope types.APIEnvelope[T]
36
if err := json.NewDecoder(r).Decode(&envelope); err != nil {
utils/utils.go
+97
@@ -5,9 +5,11 @@ import (
5
"crypto/rand"
6
"crypto/subtle"
7
"crypto/x509"
8
+ "encoding/base64"
9
"encoding/hex"
10
"errors"
11
"fmt"
12
+ "io"
13
"net"
14
"net/url"
15
"os"
@@ -33,6 +35,29 @@ func SplitCSV(raw string) []string {
35
return out
36
}
37
38
+func ParseCIDRs(raw string) ([]*net.IPNet, error) {
39
+ parts := SplitCSV(raw)
40
+ if len(parts) == 0 {
41
+ return nil, nil
42
+ }
43
+
44
+ cidrs := make([]*net.IPNet, 0, len(parts))
45
+ seen := make(map[string]struct{}, len(parts))
46
+ for _, part := range parts {
47
+ _, network, err := net.ParseCIDR(part)
48
+ if err != nil {
49
+ return nil, fmt.Errorf("invalid cidr %q: %w", part, err)
50
+ }
51
+ key := network.String()
52
+ if _, ok := seen[key]; ok {
53
+ continue
54
+ }
55
+ seen[key] = struct{}{}
56
+ cidrs = append(cidrs, network)
57
+ }
58
+ return cidrs, nil
59
+}
60
+
61
func NormalizeDNSLabel(raw string) (string, error) {
62
label := NormalizeHostname(raw)
63
if label == "" {
@@ -141,6 +166,70 @@ func LeaseHostname(name, rootHost string) (string, error) {
166
return label + "." + rootHost, nil
167
}
168
169
+func FormatDuration(d time.Duration) string {
170
+ if d <= 0 {
171
+ return ""
172
+ }
173
+ if d > time.Hour {
174
+ return fmt.Sprintf("%.0fh", d.Hours())
175
+ }
176
+ if d > time.Minute {
177
+ return fmt.Sprintf("%.0fm", d.Minutes())
178
+ }
179
+ return fmt.Sprintf("%.0fs", d.Seconds())
180
+}
181
+
182
+func FormatLastSeen(d time.Duration) string {
183
+ if d <= 0 {
184
+ return ""
185
+ }
186
+ if d >= time.Hour {
187
+ hours := int(d / time.Hour)
188
+ minutes := int((d % time.Hour) / time.Minute)
189
+ if minutes > 0 {
190
+ return fmt.Sprintf("%dh %dm", hours, minutes)
191
+ }
192
+ return fmt.Sprintf("%dh", hours)
193
+ }
194
+ if d >= time.Minute {
195
+ minutes := int(d / time.Minute)
196
+ seconds := int((d % time.Minute) / time.Second)
197
+ if seconds > 0 {
198
+ return fmt.Sprintf("%dm %ds", minutes, seconds)
199
+ }
200
+ return fmt.Sprintf("%dm", minutes)
201
+ }
202
+ return fmt.Sprintf("%ds", int(d/time.Second))
203
+}
204
+
205
+func FormatISOTime(ts time.Time) string {
206
+ if ts.IsZero() {
207
+ return ""
208
+ }
209
+ return ts.UTC().Format(time.RFC3339)
210
+}
211
+
212
+func LeaseLink(host string) string {
213
+ host = strings.TrimSpace(host)
214
+ if host == "" {
215
+ return ""
216
+ }
217
+ return "https://" + host + "/"
218
+}
219
+
220
+func DecodeBase64URLString(encoded string) (string, error) {
221
+ decoded, err := base64.URLEncoding.DecodeString(encoded)
222
+ if err == nil {
223
+ return string(decoded), nil
224
+ }
225
+
226
+ decoded, err = base64.RawURLEncoding.DecodeString(encoded)
227
+ if err != nil {
228
+ return "", err
229
+ }
230
+ return string(decoded), nil
231
+}
232
+
233
// Network and transport helpers.
234
func NormalizeTargetAddr(raw string) (string, error) {
235
raw = strings.TrimSpace(raw)
@@ -220,6 +309,14 @@ func AddrString(addr net.Addr) string {
309
return addr.String()
310
}
311
312
+func RandomHex(size int) (string, error) {
313
+ buf := make([]byte, size)
314
+ if _, err := io.ReadFull(rand.Reader, buf); err != nil {
315
+ return "", fmt.Errorf("read random bytes: %w", err)
316
+ }
317
+ return hex.EncodeToString(buf), nil
318
+}
319
+
320
// Security and TLS helpers.
321
func TokenMatches(expected, actual string) bool {
322
if len(expected) == 0 || len(actual) == 0 {
utils/utils_test.go
+97
@@ -28,6 +28,26 @@ func TestNormalizeRelayURLs(t *testing.T) {
28
}
29
}
30
31
+func TestParseCIDRs(t *testing.T) {
32
+ t.Parallel()
33
+
34
+ got, err := ParseCIDRs("10.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16")
35
+ if err != nil {
36
+ t.Fatalf("ParseCIDRs() error = %v", err)
37
+ }
38
+ if len(got) != 2 {
39
+ t.Fatalf("ParseCIDRs() len = %d, want %d", len(got), 2)
40
+ }
41
+}
42
+
43
+func TestParseCIDRsRejectsInvalidValue(t *testing.T) {
44
+ t.Parallel()
45
+
46
+ if _, err := ParseCIDRs("not-a-cidr"); err == nil {
47
+ t.Fatal("ParseCIDRs() error = nil, want invalid cidr error")
48
+ }
49
+}
50
+
51
func TestNormalizeTargetAddr(t *testing.T) {
52
t.Parallel()
53
@@ -64,6 +84,71 @@ func TestLeaseHostname(t *testing.T) {
84
}
85
}
86
87
+func TestFormatDuration(t *testing.T) {
88
+ t.Parallel()
89
+
90
+ if got := FormatDuration(90 * time.Second); got != "2m" {
91
+ t.Fatalf("FormatDuration() = %q, want %q", got, "2m")
92
+ }
93
+}
94
+
95
+func TestFormatLastSeen(t *testing.T) {
96
+ t.Parallel()
97
+
98
+ if got := FormatLastSeen(65 * time.Second); got != "1m 5s" {
99
+ t.Fatalf("FormatLastSeen() = %q, want %q", got, "1m 5s")
100
+ }
101
+}
102
+
103
+func TestFormatISOTime(t *testing.T) {
104
+ t.Parallel()
105
+
106
+ ts := time.Date(2026, time.March, 17, 9, 10, 11, 0, time.FixedZone("KST", 9*60*60))
107
+ if got := FormatISOTime(ts); got != "2026-03-17T00:10:11Z" {
108
+ t.Fatalf("FormatISOTime() = %q, want %q", got, "2026-03-17T00:10:11Z")
109
+ }
110
+}
111
+
112
+func TestLeaseLink(t *testing.T) {
113
+ t.Parallel()
114
+
115
+ if got := LeaseLink("demo.example.com"); got != "https://demo.example.com/" {
116
+ t.Fatalf("LeaseLink() = %q, want %q", got, "https://demo.example.com/")
117
+ }
118
+}
119
+
120
+func TestDecodeBase64URLString(t *testing.T) {
121
+ t.Parallel()
122
+
123
+ cases := []struct {
124
+ encoded string
125
+ want string
126
+ }{
127
+ {encoded: "bGVhc2UtMTIz", want: "lease-123"},
128
+ {encoded: "bGVhc2UtMTIzZA==", want: "lease-123d"},
129
+ {encoded: "bGVhc2UtMTIzZA", want: "lease-123d"},
130
+ }
131
+
132
+ for _, tc := range cases {
133
+ encoded, want := tc.encoded, tc.want
134
+ got, err := DecodeBase64URLString(encoded)
135
+ if err != nil {
136
+ t.Fatalf("DecodeBase64URLString(%q) error = %v", encoded, err)
137
+ }
138
+ if got != want {
139
+ t.Fatalf("DecodeBase64URLString(%q) = %q, want %q", encoded, got, want)
140
+ }
141
+ }
142
+}
143
+
144
+func TestDecodeBase64URLStringRejectsInvalidValue(t *testing.T) {
145
+ t.Parallel()
146
+
147
+ if _, err := DecodeBase64URLString("%%%"); err == nil {
148
+ t.Fatal("DecodeBase64URLString() error = nil, want invalid base64 error")
149
+ }
150
+}
151
+
152
func TestRandomID(t *testing.T) {
153
t.Parallel()
154
@@ -76,6 +161,18 @@ func TestRandomID(t *testing.T) {
161
}
162
}
163
164
+func TestRandomHex(t *testing.T) {
165
+ t.Parallel()
166
+
167
+ got, err := RandomHex(16)
168
+ if err != nil {
169
+ t.Fatalf("RandomHex() error = %v", err)
170
+ }
171
+ if len(got) != 32 {
172
+ t.Fatalf("RandomHex() length = %d, want %d", len(got), 32)
173
+ }
174
+}
175
+
176
func TestSleepOrDoneCanceled(t *testing.T) {
177
t.Parallel()
178