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"