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