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