feat: Add admin dashboard with lease management and ban/unban functionality

sinwoojin committed Nov 26, 2025 at 10:31 UTC 31d639ae558262017fa7ea44b941f9e201eb117a
5 files changed +244 -2
cmd/relay-server/frontend/src/App.tsx
+4 -2
@@ -1,12 +1,14 @@
1 -import { Routes, Route } from "react-router-dom";
2 -import { ServerList } from "@/pages/ServerList";
1 +import { Admin } from "@/pages/Admin";
2 import { ServerDetail } from "@/pages/ServerDetail";
3 +import { ServerList } from "@/pages/ServerList";
4 +import { Route, Routes } from "react-router-dom";
5
6 function App() {
7 return (
8 <Routes>
9 <Route path="/" element={<ServerList />} />
10 <Route path="/server/:id" element={<ServerDetail />} />
11 + <Route path="/admin" element={<Admin />} />
12 </Routes>
13 );
14 }
cmd/relay-server/frontend/src/hooks/useAdmin.ts new
+69
@@ -0,0 +1,69 @@
1 +import { useCallback, useEffect, useState } from "react";
2 +
3 +export interface LeaseEntry {
4 + Lease: {
5 + Identity: { Id: string };
6 + Name: string;
7 + };
8 + Expires: string;
9 + LastSeen: string;
10 +}
11 +
12 +export function useAdmin() {
13 + const [leases, setLeases] = useState<LeaseEntry[]>([]);
14 + const [bannedLeases, setBannedLeases] = useState<string[]>([]);
15 + const [loading, setLoading] = useState(true);
16 + const [error, setError] = useState("");
17 +
18 + const fetchData = useCallback(async () => {
19 + try {
20 + const [leasesRes, bannedRes] = await Promise.all([
21 + fetch("/admin/leases"),
22 + fetch("/admin/leases/banned"),
23 + ]);
24 +
25 + if (!leasesRes.ok || !bannedRes.ok) {
26 + throw new Error("Failed to fetch admin data. Are you on localhost?");
27 + }
28 +
29 + const leasesData = await leasesRes.json();
30 + const bannedData = await bannedRes.json();
31 +
32 + setLeases(leasesData || []);
33 + setBannedLeases(bannedData || []);
34 + } catch (err: any) {
35 + setError(err.message);
36 + } finally {
37 + setLoading(false);
38 + }
39 + }, []);
40 +
41 + useEffect(() => {
42 + fetchData();
43 + }, [fetchData]);
44 +
45 + const toUrlSafe = (base64: string) => {
46 + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
47 + };
48 +
49 + const handleBanStatus = async (base64Id: string, isBan: boolean) => {
50 + try {
51 + const safeId = toUrlSafe(base64Id);
52 + await fetch(`/admin/leases/${safeId}/ban`, {
53 + method: isBan ? "POST" : "DELETE"
54 + });
55 + fetchData();
56 + } catch (err) {
57 + console.error(err);
58 + }
59 + };
60 +
61 + return {
62 + leases,
63 + bannedLeases,
64 + loading,
65 + error,
66 + handleBanStatus,
67 + refresh: fetchData
68 + };
69 +}
cmd/relay-server/frontend/src/pages/Admin.tsx new
+66
@@ -0,0 +1,66 @@
1 +import { useAdmin } from "@/hooks/useAdmin";
2 +import { SsgoiTransition } from "@ssgoi/react";
3 +
4 +export function Admin() {
5 + const { leases, bannedLeases, loading, error, handleBanStatus } = useAdmin();
6 +
7 + if (loading) return <div className="p-8 text-foreground">Loading...</div>;
8 + if (error) return <div className="p-8 text-red-500">Error: {error}</div>;
9 +
10 + return (
11 + <SsgoiTransition id="admin">
12 + <div className="min-h-screen bg-background p-8 text-foreground">
13 + <h1 className="text-3xl font-bold mb-8">Admin Dashboard</h1>
14 +
15 + <div className="mb-12">
16 + <h2 className="text-2xl font-semibold mb-4">Active Leases</h2>
17 + <div className="grid gap-4">
18 + {leases.map((entry, i) => {
19 + const id = entry.Lease.Identity.Id;
20 + const isBanned = bannedLeases.includes(id);
21 + return (
22 + <div key={i} className="bg-card p-4 rounded-lg flex justify-between items-center border border-border">
23 + <div>
24 + <p className="font-bold text-lg">{entry.Lease.Name || "(Unnamed)"}</p>
25 + <p className="text-sm text-muted-foreground font-mono break-all">{id}</p>
26 + <p className="text-xs text-muted-foreground">Expires: {new Date(entry.Expires).toLocaleString()}</p>
27 + </div>
28 + <button
29 + onClick={() => handleBanStatus(id, !isBanned)}
30 + className={`px-4 py-2 rounded font-medium transition-colors ml-4 ${
31 + isBanned
32 + ? "bg-green-600 hover:bg-green-700 text-white"
33 + : "bg-red-600 hover:bg-red-700 text-white"
34 + }`}
35 + >
36 + {isBanned ? "Unban" : "Ban"}
37 + </button>
38 + </div>
39 + );
40 + })}
41 + {leases.length === 0 && <p className="text-muted-foreground">No active leases.</p>}
42 + </div>
43 + </div>
44 +
45 + <div>
46 + <h2 className="text-2xl font-semibold mb-4">Banned Leases (All)</h2>
47 + <div className="grid gap-4">
48 + {bannedLeases.map((id, i) => (
49 + <div key={i} className="bg-card p-4 rounded-lg flex justify-between items-center border border-border">
50 + <p className="font-mono text-sm break-all">{id}</p>
51 + <button
52 + onClick={() => handleBanStatus(id, false)}
53 + className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded font-medium transition-colors ml-4"
54 + >
55 + Unban
56 + </button>
57 + </div>
58 + ))}
59 + {bannedLeases.length === 0 && <p className="text-muted-foreground">No banned leases.</p>}
60 + </div>
61 + </div>
62 + </div>
63 + </SsgoiTransition>
64 + );
65 +}
66 +
cmd/relay-server/view.go
+84
@@ -3,8 +3,10 @@ package main
3 import (
4 "context"
5 "embed"
6 + "encoding/base64"
7 "encoding/json"
8 "fmt"
9 + "net"
10 "net/http"
11 "strings"
12 "time"
@@ -104,6 +106,11 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
106 w.Write([]byte("{\"status\":\"ok\"}"))
107 })
108
109 + // Admin API
110 + appMux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
111 + handleAdminRequest(w, r, serv)
112 + })
113 +
114 // Create portal frontend mux (routes only)
115 portalMux := http.NewServeMux()
116
@@ -185,6 +192,74 @@ type leaseRow struct {
192 }
193
194 // convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page
195 +func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
196 + if !isLocalhost(r) {
197 + http.Error(w, "Forbidden", http.StatusForbidden)
198 + return
199 + }
200 +
201 + // Simple routing for admin
202 + p := strings.TrimPrefix(r.URL.Path, "/admin/")
203 +
204 + if p == "leases" && r.Method == http.MethodGet {
205 + // List all leases
206 + leases := serv.GetAllLeaseEntries()
207 + w.Header().Set("Content-Type", "application/json")
208 + json.NewEncoder(w).Encode(leases)
209 + return
210 + }
211 +
212 + if p == "stats" && r.Method == http.MethodGet {
213 + // Basic stats
214 + stats := map[string]interface{}{
215 + "leases_count": len(serv.GetAllLeaseEntries()),
216 + "uptime": "TODO", // We could add start time to server
217 + }
218 + w.Header().Set("Content-Type", "application/json")
219 + json.NewEncoder(w).Encode(stats)
220 + return
221 + }
222 +
223 + if strings.HasPrefix(p, "leases/") && strings.HasSuffix(p, "/ban") {
224 + parts := strings.Split(p, "/")
225 + if len(parts) == 3 {
226 + encodedID := parts[1]
227 +
228 + // Decode ID (expecting URL-safe base64 from frontend)
229 + idBytes, err := base64.URLEncoding.DecodeString(encodedID)
230 + if err != nil {
231 + // Try Raw URL encoding
232 + idBytes, err = base64.RawURLEncoding.DecodeString(encodedID)
233 + }
234 +
235 + leaseID := encodedID
236 + if err == nil {
237 + leaseID = string(idBytes)
238 + }
239 +
240 + if r.Method == http.MethodPost {
241 + serv.GetLeaseManager().BanLease(leaseID)
242 + w.WriteHeader(http.StatusOK)
243 + return
244 + }
245 + if r.Method == http.MethodDelete {
246 + serv.GetLeaseManager().UnbanLease(leaseID)
247 + w.WriteHeader(http.StatusOK)
248 + return
249 + }
250 + }
251 + }
252 +
253 + if p == "leases/banned" && r.Method == http.MethodGet {
254 + banned := serv.GetLeaseManager().GetBannedLeases()
255 + w.Header().Set("Content-Type", "application/json")
256 + json.NewEncoder(w).Encode(banned)
257 + return
258 + }
259 +
260 + http.NotFound(w, r)
261 +}
262 +
263 func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
264 // Get all lease entries directly from the lease manager
265 leaseEntries := serv.GetAllLeaseEntries()
@@ -304,3 +379,12 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
379
380 return rows
381 }
382 +
383 +func isLocalhost(r *http.Request) bool {
384 + host, _, err := net.SplitHostPort(r.RemoteAddr)
385 + if err != nil {
386 + host = r.RemoteAddr
387 + }
388 + return host == "127.0.0.1" || host == "::1"
389 +}
390 +
portal/lease.go
+21
@@ -158,6 +158,12 @@ func (lm *LeaseManager) GetLease(identity *rdsec.Identity) (*LeaseEntry, bool) {
158 defer lm.leasesLock.RUnlock()
159
160 identityID := string(identity.Id)
161 +
162 + // Check if banned
163 + if _, banned := lm.bannedLeases[identityID]; banned {
164 + return nil, false
165 + }
166 +
167 lease, exists := lm.leases[identityID]
168 if !exists {
169 return nil, false
@@ -175,6 +181,11 @@ func (lm *LeaseManager) GetLeaseByID(leaseID string) (*LeaseEntry, bool) {
181 lm.leasesLock.RLock()
182 defer lm.leasesLock.RUnlock()
183
184 + // Check if banned
185 + if _, banned := lm.bannedLeases[leaseID]; banned {
186 + return nil, false
187 + }
188 +
189 lease, exists := lm.leases[leaseID]
190 if !exists {
191 return nil, false
@@ -217,6 +228,16 @@ func (lm *LeaseManager) UnbanLease(leaseID string) {
228 lm.leasesLock.Unlock()
229 }
230
231 +func (lm *LeaseManager) GetBannedLeases() [][]byte {
232 + lm.leasesLock.RLock()
233 + defer lm.leasesLock.RUnlock()
234 + banned := make([][]byte, 0, len(lm.bannedLeases))
235 + for id := range lm.bannedLeases {
236 + banned = append(banned, []byte(id))
237 + }
238 + return banned
239 +}
240 +
241 func (lm *LeaseManager) SetNamePattern(pattern string) error {
242 lm.leasesLock.Lock()
243 defer lm.leasesLock.Unlock()