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