refactor: block lease ui and logic for admin
Hee Sung Son committed
Dec 1, 2025 at 20:16 UTC
069a926fece525094b324a92f8cb0f7360ed32d7
16 files changed
+1630
-466
cmd/relay-server/bps_manager.go
new
+174
@@ -0,0 +1,174 @@
1
+package main
2
+
3
+import (
4
+ "io"
5
+ "sync"
6
+
7
+ "github.com/hashicorp/yamux"
8
+ "github.com/rs/zerolog/log"
9
+ "gosuda.org/portal/cmd/relay-server/ratelimit"
10
+)
11
+
12
+// BPSManager manages per-lease bytes-per-second rate limiting
13
+type BPSManager struct {
14
+ mu sync.Mutex
15
+ bpsLimits map[string]int64 // leaseID -> bytes-per-second (0 = unlimited)
16
+ bpsBuckets map[string]*ratelimit.Bucket // leaseID -> rate limit bucket
17
+ defaultBPS int64 // default bytes-per-second for new leases
18
+}
19
+
20
+// NewBPSManager creates a new BPS manager
21
+func NewBPSManager() *BPSManager {
22
+ return &BPSManager{
23
+ bpsLimits: make(map[string]int64),
24
+ bpsBuckets: make(map[string]*ratelimit.Bucket),
25
+ defaultBPS: 0,
26
+ }
27
+}
28
+
29
+// SetBPSLimit sets the BPS limit for a lease
30
+func (m *BPSManager) SetBPSLimit(leaseID string, bps int64) {
31
+ m.mu.Lock()
32
+ defer m.mu.Unlock()
33
+ if bps <= 0 {
34
+ delete(m.bpsLimits, leaseID)
35
+ delete(m.bpsBuckets, leaseID)
36
+ return
37
+ }
38
+ m.bpsLimits[leaseID] = bps
39
+ // Reset bucket to apply new rate
40
+ delete(m.bpsBuckets, leaseID)
41
+}
42
+
43
+// GetBPSLimit returns the BPS limit for a lease (0 = unlimited)
44
+func (m *BPSManager) GetBPSLimit(leaseID string) int64 {
45
+ m.mu.Lock()
46
+ defer m.mu.Unlock()
47
+ if v, ok := m.bpsLimits[leaseID]; ok {
48
+ return v
49
+ }
50
+ return 0
51
+}
52
+
53
+// GetAllBPSLimits returns a copy of all BPS limits
54
+func (m *BPSManager) GetAllBPSLimits() map[string]int64 {
55
+ m.mu.Lock()
56
+ defer m.mu.Unlock()
57
+ result := make(map[string]int64, len(m.bpsLimits))
58
+ for k, v := range m.bpsLimits {
59
+ result[k] = v
60
+ }
61
+ return result
62
+}
63
+
64
+// SetDefaultBPS sets the default BPS limit for new leases
65
+func (m *BPSManager) SetDefaultBPS(bps int64) {
66
+ m.mu.Lock()
67
+ defer m.mu.Unlock()
68
+ if bps < 0 {
69
+ bps = 0
70
+ }
71
+ m.defaultBPS = bps
72
+}
73
+
74
+// GetDefaultBPS returns the default BPS limit
75
+func (m *BPSManager) GetDefaultBPS() int64 {
76
+ m.mu.Lock()
77
+ defer m.mu.Unlock()
78
+ return m.defaultBPS
79
+}
80
+
81
+// GetBucket returns a rate limit bucket for a lease, creating one if needed
82
+func (m *BPSManager) GetBucket(leaseID string) *ratelimit.Bucket {
83
+ m.mu.Lock()
84
+ defer m.mu.Unlock()
85
+
86
+ bps, ok := m.bpsLimits[leaseID]
87
+ if !ok || bps <= 0 {
88
+ return nil // No limit
89
+ }
90
+
91
+ if bucket, exists := m.bpsBuckets[leaseID]; exists {
92
+ return bucket
93
+ }
94
+
95
+ // Create new bucket
96
+ bucket := ratelimit.NewBucket(bps, bps)
97
+ m.bpsBuckets[leaseID] = bucket
98
+ log.Debug().
99
+ Str("lease_id", leaseID).
100
+ Int64("bps", bps).
101
+ Msg("[BPS] Created rate limit bucket")
102
+ return bucket
103
+}
104
+
105
+// CleanupLease removes BPS data for a lease
106
+func (m *BPSManager) CleanupLease(leaseID string) {
107
+ m.mu.Lock()
108
+ defer m.mu.Unlock()
109
+ delete(m.bpsLimits, leaseID)
110
+ delete(m.bpsBuckets, leaseID)
111
+}
112
+
113
+// Copy copies data with rate limiting
114
+func (m *BPSManager) Copy(dst io.Writer, src io.Reader, leaseID string) (int64, error) {
115
+ bucket := m.GetBucket(leaseID)
116
+ return ratelimit.Copy(dst, src, bucket)
117
+}
118
+
119
+// Connection tracking for relay (package level)
120
+var (
121
+ relayedPerLeaseCount = make(map[string]int)
122
+ relayLimitsLock sync.Mutex
123
+)
124
+
125
+// establishRelayWithBPS sets up bidirectional relay with BPS limiting
126
+func establishRelayWithBPS(clientStream, leaseStream *yamux.Stream, leaseID string, bpsManager *BPSManager) {
127
+ // Register connection
128
+ relayLimitsLock.Lock()
129
+ relayedPerLeaseCount[leaseID]++
130
+ connectionCount := relayedPerLeaseCount[leaseID]
131
+ relayLimitsLock.Unlock()
132
+
133
+ // Log relay start
134
+ bpsLimit := bpsManager.GetBPSLimit(leaseID)
135
+ log.Info().
136
+ Str("lease_id", leaseID).
137
+ Int64("bps_limit", bpsLimit).
138
+ Int("active_connections", connectionCount).
139
+ Msg("[Relay] Starting relay connection")
140
+
141
+ // Cleanup function
142
+ defer func() {
143
+ relayLimitsLock.Lock()
144
+ if relayedPerLeaseCount[leaseID] > 0 {
145
+ relayedPerLeaseCount[leaseID]--
146
+ }
147
+ remainingCount := relayedPerLeaseCount[leaseID]
148
+ relayLimitsLock.Unlock()
149
+
150
+ log.Info().
151
+ Str("lease_id", leaseID).
152
+ Int("remaining_connections", remainingCount).
153
+ Msg("[Relay] Relay connection closed")
154
+ }()
155
+
156
+ var wg sync.WaitGroup
157
+ wg.Add(2)
158
+
159
+ // Client -> Lease
160
+ go func() {
161
+ defer wg.Done()
162
+ bpsManager.Copy(leaseStream, clientStream, leaseID)
163
+ leaseStream.Close()
164
+ }()
165
+
166
+ // Lease -> Client
167
+ go func() {
168
+ defer wg.Done()
169
+ bpsManager.Copy(clientStream, leaseStream, leaseID)
170
+ clientStream.Close()
171
+ }()
172
+
173
+ wg.Wait()
174
+}
cmd/relay-server/frontend/src/components/Header.tsx
+6
-2
@@ -1,7 +1,11 @@
1
import { useEffect, useState } from "react";
2
import { Moon, Sun } from "lucide-react";
3
4
-export function Header() {
4
+interface HeaderProps {
5
+ title?: string;
6
+}
7
+
8
+export function Header({ title = "PORTAL" }: HeaderProps) {
9
const [theme, setTheme] = useState<"light" | "dark">("dark");
10
11
useEffect(() => {
@@ -47,7 +51,7 @@ export function Header() {
51
</svg>
52
</div>
53
<h2 className="text-foreground text-lg font-bold leading-tight tracking-[-0.015em]">
50
- PORTAL
54
+ {title}
55
</h2>
56
</div>
57
<div className="flex items-center gap-3">
cmd/relay-server/frontend/src/components/ServerCard.tsx
+229
-12
@@ -1,5 +1,7 @@
1
import { Link } from "react-router-dom";
2
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
3
+import clsx from "clsx";
4
+import { ReactNode, useState } from "react";
5
6
interface ServerCardProps {
7
serverId: number;
@@ -15,6 +17,13 @@ interface ServerCardProps {
17
navigationState: any;
18
isFavorite?: boolean;
19
onToggleFavorite?: (serverId: number) => void;
20
+ // Admin controls
21
+ showAdminControls?: boolean;
22
+ leaseId?: string;
23
+ isBanned?: boolean;
24
+ bps?: number;
25
+ onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
26
+ onBPSChange?: (leaseId: string, bps: number) => void;
27
}
28
29
export function ServerCard({
@@ -29,28 +38,125 @@ export function ServerCard({
38
navigationState,
39
isFavorite = false,
40
onToggleFavorite,
41
+ // Admin controls
42
+ showAdminControls = false,
43
+ leaseId,
44
+ isBanned = false,
45
+ bps = 0,
46
+ onBanStatusChange,
47
+ onBPSChange,
48
}: ServerCardProps) {
49
+ const [showBPSModal, setShowBPSModal] = useState(false);
50
+ const [bpsInput, setBpsInput] = useState(bps.toString());
51
+
52
+ // BPS slider steps: 0 (Unlimited), 10, 100, 1K, 10K, 100K, 1M, 10M
53
+ const bpsSteps = [0, 10, 100, 1000, 10000, 100000, 1000000, 10000000];
54
+
55
+ const bpsToSliderIndex = (value: number): number => {
56
+ if (value === 0) return 0;
57
+ const idx = bpsSteps.findIndex(step => step >= value);
58
+ return idx === -1 ? bpsSteps.length - 1 : idx;
59
+ };
60
+
61
+ const [sliderIndex, setSliderIndex] = useState(bpsToSliderIndex(bps));
62
+
63
+ // Sync input with slider
64
+ const handleSliderChange = (idx: number) => {
65
+ setSliderIndex(idx);
66
+ setBpsInput(bpsSteps[idx].toString());
67
+ };
68
+
69
+ // Sync slider with input (find closest step)
70
+ const syncSliderFromInput = (value: number) => {
71
+ const idx = bpsToSliderIndex(value);
72
+ setSliderIndex(idx);
73
+ };
74
const handleFavoriteClick = (e: React.MouseEvent) => {
75
e.preventDefault();
76
e.stopPropagation();
77
onToggleFavorite?.(serverId);
78
};
79
80
+ const handleBanClick = (e: React.MouseEvent) => {
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ if (leaseId && onBanStatusChange) {
84
+ onBanStatusChange(leaseId, !isBanned);
85
+ }
86
+ };
87
+
88
+ const handleBPSSettingsClick = (e: React.MouseEvent) => {
89
+ e.preventDefault();
90
+ e.stopPropagation();
91
+ setSliderIndex(bpsToSliderIndex(bps));
92
+ setBpsInput(bps.toString());
93
+ setShowBPSModal(true);
94
+ };
95
+
96
+ const handleBPSSave = () => {
97
+ if (leaseId && onBPSChange) {
98
+ const newBps = parseInt(bpsInput, 10) || 0;
99
+ onBPSChange(leaseId, newBps);
100
+ }
101
+ setShowBPSModal(false);
102
+ };
103
+
104
+ const formatSliderLabel = (value: number): string => {
105
+ if (value === 0) return "Unlimited";
106
+ if (value >= 1000000) return `${value / 1000000} MB/s`;
107
+ if (value >= 1000) return `${value / 1000} KB/s`;
108
+ return `${value} B/s`;
109
+ };
110
+
111
+ const formatStepLabel = (value: number): string => {
112
+ if (value === 0) return "∞";
113
+ if (value >= 1000000) return `${value / 1000000}M`;
114
+ if (value >= 1000) return `${value / 1000}K`;
115
+ return value.toString();
116
+ };
117
+
118
+ const handleBPSModalClose = (e: React.MouseEvent) => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ setShowBPSModal(false);
122
+ };
123
+
124
+ const formatBPS = (value: number): string => {
125
+ if (value === 0) return "Unlimited";
126
+ if (value >= 1_000_000_000)
127
+ return `${(value / 1_000_000_000).toFixed(1)} GB/s`;
128
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} MB/s`;
129
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)} KB/s`;
130
+ return `${value} B/s`;
131
+ };
132
+
133
+ const Wrapper = ({ children }: { children: ReactNode }) =>
134
+ showAdminControls ? (
135
+ <div className="relative">{children}</div>
136
+ ) : (
137
+ <Link
138
+ to={navigationPath}
139
+ state={navigationState}
140
+ className="cursor-pointer"
141
+ >
142
+ {children}
143
+ </Link>
144
+ );
145
+
146
return (
40
- <Link
41
- to={navigationPath}
42
- state={navigationState}
43
- className="relative hover:scale-105 transition-all duration-300"
44
- >
147
+ <Wrapper>
148
<div
149
data-hero-key={`server-bg-${serverId}`}
47
- className="relative h-[174.5px] bg-center bg-no-repeat bg-cover rounded-xl shadow-lg hover:shadow-xl transition-shadow duration-300 cursor-pointer z-1 border border-foreground dark:border-foreground/40"
150
+ className={clsx(
151
+ "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",
152
+ showAdminControls ? "h-[263.5px]" : "h-[174.5px]"
153
+ )}
154
style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
155
>
156
{/* Favorite button */}
157
<button
158
onClick={handleFavoriteClick}
53
- className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200"
159
+ className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200 cursor-pointer"
160
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
161
>
162
<svg
@@ -74,14 +180,16 @@ export function ServerCard({
180
<div className="flex flex-col gap-2">
181
<div className="flex items-center gap-2">
182
<div
77
- className={`w-2.5 h-2.5 rounded-full ${
183
+ className={clsx(
184
+ "w-2.5 h-2.5 rounded-full",
185
online ? "bg-green-status" : "bg-red-500"
79
- }`}
186
+ )}
187
/>
188
<p
82
- className={`text-sm font-medium leading-normal ${
189
+ className={clsx(
190
+ "text-sm font-medium leading-normal",
191
online ? "text-green-status" : "text-red-500"
84
- }`}
192
+ )}
193
>
194
{online ? "Online" : "Offline"}
195
</p>
@@ -115,10 +223,119 @@ export function ServerCard({
223
</p>
224
)}
225
</div>
226
+ {showAdminControls && leaseId && (
227
+ <div className="flex flex-col gap-2 w-full">
228
+ {/* BPS Row: display on left, settings button on right */}
229
+ <div className="flex items-center justify-between w-full">
230
+ <span className="text-sm text-text-muted">
231
+ BPS:{" "}
232
+ <span className="font-medium text-foreground">
233
+ {formatBPS(bps)}
234
+ </span>
235
+ </span>
236
+ <button
237
+ onClick={handleBPSSettingsClick}
238
+ className="px-3 py-1 text-xs rounded bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors cursor-pointer"
239
+ >
240
+ Settings
241
+ </button>
242
+ </div>
243
+ {/* Ban button */}
244
+ <button
245
+ onClick={handleBanClick}
246
+ className={clsx(
247
+ "w-full px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white",
248
+ isBanned
249
+ ? "bg-green-600 hover:bg-green-700"
250
+ : "bg-red-600 hover:bg-red-700"
251
+ )}
252
+ >
253
+ {isBanned ? "Unban" : "Ban"}
254
+ </button>
255
+ </div>
256
+ )}
257
</div>
258
</div>
259
</div>
260
<div className="absolute top-2 left-2 h-full w-full bg-secondary/70 rounded-xl z-0" />
122
- </Link>
261
+
262
+ {/* BPS Settings Modal */}
263
+ {showBPSModal && (
264
+ <div
265
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
266
+ onClick={handleBPSModalClose}
267
+ >
268
+ <div
269
+ className="bg-background rounded-lg p-6 w-96 shadow-xl border border-foreground/20"
270
+ onClick={(e) => e.stopPropagation()}
271
+ >
272
+ <h3 className="text-lg font-bold mb-4">BPS Settings</h3>
273
+ <p className="text-sm text-text-muted mb-2">
274
+ Set bytes-per-second limit (0 = unlimited)
275
+ </p>
276
+ {/* Current value display */}
277
+ <div className="text-center text-xl font-bold mb-4 text-primary">
278
+ {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
279
+ </div>
280
+ {/* Slider */}
281
+ <input
282
+ type="range"
283
+ min="0"
284
+ max={bpsSteps.length - 1}
285
+ value={sliderIndex}
286
+ onChange={(e) => {
287
+ const idx = parseInt(e.target.value, 10);
288
+ handleSliderChange(idx);
289
+ }}
290
+ className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer mb-2"
291
+ />
292
+ {/* Step labels */}
293
+ <div className="flex justify-between text-xs text-text-muted mb-4">
294
+ {bpsSteps.map((step, idx) => (
295
+ <span
296
+ key={idx}
297
+ className={clsx(
298
+ "cursor-pointer hover:text-foreground transition-colors",
299
+ sliderIndex === idx && "text-primary font-medium"
300
+ )}
301
+ onClick={() => handleSliderChange(idx)}
302
+ >
303
+ {formatStepLabel(step)}
304
+ </span>
305
+ ))}
306
+ </div>
307
+ {/* Manual input */}
308
+ <div className="mb-4">
309
+ <label className="text-xs text-text-muted mb-1 block">Custom value (B/s)</label>
310
+ <input
311
+ type="number"
312
+ value={bpsInput}
313
+ onChange={(e) => {
314
+ setBpsInput(e.target.value);
315
+ syncSliderFromInput(parseInt(e.target.value, 10) || 0);
316
+ }}
317
+ className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
318
+ placeholder="Enter BPS limit"
319
+ min="0"
320
+ />
321
+ </div>
322
+ <div className="flex gap-2">
323
+ <button
324
+ onClick={handleBPSModalClose}
325
+ className="flex-1 px-4 py-2 rounded bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors cursor-pointer"
326
+ >
327
+ Cancel
328
+ </button>
329
+ <button
330
+ onClick={handleBPSSave}
331
+ className="flex-1 px-4 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground transition-colors cursor-pointer"
332
+ >
333
+ Save
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ )}
339
+ </Wrapper>
340
);
341
}
cmd/relay-server/frontend/src/components/ServerListView.tsx
new
+173
@@ -0,0 +1,173 @@
1
+import { Header } from "@/components/Header";
2
+import { SearchBar } from "@/components/SearchBar";
3
+import { ServerCard } from "@/components/ServerCard";
4
+import type { ClientServer } from "@/hooks/useServerList";
5
+import type { AdminServer } from "@/hooks/useAdmin";
6
+import type { SortOption, StatusFilter } from "@/types/filters";
7
+
8
+// Admin-specific filter for ban status
9
+export type BanFilter = "all" | "banned" | "active";
10
+
11
+interface ServerListViewProps {
12
+ // Header customization
13
+ title?: string;
14
+ // Search & Filter state
15
+ searchQuery: string;
16
+ status: StatusFilter;
17
+ sortBy: SortOption;
18
+ selectedTags: string[];
19
+ availableTags: string[];
20
+ // Server data
21
+ filteredServers: ClientServer[] | AdminServer[];
22
+ favorites: number[];
23
+ // Handlers
24
+ onSearchChange: (value: string) => void;
25
+ onStatusChange: (value: StatusFilter) => void;
26
+ onSortByChange: (value: SortOption) => void;
27
+ onTagToggle: (tag: string) => void;
28
+ onToggleFavorite: (serverId: number) => void;
29
+ // Admin mode (optional)
30
+ isAdmin?: boolean;
31
+ banFilter?: BanFilter;
32
+ onBanFilterChange?: (value: BanFilter) => void;
33
+ onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
34
+ onBPSChange?: (leaseId: string, bps: number) => void;
35
+}
36
+
37
+function isAdminServer(
38
+ server: ClientServer | AdminServer
39
+): server is AdminServer {
40
+ return "peerId" in server;
41
+}
42
+
43
+export function ServerListView({
44
+ title = "PORTAL",
45
+ searchQuery,
46
+ status,
47
+ sortBy,
48
+ selectedTags,
49
+ availableTags,
50
+ filteredServers,
51
+ favorites,
52
+ onSearchChange,
53
+ onStatusChange,
54
+ onSortByChange,
55
+ onTagToggle,
56
+ onToggleFavorite,
57
+ // Admin props
58
+ isAdmin = false,
59
+ banFilter = "all",
60
+ onBanFilterChange,
61
+ onBanStatusChange,
62
+ onBPSChange,
63
+}: ServerListViewProps) {
64
+ return (
65
+ <div className="relative flex h-auto min-h-screen w-full flex-col">
66
+ <div className="flex h-full grow flex-col">
67
+ <div className="flex flex-1 justify-center">
68
+ <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
69
+ <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
70
+ <Header title={title} />
71
+ <SearchBar
72
+ searchQuery={searchQuery}
73
+ onSearchChange={onSearchChange}
74
+ status={status}
75
+ onStatusChange={onStatusChange}
76
+ sortBy={sortBy}
77
+ onSortByChange={onSortByChange}
78
+ availableTags={availableTags}
79
+ selectedTags={selectedTags}
80
+ onAddTag={onTagToggle}
81
+ onRemoveTag={onTagToggle}
82
+ />
83
+ {isAdmin && onBanFilterChange && (
84
+ <div className="flex gap-2 mt-4 px-4 sm:px-6">
85
+ <button
86
+ onClick={() => onBanFilterChange("all")}
87
+ className={`px-4 py-2 rounded font-medium transition-colors ${
88
+ banFilter === "all"
89
+ ? "bg-primary text-primary-foreground"
90
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
91
+ }`}
92
+ >
93
+ All
94
+ </button>
95
+ <button
96
+ onClick={() => onBanFilterChange("active")}
97
+ className={`px-4 py-2 rounded font-medium transition-colors ${
98
+ banFilter === "active"
99
+ ? "bg-green-600 text-white"
100
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
101
+ }`}
102
+ >
103
+ Active
104
+ </button>
105
+ <button
106
+ onClick={() => onBanFilterChange("banned")}
107
+ className={`px-4 py-2 rounded font-medium transition-colors ${
108
+ banFilter === "banned"
109
+ ? "bg-red-600 text-white"
110
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
111
+ }`}
112
+ >
113
+ Banned
114
+ </button>
115
+ </div>
116
+ )}
117
+ </div>
118
+ <main className="flex-1">
119
+ <div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6">
120
+ {filteredServers.length > 0 ? (
121
+ filteredServers.map((server) => (
122
+ <ServerCard
123
+ key={server.id}
124
+ serverId={server.id}
125
+ name={server.name}
126
+ description={server.description}
127
+ tags={server.tags}
128
+ thumbnail={server.thumbnail}
129
+ owner={server.owner}
130
+ online={server.online}
131
+ dns={server.dns}
132
+ serverUrl={server.link}
133
+ navigationPath={server.link || "#"}
134
+ navigationState={{
135
+ id: server.id,
136
+ name: server.name,
137
+ description: server.description,
138
+ tags: server.tags,
139
+ thumbnail: server.thumbnail,
140
+ owner: server.owner,
141
+ online: server.online,
142
+ serverUrl: server.link,
143
+ }}
144
+ isFavorite={favorites.includes(server.id)}
145
+ onToggleFavorite={onToggleFavorite}
146
+ // Admin controls
147
+ showAdminControls={isAdmin && isAdminServer(server)}
148
+ leaseId={
149
+ isAdminServer(server) ? server.peerId : undefined
150
+ }
151
+ isBanned={
152
+ isAdminServer(server) ? server.isBanned : undefined
153
+ }
154
+ bps={isAdminServer(server) ? server.bps : undefined}
155
+ onBanStatusChange={onBanStatusChange}
156
+ onBPSChange={onBPSChange}
157
+ />
158
+ ))
159
+ ) : (
160
+ <div className="col-span-full text-center py-12">
161
+ <p className="text-text-muted text-lg">
162
+ No servers found matching your criteria
163
+ </p>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </main>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+}
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+126
-28
@@ -1,32 +1,70 @@
1
-import { useCallback, useEffect, useState } from "react";
2
-
3
-export interface LeaseMetadata {
4
- description: string;
5
- tags: string[];
6
- thumbnail: string;
7
- owner: string;
8
- hide: boolean;
1
+import { useCallback, useEffect, useMemo, useState } from "react";
2
+import type { ServerData, Metadata } from "@/hooks/useSSRData";
3
+import { useList, type BaseServer } from "@/hooks/useList";
4
+import type { BanFilter } from "@/components/ServerListView";
5
+
6
+// Extended BaseServer with admin-specific fields
7
+export interface AdminServer extends BaseServer {
8
+ peerId: string;
9
+ isBanned: boolean;
10
+ bps: number; // bytes-per-second limit (0 = unlimited)
11
}
12
11
-export interface LeaseEntryParsed {
12
- ConnectionID: number;
13
- Expires: string;
14
- LastSeen: string;
15
- Lease: {
16
- identity: { id: string; public_key: string };
17
- expires: number;
18
- name: string;
19
- alpn: string[];
20
- metadata: LeaseMetadata;
13
+// Convert ServerData (from API) to AdminServer format
14
+function convertServerDataToAdminServer(
15
+ row: ServerData,
16
+ index: number,
17
+ bannedLeases: string[]
18
+): AdminServer {
19
+ let metadata: Metadata = {
20
+ description: "",
21
+ tags: [],
22
+ thumbnail: "",
23
+ owner: "",
24
+ hide: false,
25
+ };
26
+
27
+ try {
28
+ if (row.Metadata) {
29
+ metadata = JSON.parse(row.Metadata);
30
+ }
31
+ } catch (err) {
32
+ console.error("[Admin] Failed to parse metadata:", err, row.Metadata);
33
+ }
34
+
35
+ const normalizedTags = Array.isArray(metadata.tags)
36
+ ? metadata.tags
37
+ .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
38
+ .filter(Boolean)
39
+ : [];
40
+
41
+ return {
42
+ id: index + 1,
43
+ name: row.Name || row.DNS || "(unnamed)",
44
+ description: metadata.description || "",
45
+ tags: normalizedTags,
46
+ thumbnail: metadata.thumbnail || "",
47
+ owner: metadata.owner || "",
48
+ online: row.Connected,
49
+ dns: row.DNS || "",
50
+ link: row.Link,
51
+ lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
52
+ // Admin-specific fields
53
+ peerId: row.Peer,
54
+ isBanned: bannedLeases.includes(row.Peer),
55
+ bps: row.BPS || 0,
56
};
57
}
58
59
export function useAdmin() {
25
- const [leases, setLeases] = useState<LeaseEntryParsed[]>([]);
60
+ const [serverData, setServerData] = useState<ServerData[]>([]);
61
const [bannedLeases, setBannedLeases] = useState<string[]>([]);
62
const [loading, setLoading] = useState(true);
63
const [error, setError] = useState("");
64
65
+ // Admin-specific filter state
66
+ const [banFilter, setBanFilter] = useState<BanFilter>("all");
67
+
68
const fetchData = useCallback(async () => {
69
try {
70
const [leasesRes, bannedRes] = await Promise.all([
@@ -38,10 +76,10 @@ export function useAdmin() {
76
throw new Error("Failed to fetch admin data. Are you on localhost?");
77
}
78
41
- const leasesData = await leasesRes.json();
79
+ const leasesData: ServerData[] = await leasesRes.json();
80
const bannedData: string[] = await bannedRes.json();
81
44
- setLeases(leasesData || []);
82
+ setServerData(leasesData || []);
83
// bannedData is base64 encoded byte arrays, decode them
84
const decodedBanned = (bannedData || []).map((b64: string) => {
85
try {
@@ -51,8 +89,8 @@ export function useAdmin() {
89
}
90
});
91
setBannedLeases(decodedBanned);
54
- } catch (err: any) {
55
- setError(err.message);
92
+ } catch (err: unknown) {
93
+ setError(err instanceof Error ? err.message : String(err));
94
} finally {
95
setLoading(false);
96
}
@@ -62,10 +100,40 @@ export function useAdmin() {
100
fetchData();
101
}, [fetchData]);
102
65
- const handleBanStatus = async (leaseId: string, isBan: boolean) => {
103
+ // Convert ServerData to AdminServer format
104
+ const servers: AdminServer[] = useMemo(() => {
105
+ return serverData.map((row, index) =>
106
+ convertServerDataToAdminServer(row, index, bannedLeases)
107
+ );
108
+ }, [serverData, bannedLeases]);
109
+
110
+ // Additional filter for ban status
111
+ const additionalFilter = useCallback(
112
+ (server: AdminServer) => {
113
+ if (banFilter === "all") return true;
114
+ if (banFilter === "banned") return server.isBanned;
115
+ if (banFilter === "active") return !server.isBanned;
116
+ return true;
117
+ },
118
+ [banFilter]
119
+ );
120
+
121
+ // Use common list logic with additional ban filter
122
+ const listState = useList({
123
+ servers,
124
+ storageKey: "adminFavorites",
125
+ additionalFilter,
126
+ });
127
+
128
+ // Admin-specific handlers
129
+ const handleBanFilterChange = useCallback((value: BanFilter) => {
130
+ setBanFilter(value);
131
+ }, []);
132
+
133
+ const handleBanStatus = useCallback(async (peerId: string, isBan: boolean) => {
134
try {
67
- // URL-safe base64 encode the lease ID
68
- const safeId = btoa(leaseId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
135
+ // URL-safe base64 encode the peer ID
136
+ const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
137
await fetch(`/admin/leases/${safeId}/ban`, {
138
method: isBan ? "POST" : "DELETE"
139
});
@@ -73,14 +141,44 @@ export function useAdmin() {
141
} catch (err) {
142
console.error(err);
143
}
76
- };
144
+ }, [fetchData]);
145
+
146
+ const handleBPSChange = useCallback(async (peerId: string, bps: number) => {
147
+ try {
148
+ // URL-safe base64 encode the peer ID
149
+ const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
150
+ if (bps <= 0) {
151
+ await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
152
+ } else {
153
+ await fetch(`/admin/leases/${safeId}/bps`, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ bps }),
157
+ });
158
+ }
159
+ fetchData();
160
+ } catch (err) {
161
+ console.error(err);
162
+ }
163
+ }, [fetchData]);
164
165
return {
79
- leases,
166
+ // Raw data
167
+ serverData,
168
bannedLeases,
169
+ // Converted servers (before filtering)
170
+ servers,
171
+ // All list state and handlers from useList
172
+ ...listState,
173
+ // Admin-specific filter state
174
+ banFilter,
175
+ // State
176
loading,
177
error,
178
+ // Admin-specific handlers
179
+ handleBanFilterChange,
180
handleBanStatus,
181
+ handleBPSChange,
182
refresh: fetchData
183
};
184
}
cmd/relay-server/frontend/src/hooks/useList.ts
new
+190
@@ -0,0 +1,190 @@
1
+import { useCallback, useEffect, useMemo, useState } from "react";
2
+import type { SortOption, StatusFilter } from "@/types/filters";
3
+
4
+// Base server interface that both ClientServer and AdminServer extend
5
+export interface BaseServer {
6
+ id: number;
7
+ name: string;
8
+ description: string;
9
+ tags: string[];
10
+ thumbnail: string;
11
+ owner: string;
12
+ online: boolean;
13
+ dns: string;
14
+ link: string;
15
+ lastUpdated?: string;
16
+}
17
+
18
+export interface UseListOptions<T extends BaseServer> {
19
+ servers: T[];
20
+ storageKey: string;
21
+ // Optional additional filter function for extended filtering (e.g., ban filter)
22
+ additionalFilter?: (server: T) => boolean;
23
+}
24
+
25
+export interface UseListReturn<T extends BaseServer> {
26
+ // Filter states
27
+ searchQuery: string;
28
+ status: StatusFilter;
29
+ sortBy: SortOption;
30
+ selectedTags: string[];
31
+ favorites: number[];
32
+ // Derived data
33
+ availableTags: string[];
34
+ filteredServers: T[];
35
+ // Handlers
36
+ handleSearchChange: (value: string) => void;
37
+ handleStatusChange: (value: StatusFilter) => void;
38
+ handleSortByChange: (value: SortOption) => void;
39
+ handleTagToggle: (tag: string) => void;
40
+ handleToggleFavorite: (serverId: number) => void;
41
+}
42
+
43
+export function useList<T extends BaseServer>({
44
+ servers,
45
+ storageKey,
46
+ additionalFilter,
47
+}: UseListOptions<T>): UseListReturn<T> {
48
+ const [searchQuery, setSearchQuery] = useState("");
49
+ const [status, setStatus] = useState<StatusFilter>("all");
50
+ const [sortBy, setSortBy] = useState<SortOption>("default");
51
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
52
+ const [favorites, setFavorites] = useState<number[]>(() => {
53
+ const stored = localStorage.getItem(storageKey);
54
+ return stored ? JSON.parse(stored) : [];
55
+ });
56
+
57
+ // Save favorites to localStorage whenever they change
58
+ useEffect(() => {
59
+ localStorage.setItem(storageKey, JSON.stringify(favorites));
60
+ }, [favorites, storageKey]);
61
+
62
+ // Extract available tags
63
+ const availableTags = useMemo(() => {
64
+ const counts = new Map<string, number>();
65
+ servers.forEach((server) => {
66
+ server.tags.forEach((tag) => {
67
+ counts.set(tag, (counts.get(tag) || 0) + 1);
68
+ });
69
+ });
70
+ return Array.from(counts.entries())
71
+ .sort((a, b) => b[1] - a[1])
72
+ .map(([tag]) => tag);
73
+ }, [servers]);
74
+
75
+ // Filter and sort servers
76
+ const filteredServers = useMemo(() => {
77
+ const query = searchQuery.toLowerCase();
78
+
79
+ const matchesTags = (server: T) => {
80
+ if (selectedTags.length === 0) return true;
81
+ const tagsLower = server.tags.map((t) => t.toLowerCase());
82
+ return selectedTags.some((tag) => tagsLower.includes(tag.toLowerCase()));
83
+ };
84
+
85
+ const filtered = servers.filter((server) => {
86
+ const matchesSearch =
87
+ query === "" ||
88
+ server.name.toLowerCase().includes(query) ||
89
+ server.description.toLowerCase().includes(query) ||
90
+ server.tags.some((tag) => tag.toLowerCase().includes(query));
91
+
92
+ const matchesStatus =
93
+ status === "all" ||
94
+ (status === "online" && server.online) ||
95
+ (status === "offline" && !server.online);
96
+
97
+ const matchesAdditional = additionalFilter ? additionalFilter(server) : true;
98
+
99
+ return matchesSearch && matchesStatus && matchesTags(server) && matchesAdditional;
100
+ });
101
+
102
+ const sorted = [...filtered];
103
+ switch (sortBy) {
104
+ case "name-asc":
105
+ sorted.sort((a, b) => a.name.localeCompare(b.name));
106
+ break;
107
+ case "name-desc":
108
+ sorted.sort((a, b) => b.name.localeCompare(a.name));
109
+ break;
110
+ case "updated":
111
+ sorted.sort((a, b) => {
112
+ const aTime = a.lastUpdated ? Date.parse(a.lastUpdated) : 0;
113
+ const bTime = b.lastUpdated ? Date.parse(b.lastUpdated) : 0;
114
+ return bTime - aTime;
115
+ });
116
+ break;
117
+ case "description":
118
+ sorted.sort((a, b) => a.description.localeCompare(b.description));
119
+ break;
120
+ case "tags":
121
+ sorted.sort((a, b) => {
122
+ const aTag = a.tags[0] || "";
123
+ const bTag = b.tags[0] || "";
124
+ return aTag.localeCompare(bTag);
125
+ });
126
+ break;
127
+ case "owner":
128
+ sorted.sort((a, b) => a.owner.localeCompare(b.owner));
129
+ break;
130
+ default:
131
+ break;
132
+ }
133
+
134
+ // Sort by favorites first
135
+ sorted.sort((a, b) => {
136
+ const aIsFav = favorites.includes(a.id);
137
+ const bIsFav = favorites.includes(b.id);
138
+ if (aIsFav && !bIsFav) return -1;
139
+ if (!aIsFav && bIsFav) return 1;
140
+ return 0;
141
+ });
142
+
143
+ return sorted;
144
+ }, [servers, searchQuery, status, sortBy, selectedTags, favorites, additionalFilter]);
145
+
146
+ // Handlers
147
+ const handleSearchChange = useCallback((value: string) => {
148
+ setSearchQuery(value);
149
+ }, []);
150
+
151
+ const handleStatusChange = useCallback((value: StatusFilter) => {
152
+ setStatus(value);
153
+ }, []);
154
+
155
+ const handleSortByChange = useCallback((value: SortOption) => {
156
+ setSortBy(value);
157
+ }, []);
158
+
159
+ const handleTagToggle = useCallback((tag: string) => {
160
+ setSelectedTags((prev) =>
161
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
162
+ );
163
+ }, []);
164
+
165
+ const handleToggleFavorite = useCallback((serverId: number) => {
166
+ setFavorites((prev) =>
167
+ prev.includes(serverId)
168
+ ? prev.filter((id) => id !== serverId)
169
+ : [...prev, serverId]
170
+ );
171
+ }, []);
172
+
173
+ return {
174
+ // Filter states
175
+ searchQuery,
176
+ status,
177
+ sortBy,
178
+ selectedTags,
179
+ favorites,
180
+ // Derived data
181
+ availableTags,
182
+ filteredServers,
183
+ // Handlers
184
+ handleSearchChange,
185
+ handleStatusChange,
186
+ handleSortByChange,
187
+ handleTagToggle,
188
+ handleToggleFavorite,
189
+ };
190
+}
cmd/relay-server/frontend/src/hooks/useSSRData.ts
+1
@@ -21,6 +21,7 @@ export interface ServerData {
21
StaleRed: boolean;
22
Hide: boolean;
23
Metadata: string;
24
+ BPS?: number; // bytes-per-second limit (0 = unlimited), admin only
25
}
26
27
/**
cmd/relay-server/frontend/src/hooks/useServerList.ts
new
+83
@@ -0,0 +1,83 @@
1
+import { useMemo } from "react";
2
+import { useSSRData } from "@/hooks/useSSRData";
3
+import type { ServerData, Metadata } from "@/hooks/useSSRData";
4
+import { useList, type BaseServer } from "@/hooks/useList";
5
+import { generateRandomServers } from "@/lib/testUtils";
6
+
7
+const useDebug = false;
8
+
9
+export type ClientServer = BaseServer;
10
+
11
+function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
12
+ return ssrData.map((row, index) => {
13
+ let metadata: Metadata = {
14
+ description: "",
15
+ tags: [],
16
+ thumbnail: "",
17
+ owner: "",
18
+ hide: false,
19
+ };
20
+
21
+ try {
22
+ if (row.Metadata) {
23
+ metadata = JSON.parse(row.Metadata);
24
+ }
25
+ } catch (err) {
26
+ console.error("[App] Failed to parse metadata:", err, row.Metadata);
27
+ }
28
+
29
+ const normalizedTags = Array.isArray(metadata.tags)
30
+ ? metadata.tags
31
+ .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
32
+ .filter(Boolean)
33
+ : [];
34
+
35
+ return {
36
+ id: index + 1,
37
+ name: row.Name || row.DNS || "(unnamed)",
38
+ description: metadata.description || "",
39
+ tags: normalizedTags,
40
+ thumbnail: metadata.thumbnail || "",
41
+ owner: metadata.owner || "",
42
+ online: row.Connected,
43
+ dns: row.DNS || "",
44
+ link: row.Link,
45
+ lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
46
+ };
47
+ });
48
+}
49
+
50
+export function useServerList() {
51
+ // Get SSR data
52
+ const ssrData = useSSRData();
53
+
54
+ // Convert SSR data to servers
55
+ const servers: ClientServer[] = useMemo(() => {
56
+ console.log("[App] SSR data length:", ssrData.length);
57
+
58
+ if (useDebug) {
59
+ return generateRandomServers(100);
60
+ }
61
+ if (ssrData.length > 0) {
62
+ console.log("[App] Using SSR data");
63
+ const converted = convertSSRDataToServers(ssrData);
64
+ console.log("[App] Converted servers:", converted);
65
+ return converted;
66
+ }
67
+ console.log("[App] Using sample servers");
68
+ return [];
69
+ }, [ssrData]);
70
+
71
+ // Use common list logic
72
+ const listState = useList({
73
+ servers,
74
+ storageKey: "serverFavorites",
75
+ });
76
+
77
+ return {
78
+ // Raw servers (before filtering)
79
+ servers,
80
+ // All list state and handlers from useList
81
+ ...listState,
82
+ };
83
+}
cmd/relay-server/frontend/src/pages/Admin.tsx
+43
-54
@@ -1,66 +1,55 @@
1
-import { useAdmin } from "@/hooks/useAdmin";
1
import { SsgoiTransition } from "@ssgoi/react";
2
+import { useAdmin } from "@/hooks/useAdmin";
3
+import { ServerListView } from "@/components/ServerListView";
4
5
export function Admin() {
5
- const { leases, bannedLeases, loading, error, handleBanStatus } = useAdmin();
6
+ const {
7
+ filteredServers,
8
+ availableTags,
9
+ searchQuery,
10
+ status,
11
+ sortBy,
12
+ selectedTags,
13
+ banFilter,
14
+ favorites,
15
+ loading,
16
+ error,
17
+ handleSearchChange,
18
+ handleStatusChange,
19
+ handleSortByChange,
20
+ handleTagToggle,
21
+ handleBanFilterChange,
22
+ handleToggleFavorite,
23
+ handleBanStatus,
24
+ handleBPSChange,
25
+ } = useAdmin();
26
27
if (loading) return <div className="p-8 text-foreground">Loading...</div>;
28
if (error) return <div className="p-8 text-red-500">Error: {error}</div>;
29
30
return (
31
<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>
32
+ <ServerListView
33
+ title="PORTAL ADMIN"
34
+ searchQuery={searchQuery}
35
+ status={status}
36
+ sortBy={sortBy}
37
+ selectedTags={selectedTags}
38
+ availableTags={availableTags}
39
+ filteredServers={filteredServers}
40
+ favorites={favorites}
41
+ onSearchChange={handleSearchChange}
42
+ onStatusChange={handleStatusChange}
43
+ onSortByChange={handleSortByChange}
44
+ onTagToggle={handleTagToggle}
45
+ onToggleFavorite={handleToggleFavorite}
46
+ // Admin-specific props
47
+ isAdmin={true}
48
+ banFilter={banFilter}
49
+ onBanFilterChange={handleBanFilterChange}
50
+ onBanStatusChange={handleBanStatus}
51
+ onBPSChange={handleBPSChange}
52
+ />
53
</SsgoiTransition>
54
);
55
}
66
-
cmd/relay-server/frontend/src/pages/ServerList.tsx
+33
-267
@@ -1,277 +1,43 @@
1
-import { useEffect, useMemo, useState } from "react";
2
-import { Header } from "@/components/Header";
3
-import { SearchBar } from "@/components/SearchBar";
4
-import { ServerCard } from "@/components/ServerCard";
5
-import { useSSRData } from "@/hooks/useSSRData";
6
-import type { ServerData, Metadata } from "@/hooks/useSSRData";
1
import { SsgoiTransition } from "@ssgoi/react";
8
-import type { SortOption, StatusFilter } from "@/types/filters";
9
-import { generateRandomServers } from "@/lib/testUtils";
2
+import { useServerList } from "@/hooks/useServerList";
3
+import { ServerListView } from "@/components/ServerListView";
4
11
-const useDebug = false;
12
-
13
-export type ClientServer = {
14
- id: number;
15
- name: string;
16
- description: string;
17
- tags: string[];
18
- thumbnail: string;
19
- owner: string;
20
- online: boolean;
21
- dns: string;
22
- link: string;
23
- lastUpdated?: string;
24
-};
25
-
26
-// Helper function to convert SSR ServerData to frontend format
27
-function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
28
- return ssrData.map((row, index) => {
29
- // Parse metadata JSON string
30
- let metadata: Metadata = {
31
- description: "",
32
- tags: [],
33
- thumbnail: "",
34
- owner: "",
35
- hide: false,
36
- };
37
-
38
- try {
39
- if (row.Metadata) {
40
- metadata = JSON.parse(row.Metadata);
41
- }
42
- } catch (err) {
43
- console.error("[App] Failed to parse metadata:", err, row.Metadata);
44
- }
45
-
46
- const normalizedTags = Array.isArray(metadata.tags)
47
- ? metadata.tags
48
- .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
49
- .filter(Boolean)
50
- : [];
51
-
52
- return {
53
- id: index + 1,
54
- name: row.Name || row.DNS || "(unnamed)",
55
- description: metadata.description || "",
56
- tags: normalizedTags,
57
- thumbnail: metadata.thumbnail || "",
58
- owner: metadata.owner || "",
59
- online: row.Connected,
60
- dns: row.DNS || "",
61
- link: row.Link,
62
- lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
63
- };
64
- });
65
-}
5
+// Re-export for backwards compatibility
6
+export type { ClientServer } from "@/hooks/useServerList";
7
8
export function ServerList() {
68
- const [searchQuery, setSearchQuery] = useState("");
69
- const [status, setStatus] = useState<StatusFilter>("all");
70
- const [sortBy, setSortBy] = useState<SortOption>("default");
71
- const [selectedTags, setSelectedTags] = useState<string[]>([]);
72
- const [favorites, setFavorites] = useState<number[]>(() => {
73
- // Load favorites from localStorage
74
- const stored = localStorage.getItem("serverFavorites");
75
- return stored ? JSON.parse(stored) : [];
76
- });
77
-
78
- // Save favorites to localStorage whenever they change
79
- useEffect(() => {
80
- localStorage.setItem("serverFavorites", JSON.stringify(favorites));
81
- }, [favorites]);
82
-
83
- // Get SSR data
84
- const ssrData = useSSRData();
85
-
86
- // Use SSR data if available, otherwise fall back to sample servers
87
- const servers: ClientServer[] = useMemo(() => {
88
- console.log("[App] SSR data length:", ssrData.length);
89
-
90
- if (useDebug) {
91
- return generateRandomServers(100);
92
- }
93
- if (ssrData.length > 0) {
94
- console.log("[App] Using SSR data");
95
- const converted = convertSSRDataToServers(ssrData);
96
- console.log("[App] Converted servers:", converted);
97
- return converted;
98
- }
99
- console.log("[App] Using sample servers");
100
- return [];
101
- }, [ssrData]);
102
-
103
- const availableTags = useMemo(() => {
104
- const counts = new Map<string, number>();
105
- servers.forEach((server) => {
106
- server.tags.forEach((tag) => {
107
- counts.set(tag, (counts.get(tag) || 0) + 1);
108
- });
109
- });
110
- return Array.from(counts.entries())
111
- .sort((a, b) => b[1] - a[1])
112
- .map(([tag]) => tag);
113
- }, [servers]);
114
-
115
- // Filter and sort servers (render all at once, no pagination)
116
- const filteredServers = useMemo(() => {
117
- const query = searchQuery.toLowerCase();
118
-
119
- const matchesTags = (server: ClientServer) => {
120
- if (selectedTags.length === 0) return true;
121
- const tagsLower = server.tags.map((t) => t.toLowerCase());
122
- // Always use OR mode
123
- return selectedTags.some((tag) => tagsLower.includes(tag.toLowerCase()));
124
- };
125
-
126
- const filtered = servers.filter((server) => {
127
- const matchesSearch =
128
- query === "" ||
129
- server.name.toLowerCase().includes(query) ||
130
- server.description.toLowerCase().includes(query) ||
131
- server.tags.some((tag) => tag.toLowerCase().includes(query));
132
-
133
- const matchesStatus =
134
- status === "all" ||
135
- (status === "online" && server.online) ||
136
- (status === "offline" && !server.online);
137
-
138
- return matchesSearch && matchesStatus && matchesTags(server);
139
- });
140
-
141
- const sorted = [...filtered];
142
- switch (sortBy) {
143
- case "name-asc":
144
- sorted.sort((a, b) => a.name.localeCompare(b.name));
145
- break;
146
- case "name-desc":
147
- sorted.sort((a, b) => b.name.localeCompare(a.name));
148
- break;
149
- case "updated":
150
- sorted.sort((a, b) => {
151
- const aTime = a.lastUpdated ? Date.parse(a.lastUpdated) : 0;
152
- const bTime = b.lastUpdated ? Date.parse(b.lastUpdated) : 0;
153
- return bTime - aTime;
154
- });
155
- break;
156
- case "description":
157
- sorted.sort((a, b) => a.description.localeCompare(b.description));
158
- break;
159
- case "tags":
160
- sorted.sort((a, b) => {
161
- const aTag = a.tags[0] || "";
162
- const bTag = b.tags[0] || "";
163
- return aTag.localeCompare(bTag);
164
- });
165
- break;
166
- case "owner":
167
- sorted.sort((a, b) => a.owner.localeCompare(b.owner));
168
- break;
169
- default:
170
- break;
171
- }
172
-
173
- // Sort by favorites first
174
- sorted.sort((a, b) => {
175
- const aIsFav = favorites.includes(a.id);
176
- const bIsFav = favorites.includes(b.id);
177
- if (aIsFav && !bIsFav) return -1;
178
- if (!aIsFav && bIsFav) return 1;
179
- return 0;
180
- });
181
-
182
- return sorted;
183
- }, [servers, searchQuery, status, sortBy, selectedTags, favorites]);
184
-
185
- const handleSearchChange = (value: string) => {
186
- setSearchQuery(value);
187
- };
188
-
189
- const handleStatusChange = (value: StatusFilter) => {
190
- setStatus(value);
191
- };
192
-
193
- const handleSortByChange = (value: SortOption) => {
194
- setSortBy(value);
195
- };
196
-
197
- const handleTagToggle = (tag: string) => {
198
- setSelectedTags((prev) =>
199
- prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
200
- );
201
- };
202
-
203
- const handleToggleFavorite = (serverId: number) => {
204
- setFavorites((prev) =>
205
- prev.includes(serverId)
206
- ? prev.filter((id) => id !== serverId)
207
- : [...prev, serverId]
208
- );
209
- };
9
+ // Controller: useServerList hook handles all server list logic
10
+ const {
11
+ searchQuery,
12
+ status,
13
+ sortBy,
14
+ selectedTags,
15
+ availableTags,
16
+ filteredServers,
17
+ favorites,
18
+ handleSearchChange,
19
+ handleStatusChange,
20
+ handleSortByChange,
21
+ handleTagToggle,
22
+ handleToggleFavorite,
23
+ } = useServerList();
24
25
return (
26
<SsgoiTransition id="/">
213
- <div className="relative flex h-auto min-h-screen w-full flex-col">
214
- <div className="flex h-full grow flex-col">
215
- <div className="flex flex-1 justify-center">
216
- <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
217
- <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
218
- <Header />
219
- <SearchBar
220
- searchQuery={searchQuery}
221
- onSearchChange={handleSearchChange}
222
- status={status}
223
- onStatusChange={handleStatusChange}
224
- sortBy={sortBy}
225
- onSortByChange={handleSortByChange}
226
- availableTags={availableTags}
227
- selectedTags={selectedTags}
228
- onAddTag={handleTagToggle}
229
- onRemoveTag={handleTagToggle}
230
- />
231
- </div>
232
- <main className="flex-1">
233
- <div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6">
234
- {filteredServers.length > 0 ? (
235
- filteredServers.map((server) => (
236
- <ServerCard
237
- key={server.id}
238
- serverId={server.id}
239
- name={server.name}
240
- description={server.description}
241
- tags={server.tags}
242
- thumbnail={server.thumbnail}
243
- owner={server.owner}
244
- online={server.online}
245
- dns={server.dns}
246
- serverUrl={server.link}
247
- navigationPath={server.link}
248
- navigationState={{
249
- id: server.id,
250
- name: server.name,
251
- description: server.description,
252
- tags: server.tags,
253
- thumbnail: server.thumbnail,
254
- owner: server.owner,
255
- online: server.online,
256
- serverUrl: server.link,
257
- }}
258
- isFavorite={favorites.includes(server.id)}
259
- onToggleFavorite={handleToggleFavorite}
260
- />
261
- ))
262
- ) : (
263
- <div className="col-span-full text-center py-12">
264
- <p className="text-text-muted text-lg">
265
- No servers found matching your criteria
266
- </p>
267
- </div>
268
- )}
269
- </div>
270
- </main>
271
- </div>
272
- </div>
273
- </div>
274
- </div>
27
+ <ServerListView
28
+ searchQuery={searchQuery}
29
+ status={status}
30
+ sortBy={sortBy}
31
+ selectedTags={selectedTags}
32
+ availableTags={availableTags}
33
+ filteredServers={filteredServers}
34
+ favorites={favorites}
35
+ onSearchChange={handleSearchChange}
36
+ onStatusChange={handleStatusChange}
37
+ onSortByChange={handleSortByChange}
38
+ onTagToggle={handleTagToggle}
39
+ onToggleFavorite={handleToggleFavorite}
40
+ />
41
</SsgoiTransition>
42
);
43
}
cmd/relay-server/main.go
+15
-2
@@ -10,6 +10,7 @@ import (
10
"syscall"
11
"time"
12
13
+ "github.com/hashicorp/yamux"
14
"github.com/rs/zerolog"
15
"github.com/rs/zerolog/log"
16
@@ -81,13 +82,25 @@ func runServer() error {
82
if flagMaxLease > 0 {
83
serv.SetMaxRelayedPerLease(flagMaxLease)
84
}
85
+
86
+ // Create BPS manager for rate limiting
87
+ bpsManager := NewBPSManager()
88
if flagLeaseBPS > 0 {
85
- serv.GetLeaseManager().SetDefaultBPS(int64(flagLeaseBPS))
89
+ bpsManager.SetDefaultBPS(int64(flagLeaseBPS))
90
}
91
+
92
+ // Load persisted admin settings (ban list, BPS limits)
93
+ loadAdminSettings(serv, bpsManager)
94
+
95
+ // Register relay callback for BPS handling
96
+ serv.SetEstablishRelayCallback(func(clientStream, leaseStream *yamux.Stream, leaseID string) {
97
+ establishRelayWithBPS(clientStream, leaseStream, leaseID, bpsManager)
98
+ })
99
+
100
serv.Start()
101
defer serv.Stop()
102
90
- httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, cred.ID(), flagBootstraps, flagNoIndex, stop)
103
+ httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, bpsManager, cred.ID(), flagBootstraps, flagNoIndex, stop)
104
105
<-ctx.Done()
106
log.Info().Msg("[server] shutting down...")
cmd/relay-server/ratelimit/bucket.go
new
+256
@@ -0,0 +1,256 @@
1
+package ratelimit
2
+
3
+import (
4
+ "io"
5
+ "sync"
6
+ "sync/atomic"
7
+ "time"
8
+
9
+ "github.com/rs/zerolog/log"
10
+)
11
+
12
+// Bucket is a thread-safe rate limiter that supports multiple concurrent connections
13
+// sharing the same bandwidth limit. It uses a token bucket algorithm where tokens
14
+// represent bytes, and the bucket refills at the configured rate.
15
+type Bucket struct {
16
+ mu sync.Mutex
17
+
18
+ rateBps int64 // bytes per second limit
19
+ tokens float64 // current available tokens (bytes)
20
+ maxTokens float64 // maximum tokens (burst size)
21
+ lastRefill time.Time
22
+
23
+ // Stats
24
+ totalBytes int64
25
+ totalWaited int64 // total wait time in nanoseconds
26
+ throttleHits int64 // number of times we had to wait
27
+}
28
+
29
+// NewBucket creates a limiter for rateBps with burst bytes.
30
+// Multiple connections can share this bucket for fair bandwidth distribution.
31
+func NewBucket(rateBps int64, burst int64) *Bucket {
32
+ if rateBps <= 0 {
33
+ return nil
34
+ }
35
+ if burst <= 0 {
36
+ burst = rateBps // default burst = 1 second worth
37
+ }
38
+ return &Bucket{
39
+ rateBps: rateBps,
40
+ tokens: float64(burst), // start with full burst
41
+ maxTokens: float64(burst),
42
+ lastRefill: time.Now(),
43
+ }
44
+}
45
+
46
+// Take requests n bytes from the bucket. If not enough tokens are available,
47
+// it waits until sufficient tokens accumulate. This ensures fair distribution
48
+// among multiple concurrent connections sharing the same bucket.
49
+func (b *Bucket) Take(n int64) {
50
+ if b == nil || n <= 0 {
51
+ return
52
+ }
53
+
54
+ needed := float64(n)
55
+
56
+ for {
57
+ b.mu.Lock()
58
+
59
+ // Refill tokens based on elapsed time
60
+ now := time.Now()
61
+ elapsed := now.Sub(b.lastRefill).Seconds()
62
+ b.tokens += elapsed * float64(b.rateBps)
63
+ if b.tokens > b.maxTokens {
64
+ b.tokens = b.maxTokens
65
+ }
66
+ b.lastRefill = now
67
+
68
+ if b.tokens >= needed {
69
+ // Enough tokens available, consume them
70
+ b.tokens -= needed
71
+ b.mu.Unlock()
72
+ atomic.AddInt64(&b.totalBytes, n)
73
+ return
74
+ }
75
+
76
+ // Calculate how long to wait for enough tokens
77
+ deficit := needed - b.tokens
78
+ waitTime := time.Duration(deficit / float64(b.rateBps) * float64(time.Second))
79
+
80
+ // Take whatever tokens are available now
81
+ if b.tokens > 0 {
82
+ needed -= b.tokens
83
+ b.tokens = 0
84
+ }
85
+
86
+ b.mu.Unlock()
87
+
88
+ // Wait for tokens to accumulate
89
+ if waitTime > 0 {
90
+ atomic.AddInt64(&b.throttleHits, 1)
91
+ atomic.AddInt64(&b.totalWaited, int64(waitTime))
92
+ log.Debug().
93
+ Int64("bytes_requested", n).
94
+ Int64("rate_bps", b.rateBps).
95
+ Dur("wait_time", waitTime).
96
+ Msg("[RateLimit] Throttling - waiting for bandwidth")
97
+ time.Sleep(waitTime)
98
+ }
99
+ }
100
+}
101
+
102
+// TakeWithTimeout requests n bytes but returns false if it would take longer
103
+// than maxWait to acquire them. Returns true if tokens were acquired.
104
+func (b *Bucket) TakeWithTimeout(n int64, maxWait time.Duration) bool {
105
+ if b == nil || n <= 0 {
106
+ return true
107
+ }
108
+
109
+ deadline := time.Now().Add(maxWait)
110
+ needed := float64(n)
111
+
112
+ for {
113
+ b.mu.Lock()
114
+
115
+ // Refill tokens
116
+ now := time.Now()
117
+ elapsed := now.Sub(b.lastRefill).Seconds()
118
+ b.tokens += elapsed * float64(b.rateBps)
119
+ if b.tokens > b.maxTokens {
120
+ b.tokens = b.maxTokens
121
+ }
122
+ b.lastRefill = now
123
+
124
+ if b.tokens >= needed {
125
+ b.tokens -= needed
126
+ b.mu.Unlock()
127
+ atomic.AddInt64(&b.totalBytes, n)
128
+ return true
129
+ }
130
+
131
+ // Check if we have time to wait
132
+ deficit := needed - b.tokens
133
+ waitTime := time.Duration(deficit / float64(b.rateBps) * float64(time.Second))
134
+
135
+ if now.Add(waitTime).After(deadline) {
136
+ b.mu.Unlock()
137
+ return false // Would take too long
138
+ }
139
+
140
+ if b.tokens > 0 {
141
+ needed -= b.tokens
142
+ b.tokens = 0
143
+ }
144
+
145
+ b.mu.Unlock()
146
+
147
+ if waitTime > 0 {
148
+ atomic.AddInt64(&b.throttleHits, 1)
149
+ atomic.AddInt64(&b.totalWaited, int64(waitTime))
150
+ time.Sleep(waitTime)
151
+ }
152
+ }
153
+}
154
+
155
+// Available returns the current number of available tokens (bytes)
156
+func (b *Bucket) Available() float64 {
157
+ if b == nil {
158
+ return 0
159
+ }
160
+ b.mu.Lock()
161
+ defer b.mu.Unlock()
162
+
163
+ // Refill first
164
+ now := time.Now()
165
+ elapsed := now.Sub(b.lastRefill).Seconds()
166
+ b.tokens += elapsed * float64(b.rateBps)
167
+ if b.tokens > b.maxTokens {
168
+ b.tokens = b.maxTokens
169
+ }
170
+ b.lastRefill = now
171
+
172
+ return b.tokens
173
+}
174
+
175
+// Rate returns the configured rate in bytes per second
176
+func (b *Bucket) Rate() int64 {
177
+ if b == nil {
178
+ return 0
179
+ }
180
+ return b.rateBps
181
+}
182
+
183
+// Stats returns current statistics
184
+func (b *Bucket) Stats() (totalBytes, throttleHits int64, totalWaited time.Duration) {
185
+ return atomic.LoadInt64(&b.totalBytes),
186
+ atomic.LoadInt64(&b.throttleHits),
187
+ time.Duration(atomic.LoadInt64(&b.totalWaited))
188
+}
189
+
190
+// internal buffer pool for Copy - use smaller buffer for finer-grained rate limiting
191
+var bufPool = sync.Pool{New: func() any { return make([]byte, 16*1024) }}
192
+
193
+// Copy copies from src to dst, enforcing the provided byte-rate bucket if not nil.
194
+// Multiple Copy calls sharing the same bucket will fairly share the bandwidth.
195
+// Returns bytes written and any copy error encountered.
196
+func Copy(dst io.Writer, src io.Reader, b *Bucket) (int64, error) {
197
+ if b == nil {
198
+ return io.Copy(dst, src)
199
+ }
200
+ buf := bufPool.Get().([]byte)
201
+ defer bufPool.Put(buf)
202
+
203
+ var total int64
204
+ startTime := time.Now()
205
+
206
+ for {
207
+ nr, er := src.Read(buf)
208
+ if nr > 0 {
209
+ // Take rate limit BEFORE writing - this delays the write if needed
210
+ // Multiple connections sharing this bucket will wait fairly
211
+ b.Take(int64(nr))
212
+ nw, ew := dst.Write(buf[:nr])
213
+ if nw > 0 {
214
+ total += int64(nw)
215
+ }
216
+ if ew != nil {
217
+ logCopyStats(b, total, startTime)
218
+ return total, ew
219
+ }
220
+ if nr != nw {
221
+ logCopyStats(b, total, startTime)
222
+ return total, io.ErrShortWrite
223
+ }
224
+ }
225
+ if er != nil {
226
+ if er == io.EOF {
227
+ break
228
+ }
229
+ logCopyStats(b, total, startTime)
230
+ return total, er
231
+ }
232
+ }
233
+ logCopyStats(b, total, startTime)
234
+ return total, nil
235
+}
236
+
237
+// logCopyStats logs summary statistics when copy completes
238
+func logCopyStats(b *Bucket, totalBytes int64, startTime time.Time) {
239
+ if b == nil || totalBytes == 0 {
240
+ return
241
+ }
242
+ elapsed := time.Since(startTime)
243
+ if elapsed > 0 {
244
+ actualBps := float64(totalBytes) / elapsed.Seconds()
245
+ throttleHits := atomic.LoadInt64(&b.throttleHits)
246
+ totalWaited := time.Duration(atomic.LoadInt64(&b.totalWaited))
247
+ log.Debug().
248
+ Int64("total_bytes", totalBytes).
249
+ Int64("rate_limit_bps", b.rateBps).
250
+ Float64("actual_bps", actualBps).
251
+ Dur("elapsed", elapsed).
252
+ Int64("throttle_hits", throttleHits).
253
+ Dur("total_waited", totalWaited).
254
+ Msg("[RateLimit] Copy completed")
255
+ }
256
+}
cmd/relay-server/view.go
+279
-4
@@ -8,7 +8,10 @@ import (
8
"fmt"
9
"net"
10
"net/http"
11
+ "os"
12
+ "path/filepath"
13
"strings"
14
+ "sync"
15
"time"
16
17
"github.com/rs/zerolog/log"
@@ -21,8 +24,12 @@ import (
24
//go:embed dist/*
25
var distFS embed.FS
26
27
+// Package-level BPS manager reference for admin handlers
28
+var globalBPSManager *BPSManager
29
+
30
// serveHTTP builds the HTTP mux and returns the server.
25
-func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps []string, noIndex bool, cancel context.CancelFunc) *http.Server {
31
+func serveHTTP(addr string, serv *portal.RelayServer, bpsManager *BPSManager, nodeID string, bootstraps []string, noIndex bool, cancel context.CancelFunc) *http.Server {
32
+ globalBPSManager = bpsManager
33
if addr == "" {
34
addr = ":0"
35
}
@@ -189,6 +196,7 @@ type leaseRow struct {
196
StaleRed bool
197
Hide bool
198
Metadata string
199
+ BPS int64 // bytes-per-second limit (0 = unlimited)
200
}
201
202
// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page
@@ -202,10 +210,10 @@ func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.Rel
210
p := strings.TrimPrefix(r.URL.Path, "/admin/")
211
212
if p == "leases" && r.Method == http.MethodGet {
205
- // List all leases
206
- leases := serv.GetAllLeaseEntries()
213
+ // List all leases in the same format as SSR data (leaseRow)
214
+ rows := convertLeaseEntriesToAdminRows(serv)
215
w.Header().Set("Content-Type", "application/json")
208
- json.NewEncoder(w).Encode(leases)
216
+ json.NewEncoder(w).Encode(rows)
217
return
218
}
219
@@ -239,11 +247,13 @@ func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.Rel
247
248
if r.Method == http.MethodPost {
249
serv.GetLeaseManager().BanLease(leaseID)
250
+ saveAdminSettings(serv, globalBPSManager)
251
w.WriteHeader(http.StatusOK)
252
return
253
}
254
if r.Method == http.MethodDelete {
255
serv.GetLeaseManager().UnbanLease(leaseID)
256
+ saveAdminSettings(serv, globalBPSManager)
257
w.WriteHeader(http.StatusOK)
258
return
259
}
@@ -257,9 +267,172 @@ func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.Rel
267
return
268
}
269
270
+ // Set BPS limit for a lease: POST/DELETE /admin/leases/{id}/bps
271
+ if strings.HasPrefix(p, "leases/") && strings.HasSuffix(p, "/bps") {
272
+ parts := strings.Split(p, "/")
273
+ if len(parts) == 3 {
274
+ encodedID := parts[1]
275
+
276
+ // Decode ID (expecting URL-safe base64 from frontend)
277
+ idBytes, err := base64.URLEncoding.DecodeString(encodedID)
278
+ if err != nil {
279
+ idBytes, err = base64.RawURLEncoding.DecodeString(encodedID)
280
+ }
281
+
282
+ leaseID := encodedID
283
+ if err == nil {
284
+ leaseID = string(idBytes)
285
+ }
286
+
287
+ if r.Method == http.MethodPost {
288
+ var req struct {
289
+ BPS int64 `json:"bps"`
290
+ }
291
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
292
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
293
+ return
294
+ }
295
+ oldBPS := globalBPSManager.GetBPSLimit(leaseID)
296
+ globalBPSManager.SetBPSLimit(leaseID, req.BPS)
297
+ log.Info().
298
+ Str("lease_id", leaseID).
299
+ Int64("old_bps", oldBPS).
300
+ Int64("new_bps", req.BPS).
301
+ Msg("[Admin] BPS limit updated")
302
+ saveAdminSettings(serv, globalBPSManager)
303
+ w.WriteHeader(http.StatusOK)
304
+ return
305
+ }
306
+ if r.Method == http.MethodDelete {
307
+ oldBPS := globalBPSManager.GetBPSLimit(leaseID)
308
+ globalBPSManager.SetBPSLimit(leaseID, 0)
309
+ log.Info().
310
+ Str("lease_id", leaseID).
311
+ Int64("old_bps", oldBPS).
312
+ Msg("[Admin] BPS limit removed (now unlimited)")
313
+ saveAdminSettings(serv, globalBPSManager)
314
+ w.WriteHeader(http.StatusOK)
315
+ return
316
+ }
317
+ }
318
+ }
319
+
320
serveAppStatic(w, r, "", serv)
321
}
322
323
+// convertLeaseEntriesToAdminRows converts LeaseEntry data to leaseRow format for admin API
324
+// Unlike convertLeaseEntriesToRows, this includes banned and hidden entries
325
+func convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
326
+ leaseEntries := serv.GetAllLeaseEntries()
327
+ rows := []leaseRow{}
328
+ now := time.Now()
329
+
330
+ for _, leaseEntry := range leaseEntries {
331
+ // Check if lease is still valid
332
+ if now.After(leaseEntry.Expires) {
333
+ continue
334
+ }
335
+
336
+ lease := leaseEntry.Lease
337
+ identityID := string(lease.Identity.Id)
338
+
339
+ // Metadata parsing
340
+ var meta sdk.Metadata
341
+ _ = json.Unmarshal([]byte(lease.Metadata), &meta)
342
+
343
+ // Calculate TTL
344
+ ttl := time.Until(leaseEntry.Expires)
345
+ ttlStr := ""
346
+ if ttl > 0 {
347
+ if ttl > time.Hour {
348
+ ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
349
+ } else if ttl > time.Minute {
350
+ ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
351
+ } else {
352
+ ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
353
+ }
354
+ }
355
+
356
+ // Format last active as relative time
357
+ since := now.Sub(leaseEntry.LastSeen)
358
+ if since < 0 {
359
+ since = 0
360
+ }
361
+ lastSeenStr := func(d time.Duration) string {
362
+ if d >= time.Hour {
363
+ h := int(d / time.Hour)
364
+ m := int((d % time.Hour) / time.Minute)
365
+ if m > 0 {
366
+ return fmt.Sprintf("%dh %dm", h, m)
367
+ }
368
+ return fmt.Sprintf("%dh", h)
369
+ }
370
+ if d >= time.Minute {
371
+ m := int(d / time.Minute)
372
+ s := int((d % time.Minute) / time.Second)
373
+ if s > 0 {
374
+ return fmt.Sprintf("%dm %ds", m, s)
375
+ }
376
+ return fmt.Sprintf("%dm", m)
377
+ }
378
+ s := int(d / time.Second)
379
+ return fmt.Sprintf("%ds", s)
380
+ }(since)
381
+ lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
382
+
383
+ // Check if connection is still active
384
+ connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
385
+
386
+ // Use name from lease if available
387
+ name := lease.Name
388
+ if name == "" {
389
+ name = "(unnamed)"
390
+ }
391
+
392
+ // Determine kind/type based on ALPN if available
393
+ kind := "client"
394
+ if len(lease.Alpn) > 0 {
395
+ kind = lease.Alpn[0]
396
+ }
397
+
398
+ // Create DNS label from identity
399
+ dnsLabel := identityID
400
+ if len(dnsLabel) > 8 {
401
+ dnsLabel = dnsLabel[:8] + "..."
402
+ }
403
+
404
+ // Build link using the configured subdomain base
405
+ base := flagPortalAppURL
406
+ if base == "" {
407
+ base = flagPortalURL
408
+ }
409
+ link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
410
+
411
+ // Get BPS limit for this lease from BPSManager
412
+ bps := globalBPSManager.GetBPSLimit(identityID)
413
+
414
+ row := leaseRow{
415
+ Peer: identityID,
416
+ Name: name,
417
+ Kind: kind,
418
+ Connected: connected,
419
+ DNS: dnsLabel,
420
+ LastSeen: lastSeenStr,
421
+ LastSeenISO: lastSeenISO,
422
+ TTL: ttlStr,
423
+ Link: link,
424
+ StaleRed: !connected && since >= 15*time.Second,
425
+ Hide: meta.Hide,
426
+ Metadata: lease.Metadata,
427
+ BPS: bps,
428
+ }
429
+
430
+ rows = append(rows, row)
431
+ }
432
+
433
+ return rows
434
+}
435
+
436
func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
437
// Get all lease entries directly from the lease manager
438
leaseEntries := serv.GetAllLeaseEntries()
@@ -410,3 +583,105 @@ func isLeaseBanned(serv *portal.RelayServer, leaseID string) bool {
583
return false
584
}
585
586
+// AdminSettings stores persistent admin configuration
587
+type AdminSettings struct {
588
+ BannedLeases []string `json:"banned_leases"`
589
+ BPSLimits map[string]int64 `json:"bps_limits"`
590
+}
591
+
592
+var (
593
+ adminSettingsPath = "admin_settings.json"
594
+ adminSettingsMu sync.Mutex
595
+)
596
+
597
+// SetAdminSettingsPath sets the path for admin settings file
598
+func SetAdminSettingsPath(path string) {
599
+ adminSettingsMu.Lock()
600
+ defer adminSettingsMu.Unlock()
601
+ adminSettingsPath = path
602
+}
603
+
604
+// saveAdminSettings persists ban and BPS settings to disk
605
+func saveAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
606
+ adminSettingsMu.Lock()
607
+ defer adminSettingsMu.Unlock()
608
+
609
+ lm := serv.GetLeaseManager()
610
+
611
+ // Collect banned leases
612
+ bannedBytes := lm.GetBannedLeases()
613
+ banned := make([]string, len(bannedBytes))
614
+ for i, b := range bannedBytes {
615
+ banned[i] = string(b)
616
+ }
617
+
618
+ // Collect BPS limits from BPSManager
619
+ bpsLimits := bpsManager.GetAllBPSLimits()
620
+
621
+ settings := AdminSettings{
622
+ BannedLeases: banned,
623
+ BPSLimits: bpsLimits,
624
+ }
625
+
626
+ data, err := json.MarshalIndent(settings, "", " ")
627
+ if err != nil {
628
+ log.Error().Err(err).Msg("[Admin] Failed to marshal admin settings")
629
+ return
630
+ }
631
+
632
+ // Ensure directory exists
633
+ dir := filepath.Dir(adminSettingsPath)
634
+ if dir != "" && dir != "." {
635
+ if err := os.MkdirAll(dir, 0755); err != nil {
636
+ log.Error().Err(err).Msg("[Admin] Failed to create settings directory")
637
+ return
638
+ }
639
+ }
640
+
641
+ if err := os.WriteFile(adminSettingsPath, data, 0644); err != nil {
642
+ log.Error().Err(err).Msg("[Admin] Failed to save admin settings")
643
+ return
644
+ }
645
+
646
+ log.Debug().Str("path", adminSettingsPath).Msg("[Admin] Saved admin settings")
647
+}
648
+
649
+// loadAdminSettings loads ban and BPS settings from disk
650
+func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
651
+ adminSettingsMu.Lock()
652
+ defer adminSettingsMu.Unlock()
653
+
654
+ data, err := os.ReadFile(adminSettingsPath)
655
+ if err != nil {
656
+ if os.IsNotExist(err) {
657
+ log.Debug().Msg("[Admin] No admin settings file found, starting fresh")
658
+ return
659
+ }
660
+ log.Error().Err(err).Msg("[Admin] Failed to read admin settings")
661
+ return
662
+ }
663
+
664
+ var settings AdminSettings
665
+ if err := json.Unmarshal(data, &settings); err != nil {
666
+ log.Error().Err(err).Msg("[Admin] Failed to parse admin settings")
667
+ return
668
+ }
669
+
670
+ lm := serv.GetLeaseManager()
671
+
672
+ // Restore banned leases
673
+ for _, leaseID := range settings.BannedLeases {
674
+ lm.BanLease(leaseID)
675
+ }
676
+
677
+ // Restore BPS limits to BPSManager
678
+ for leaseID, bps := range settings.BPSLimits {
679
+ bpsManager.SetBPSLimit(leaseID, bps)
680
+ }
681
+
682
+ log.Info().
683
+ Int("banned_count", len(settings.BannedLeases)).
684
+ Int("bps_limits_count", len(settings.BPSLimits)).
685
+ Msg("[Admin] Loaded admin settings")
686
+}
687
+
portal/handlers.go
+12
-20
@@ -3,7 +3,6 @@ package portal
3
import (
4
"encoding/binary"
5
"io"
6
- "sync"
6
7
"github.com/hashicorp/yamux"
8
"github.com/rs/zerolog/log"
@@ -11,7 +10,6 @@ import (
10
"gosuda.org/portal/portal/core/cryptoops"
11
"gosuda.org/portal/portal/core/proto/rdsec"
12
"gosuda.org/portal/portal/core/proto/rdverb"
14
- "gosuda.org/portal/portal/utils/ratelimit"
13
)
14
15
type StreamContext struct {
@@ -275,7 +273,7 @@ func (g *RelayServer) sendConnectionResponse(stream *yamux.Stream, code rdverb.R
273
}
274
275
func (g *RelayServer) establishRelayedConnection(clientStream, leaseStream *yamux.Stream, leaseID string) {
278
- // Register connection
276
+ // Register connection for tracking
277
g.limitsLock.Lock()
278
g.relayedPerLeaseCount[leaseID]++
279
g.limitsLock.Unlock()
@@ -304,24 +302,18 @@ func (g *RelayServer) establishRelayedConnection(clientStream, leaseStream *yamu
302
g.relayedConnectionsLock.Unlock()
303
}()
304
307
- var wg sync.WaitGroup
308
- wg.Add(2)
309
-
310
- // Client -> Lease
311
- go func() {
312
- defer wg.Done()
313
- ratelimit.Copy(leaseStream, clientStream, g.getLeaseBPSBucket(leaseID))
314
- leaseStream.Close()
315
- }()
316
-
317
- // Lease -> Client
318
- go func() {
319
- defer wg.Done()
320
- ratelimit.Copy(clientStream, leaseStream, g.getLeaseBPSBucket(leaseID))
305
+ // Use callback for actual relay (handles BPS limiting in relay-server)
306
+ if g.onEstablishRelay != nil {
307
+ g.onEstablishRelay(clientStream, leaseStream, leaseID)
308
+ } else {
309
+ // Fallback: simple copy without rate limiting
310
+ go func() {
311
+ io.Copy(leaseStream, clientStream)
312
+ leaseStream.Close()
313
+ }()
314
+ io.Copy(clientStream, leaseStream)
315
clientStream.Close()
322
- }()
323
-
324
- wg.Wait()
316
+ }
317
}
318
319
// Helper function to read packet from stream
portal/lease.go
-46
@@ -27,9 +27,6 @@ type LeaseManager struct {
27
namePattern *regexp.Regexp
28
minTTL time.Duration // 0 = no bound
29
maxTTL time.Duration // 0 = no bound
30
- // per-lease byte limit
31
- bpsLimits map[string]int64 // leaseID -> bytes-per-second (0 = unlimited)
32
- defaultBPS int64 // default bytes-per-second for new/updated leases (0 = none)
30
}
31
32
func NewLeaseManager(ttlInterval time.Duration) *LeaseManager {
@@ -38,8 +35,6 @@ func NewLeaseManager(ttlInterval time.Duration) *LeaseManager {
35
stopCh: make(chan struct{}),
36
ttlInterval: ttlInterval,
37
bannedLeases: make(map[string]struct{}),
41
- bpsLimits: make(map[string]int64),
42
- defaultBPS: 0,
38
}
39
}
40
@@ -73,7 +68,6 @@ func (lm *LeaseManager) cleanupExpiredLeases() {
68
for id, lease := range lm.leases {
69
if now.After(lease.Expires) {
70
delete(lm.leases, id)
76
- delete(lm.bpsLimits, id)
71
}
72
}
73
}
@@ -130,13 +124,6 @@ func (lm *LeaseManager) UpdateLease(lease *rdverb.Lease, connectionID int64) boo
124
ConnectionID: connectionID,
125
}
126
133
- // Apply default BPS limit for this lease if configured and no explicit limit set
134
- if lm.defaultBPS > 0 {
135
- if _, exists := lm.bpsLimits[identityID]; !exists {
136
- lm.bpsLimits[identityID] = lm.defaultBPS
137
- }
138
- }
139
-
127
return true
128
}
129
@@ -147,7 +134,6 @@ func (lm *LeaseManager) DeleteLease(identity *rdsec.Identity) bool {
134
identityID := string(identity.Id)
135
if _, exists := lm.leases[identityID]; exists {
136
delete(lm.leases, identityID)
150
- delete(lm.bpsLimits, identityID)
137
return true
138
}
139
return false
@@ -265,41 +251,9 @@ func (lm *LeaseManager) CleanupLeasesByConnectionID(connectionID int64) []string
251
for leaseID, lease := range lm.leases {
252
if lease.ConnectionID == connectionID {
253
delete(lm.leases, leaseID)
268
- delete(lm.bpsLimits, leaseID)
254
cleanedLeaseIDs = append(cleanedLeaseIDs, leaseID)
255
}
256
}
257
258
return cleanedLeaseIDs
259
}
275
-
276
-// Per-lease BPS limit configuration
277
-func (lm *LeaseManager) SetBPSLimit(leaseID string, bps int64) {
278
- lm.leasesLock.Lock()
279
- defer lm.leasesLock.Unlock()
280
- if bps <= 0 {
281
- delete(lm.bpsLimits, leaseID)
282
- return
283
- }
284
- lm.bpsLimits[leaseID] = bps
285
-}
286
-
287
-func (lm *LeaseManager) GetBPSLimit(leaseID string) int64 {
288
- lm.leasesLock.RLock()
289
- defer lm.leasesLock.RUnlock()
290
- if v, ok := lm.bpsLimits[leaseID]; ok {
291
- return v
292
- }
293
- return 0
294
-}
295
-
296
-// SetDefaultBPS sets a default bytes-per-second limit applied to leases on update/registration
297
-// If set to 0, no default is applied. Existing explicit per-lease limits are not overwritten.
298
-func (lm *LeaseManager) SetDefaultBPS(bps int64) {
299
- lm.leasesLock.Lock()
300
- defer lm.leasesLock.Unlock()
301
- if bps < 0 {
302
- bps = 0
303
- }
304
- lm.defaultBPS = bps
305
-}
portal/relay.go
+10
-31
@@ -10,7 +10,6 @@ import (
10
"gosuda.org/portal/portal/core/cryptoops"
11
"gosuda.org/portal/portal/core/proto/rdsec"
12
"gosuda.org/portal/portal/core/proto/rdverb"
13
- "gosuda.org/portal/portal/utils/ratelimit"
13
)
14
15
type Connection struct {
@@ -46,9 +45,8 @@ type RelayServer struct {
45
relayedPerLeaseCount map[string]int
46
limitsLock sync.Mutex
47
49
- // Per-lease byte rate limit (BPS for relay throughput)
50
- leaseBPS map[string]*ratelimit.Bucket
51
- leaseBPSRate map[string]int64
48
+ // Callback for relay connection establishment (set by relay-server for BPS handling)
49
+ onEstablishRelay func(clientStream, leaseStream *yamux.Stream, leaseID string)
50
}
51
52
func NewRelayServer(credential *cryptoops.Credential, address []string) *RelayServer {
@@ -66,38 +64,11 @@ func NewRelayServer(credential *cryptoops.Credential, address []string) *RelaySe
64
leaseManager: NewLeaseManager(30 * time.Second), // TTL check every 30 seconds
65
stopch: make(chan struct{}),
66
relayedPerLeaseCount: make(map[string]int),
69
- leaseBPS: make(map[string]*ratelimit.Bucket),
70
- leaseBPSRate: make(map[string]int64),
67
}
68
}
69
70
var _yamux_config = yamux.DefaultConfig()
71
76
-func (g *RelayServer) getLeaseBPSBucket(leaseID string) *ratelimit.Bucket {
77
- // Lookup per-lease BPS from LeaseManager (0 = unlimited)
78
- bps := g.leaseManager.GetBPSLimit(leaseID)
79
- if bps <= 0 {
80
- return nil
81
- }
82
- desired := bps
83
- g.limitsLock.Lock()
84
- defer g.limitsLock.Unlock()
85
- if b, ok := g.leaseBPS[leaseID]; ok {
86
- if g.leaseBPSRate[leaseID] == desired {
87
- return b
88
- }
89
- // replace with new rate
90
- nb := ratelimit.NewBucket(desired, desired)
91
- g.leaseBPS[leaseID] = nb
92
- g.leaseBPSRate[leaseID] = desired
93
- return nb
94
- }
95
- b := ratelimit.NewBucket(desired, desired)
96
- g.leaseBPS[leaseID] = b
97
- g.leaseBPSRate[leaseID] = desired
98
- return b
99
-}
100
-
72
func (g *RelayServer) handleConn(id int64, connection *Connection) {
73
log.Debug().Int64("conn_id", id).Msg("[RelayServer] Handling new connection")
74
@@ -352,3 +323,11 @@ func (g *RelayServer) SetMaxRelayedPerLease(n int) {
323
g.maxRelayedPerLease = n
324
g.limitsLock.Unlock()
325
}
326
+
327
+// SetEstablishRelayCallback sets the callback for relay connection establishment
328
+// This allows external code (e.g., relay-server) to handle BPS limiting
329
+func (g *RelayServer) SetEstablishRelayCallback(
330
+ callback func(clientStream, leaseStream *yamux.Stream, leaseID string),
331
+) {
332
+ g.onEstablishRelay = callback
333
+}