feat: Add admin authentication system with secret key
Hee Sung Son committed
Dec 21, 2025 at 12:32 UTC
57ba74a3156fc2550b2acc708a33b4b575bc48a0
19 files changed
+920
-27
Dockerfile
+1
@@ -28,6 +28,7 @@ COPY --from=builder /src/bin/relay-server /usr/bin/relay-server
28
ENV PORTAL_URL=http://localhost:4017
29
ENV PORTAL_APP_URL=http://*.localhost:4017
30
ENV BOOTSTRAP_URIS=ws://localhost:4017/relay
31
+ENV ADMIN_SECRET_KEY=
32
ENV NOINDEX=false
33
ENV TZ=UTC
34
README.md
+4
@@ -42,6 +42,10 @@ docker compose up
42
# 2. Open in browser
43
http://localhost:4017
44
45
+# 3. Access admin panel at http://localhost:4017/admin
46
+# If ADMIN_SECRET_KEY is not set, a random key will be auto-generated and shown in logs
47
+# To use your own key:
48
+ADMIN_SECRET_KEY=your-secret-key docker compose up
49
```
50
51
For a public deployment guide (DNS, TLS, reverse proxy), see [docs/portal-deploy-guide.md](docs/portal-deploy-guide.md).
cmd/relay-server/admin.go
+144
-4
@@ -18,6 +18,8 @@ import (
18
"gosuda.org/portal/utils"
19
)
20
21
+const adminCookieName = "portal_admin"
22
+
23
// Admin manages approval state and persistence for relay-server.
24
type Admin struct {
25
settingsPath string
@@ -26,11 +28,12 @@ type Admin struct {
28
approveManager *manager.ApproveManager
29
bpsManager *manager.BPSManager
30
ipManager *manager.IPManager
31
+ authManager *manager.AuthManager
32
33
frontend *Frontend
34
}
35
33
-func NewAdmin(defaultLeaseBPS int64, frontend *Frontend) *Admin {
36
+func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *manager.AuthManager) *Admin {
37
bpsManager := manager.NewBPSManager()
38
if defaultLeaseBPS > 0 {
39
bpsManager.SetDefaultBPS(defaultLeaseBPS)
@@ -40,6 +43,7 @@ func NewAdmin(defaultLeaseBPS int64, frontend *Frontend) *Admin {
43
approveManager: manager.NewApproveManager(),
44
bpsManager: bpsManager,
45
ipManager: manager.NewIPManager(),
46
+ authManager: authManager,
47
frontend: frontend,
48
}
49
}
@@ -186,14 +190,53 @@ func (a *Admin) LoadSettings(serv *portal.RelayServer) {
190
Msg("[Admin] Loaded admin settings")
191
}
192
193
+// isAuthenticated checks if the request has a valid admin session
194
+func (a *Admin) isAuthenticated(r *http.Request) bool {
195
+ // If no secret key is configured, deny all access
196
+ if a.authManager == nil || !a.authManager.HasSecretKey() {
197
+ return false
198
+ }
199
+
200
+ cookie, err := r.Cookie(adminCookieName)
201
+ if err != nil {
202
+ return false
203
+ }
204
+
205
+ return a.authManager.ValidateSession(cookie.Value)
206
+}
207
+
208
// HandleAdminRequest routes /admin/* requests.
209
func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
191
- if !utils.IsLocalhost(r) {
192
- http.Error(w, "Forbidden", http.StatusForbidden)
210
+ route := strings.Trim(strings.TrimPrefix(r.URL.Path, "/admin"), "/")
211
+
212
+ // Public routes (no authentication required)
213
+ switch {
214
+ case route == "login" && r.Method == http.MethodPost:
215
+ a.handleLogin(w, r)
216
+ return
217
+ case route == "login":
218
+ // Serve login page (GET)
219
+ a.frontend.ServeAppStatic(w, r, "", serv)
220
+ return
221
+ case route == "logout" && r.Method == http.MethodPost:
222
+ a.handleLogout(w, r)
223
+ return
224
+ case route == "auth/status" && r.Method == http.MethodGet:
225
+ a.handleAuthStatus(w, r)
226
return
227
}
228
196
- route := strings.Trim(strings.TrimPrefix(r.URL.Path, "/admin"), "/")
229
+ // Protected routes - require authentication
230
+ if !a.isAuthenticated(r) {
231
+ // For page requests (no specific route), show login page
232
+ if route == "" {
233
+ a.frontend.ServeAppStatic(w, r, "", serv)
234
+ return
235
+ }
236
+ // For API requests, return 401
237
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
238
+ return
239
+ }
240
241
switch {
242
case route == "":
@@ -226,6 +269,103 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
269
}
270
}
271
272
+// handleLogin handles POST /admin/login
273
+func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
274
+ clientIP := manager.ExtractClientIP(r)
275
+
276
+ // Check if IP is locked
277
+ if a.authManager.IsIPLocked(clientIP) {
278
+ remaining := a.authManager.GetLockRemainingSeconds(clientIP)
279
+ w.WriteHeader(http.StatusTooManyRequests)
280
+ writeJSON(w, map[string]interface{}{
281
+ "success": false,
282
+ "error": "Too many failed attempts. Please try again later.",
283
+ "locked": true,
284
+ "remaining_seconds": remaining,
285
+ })
286
+ return
287
+ }
288
+
289
+ var req struct {
290
+ Key string `json:"key"`
291
+ }
292
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
293
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
294
+ return
295
+ }
296
+
297
+ if !a.authManager.ValidateKey(req.Key) {
298
+ // Record failed attempt
299
+ nowLocked := a.authManager.RecordFailedLogin(clientIP)
300
+ log.Warn().Str("ip", clientIP).Bool("now_locked", nowLocked).Msg("[Admin] Failed login attempt")
301
+
302
+ response := map[string]interface{}{
303
+ "success": false,
304
+ "error": "Invalid key",
305
+ "locked": nowLocked,
306
+ }
307
+ if nowLocked {
308
+ response["remaining_seconds"] = 60
309
+ }
310
+ w.WriteHeader(http.StatusUnauthorized)
311
+ writeJSON(w, response)
312
+ return
313
+ }
314
+
315
+ // Successful login
316
+ a.authManager.ResetFailedLogin(clientIP)
317
+ token := a.authManager.CreateSession()
318
+
319
+ http.SetCookie(w, &http.Cookie{
320
+ Name: adminCookieName,
321
+ Value: token,
322
+ Path: "/admin",
323
+ HttpOnly: true,
324
+ SameSite: http.SameSiteStrictMode,
325
+ MaxAge: 86400, // 24 hours
326
+ })
327
+
328
+ log.Info().Str("ip", clientIP).Msg("[Admin] Successful login")
329
+ writeJSON(w, map[string]interface{}{
330
+ "success": true,
331
+ })
332
+}
333
+
334
+// handleLogout handles POST /admin/logout
335
+func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
336
+ cookie, err := r.Cookie(adminCookieName)
337
+ if err == nil && cookie.Value != "" {
338
+ a.authManager.DeleteSession(cookie.Value)
339
+ }
340
+
341
+ // Clear the cookie
342
+ http.SetCookie(w, &http.Cookie{
343
+ Name: adminCookieName,
344
+ Value: "",
345
+ Path: "/admin",
346
+ HttpOnly: true,
347
+ SameSite: http.SameSiteStrictMode,
348
+ MaxAge: -1, // Delete cookie
349
+ })
350
+
351
+ writeJSON(w, map[string]interface{}{
352
+ "success": true,
353
+ })
354
+}
355
+
356
+// handleAuthStatus handles GET /admin/auth/status
357
+func (a *Admin) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
358
+ authenticated := a.isAuthenticated(r)
359
+
360
+ // Check if secret key is configured
361
+ authEnabled := a.authManager != nil && a.authManager.HasSecretKey()
362
+
363
+ writeJSON(w, map[string]interface{}{
364
+ "authenticated": authenticated,
365
+ "auth_enabled": authEnabled,
366
+ })
367
+}
368
+
369
func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
370
parts := strings.Split(route, "/")
371
if len(parts) != 3 {
cmd/relay-server/frontend/package-lock.json
+53
@@ -14,6 +14,7 @@
14
"@radix-ui/react-scroll-area": "^1.2.10",
15
"@radix-ui/react-select": "^2.2.6",
16
"@radix-ui/react-slot": "^1.2.4",
17
+ "@radix-ui/react-tooltip": "^1.2.8",
18
"@ssgoi/react": "^2.5.5",
19
"class-variance-authority": "^0.7.1",
20
"clsx": "^2.1.1",
@@ -1672,6 +1673,58 @@
1673
}
1674
}
1675
},
1676
+ "node_modules/@radix-ui/react-tooltip": {
1677
+ "version": "1.2.8",
1678
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
1679
+ "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
1680
+ "license": "MIT",
1681
+ "dependencies": {
1682
+ "@radix-ui/primitive": "1.1.3",
1683
+ "@radix-ui/react-compose-refs": "1.1.2",
1684
+ "@radix-ui/react-context": "1.1.2",
1685
+ "@radix-ui/react-dismissable-layer": "1.1.11",
1686
+ "@radix-ui/react-id": "1.1.1",
1687
+ "@radix-ui/react-popper": "1.2.8",
1688
+ "@radix-ui/react-portal": "1.1.9",
1689
+ "@radix-ui/react-presence": "1.1.5",
1690
+ "@radix-ui/react-primitive": "2.1.3",
1691
+ "@radix-ui/react-slot": "1.2.3",
1692
+ "@radix-ui/react-use-controllable-state": "1.2.2",
1693
+ "@radix-ui/react-visually-hidden": "1.2.3"
1694
+ },
1695
+ "peerDependencies": {
1696
+ "@types/react": "*",
1697
+ "@types/react-dom": "*",
1698
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1699
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1700
+ },
1701
+ "peerDependenciesMeta": {
1702
+ "@types/react": {
1703
+ "optional": true
1704
+ },
1705
+ "@types/react-dom": {
1706
+ "optional": true
1707
+ }
1708
+ }
1709
+ },
1710
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
1711
+ "version": "1.2.3",
1712
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
1713
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
1714
+ "license": "MIT",
1715
+ "dependencies": {
1716
+ "@radix-ui/react-compose-refs": "1.1.2"
1717
+ },
1718
+ "peerDependencies": {
1719
+ "@types/react": "*",
1720
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1721
+ },
1722
+ "peerDependenciesMeta": {
1723
+ "@types/react": {
1724
+ "optional": true
1725
+ }
1726
+ }
1727
+ },
1728
"node_modules/@radix-ui/react-use-callback-ref": {
1729
"version": "1.1.1",
1730
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
cmd/relay-server/frontend/package.json
+1
@@ -18,6 +18,7 @@
18
"@radix-ui/react-scroll-area": "^1.2.10",
19
"@radix-ui/react-select": "^2.2.6",
20
"@radix-ui/react-slot": "^1.2.4",
21
+ "@radix-ui/react-tooltip": "^1.2.8",
22
"@ssgoi/react": "^2.5.5",
23
"class-variance-authority": "^0.7.1",
24
"clsx": "^2.1.1",
cmd/relay-server/frontend/src/App.tsx
+2
@@ -1,4 +1,5 @@
1
import { Admin } from "@/pages/Admin";
2
+import { AdminLogin } from "@/pages/AdminLogin";
3
import { ServerDetail } from "@/pages/ServerDetail";
4
import { ServerList } from "@/pages/ServerList";
5
import { Route, Routes } from "react-router-dom";
@@ -8,6 +9,7 @@ function App() {
9
<Routes>
10
<Route path="/" element={<ServerList />} />
11
<Route path="/server/:id" element={<ServerDetail />} />
12
+ <Route path="/admin/login" element={<AdminLogin />} />
13
<Route path="/admin" element={<Admin />} />
14
</Routes>
15
);
cmd/relay-server/frontend/src/components/Header.tsx
+33
-4
@@ -1,15 +1,22 @@
1
import { useEffect, useState } from "react";
2
-import { Moon, Sun } from "lucide-react";
2
+import { LogOut, Moon, Sun } from "lucide-react";
3
import { Button } from "@/components/ui/button";
4
+import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipProvider,
8
+ TooltipTrigger,
9
+} from "@/components/ui/tooltip";
10
import { TunnelCommandModal } from "@/components/TunnelCommandModal";
11
import clsx from "clsx";
12
13
interface HeaderProps {
14
title?: string;
15
isAdmin?: boolean;
16
+ onLogout?: () => void;
17
}
18
12
-export function Header({ title = "PORTAL", isAdmin }: HeaderProps) {
19
+export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) {
20
const [theme, setTheme] = useState<"light" | "dark">("dark");
21
22
useEffect(() => {
@@ -78,7 +85,7 @@ export function Header({ title = "PORTAL", isAdmin }: HeaderProps) {
85
</a>
86
<button
87
onClick={toggleTheme}
81
- className="text-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-secondary"
88
+ className="cursor-pointer text-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-secondary"
89
aria-label="Toggle theme"
90
>
91
{theme === "dark" ? (
@@ -89,11 +96,33 @@ export function Header({ title = "PORTAL", isAdmin }: HeaderProps) {
96
</button>
97
<TunnelCommandModal
98
trigger={
92
- <Button className={clsx(isAdmin && "hidden sm:block")}>
99
+ <Button
100
+ className={clsx(isAdmin && "hidden sm:block", "cursor-pointer")}
101
+ >
102
<span className="truncate">Add Your Server</span>
103
</Button>
104
}
105
/>
106
+ {isAdmin && onLogout && (
107
+ <TooltipProvider>
108
+ <Tooltip>
109
+ <TooltipTrigger asChild>
110
+ <Button
111
+ variant="outline"
112
+ size="icon"
113
+ onClick={onLogout}
114
+ className="cursor-pointer text-foreground hover:text-destructive"
115
+ aria-label="Logout"
116
+ >
117
+ <LogOut className="w-5 h-5" />
118
+ </Button>
119
+ </TooltipTrigger>
120
+ <TooltipContent>
121
+ <p>Logout</p>
122
+ </TooltipContent>
123
+ </Tooltip>
124
+ </TooltipProvider>
125
+ )}
126
</div>
127
</header>
128
);
cmd/relay-server/frontend/src/components/ServerCard.tsx
+9
-3
@@ -223,7 +223,7 @@ export function ServerCard({
223
data-hero-key={`server-bg-${serverId}`}
224
className={clsx(
225
"relative bg-center bg-no-repeat bg-cover rounded-xl shadow-lg hover:shadow-xl transition-shadow duration-300 z-1 border border-foreground dark:border-foreground/40",
226
- showAdminControls ? "h-[263.5px]" : "h-[174.5px]"
226
+ showAdminControls ? "h-[286px]" : "h-[174.5px]"
227
)}
228
style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
229
>
@@ -452,10 +452,16 @@ export function ServerCard({
452
/>
453
</div>
454
<DialogFooter className="gap-2 sm:gap-0">
455
- <Button variant="secondary" onClick={() => setShowBPSModal(false)}>
455
+ <Button
456
+ className="cursor-pointer"
457
+ variant="secondary"
458
+ onClick={() => setShowBPSModal(false)}
459
+ >
460
Cancel
461
</Button>
458
- <Button onClick={handleBPSSave}>Save</Button>
462
+ <Button className="cursor-pointer" onClick={handleBPSSave}>
463
+ Save
464
+ </Button>
465
</DialogFooter>
466
</DialogContent>
467
</Dialog>
cmd/relay-server/frontend/src/components/ServerListView.tsx
+5
-1
@@ -54,6 +54,8 @@ interface ServerListViewProps {
54
onBulkApprove?: (leaseIds: string[]) => void;
55
onBulkDeny?: (leaseIds: string[]) => void;
56
onBulkBan?: (leaseIds: string[]) => void;
57
+ // Logout handler (admin only)
58
+ onLogout?: () => void;
59
}
60
61
function isAdminServer(
@@ -91,6 +93,8 @@ export function ServerListView({
93
onBulkApprove,
94
onBulkDeny,
95
onBulkBan,
96
+ // Logout handler
97
+ onLogout,
98
}: ServerListViewProps) {
99
const [showFilterModal, setShowFilterModal] = useState(false);
100
const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
@@ -191,7 +195,7 @@ export function ServerListView({
195
<div className="flex flex-1 justify-center">
196
<div className="flex flex-col w-full max-w-6xl flex-1 px-0 md:px-8">
197
<div className="sticky top-0 z-10 bg-background pb-4 pt-5">
194
- <Header title={title} isAdmin={isAdmin} />
198
+ <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
199
<div className="flex items-center gap-2">
200
<div className="flex-1">
201
<SearchBar
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+1
-1
@@ -88,7 +88,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
88
<Dialog>
89
<DialogTrigger asChild>
90
{trigger || (
91
- <Button>
91
+ <Button className="cursor-pointer">
92
<span className="truncate">Add Your Server</span>
93
</Button>
94
)}
cmd/relay-server/frontend/src/components/button/ApprovalModeToggle.tsx
+2
-2
@@ -12,7 +12,7 @@ export const ApprovalModeToggle = ({
12
<div className="flex rounded-lg overflow-hidden border border-foreground/20">
13
<button
14
onClick={() => onApprovalModeChange("auto")}
15
- className={`px-4 h-10 text-sm font-medium transition-colors ${
15
+ className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
16
approvalMode === "auto"
17
? "bg-primary text-primary-foreground"
18
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
@@ -22,7 +22,7 @@ export const ApprovalModeToggle = ({
22
</button>
23
<button
24
onClick={() => onApprovalModeChange("manual")}
25
- className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
25
+ className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
26
approvalMode === "manual"
27
? "bg-primary text-primary-foreground"
28
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
cmd/relay-server/frontend/src/components/button/BanStatusButtons.tsx
+3
-3
@@ -20,7 +20,7 @@ export const BanStatusButtons = ({
20
>
21
<button
22
onClick={() => onBanFilterChange("all")}
23
- className={`px-4 h-10 text-sm font-medium transition-colors ${
23
+ className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
24
banFilter === "all"
25
? "bg-primary text-primary-foreground"
26
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
@@ -30,7 +30,7 @@ export const BanStatusButtons = ({
30
</button>
31
<button
32
onClick={() => onBanFilterChange("active")}
33
- className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
33
+ className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
34
banFilter === "active"
35
? "bg-green-600 text-white"
36
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
@@ -40,7 +40,7 @@ export const BanStatusButtons = ({
40
</button>
41
<button
42
onClick={() => onBanFilterChange("banned")}
43
- className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
43
+ className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
44
banFilter === "banned"
45
? "bg-red-600 text-white"
46
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
cmd/relay-server/frontend/src/components/ui/tooltip.tsx
new
+30
@@ -0,0 +1,30 @@
1
+import * as React from "react"
2
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3
+
4
+import { cn } from "@/lib/utils"
5
+
6
+const TooltipProvider = TooltipPrimitive.Provider
7
+
8
+const Tooltip = TooltipPrimitive.Root
9
+
10
+const TooltipTrigger = TooltipPrimitive.Trigger
11
+
12
+const TooltipContent = React.forwardRef<
13
+ React.ElementRef<typeof TooltipPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
15
+>(({ className, sideOffset = 4, ...props }, ref) => (
16
+ <TooltipPrimitive.Portal>
17
+ <TooltipPrimitive.Content
18
+ ref={ref}
19
+ sideOffset={sideOffset}
20
+ className={cn(
21
+ "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
22
+ className
23
+ )}
24
+ {...props}
25
+ />
26
+ </TooltipPrimitive.Portal>
27
+))
28
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
29
+
30
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
cmd/relay-server/frontend/src/hooks/useAuth.ts
new
+213
@@ -0,0 +1,213 @@
1
+import { useCallback, useEffect, useState } from "react";
2
+
3
+const STORAGE_KEY = "admin_login_attempts";
4
+const MAX_ATTEMPTS = 3;
5
+const LOCK_DURATION_MS = 60 * 1000; // 1 minute
6
+
7
+interface LoginAttempts {
8
+ count: number;
9
+ lockedUntil: number | null;
10
+}
11
+
12
+interface AuthState {
13
+ isAuthenticated: boolean;
14
+ isLoading: boolean;
15
+ authEnabled: boolean;
16
+}
17
+
18
+interface LoginResult {
19
+ success: boolean;
20
+ error?: string;
21
+ locked?: boolean;
22
+ remaining_seconds?: number;
23
+}
24
+
25
+function getStoredAttempts(): LoginAttempts {
26
+ try {
27
+ const stored = localStorage.getItem(STORAGE_KEY);
28
+ if (stored) {
29
+ return JSON.parse(stored);
30
+ }
31
+ } catch {
32
+ // Ignore parse errors
33
+ }
34
+ return { count: 0, lockedUntil: null };
35
+}
36
+
37
+function setStoredAttempts(attempts: LoginAttempts): void {
38
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts));
39
+}
40
+
41
+function clearStoredAttempts(): void {
42
+ localStorage.removeItem(STORAGE_KEY);
43
+}
44
+
45
+export function useAuth() {
46
+ const [authState, setAuthState] = useState<AuthState>({
47
+ isAuthenticated: false,
48
+ isLoading: true,
49
+ authEnabled: true,
50
+ });
51
+
52
+ const [clientLock, setClientLock] = useState<{
53
+ isLocked: boolean;
54
+ remainingSeconds: number;
55
+ }>({
56
+ isLocked: false,
57
+ remainingSeconds: 0,
58
+ });
59
+
60
+ // Check client-side lock status
61
+ const checkClientLock = useCallback(() => {
62
+ const attempts = getStoredAttempts();
63
+ if (attempts.lockedUntil) {
64
+ const remaining = attempts.lockedUntil - Date.now();
65
+ if (remaining > 0) {
66
+ setClientLock({
67
+ isLocked: true,
68
+ remainingSeconds: Math.ceil(remaining / 1000),
69
+ });
70
+ return true;
71
+ } else {
72
+ // Lock expired, clear it
73
+ clearStoredAttempts();
74
+ setClientLock({ isLocked: false, remainingSeconds: 0 });
75
+ }
76
+ }
77
+ return false;
78
+ }, []);
79
+
80
+ // Update countdown timer
81
+ useEffect(() => {
82
+ if (!clientLock.isLocked) return;
83
+
84
+ const interval = setInterval(() => {
85
+ const attempts = getStoredAttempts();
86
+ if (attempts.lockedUntil) {
87
+ const remaining = attempts.lockedUntil - Date.now();
88
+ if (remaining > 0) {
89
+ setClientLock({
90
+ isLocked: true,
91
+ remainingSeconds: Math.ceil(remaining / 1000),
92
+ });
93
+ } else {
94
+ clearStoredAttempts();
95
+ setClientLock({ isLocked: false, remainingSeconds: 0 });
96
+ }
97
+ }
98
+ }, 1000);
99
+
100
+ return () => clearInterval(interval);
101
+ }, [clientLock.isLocked]);
102
+
103
+ // Check authentication status on mount
104
+ const checkAuth = useCallback(async () => {
105
+ try {
106
+ const res = await fetch("/admin/auth/status");
107
+ if (res.ok) {
108
+ const data = await res.json();
109
+ setAuthState({
110
+ isAuthenticated: data.authenticated,
111
+ isLoading: false,
112
+ authEnabled: data.auth_enabled,
113
+ });
114
+ } else {
115
+ setAuthState({
116
+ isAuthenticated: false,
117
+ isLoading: false,
118
+ authEnabled: true,
119
+ });
120
+ }
121
+ } catch {
122
+ setAuthState({
123
+ isAuthenticated: false,
124
+ isLoading: false,
125
+ authEnabled: true,
126
+ });
127
+ }
128
+ }, []);
129
+
130
+ useEffect(() => {
131
+ checkAuth();
132
+ checkClientLock();
133
+ }, [checkAuth, checkClientLock]);
134
+
135
+ // Login function
136
+ const login = useCallback(
137
+ async (key: string): Promise<LoginResult> => {
138
+ // Check client-side lock first
139
+ if (checkClientLock()) {
140
+ const attempts = getStoredAttempts();
141
+ const remaining = attempts.lockedUntil
142
+ ? Math.ceil((attempts.lockedUntil - Date.now()) / 1000)
143
+ : 60;
144
+ return {
145
+ success: false,
146
+ error: "Too many failed attempts. Please try again later.",
147
+ locked: true,
148
+ remaining_seconds: remaining,
149
+ };
150
+ }
151
+
152
+ try {
153
+ const res = await fetch("/admin/login", {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ key }),
157
+ });
158
+
159
+ const data = await res.json();
160
+
161
+ if (data.success) {
162
+ // Clear failed attempts on success
163
+ clearStoredAttempts();
164
+ setClientLock({ isLocked: false, remainingSeconds: 0 });
165
+ setAuthState((prev) => ({ ...prev, isAuthenticated: true }));
166
+ return { success: true };
167
+ }
168
+
169
+ // Record failed attempt client-side
170
+ const attempts = getStoredAttempts();
171
+ attempts.count++;
172
+
173
+ if (attempts.count >= MAX_ATTEMPTS) {
174
+ attempts.lockedUntil = Date.now() + LOCK_DURATION_MS;
175
+ setClientLock({ isLocked: true, remainingSeconds: 60 });
176
+ }
177
+
178
+ setStoredAttempts(attempts);
179
+
180
+ return {
181
+ success: false,
182
+ error: data.error || "Invalid key",
183
+ locked: data.locked || attempts.count >= MAX_ATTEMPTS,
184
+ remaining_seconds: data.remaining_seconds || 60,
185
+ };
186
+ } catch (err) {
187
+ return {
188
+ success: false,
189
+ error: err instanceof Error ? err.message : "Login failed",
190
+ };
191
+ }
192
+ },
193
+ [checkClientLock]
194
+ );
195
+
196
+ // Logout function
197
+ const logout = useCallback(async () => {
198
+ try {
199
+ await fetch("/admin/logout", { method: "POST" });
200
+ } catch {
201
+ // Ignore errors
202
+ }
203
+ setAuthState((prev) => ({ ...prev, isAuthenticated: false }));
204
+ }, []);
205
+
206
+ return {
207
+ ...authState,
208
+ ...clientLock,
209
+ login,
210
+ logout,
211
+ checkAuth,
212
+ };
213
+}
cmd/relay-server/frontend/src/pages/Admin.tsx
+27
@@ -1,8 +1,14 @@
1
+import { useEffect } from "react";
2
+import { useNavigate } from "react-router-dom";
3
import { SsgoiTransition } from "@ssgoi/react";
4
import { useAdmin } from "@/hooks/useAdmin";
5
+import { useAuth } from "@/hooks/useAuth";
6
import { ServerListView } from "@/components/ServerListView";
7
8
export function Admin() {
9
+ const navigate = useNavigate();
10
+ const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
11
+
12
const {
13
filteredServers,
14
availableTags,
@@ -32,6 +38,26 @@ export function Admin() {
38
handleBulkBan,
39
} = useAdmin();
40
41
+ // Redirect to login if not authenticated
42
+ useEffect(() => {
43
+ if (!authLoading && !isAuthenticated) {
44
+ navigate("/admin/login", { replace: true });
45
+ }
46
+ }, [authLoading, isAuthenticated, navigate]);
47
+
48
+ const handleLogout = async () => {
49
+ await logout();
50
+ navigate("/admin/login", { replace: true });
51
+ };
52
+
53
+ if (authLoading) {
54
+ return <div className="p-8 text-foreground">Checking authentication...</div>;
55
+ }
56
+
57
+ if (!isAuthenticated) {
58
+ return null; // Will redirect
59
+ }
60
+
61
if (loading) return <div className="p-8 text-foreground">Loading...</div>;
62
if (error) return <div className="p-8 text-red-500">Error: {error}</div>;
63
@@ -65,6 +91,7 @@ export function Admin() {
91
onBulkApprove={handleBulkApprove}
92
onBulkDeny={handleBulkDeny}
93
onBulkBan={handleBulkBan}
94
+ onLogout={handleLogout}
95
/>
96
</SsgoiTransition>
97
);
cmd/relay-server/frontend/src/pages/AdminLogin.tsx
new
+178
@@ -0,0 +1,178 @@
1
+import { useState, useEffect, FormEvent } from "react";
2
+import { useNavigate } from "react-router-dom";
3
+import { KeyRound, ShieldCheck } from "lucide-react";
4
+import { useAuth } from "@/hooks/useAuth";
5
+
6
+export function AdminLogin() {
7
+ const navigate = useNavigate();
8
+ const {
9
+ isAuthenticated,
10
+ isLoading,
11
+ authEnabled,
12
+ isLocked,
13
+ remainingSeconds,
14
+ login,
15
+ } = useAuth();
16
+
17
+ const [key, setKey] = useState("");
18
+ const [error, setError] = useState("");
19
+ const [submitting, setSubmitting] = useState(false);
20
+
21
+ // Redirect if already authenticated
22
+ useEffect(() => {
23
+ if (!isLoading && isAuthenticated) {
24
+ navigate("/admin", { replace: true });
25
+ }
26
+ }, [isAuthenticated, isLoading, navigate]);
27
+
28
+ // Show auth not enabled message
29
+ useEffect(() => {
30
+ if (!isLoading && !authEnabled) {
31
+ setError(
32
+ "Admin authentication is not configured. Set ADMIN_SECRET_KEY in your environment."
33
+ );
34
+ }
35
+ }, [isLoading, authEnabled]);
36
+
37
+ const handleSubmit = async (e: FormEvent) => {
38
+ e.preventDefault();
39
+ if (!key.trim() || isLocked || submitting) return;
40
+
41
+ setSubmitting(true);
42
+ setError("");
43
+
44
+ const result = await login(key);
45
+
46
+ setSubmitting(false);
47
+
48
+ if (result.success) {
49
+ navigate("/admin", { replace: true });
50
+ } else {
51
+ setError(result.error || "Login failed");
52
+ }
53
+ };
54
+
55
+ if (isLoading) {
56
+ return (
57
+ <div className="min-h-screen bg-background flex items-center justify-center">
58
+ <div className="text-muted-foreground">Loading...</div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div className="relative flex h-auto min-h-screen w-full flex-col">
65
+ <div className="flex h-full grow flex-col">
66
+ <div className="flex flex-1 justify-center py-5">
67
+ <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
68
+ {/* Header */}
69
+ <header className="flex items-center justify-between whitespace-nowrap px-4 sm:px-6 py-3">
70
+ <div className="flex items-center gap-4 text-foreground">
71
+ <div className="text-primary size-6">
72
+ <svg
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ width="24"
75
+ height="24"
76
+ viewBox="0 0 906.26 1457.543"
77
+ >
78
+ <path
79
+ fill="#17C0E9"
80
+ d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
81
+ ></path>
82
+ </svg>
83
+ </div>
84
+ <h2 className="text-foreground text-lg font-bold leading-tight tracking-[-0.015em]">
85
+ PORTAL ADMIN
86
+ </h2>
87
+ </div>
88
+ </header>
89
+
90
+ {/* Main Content */}
91
+ <main className="flex flex-1 flex-col items-center justify-center py-16">
92
+ <div className="flex w-full max-w-md flex-col items-center gap-8 rounded-xl bg-card p-8 shadow-lg">
93
+ {/* Icon and Title */}
94
+ <div className="flex flex-col items-center gap-2 text-center">
95
+ <ShieldCheck className="w-10 h-10 text-primary" />
96
+ <h1 className="text-2xl font-bold text-foreground">
97
+ Admin Access
98
+ </h1>
99
+ <p className="text-muted-foreground">
100
+ Enter your secret key to manage servers.
101
+ </p>
102
+ </div>
103
+
104
+ {/* Form */}
105
+ <form
106
+ onSubmit={handleSubmit}
107
+ className="flex w-full flex-col gap-6"
108
+ >
109
+ <div className="flex flex-col gap-2">
110
+ <label
111
+ className="text-sm font-medium text-muted-foreground"
112
+ htmlFor="admin-key"
113
+ >
114
+ ADMIN_SECRET_KEY
115
+ </label>
116
+ <div className="relative">
117
+ <KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
118
+ <input
119
+ id="admin-key"
120
+ type="password"
121
+ placeholder="Enter your secret key"
122
+ value={key}
123
+ onChange={(e) => setKey(e.target.value)}
124
+ disabled={isLocked || submitting || !authEnabled}
125
+ autoFocus
126
+ className="h-12 w-full rounded-lg border-none bg-secondary pl-10 pr-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
127
+ />
128
+ </div>
129
+ </div>
130
+
131
+ {/* Error Message */}
132
+ {error && (
133
+ <div className="text-destructive text-sm text-center bg-destructive/10 p-3 rounded-md">
134
+ {error}
135
+ </div>
136
+ )}
137
+
138
+ {/* Lock Message */}
139
+ {isLocked && (
140
+ <div className="text-amber-500 text-sm text-center bg-amber-500/10 p-3 rounded-md">
141
+ Too many failed attempts. Please wait {remainingSeconds}{" "}
142
+ seconds.
143
+ </div>
144
+ )}
145
+
146
+ {/* Submit Button */}
147
+ <button
148
+ type="submit"
149
+ disabled={
150
+ !key.trim() || isLocked || submitting || !authEnabled
151
+ }
152
+ className="flex h-12 w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-primary text-base font-bold text-white transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
153
+ >
154
+ <span className="truncate">
155
+ {submitting
156
+ ? "Authenticating..."
157
+ : isLocked
158
+ ? `Wait ${remainingSeconds}s`
159
+ : "Login"}
160
+ </span>
161
+ </button>
162
+ </form>
163
+
164
+ {/* Back Link */}
165
+ <a
166
+ href="/"
167
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
168
+ >
169
+ Back to Home
170
+ </a>
171
+ </div>
172
+ </main>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+}
cmd/relay-server/main.go
+29
-9
@@ -2,6 +2,8 @@ package main
2
3
import (
4
"context"
5
+ "crypto/rand"
6
+ "encoding/hex"
7
"flag"
8
"fmt"
9
"os"
@@ -21,14 +23,15 @@ import (
23
)
24
25
var (
24
- flagPortalURL string
25
- flagPortalAppURL string
26
- flagBootstraps []string
27
- flagALPN string
28
- flagPort int
29
- flagMaxLease int
30
- flagLeaseBPS int
31
- flagNoIndex bool
26
+ flagPortalURL string
27
+ flagPortalAppURL string
28
+ flagBootstraps []string
29
+ flagALPN string
30
+ flagPort int
31
+ flagMaxLease int
32
+ flagLeaseBPS int
33
+ flagNoIndex bool
34
+ flagAdminSecretKey string
35
)
36
37
func main() {
@@ -59,6 +62,9 @@ func main() {
62
63
defaultNoIndex := os.Getenv("NOINDEX") == "true"
64
flag.BoolVar(&flagNoIndex, "noindex", defaultNoIndex, "disallow all crawlers via robots.txt (env: NOINDEX)")
65
+
66
+ defaultAdminSecretKey := os.Getenv("ADMIN_SECRET_KEY")
67
+ flag.StringVar(&flagAdminSecretKey, "admin-secret-key", defaultAdminSecretKey, "secret key for admin authentication (env: ADMIN_SECRET_KEY)")
68
flag.Parse()
69
70
flagBootstraps = utils.ParseURLs(flagBootstrapsCSV)
@@ -84,9 +90,23 @@ func runServer() error {
90
serv.SetMaxRelayedPerLease(flagMaxLease)
91
}
92
93
+ // Create AuthManager for admin authentication
94
+ // Auto-generate secret key if not provided
95
+ if flagAdminSecretKey == "" {
96
+ randomBytes := make([]byte, 16)
97
+ if _, err := rand.Read(randomBytes); err != nil {
98
+ log.Fatal().Err(err).Msg("[server] failed to generate random admin secret key")
99
+ }
100
+ flagAdminSecretKey = hex.EncodeToString(randomBytes)
101
+ log.Warn().Str("key", flagAdminSecretKey).Msg("[server] auto-generated ADMIN_SECRET_KEY (set ADMIN_SECRET_KEY env to use your own)")
102
+ } else {
103
+ log.Info().Str("key", flagAdminSecretKey).Msg("[server] admin authentication enabled")
104
+ }
105
+ authManager := manager.NewAuthManager(flagAdminSecretKey)
106
+
107
// Create Frontend first, then Admin, then attach Admin back to Frontend.
108
frontend := NewFrontend()
89
- admin := NewAdmin(int64(flagLeaseBPS), frontend)
109
+ admin := NewAdmin(int64(flagLeaseBPS), frontend, authManager)
110
frontend.SetAdmin(admin)
111
112
// Load persisted admin settings (ban list, BPS limits, IP bans)
cmd/relay-server/manager/auth_manager.go
new
+184
@@ -0,0 +1,184 @@
1
+package manager
2
+
3
+import (
4
+ "crypto/rand"
5
+ "crypto/subtle"
6
+ "encoding/hex"
7
+ "sync"
8
+ "time"
9
+)
10
+
11
+const (
12
+ maxFailedAttempts = 3
13
+ lockDuration = 1 * time.Minute
14
+ sessionDuration = 24 * time.Hour
15
+)
16
+
17
+// AuthManager manages admin authentication with rate limiting
18
+type AuthManager struct {
19
+ secretKey string
20
+ mu sync.RWMutex
21
+ failedLogins map[string]*loginAttempt // IP -> attempt info
22
+ sessions map[string]time.Time // token -> expiry
23
+}
24
+
25
+type loginAttempt struct {
26
+ count int
27
+ lockedAt time.Time
28
+}
29
+
30
+// NewAuthManager creates a new AuthManager with the given secret key
31
+func NewAuthManager(secretKey string) *AuthManager {
32
+ return &AuthManager{
33
+ secretKey: secretKey,
34
+ failedLogins: make(map[string]*loginAttempt),
35
+ sessions: make(map[string]time.Time),
36
+ }
37
+}
38
+
39
+// IsIPLocked checks if an IP is currently locked out
40
+func (m *AuthManager) IsIPLocked(ip string) bool {
41
+ m.mu.RLock()
42
+ defer m.mu.RUnlock()
43
+
44
+ attempt, exists := m.failedLogins[ip]
45
+ if !exists {
46
+ return false
47
+ }
48
+
49
+ if attempt.count >= maxFailedAttempts {
50
+ // Check if lock has expired
51
+ if time.Since(attempt.lockedAt) < lockDuration {
52
+ return true
53
+ }
54
+ }
55
+
56
+ return false
57
+}
58
+
59
+// GetLockRemainingSeconds returns the remaining seconds until the IP is unlocked
60
+func (m *AuthManager) GetLockRemainingSeconds(ip string) int {
61
+ m.mu.RLock()
62
+ defer m.mu.RUnlock()
63
+
64
+ attempt, exists := m.failedLogins[ip]
65
+ if !exists {
66
+ return 0
67
+ }
68
+
69
+ if attempt.count >= maxFailedAttempts {
70
+ remaining := lockDuration - time.Since(attempt.lockedAt)
71
+ if remaining > 0 {
72
+ return int(remaining.Seconds())
73
+ }
74
+ }
75
+
76
+ return 0
77
+}
78
+
79
+// RecordFailedLogin records a failed login attempt and returns true if the IP is now locked
80
+func (m *AuthManager) RecordFailedLogin(ip string) bool {
81
+ m.mu.Lock()
82
+ defer m.mu.Unlock()
83
+
84
+ attempt, exists := m.failedLogins[ip]
85
+ if !exists {
86
+ attempt = &loginAttempt{}
87
+ m.failedLogins[ip] = attempt
88
+ }
89
+
90
+ // Reset if lock has expired
91
+ if attempt.count >= maxFailedAttempts && time.Since(attempt.lockedAt) >= lockDuration {
92
+ attempt.count = 0
93
+ }
94
+
95
+ attempt.count++
96
+
97
+ if attempt.count >= maxFailedAttempts {
98
+ attempt.lockedAt = time.Now()
99
+ return true
100
+ }
101
+
102
+ return false
103
+}
104
+
105
+// ResetFailedLogin resets the failed login count for an IP
106
+func (m *AuthManager) ResetFailedLogin(ip string) {
107
+ m.mu.Lock()
108
+ defer m.mu.Unlock()
109
+
110
+ delete(m.failedLogins, ip)
111
+}
112
+
113
+// ValidateKey checks if the provided key matches the secret key
114
+func (m *AuthManager) ValidateKey(key string) bool {
115
+ if m.secretKey == "" {
116
+ return false
117
+ }
118
+ return subtle.ConstantTimeCompare([]byte(key), []byte(m.secretKey)) == 1
119
+}
120
+
121
+// HasSecretKey returns true if a secret key is configured
122
+func (m *AuthManager) HasSecretKey() bool {
123
+ return m.secretKey != ""
124
+}
125
+
126
+// CreateSession creates a new session and returns the token
127
+func (m *AuthManager) CreateSession() string {
128
+ token := generateToken()
129
+
130
+ m.mu.Lock()
131
+ defer m.mu.Unlock()
132
+
133
+ m.sessions[token] = time.Now().Add(sessionDuration)
134
+
135
+ // Clean up expired sessions
136
+ m.cleanupExpiredSessions()
137
+
138
+ return token
139
+}
140
+
141
+// ValidateSession checks if a session token is valid
142
+func (m *AuthManager) ValidateSession(token string) bool {
143
+ if token == "" {
144
+ return false
145
+ }
146
+
147
+ m.mu.RLock()
148
+ defer m.mu.RUnlock()
149
+
150
+ expiry, exists := m.sessions[token]
151
+ if !exists {
152
+ return false
153
+ }
154
+
155
+ return time.Now().Before(expiry)
156
+}
157
+
158
+// DeleteSession removes a session
159
+func (m *AuthManager) DeleteSession(token string) {
160
+ m.mu.Lock()
161
+ defer m.mu.Unlock()
162
+
163
+ delete(m.sessions, token)
164
+}
165
+
166
+// cleanupExpiredSessions removes expired sessions (must be called with lock held)
167
+func (m *AuthManager) cleanupExpiredSessions() {
168
+ now := time.Now()
169
+ for token, expiry := range m.sessions {
170
+ if now.After(expiry) {
171
+ delete(m.sessions, token)
172
+ }
173
+ }
174
+}
175
+
176
+// generateToken generates a secure random token
177
+func generateToken() string {
178
+ bytes := make([]byte, 32)
179
+ if _, err := rand.Read(bytes); err != nil {
180
+ // Fallback to timestamp-based token (less secure but functional)
181
+ return hex.EncodeToString([]byte(time.Now().String()))
182
+ }
183
+ return hex.EncodeToString(bytes)
184
+}
docker-compose.yml
+1
@@ -12,6 +12,7 @@ services:
12
PORTAL_URL: ${PORTAL_URL:-http://localhost:${PORTAL_PORT:-4017}}
13
PORTAL_APP_URL: ${PORTAL_APP_URL:-http://*.localhost:${PORTAL_PORT:-4017}}
14
BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-ws://localhost:${PORTAL_PORT:-4017}/relay}
15
+ ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
16
ports:
17
- "4017:4017"
18
restart: unless-stopped