restore admin

Kim committed Mar 6, 2026 at 15:49 UTC 04ae25529292aba5b8063bcb911e6bc117dab55f
20 files changed +1818 -246
cmd/relay-server/admin.go deleted
-84
@@ -1,84 +0,0 @@
1 -package main
2 -
3 -import (
4 - "crypto/subtle"
5 - "encoding/base64"
6 - "encoding/json"
7 - "fmt"
8 - "net/http"
9 - "strings"
10 -
11 - "gosuda.org/portal/portal"
12 - "gosuda.org/portal/types"
13 -)
14 -
15 -type Admin struct {
16 - frontend *Frontend
17 - server *portal.Server
18 - secret string
19 - trustProxy bool
20 -}
21 -
22 -func NewAdmin(secret string, trustProxy bool, frontend *Frontend) *Admin {
23 - return &Admin{
24 - secret: strings.TrimSpace(secret),
25 - trustProxy: trustProxy,
26 - frontend: frontend,
27 - }
28 -}
29 -
30 -func (a *Admin) Bind(server *portal.Server) {
31 - a.server = server
32 -}
33 -
34 -func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request) {
35 - if !a.authorize(r) {
36 - w.Header().Set("WWW-Authenticate", `Bearer realm="portal-admin"`)
37 - http.Error(w, "unauthorized", http.StatusUnauthorized)
38 - return
39 - }
40 -
41 - switch strings.TrimSuffix(r.URL.Path, "/") {
42 - case types.PathAdmin:
43 - a.handleAdminIndex(w)
44 - case types.PathAdminLeases:
45 - w.Header().Set("Content-Type", "application/json")
46 - w.WriteHeader(http.StatusOK)
47 - _ = json.NewEncoder(w).Encode(convertLeaseEntriesToRows(a.server, true, a.frontend.portalURL))
48 - default:
49 - http.NotFound(w, r)
50 - }
51 -}
52 -
53 -func (a *Admin) handleAdminIndex(w http.ResponseWriter) {
54 - rows := convertLeaseEntriesToRows(a.server, true, a.frontend.portalURL)
55 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
56 - _, _ = fmt.Fprintf(w, `<!doctype html><html><body><h1>Portal Admin</h1><p>%d leases</p><p><a href="%s">JSON lease list</a></p></body></html>`, len(rows), types.PathAdminLeases)
57 -}
58 -
59 -func (a *Admin) authorize(r *http.Request) bool {
60 - if a.secret == "" {
61 - return true
62 - }
63 - queryKey := strings.TrimSpace(r.URL.Query().Get("key"))
64 - if queryKey != "" && subtle.ConstantTimeCompare([]byte(queryKey), []byte(a.secret)) == 1 {
65 - return true
66 - }
67 - auth := strings.TrimSpace(r.Header.Get("Authorization"))
68 - if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
69 - bearerToken := strings.TrimSpace(auth[7:])
70 - if bearerToken != "" && subtle.ConstantTimeCompare([]byte(bearerToken), []byte(a.secret)) == 1 {
71 - return true
72 - }
73 - }
74 - if strings.HasPrefix(strings.ToLower(auth), "basic ") {
75 - raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(auth[6:]))
76 - if err == nil {
77 - parts := strings.SplitN(string(raw), ":", 2)
78 - if len(parts) == 2 && parts[1] != "" && subtle.ConstantTimeCompare([]byte(parts[1]), []byte(a.secret)) == 1 {
79 - return true
80 - }
81 - }
82 - }
83 - return false
84 -}
cmd/relay-server/frontend.go
+2 -66
@@ -3,7 +3,6 @@ package main
3 import (
4 "embed"
5 "encoding/json"
6 - "fmt"
6 "html"
7 "io/fs"
8 "mime"
@@ -11,9 +10,9 @@ import (
10 "path"
11 "strings"
12 "sync"
14 - "time"
13
14 "gosuda.org/portal/portal"
15 + "gosuda.org/portal/portal/admin"
16 )
17
18 type readDirFileFS interface {
@@ -135,7 +134,7 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) {
134 }
135
136 func (f *Frontend) injectServerData(htmlContent string) string {
138 - rows := convertLeaseEntriesToRows(f.server, false, f.portalURL)
137 + rows := admin.BuildLeaseRows(f.server, false, f.portalURL)
138 jsonData, err := json.Marshal(rows)
139 if err != nil {
140 jsonData = []byte("[]")
@@ -195,66 +194,3 @@ func getContentType(ext string) string {
194 return ""
195 }
196 }
198 -
199 -type leaseRow struct {
200 - TTL string
201 - Metadata string
202 - Kind string
203 - DNS string
204 - Name string
205 - Peer string
206 - Link string
207 - Hide bool
208 - Connected bool
209 -}
210 -
211 -func convertLeaseEntriesToRows(serv *portal.Server, includeHidden bool, portalURL string) []leaseRow {
212 - if serv == nil {
213 - return nil
214 - }
215 - snapshots := serv.ListLeases()
216 - rows := make([]leaseRow, 0, len(snapshots))
217 - for _, snapshot := range snapshots {
218 - if !includeHidden && snapshot.Metadata.Hide {
219 - continue
220 - }
221 - metadataJSON, _ := json.Marshal(snapshot.Metadata)
222 - host := ""
223 - if len(snapshot.Hostnames) > 0 {
224 - host = snapshot.Hostnames[0]
225 - }
226 - rows = append(rows, leaseRow{
227 - TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
228 - Metadata: string(metadataJSON),
229 - Kind: "https",
230 - DNS: host,
231 - Name: snapshot.Name,
232 - Peer: snapshot.ID,
233 - Link: leaseLink(host, portalURL),
234 - Hide: snapshot.Metadata.Hide,
235 - Connected: snapshot.Ready > 0,
236 - })
237 - }
238 - return rows
239 -}
240 -
241 -func formatDuration(d time.Duration) string {
242 - if d <= 0 {
243 - return ""
244 - }
245 - if d > time.Hour {
246 - return fmt.Sprintf("%.0fh", d.Hours())
247 - }
248 - if d > time.Minute {
249 - return fmt.Sprintf("%.0fm", d.Minutes())
250 - }
251 - return fmt.Sprintf("%.0fs", d.Seconds())
252 -}
253 -
254 -func leaseLink(host, portalURL string) string {
255 - host = strings.TrimSpace(host)
256 - if host == "" {
257 - return ""
258 - }
259 - return "https://" + host + "/"
260 -}
cmd/relay-server/serve.go
+38 -28
@@ -13,20 +13,12 @@ import (
13
14 "gosuda.org/portal/portal"
15 "gosuda.org/portal/portal/acme"
16 + portaladmin "gosuda.org/portal/portal/admin"
17 "gosuda.org/portal/portal/keyless"
18 + "gosuda.org/portal/portal/policy"
19 "gosuda.org/portal/types"
20 )
21
20 -const (
21 - pathFaviconICO = "/favicon.ico"
22 - pathFaviconSVG = "/favicon.svg"
23 - pathFavicon96PNG = "/favicon-96x96.png"
24 - pathAppleTouchIconPNG = "/apple-touch-icon.png"
25 - pathWebAppManifest192PNG = "/web-app-manifest-192x192.png"
26 - pathWebAppManifest512PNG = "/web-app-manifest-512x512.png"
27 - pathPortalJPG = "/portal.jpg"
28 -)
29 -
22 func runServer(cfg relayServerConfig) error {
23 logger := log.With().Str("component", "relay-server").Logger()
24
@@ -39,6 +31,11 @@ func runServer(cfg relayServerConfig) error {
31 rootHost := portal.PortalRootHost(cfg.PortalURL)
32 apiListenAddr := fmt.Sprintf(":%d", cfg.AdminPort)
33 sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
34 + trustedProxyCIDRs, err := policy.ParseTrustedProxyCIDRs(cfg.TrustedProxyCIDRs)
35 + if err != nil {
36 + return fmt.Errorf("parse trusted proxy cidrs: %w", err)
37 + }
38 + policy.SetTrustedProxyCIDRs(trustedProxyCIDRs)
39
40 acmeManager, err := acme.NewManager(acme.Config{
41 BaseDomain: rootHost,
@@ -59,14 +56,27 @@ func runServer(cfg relayServerConfig) error {
56 }
57
58 frontend := NewFrontend(cfg.PortalURL)
62 - admin := NewAdmin(cfg.AdminSecretKey, cfg.TrustProxyHeaders, frontend)
59 + adminHandler := portaladmin.NewHandler(portaladmin.Config{
60 + PortalURL: cfg.PortalURL,
61 + Secret: cfg.AdminSecretKey,
62 + TrustProxy: cfg.TrustProxyHeaders,
63 + SettingsPath: "admin_settings.json",
64 + ServeAppStatic: func(w http.ResponseWriter, r *http.Request, appPath string) {
65 + frontend.ServeAppStatic(w, r, appPath)
66 + },
67 + })
68 + if err := adminHandler.LoadSettings(); err != nil {
69 + logger.Warn().Err(err).Msg("load admin settings")
70 + }
71
72 server, err := portal.NewServer(portal.ServerConfig{
65 - PortalURL: cfg.PortalURL,
66 - APIListenAddr: apiListenAddr,
67 - SNIListenAddr: sniListenAddr,
68 - RootHost: rootHost,
69 - RootFallbackAddr: portal.HostPortOrLoopback(apiListenAddr),
73 + PortalURL: cfg.PortalURL,
74 + APIListenAddr: apiListenAddr,
75 + SNIListenAddr: sniListenAddr,
76 + RootHost: rootHost,
77 + RootFallbackAddr: portal.HostPortOrLoopback(apiListenAddr),
78 + Policy: adminHandler.Runtime(),
79 + TrustProxyHeaders: cfg.TrustProxyHeaders,
80 KeylessSignerHandler: func() http.Handler {
81 if signer == nil {
82 return nil
@@ -77,14 +87,14 @@ func runServer(cfg relayServerConfig) error {
87 CertPEM: mustRead(certFile),
88 KeyPEM: mustRead(keyFile),
89 },
80 - APIHandlerWrapper: serveAPI(frontend, admin, cfg),
90 + APIHandlerWrapper: serveAPI(frontend, adminHandler, cfg),
91 })
92 if err != nil {
93 return fmt.Errorf("create relay server: %w", err)
94 }
95
96 frontend.Bind(server)
87 - admin.Bind(server)
97 + adminHandler.Bind(server)
98
99 if err := server.Start(ctx); err != nil {
100 return fmt.Errorf("start relay server: %w", err)
@@ -102,7 +112,7 @@ func runServer(cfg relayServerConfig) error {
112 return server.Wait()
113 }
114
105 -func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http.Handler) http.Handler {
115 +func serveAPI(frontend *Frontend, adminHandler *portaladmin.Handler, cfg relayServerConfig) func(http.Handler) http.Handler {
116 return func(base http.Handler) http.Handler {
117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 switch {
@@ -121,9 +131,9 @@ func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http
131 case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix):
132 frontend.ServeAppStatic(w, r, strings.TrimPrefix(strings.TrimSpace(r.URL.Path), types.PathAppPrefix))
133 case r.URL.Path == types.PathAdmin || r.URL.Path == types.PathAdminPrefix:
124 - admin.HandleAdminRequest(w, r)
134 + adminHandler.HandleRequest(w, r)
135 case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathAdminPrefix):
126 - admin.HandleAdminRequest(w, r)
136 + adminHandler.HandleRequest(w, r)
137 case r.URL.Path == types.PathTunnel:
138 serveTunnelScript(w, r, cfg.PortalURL)
139 case strings.HasPrefix(strings.TrimSpace(r.URL.Path), types.PathTunnelBinPrefix):
@@ -137,13 +147,13 @@ func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http
147
148 func isFrontendRootAssetPath(requestPath string) bool {
149 switch requestPath {
140 - case pathFaviconICO,
141 - pathFaviconSVG,
142 - pathFavicon96PNG,
143 - pathAppleTouchIconPNG,
144 - pathWebAppManifest192PNG,
145 - pathWebAppManifest512PNG,
146 - pathPortalJPG:
150 + case "/favicon.ico",
151 + "/favicon.svg",
152 + "/favicon-96x96.png",
153 + "/apple-touch-icon.png",
154 + "/web-app-manifest-192x192.png",
155 + "/web-app-manifest-512x512.png",
156 + "/portal.jpg":
157 return true
158 default:
159 return false
cmd/relay-server/tunnel.go
+4 -4
@@ -92,7 +92,7 @@ exec "$@"
92 const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
93
94 $BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
95 -$Relays = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
95 +$RelayUrl = if ($env:RELAY_URL) { $env:RELAY_URL } else { $BaseUrl }
96 $OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
97 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
98
@@ -149,10 +149,10 @@ if ($ActualHash -ne $ExpectedHash) {
149 exit 1
150 }
151
152 -$ArgsList = @("--relay", $Relays)
152 +$ArgsList = @("--relay", $RelayUrl)
153
154 -if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
155 -if ($env:APP_NAME) { $ArgsList += "--name", $env:APP_NAME }
154 +if ($env:HOST) { $ArgsList += "--host", $env:HOST } else { $ArgsList += "--host", "localhost:3000" }
155 +if ($env:NAME) { $ArgsList += "--name", $env:NAME }
156 if ($env:APP_DESCRIPTION) { $ArgsList += "--description", $env:APP_DESCRIPTION }
157 if ($env:APP_TAGS) { $ArgsList += "--tags", $env:APP_TAGS }
158 if ($env:APP_THUMBNAIL) { $ArgsList += "--thumbnail", $env:APP_THUMBNAIL }
frontend/src/components/ServerCard.tsx
+13 -11
@@ -365,17 +365,19 @@ export function ServerCard({
365
366 {showAdminControls && leaseId && (
367 <div className="flex flex-col gap-2 w-full mt-2">
368 - <div className="flex items-center justify-between w-full">
369 - <span className="text-xs text-white/60">
370 - BPS: <span className="font-medium text-white">{formatBPS(bps)}</span>
371 - </span>
372 - <button
373 - onClick={handleBPSSettingsClick}
374 - className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
375 - >
376 - Settings
377 - </button>
378 - </div>
368 + {onBPSChange && (
369 + <div className="flex items-center justify-between w-full">
370 + <span className="text-xs text-white/60">
371 + BPS: <span className="font-medium text-white">{formatBPS(bps)}</span>
372 + </span>
373 + <button
374 + onClick={handleBPSSettingsClick}
375 + className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
376 + >
377 + Settings
378 + </button>
379 + </div>
380 + )}
381
382 {isApproved && ip && (
383 <div className="text-[10px] text-white/50">
frontend/src/components/TunnelCommandModal.tsx
+7 -4
@@ -79,8 +79,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
79 if (os === "windows") {
80 const windowsScriptURL = new URL(tunnelScriptURL);
81 windowsScriptURL.searchParams.set("os", "windows");
82 - const downloadCommand = `& { $proto = [System.Net.ServicePointManager]::SecurityProtocol; try { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12; irm ${windowsScriptURL.toString()} } finally { [System.Net.ServicePointManager]::SecurityProtocol = $proto } }`;
83 - return `$ProgressPreference = 'SilentlyContinue'; $env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; ${downloadCommand} | iex`;
82 + return `$ProgressPreference = 'SilentlyContinue'; $env:HOST="${hostVal}"; $env:NAME="${nameVal}"; $env:RELAY_URL="${relayUrlVal}"; irm ${windowsScriptURL.toString()} | iex`;
83 }
84
85 const curlFlags = localhostRelay ? "-kfsSL" : "-fsSL";
@@ -127,7 +126,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
126 Host
127 </label>
128 <div className="flex items-center rounded-md bg-border">
130 - <span className="px-3 text-sm text-text-muted">APP_HOST=</span>
129 + <span className="px-3 text-sm text-text-muted">
130 + {os === "windows" ? "HOST=" : "APP_HOST="}
131 + </span>
132 <Input
133 id="host"
134 type="text"
@@ -151,7 +152,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
152 Service Name
153 </label>
154 <div className="flex items-center rounded-md bg-border">
154 - <span className="px-3 text-sm text-text-muted">APP_NAME=</span>
155 + <span className="px-3 text-sm text-text-muted">
156 + {os === "windows" ? "NAME=" : "APP_NAME="}
157 + </span>
158 <Input
159 id="name"
160 type="text"
frontend/src/pages/Admin.tsx
-2
@@ -28,7 +28,6 @@ export function Admin() {
28 handleBanFilterChange,
29 handleToggleFavorite,
30 handleBanStatus,
31 - handleBPSChange,
31 handleApprovalModeChange,
32 handleApproveStatus,
33 handleDenyStatus,
@@ -83,7 +82,6 @@ export function Admin() {
82 approvalMode={approvalMode}
83 onBanFilterChange={handleBanFilterChange}
84 onBanStatusChange={handleBanStatus}
86 - onBPSChange={handleBPSChange}
85 onApprovalModeChange={handleApprovalModeChange}
86 onApproveStatusChange={handleApproveStatus}
87 onDenyStatusChange={handleDenyStatus}
portal/admin/handler.go new
+412
@@ -0,0 +1,412 @@
1 +package admin
2 +
3 +import (
4 + "encoding/base64"
5 + "encoding/json"
6 + "net"
7 + "net/http"
8 + "strings"
9 +
10 + "gosuda.org/portal/portal"
11 + "gosuda.org/portal/portal/policy"
12 + "gosuda.org/portal/types"
13 +)
14 +
15 +const cookieName = "portal_admin"
16 +
17 +type Config struct {
18 + PortalURL string
19 + Secret string
20 + SettingsPath string
21 + TrustProxy bool
22 + ServeAppStatic func(http.ResponseWriter, *http.Request, string)
23 +}
24 +
25 +type Handler struct {
26 + auth *policy.Authenticator
27 + runtime *policy.Runtime
28 + server *portal.Server
29 + settings *stateStore
30 + portalURL string
31 + trustProxy bool
32 + serveAppStatic func(http.ResponseWriter, *http.Request, string)
33 +}
34 +
35 +func NewHandler(cfg Config) *Handler {
36 + h := &Handler{
37 + auth: policy.NewAuthenticator(strings.TrimSpace(cfg.Secret)),
38 + runtime: policy.NewRuntime(),
39 + settings: newStateStore(cfg.SettingsPath),
40 + portalURL: strings.TrimSpace(cfg.PortalURL),
41 + trustProxy: cfg.TrustProxy,
42 + }
43 + if cfg.ServeAppStatic != nil {
44 + h.serveAppStatic = cfg.ServeAppStatic
45 + } else {
46 + h.serveAppStatic = func(w http.ResponseWriter, r *http.Request, _ string) {
47 + http.NotFound(w, r)
48 + }
49 + }
50 + return h
51 +}
52 +
53 +func (h *Handler) Bind(server *portal.Server) {
54 + h.server = server
55 +}
56 +
57 +func (h *Handler) Runtime() *policy.Runtime {
58 + return h.runtime
59 +}
60 +
61 +func (h *Handler) LoadSettings() error {
62 + return h.settings.Load(h.runtime)
63 +}
64 +
65 +func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
66 + path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
67 + if path == "" {
68 + path = types.PathRoot
69 + }
70 +
71 + switch path {
72 + case types.PathAdmin:
73 + h.serveAppStatic(w, r, "")
74 + return
75 + case types.PathAdminLogin:
76 + if r.Method == http.MethodGet {
77 + h.serveAppStatic(w, r, "")
78 + return
79 + }
80 + if r.Method == http.MethodPost {
81 + h.handleLogin(w, r)
82 + return
83 + }
84 + case types.PathAdminLogout:
85 + h.handleLogout(w, r)
86 + return
87 + case types.PathAdminAuthStatus:
88 + h.handleAuthStatus(w, r)
89 + return
90 + }
91 +
92 + if !h.isAuthenticated(r) {
93 + writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
94 + return
95 + }
96 +
97 + switch path {
98 + case types.PathAdminLeases:
99 + h.handleLeases(w, r)
100 + case types.PathAdminBanned:
101 + h.handleBannedLeases(w, r)
102 + case types.PathAdminSettings:
103 + h.handleSettings(w, r)
104 + case types.PathAdminApproval:
105 + h.handleApprovalMode(w, r)
106 + default:
107 + switch {
108 + case strings.HasPrefix(path, types.PathAdminLeasesPrefix):
109 + h.handleLeaseAction(w, r, path)
110 + case strings.HasPrefix(path, types.PathAdminIPsPrefix):
111 + h.handleIPBan(w, r, path)
112 + default:
113 + http.NotFound(w, r)
114 + }
115 + }
116 +}
117 +
118 +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
119 + if r.Method != http.MethodPost {
120 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
121 + return
122 + }
123 + if !h.auth.AuthEnabled() {
124 + writeAPIError(w, http.StatusServiceUnavailable, "auth_disabled", "admin authentication is not configured")
125 + return
126 + }
127 +
128 + clientIP := policy.ExtractClientIP(r, h.trustProxy)
129 + if h.auth.IsIPLocked(clientIP) {
130 + writeAPIErrorWithData(w, http.StatusTooManyRequests, "auth_locked", "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
131 + Locked: true,
132 + RemainingSeconds: h.auth.LockRemainingSeconds(clientIP),
133 + })
134 + return
135 + }
136 +
137 + var req types.AdminLoginRequest
138 + if err := decodeJSON(w, r, &req); err != nil {
139 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
140 + return
141 + }
142 + if !h.auth.ValidateKey(req.Key) {
143 + locked := h.auth.RecordFailedLogin(clientIP)
144 + resp := types.AdminLoginResponse{Locked: locked}
145 + if locked {
146 + resp.RemainingSeconds = h.auth.LockRemainingSeconds(clientIP)
147 + }
148 + writeAPIErrorWithData(w, http.StatusUnauthorized, "invalid_key", "Invalid key", resp)
149 + return
150 + }
151 +
152 + h.auth.ResetFailedLogin(clientIP)
153 + token, err := h.auth.CreateSession()
154 + if err != nil {
155 + writeAPIError(w, http.StatusInternalServerError, "session_create_failed", "failed to create admin session")
156 + return
157 + }
158 +
159 + http.SetCookie(w, &http.Cookie{
160 + Name: cookieName,
161 + Value: token,
162 + Path: types.PathAdmin,
163 + HttpOnly: true,
164 + Secure: policy.IsSecureForwardedRequest(r, h.trustProxy),
165 + SameSite: http.SameSiteStrictMode,
166 + MaxAge: 86400,
167 + })
168 + writeAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
169 +}
170 +
171 +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
172 + if r.Method != http.MethodPost {
173 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
174 + return
175 + }
176 +
177 + if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
178 + h.auth.DeleteSession(cookie.Value)
179 + }
180 + http.SetCookie(w, &http.Cookie{
181 + Name: cookieName,
182 + Value: "",
183 + Path: types.PathAdmin,
184 + HttpOnly: true,
185 + Secure: policy.IsSecureForwardedRequest(r, h.trustProxy),
186 + SameSite: http.SameSiteStrictMode,
187 + MaxAge: -1,
188 + })
189 + writeAPIOK(w, http.StatusOK)
190 +}
191 +
192 +func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
193 + if r.Method != http.MethodGet {
194 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
195 + return
196 + }
197 + writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
198 + Authenticated: h.isAuthenticated(r),
199 + AuthEnabled: h.auth.AuthEnabled(),
200 + })
201 +}
202 +
203 +func (h *Handler) handleLeases(w http.ResponseWriter, r *http.Request) {
204 + if r.Method != http.MethodGet {
205 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
206 + return
207 + }
208 + writeAPIData(w, http.StatusOK, BuildLeaseRows(h.server, true, h.portalURL))
209 +}
210 +
211 +func (h *Handler) handleBannedLeases(w http.ResponseWriter, r *http.Request) {
212 + if r.Method != http.MethodGet {
213 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
214 + return
215 + }
216 + writeAPIData(w, http.StatusOK, h.runtime.BannedLeases())
217 +}
218 +
219 +func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
220 + if r.Method != http.MethodGet {
221 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
222 + return
223 + }
224 + approver := h.runtime.Approver()
225 + writeAPIData(w, http.StatusOK, types.AdminSettingsResponse{
226 + ApprovalMode: string(approver.Mode()),
227 + ApprovedLeases: approver.ApprovedLeases(),
228 + DeniedLeases: approver.DeniedLeases(),
229 + })
230 +}
231 +
232 +func (h *Handler) handleApprovalMode(w http.ResponseWriter, r *http.Request) {
233 + approver := h.runtime.Approver()
234 + switch r.Method {
235 + case http.MethodGet:
236 + writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
237 + case http.MethodPost:
238 + var req types.AdminApprovalModeRequest
239 + if err := decodeJSON(w, r, &req); err != nil {
240 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
241 + return
242 + }
243 + if err := approver.SetMode(policy.Mode(req.Mode)); err != nil {
244 + writeAPIError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (must be 'auto' or 'manual')")
245 + return
246 + }
247 + _ = h.settings.Save(h.runtime)
248 + writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
249 + default:
250 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
251 + }
252 +}
253 +
254 +func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path string) {
255 + rest := strings.TrimPrefix(path, types.PathAdminLeasesPrefix)
256 + parts := strings.Split(rest, "/")
257 + if len(parts) != 2 {
258 + http.NotFound(w, r)
259 + return
260 + }
261 +
262 + leaseID, ok := decodeLeaseID(parts[0])
263 + if !ok {
264 + writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
265 + return
266 + }
267 +
268 + switch parts[1] {
269 + case "ban":
270 + h.handleLeaseBan(w, r, leaseID)
271 + case "bps":
272 + writeAPIError(w, http.StatusNotImplemented, "feature_unavailable", "bps control is not enabled in this build")
273 + case "approve":
274 + h.handleLeaseApproval(w, r, leaseID)
275 + case "deny":
276 + h.handleLeaseDenial(w, r, leaseID)
277 + default:
278 + http.NotFound(w, r)
279 + }
280 +}
281 +
282 +func (h *Handler) handleLeaseBan(w http.ResponseWriter, r *http.Request, leaseID string) {
283 + switch r.Method {
284 + case http.MethodPost:
285 + h.runtime.BanLease(leaseID)
286 + _ = h.settings.Save(h.runtime)
287 + writeAPIOK(w, http.StatusOK)
288 + case http.MethodDelete:
289 + h.runtime.UnbanLease(leaseID)
290 + _ = h.settings.Save(h.runtime)
291 + writeAPIOK(w, http.StatusOK)
292 + default:
293 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
294 + }
295 +}
296 +
297 +func (h *Handler) handleLeaseApproval(w http.ResponseWriter, r *http.Request, leaseID string) {
298 + approver := h.runtime.Approver()
299 + switch r.Method {
300 + case http.MethodPost:
301 + approver.Approve(leaseID)
302 + approver.Undeny(leaseID)
303 + _ = h.settings.Save(h.runtime)
304 + writeAPIOK(w, http.StatusOK)
305 + case http.MethodDelete:
306 + approver.Revoke(leaseID)
307 + _ = h.settings.Save(h.runtime)
308 + writeAPIOK(w, http.StatusOK)
309 + default:
310 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
311 + }
312 +}
313 +
314 +func (h *Handler) handleLeaseDenial(w http.ResponseWriter, r *http.Request, leaseID string) {
315 + approver := h.runtime.Approver()
316 + switch r.Method {
317 + case http.MethodPost:
318 + approver.Deny(leaseID)
319 + _ = h.settings.Save(h.runtime)
320 + writeAPIOK(w, http.StatusOK)
321 + case http.MethodDelete:
322 + approver.Undeny(leaseID)
323 + _ = h.settings.Save(h.runtime)
324 + writeAPIOK(w, http.StatusOK)
325 + default:
326 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
327 + }
328 +}
329 +
330 +func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path string) {
331 + if !strings.HasSuffix(path, "/ban") {
332 + http.NotFound(w, r)
333 + return
334 + }
335 + rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
336 + rawIP = strings.Trim(rawIP, "/")
337 + if net.ParseIP(rawIP) == nil {
338 + writeAPIError(w, http.StatusBadRequest, "invalid_ip", "invalid IP address")
339 + return
340 + }
341 +
342 + ipFilter := h.runtime.IPFilter()
343 + switch r.Method {
344 + case http.MethodPost:
345 + ipFilter.BanIP(rawIP)
346 + _ = h.settings.Save(h.runtime)
347 + writeAPIOK(w, http.StatusOK)
348 + case http.MethodDelete:
349 + ipFilter.UnbanIP(rawIP)
350 + _ = h.settings.Save(h.runtime)
351 + writeAPIOK(w, http.StatusOK)
352 + default:
353 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
354 + }
355 +}
356 +
357 +func (h *Handler) isAuthenticated(r *http.Request) bool {
358 + if !h.auth.AuthEnabled() {
359 + return false
360 + }
361 + cookie, err := r.Cookie(cookieName)
362 + if err != nil {
363 + return false
364 + }
365 + return h.auth.ValidateSession(cookie.Value)
366 +}
367 +
368 +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
369 + r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
370 + defer r.Body.Close()
371 + return json.NewDecoder(r.Body).Decode(dst)
372 +}
373 +
374 +func decodeLeaseID(encoded string) (string, bool) {
375 + idBytes, err := base64.URLEncoding.DecodeString(encoded)
376 + if err != nil {
377 + idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
378 + if err != nil {
379 + return "", false
380 + }
381 + }
382 + return string(idBytes), true
383 +}
384 +
385 +func writeAPIData(w http.ResponseWriter, status int, data any) {
386 + w.Header().Set("Content-Type", "application/json")
387 + w.WriteHeader(status)
388 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true, Data: data})
389 +}
390 +
391 +func writeAPIOK(w http.ResponseWriter, status int) {
392 + writeAPIData(w, status, map[string]any{})
393 +}
394 +
395 +func writeAPIError(w http.ResponseWriter, status int, code, message string) {
396 + w.Header().Set("Content-Type", "application/json")
397 + w.WriteHeader(status)
398 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{
399 + OK: false,
400 + Error: &types.APIError{Code: code, Message: message},
401 + })
402 +}
403 +
404 +func writeAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
405 + w.Header().Set("Content-Type", "application/json")
406 + w.WriteHeader(status)
407 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{
408 + OK: false,
409 + Data: data,
410 + Error: &types.APIError{Code: code, Message: message},
411 + })
412 +}
portal/admin/handler_test.go new
+98
@@ -0,0 +1,98 @@
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 + "gosuda.org/portal/portal/policy"
12 + "gosuda.org/portal/types"
13 +)
14 +
15 +func TestLoginAndProtectedActions(t *testing.T) {
16 + t.Parallel()
17 +
18 + handler := NewHandler(Config{
19 + Secret: "secret-key",
20 + SettingsPath: filepath.Join(t.TempDir(), "admin_settings.json"),
21 + ServeAppStatic: func(w http.ResponseWriter, _ *http.Request, _ string) {
22 + w.WriteHeader(http.StatusOK)
23 + },
24 + })
25 +
26 + loginRecorder := httptest.NewRecorder()
27 + loginRequest := httptest.NewRequest(http.MethodPost, types.PathAdminLogin, bytes.NewBufferString(`{"key":"secret-key"}`))
28 + loginRequest.RemoteAddr = "127.0.0.1:1234"
29 + handler.HandleRequest(loginRecorder, loginRequest)
30 +
31 + if loginRecorder.Code != http.StatusOK {
32 + t.Fatalf("login status = %d, want %d", loginRecorder.Code, http.StatusOK)
33 + }
34 + loginResponse := decodeEnvelope[types.AdminLoginResponse](t, loginRecorder)
35 + if !loginResponse.Success {
36 + t.Fatalf("login success = false, want true")
37 + }
38 + cookies := loginRecorder.Result().Cookies()
39 + if len(cookies) == 0 {
40 + t.Fatalf("login cookies = 0, want at least 1")
41 + }
42 +
43 + authRecorder := httptest.NewRecorder()
44 + authRequest := httptest.NewRequest(http.MethodGet, types.PathAdminAuthStatus, nil)
45 + authRequest.RemoteAddr = "127.0.0.1:1234"
46 + authRequest.AddCookie(cookies[0])
47 + handler.HandleRequest(authRecorder, authRequest)
48 + authStatus := decodeEnvelope[types.AdminAuthStatusResponse](t, authRecorder)
49 + if !authStatus.Authenticated || !authStatus.AuthEnabled {
50 + t.Fatalf("auth status = %+v, want authenticated + auth enabled", authStatus)
51 + }
52 +
53 + approvalRecorder := httptest.NewRecorder()
54 + approvalRequest := httptest.NewRequest(http.MethodPost, types.PathAdminApproval, bytes.NewBufferString(`{"mode":"manual"}`))
55 + approvalRequest.RemoteAddr = "127.0.0.1:1234"
56 + approvalRequest.AddCookie(cookies[0])
57 + handler.HandleRequest(approvalRecorder, approvalRequest)
58 + if approvalRecorder.Code != http.StatusOK {
59 + t.Fatalf("approval status = %d, want %d", approvalRecorder.Code, http.StatusOK)
60 + }
61 + if got := handler.runtime.Approver().Mode(); got != policy.ModeManual {
62 + t.Fatalf("approval mode = %q, want %q", got, policy.ModeManual)
63 + }
64 +
65 + ipBanRecorder := httptest.NewRecorder()
66 + ipBanRequest := httptest.NewRequest(http.MethodPost, types.PathAdminIPsPrefix+"203.0.113.10/ban", nil)
67 + ipBanRequest.RemoteAddr = "127.0.0.1:1234"
68 + ipBanRequest.AddCookie(cookies[0])
69 + handler.HandleRequest(ipBanRecorder, ipBanRequest)
70 + if ipBanRecorder.Code != http.StatusOK {
71 + t.Fatalf("ip ban status = %d, want %d", ipBanRecorder.Code, http.StatusOK)
72 + }
73 + if !handler.runtime.IPFilter().IsIPBanned("203.0.113.10") {
74 + t.Fatalf("IsIPBanned() = false, want true")
75 + }
76 +}
77 +
78 +func decodeEnvelope[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
79 + t.Helper()
80 +
81 + var envelope types.APIEnvelope
82 + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
83 + t.Fatalf("Decode envelope error = %v", err)
84 + }
85 + if !envelope.OK {
86 + t.Fatalf("envelope not OK: %+v", envelope)
87 + }
88 + data, err := json.Marshal(envelope.Data)
89 + if err != nil {
90 + t.Fatalf("Marshal envelope data error = %v", err)
91 + }
92 +
93 + var out T
94 + if err := json.Unmarshal(data, &out); err != nil {
95 + t.Fatalf("Unmarshal envelope data error = %v", err)
96 + }
97 + return out
98 +}
portal/admin/rows.go new
+141
@@ -0,0 +1,141 @@
1 +package admin
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "strings"
7 + "time"
8 +
9 + "gosuda.org/portal/portal"
10 +)
11 +
12 +const staleLeaseHideWindow = 3 * time.Minute
13 +
14 +type LeaseRow struct {
15 + TTL string
16 + Metadata string
17 + Kind string
18 + IP string
19 + DNS string
20 + LastSeen string
21 + LastSeenISO string
22 + FirstSeenISO string
23 + Name string
24 + Peer string
25 + Link string
26 + BPS int64
27 + Hide bool
28 + StaleRed bool
29 + IsApproved bool
30 + IsDenied bool
31 + Connected bool
32 + IsIPBanned bool
33 +}
34 +
35 +func BuildLeaseRows(serv *portal.Server, includeAdmin bool, portalURL string) []LeaseRow {
36 + if serv == nil {
37 + return nil
38 + }
39 +
40 + now := time.Now()
41 + snapshots := serv.ListLeases()
42 + rows := make([]LeaseRow, 0, len(snapshots))
43 + for _, snapshot := range snapshots {
44 + if now.After(snapshot.ExpiresAt) {
45 + continue
46 + }
47 +
48 + since := time.Duration(0)
49 + if !snapshot.LastSeenAt.IsZero() {
50 + since = max(now.Sub(snapshot.LastSeenAt), 0)
51 + }
52 + connected := snapshot.Ready > 0
53 + if !includeAdmin {
54 + if snapshot.IsBanned || snapshot.IsDenied || !snapshot.IsApproved || snapshot.Metadata.Hide {
55 + continue
56 + }
57 + if !connected && since >= staleLeaseHideWindow {
58 + continue
59 + }
60 + }
61 +
62 + metadataJSON, _ := json.Marshal(snapshot.Metadata)
63 + host := ""
64 + if len(snapshot.Hostnames) > 0 {
65 + host = snapshot.Hostnames[0]
66 + }
67 +
68 + rows = append(rows, LeaseRow{
69 + TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
70 + Metadata: string(metadataJSON),
71 + Kind: "https",
72 + IP: snapshot.ClientIP,
73 + DNS: host,
74 + LastSeen: formatLastSeen(since),
75 + LastSeenISO: formatISOTime(snapshot.LastSeenAt),
76 + FirstSeenISO: formatISOTime(snapshot.FirstSeenAt),
77 + Name: strings.TrimSpace(snapshot.Name),
78 + Peer: snapshot.ID,
79 + Link: leaseLink(host),
80 + BPS: 0,
81 + Hide: snapshot.Metadata.Hide,
82 + StaleRed: !connected && since >= staleLeaseHideWindow,
83 + IsApproved: snapshot.IsApproved,
84 + IsDenied: snapshot.IsDenied,
85 + Connected: connected,
86 + IsIPBanned: snapshot.IsIPBanned,
87 + })
88 + }
89 + return rows
90 +}
91 +
92 +func formatDuration(d time.Duration) string {
93 + if d <= 0 {
94 + return ""
95 + }
96 + if d > time.Hour {
97 + return fmt.Sprintf("%.0fh", d.Hours())
98 + }
99 + if d > time.Minute {
100 + return fmt.Sprintf("%.0fm", d.Minutes())
101 + }
102 + return fmt.Sprintf("%.0fs", d.Seconds())
103 +}
104 +
105 +func formatLastSeen(d time.Duration) string {
106 + if d <= 0 {
107 + return ""
108 + }
109 + if d >= time.Hour {
110 + hours := int(d / time.Hour)
111 + minutes := int((d % time.Hour) / time.Minute)
112 + if minutes > 0 {
113 + return fmt.Sprintf("%dh %dm", hours, minutes)
114 + }
115 + return fmt.Sprintf("%dh", hours)
116 + }
117 + if d >= time.Minute {
118 + minutes := int(d / time.Minute)
119 + seconds := int((d % time.Minute) / time.Second)
120 + if seconds > 0 {
121 + return fmt.Sprintf("%dm %ds", minutes, seconds)
122 + }
123 + return fmt.Sprintf("%dm", minutes)
124 + }
125 + return fmt.Sprintf("%ds", int(d/time.Second))
126 +}
127 +
128 +func formatISOTime(ts time.Time) string {
129 + if ts.IsZero() {
130 + return ""
131 + }
132 + return ts.UTC().Format(time.RFC3339)
133 +}
134 +
135 +func leaseLink(host string) string {
136 + host = strings.TrimSpace(host)
137 + if host == "" {
138 + return ""
139 + }
140 + return "https://" + host + "/"
141 +}
portal/admin/state.go new
+114
@@ -0,0 +1,114 @@
1 +package admin
2 +
3 +import (
4 + "encoding/json"
5 + "errors"
6 + "os"
7 + "path/filepath"
8 +
9 + "gosuda.org/portal/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 new
+51
@@ -0,0 +1,51 @@
1 +package admin
2 +
3 +import (
4 + "os"
5 + "path/filepath"
6 + "testing"
7 +
8 + "gosuda.org/portal/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/policy/approver.go new
+104
@@ -0,0 +1,104 @@
1 +package policy
2 +
3 +import (
4 + "fmt"
5 + "sync"
6 +)
7 +
8 +type Mode string
9 +
10 +const (
11 + ModeAuto Mode = "auto"
12 + ModeManual Mode = "manual"
13 +)
14 +
15 +type Approver struct {
16 + approvedLeases map[string]struct{}
17 + deniedLeases map[string]struct{}
18 + approvalMode Mode
19 + mu sync.RWMutex
20 +}
21 +
22 +func NewApprover() *Approver {
23 + return &Approver{
24 + approvalMode: ModeAuto,
25 + approvedLeases: make(map[string]struct{}),
26 + deniedLeases: make(map[string]struct{}),
27 + }
28 +}
29 +
30 +func (a *Approver) Mode() Mode {
31 + a.mu.RLock()
32 + defer a.mu.RUnlock()
33 + return a.approvalMode
34 +}
35 +
36 +func (a *Approver) SetMode(mode Mode) error {
37 + if mode != ModeAuto && mode != ModeManual {
38 + return fmt.Errorf("invalid approval mode: %q", mode)
39 + }
40 + a.mu.Lock()
41 + defer a.mu.Unlock()
42 + a.approvalMode = mode
43 + return nil
44 +}
45 +
46 +func (a *Approver) IsApproved(leaseID string) bool {
47 + a.mu.RLock()
48 + defer a.mu.RUnlock()
49 + _, ok := a.approvedLeases[leaseID]
50 + return ok
51 +}
52 +
53 +func (a *Approver) Approve(leaseID string) {
54 + a.mu.Lock()
55 + defer a.mu.Unlock()
56 + a.approvedLeases[leaseID] = struct{}{}
57 + delete(a.deniedLeases, leaseID)
58 +}
59 +
60 +func (a *Approver) Revoke(leaseID string) {
61 + a.mu.Lock()
62 + defer a.mu.Unlock()
63 + delete(a.approvedLeases, leaseID)
64 +}
65 +
66 +func (a *Approver) ApprovedLeases() []string {
67 + a.mu.RLock()
68 + defer a.mu.RUnlock()
69 + out := make([]string, 0, len(a.approvedLeases))
70 + for leaseID := range a.approvedLeases {
71 + out = append(out, leaseID)
72 + }
73 + return out
74 +}
75 +
76 +func (a *Approver) IsDenied(leaseID string) bool {
77 + a.mu.RLock()
78 + defer a.mu.RUnlock()
79 + _, ok := a.deniedLeases[leaseID]
80 + return ok
81 +}
82 +
83 +func (a *Approver) Deny(leaseID string) {
84 + a.mu.Lock()
85 + defer a.mu.Unlock()
86 + a.deniedLeases[leaseID] = struct{}{}
87 + delete(a.approvedLeases, leaseID)
88 +}
89 +
90 +func (a *Approver) Undeny(leaseID string) {
91 + a.mu.Lock()
92 + defer a.mu.Unlock()
93 + delete(a.deniedLeases, leaseID)
94 +}
95 +
96 +func (a *Approver) DeniedLeases() []string {
97 + a.mu.RLock()
98 + defer a.mu.RUnlock()
99 + out := make([]string, 0, len(a.deniedLeases))
100 + for leaseID := range a.deniedLeases {
101 + out = append(out, leaseID)
102 + }
103 + return out
104 +}
portal/policy/authenticator.go new
+232
@@ -0,0 +1,232 @@
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 + ip string
173 + lastSeenAt time.Time
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 new
+18
@@ -0,0 +1,18 @@
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 new
+264
@@ -0,0 +1,264 @@
1 +package policy
2 +
3 +import (
4 + "fmt"
5 + "net"
6 + "net/http"
7 + "slices"
8 + "strings"
9 + "sync"
10 +)
11 +
12 +const (
13 + xForwardedForHeader = "X-Forwarded-For"
14 + xRealIPHeader = "X-Real-IP"
15 + xForwardedProto = "X-Forwarded-Proto"
16 +)
17 +
18 +type IPFilter struct {
19 + bannedIPs map[string]struct{}
20 + leaseToIP map[string]string
21 + ipToLeases map[string][]string
22 + mu sync.RWMutex
23 +}
24 +
25 +var (
26 + trustedProxyMu sync.RWMutex
27 + trustedProxyCIDRs []*net.IPNet
28 +)
29 +
30 +func NewIPFilter() *IPFilter {
31 + return &IPFilter{
32 + bannedIPs: make(map[string]struct{}),
33 + leaseToIP: make(map[string]string),
34 + ipToLeases: make(map[string][]string),
35 + }
36 +}
37 +
38 +func ParseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) {
39 + raw = strings.TrimSpace(raw)
40 + if raw == "" {
41 + return nil, nil
42 + }
43 +
44 + parts := strings.Split(raw, ",")
45 + cidrs := make([]*net.IPNet, 0, len(parts))
46 + seen := make(map[string]struct{}, len(parts))
47 + for _, part := range parts {
48 + part = strings.TrimSpace(part)
49 + if part == "" {
50 + continue
51 + }
52 + _, network, err := net.ParseCIDR(part)
53 + if err != nil {
54 + return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", part, err)
55 + }
56 + key := network.String()
57 + if _, ok := seen[key]; ok {
58 + continue
59 + }
60 + seen[key] = struct{}{}
61 + cidrs = append(cidrs, network)
62 + }
63 + return cidrs, nil
64 +}
65 +
66 +func SetTrustedProxyCIDRs(cidrs []*net.IPNet) {
67 + trustedProxyMu.Lock()
68 + defer trustedProxyMu.Unlock()
69 + if len(cidrs) == 0 {
70 + trustedProxyCIDRs = nil
71 + return
72 + }
73 + trustedProxyCIDRs = append(make([]*net.IPNet, 0, len(cidrs)), cidrs...)
74 +}
75 +
76 +func IsTrustedProxyRemoteAddr(remoteAddr string) bool {
77 + remoteIP := parseRemoteAddrIP(remoteAddr)
78 + if remoteIP == nil {
79 + return false
80 + }
81 +
82 + trustedProxyMu.RLock()
83 + defer trustedProxyMu.RUnlock()
84 + for _, network := range trustedProxyCIDRs {
85 + if network != nil && network.Contains(remoteIP) {
86 + return true
87 + }
88 + }
89 + return false
90 +}
91 +
92 +func ExtractClientIP(r *http.Request, trustProxyHeaders bool) string {
93 + if r == nil {
94 + return ""
95 + }
96 +
97 + if trustProxyHeaders && IsTrustedProxyRemoteAddr(r.RemoteAddr) {
98 + if xff := r.Header.Get(xForwardedForHeader); xff != "" {
99 + if before, _, ok := strings.Cut(xff, ","); ok {
100 + if ip := normalizeClientIPCandidate(before); ip != "" {
101 + return ip
102 + }
103 + } else if ip := normalizeClientIPCandidate(xff); ip != "" {
104 + return ip
105 + }
106 + }
107 + if xri := r.Header.Get(xRealIPHeader); xri != "" {
108 + if ip := normalizeClientIPCandidate(xri); ip != "" {
109 + return ip
110 + }
111 + }
112 + }
113 +
114 + host, _, err := net.SplitHostPort(r.RemoteAddr)
115 + if err != nil {
116 + return strings.TrimSpace(r.RemoteAddr)
117 + }
118 + if normalized := normalizeClientIPCandidate(host); normalized != "" {
119 + return normalized
120 + }
121 + return strings.TrimSpace(host)
122 +}
123 +
124 +func IsSecureForwardedRequest(r *http.Request, trustProxyHeaders bool) bool {
125 + if r == nil {
126 + return false
127 + }
128 + if r.TLS != nil {
129 + return true
130 + }
131 + if !trustProxyHeaders || !IsTrustedProxyRemoteAddr(r.RemoteAddr) {
132 + return false
133 + }
134 + return strings.EqualFold(strings.TrimSpace(r.Header.Get(xForwardedProto)), "https")
135 +}
136 +
137 +func (f *IPFilter) BanIP(ip string) {
138 + f.mu.Lock()
139 + defer f.mu.Unlock()
140 + f.bannedIPs[strings.TrimSpace(ip)] = struct{}{}
141 +}
142 +
143 +func (f *IPFilter) UnbanIP(ip string) {
144 + f.mu.Lock()
145 + defer f.mu.Unlock()
146 + delete(f.bannedIPs, strings.TrimSpace(ip))
147 +}
148 +
149 +func (f *IPFilter) IsIPBanned(ip string) bool {
150 + f.mu.RLock()
151 + defer f.mu.RUnlock()
152 + _, ok := f.bannedIPs[strings.TrimSpace(ip)]
153 + return ok
154 +}
155 +
156 +func (f *IPFilter) BannedIPs() []string {
157 + f.mu.RLock()
158 + defer f.mu.RUnlock()
159 + out := make([]string, 0, len(f.bannedIPs))
160 + for ip := range f.bannedIPs {
161 + out = append(out, ip)
162 + }
163 + return out
164 +}
165 +
166 +func (f *IPFilter) SetBannedIPs(ips []string) {
167 + f.mu.Lock()
168 + defer f.mu.Unlock()
169 + f.bannedIPs = make(map[string]struct{}, len(ips))
170 + for _, ip := range ips {
171 + ip = strings.TrimSpace(ip)
172 + if ip == "" {
173 + continue
174 + }
175 + f.bannedIPs[ip] = struct{}{}
176 + }
177 +}
178 +
179 +func (f *IPFilter) RegisterLeaseIP(leaseID, ip string) {
180 + f.mu.Lock()
181 + defer f.mu.Unlock()
182 + leaseID = strings.TrimSpace(leaseID)
183 + ip = strings.TrimSpace(ip)
184 + if leaseID == "" || ip == "" {
185 + return
186 + }
187 +
188 + if oldIP, ok := f.leaseToIP[leaseID]; ok {
189 + if oldIP == ip {
190 + return
191 + }
192 + f.removeLeaseFromIPLocked(leaseID, oldIP)
193 + }
194 + if slices.Contains(f.ipToLeases[ip], leaseID) {
195 + f.leaseToIP[leaseID] = ip
196 + return
197 + }
198 +
199 + f.leaseToIP[leaseID] = ip
200 + f.ipToLeases[ip] = append(f.ipToLeases[ip], leaseID)
201 +}
202 +
203 +func (f *IPFilter) LeaseIP(leaseID string) string {
204 + f.mu.RLock()
205 + defer f.mu.RUnlock()
206 + return f.leaseToIP[strings.TrimSpace(leaseID)]
207 +}
208 +
209 +func (f *IPFilter) RemoveLeaseIP(leaseID string) {
210 + f.mu.Lock()
211 + defer f.mu.Unlock()
212 +
213 + leaseID = strings.TrimSpace(leaseID)
214 + ip, ok := f.leaseToIP[leaseID]
215 + if !ok {
216 + return
217 + }
218 + delete(f.leaseToIP, leaseID)
219 + f.removeLeaseFromIPLocked(leaseID, ip)
220 +}
221 +
222 +func (f *IPFilter) removeLeaseFromIPLocked(leaseID, ip string) {
223 + leases := f.ipToLeases[ip]
224 + for i, candidate := range leases {
225 + if candidate == leaseID {
226 + f.ipToLeases[ip] = append(leases[:i], leases[i+1:]...)
227 + break
228 + }
229 + }
230 + if len(f.ipToLeases[ip]) == 0 {
231 + delete(f.ipToLeases, ip)
232 + }
233 +}
234 +
235 +func normalizeClientIPCandidate(raw string) string {
236 + candidate := strings.TrimSpace(raw)
237 + if candidate == "" {
238 + return ""
239 + }
240 + if ip := net.ParseIP(candidate); ip != nil {
241 + return candidate
242 + }
243 + host, _, err := net.SplitHostPort(candidate)
244 + if err != nil {
245 + return ""
246 + }
247 + host = strings.TrimSpace(host)
248 + if host == "" || net.ParseIP(host) == nil {
249 + return ""
250 + }
251 + return host
252 +}
253 +
254 +func parseRemoteAddrIP(remoteAddr string) net.IP {
255 + remoteAddr = strings.TrimSpace(remoteAddr)
256 + if remoteAddr == "" {
257 + return nil
258 + }
259 + host := remoteAddr
260 + if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
261 + host = parsedHost
262 + }
263 + return net.ParseIP(strings.TrimSpace(host))
264 +}
portal/policy/runtime.go new
+115
@@ -0,0 +1,115 @@
1 +package policy
2 +
3 +import (
4 + "strings"
5 + "sync"
6 +)
7 +
8 +type Runtime struct {
9 + approver *Approver
10 + ipFilter *IPFilter
11 + bannedLeases map[string]struct{}
12 + mu sync.RWMutex
13 +}
14 +
15 +func NewRuntime() *Runtime {
16 + return &Runtime{
17 + approver: NewApprover(),
18 + ipFilter: NewIPFilter(),
19 + bannedLeases: make(map[string]struct{}),
20 + }
21 +}
22 +
23 +func (r *Runtime) Approver() *Approver {
24 + if r == nil {
25 + return nil
26 + }
27 + return r.approver
28 +}
29 +
30 +func (r *Runtime) IPFilter() *IPFilter {
31 + if r == nil {
32 + return nil
33 + }
34 + return r.ipFilter
35 +}
36 +
37 +func (r *Runtime) BanLease(leaseID string) {
38 + if r == nil {
39 + return
40 + }
41 + leaseID = strings.TrimSpace(leaseID)
42 + if leaseID == "" {
43 + return
44 + }
45 + r.mu.Lock()
46 + defer r.mu.Unlock()
47 + r.bannedLeases[leaseID] = struct{}{}
48 +}
49 +
50 +func (r *Runtime) UnbanLease(leaseID string) {
51 + if r == nil {
52 + return
53 + }
54 + leaseID = strings.TrimSpace(leaseID)
55 + r.mu.Lock()
56 + defer r.mu.Unlock()
57 + delete(r.bannedLeases, leaseID)
58 +}
59 +
60 +func (r *Runtime) IsLeaseBanned(leaseID string) bool {
61 + if r == nil {
62 + return false
63 + }
64 + r.mu.RLock()
65 + defer r.mu.RUnlock()
66 + _, ok := r.bannedLeases[strings.TrimSpace(leaseID)]
67 + return ok
68 +}
69 +
70 +func (r *Runtime) BannedLeases() []string {
71 + if r == nil {
72 + return nil
73 + }
74 + r.mu.RLock()
75 + defer r.mu.RUnlock()
76 + out := make([]string, 0, len(r.bannedLeases))
77 + for leaseID := range r.bannedLeases {
78 + out = append(out, leaseID)
79 + }
80 + return out
81 +}
82 +
83 +func (r *Runtime) EffectiveApproval(leaseID string) bool {
84 + if r == nil || r.approver == nil {
85 + return true
86 + }
87 + if r.approver.Mode() == ModeAuto {
88 + return true
89 + }
90 + return r.approver.IsApproved(strings.TrimSpace(leaseID))
91 +}
92 +
93 +func (r *Runtime) IsLeaseDenied(leaseID string) bool {
94 + if r == nil || r.approver == nil {
95 + return false
96 + }
97 + return r.approver.IsDenied(strings.TrimSpace(leaseID))
98 +}
99 +
100 +func (r *Runtime) IsLeaseRoutable(leaseID string) bool {
101 + if r == nil {
102 + return true
103 + }
104 + if r.IsLeaseBanned(leaseID) || r.IsLeaseDenied(leaseID) {
105 + return false
106 + }
107 + return r.EffectiveApproval(leaseID)
108 +}
109 +
110 +func (r *Runtime) ForgetLease(leaseID string) {
111 + if r == nil || r.ipFilter == nil {
112 + return
113 + }
114 + r.ipFilter.RemoveLeaseIP(leaseID)
115 +}
portal/server.go
+157 -36
@@ -19,12 +19,14 @@ import (
19
20 "github.com/gosuda/keyless_tls/relay/l4"
21 "gosuda.org/portal/portal/keyless"
22 + "gosuda.org/portal/portal/policy"
23 "gosuda.org/portal/types"
24 )
25
26 type ServerConfig struct {
27 APIHandlerWrapper func(http.Handler) http.Handler
28 KeylessSignerHandler http.Handler
29 + Policy *policy.Runtime
30 PortalURL string
31 APIListenAddr string
32 SNIListenAddr string
@@ -36,6 +38,7 @@ type ServerConfig struct {
38 IdleKeepaliveInterval time.Duration
39 ReadyQueueLimit int
40 ClientHelloTimeout time.Duration
41 + TrustProxyHeaders bool
42 }
43
44 type Server struct {
@@ -56,21 +59,31 @@ type Server struct {
59
60 type leaseRecord struct {
61 ExpiresAt time.Time
62 + FirstSeenAt time.Time
63 + LastSeenAt time.Time
64 Broker *leaseBroker
65 ID string
66 Name string
67 ReverseToken string
68 + ClientIP string
69 Hostnames []string
70 Metadata types.LeaseMetadata
71 }
72
73 type LeaseSnapshot struct {
68 - ExpiresAt time.Time
69 - ID string
70 - Name string
71 - Hostnames []string
72 - Metadata types.LeaseMetadata
73 - Ready int
74 + ExpiresAt time.Time
75 + FirstSeenAt time.Time
76 + LastSeenAt time.Time
77 + ID string
78 + Name string
79 + ClientIP string
80 + Hostnames []string
81 + Metadata types.LeaseMetadata
82 + Ready int
83 + IsApproved bool
84 + IsBanned bool
85 + IsDenied bool
86 + IsIPBanned bool
87 }
88
89 func NewServer(cfg ServerConfig) (*Server, error) {
@@ -88,6 +101,9 @@ func NewServer(cfg ServerConfig) (*Server, error) {
101 if cfg.RootHost == "" {
102 cfg.RootHost = PortalRootHost(cfg.PortalURL)
103 }
104 + if cfg.Policy == nil {
105 + cfg.Policy = policy.NewRuntime()
106 + }
107 if cfg.RootHost == "" {
108 return nil, errors.New("root host is required")
109 }
@@ -214,14 +230,7 @@ func (s *Server) GetLease(leaseID string) (LeaseSnapshot, bool) {
230 if !ok {
231 return LeaseSnapshot{}, false
232 }
217 - return LeaseSnapshot{
218 - ID: record.ID,
219 - Name: record.Name,
220 - Hostnames: append([]string(nil), record.Hostnames...),
221 - Metadata: record.Metadata,
222 - ExpiresAt: record.ExpiresAt,
223 - Ready: record.Broker.ReadyCount(),
224 - }, true
233 + return s.snapshotForLease(record), true
234 }
235
236 func (s *Server) ListLeases() []LeaseSnapshot {
@@ -230,14 +239,7 @@ func (s *Server) ListLeases() []LeaseSnapshot {
239
240 out := make([]LeaseSnapshot, 0, len(s.leases))
241 for _, record := range s.leases {
233 - out = append(out, LeaseSnapshot{
234 - ID: record.ID,
235 - Name: record.Name,
236 - Hostnames: append([]string(nil), record.Hostnames...),
237 - Metadata: record.Metadata,
238 - ExpiresAt: record.ExpiresAt,
239 - Ready: record.Broker.ReadyCount(),
240 - })
242 + out = append(out, s.snapshotForLease(record))
243 }
244 return out
245 }
@@ -285,17 +287,25 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
287 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
288 return
289 }
290 + clientIP := s.clientIPFromRequest(r)
291 + if s.isClientIPBanned(clientIP) {
292 + writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
293 + return
294 + }
295 var req types.RegisterRequest
296 if err := decodeJSONBody(w, r, &req); err != nil {
297 writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
298 return
299 }
293 - resp, err := s.registerLease(req)
300 + resp, err := s.registerLease(req, clientIP)
301 if err != nil {
302 status, code := http.StatusBadRequest, "invalid_request"
303 if errors.Is(err, errHostnameConflict) {
304 status, code = http.StatusConflict, "hostname_conflict"
305 }
306 + if errors.Is(err, errIPBanned) {
307 + status, code = http.StatusForbidden, "ip_banned"
308 + }
309 writeAPIError(w, status, code, err.Error())
310 return
311 }
@@ -307,12 +317,17 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
317 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
318 return
319 }
320 + clientIP := s.clientIPFromRequest(r)
321 + if s.isClientIPBanned(clientIP) {
322 + writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
323 + return
324 + }
325 var req types.RenewRequest
326 if err := decodeJSONBody(w, r, &req); err != nil {
327 writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
328 return
329 }
315 - resp, err := s.renewLease(req)
330 + resp, err := s.renewLease(req, clientIP)
331 if err != nil {
332 status, code := http.StatusBadRequest, "invalid_request"
333 if errors.Is(err, errLeaseNotFound) {
@@ -321,6 +336,9 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
336 if errors.Is(err, errUnauthorized) {
337 status, code = http.StatusForbidden, "unauthorized"
338 }
339 + if errors.Is(err, errIPBanned) {
340 + status, code = http.StatusForbidden, "ip_banned"
341 + }
342 writeAPIError(w, status, code, err.Error())
343 return
344 }
@@ -363,13 +381,23 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
381
382 leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
383 token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
366 - lease, err := s.lookupLeaseByID(leaseID, token)
384 + clientIP := s.clientIPFromRequest(r)
385 + if s.isClientIPBanned(clientIP) {
386 + writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
387 + return
388 + }
389 +
390 + lease, err := s.findLeaseByID(leaseID)
391 if err != nil {
368 - status, code := http.StatusForbidden, "unauthorized"
369 - if errors.Is(err, errLeaseNotFound) {
370 - status, code = http.StatusNotFound, "lease_not_found"
371 - }
372 - writeAPIError(w, status, code, err.Error())
392 + writeAPIError(w, http.StatusNotFound, "lease_not_found", err.Error())
393 + return
394 + }
395 + if !s.isLeaseRoutable(lease) {
396 + writeAPIError(w, http.StatusForbidden, "lease_rejected", "lease is not approved for routing")
397 + return
398 + }
399 + if err := s.authorizeLeaseToken(lease, token); err != nil {
400 + writeAPIError(w, http.StatusForbidden, "unauthorized", err.Error())
401 return
402 }
403
@@ -406,6 +434,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
434 _ = session.Close()
435 return
436 }
437 + s.touchLease(lease.ID, clientIP)
438 log.Info().
439 Str("component", "relay-server").
440 Str("lease_id", lease.ID).
@@ -415,7 +444,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
444 Msg("sdk reverse connected")
445 }
446
418 -func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterResponse, error) {
447 +func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (types.RegisterResponse, error) {
448 if strings.TrimSpace(req.Name) == "" {
449 return types.RegisterResponse{}, errors.New("name is required")
450 }
@@ -425,6 +454,9 @@ func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterRespons
454 if !req.TLS {
455 return types.RegisterResponse{}, errors.New("tls must be true")
456 }
457 + if s.isClientIPBanned(clientIP) {
458 + return types.RegisterResponse{}, errIPBanned
459 + }
460
461 hostnames := normalizeHostnames(req.Hostnames)
462 if len(hostnames) == 0 {
@@ -446,7 +478,8 @@ func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterRespons
478 }
479
480 leaseID := randomID("lease_")
449 - expiresAt := time.Now().Add(ttl)
481 + now := time.Now()
482 + expiresAt := now.Add(ttl)
483 record := &leaseRecord{
484 ID: leaseID,
485 Name: strings.TrimSpace(req.Name),
@@ -454,6 +487,9 @@ func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterRespons
487 Metadata: normalizeMetadata(req.Metadata),
488 ReverseToken: req.ReverseToken,
489 ExpiresAt: expiresAt,
490 + FirstSeenAt: now,
491 + LastSeenAt: now,
492 + ClientIP: clientIP,
493 Broker: newLeaseBroker(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
494 }
495
@@ -461,6 +497,9 @@ func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterRespons
497 for _, host := range hostnames {
498 s.routes.Set(host, leaseID)
499 }
500 + if strings.TrimSpace(clientIP) != "" {
501 + s.cfg.Policy.IPFilter().RegisterLeaseIP(leaseID, clientIP)
502 + }
503
504 return types.RegisterResponse{
505 LeaseID: leaseID,
@@ -471,7 +510,11 @@ func (s *Server) registerLease(req types.RegisterRequest) (types.RegisterRespons
510 }, nil
511 }
512
474 -func (s *Server) renewLease(req types.RenewRequest) (types.RenewResponse, error) {
513 +func (s *Server) renewLease(req types.RenewRequest, clientIP string) (types.RenewResponse, error) {
514 + if s.isClientIPBanned(clientIP) {
515 + return types.RenewResponse{}, errIPBanned
516 + }
517 +
518 s.mu.Lock()
519 defer s.mu.Unlock()
520
@@ -488,6 +531,11 @@ func (s *Server) renewLease(req types.RenewRequest) (types.RenewResponse, error)
531 ttl = time.Duration(req.TTLSeconds) * time.Second
532 }
533 record.ExpiresAt = time.Now().Add(ttl)
534 + record.LastSeenAt = time.Now()
535 + if strings.TrimSpace(clientIP) != "" {
536 + record.ClientIP = clientIP
537 + s.cfg.Policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
538 + }
539 record.Broker.Reset()
540 return types.RenewResponse{LeaseID: record.ID, ExpiresAt: record.ExpiresAt}, nil
541 }
@@ -507,11 +555,12 @@ func (s *Server) unregisterLease(req types.UnregisterRequest) error {
555 s.mu.Unlock()
556
557 s.routes.DeleteLease(record.Hostnames)
558 + s.cfg.Policy.ForgetLease(record.ID)
559 record.Broker.Drop()
560 return nil
561 }
562
514 -func (s *Server) lookupLeaseByID(leaseID, token string) (*leaseRecord, error) {
563 +func (s *Server) findLeaseByID(leaseID string) (*leaseRecord, error) {
564 s.mu.RLock()
565 record, ok := s.leases[strings.TrimSpace(leaseID)]
566 s.mu.RUnlock()
@@ -521,10 +570,17 @@ func (s *Server) lookupLeaseByID(leaseID, token string) (*leaseRecord, error) {
570 if time.Now().After(record.ExpiresAt) {
571 return nil, errLeaseNotFound
572 }
573 + return record, nil
574 +}
575 +
576 +func (s *Server) authorizeLeaseToken(record *leaseRecord, token string) error {
577 + if record == nil {
578 + return errLeaseNotFound
579 + }
580 if !tokenMatches(record.ReverseToken, token) {
525 - return nil, errUnauthorized
581 + return errUnauthorized
582 }
527 - return record, nil
583 + return nil
584 }
585
586 func (s *Server) findLeaseByHostnameLocked(host string) *leaseRecord {
@@ -591,6 +647,10 @@ func (s *Server) handleSNIConn(conn net.Conn) {
647 _ = wrappedConn.Close()
648 return
649 }
650 + if !s.isLeaseRoutable(record) {
651 + _ = wrappedConn.Close()
652 + return
653 + }
654
655 claimCtx, cancel := context.WithTimeout(s.context(), s.cfg.ClaimTimeout)
656 defer cancel()
@@ -643,6 +703,7 @@ func (s *Server) cleanupExpiredLeases() {
703
704 for _, lease := range expired {
705 s.routes.DeleteLease(lease.Hostnames)
706 + s.cfg.Policy.ForgetLease(lease.ID)
707 lease.Broker.Drop()
708 }
709 }
@@ -674,6 +735,7 @@ func (s *Server) wrapAPIHandler(base http.Handler) http.Handler {
735
736 var (
737 errLeaseNotFound = errors.New("lease not found")
738 + errIPBanned = errors.New("request denied because source IP is banned")
739 errUnauthorized = errors.New("unauthorized")
740 errHostnameConflict = errors.New("hostname already registered")
741 )
@@ -774,3 +836,62 @@ func (s *Server) isClosed() bool {
836 return false
837 }
838 }
839 +
840 +func (s *Server) snapshotForLease(record *leaseRecord) LeaseSnapshot {
841 + if record == nil {
842 + return LeaseSnapshot{}
843 + }
844 + clientIP := record.ClientIP
845 + runtime := s.cfg.Policy
846 + return LeaseSnapshot{
847 + ID: record.ID,
848 + Name: record.Name,
849 + ClientIP: clientIP,
850 + Hostnames: append([]string(nil), record.Hostnames...),
851 + Metadata: record.Metadata,
852 + ExpiresAt: record.ExpiresAt,
853 + FirstSeenAt: record.FirstSeenAt,
854 + LastSeenAt: record.LastSeenAt,
855 + Ready: record.Broker.ReadyCount(),
856 + IsApproved: runtime.EffectiveApproval(record.ID),
857 + IsBanned: runtime.IsLeaseBanned(record.ID),
858 + IsDenied: runtime.IsLeaseDenied(record.ID),
859 + IsIPBanned: runtime.IPFilter().IsIPBanned(clientIP),
860 + }
861 +}
862 +
863 +func (s *Server) clientIPFromRequest(r *http.Request) string {
864 + if r == nil {
865 + return ""
866 + }
867 + return policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders)
868 +}
869 +
870 +func (s *Server) isClientIPBanned(clientIP string) bool {
871 + return s.cfg.Policy.IPFilter().IsIPBanned(clientIP)
872 +}
873 +
874 +func (s *Server) isLeaseRoutable(record *leaseRecord) bool {
875 + if record == nil {
876 + return false
877 + }
878 + return s.cfg.Policy.IsLeaseRoutable(record.ID)
879 +}
880 +
881 +func (s *Server) touchLease(leaseID, clientIP string) {
882 + now := time.Now()
883 +
884 + s.mu.Lock()
885 + record := s.leases[strings.TrimSpace(leaseID)]
886 + if record != nil {
887 + record.LastSeenAt = now
888 + if strings.TrimSpace(clientIP) != "" {
889 + record.ClientIP = clientIP
890 + }
891 + }
892 + s.mu.Unlock()
893 +
894 + if record != nil && strings.TrimSpace(clientIP) != "" {
895 + s.cfg.Policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
896 + }
897 +}
types/api.go
+29
@@ -64,3 +64,32 @@ type DomainResponse struct {
64 RootHost string `json:"root_host"`
65 SuggestedHostname string `json:"suggested_hostname"`
66 }
67 +
68 +type AdminLoginRequest struct {
69 + Key string `json:"key"`
70 +}
71 +
72 +type AdminLoginResponse struct {
73 + Success bool `json:"success,omitempty"`
74 + Locked bool `json:"locked,omitempty"`
75 + RemainingSeconds int `json:"remaining_seconds,omitempty"`
76 +}
77 +
78 +type AdminAuthStatusResponse struct {
79 + Authenticated bool `json:"authenticated"`
80 + AuthEnabled bool `json:"auth_enabled"`
81 +}
82 +
83 +type AdminApprovalModeRequest struct {
84 + Mode string `json:"mode"`
85 +}
86 +
87 +type AdminApprovalModeResponse struct {
88 + ApprovalMode string `json:"approval_mode"`
89 +}
90 +
91 +type AdminSettingsResponse struct {
92 + ApprovalMode string `json:"approval_mode"`
93 + ApprovedLeases []string `json:"approved_leases,omitempty"`
94 + DeniedLeases []string `json:"denied_leases,omitempty"`
95 +}
types/paths.go
+19 -11
@@ -1,17 +1,25 @@
1 package types
2
3 const (
4 - PathV1Sign = "/v1/sign"
5 - PathHealthz = "/healthz"
6 - PathRoot = "/"
7 - PathAssetsPrefix = "/assets/"
8 - PathApp = "/app"
9 - PathAppPrefix = "/app/"
10 - PathAdmin = "/admin"
11 - PathAdminPrefix = "/admin/"
12 - PathAdminLeases = "/admin/leases"
13 - PathTunnel = "/tunnel"
14 - PathTunnelBinPrefix = "/tunnel/bin/"
4 + PathV1Sign = "/v1/sign"
5 + PathHealthz = "/healthz"
6 + PathRoot = "/"
7 + PathAssetsPrefix = "/assets/"
8 + PathApp = "/app"
9 + PathAppPrefix = "/app/"
10 + PathAdmin = "/admin"
11 + PathAdminPrefix = "/admin/"
12 + PathAdminLeases = "/admin/leases"
13 + PathAdminLeasesPrefix = "/admin/leases/"
14 + PathAdminBanned = "/admin/leases/banned"
15 + PathAdminLogin = "/admin/login"
16 + PathAdminLogout = "/admin/logout"
17 + PathAdminAuthStatus = "/admin/auth/status"
18 + PathAdminSettings = "/admin/settings"
19 + PathAdminApproval = "/admin/settings/approval-mode"
20 + PathAdminIPsPrefix = "/admin/ips/"
21 + PathTunnel = "/tunnel"
22 + PathTunnelBinPrefix = "/tunnel/bin/"
23
24 PathSDKPrefix = "/sdk/"
25 PathSDKDomain = "/sdk/domain"