feat: Add admin approval mode and IP management features

Hee Sung Son committed Dec 12, 2025 at 16:00 UTC fc96473cf5d092f21b95dfcbc24b0829f7ebd106
18 files changed +1512 -250
cmd/relay-server/admin.go
+286 -5
@@ -37,10 +37,20 @@ func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.Rel
37 "leases_count": len(serv.GetAllLeaseEntries()),
38 "uptime": "TODO",
39 })
40 + case route == "settings" && r.Method == http.MethodGet:
41 + handleGetSettings(w)
42 + case route == "settings/approval-mode":
43 + handleApprovalModeRequest(w, r, serv)
44 case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/ban"):
45 handleLeaseBanRequest(w, r, serv, route)
46 case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/bps"):
47 handleLeaseBPSRequest(w, r, serv, route)
48 + case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/approve"):
49 + handleLeaseApproveRequest(w, r, serv, route)
50 + case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/deny"):
51 + handleLeaseDenyRequest(w, r, serv, route)
52 + case strings.HasPrefix(route, "ips/") && strings.HasSuffix(route, "/ban"):
53 + handleIPBanRequest(w, r, serv, route)
54 default:
55 http.NotFound(w, r)
56 }
@@ -73,6 +83,103 @@ func handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.
83 }
84 }
85
86 +func handleGetSettings(w http.ResponseWriter) {
87 + writeJSON(w, map[string]interface{}{
88 + "approval_mode": getApprovalMode(),
89 + "approved_leases": getApprovedLeases(),
90 + "denied_leases": getDeniedLeases(),
91 + })
92 +}
93 +
94 +func handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
95 + switch r.Method {
96 + case http.MethodGet:
97 + writeJSON(w, map[string]interface{}{
98 + "approval_mode": getApprovalMode(),
99 + })
100 + case http.MethodPost:
101 + var req struct {
102 + Mode string `json:"mode"`
103 + }
104 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
105 + http.Error(w, "Invalid request body", http.StatusBadRequest)
106 + return
107 + }
108 + mode := ApprovalMode(req.Mode)
109 + if mode != ApprovalModeAuto && mode != ApprovalModeManual {
110 + http.Error(w, "Invalid mode (must be 'auto' or 'manual')", http.StatusBadRequest)
111 + return
112 + }
113 + setApprovalMode(mode)
114 + saveAdminSettings(serv, globalBPSManager)
115 + log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
116 + writeJSON(w, map[string]interface{}{
117 + "approval_mode": mode,
118 + })
119 + default:
120 + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
121 + }
122 +}
123 +
124 +func handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
125 + parts := strings.Split(route, "/")
126 + if len(parts) != 3 {
127 + http.NotFound(w, r)
128 + return
129 + }
130 +
131 + leaseID, ok := decodeLeaseID(parts[1])
132 + if !ok {
133 + http.Error(w, "Invalid lease ID", http.StatusBadRequest)
134 + return
135 + }
136 +
137 + switch r.Method {
138 + case http.MethodPost:
139 + approveLease(leaseID)
140 + undenyLease(leaseID) // Remove from denied if exists
141 + saveAdminSettings(serv, globalBPSManager)
142 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approved")
143 + w.WriteHeader(http.StatusOK)
144 + case http.MethodDelete:
145 + revokeLease(leaseID)
146 + saveAdminSettings(serv, globalBPSManager)
147 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approval revoked")
148 + w.WriteHeader(http.StatusOK)
149 + default:
150 + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
151 + }
152 +}
153 +
154 +func handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
155 + parts := strings.Split(route, "/")
156 + if len(parts) != 3 {
157 + http.NotFound(w, r)
158 + return
159 + }
160 +
161 + leaseID, ok := decodeLeaseID(parts[1])
162 + if !ok {
163 + http.Error(w, "Invalid lease ID", http.StatusBadRequest)
164 + return
165 + }
166 +
167 + switch r.Method {
168 + case http.MethodPost:
169 + denyLease(leaseID)
170 + saveAdminSettings(serv, globalBPSManager)
171 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denied")
172 + w.WriteHeader(http.StatusOK)
173 + case http.MethodDelete:
174 + undenyLease(leaseID)
175 + saveAdminSettings(serv, globalBPSManager)
176 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denial removed")
177 + w.WriteHeader(http.StatusOK)
178 + default:
179 + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
180 + }
181 +}
182 +
183 func handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
184 parts := strings.Split(route, "/")
185 if len(parts) != 3 {
@@ -118,6 +225,41 @@ func handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.
225 }
226 }
227
228 +func handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
229 + // Route format: ips/{ip}/ban
230 + parts := strings.Split(route, "/")
231 + if len(parts) != 3 {
232 + http.NotFound(w, r)
233 + return
234 + }
235 +
236 + ip := parts[1]
237 + if ip == "" {
238 + http.Error(w, "Invalid IP address", http.StatusBadRequest)
239 + return
240 + }
241 +
242 + if globalIPManager == nil {
243 + http.Error(w, "IP manager not initialized", http.StatusInternalServerError)
244 + return
245 + }
246 +
247 + switch r.Method {
248 + case http.MethodPost:
249 + globalIPManager.BanIP(ip)
250 + saveAdminSettings(serv, globalBPSManager)
251 + log.Info().Str("ip", ip).Msg("[Admin] IP banned")
252 + w.WriteHeader(http.StatusOK)
253 + case http.MethodDelete:
254 + globalIPManager.UnbanIP(ip)
255 + saveAdminSettings(serv, globalBPSManager)
256 + log.Info().Str("ip", ip).Msg("[Admin] IP unbanned")
257 + w.WriteHeader(http.StatusOK)
258 + default:
259 + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
260 + }
261 +}
262 +
263 func decodeLeaseID(encoded string) (string, bool) {
264 idBytes, err := base64.URLEncoding.DecodeString(encoded)
265 if err != nil {
@@ -206,6 +348,16 @@ func convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
348
349 bps := globalBPSManager.GetBPSLimit(identityID)
350
351 + // Get IP info for this lease
352 + var ip string
353 + var isIPBanned bool
354 + if globalIPManager != nil {
355 + ip = globalIPManager.GetLeaseIP(identityID)
356 + if ip != "" {
357 + isIPBanned = globalIPManager.IsIPBanned(ip)
358 + }
359 + }
360 +
361 rows = append(rows, leaseRow{
362 Peer: identityID,
363 Name: name,
@@ -221,16 +373,112 @@ func convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
373 Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
374 Metadata: lease.Metadata,
375 BPS: bps,
376 + IsApproved: getApprovalMode() == ApprovalModeAuto || isLeaseApproved(identityID),
377 + IsDenied: isLeaseDenied(identityID),
378 + IP: ip,
379 + IsIPBanned: isIPBanned,
380 })
381 }
382
383 return rows
384 }
385
386 +// ApprovalMode represents the approval mode for new connections
387 +type ApprovalMode string
388 +
389 +const (
390 + ApprovalModeAuto ApprovalMode = "auto"
391 + ApprovalModeManual ApprovalMode = "manual"
392 +)
393 +
394 // AdminSettings stores persistent admin configuration
395 type AdminSettings struct {
232 - BannedLeases []string `json:"banned_leases"`
233 - BPSLimits map[string]int64 `json:"bps_limits"`
396 + BannedLeases []string `json:"banned_leases"`
397 + BPSLimits map[string]int64 `json:"bps_limits"`
398 + ApprovalMode ApprovalMode `json:"approval_mode"`
399 + ApprovedLeases []string `json:"approved_leases,omitempty"`
400 + DeniedLeases []string `json:"denied_leases,omitempty"`
401 + BannedIPs []string `json:"banned_ips,omitempty"`
402 +}
403 +
404 +// Global approval mode (default: auto)
405 +var (
406 + globalApprovalMode ApprovalMode = ApprovalModeAuto
407 + globalApprovedLeases = make(map[string]struct{})
408 + globalDeniedLeases = make(map[string]struct{})
409 + approvalMu sync.RWMutex
410 +)
411 +
412 +func getApprovalMode() ApprovalMode {
413 + approvalMu.RLock()
414 + defer approvalMu.RUnlock()
415 + return globalApprovalMode
416 +}
417 +
418 +func setApprovalMode(mode ApprovalMode) {
419 + approvalMu.Lock()
420 + defer approvalMu.Unlock()
421 + globalApprovalMode = mode
422 +}
423 +
424 +func isLeaseApproved(leaseID string) bool {
425 + approvalMu.RLock()
426 + defer approvalMu.RUnlock()
427 + _, ok := globalApprovedLeases[leaseID]
428 + return ok
429 +}
430 +
431 +func approveLease(leaseID string) {
432 + approvalMu.Lock()
433 + defer approvalMu.Unlock()
434 + globalApprovedLeases[leaseID] = struct{}{}
435 +}
436 +
437 +func revokeLease(leaseID string) {
438 + approvalMu.Lock()
439 + defer approvalMu.Unlock()
440 + delete(globalApprovedLeases, leaseID)
441 +}
442 +
443 +func getApprovedLeases() []string {
444 + approvalMu.RLock()
445 + defer approvalMu.RUnlock()
446 + result := make([]string, 0, len(globalApprovedLeases))
447 + for id := range globalApprovedLeases {
448 + result = append(result, id)
449 + }
450 + return result
451 +}
452 +
453 +func isLeaseDenied(leaseID string) bool {
454 + approvalMu.RLock()
455 + defer approvalMu.RUnlock()
456 + _, ok := globalDeniedLeases[leaseID]
457 + return ok
458 +}
459 +
460 +func denyLease(leaseID string) {
461 + approvalMu.Lock()
462 + defer approvalMu.Unlock()
463 + globalDeniedLeases[leaseID] = struct{}{}
464 + // Remove from approved if exists
465 + delete(globalApprovedLeases, leaseID)
466 +}
467 +
468 +func undenyLease(leaseID string) {
469 + approvalMu.Lock()
470 + defer approvalMu.Unlock()
471 + delete(globalDeniedLeases, leaseID)
472 +}
473 +
474 +func getDeniedLeases() []string {
475 + approvalMu.RLock()
476 + defer approvalMu.RUnlock()
477 + result := make([]string, 0, len(globalDeniedLeases))
478 + for id := range globalDeniedLeases {
479 + result = append(result, id)
480 + }
481 + return result
482 }
483
484 var (
@@ -259,9 +507,18 @@ func saveAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
507
508 bpsLimits := bpsManager.GetAllBPSLimits()
509
510 + var bannedIPs []string
511 + if globalIPManager != nil {
512 + bannedIPs = globalIPManager.GetBannedIPs()
513 + }
514 +
515 settings := AdminSettings{
263 - BannedLeases: banned,
264 - BPSLimits: bpsLimits,
516 + BannedLeases: banned,
517 + BPSLimits: bpsLimits,
518 + ApprovalMode: getApprovalMode(),
519 + ApprovedLeases: getApprovedLeases(),
520 + DeniedLeases: getDeniedLeases(),
521 + BannedIPs: bannedIPs,
522 }
523
524 data, err := json.MarshalIndent(settings, "", " ")
@@ -286,7 +543,7 @@ func saveAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
543 log.Debug().Str("path", adminSettingsPath).Msg("[Admin] Saved admin settings")
544 }
545
289 -func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
546 +func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager, ipManager *IPManager) {
547 adminSettingsMu.Lock()
548 defer adminSettingsMu.Unlock()
549
@@ -316,8 +573,32 @@ func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
573 bpsManager.SetBPSLimit(leaseID, bps)
574 }
575
576 + // Load approval mode
577 + if settings.ApprovalMode != "" {
578 + setApprovalMode(settings.ApprovalMode)
579 + }
580 +
581 + // Load approved leases
582 + for _, leaseID := range settings.ApprovedLeases {
583 + approveLease(leaseID)
584 + }
585 +
586 + // Load denied leases
587 + for _, leaseID := range settings.DeniedLeases {
588 + denyLease(leaseID)
589 + }
590 +
591 + // Load banned IPs
592 + if ipManager != nil && len(settings.BannedIPs) > 0 {
593 + ipManager.SetBannedIPs(settings.BannedIPs)
594 + }
595 +
596 log.Info().
597 Int("banned_count", len(settings.BannedLeases)).
598 Int("bps_limits_count", len(settings.BPSLimits)).
599 + Str("approval_mode", string(getApprovalMode())).
600 + Int("approved_count", len(settings.ApprovedLeases)).
601 + Int("denied_count", len(settings.DeniedLeases)).
602 + Int("banned_ips_count", len(settings.BannedIPs)).
603 Msg("[Admin] Loaded admin settings")
604 }
cmd/relay-server/frontend/src/components/CloseIcon.tsx new
+17
@@ -0,0 +1,17 @@
1 +import clsx from "clsx";
2 +
3 +export const CloseIcon = ({ className }: { className?: string }) => (
4 + <svg
5 + xmlns="http://www.w3.org/2000/svg"
6 + className={clsx("w-5 h-5", className)}
7 + viewBox="0 0 24 24"
8 + fill="none"
9 + stroke="currentColor"
10 + strokeWidth="2"
11 + strokeLinecap="round"
12 + strokeLinejoin="round"
13 + >
14 + <line x1="18" y1="6" x2="6" y2="18" />
15 + <line x1="6" y1="6" x2="18" y2="18" />
16 + </svg>
17 +);
cmd/relay-server/frontend/src/components/FloatingActionBar.tsx new
+101
@@ -0,0 +1,101 @@
1 +import { useState } from "react";
2 +import {
3 + Select,
4 + SelectContent,
5 + SelectItem,
6 + SelectTrigger,
7 + SelectValue,
8 +} from "@/components/ui/select";
9 +import { Button } from "@/components/ui/button";
10 +
11 +type BulkAction = "approve" | "deny" | "ban";
12 +
13 +interface FloatingActionBarProps {
14 + selectedCount: number;
15 + totalCount: number;
16 + isAllSelected: boolean;
17 + onSelectAll: () => void;
18 + onApprove: () => void;
19 + onDeny: () => void;
20 + onBan: () => void;
21 +}
22 +
23 +export const FloatingActionBar = ({
24 + selectedCount,
25 + totalCount,
26 + isAllSelected,
27 + onSelectAll,
28 + onApprove,
29 + onDeny,
30 + onBan,
31 +}: FloatingActionBarProps) => {
32 + const [selectedAction, setSelectedAction] = useState<BulkAction>("approve");
33 +
34 + const handleExecute = () => {
35 + switch (selectedAction) {
36 + case "approve":
37 + onApprove();
38 + break;
39 + case "deny":
40 + onDeny();
41 + break;
42 + case "ban":
43 + onBan();
44 + break;
45 + }
46 + };
47 +
48 + const getExecuteButtonStyle = () => {
49 + switch (selectedAction) {
50 + case "approve":
51 + return "bg-green-600 hover:bg-green-700";
52 + case "deny":
53 + return "bg-red-600 hover:bg-red-700";
54 + case "ban":
55 + return "bg-orange-600 hover:bg-orange-700";
56 + }
57 + };
58 +
59 + return (
60 + <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
61 + <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-xl shadow-2xl border border-foreground/20">
62 + <button
63 + onClick={onSelectAll}
64 + className={`px-3 h-10 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
65 + isAllSelected
66 + ? "bg-primary text-primary-foreground"
67 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
68 + }`}
69 + >
70 + {isAllSelected ? "Deselect" : "Select All"}
71 + </button>
72 + <span className="text-sm font-medium text-foreground whitespace-nowrap px-1">
73 + {selectedCount}/{totalCount}
74 + </span>
75 + {selectedCount > 0 && (
76 + <>
77 + <Select
78 + value={selectedAction}
79 + onValueChange={(v) => setSelectedAction(v as BulkAction)}
80 + >
81 + <SelectTrigger className="w-[100px] h-10">
82 + <SelectValue />
83 + </SelectTrigger>
84 + <SelectContent>
85 + <SelectItem value="approve">Approve</SelectItem>
86 + <SelectItem value="deny">Deny</SelectItem>
87 + <SelectItem value="ban">Ban</SelectItem>
88 + </SelectContent>
89 + </Select>
90 + <Button
91 + onClick={handleExecute}
92 + className={`h-10 px-4 text-white ${getExecuteButtonStyle()}`}
93 + >
94 + Run
95 + </Button>
96 + </>
97 + )}
98 + </div>
99 + </div>
100 + );
101 +};
cmd/relay-server/frontend/src/components/Header.tsx
+4 -2
@@ -2,12 +2,14 @@ import { useEffect, useState } from "react";
2 import { Moon, Sun } from "lucide-react";
3 import { Button } from "@/components/ui/button";
4 import { TunnelCommandModal } from "@/components/TunnelCommandModal";
5 +import clsx from "clsx";
6
7 interface HeaderProps {
8 title?: string;
9 + isAdmin?: boolean;
10 }
11
10 -export function Header({ title = "PORTAL" }: HeaderProps) {
12 +export function Header({ title = "PORTAL", isAdmin }: HeaderProps) {
13 const [theme, setTheme] = useState<"light" | "dark">("dark");
14
15 useEffect(() => {
@@ -87,7 +89,7 @@ export function Header({ title = "PORTAL" }: HeaderProps) {
89 </button>
90 <TunnelCommandModal
91 trigger={
90 - <Button>
92 + <Button className={clsx(isAdmin && "hidden sm:block")}>
93 <span className="truncate">Add Your Server</span>
94 </Button>
95 }
cmd/relay-server/frontend/src/components/SearchBar.tsx
+46 -45
@@ -1,14 +1,10 @@
1 -import { Search } from "lucide-react";
1 +import { Search, Settings } from "lucide-react";
2 import { Input } from "@/components/ui/input";
3 -import {
4 - Select,
5 - SelectContent,
6 - SelectItem,
7 - SelectTrigger,
8 - SelectValue,
9 -} from "@/components/ui/select";
3 import type { SortOption, StatusFilter } from "@/types/filters";
4 import { TagCombobox } from "@/components/TagCombobox";
5 +import { Dispatch, SetStateAction } from "react";
6 +import { StatusSelect } from "@/components/select/StatusSelect";
7 +import { SortbySelect } from "@/components/select/SortbySelect";
8
9 interface SearchBarProps {
10 searchQuery: string;
@@ -21,6 +17,8 @@ interface SearchBarProps {
17 selectedTags: string[];
18 onAddTag: (tag: string) => void;
19 onRemoveTag: (tag: string) => void;
20 + hideFiltersOnMobile?: boolean;
21 + setShowFilterModal: Dispatch<SetStateAction<boolean>>;
22 }
23
24 export function SearchBar({
@@ -34,49 +32,52 @@ export function SearchBar({
32 selectedTags,
33 onAddTag,
34 onRemoveTag,
35 + hideFiltersOnMobile = false,
36 + setShowFilterModal,
37 }: SearchBarProps) {
38 return (
39 <div className="flex flex-wrap mt-4 sm:mt-6 items-center gap-3 px-4 sm:px-6">
40 - <label className="flex min-w-[220px] flex-1 items-stretch h-10">
41 - <div className="text-text-muted flex items-center justify-center pl-3 pr-2 rounded-l-md bg-border">
42 - <Search className="w-4 h-4" />
43 - </div>
44 - <Input
45 - placeholder="Search by server name..."
46 - value={searchQuery}
47 - onChange={(e) => onSearchChange(e.target.value)}
48 - className="rounded-l-none h-10"
49 - />
50 - </label>
40 + <div className="flex gap-2 items-center w-full">
41 + <label className="flex min-w-[220px] flex-1 items-stretch h-10">
42 + <div className="text-text-muted flex items-center justify-center pl-3 pr-2 rounded-l-md bg-border">
43 + <Search className="w-4 h-4" />
44 + </div>
45 + <Input
46 + placeholder="Search by server name..."
47 + value={searchQuery}
48 + onChange={(e) => onSearchChange(e.target.value)}
49 + className="rounded-l-none h-10"
50 + />
51 + </label>
52 + {/* Mobile filter button - only show for admin */}
53 + {hideFiltersOnMobile && (
54 + <button
55 + onClick={() => setShowFilterModal(true)}
56 + className="sm:hidden flex items-center justify-center w-10 h-10 rounded-lg bg-secondary hover:bg-secondary/80 transition-colors"
57 + aria-label="Filter settings"
58 + >
59 + <Settings className="w-5 h-5 text-secondary-foreground" />
60 + </button>
61 + )}
62 + </div>
63
52 - <Select value={status} onValueChange={onStatusChange}>
53 - <SelectTrigger className="w-[130px] h-10">
54 - <SelectValue placeholder="Status" />
55 - </SelectTrigger>
56 - <SelectContent>
57 - <SelectItem value="all">All Status</SelectItem>
58 - <SelectItem value="online">Online</SelectItem>
59 - <SelectItem value="offline">Offline</SelectItem>
60 - </SelectContent>
61 - </Select>
64 + <StatusSelect
65 + status={status}
66 + onStatusChange={onStatusChange}
67 + hideFiltersOnMobile={hideFiltersOnMobile}
68 + />
69
63 - <Select value={sortBy} onValueChange={onSortByChange}>
64 - <SelectTrigger className="w-[150px] h-10">
65 - <SelectValue placeholder="Sort By" />
66 - </SelectTrigger>
67 - <SelectContent>
68 - <SelectItem value="default">Default</SelectItem>
69 - <SelectItem value="name-asc">Name (A-Z)</SelectItem>
70 - <SelectItem value="name-desc">Name (Z-A)</SelectItem>
71 - <SelectItem value="updated">Recently Updated</SelectItem>
72 - <SelectItem value="duration">Duration (Maintained)</SelectItem>
73 - <SelectItem value="description">Description</SelectItem>
74 - <SelectItem value="tags">Tags</SelectItem>
75 - <SelectItem value="owner">Owner</SelectItem>
76 - </SelectContent>
77 - </Select>
70 + <SortbySelect
71 + sortBy={sortBy}
72 + onSortByChange={onSortByChange}
73 + hideFiltersOnMobile={hideFiltersOnMobile}
74 + />
75
79 - <div className="relative flex w-full sm:w-auto sm:min-w-[320px] flex-1">
76 + <div
77 + className={`relative flex w-full sm:w-auto sm:min-w-[320px] flex-1 ${
78 + hideFiltersOnMobile ? "hidden sm:flex" : ""
79 + }`}
80 + >
81 <TagCombobox
82 availableTags={availableTags}
83 selectedTags={selectedTags}
cmd/relay-server/frontend/src/components/ServerCard.tsx
+214 -119
@@ -2,6 +2,15 @@ import { Link } from "react-router-dom";
2 import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
3 import clsx from "clsx";
4 import { ReactNode, useState, useMemo } from "react";
5 +import {
6 + Dialog,
7 + DialogContent,
8 + DialogHeader,
9 + DialogTitle,
10 + DialogDescription,
11 + DialogFooter,
12 +} from "@/components/ui/dialog";
13 +import { Button } from "@/components/ui/button";
14
15 interface ServerCardProps {
16 serverId: number;
@@ -22,9 +31,19 @@ interface ServerCardProps {
31 showAdminControls?: boolean;
32 leaseId?: string;
33 isBanned?: boolean;
34 + isApproved?: boolean;
35 + isDenied?: boolean;
36 bps?: number;
37 + ip?: string;
38 + isIPBanned?: boolean;
39 onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
40 onBPSChange?: (leaseId: string, bps: number) => void;
41 + onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
42 + onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
43 + onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
44 + // Selection for bulk actions
45 + isSelected?: boolean;
46 + onToggleSelect?: (leaseId: string) => void;
47 }
48
49 export function ServerCard({
@@ -44,9 +63,19 @@ export function ServerCard({
63 showAdminControls = false,
64 leaseId,
65 isBanned = false,
66 + isApproved = false,
67 + isDenied = false,
68 bps = 0,
69 + ip = "",
70 + isIPBanned = false,
71 onBanStatusChange,
72 onBPSChange,
73 + onApproveStatusChange,
74 + onDenyStatusChange,
75 + onIPBanStatusChange,
76 + // Selection for bulk actions
77 + isSelected = false,
78 + onToggleSelect,
79 }: ServerCardProps) {
80 const [showBPSModal, setShowBPSModal] = useState(false);
81 const [bpsInput, setBpsInput] = useState(bps.toString());
@@ -79,6 +108,14 @@ export function ServerCard({
108 onToggleFavorite?.(serverId);
109 };
110
111 + const handleSelectClick = (e: React.MouseEvent) => {
112 + e.preventDefault();
113 + e.stopPropagation();
114 + if (leaseId && onToggleSelect) {
115 + onToggleSelect(leaseId);
116 + }
117 + };
118 +
119 const handleBanClick = (e: React.MouseEvent) => {
120 e.preventDefault();
121 e.stopPropagation();
@@ -87,6 +124,30 @@ export function ServerCard({
124 }
125 };
126
127 + const handleApproveClick = (e: React.MouseEvent) => {
128 + e.preventDefault();
129 + e.stopPropagation();
130 + if (leaseId && onApproveStatusChange) {
131 + onApproveStatusChange(leaseId, !isApproved);
132 + }
133 + };
134 +
135 + const handleDenyClick = (e: React.MouseEvent) => {
136 + e.preventDefault();
137 + e.stopPropagation();
138 + if (leaseId && onDenyStatusChange) {
139 + onDenyStatusChange(leaseId, !isDenied);
140 + }
141 + };
142 +
143 + const handleIPBanClick = (e: React.MouseEvent) => {
144 + e.preventDefault();
145 + e.stopPropagation();
146 + if (ip && onIPBanStatusChange) {
147 + onIPBanStatusChange(ip, !isIPBanned);
148 + }
149 + };
150 +
151 const handleBPSSettingsClick = (e: React.MouseEvent) => {
152 e.preventDefault();
153 e.stopPropagation();
@@ -117,12 +178,6 @@ export function ServerCard({
178 return value.toString();
179 };
180
120 - const handleBPSModalClose = (e: React.MouseEvent) => {
121 - e.preventDefault();
122 - e.stopPropagation();
123 - setShowBPSModal(false);
124 - };
125 -
181 const formatBPS = (value: number): string => {
182 if (value === 0) return "Unlimited";
183 if (value >= 1_000_000_000)
@@ -172,26 +227,54 @@ export function ServerCard({
227 )}
228 style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
229 >
175 - {/* Favorite button */}
176 - <button
177 - onClick={handleFavoriteClick}
178 - className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200 cursor-pointer"
179 - aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
180 - >
181 - <svg
182 - xmlns="http://www.w3.org/2000/svg"
183 - viewBox="0 0 24 24"
184 - className="w-5 h-5 transition-colors duration-200"
185 - fill={isFavorite ? "currentColor" : "none"}
186 - stroke="currentColor"
187 - strokeWidth="2"
188 - strokeLinecap="round"
189 - strokeLinejoin="round"
190 - style={{ color: isFavorite ? "var(--primary)" : "currentColor" }}
230 + {/* Admin mode: Checkbox for selection / Normal mode: Favorite star */}
231 + {showAdminControls ? (
232 + <button
233 + onClick={handleSelectClick}
234 + className={clsx(
235 + "absolute top-3 right-3 z-10 p-1.5 rounded-full transition-colors duration-200 cursor-pointer border",
236 + isSelected
237 + ? "bg-primary border-primary"
238 + : "bg-background/80 hover:bg-background border-foreground/30"
239 + )}
240 + aria-label={isSelected ? "Deselect" : "Select"}
241 + >
242 + <svg
243 + xmlns="http://www.w3.org/2000/svg"
244 + viewBox="0 0 24 24"
245 + className="w-5 h-5 transition-colors duration-200"
246 + fill="none"
247 + stroke={isSelected ? "white" : "currentColor"}
248 + strokeWidth="3"
249 + strokeLinecap="round"
250 + strokeLinejoin="round"
251 + >
252 + {isSelected && <polyline points="20 6 9 17 4 12" />}
253 + </svg>
254 + </button>
255 + ) : (
256 + <button
257 + onClick={handleFavoriteClick}
258 + className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200 cursor-pointer"
259 + aria-label={
260 + isFavorite ? "Remove from favorites" : "Add to favorites"
261 + }
262 >
192 - <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
193 - </svg>
194 - </button>
263 + <svg
264 + xmlns="http://www.w3.org/2000/svg"
265 + viewBox="0 0 24 24"
266 + className="w-5 h-5 transition-colors duration-200"
267 + fill={isFavorite ? "currentColor" : "none"}
268 + stroke="currentColor"
269 + strokeWidth="2"
270 + strokeLinecap="round"
271 + strokeLinejoin="round"
272 + style={{ color: isFavorite ? "var(--primary)" : "currentColor" }}
273 + >
274 + <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
275 + </svg>
276 + </button>
277 + )}
278
279 {/* Content overlay - not part of hero transition */}
280 <div className="relative h-full w-full bg-background/80 rounded-xl flex flex-col gap-4 p-4 items-start text-start">
@@ -211,18 +294,12 @@ export function ServerCard({
294 )}
295 >
296 {online ? "Online" : "Offline"}
297 + {formattedDuration && online && ` (${formattedDuration})`}
298 </p>
299 </div>
216 - <div className="flex items-center justify-between gap-2 max-w-full">
217 - <p className="text-foreground text-lg font-bold leading-tight truncate flex-1">
218 - {name}
219 - </p>
220 - {formattedDuration && online && (
221 - <span className="text-xs text-text-muted font-medium whitespace-nowrap">
222 - ({formattedDuration})
223 - </span>
224 - )}
225 - </div>
300 + <p className="text-foreground text-lg font-bold leading-tight truncate max-w-full">
301 + {name}
302 + </p>
303 {description && (
304 <p className="text-text-muted text-sm font-normal leading-normal truncate max-w-full">
305 {description}
@@ -266,18 +343,50 @@ export function ServerCard({
343 Settings
344 </button>
345 </div>
269 - {/* Ban button */}
270 - <button
271 - onClick={handleBanClick}
272 - className={clsx(
273 - "w-full px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white",
274 - isBanned
275 - ? "bg-green-600 hover:bg-green-700"
276 - : "bg-red-600 hover:bg-red-700"
277 - )}
278 - >
279 - {isBanned ? "Unban" : "Ban"}
280 - </button>
346 + {/* IP display (for approved items in both auto and manual mode) */}
347 + {isApproved && ip && (
348 + <div className="text-xs text-text-muted">
349 + IP: <span className="font-mono">{ip}</span>
350 + {isIPBanned && (
351 + <span className="ml-2 text-red-500">(Banned)</span>
352 + )}
353 + </div>
354 + )}
355 + {/* Approve/Deny buttons: show only for unapproved items (both manual and auto mode) */}
356 + {!isApproved && !isDenied ? (
357 + <div className="flex gap-2 w-full">
358 + <button
359 + onClick={handleApproveClick}
360 + className="flex-1 px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white bg-green-600 hover:bg-green-700"
361 + >
362 + Approve
363 + </button>
364 + <button
365 + onClick={handleDenyClick}
366 + className="flex-1 px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white bg-red-600 hover:bg-red-700"
367 + >
368 + Deny
369 + </button>
370 + </div>
371 + ) : (
372 + <button
373 + onClick={ip ? handleIPBanClick : handleBanClick}
374 + className={clsx(
375 + "w-full px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white",
376 + (ip ? isIPBanned : isBanned)
377 + ? "bg-green-600 hover:bg-green-700"
378 + : "bg-red-600 hover:bg-red-700"
379 + )}
380 + >
381 + {ip
382 + ? isIPBanned
383 + ? "Unban IP"
384 + : "Ban IP"
385 + : isBanned
386 + ? "Unban"
387 + : "Ban"}
388 + </button>
389 + )}
390 </div>
391 )}
392 </div>
@@ -286,84 +395,70 @@ export function ServerCard({
395 <div className="absolute top-2 left-2 h-full w-full bg-secondary/70 rounded-xl z-0" />
396
397 {/* BPS Settings Modal */}
289 - {showBPSModal && (
290 - <div
291 - className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
292 - onClick={handleBPSModalClose}
293 - >
294 - <div
295 - className="bg-background rounded-md p-6 w-96 shadow-xl border border-foreground/20"
296 - onClick={(e) => e.stopPropagation()}
297 - >
298 - <h3 className="text-lg font-bold mb-4">BPS Settings</h3>
299 - <p className="text-sm text-text-muted mb-2">
398 + <Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
399 + <DialogContent className="max-w-sm rounded-sm">
400 + <DialogHeader>
401 + <DialogTitle>BPS Settings</DialogTitle>
402 + <DialogDescription>
403 Set bytes-per-second limit (0 = unlimited)
301 - </p>
302 - {/* Current value display */}
303 - <div className="text-center text-xl font-bold mb-4 text-primary">
304 - {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
305 - </div>
306 - {/* Slider */}
404 + </DialogDescription>
405 + </DialogHeader>
406 + {/* Current value display */}
407 + <div className="text-center text-xl font-bold text-primary">
408 + {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
409 + </div>
410 + {/* Slider */}
411 + <input
412 + type="range"
413 + min="0"
414 + max={bpsSteps.length - 1}
415 + value={sliderIndex}
416 + onChange={(e) => {
417 + const idx = parseInt(e.target.value, 10);
418 + handleSliderChange(idx);
419 + }}
420 + className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer"
421 + />
422 + {/* Step labels */}
423 + <div className="flex justify-between text-xs text-text-muted">
424 + {bpsSteps.map((step, idx) => (
425 + <span
426 + key={idx}
427 + className={clsx(
428 + "cursor-pointer hover:text-foreground transition-colors",
429 + sliderIndex === idx && "text-primary font-medium"
430 + )}
431 + onClick={() => handleSliderChange(idx)}
432 + >
433 + {formatStepLabel(step)}
434 + </span>
435 + ))}
436 + </div>
437 + {/* Manual input */}
438 + <div>
439 + <label className="text-xs text-text-muted mb-1 block">
440 + Custom value (B/s)
441 + </label>
442 <input
308 - type="range"
309 - min="0"
310 - max={bpsSteps.length - 1}
311 - value={sliderIndex}
443 + type="number"
444 + value={bpsInput}
445 onChange={(e) => {
313 - const idx = parseInt(e.target.value, 10);
314 - handleSliderChange(idx);
446 + setBpsInput(e.target.value);
447 + syncSliderFromInput(parseInt(e.target.value, 10) || 0);
448 }}
316 - className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer mb-2"
449 + className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
450 + placeholder="Enter BPS limit"
451 + min="0"
452 />
318 - {/* Step labels */}
319 - <div className="flex justify-between text-xs text-text-muted mb-4">
320 - {bpsSteps.map((step, idx) => (
321 - <span
322 - key={idx}
323 - className={clsx(
324 - "cursor-pointer hover:text-foreground transition-colors",
325 - sliderIndex === idx && "text-primary font-medium"
326 - )}
327 - onClick={() => handleSliderChange(idx)}
328 - >
329 - {formatStepLabel(step)}
330 - </span>
331 - ))}
332 - </div>
333 - {/* Manual input */}
334 - <div className="mb-4">
335 - <label className="text-xs text-text-muted mb-1 block">
336 - Custom value (B/s)
337 - </label>
338 - <input
339 - type="number"
340 - value={bpsInput}
341 - onChange={(e) => {
342 - setBpsInput(e.target.value);
343 - syncSliderFromInput(parseInt(e.target.value, 10) || 0);
344 - }}
345 - className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
346 - placeholder="Enter BPS limit"
347 - min="0"
348 - />
349 - </div>
350 - <div className="flex gap-2">
351 - <button
352 - onClick={handleBPSModalClose}
353 - className="flex-1 px-4 py-2 rounded bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors cursor-pointer"
354 - >
355 - Cancel
356 - </button>
357 - <button
358 - onClick={handleBPSSave}
359 - className="flex-1 px-4 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground transition-colors cursor-pointer"
360 - >
361 - Save
362 - </button>
363 - </div>
453 </div>
365 - </div>
366 - )}
454 + <DialogFooter className="gap-2 sm:gap-0">
455 + <Button variant="secondary" onClick={() => setShowBPSModal(false)}>
456 + Cancel
457 + </Button>
458 + <Button onClick={handleBPSSave}>Save</Button>
459 + </DialogFooter>
460 + </DialogContent>
461 + </Dialog>
462 </Wrapper>
463 );
464 }
cmd/relay-server/frontend/src/components/ServerListView.tsx
+253 -47
@@ -1,9 +1,22 @@
1 +import { useState } from "react";
2 import { Header } from "@/components/Header";
3 import { SearchBar } from "@/components/SearchBar";
4 import { ServerCard } from "@/components/ServerCard";
5 +import { TagCombobox } from "@/components/TagCombobox";
6 import type { ClientServer } from "@/hooks/useServerList";
5 -import type { AdminServer } from "@/hooks/useAdmin";
7 +import type { AdminServer, ApprovalMode } from "@/hooks/useAdmin";
8 import type { SortOption, StatusFilter } from "@/types/filters";
9 +import { StatusSelect } from "@/components/select/StatusSelect";
10 +import { BanStatusButtons } from "@/components/button/BanStatusButtons";
11 +import { SortbySelect } from "@/components/select/SortbySelect";
12 +import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle";
13 +import { FloatingActionBar } from "@/components/FloatingActionBar";
14 +import {
15 + Dialog,
16 + DialogContent,
17 + DialogHeader,
18 + DialogTitle,
19 +} from "@/components/ui/dialog";
20
21 // Admin-specific filter for ban status
22 export type BanFilter = "all" | "banned" | "active";
@@ -29,9 +42,18 @@ interface ServerListViewProps {
42 // Admin mode (optional)
43 isAdmin?: boolean;
44 banFilter?: BanFilter;
45 + approvalMode?: ApprovalMode;
46 onBanFilterChange?: (value: BanFilter) => void;
47 onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
48 onBPSChange?: (leaseId: string, bps: number) => void;
49 + onApprovalModeChange?: (mode: ApprovalMode) => void;
50 + onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
51 + onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
52 + onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
53 + // Bulk action handlers
54 + onBulkApprove?: (leaseIds: string[]) => void;
55 + onBulkDeny?: (leaseIds: string[]) => void;
56 + onBulkBan?: (leaseIds: string[]) => void;
57 }
58
59 function isAdminServer(
@@ -57,61 +79,153 @@ export function ServerListView({
79 // Admin props
80 isAdmin = false,
81 banFilter = "all",
82 + approvalMode = "auto",
83 onBanFilterChange,
84 onBanStatusChange,
85 onBPSChange,
86 + onApprovalModeChange,
87 + onApproveStatusChange,
88 + onDenyStatusChange,
89 + onIPBanStatusChange,
90 + // Bulk action handlers
91 + onBulkApprove,
92 + onBulkDeny,
93 + onBulkBan,
94 }: ServerListViewProps) {
95 + const [showFilterModal, setShowFilterModal] = useState(false);
96 + const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
97 + new Set()
98 + );
99 +
100 + // Toggle selection for a single card
101 + const handleToggleSelect = (leaseId: string) => {
102 + setSelectedLeaseIds((prev) => {
103 + const next = new Set(prev);
104 + if (next.has(leaseId)) {
105 + next.delete(leaseId);
106 + } else {
107 + next.add(leaseId);
108 + }
109 + return next;
110 + });
111 + };
112 +
113 + // Clear all selections
114 + const handleClearSelection = () => {
115 + setSelectedLeaseIds(new Set());
116 + };
117 +
118 + // Get all selectable lease IDs from filtered servers
119 + const allLeaseIds = (filteredServers as (ClientServer | AdminServer)[])
120 + .filter(isAdminServer)
121 + .map((server) => server.peerId);
122 +
123 + // Check if all items are selected
124 + const isAllSelected =
125 + allLeaseIds.length > 0 &&
126 + allLeaseIds.every((id) => selectedLeaseIds.has(id));
127 +
128 + // Select all / Deselect all
129 + const handleSelectAll = () => {
130 + if (isAllSelected) {
131 + setSelectedLeaseIds(new Set());
132 + } else {
133 + setSelectedLeaseIds(new Set(allLeaseIds));
134 + }
135 + };
136 +
137 + // Bulk action handlers
138 + const handleBulkApprove = () => {
139 + if (onBulkApprove && selectedLeaseIds.size > 0) {
140 + onBulkApprove(Array.from(selectedLeaseIds));
141 + handleClearSelection();
142 + }
143 + };
144 +
145 + const handleBulkDeny = () => {
146 + if (onBulkDeny && selectedLeaseIds.size > 0) {
147 + onBulkDeny(Array.from(selectedLeaseIds));
148 + handleClearSelection();
149 + }
150 + };
151 +
152 + const handleBulkBan = () => {
153 + if (onBulkBan && selectedLeaseIds.size > 0) {
154 + onBulkBan(Array.from(selectedLeaseIds));
155 + handleClearSelection();
156 + }
157 + };
158 +
159 + // Admin filter content (Ban Status + Approval) - for desktop only
160 + const AdminFilterContent = () => (
161 + <>
162 + {/* Ban Status Filter Buttons */}
163 + {onBanFilterChange && (
164 + <div className="flex items-center gap-3">
165 + <span className="text-sm font-medium text-text-muted">
166 + Ban Status
167 + </span>
168 + <BanStatusButtons
169 + banFilter={banFilter}
170 + onBanFilterChange={onBanFilterChange}
171 + />
172 + </div>
173 + )}
174 +
175 + {/* Approval Mode Toggle */}
176 + {onApprovalModeChange && (
177 + <div className="flex items-center gap-3">
178 + <span className="text-sm font-medium text-text-muted">Approval</span>
179 + <ApprovalModeToggle
180 + approvalMode={approvalMode}
181 + onApprovalModeChange={onApprovalModeChange}
182 + />
183 + </div>
184 + )}
185 + </>
186 + );
187 +
188 return (
189 <div className="relative flex h-auto min-h-screen w-full flex-col">
190 <div className="flex h-full grow flex-col">
191 <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">
192 + <div className="flex flex-col w-full max-w-6xl flex-1 px-0 md:px-8">
193 <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>
194 + <Header title={title} isAdmin={isAdmin} />
195 + <div className="flex items-center gap-2">
196 + <div className="flex-1">
197 + <SearchBar
198 + searchQuery={searchQuery}
199 + onSearchChange={onSearchChange}
200 + status={status}
201 + onStatusChange={onStatusChange}
202 + sortBy={sortBy}
203 + onSortByChange={onSortByChange}
204 + availableTags={availableTags}
205 + selectedTags={selectedTags}
206 + onAddTag={onTagToggle}
207 + onRemoveTag={onTagToggle}
208 + hideFiltersOnMobile={isAdmin}
209 + setShowFilterModal={setShowFilterModal}
210 + />
211 + </div>
212 + </div>
213 + {/* Desktop filters - hidden on mobile */}
214 + {isAdmin && (
215 + <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
216 + <AdminFilterContent />
217 + </div>
218 + )}
219 + {/* Mobile-only Approval filter - always visible outside modal */}
220 + {isAdmin && onApprovalModeChange && (
221 + <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
222 + <span className="text-sm font-medium text-text-muted">
223 + Approval
224 + </span>
225 + <ApprovalModeToggle
226 + approvalMode={approvalMode}
227 + onApprovalModeChange={onApprovalModeChange}
228 + />
229 </div>
230 )}
231 </div>
@@ -152,9 +266,29 @@ export function ServerListView({
266 isBanned={
267 isAdminServer(server) ? server.isBanned : undefined
268 }
269 + isApproved={
270 + isAdminServer(server) ? server.isApproved : undefined
271 + }
272 + isDenied={
273 + isAdminServer(server) ? server.isDenied : undefined
274 + }
275 bps={isAdminServer(server) ? server.bps : undefined}
276 + ip={isAdminServer(server) ? server.ip : undefined}
277 + isIPBanned={
278 + isAdminServer(server) ? server.isIPBanned : undefined
279 + }
280 onBanStatusChange={onBanStatusChange}
281 onBPSChange={onBPSChange}
282 + onApproveStatusChange={onApproveStatusChange}
283 + onDenyStatusChange={onDenyStatusChange}
284 + onIPBanStatusChange={onIPBanStatusChange}
285 + // Selection for bulk actions
286 + isSelected={
287 + isAdminServer(server)
288 + ? selectedLeaseIds.has(server.peerId)
289 + : false
290 + }
291 + onToggleSelect={handleToggleSelect}
292 />
293 ))
294 ) : (
@@ -169,6 +303,78 @@ export function ServerListView({
303 </div>
304 </div>
305 </div>
306 +
307 + {/* Filter Modal for mobile - contains SearchBar filters (Status, Sort, Tag) */}
308 + <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}>
309 + <DialogContent className="sm:hidden max-w-sm rounded-sm">
310 + <DialogHeader>
311 + <DialogTitle>Filters</DialogTitle>
312 + </DialogHeader>
313 + <div className="flex flex-col gap-4">
314 + {/* Online/Offline Status Filter - Select style */}
315 + <div className="flex flex-col gap-2">
316 + <span className="text-sm font-medium text-text-muted">
317 + Status
318 + </span>
319 + <StatusSelect
320 + status={status}
321 + onStatusChange={onStatusChange}
322 + className="w-full!"
323 + />
324 + </div>
325 +
326 + {/* Admin Ban Status Filter - All/Active/Banned (button group style) */}
327 + {isAdmin && onBanFilterChange && (
328 + <div className="flex flex-col gap-2">
329 + <span className="text-sm font-medium text-text-muted">
330 + Ban Status
331 + </span>
332 + <BanStatusButtons
333 + className="[&>button]:w-full"
334 + banFilter={banFilter}
335 + onBanFilterChange={onBanFilterChange}
336 + />
337 + </div>
338 + )}
339 +
340 + {/* Sort By */}
341 + <div className="flex flex-col gap-2">
342 + <span className="text-sm font-medium text-text-muted">
343 + Sort By
344 + </span>
345 + <SortbySelect
346 + className="w-full!"
347 + sortBy={sortBy}
348 + onSortByChange={onSortByChange}
349 + />
350 + </div>
351 +
352 + {/* Tag Filter */}
353 + <div className="flex flex-col gap-2">
354 + <span className="text-sm font-medium text-text-muted">Tags</span>
355 + <TagCombobox
356 + availableTags={availableTags}
357 + selectedTags={selectedTags}
358 + onAdd={onTagToggle}
359 + onRemove={onTagToggle}
360 + />
361 + </div>
362 + </div>
363 + </DialogContent>
364 + </Dialog>
365 +
366 + {/* Floating Action Bar - shows when items are selected in admin mode */}
367 + {isAdmin && (
368 + <FloatingActionBar
369 + selectedCount={selectedLeaseIds.size}
370 + totalCount={allLeaseIds.length}
371 + isAllSelected={isAllSelected}
372 + onSelectAll={handleSelectAll}
373 + onApprove={handleBulkApprove}
374 + onDeny={handleBulkDeny}
375 + onBan={handleBulkBan}
376 + />
377 + )}
378 </div>
379 );
380 }
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+8 -3
@@ -55,7 +55,11 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
55 if (e.key === "Enter") {
56 e.preventDefault();
57 addRelayUrl(urlInput);
58 - } else if (e.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
58 + } else if (
59 + e.key === "Backspace" &&
60 + urlInput === "" &&
61 + relayUrls.length > 0
62 + ) {
63 // Remove last URL when backspace on empty input
64 setRelayUrls(relayUrls.slice(0, -1));
65 }
@@ -65,7 +69,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
69 const command = useMemo(() => {
70 const hostVal = host === "" ? defaultHost : host;
71 const nameVal = name === "" ? defaultName : name;
68 - const relayUrlVal = relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
72 + const relayUrlVal =
73 + relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
74 return `curl -fsSL ${currentOrigin}/tunnel | HOST=${hostVal} NAME=${nameVal} RELAY_URL="${relayUrlVal}" sh`;
75 }, [currentOrigin, host, name, relayUrls]);
76
@@ -88,7 +93,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
93 </Button>
94 )}
95 </DialogTrigger>
91 - <DialogContent className="sm:max-w-[500px]">
96 + <DialogContent className="sm:max-w-[500px] rounded-sm">
97 <DialogHeader>
98 <DialogTitle className="flex items-center gap-2">
99 <Terminal className="w-5 h-5" />
cmd/relay-server/frontend/src/components/button/ApprovalModeToggle.tsx new
+34
@@ -0,0 +1,34 @@
1 +import type { ApprovalMode } from "@/hooks/useAdmin";
2 +
3 +interface ApprovalModeToggleProps {
4 + approvalMode: ApprovalMode;
5 + onApprovalModeChange: (mode: ApprovalMode) => void;
6 +}
7 +
8 +export const ApprovalModeToggle = ({
9 + approvalMode,
10 + onApprovalModeChange,
11 +}: ApprovalModeToggleProps) => (
12 + <div className="flex rounded-lg overflow-hidden border border-foreground/20">
13 + <button
14 + onClick={() => onApprovalModeChange("auto")}
15 + className={`px-4 h-10 text-sm font-medium transition-colors ${
16 + approvalMode === "auto"
17 + ? "bg-primary text-primary-foreground"
18 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
19 + }`}
20 + >
21 + Auto
22 + </button>
23 + <button
24 + onClick={() => onApprovalModeChange("manual")}
25 + className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
26 + approvalMode === "manual"
27 + ? "bg-primary text-primary-foreground"
28 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
29 + }`}
30 + >
31 + Manual
32 + </button>
33 + </div>
34 +);
cmd/relay-server/frontend/src/components/button/BanStatusButtons.tsx new
+52
@@ -0,0 +1,52 @@
1 +import { BanFilter } from "@/components/ServerListView";
2 +import clsx from "clsx";
3 +
4 +interface BanStatusButtonsProps {
5 + banFilter: string;
6 + onBanFilterChange: (value: BanFilter) => void;
7 + className?: string;
8 +}
9 +
10 +export const BanStatusButtons = ({
11 + banFilter,
12 + onBanFilterChange,
13 + className,
14 +}: BanStatusButtonsProps) => (
15 + <div
16 + className={clsx(
17 + "flex rounded-lg overflow-hidden border border-foreground/20",
18 + className
19 + )}
20 + >
21 + <button
22 + onClick={() => onBanFilterChange("all")}
23 + className={`px-4 h-10 text-sm font-medium transition-colors ${
24 + banFilter === "all"
25 + ? "bg-primary text-primary-foreground"
26 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
27 + }`}
28 + >
29 + All
30 + </button>
31 + <button
32 + onClick={() => onBanFilterChange("active")}
33 + className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
34 + banFilter === "active"
35 + ? "bg-green-600 text-white"
36 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
37 + }`}
38 + >
39 + Active
40 + </button>
41 + <button
42 + onClick={() => onBanFilterChange("banned")}
43 + className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
44 + banFilter === "banned"
45 + ? "bg-red-600 text-white"
46 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
47 + }`}
48 + >
49 + Banned
50 + </button>
51 + </div>
52 +);
cmd/relay-server/frontend/src/components/select/SortbySelect.tsx new
+45
@@ -0,0 +1,45 @@
1 +import {
2 + Select,
3 + SelectContent,
4 + SelectItem,
5 + SelectTrigger,
6 + SelectValue,
7 +} from "@/components/ui/select";
8 +import { SortOption } from "@/types/filters";
9 +import clsx from "clsx";
10 +
11 +interface SortbySelectProps {
12 + sortBy: string;
13 + onSortByChange: (value: SortOption) => void;
14 + hideFiltersOnMobile?: boolean;
15 + className?: string;
16 +}
17 +
18 +export const SortbySelect = ({
19 + sortBy,
20 + onSortByChange,
21 + hideFiltersOnMobile,
22 + className,
23 +}: SortbySelectProps) => (
24 + <Select value={sortBy} onValueChange={onSortByChange}>
25 + <SelectTrigger
26 + className={clsx(
27 + "w-[150px] h-10",
28 + hideFiltersOnMobile && "hidden sm:flex",
29 + className
30 + )}
31 + >
32 + <SelectValue placeholder="Sort By" />
33 + </SelectTrigger>
34 + <SelectContent>
35 + <SelectItem value="default">Default</SelectItem>
36 + <SelectItem value="name-asc">Name (A-Z)</SelectItem>
37 + <SelectItem value="name-desc">Name (Z-A)</SelectItem>
38 + <SelectItem value="updated">Recently Updated</SelectItem>
39 + <SelectItem value="duration">Duration (Maintained)</SelectItem>
40 + <SelectItem value="description">Description</SelectItem>
41 + <SelectItem value="tags">Tags</SelectItem>
42 + <SelectItem value="owner">Owner</SelectItem>
43 + </SelectContent>
44 + </Select>
45 +);
cmd/relay-server/frontend/src/components/select/StatusSelect.tsx new
+40
@@ -0,0 +1,40 @@
1 +import {
2 + Select,
3 + SelectContent,
4 + SelectItem,
5 + SelectTrigger,
6 + SelectValue,
7 +} from "@/components/ui/select";
8 +import { StatusFilter } from "@/types/filters";
9 +import clsx from "clsx";
10 +
11 +interface StatusSelectProps {
12 + status: string;
13 + onStatusChange: (value: StatusFilter) => void;
14 + hideFiltersOnMobile?: boolean;
15 + className?: string;
16 +}
17 +
18 +export const StatusSelect = ({
19 + status,
20 + onStatusChange,
21 + hideFiltersOnMobile,
22 + className,
23 +}: StatusSelectProps) => (
24 + <Select value={status} onValueChange={onStatusChange}>
25 + <SelectTrigger
26 + className={clsx(
27 + "w-[130px] h-10",
28 + hideFiltersOnMobile && "hidden sm:flex",
29 + className
30 + )}
31 + >
32 + <SelectValue placeholder="Status" />
33 + </SelectTrigger>
34 + <SelectContent>
35 + <SelectItem value="all">All Status</SelectItem>
36 + <SelectItem value="online">Online</SelectItem>
37 + <SelectItem value="offline">Offline</SelectItem>
38 + </SelectContent>
39 + </Select>
40 +);
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+189 -25
@@ -3,11 +3,18 @@ import type { ServerData, Metadata } from "@/hooks/useSSRData";
3 import { useList, type BaseServer } from "@/hooks/useList";
4 import type { BanFilter } from "@/components/ServerListView";
5
6 +// Approval mode type
7 +export type ApprovalMode = "auto" | "manual";
8 +
9 // Extended BaseServer with admin-specific fields
10 export interface AdminServer extends BaseServer {
11 peerId: string;
12 isBanned: boolean;
13 bps: number; // bytes-per-second limit (0 = unlimited)
14 + isApproved: boolean; // whether lease is approved (for manual mode)
15 + isDenied: boolean; // whether lease is denied (for manual mode)
16 + ip: string; // client IP address (for IP-based ban)
17 + isIPBanned: boolean; // whether the IP is banned
18 }
19
20 // Convert ServerData (from API) to AdminServer format
@@ -54,12 +61,17 @@ function convertServerDataToAdminServer(
61 peerId: row.Peer,
62 isBanned: bannedLeases.includes(row.Peer),
63 bps: row.BPS || 0,
64 + isApproved: row.IsApproved || false,
65 + isDenied: row.IsDenied || false,
66 + ip: row.IP || "",
67 + isIPBanned: row.IsIPBanned || false,
68 };
69 }
70
71 export function useAdmin() {
72 const [serverData, setServerData] = useState<ServerData[]>([]);
73 const [bannedLeases, setBannedLeases] = useState<string[]>([]);
74 + const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
75 const [loading, setLoading] = useState(true);
76 const [error, setError] = useState("");
77
@@ -68,9 +80,10 @@ export function useAdmin() {
80
81 const fetchData = useCallback(async () => {
82 try {
71 - const [leasesRes, bannedRes] = await Promise.all([
83 + const [leasesRes, bannedRes, settingsRes] = await Promise.all([
84 fetch("/admin/leases"),
85 fetch("/admin/leases/banned"),
86 + fetch("/admin/settings"),
87 ]);
88
89 if (!leasesRes.ok || !bannedRes.ok) {
@@ -90,6 +103,12 @@ export function useAdmin() {
103 }
104 });
105 setBannedLeases(decodedBanned);
106 +
107 + // Load settings
108 + if (settingsRes.ok) {
109 + const settings = await settingsRes.json();
110 + setApprovalMode(settings.approval_mode || "auto");
111 + }
112 } catch (err: unknown) {
113 setError(err instanceof Error ? err.message : String(err));
114 } finally {
@@ -131,37 +150,173 @@ export function useAdmin() {
150 setBanFilter(value);
151 }, []);
152
134 - const handleBanStatus = useCallback(async (peerId: string, isBan: boolean) => {
153 + const handleBanStatus = useCallback(
154 + async (peerId: string, isBan: boolean) => {
155 + try {
156 + // URL-safe base64 encode the peer ID
157 + const safeId = btoa(peerId)
158 + .replace(/\+/g, "-")
159 + .replace(/\//g, "_")
160 + .replace(/=+$/, "");
161 + await fetch(`/admin/leases/${safeId}/ban`, {
162 + method: isBan ? "POST" : "DELETE",
163 + });
164 + fetchData();
165 + } catch (err) {
166 + console.error(err);
167 + }
168 + },
169 + [fetchData]
170 + );
171 +
172 + const handleBPSChange = useCallback(
173 + async (peerId: string, bps: number) => {
174 + try {
175 + // URL-safe base64 encode the peer ID
176 + const safeId = btoa(peerId)
177 + .replace(/\+/g, "-")
178 + .replace(/\//g, "_")
179 + .replace(/=+$/, "");
180 + if (bps <= 0) {
181 + await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
182 + } else {
183 + await fetch(`/admin/leases/${safeId}/bps`, {
184 + method: "POST",
185 + headers: { "Content-Type": "application/json" },
186 + body: JSON.stringify({ bps }),
187 + });
188 + }
189 + fetchData();
190 + } catch (err) {
191 + console.error(err);
192 + }
193 + },
194 + [fetchData]
195 + );
196 +
197 + const handleApprovalModeChange = useCallback(async (mode: ApprovalMode) => {
198 try {
136 - // URL-safe base64 encode the peer ID
137 - const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
138 - await fetch(`/admin/leases/${safeId}/ban`, {
139 - method: isBan ? "POST" : "DELETE"
199 + await fetch("/admin/settings/approval-mode", {
200 + method: "POST",
201 + headers: { "Content-Type": "application/json" },
202 + body: JSON.stringify({ mode }),
203 });
141 - fetchData();
204 + setApprovalMode(mode);
205 } catch (err) {
206 console.error(err);
207 }
145 - }, [fetchData]);
208 + }, []);
209
147 - const handleBPSChange = useCallback(async (peerId: string, bps: number) => {
148 - try {
149 - // URL-safe base64 encode the peer ID
150 - const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
151 - if (bps <= 0) {
152 - await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
153 - } else {
154 - await fetch(`/admin/leases/${safeId}/bps`, {
155 - method: "POST",
156 - headers: { "Content-Type": "application/json" },
157 - body: JSON.stringify({ bps }),
210 + const handleApproveStatus = useCallback(
211 + async (peerId: string, approve: boolean) => {
212 + try {
213 + const safeId = btoa(peerId)
214 + .replace(/\+/g, "-")
215 + .replace(/\//g, "_")
216 + .replace(/=+$/, "");
217 + await fetch(`/admin/leases/${safeId}/approve`, {
218 + method: approve ? "POST" : "DELETE",
219 });
220 + fetchData();
221 + } catch (err) {
222 + console.error(err);
223 }
160 - fetchData();
161 - } catch (err) {
162 - console.error(err);
163 - }
164 - }, [fetchData]);
224 + },
225 + [fetchData]
226 + );
227 +
228 + const handleDenyStatus = useCallback(
229 + async (peerId: string, deny: boolean) => {
230 + try {
231 + const safeId = btoa(peerId)
232 + .replace(/\+/g, "-")
233 + .replace(/\//g, "_")
234 + .replace(/=+$/, "");
235 + await fetch(`/admin/leases/${safeId}/deny`, {
236 + method: deny ? "POST" : "DELETE",
237 + });
238 + fetchData();
239 + } catch (err) {
240 + console.error(err);
241 + }
242 + },
243 + [fetchData]
244 + );
245 +
246 + const handleIPBanStatus = useCallback(
247 + async (ip: string, isBan: boolean) => {
248 + try {
249 + await fetch(`/admin/ips/${ip}/ban`, {
250 + method: isBan ? "POST" : "DELETE",
251 + });
252 + fetchData();
253 + } catch (err) {
254 + console.error(err);
255 + }
256 + },
257 + [fetchData]
258 + );
259 +
260 + // Bulk action handlers
261 + const handleBulkApprove = useCallback(
262 + async (peerIds: string[]) => {
263 + try {
264 + await Promise.all(
265 + peerIds.map((peerId) => {
266 + const safeId = btoa(peerId)
267 + .replace(/\+/g, "-")
268 + .replace(/\//g, "_")
269 + .replace(/=+$/, "");
270 + return fetch(`/admin/leases/${safeId}/approve`, { method: "POST" });
271 + })
272 + );
273 + fetchData();
274 + } catch (err) {
275 + console.error(err);
276 + }
277 + },
278 + [fetchData]
279 + );
280 +
281 + const handleBulkDeny = useCallback(
282 + async (peerIds: string[]) => {
283 + try {
284 + await Promise.all(
285 + peerIds.map((peerId) => {
286 + const safeId = btoa(peerId)
287 + .replace(/\+/g, "-")
288 + .replace(/\//g, "_")
289 + .replace(/=+$/, "");
290 + return fetch(`/admin/leases/${safeId}/deny`, { method: "POST" });
291 + })
292 + );
293 + fetchData();
294 + } catch (err) {
295 + console.error(err);
296 + }
297 + },
298 + [fetchData]
299 + );
300 +
301 + const handleBulkBan = useCallback(
302 + async (peerIds: string[]) => {
303 + try {
304 + await Promise.all(
305 + peerIds.map((peerId) => {
306 + const safeId = btoa(peerId)
307 + .replace(/\+/g, "-")
308 + .replace(/\//g, "_")
309 + .replace(/=+$/, "");
310 + return fetch(`/admin/leases/${safeId}/ban`, { method: "POST" });
311 + })
312 + );
313 + fetchData();
314 + } catch (err) {
315 + console.error(err);
316 + }
317 + },
318 + [fetchData]
319 + );
320
321 return {
322 // Raw data
@@ -173,6 +328,7 @@ export function useAdmin() {
328 ...listState,
329 // Admin-specific filter state
330 banFilter,
331 + approvalMode,
332 // State
333 loading,
334 error,
@@ -180,6 +336,14 @@ export function useAdmin() {
336 handleBanFilterChange,
337 handleBanStatus,
338 handleBPSChange,
183 - refresh: fetchData
339 + handleApprovalModeChange,
340 + handleApproveStatus,
341 + handleDenyStatus,
342 + handleIPBanStatus,
343 + // Bulk action handlers
344 + handleBulkApprove,
345 + handleBulkDeny,
346 + handleBulkBan,
347 + refresh: fetchData,
348 };
349 }
cmd/relay-server/frontend/src/hooks/useSSRData.ts
+4
@@ -23,6 +23,10 @@ export interface ServerData {
23 Hide: boolean;
24 Metadata: string;
25 BPS?: number; // bytes-per-second limit (0 = unlimited), admin only
26 + IsApproved?: boolean; // whether lease is approved (for manual mode), admin only
27 + IsDenied?: boolean; // whether lease is denied (for manual mode), admin only
28 + IP?: string; // client IP address (for IP-based ban), admin only
29 + IsIPBanned?: boolean; // whether the IP is banned, admin only
30 }
31
32 /**
cmd/relay-server/frontend/src/pages/Admin.tsx
+16
@@ -11,6 +11,7 @@ export function Admin() {
11 sortBy,
12 selectedTags,
13 banFilter,
14 + approvalMode,
15 favorites,
16 loading,
17 error,
@@ -22,6 +23,13 @@ export function Admin() {
23 handleToggleFavorite,
24 handleBanStatus,
25 handleBPSChange,
26 + handleApprovalModeChange,
27 + handleApproveStatus,
28 + handleDenyStatus,
29 + handleIPBanStatus,
30 + handleBulkApprove,
31 + handleBulkDeny,
32 + handleBulkBan,
33 } = useAdmin();
34
35 if (loading) return <div className="p-8 text-foreground">Loading...</div>;
@@ -46,9 +54,17 @@ export function Admin() {
54 // Admin-specific props
55 isAdmin={true}
56 banFilter={banFilter}
57 + approvalMode={approvalMode}
58 onBanFilterChange={handleBanFilterChange}
59 onBanStatusChange={handleBanStatus}
60 onBPSChange={handleBPSChange}
61 + onApprovalModeChange={handleApprovalModeChange}
62 + onApproveStatusChange={handleApproveStatus}
63 + onDenyStatusChange={handleDenyStatus}
64 + onIPBanStatusChange={handleIPBanStatus}
65 + onBulkApprove={handleBulkApprove}
66 + onBulkDeny={handleBulkDeny}
67 + onBulkBan={handleBulkBan}
68 />
69 </SsgoiTransition>
70 );
cmd/relay-server/ip_manager.go new
+168
@@ -0,0 +1,168 @@
1 +package main
2 +
3 +import (
4 + "net"
5 + "net/http"
6 + "strings"
7 + "sync"
8 +)
9 +
10 +// IPManager manages IP-based bans and lease-to-IP mapping
11 +type IPManager struct {
12 + mu sync.RWMutex
13 + bannedIPs map[string]struct{} // set of banned IPs
14 + leaseToIP map[string]string // lease ID -> IP address
15 + ipToLeases map[string][]string // IP -> list of lease IDs (for lookup)
16 +}
17 +
18 +// NewIPManager creates a new IP manager
19 +func NewIPManager() *IPManager {
20 + return &IPManager{
21 + bannedIPs: make(map[string]struct{}),
22 + leaseToIP: make(map[string]string),
23 + ipToLeases: make(map[string][]string),
24 + }
25 +}
26 +
27 +// BanIP adds an IP to the ban list
28 +func (m *IPManager) BanIP(ip string) {
29 + m.mu.Lock()
30 + defer m.mu.Unlock()
31 + m.bannedIPs[ip] = struct{}{}
32 +}
33 +
34 +// UnbanIP removes an IP from the ban list
35 +func (m *IPManager) UnbanIP(ip string) {
36 + m.mu.Lock()
37 + defer m.mu.Unlock()
38 + delete(m.bannedIPs, ip)
39 +}
40 +
41 +// IsIPBanned checks if an IP is banned
42 +func (m *IPManager) IsIPBanned(ip string) bool {
43 + m.mu.RLock()
44 + defer m.mu.RUnlock()
45 + _, banned := m.bannedIPs[ip]
46 + return banned
47 +}
48 +
49 +// GetBannedIPs returns all banned IPs
50 +func (m *IPManager) GetBannedIPs() []string {
51 + m.mu.RLock()
52 + defer m.mu.RUnlock()
53 + result := make([]string, 0, len(m.bannedIPs))
54 + for ip := range m.bannedIPs {
55 + result = append(result, ip)
56 + }
57 + return result
58 +}
59 +
60 +// SetBannedIPs sets the banned IPs list (for loading from settings)
61 +func (m *IPManager) SetBannedIPs(ips []string) {
62 + m.mu.Lock()
63 + defer m.mu.Unlock()
64 + m.bannedIPs = make(map[string]struct{}, len(ips))
65 + for _, ip := range ips {
66 + m.bannedIPs[ip] = struct{}{}
67 + }
68 +}
69 +
70 +// RegisterLeaseIP associates a lease ID with an IP address
71 +func (m *IPManager) RegisterLeaseIP(leaseID, ip string) {
72 + m.mu.Lock()
73 + defer m.mu.Unlock()
74 +
75 + // Remove old mapping if exists
76 + if oldIP, exists := m.leaseToIP[leaseID]; exists && oldIP != ip {
77 + m.removeLeaseFromIP(leaseID, oldIP)
78 + }
79 +
80 + m.leaseToIP[leaseID] = ip
81 + m.ipToLeases[ip] = append(m.ipToLeases[ip], leaseID)
82 +}
83 +
84 +// removeLeaseFromIP removes a lease from IP's lease list (must hold lock)
85 +func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
86 + leases := m.ipToLeases[ip]
87 + for i, id := range leases {
88 + if id == leaseID {
89 + m.ipToLeases[ip] = append(leases[:i], leases[i+1:]...)
90 + break
91 + }
92 + }
93 + if len(m.ipToLeases[ip]) == 0 {
94 + delete(m.ipToLeases, ip)
95 + }
96 +}
97 +
98 +// GetLeaseIP returns the IP address for a lease ID
99 +func (m *IPManager) GetLeaseIP(leaseID string) string {
100 + m.mu.RLock()
101 + defer m.mu.RUnlock()
102 + return m.leaseToIP[leaseID]
103 +}
104 +
105 +// GetIPLeases returns all lease IDs for an IP
106 +func (m *IPManager) GetIPLeases(ip string) []string {
107 + m.mu.RLock()
108 + defer m.mu.RUnlock()
109 + result := make([]string, len(m.ipToLeases[ip]))
110 + copy(result, m.ipToLeases[ip])
111 + return result
112 +}
113 +
114 +// ExtractClientIP extracts the client IP from an HTTP request
115 +func ExtractClientIP(r *http.Request) string {
116 + // Check X-Forwarded-For header first (for proxied requests)
117 + if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
118 + // X-Forwarded-For can contain multiple IPs, take the first one
119 + if idx := strings.Index(xff, ","); idx != -1 {
120 + return strings.TrimSpace(xff[:idx])
121 + }
122 + return strings.TrimSpace(xff)
123 + }
124 +
125 + // Check X-Real-IP header
126 + if xri := r.Header.Get("X-Real-IP"); xri != "" {
127 + return strings.TrimSpace(xri)
128 + }
129 +
130 + // Fall back to RemoteAddr
131 + ip, _, err := net.SplitHostPort(r.RemoteAddr)
132 + if err != nil {
133 + return r.RemoteAddr
134 + }
135 + return ip
136 +}
137 +
138 +// Global IP manager instance
139 +var globalIPManager *IPManager
140 +
141 +// pendingIPs stores recent connection IPs in a circular buffer for lease association
142 +var (
143 + pendingIPsMu sync.Mutex
144 + pendingIPsQueue []string
145 + pendingIPsMax = 100 // Keep last 100 IPs
146 +)
147 +
148 +// storePendingIP stores a client IP for later association with a lease
149 +func storePendingIP(ip string) {
150 + pendingIPsMu.Lock()
151 + defer pendingIPsMu.Unlock()
152 + pendingIPsQueue = append(pendingIPsQueue, ip)
153 + if len(pendingIPsQueue) > pendingIPsMax {
154 + pendingIPsQueue = pendingIPsQueue[1:]
155 + }
156 +}
157 +
158 +// popPendingIP retrieves and removes the oldest pending IP
159 +func popPendingIP() string {
160 + pendingIPsMu.Lock()
161 + defer pendingIPsMu.Unlock()
162 + if len(pendingIPsQueue) == 0 {
163 + return ""
164 + }
165 + ip := pendingIPsQueue[0]
166 + pendingIPsQueue = pendingIPsQueue[1:]
167 + return ip
168 +}
cmd/relay-server/main.go
+11 -3
@@ -89,11 +89,19 @@ func runServer() error {
89 bpsManager.SetDefaultBPS(int64(flagLeaseBPS))
90 }
91
92 - // Load persisted admin settings (ban list, BPS limits)
93 - loadAdminSettings(serv, bpsManager)
92 + // Create IP manager for IP-based bans
93 + ipManager := NewIPManager()
94 + globalIPManager = ipManager
95
95 - // Register relay callback for BPS handling
96 + // Load persisted admin settings (ban list, BPS limits, IP bans)
97 + loadAdminSettings(serv, bpsManager, ipManager)
98 +
99 + // Register relay callback for BPS handling and IP tracking
100 serv.SetEstablishRelayCallback(func(clientStream, leaseStream *yamux.Stream, leaseID string) {
101 + // Associate pending IP with this lease
102 + if ip := popPendingIP(); ip != "" && globalIPManager != nil {
103 + globalIPManager.RegisterLeaseIP(leaseID, ip)
104 + }
105 establishRelayWithBPS(clientStream, leaseStream, leaseID, bpsManager)
106 })
107
cmd/relay-server/view.go
+24 -1
@@ -92,11 +92,25 @@ func serveHTTP(addr string, serv *portal.RelayServer, bpsManager *BPSManager, no
92 return
93 }
94
95 + // Check if IP is banned
96 + clientIP := ExtractClientIP(r)
97 + if globalIPManager != nil && globalIPManager.IsIPBanned(clientIP) {
98 + log.Warn().Str("ip", clientIP).Msg("[server] connection rejected: IP banned")
99 + http.Error(w, "forbidden", http.StatusForbidden)
100 + return
101 + }
102 +
103 stream, wsConn, err := utils.UpgradeToWSStream(w, r, nil)
104 if err != nil {
105 log.Error().Err(err).Msg("[server] websocket upgrade failed")
106 return
107 }
108 +
109 + // Store pending IP for lease association (will be linked when lease is registered)
110 + if globalIPManager != nil && clientIP != "" {
111 + storePendingIP(clientIP)
112 + }
113 +
114 if err := serv.HandleConnection(stream); err != nil {
115 log.Error().Err(err).Msg("[server] websocket relay connection error")
116 wsConn.Close()
@@ -200,7 +214,11 @@ type leaseRow struct {
214 StaleRed bool
215 Hide bool
216 Metadata string
203 - BPS int64 // bytes-per-second limit (0 = unlimited)
217 + BPS int64 // bytes-per-second limit (0 = unlimited)
218 + IsApproved bool // whether lease is approved (for manual mode)
219 + IsDenied bool // whether lease is denied (for manual mode)
220 + IP string // client IP address (for IP-based ban)
221 + IsIPBanned bool // whether the IP is banned
222 }
223
224 // convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page
@@ -236,6 +254,11 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
254 continue
255 }
256
257 + // Skip unapproved leases in manual mode for user-facing list
258 + if getApprovalMode() == ApprovalModeManual && !isLeaseApproved(identityID) {
259 + continue
260 + }
261 +
262 // Check hidden status
263 if metadata.Hide {
264 continue