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()