refactor(frontend): remove callback artifacts and align mtls docs
cognitive committed
Mar 4, 2026 at 06:21 UTC
6d38e803f9890b91e744cb480c51a1ad2ab97b13
10 files changed
+416
-332
README.md
+13
-6
@@ -32,8 +32,6 @@ and routes incoming traffic while preserving end-to-end TLS.
32
- **Fast setup**: Expose a local app with a short command flow
33
- **Central anti-abuse enforcement**: `/sdk/register` and `/sdk/connect` use the same admin-managed policy controls (IP bans, lease authorization) before accepting a tunnel
34
35
-Security policy hardening in this refactor does not require operator setup changes.
36
-
35
## Components
36
37
- **Relay**: A server that routes public requests to the right connected app.
@@ -44,17 +42,26 @@ For details, see [docs/glossary.md](docs/glossary.md).
42
## Protocol Scope
43
44
- Raw TCP reverse-connect is the only supported relay/tunnel transport.
47
-- No websocket compatibility path is provided for transport control or data-plane flow.
45
+- Websocket transport is unsupported for relay/tunnel traffic.
46
+
47
+## Connection Model
48
+
49
+- Conn #1 (`browser -> app`) is the data plane and keeps existing tenant-facing TLS behavior.
50
+- Conn #2 (`relay -> tunnel`) is the control plane and requires lease-bound client mTLS identity on `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister`.
51
52
## Runtime Contracts
53
54
- Lease IDs in admin and SDK payloads are plain string IDs.
55
- Base64URL lease-ID encoding is used only for admin action route path segments (`/admin/leases/{encodedLeaseID}/{action}`).
53
-- `/sdk/connect` accepts secure transport when either:
54
- - direct TLS is present, or
55
- - request comes from an allowlisted trusted proxy and forwarded HTTPS headers indicate HTTPS.
56
+- Control-plane admission order is strict: `IP -> Lease -> CertBind -> Token`.
57
- Tunnel installer scripts always fetch `${BIN_URL}.sha256` and fail closed on missing, malformed, or mismatched checksum.
58
59
+## Control-Plane Upgrade Requirement
60
+
61
+- This release wave is a hard-break for control-plane identity.
62
+- Clients without valid lease-bound mTLS identity fail deterministically at control-plane admission.
63
+- There is no token-only fallback mode after cutover.
64
+
65
### Routing Notes
66
67
- SNI routing preserves an exact-match fallback for the portal root host. Requests that target the exact `PORTAL_URL` host (for example, `portal.example.com`) are handled by the admin/API listener via the no-route path.
cmd/relay-server/frontend/README.md
+13
-3
@@ -136,8 +136,8 @@ Admin lease ID contract:
136
The relay enforces a consistent anti-abuse gate for both control APIs and reverse admission:
137
138
- `/sdk/register`, `/sdk/unregister`, `/sdk/renew`, and `/sdk/domain` return JSON envelopes (`{ ok, data, error }`).
139
-- `/sdk/connect` is the raw transport endpoint and returns HTTP status + JSON envelope errors for validation failures before connection hijack (`tls_required`, `missing_lease_id`, `missing_reverse_token`, `unsupported_transport`, `ip_banned`, `lease_not_found`, `unauthorized`).
140
-- `/sdk/connect` treats transport as secure when direct TLS is present, or when forwarded HTTPS headers are received from an allowlisted trusted proxy (`TRUST_PROXY_HEADERS=true` + trusted proxy CIDRs).
139
+- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
140
+- Control-plane admission order is deterministic: `IP -> Lease -> CertBind -> Token`.
141
- `/sdk/connect` is additionally re-validated inside `ReverseHub` before pooling so token and IP authorization are applied at both admission layers.
142
143
### Run with Relay Server
@@ -160,9 +160,19 @@ STATIC_DIR=./dist go run cmd/relay-server/*.go -adminport 4017
160
161
## Technical Notes
162
163
-- Backend transport is raw TCP reverse-connect only; there is no websocket control or data plane in relay transport semantics.
163
+- Backend relay/tunnel transport is raw TCP reverse-connect only.
164
- SNI routing keeps exact `PORTAL_URL` host fallbacks on the admin/API listener to preserve portal dashboard control-plane locality.
165
166
+### Connection Responsibilities
167
+
168
+- Conn #1 (`browser -> app`) is the data plane and keeps existing tenant-facing TLS behavior.
169
+- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease-bound mTLS identity.
170
+
171
+### Breaking-Change Expectation
172
+
173
+- Non-mTLS control-plane clients are expected to fail admission after cutover.
174
+- There is no token-only fallback mode.
175
+
176
### Radix Select Values
177
178
Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
cmd/relay-server/frontend/src/components/ServerCard.tsx
+241
-249
@@ -1,7 +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, useMemo } from "react";
4
+import { useState, useMemo } from "react";
5
import {
6
Dialog,
7
DialogContent,
@@ -27,7 +27,6 @@ interface ServerCardProps {
27
navigationState: any;
28
isFavorite?: boolean;
29
onToggleFavorite?: (serverId: number) => void;
30
- // Admin controls
30
showAdminControls?: boolean;
31
leaseId?: string;
32
isBanned?: boolean;
@@ -36,12 +35,17 @@ interface ServerCardProps {
35
bps?: number;
36
ip?: string;
37
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
38
+ onBanStatusChange?: (
39
+ leaseId: string,
40
+ isBan: boolean
41
+ ) => void | Promise<void>;
42
+ onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
43
+ onApproveStatusChange?: (
44
+ leaseId: string,
45
+ approve: boolean
46
+ ) => void | Promise<void>;
47
+ onDenyStatusChange?: (leaseId: string, deny: boolean) => void | Promise<void>;
48
+ onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
49
isSelected?: boolean;
50
onToggleSelect?: (leaseId: string) => void;
51
}
@@ -59,7 +63,6 @@ export function ServerCard({
63
navigationState,
64
isFavorite = false,
65
onToggleFavorite,
62
- // Admin controls
66
showAdminControls = false,
67
leaseId,
68
isBanned = false,
@@ -73,14 +76,12 @@ export function ServerCard({
76
onApproveStatusChange,
77
onDenyStatusChange,
78
onIPBanStatusChange,
76
- // Selection for bulk actions
79
isSelected = false,
80
onToggleSelect,
81
}: ServerCardProps) {
82
const [showBPSModal, setShowBPSModal] = useState(false);
83
const [bpsInput, setBpsInput] = useState(bps.toString());
84
83
- // BPS slider steps: 0 (Unlimited), 10, 100, 1K, 10K, 100K, 1M, 10M
85
const bpsSteps = [0, 10, 100, 1000, 10000, 100000, 1000000, 10000000];
86
87
const bpsToSliderIndex = (value: number): number => {
@@ -91,13 +92,28 @@ export function ServerCard({
92
93
const [sliderIndex, setSliderIndex] = useState(bpsToSliderIndex(bps));
94
94
- // Sync input with slider
95
+ const runAsyncAdminAction = (action?: () => void | Promise<void>) => {
96
+ if (!action) {
97
+ return;
98
+ }
99
+
100
+ try {
101
+ const result = action();
102
+ if (result && typeof (result as Promise<void>).then === "function") {
103
+ void result.catch((error) => {
104
+ console.error("Failed admin action", error);
105
+ });
106
+ }
107
+ } catch (error) {
108
+ console.error("Failed admin action", error);
109
+ }
110
+ };
111
+
112
const handleSliderChange = (idx: number) => {
113
setSliderIndex(idx);
114
setBpsInput(bpsSteps[idx].toString());
115
};
116
100
- // Sync slider with input (find closest step)
117
const syncSliderFromInput = (value: number) => {
118
const idx = bpsToSliderIndex(value);
119
setSliderIndex(idx);
@@ -119,32 +135,32 @@ export function ServerCard({
135
const handleBanClick = (e: React.MouseEvent) => {
136
e.preventDefault();
137
e.stopPropagation();
122
- if (leaseId && onBanStatusChange) {
123
- onBanStatusChange(leaseId, !isBanned);
138
+ if (leaseId) {
139
+ runAsyncAdminAction(() => onBanStatusChange?.(leaseId, !isBanned));
140
}
141
};
142
143
const handleApproveClick = (e: React.MouseEvent) => {
144
e.preventDefault();
145
e.stopPropagation();
130
- if (leaseId && onApproveStatusChange) {
131
- onApproveStatusChange(leaseId, !isApproved);
146
+ if (leaseId) {
147
+ runAsyncAdminAction(() => onApproveStatusChange?.(leaseId, !isApproved));
148
}
149
};
150
151
const handleDenyClick = (e: React.MouseEvent) => {
152
e.preventDefault();
153
e.stopPropagation();
138
- if (leaseId && onDenyStatusChange) {
139
- onDenyStatusChange(leaseId, !isDenied);
154
+ if (leaseId) {
155
+ runAsyncAdminAction(() => onDenyStatusChange?.(leaseId, !isDenied));
156
}
157
};
158
159
const handleIPBanClick = (e: React.MouseEvent) => {
160
e.preventDefault();
161
e.stopPropagation();
146
- if (ip && onIPBanStatusChange) {
147
- onIPBanStatusChange(ip, !isIPBanned);
162
+ if (ip) {
163
+ runAsyncAdminAction(() => onIPBanStatusChange?.(ip, !isIPBanned));
164
}
165
};
166
@@ -157,9 +173,9 @@ export function ServerCard({
173
};
174
175
const handleBPSSave = () => {
160
- if (leaseId && onBPSChange) {
176
+ if (leaseId) {
177
const newBps = parseInt(bpsInput, 10) || 0;
162
- onBPSChange(leaseId, newBps);
178
+ runAsyncAdminAction(() => onBPSChange?.(leaseId, newBps));
179
}
180
setShowBPSModal(false);
181
};
@@ -204,247 +220,227 @@ export function ServerCard({
220
return `${seconds}s`;
221
}, [firstSeen]);
222
207
- const Wrapper = ({ children }: { children: ReactNode }) =>
208
- showAdminControls ? (
209
- <div className="relative">{children}</div>
210
- ) : (
211
- <Link
212
- to={navigationPath}
213
- state={navigationState}
214
- className="relative cursor-pointer block"
215
- >
216
- {children}
217
- </Link>
218
- );
223
+ const cardBody = (
224
+ <article
225
+ data-hero-key={`server-bg-${serverId}`}
226
+ className={clsx(
227
+ "relative w-full overflow-hidden rounded-3xl group border border-white/10 shadow-lg",
228
+ showAdminControls ? "h-[286px]" : "h-[174.5px]"
229
+ )}
230
+ >
231
+ <div
232
+ className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
233
+ style={{
234
+ backgroundImage: thumbnail
235
+ ? `url(${thumbnail})`
236
+ : "linear-gradient(135deg, var(--card) 0%, var(--background) 100%)",
237
+ }}
238
+ />
239
+
240
+ <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
241
+
242
+ <div className="relative z-10 flex h-full flex-col justify-between p-5">
243
+ <div className="flex items-start justify-between">
244
+ <div className="flex items-center gap-2 rounded-full bg-black/40 px-3 py-1 backdrop-blur-sm border border-white/5">
245
+ <div
246
+ className={clsx(
247
+ "size-2 rounded-full",
248
+ online
249
+ ? "bg-primary shadow-[0_0_8px_rgba(0,219,219,0.8)] animate-pulse"
250
+ : "bg-gray-500"
251
+ )}
252
+ />
253
+ <span
254
+ className={clsx(
255
+ "text-[10px] font-bold uppercase tracking-wider",
256
+ online ? "text-white" : "text-white/60"
257
+ )}
258
+ >
259
+ {online ? "Online" : "Offline"}
260
+ {formattedDuration && online && ` · ${formattedDuration}`}
261
+ </span>
262
+ </div>
263
220
- return (
221
- <Wrapper>
222
- <article
223
- data-hero-key={`server-bg-${serverId}`}
224
- className={clsx(
225
- "relative w-full overflow-hidden rounded-3xl group border border-white/10 shadow-lg",
226
- showAdminControls ? "h-[286px]" : "h-[174.5px]"
227
- )}
228
- >
229
- {/* Background Image */}
230
- <div
231
- className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
232
- style={{
233
- backgroundImage: thumbnail
234
- ? `url(${thumbnail})`
235
- : "linear-gradient(135deg, var(--card) 0%, var(--background) 100%)",
236
- }}
237
- />
238
-
239
- {/* Gradient Overlay */}
240
- <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
241
-
242
- {/* Content */}
243
- <div className="relative z-10 flex h-full flex-col justify-between p-5">
244
- {/* Top Row: Status Badge + Action Button */}
245
- <div className="flex items-start justify-between">
246
- {/* Online/Offline Status Badge */}
247
- <div className="flex items-center gap-2 rounded-full bg-black/40 px-3 py-1 backdrop-blur-sm border border-white/5">
248
- <div
249
- className={clsx(
250
- "size-2 rounded-full",
251
- online
252
- ? "bg-primary shadow-[0_0_8px_rgba(0,219,219,0.8)] animate-pulse"
253
- : "bg-gray-500"
254
- )}
255
- />
256
- <span
257
- className={clsx(
258
- "text-[10px] font-bold uppercase tracking-wider",
259
- online ? "text-white" : "text-white/60"
260
- )}
264
+ {showAdminControls ? (
265
+ <button
266
+ onClick={handleSelectClick}
267
+ className={clsx(
268
+ "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
269
+ isSelected
270
+ ? "bg-primary text-black"
271
+ : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
272
+ )}
273
+ aria-label={isSelected ? "Deselect" : "Select"}
274
+ >
275
+ <svg
276
+ xmlns="http://www.w3.org/2000/svg"
277
+ viewBox="0 0 24 24"
278
+ className="w-[18px] h-[18px]"
279
+ fill="none"
280
+ stroke="currentColor"
281
+ strokeWidth="3"
282
+ strokeLinecap="round"
283
+ strokeLinejoin="round"
284
>
262
- {online ? "Online" : "Offline"}
263
- {formattedDuration && online && ` · ${formattedDuration}`}
264
- </span>
285
+ {isSelected && <polyline points="20 6 9 17 4 12" />}
286
+ </svg>
287
+ </button>
288
+ ) : (
289
+ <button
290
+ onClick={handleFavoriteClick}
291
+ className={clsx(
292
+ "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
293
+ isFavorite
294
+ ? "bg-primary text-black"
295
+ : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
296
+ )}
297
+ aria-label={
298
+ isFavorite ? "Remove from favorites" : "Add to favorites"
299
+ }
300
+ >
301
+ <svg
302
+ xmlns="http://www.w3.org/2000/svg"
303
+ viewBox="0 0 24 24"
304
+ className="w-[18px] h-[18px]"
305
+ fill={isFavorite ? "currentColor" : "none"}
306
+ stroke="currentColor"
307
+ strokeWidth="2"
308
+ strokeLinecap="round"
309
+ strokeLinejoin="round"
310
+ >
311
+ <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" />
312
+ </svg>
313
+ </button>
314
+ )}
315
+ </div>
316
+
317
+ <div className="flex flex-col gap-3">
318
+ <div className="flex items-end justify-between gap-3">
319
+ <div className="flex flex-col gap-1.5 flex-1 min-w-0">
320
+ <h3 className="font-display text-xl font-bold leading-tight text-white truncate">
321
+ {name}
322
+ </h3>
323
+
324
+ {description && (
325
+ <p className="text-xs text-white/70 line-clamp-1 font-medium">
326
+ {description}
327
+ </p>
328
+ )}
329
+
330
+ {tags && tags.length > 0 && (
331
+ <ScrollArea className="w-full mt-1">
332
+ <div className="flex gap-1.5 min-w-max">
333
+ {tags.map((tag, index) => (
334
+ <span
335
+ key={index}
336
+ className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary border border-primary/30 whitespace-nowrap"
337
+ >
338
+ #{tag}
339
+ </span>
340
+ ))}
341
+ </div>
342
+ <ScrollBar orientation="horizontal" />
343
+ </ScrollArea>
344
+ )}
345
+
346
+ {owner && (
347
+ <span className="text-[10px] font-medium text-white/50">
348
+ by {owner}
349
+ </span>
350
+ )}
351
</div>
352
267
- {/* Admin mode: Checkbox / Normal mode: Favorite star */}
268
- {showAdminControls ? (
269
- <button
270
- onClick={handleSelectClick}
271
- className={clsx(
272
- "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
273
- isSelected
274
- ? "bg-primary text-black"
275
- : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
276
- )}
277
- aria-label={isSelected ? "Deselect" : "Select"}
278
- >
279
- <svg
280
- xmlns="http://www.w3.org/2000/svg"
281
- viewBox="0 0 24 24"
282
- className="w-[18px] h-[18px]"
283
- fill="none"
284
- stroke="currentColor"
285
- strokeWidth="3"
286
- strokeLinecap="round"
287
- strokeLinejoin="round"
288
- >
289
- {isSelected && <polyline points="20 6 9 17 4 12" />}
290
- </svg>
291
- </button>
292
- ) : (
293
- <button
294
- onClick={handleFavoriteClick}
295
- className={clsx(
296
- "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
297
- isFavorite
298
- ? "bg-primary text-black"
299
- : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
300
- )}
301
- aria-label={
302
- isFavorite ? "Remove from favorites" : "Add to favorites"
303
- }
304
- >
305
- <svg
306
- xmlns="http://www.w3.org/2000/svg"
307
- viewBox="0 0 24 24"
308
- className="w-[18px] h-[18px]"
309
- fill={isFavorite ? "currentColor" : "none"}
310
- stroke="currentColor"
311
- strokeWidth="2"
312
- strokeLinecap="round"
313
- strokeLinejoin="round"
314
- >
315
- <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" />
316
- </svg>
317
- </button>
353
+ {!showAdminControls && thumbnail && (
354
+ <div className="shrink-0">
355
+ <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
356
+ <img
357
+ alt={`${name} avatar`}
358
+ className="h-full w-full object-cover"
359
+ src={thumbnail}
360
+ />
361
+ </div>
362
+ </div>
363
)}
364
</div>
365
321
- {/* Bottom Content */}
322
- <div className="flex flex-col gap-3">
323
- {/* Server Info Row */}
324
- <div className="flex items-end justify-between gap-3">
325
- <div className="flex flex-col gap-1.5 flex-1 min-w-0">
326
- {/* Server Name */}
327
- <h3 className="font-display text-xl font-bold leading-tight text-white truncate">
328
- {name}
329
- </h3>
330
-
331
- {/* Description */}
332
- {description && (
333
- <p className="text-xs text-white/70 line-clamp-1 font-medium">
334
- {description}
335
- </p>
336
- )}
337
-
338
- {/* Tags */}
339
- {tags && tags.length > 0 && (
340
- <ScrollArea className="w-full mt-1">
341
- <div className="flex gap-1.5 min-w-max">
342
- {tags.map((tag, index) => (
343
- <span
344
- key={index}
345
- className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary border border-primary/30 whitespace-nowrap"
346
- >
347
- #{tag}
348
- </span>
349
- ))}
350
- </div>
351
- <ScrollBar orientation="horizontal" />
352
- </ScrollArea>
353
- )}
354
-
355
- {/* Owner */}
356
- {owner && (
357
- <span className="text-[10px] font-medium text-white/50">
358
- by {owner}
359
- </span>
360
- )}
366
+ {showAdminControls && leaseId && (
367
+ <div className="flex flex-col gap-2 w-full mt-2">
368
+ <div className="flex items-center justify-between w-full">
369
+ <span className="text-xs text-white/60">
370
+ BPS: <span className="font-medium text-white">{formatBPS(bps)}</span>
371
+ </span>
372
+ <button
373
+ onClick={handleBPSSettingsClick}
374
+ className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
375
+ >
376
+ Settings
377
+ </button>
378
</div>
379
363
- {/* Thumbnail Avatar (if no admin controls) */}
364
- {!showAdminControls && thumbnail && (
365
- <div className="shrink-0">
366
- <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
367
- <img
368
- alt={`${name} avatar`}
369
- className="h-full w-full object-cover"
370
- src={thumbnail}
371
- />
372
- </div>
380
+ {isApproved && ip && (
381
+ <div className="text-[10px] text-white/50">
382
+ IP: <span className="font-mono">{ip}</span>
383
+ {isIPBanned && (
384
+ <span className="ml-2 text-red-400">(Banned)</span>
385
+ )}
386
</div>
387
)}
375
- </div>
388
377
- {/* Admin Controls */}
378
- {showAdminControls && leaseId && (
379
- <div className="flex flex-col gap-2 w-full mt-2">
380
- {/* BPS Row */}
381
- <div className="flex items-center justify-between w-full">
382
- <span className="text-xs text-white/60">
383
- BPS:{" "}
384
- <span className="font-medium text-white">
385
- {formatBPS(bps)}
386
- </span>
387
- </span>
389
+ {!isApproved && !isDenied ? (
390
+ <div className="flex gap-2 w-full">
391
<button
389
- onClick={handleBPSSettingsClick}
390
- className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
392
+ onClick={handleApproveClick}
393
+ className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
394
>
392
- Settings
395
+ Approve
396
</button>
394
- </div>
395
-
396
- {/* IP display */}
397
- {isApproved && ip && (
398
- <div className="text-[10px] text-white/50">
399
- IP: <span className="font-mono">{ip}</span>
400
- {isIPBanned && (
401
- <span className="ml-2 text-red-400">(Banned)</span>
402
- )}
403
- </div>
404
- )}
405
-
406
- {/* Approve/Deny buttons */}
407
- {!isApproved && !isDenied ? (
408
- <div className="flex gap-2 w-full">
409
- <button
410
- onClick={handleApproveClick}
411
- className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
412
- >
413
- Approve
414
- </button>
415
- <button
416
- onClick={handleDenyClick}
417
- className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
418
- >
419
- Deny
420
- </button>
421
- </div>
422
- ) : (
397
<button
424
- onClick={ip ? handleIPBanClick : handleBanClick}
425
- className={clsx(
426
- "w-full px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
427
- (ip ? isIPBanned : isBanned)
428
- ? "bg-green-600/80 hover:bg-green-600"
429
- : "bg-red-600/80 hover:bg-red-600"
430
- )}
398
+ onClick={handleDenyClick}
399
+ className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
400
>
432
- {ip
433
- ? isIPBanned
434
- ? "Unban IP"
435
- : "Ban IP"
436
- : isBanned
437
- ? "Unban"
438
- : "Ban"}
401
+ Deny
402
</button>
440
- )}
441
- </div>
442
- )}
443
- </div>
403
+ </div>
404
+ ) : (
405
+ <button
406
+ onClick={ip ? handleIPBanClick : handleBanClick}
407
+ className={clsx(
408
+ "w-full px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
409
+ (ip ? isIPBanned : isBanned)
410
+ ? "bg-green-600/80 hover:bg-green-600"
411
+ : "bg-red-600/80 hover:bg-red-600"
412
+ )}
413
+ >
414
+ {ip
415
+ ? isIPBanned
416
+ ? "Unban IP"
417
+ : "Ban IP"
418
+ : isBanned
419
+ ? "Unban"
420
+ : "Ban"}
421
+ </button>
422
+ )}
423
+ </div>
424
+ )}
425
</div>
445
- </article>
426
+ </div>
427
+ </article>
428
+ );
429
+
430
+ return (
431
+ <>
432
+ {showAdminControls ? (
433
+ <div className="relative">{cardBody}</div>
434
+ ) : (
435
+ <Link
436
+ to={navigationPath}
437
+ state={navigationState}
438
+ className="relative cursor-pointer block"
439
+ >
440
+ {cardBody}
441
+ </Link>
442
+ )}
443
447
- {/* BPS Settings Modal */}
444
<Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
445
<DialogContent className="max-w-sm rounded-xl">
446
<DialogHeader>
@@ -453,11 +449,9 @@ export function ServerCard({
449
Set bytes-per-second limit (0 = unlimited)
450
</DialogDescription>
451
</DialogHeader>
456
- {/* Current value display */}
452
<div className="text-center text-xl font-bold text-primary">
453
{formatSliderLabel(parseInt(bpsInput, 10) || 0)}
454
</div>
460
- {/* Slider */}
455
<input
456
type="range"
457
min="0"
@@ -469,7 +463,6 @@ export function ServerCard({
463
}}
464
className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer"
465
/>
472
- {/* Step labels */}
466
<div className="flex justify-between text-xs text-text-muted">
467
{bpsSteps.map((step, idx) => (
468
<span
@@ -484,7 +477,6 @@ export function ServerCard({
477
</span>
478
))}
479
</div>
487
- {/* Manual input */}
480
<div>
481
<label className="text-xs text-text-muted mb-1 block">
482
Custom value (B/s)
@@ -515,6 +507,6 @@ export function ServerCard({
507
</DialogFooter>
508
</DialogContent>
509
</Dialog>
518
- </Wrapper>
510
+ </>
511
);
512
}
cmd/relay-server/frontend/src/components/ServerListView.tsx
+40
-44
@@ -39,15 +39,24 @@ interface ServerListViewProps {
39
banFilter?: BanFilter;
40
approvalMode?: ApprovalMode;
41
onBanFilterChange?: (value: BanFilter) => void;
42
- onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
43
- onBPSChange?: (leaseId: string, bps: number) => void;
42
+ onBanStatusChange?: (
43
+ leaseId: string,
44
+ isBan: boolean
45
+ ) => void | Promise<void>;
46
+ onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
47
onApprovalModeChange?: (mode: ApprovalMode) => void;
45
- onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
46
- onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
47
- onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
48
- onBulkApprove?: (leaseIds: string[]) => void;
49
- onBulkDeny?: (leaseIds: string[]) => void;
50
- onBulkBan?: (leaseIds: string[]) => void;
48
+ onApproveStatusChange?: (
49
+ leaseId: string,
50
+ approve: boolean
51
+ ) => void | Promise<void>;
52
+ onDenyStatusChange?: (
53
+ leaseId: string,
54
+ deny: boolean
55
+ ) => void | Promise<void>;
56
+ onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
57
+ onBulkApprove?: (leaseIds: string[]) => void | Promise<void>;
58
+ onBulkDeny?: (leaseIds: string[]) => void | Promise<void>;
59
+ onBulkBan?: (leaseIds: string[]) => void | Promise<void>;
60
onLogout?: () => void;
61
}
62
@@ -59,20 +68,6 @@ function toAdminServer(server: ListServer): AdminServer | undefined {
68
return isAdminServer(server) ? server : undefined;
69
}
70
62
-function wrapAdminHandler<Args extends unknown[]>(
63
- handler?: (...args: Args) => void | Promise<void>
64
-): ((...args: Args) => void) | undefined {
65
- if (!handler) {
66
- return undefined;
67
- }
68
-
69
- return (...args: Args) => {
70
- void Promise.resolve(handler(...args)).catch((error) => {
71
- console.error("Failed admin action", error);
72
- });
73
- };
74
-}
75
-
71
export function ServerListView({
72
title = "PORTAL",
73
searchQuery,
@@ -189,29 +184,30 @@ export function ServerListView({
184
}
185
};
186
192
- const runBulkAction = (handler?: (leaseIds: string[]) => void) => {
187
+ const runBulkAction = async (
188
+ handler?: (leaseIds: string[]) => void | Promise<void>
189
+ ) => {
190
if (!handler || selectedLeaseIds.size === 0) {
191
return;
192
}
193
197
- void Promise.resolve(handler(Array.from(selectedLeaseIds)))
198
- .then(() => {
199
- handleClearSelection();
200
- })
201
- .catch((err) => {
202
- console.error("Failed bulk admin action", err);
203
- });
194
+ try {
195
+ await handler(Array.from(selectedLeaseIds));
196
+ handleClearSelection();
197
+ } catch (err) {
198
+ console.error("Failed bulk admin action", err);
199
+ }
200
};
201
206
- const handleBulkApprove = () => runBulkAction(onBulkApprove);
207
- const handleBulkDeny = () => runBulkAction(onBulkDeny);
208
- const handleBulkBan = () => runBulkAction(onBulkBan);
209
-
210
- const handleCardBanStatusChange = wrapAdminHandler(onBanStatusChange);
211
- const handleCardBPSChange = wrapAdminHandler(onBPSChange);
212
- const handleCardApproveStatusChange = wrapAdminHandler(onApproveStatusChange);
213
- const handleCardDenyStatusChange = wrapAdminHandler(onDenyStatusChange);
214
- const handleCardIPBanStatusChange = wrapAdminHandler(onIPBanStatusChange);
202
+ const handleBulkApprove = () => {
203
+ void runBulkAction(onBulkApprove);
204
+ };
205
+ const handleBulkDeny = () => {
206
+ void runBulkAction(onBulkDeny);
207
+ };
208
+ const handleBulkBan = () => {
209
+ void runBulkAction(onBulkBan);
210
+ };
211
212
const adminFilterControls = (
213
<>
@@ -321,11 +317,11 @@ export function ServerListView({
317
bps={adminServer?.bps}
318
ip={adminServer?.ip}
319
isIPBanned={adminServer?.isIPBanned}
324
- onBanStatusChange={handleCardBanStatusChange}
325
- onBPSChange={handleCardBPSChange}
326
- onApproveStatusChange={handleCardApproveStatusChange}
327
- onDenyStatusChange={handleCardDenyStatusChange}
328
- onIPBanStatusChange={handleCardIPBanStatusChange}
320
+ onBanStatusChange={onBanStatusChange}
321
+ onBPSChange={onBPSChange}
322
+ onApproveStatusChange={onApproveStatusChange}
323
+ onDenyStatusChange={onDenyStatusChange}
324
+ onIPBanStatusChange={onIPBanStatusChange}
325
isSelected={isSelected}
326
onToggleSelect={handleToggleSelect}
327
/>
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+61
-18
@@ -1,4 +1,4 @@
1
-import { useCallback, useEffect, useMemo, useState } from "react";
1
+import { useEffect, useMemo, useState } from "react";
2
import type { ServerData } from "@/hooks/useSSRData";
3
import { useList, type BaseServer } from "@/hooks/useList";
4
import type { BanFilter } from "@/components/ServerListView";
@@ -116,6 +116,30 @@ function dedupeStrings(values: string[]): string[] {
116
return output;
117
}
118
119
+interface AdminSnapshot {
120
+ serverData: ServerData[];
121
+ bannedLeases: string[];
122
+ approvalMode: ApprovalMode;
123
+}
124
+
125
+async function loadAdminSnapshot(): Promise<AdminSnapshot> {
126
+ const [leasesData, bannedData, settings] = await Promise.all([
127
+ apiClient.get<ServerData[]>(API_PATHS.admin.leases),
128
+ apiClient.get<string[]>(API_PATHS.admin.bannedLeases),
129
+ apiClient.get<SettingsResponse>(API_PATHS.admin.approvalMode),
130
+ ]);
131
+
132
+ const normalizedBans = (Array.isArray(bannedData) ? bannedData : []).filter(
133
+ (leaseID): leaseID is string => typeof leaseID === "string"
134
+ );
135
+
136
+ return {
137
+ serverData: Array.isArray(leasesData) ? leasesData : [],
138
+ bannedLeases: dedupeStrings(normalizedBans),
139
+ approvalMode: normalizeApprovalMode(settings?.approval_mode),
140
+ };
141
+}
142
+
143
export function useAdmin() {
144
const [serverData, setServerData] = useState<ServerData[]>([]);
145
const [bannedLeases, setBannedLeases] = useState<string[]>([]);
@@ -125,34 +149,53 @@ export function useAdmin() {
149
150
const [banFilter, setBanFilter] = useState<BanFilter>("all");
151
128
- const fetchData = useCallback(async () => {
152
+ const applySnapshot = (snapshot: AdminSnapshot) => {
153
+ setServerData(snapshot.serverData);
154
+ setBannedLeases(snapshot.bannedLeases);
155
+ setApprovalMode(snapshot.approvalMode);
156
+ };
157
+
158
+ const fetchData = async () => {
159
setError("");
160
setLoading(true);
161
162
try {
133
- const [leasesData, bannedData, settings] = await Promise.all([
134
- apiClient.get<ServerData[]>(API_PATHS.admin.leases),
135
- apiClient.get<string[]>(API_PATHS.admin.bannedLeases),
136
- apiClient.get<SettingsResponse>(API_PATHS.admin.approvalMode),
137
- ]);
138
-
139
- const normalizedBans = (Array.isArray(bannedData) ? bannedData : []).filter(
140
- (leaseID): leaseID is string => typeof leaseID === "string"
141
- );
142
-
143
- setServerData(Array.isArray(leasesData) ? leasesData : []);
144
- setBannedLeases(dedupeStrings(normalizedBans));
145
- setApprovalMode(normalizeApprovalMode(settings?.approval_mode));
163
+ applySnapshot(await loadAdminSnapshot());
164
} catch (err: unknown) {
165
setError(toAdminErrorMessage(err, "Failed to load admin data"));
166
} finally {
167
setLoading(false);
168
}
151
- }, []);
169
+ };
170
171
useEffect(() => {
154
- fetchData();
155
- }, [fetchData]);
172
+ let mounted = true;
173
+ const loadInitialData = async () => {
174
+ setError("");
175
+ setLoading(true);
176
+ try {
177
+ const snapshot = await loadAdminSnapshot();
178
+ if (!mounted) {
179
+ return;
180
+ }
181
+ applySnapshot(snapshot);
182
+ } catch (err: unknown) {
183
+ if (!mounted) {
184
+ return;
185
+ }
186
+ setError(toAdminErrorMessage(err, "Failed to load admin data"));
187
+ } finally {
188
+ if (mounted) {
189
+ setLoading(false);
190
+ }
191
+ }
192
+ };
193
+
194
+ void loadInitialData();
195
+ return () => {
196
+ mounted = false;
197
+ };
198
+ }, []);
199
200
const bannedLeaseSet = useMemo(
201
() => new Set(bannedLeases),
docs/adr/0003-security-and-anti-abuse-hardening.md
+2
-1
@@ -15,7 +15,8 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
15
- Enforce lease-token validation before bridging reverse connections.
16
- Keep root-domain and tenant-subdomain traffic split through SNI routing rules to prevent accidental cross-path handling.
17
- Standardize SDK endpoint handling: `/sdk/register` (and related SDK APIs) and `/sdk/connect` validation failures return JSON envelopes (`{ ok, error }`) with explicit error codes prior to connection hijack, and `/sdk/connect` remains subject to `ReverseHub` authorization before pooling.
18
-- Allow trusted-proxy forwarded HTTPS as a secure `/sdk/connect` transport signal only when the peer is in the configured trusted-proxy CIDR allowlist.
18
+- Require lease-bound client mTLS identity on `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` with deterministic admission order `IP -> Lease -> CertBind -> Token`.
19
+- Enforce hard-break behavior for control-plane identity: non-mTLS clients fail admission deterministically and there is no token-only fallback mode.
20
- Enforce installer binary integrity with mandatory SHA256 sidecar verification (`${BIN_URL}.sha256`) and fail-closed behavior on verification errors.
21
22
Operator setup remains unchanged: no new relay flags/env vars are introduced for anti-abuse behavior.
docs/architecture.md
+25
-8
@@ -14,6 +14,16 @@ Client (Browser)
14
-> Local service (App/Tunnel host)
15
```
16
17
+## Connection Responsibilities
18
+
19
+- Conn #1 (`browser -> app`) is the tenant-facing data plane.
20
+ - Data-plane TLS behavior remains end-to-end between client and app/tunnel host.
21
+ - Relay forwards tenant traffic and does not replace app identity policy.
22
+- Conn #2 (`relay -> tunnel`) is the control plane.
23
+ - `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
24
+ - Control-plane admission order is strict and deterministic: `IP -> Lease -> CertBind -> Token`.
25
+ - Legacy non-mTLS clients are rejected at admission (hard-break behavior).
26
+
27
## Core Components
28
29
### Relay Server (`cmd/relay-server`)
@@ -52,7 +62,7 @@ Anti-abuse policy is driven from admin-managed state and applied consistently fo
62
3. Clients connect via HTTPS to relay SNI port.
63
4. Relay resolves route by SNI and acquires a reverse connection from `ReverseHub`.
64
5. Tunnel-side listener performs TLS handshake (keyless-backed signer), while relay forwards raw TCP transparently.
55
-6. No websocket or legacy compatibility transport is used.
65
+6. No alternate control-plane transport is supported; relay/tunnel transport stays raw TCP reverse-connect only.
66
67
Result: the relay handles SNI-based routing and transparent raw TCP forwarding, preserving end-to-end TLS where applicable.
68
@@ -68,27 +78,27 @@ Result: the relay handles SNI-based routing and transparent raw TCP forwarding,
78
- `reverse_token`
79
- Relay stores lease and (TLS only) registers SNI route.
80
- Route hostnames are generated from normalized lease + normalized `PORTAL_URL` host (extract host from URL without scheme/port/path); path segments are ignored, so `https://portal.example.com:8443/admin` and `https://portal.example.com` both map to `portal.example.com`.
71
-- `/sdk/register` and `/sdk/connect` both apply the admin policy gate path before a tunnel is allowed to stay active.
81
+- `/sdk/register` admission uses strict order: `IP -> Lease -> CertBind -> Token`.
82
83
### 2. Reverse Connect
84
85
- Backend opens a raw TCP reverse connection to `GET /sdk/connect` and streams traffic over that long-lived connection
76
- - `/sdk/connect` first validates secure transport + lease/token/IP policy and rejects invalid attempts with HTTP status plus JSON envelope errors before hijacking:
77
- - `tls_required` (`426`), `missing_lease_id` (`400`), `missing_reverse_token` (`401`), `unsupported_transport` (`400`), `ip_banned` (`403`), `lease_not_found` (`404`), `unauthorized` (`401`)
78
- - Secure transport is accepted when either direct TLS is present, or forwarded HTTPS headers come from an allowlisted trusted proxy.
86
+ - `/sdk/connect` requires lease-bound client mTLS and applies strict admission order before hijacking:
87
+ - `IP -> Lease -> CertBind -> Token`
88
+ - Cert binding validates lease identity in client cert SAN/subject against request lease context.
89
- `X-Portal-Reverse-Token` is validated at HTTP precheck, then validated again in `ReverseHub` with centralized policy callbacks before the connection is pooled.
90
- Connection is pooled in `ReverseHub` only after token/IP checks pass.
91
92
### 3. Renew
93
94
- Backend sends `POST /sdk/renew` keepalive.
85
-- `/sdk/renew` requires both `lease_id` and `reverse_token`.
95
+- `/sdk/renew` requires both lease-bound mTLS identity and `reverse_token`.
96
- Relay refreshes lease TTL and keeps route state current.
97
98
### 4. Unregister
99
100
- Backend sends `POST /sdk/unregister`.
91
-- `/sdk/unregister` validates normalized `lease_id` before deletion.
101
+- `/sdk/unregister` validates normalized `lease_id`, lease-bound mTLS identity, and token before deletion.
102
- Relay removes lease, route, and reverse pool.
103
104
## Admin Lease ID Contract
@@ -121,10 +131,17 @@ Note: wildcard does not match the portal root host itself (`example.com` or `por
131
- Reverse-only backend connectivity (no inbound port required on the app host)
132
- Per-lease reverse token authorization
133
- Separation of control plane (`/sdk/*`) and data plane (SNI + raw TCP forwarding)
124
-- Single transport policy: raw TCP reverse-connect only (no websocket/legacy compatibility mode)
134
+- Single relay/tunnel transport policy: raw TCP reverse-connect only
135
+- Mandatory control-plane identity policy: lease-bound mTLS with deterministic admission order
136
- Unified lease abstraction for routing, metadata, and lifecycle
137
- Shared anti-abuse path: admin-managed bans and lease authorization are enforced both in SDK registration and reverse admission
138
139
+## Breaking-Change Upgrade Expectations
140
+
141
+- This architecture wave is a hard-break for control-plane identity.
142
+- Tunnels/SDK clients that do not present valid lease-bound mTLS identity are rejected deterministically.
143
+- There is no token-only fallback mode after cutover.
144
+
145
## ADRs
146
147
- Decision records: [docs/adr/README.md](./adr/README.md)
docs/deployment.md
+8
-2
@@ -48,8 +48,14 @@ If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.exampl
48
49
Portal normalizes `PORTAL_URL` to its host for routing, so public service hosts become `<lease>.portal.example.com`.
50
Requests to the exact `PORTAL_URL` host (for example, `portal.example.com`) are not wildcard-matched; the router uses no-route fallback and forwards them to the admin/API listener.
51
-Backend registration and reverse traffic are raw TCP on `/sdk/connect`.
52
-This build does not include websocket transport compatibility.
51
+Relay/tunnel traffic for reverse admission stays raw TCP on `/sdk/connect`.
52
+
53
+### 2.4 Control-Plane Identity Requirements (Mandatory Upgrade)
54
+
55
+- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` require lease-bound client mTLS identity.
56
+- Control-plane admission order is fixed: `IP -> Lease -> CertBind -> Token`.
57
+- Clients without valid lease-bound mTLS identity are rejected; there is no token-only runtime fallback.
58
+- Identity material must be stored under `KEYLESS_DIR` with owner-only file permissions and encrypted-at-rest policy enabled in your environment.
59
60
### 2.3 Create Cloudflare API Token
61
docs/glossary.md
+12
-1
@@ -6,7 +6,7 @@ Key terms used in Portal.
6
7
The central server that handles lease registration, routing, and reverse-connection brokering.
8
In TLS passthrough mode, it routes transport and does not terminate app payload TLS.
9
-All backend-to-relay ingress uses a long-lived raw TCP reverse-connect channel (`/sdk/connect`); websocket compatibility transport is not supported.
9
+All backend-to-relay ingress uses a long-lived raw TCP reverse-connect channel (`/sdk/connect`).
10
11
## App (Service Publisher)
12
@@ -17,6 +17,16 @@ An app publishes one or more leases and serves traffic from local services.
17
18
A browser or external caller that accesses a published service through relay-managed domains.
19
20
+## Conn #1 (Data Plane)
21
+
22
+Tenant-facing traffic path between browser/client and app/tunnel endpoint.
23
+This connection keeps existing data-plane TLS behavior.
24
+
25
+## Conn #2 (Control Plane)
26
+
27
+Relay-to-tunnel control path used by `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister`.
28
+This connection requires lease-bound client mTLS identity with admission order `IP -> Lease -> CertBind -> Token`.
29
+
30
## Tunnel
31
32
The CLI publisher path (`cmd/portal-tunnel`).
@@ -39,6 +49,7 @@ The human-readable identifier used for subdomain routing (for example, `myapp` -
49
## Reverse Token
50
51
A per-lease secret used to authenticate reverse connections (`/sdk/connect`) from backend to relay.
52
+Token validation is a required admission stage, but only after lease-bound mTLS cert binding passes.
53
54
## ReverseHub
55
keyless_tls
new
+1
@@ -0,0 +1 @@
1
+Subproject commit 1d125c942539690d6ad32bd5e631e8ac06b9747d