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 +}