feat: refactor relay discovery logic to support source base URL and simplify related functions
Kim committed
Apr 13, 2026 at 17:48 UTC
84cebbb4f4f583932bddf85a1e236e3eb8916c47
7 files changed
+258
-516
frontend/src/components/ServerListView.tsx
+24
-203
@@ -26,28 +26,18 @@ import {
26
export type BanFilter = "all" | "banned" | "active";
27
type ListServer = ClientServer | AdminServer;
28
29
-interface OfficialRegistryRelay {
30
- url: string;
31
- status: "online" | "disconnected" | "checking";
32
- releaseVersion?: string;
33
-}
34
-
29
interface RelayDomainResponse {
30
release_version?: string;
31
}
32
39
-interface OfficialRegistryDocument {
40
- relays?: string[];
41
-}
42
-
33
const OFFICIAL_REGISTRY_SOURCE_URL =
34
"https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json";
35
const REPOSITORY_URL = "https://github.com/gosuda/portal-tunnel";
36
47
-async function loadOfficialRegistryRelay(
37
+async function loadRelayReleaseVersion(
38
relayURL: string,
39
timeoutMs: number = 5000
50
-): Promise<OfficialRegistryRelay> {
40
+): Promise<string> {
41
const domainURL = new URL(API_PATHS.sdk.domain, relayURL).toString();
42
43
const timeoutPromise = new Promise<never>((_, reject) => {
@@ -59,77 +49,12 @@ async function loadOfficialRegistryRelay(
49
apiClient.get<RelayDomainResponse>(domainURL),
50
timeoutPromise,
51
]);
62
- return {
63
- url: relayURL,
64
- status: "online",
65
- releaseVersion:
66
- typeof domain?.release_version === "string"
67
- ? domain.release_version.trim()
68
- : "",
69
- };
52
+ return typeof domain?.release_version === "string"
53
+ ? domain.release_version.trim()
54
+ : "";
55
} catch {
71
- return {
72
- url: relayURL,
73
- status: "disconnected",
74
- releaseVersion: "",
75
- };
76
- }
77
-}
78
-
79
-async function loadOfficialRegistryRelayURLs(sourceURL: string): Promise<string[]> {
80
- const response = await fetch(sourceURL, {
81
- headers: { Accept: "application/json" },
82
- });
83
- if (!response.ok) {
84
- throw new Error(`registry request failed with status ${response.status}`);
85
- }
86
-
87
- const document = (await response.json()) as OfficialRegistryDocument;
88
- const relayURLs = Array.isArray(document.relays)
89
- ? document.relays.filter(
90
- (relay): relay is string =>
91
- typeof relay === "string" && relay.trim().length > 0
92
- )
93
- : [];
94
-
95
- return relayURLs.map((relayURL) => relayURL.trim());
96
-}
97
-
98
-function replaceOfficialRegistryRelay(
99
- currentRelays: OfficialRegistryRelay[] | null,
100
- nextRelay: OfficialRegistryRelay
101
-): OfficialRegistryRelay[] | null {
102
- if (!currentRelays) {
103
- return currentRelays;
104
- }
105
-
106
- return currentRelays.map((relay) =>
107
- relay.url === nextRelay.url ? nextRelay : relay
108
- );
109
-}
110
-
111
-async function retryDisconnectedRelays(
112
- currentRelays: OfficialRegistryRelay[]
113
-): Promise<OfficialRegistryRelay[]> {
114
- const disconnectedRelays = currentRelays.filter(
115
- (relay) => relay.status === "disconnected"
116
- );
117
-
118
- if (disconnectedRelays.length === 0) {
119
- return currentRelays;
56
+ return "";
57
}
121
-
122
- const retriedResults = await Promise.all(
123
- disconnectedRelays.map((relay) =>
124
- loadOfficialRegistryRelay(relay.url, 5000)
125
- )
126
- );
127
-
128
- const resultMap = new Map<string, OfficialRegistryRelay>();
129
- currentRelays.forEach((relay) => resultMap.set(relay.url, relay));
130
- retriedResults.forEach((relay) => resultMap.set(relay.url, relay));
131
-
132
- return Array.from(resultMap.values());
58
}
59
60
interface ServerListViewProps {
@@ -221,9 +146,8 @@ export function ServerListView({
146
onLogout,
147
}: ServerListViewProps) {
148
const [showFilterModal, setShowFilterModal] = useState(false);
224
- const [officialRegistryRelays, setOfficialRegistryRelays] = useState<
225
- OfficialRegistryRelay[] | null
226
- >(null);
149
+ const [connectedRelayReleaseVersion, setConnectedRelayReleaseVersion] =
150
+ useState("");
151
const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>(
152
new Set()
153
);
@@ -304,80 +228,27 @@ export function ServerListView({
228
if (isAdmin) {
229
return;
230
}
307
-
308
- let cancelled = false;
309
- setOfficialRegistryRelays(null);
310
-
311
- void loadOfficialRegistryRelayURLs(OFFICIAL_REGISTRY_SOURCE_URL)
312
- .then((relayURLs) => {
313
- if (!cancelled) {
314
- setOfficialRegistryRelays(
315
- relayURLs.map((relayURL) => ({
316
- url: relayURL,
317
- status: "checking",
318
- releaseVersion: "",
319
- }))
320
- );
321
-
322
- relayURLs.forEach((relayURL) => {
323
- void loadOfficialRegistryRelay(relayURL).then((relay) => {
324
- if (!cancelled) {
325
- setOfficialRegistryRelays((currentRelays) =>
326
- replaceOfficialRegistryRelay(currentRelays, relay)
327
- );
328
- }
329
- });
330
- });
331
- }
332
- })
333
- .catch((error) => {
334
- if (!cancelled) {
335
- console.error("Failed to load official registry", error);
336
- setOfficialRegistryRelays([]);
337
- }
338
- });
339
-
340
- return () => {
341
- cancelled = true;
342
- };
343
- }, [isAdmin]);
344
-
345
- useEffect(() => {
346
- if (isAdmin || !officialRegistryRelays) {
231
+ if (connectedRelayURL.trim().length === 0) {
232
+ setConnectedRelayReleaseVersion("");
233
return;
234
}
235
350
- const hasDisconnected = officialRegistryRelays.some(
351
- (relay) => relay.status === "disconnected"
352
- );
353
-
354
- if (!hasDisconnected) {
355
- return;
356
- }
236
+ let cancelled = false;
237
+ setConnectedRelayReleaseVersion("");
238
358
- const intervalId = setInterval(() => {
359
- void retryDisconnectedRelays(officialRegistryRelays)
360
- .then((updatedRelays) => {
361
- setOfficialRegistryRelays(updatedRelays);
362
- })
363
- .catch((error) => {
364
- console.error("Failed to retry disconnected relays", error);
365
- });
366
- }, 30000);
239
+ void loadRelayReleaseVersion(connectedRelayURL).then((releaseVersion) => {
240
+ if (!cancelled) {
241
+ setConnectedRelayReleaseVersion(releaseVersion);
242
+ }
243
+ });
244
245
return () => {
369
- clearInterval(intervalId);
246
+ cancelled = true;
247
};
371
- }, [isAdmin, officialRegistryRelays]);
372
-
373
- const officialRegistryList = officialRegistryRelays ?? [];
374
- const connectedOfficialRegistryRelay = officialRegistryList.find(
375
- (relay) => relay.url === connectedRelayURL
376
- );
248
+ }, [connectedRelayURL, isAdmin]);
249
const isAllSelected =
250
allIdentityKeys.length > 0 &&
251
allIdentityKeys.every((identityKey) => selectedIdentityKeys.has(identityKey));
380
- const officialRegistryAvailable = officialRegistryList.length > 0;
252
253
const handleSelectAll = () => {
254
if (isAllSelected) {
@@ -883,11 +754,9 @@ export function ServerListView({
754
<div className="mt-6 flex flex-col gap-6">
755
<div className="rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
756
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
886
- <div className="space-y-1">
887
- <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
888
- Official registry
889
- </p>
890
- </div>
757
+ <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
758
+ Connected relay
759
+ </p>
760
<a
761
href={OFFICIAL_REGISTRY_SOURCE_URL}
762
target="_blank"
@@ -897,54 +766,6 @@ export function ServerListView({
766
Open registry.json
767
</a>
768
</div>
900
- <div className="mt-5">
901
- {officialRegistryAvailable ? (
902
- <div className="grid gap-3 md:grid-cols-2">
903
- {officialRegistryList.map((relay) => {
904
- return (
905
- <div
906
- key={relay.url}
907
- className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
908
- >
909
- <a
910
- href={relay.url}
911
- target="_blank"
912
- rel="noopener noreferrer"
913
- className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
914
- >
915
- {relay.url}
916
- </a>
917
- <div className="flex shrink-0 flex-wrap items-center gap-2">
918
- {relay.status === "checking" ? (
919
- <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
920
- Checking
921
- </span>
922
- ) : relay.status === "disconnected" ? (
923
- <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
924
- Disconnected
925
- </span>
926
- ) : relay.releaseVersion ? (
927
- <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
928
- {relay.releaseVersion}
929
- </span>
930
- ) : null}
931
- </div>
932
- </div>
933
- );
934
- })}
935
- </div>
936
- ) : officialRegistryRelays !== null ? (
937
- <p className="text-sm text-text-muted">
938
- Registry entries are unavailable right now.
939
- </p>
940
- ) : null}
941
- </div>
942
- </div>
943
-
944
- <div className="rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
945
- <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
946
- Connected relay
947
- </p>
769
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
770
<a
771
href={connectedRelayURL}
@@ -958,9 +779,9 @@ export function ServerListView({
779
<span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
780
Connected
781
</span>
961
- {connectedOfficialRegistryRelay?.releaseVersion ? (
782
+ {connectedRelayReleaseVersion ? (
783
<span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
963
- {connectedOfficialRegistryRelay.releaseVersion}
784
+ {connectedRelayReleaseVersion}
785
</span>
786
) : null}
787
</div>
frontend/src/components/TunnelCommandForm.tsx
+184
-263
@@ -346,6 +346,7 @@ function FullTunnelCommandForm({
346
currentOrigin,
347
]);
348
const [discoveryEnabled, setDiscoveryEnabled] = useState(true);
349
+ const [showAdvanced, setShowAdvanced] = useState(false);
350
const [urlInput, setUrlInput] = useState("");
351
const [enableUDP, setEnableUDP] = useState(false);
352
const [udpPort, setUDPPort] = useState("");
@@ -366,6 +367,7 @@ function FullTunnelCommandForm({
367
const {
368
target,
369
setTarget,
370
+ name,
371
copied,
372
os,
373
setOs,
@@ -374,7 +376,6 @@ function FullTunnelCommandForm({
376
runBlock,
377
handleCopy,
378
handleNameChange,
377
- handleShuffleName,
379
} = useTunnelCommand({
380
relayUrls,
381
discovery: discoveryEnabled,
@@ -416,150 +417,109 @@ function FullTunnelCommandForm({
417
setRelayUrls((prev) => prev.slice(0, -1));
418
}
419
};
419
-
420
- const shuffleButtonClass = cn(
421
- "inline-flex h-12 shrink-0 items-center justify-center rounded-lg border px-3 text-xs font-semibold transition-colors",
422
- isTerminal
423
- ? "border-white/10 bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white"
424
- : "border-border bg-white text-text-muted hover:text-foreground"
425
- );
420
+ const sectionLabelClass = isTerminal
421
+ ? "text-sm font-medium text-slate-200"
422
+ : "text-sm font-medium text-foreground dark:text-zinc-100";
423
+ const helpTextClass = isTerminal
424
+ ? "text-xs text-slate-400"
425
+ : "text-xs leading-5 text-muted-foreground dark:text-zinc-500";
426
+ const inlineFieldClass = isTerminal
427
+ ? "flex items-center overflow-hidden rounded-xl border border-white/10 bg-white/5"
428
+ : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-[#272727]";
429
+ const inlinePrefixClass = isTerminal
430
+ ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-[0.12em] text-slate-400"
431
+ : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-[0.08em] text-text-muted dark:border-black/25 dark:text-zinc-400";
432
+ const inlineInputClass = isTerminal
433
+ ? "h-10 border-0 bg-transparent px-3 text-sm text-white placeholder:text-slate-500 shadow-none focus-visible:ring-0"
434
+ : "h-9 border-0 bg-transparent px-3 text-sm font-semibold text-foreground placeholder:text-text-muted shadow-none focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500";
435
+ const relayFieldClass = isTerminal
436
+ ? "flex min-h-12 flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-2.5 py-2"
437
+ : "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-border bg-background px-2 py-2 shadow-sm focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/25 dark:border-white/10 dark:bg-transparent";
438
+ const relayChipClass = isTerminal
439
+ ? "inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-xs font-medium text-slate-100"
440
+ : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-[#22312c] dark:text-[#dceee4]";
441
+ const relayChipRemoveClass = isTerminal
442
+ ? "hover:bg-white/10"
443
+ : "text-text-muted hover:bg-background/80 dark:text-[#cfe3d8] dark:hover:bg-black/20";
444
+ const advancedInputClass = isTerminal
445
+ ? "h-10 rounded-lg border-white/10 bg-white/5 text-white placeholder:text-slate-500"
446
+ : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-[#171717] dark:text-zinc-100 dark:placeholder:text-zinc-500";
447
+ const osGroupClass = isTerminal
448
+ ? "flex rounded-xl bg-white/8 p-1"
449
+ : "flex rounded-md bg-border p-1 dark:bg-[#272727]";
450
+ const osButtonClass = (selected: boolean) =>
451
+ cn(
452
+ "flex-1 rounded-sm px-3 py-2 text-sm font-semibold transition-colors",
453
+ isTerminal
454
+ ? selected
455
+ ? "bg-white/8 text-slate-200"
456
+ : "text-slate-400 hover:text-slate-300"
457
+ : selected
458
+ ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:ring-transparent"
459
+ : "text-muted-foreground hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
460
+ );
461
+ const commandPanelClass = isTerminal
462
+ ? "relative rounded-xl border border-white/10 bg-black/30"
463
+ : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-[#272727] dark:ring-transparent";
464
+ const commandPreClass = isTerminal
465
+ ? "overflow-x-auto whitespace-pre-wrap break-all p-4 pr-12 font-mono text-sm leading-7 text-white"
466
+ : "overflow-x-auto whitespace-pre-wrap break-all p-3 pr-12 font-mono text-sm leading-7 text-foreground dark:text-zinc-100";
467
468
return (
428
- <div className={cn("space-y-5", className)}>
469
+ <div className={cn("space-y-4 py-1", className)}>
470
<div className="space-y-2">
430
- <p
431
- className={cn(
432
- "text-sm leading-6",
433
- isTerminal ? "text-slate-300" : "text-text-muted"
434
- )}
435
- >
436
- Start your local app, then point Portal at it with a port like
437
- <span className={cn("mx-1 font-mono", isTerminal ? "text-white" : "text-foreground")}>
438
- 3000
439
- </span>
440
- or an address like
441
- <span className={cn("mx-1 font-mono", isTerminal ? "text-white" : "text-foreground")}>
442
- localhost:3000
443
- </span>
444
- .
445
- </p>
471
<label
472
htmlFor={`${inputId}-host`}
448
- className={cn(
449
- "text-sm font-medium",
450
- isTerminal ? "text-slate-200" : "text-foreground"
451
- )}
473
+ className={sectionLabelClass}
474
>
453
- Local App
475
+ Host
476
</label>
455
- <Input
456
- id={`${inputId}-host`}
457
- type="text"
458
- value={target}
459
- onChange={(event) => setTarget(event.target.value)}
460
- placeholder={DEFAULT_HOST}
461
- className={cn(
462
- "h-12 rounded-xl",
463
- isTerminal
464
- ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
465
- : "border-border bg-white"
466
- )}
467
- />
468
- <p
469
- className={cn(
470
- "text-xs",
471
- isTerminal ? "text-slate-400" : "text-muted-foreground"
472
- )}
473
- >
474
- Use a local port or address that is already running.
477
+ <div className={inlineFieldClass}>
478
+ <span className={inlinePrefixClass}>HOST=</span>
479
+ <Input
480
+ id={`${inputId}-host`}
481
+ type="text"
482
+ value={target}
483
+ onChange={(event) => setTarget(event.target.value)}
484
+ placeholder={DEFAULT_HOST}
485
+ className={inlineInputClass}
486
+ />
487
+ </div>
488
+ <p className={helpTextClass}>
489
+ The hostname or IP:Port where your service is running
490
</p>
491
</div>
492
493
<div className="space-y-2">
494
<label
495
htmlFor={`${inputId}-name`}
481
- className={cn(
482
- "text-sm font-medium",
483
- isTerminal ? "text-slate-200" : "text-foreground"
484
- )}
496
+ className={sectionLabelClass}
497
>
498
Service Name
499
</label>
488
- <div className="flex items-center gap-2">
500
+ <div className={inlineFieldClass}>
501
+ <span className={inlinePrefixClass}>NAME=</span>
502
<Input
503
id={`${inputId}-name`}
504
type="text"
505
+ value={name}
506
onChange={handleNameChange}
507
placeholder={generatedName}
494
- className={cn(
495
- "h-12 flex-1 rounded-xl",
496
- isTerminal
497
- ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
498
- : "border-border bg-white"
499
- )}
508
+ className={inlineInputClass}
509
/>
501
- <button
502
- type="button"
503
- onClick={handleShuffleName}
504
- className={shuffleButtonClass}
505
- aria-label="Shuffle public name"
506
- title="Shuffle public name"
507
- >
508
- <RefreshCw className="h-4 w-4" aria-hidden="true" />
509
- </button>
510
</div>
511
+ <p className={helpTextClass}>A unique identifier for your tunnel</p>
512
</div>
513
514
<div className="space-y-2">
514
- <label
515
- className={cn(
516
- "text-sm font-medium",
517
- isTerminal ? "text-slate-200" : "text-foreground"
518
- )}
519
- >
515
+ <label className={sectionLabelClass}>
516
Relay URLs
517
</label>
522
-
523
- <div className="flex items-center justify-between gap-3">
524
- <label
525
- className={cn(
526
- "ml-auto flex items-center gap-2 text-xs",
527
- isTerminal ? "text-slate-400" : "text-text-muted"
528
- )}
529
- >
530
- <input
531
- type="checkbox"
532
- checked={discoveryEnabled}
533
- onChange={(event) => {
534
- const nextEnabled = event.target.checked;
535
- setDiscoveryEnabled(nextEnabled);
536
- if (!nextEnabled && relayUrls.length === 0) {
537
- setRelayUrls([currentOrigin]);
538
- }
539
- }}
540
- className="h-4 w-4"
541
- />
542
- <span>Enable relay discovery</span>
543
- </label>
544
- </div>
545
-
546
- <div
547
- className={cn(
548
- "flex min-h-12 flex-wrap items-center gap-2 rounded-xl px-2.5 py-2",
549
- isTerminal
550
- ? "border border-white/10 bg-white/5"
551
- : "border border-border bg-white"
552
- )}
553
- >
518
+ <div className={relayFieldClass}>
519
{relayUrls.map((url) => (
520
<span
521
key={url}
557
- className={cn(
558
- "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium",
559
- isTerminal
560
- ? "bg-white/10 text-slate-100"
561
- : "bg-secondary text-secondary-foreground"
562
- )}
522
+ className={relayChipClass}
523
>
524
{url}
525
<button
@@ -569,9 +529,7 @@ function FullTunnelCommandForm({
529
"ml-1 rounded-sm p-0.5",
530
!discoveryEnabled && relayUrls.length <= 1
531
? "cursor-not-allowed opacity-40"
572
- : isTerminal
573
- ? "hover:bg-white/10"
574
- : "hover:bg-destructive/15"
532
+ : relayChipRemoveClass
533
)}
534
aria-label={`Remove ${url}`}
535
disabled={!discoveryEnabled && relayUrls.length <= 1}
@@ -588,150 +546,32 @@ function FullTunnelCommandForm({
546
onKeyDown={handleURLKeyDown}
547
placeholder="Add relay URL..."
548
className={cn(
591
- "min-w-35 flex-1 bg-transparent text-sm outline-none",
549
+ "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
550
isTerminal
551
? "text-white placeholder:text-slate-500"
594
- : "text-foreground placeholder:text-muted-foreground"
552
+ : "text-foreground placeholder:text-muted-foreground dark:text-zinc-100 dark:placeholder:text-zinc-500"
553
)}
554
/>
555
</div>
556
+ <p className={helpTextClass}>
557
+ Press Enter to add. Multiple relay servers for redundancy.
558
+ </p>
559
</div>
560
561
<div className="space-y-2">
601
- <label
602
- className={cn(
603
- "text-sm font-medium",
604
- isTerminal ? "text-slate-200" : "text-foreground"
605
- )}
606
- >
607
- UDP Transport
608
- </label>
609
- <label
610
- className={cn(
611
- "flex items-center gap-2 text-sm",
612
- isTerminal ? "text-slate-300" : "text-muted-foreground"
613
- )}
614
- >
615
- <input
616
- type="checkbox"
617
- checked={enableUDP}
618
- onChange={(event) => {
619
- const nextEnabled = event.target.checked;
620
- setEnableUDP(nextEnabled);
621
- if (!nextEnabled) {
622
- setUDPPort("");
623
- }
624
- }}
625
- className="h-4 w-4"
626
- />
627
- <span>Enable UDP transport</span>
628
- </label>
629
-
630
- {enableUDP && (
631
- <div className="space-y-1.5">
632
- <Input
633
- id={`${inputId}-udp-port`}
634
- type="text"
635
- value={udpPort}
636
- onChange={(event) => setUDPPort(event.target.value)}
637
- placeholder={target.trim() || DEFAULT_HOST}
638
- className={cn(
639
- "h-12 rounded-xl",
640
- isTerminal
641
- ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
642
- : "border-border bg-white"
643
- )}
644
- />
645
- <p
646
- className={cn(
647
- "text-xs",
648
- isTerminal ? "text-slate-400" : "text-muted-foreground"
649
- )}
650
- >
651
- Local UDP port to forward. Defaults to the same as Host.
652
- </p>
653
- </div>
654
- )}
655
- </div>
656
-
657
- <div className="space-y-2">
658
- <label
659
- htmlFor={`${inputId}-thumbnail`}
660
- className={cn(
661
- "text-sm font-medium",
662
- isTerminal ? "text-slate-200" : "text-foreground"
663
- )}
664
- >
665
- Thumbnail URL
666
- </label>
667
- <Input
668
- id={`${inputId}-thumbnail`}
669
- type="url"
670
- value={thumbnailURL}
671
- onChange={(event) => setThumbnailURL(event.target.value)}
672
- placeholder="https://cdn.example.com/thumb.png"
673
- className={cn(
674
- "h-12 rounded-xl",
675
- isTerminal
676
- ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
677
- : "border-border bg-white"
678
- )}
679
- />
680
- {normalizedThumbnailURL && (
681
- <div
682
- className={cn(
683
- "flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border",
684
- isTerminal
685
- ? "border-white/10 bg-white/5"
686
- : "border-border bg-background"
687
- )}
688
- >
689
- <img
690
- src={normalizedThumbnailURL}
691
- alt="Thumbnail preview"
692
- className="h-full w-full object-cover"
693
- />
694
- </div>
695
- )}
696
- {thumbnailError && <p className="text-xs text-destructive">{thumbnailError}</p>}
697
- </div>
698
-
699
- <div className="space-y-2">
700
- <div
701
- className={cn(
702
- "flex rounded-xl p-1",
703
- isTerminal ? "bg-white/8" : "bg-border"
704
- )}
705
- >
562
+ <label className={sectionLabelClass}>Operating System</label>
563
+ <div className={osGroupClass}>
564
<button
565
type="button"
566
onClick={() => setOs("unix")}
709
- className={cn(
710
- "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
711
- os === "unix"
712
- ? isTerminal
713
- ? "bg-white/8 text-slate-200"
714
- : "bg-background text-foreground/85"
715
- : isTerminal
716
- ? "text-slate-400 hover:text-slate-300"
717
- : "text-text-muted hover:text-foreground"
718
- )}
567
+ className={osButtonClass(os === "unix")}
568
>
569
Linux / macOS
570
</button>
571
<button
572
type="button"
573
onClick={() => setOs("windows")}
725
- className={cn(
726
- "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
727
- os === "windows"
728
- ? isTerminal
729
- ? "bg-white/8 text-slate-200"
730
- : "bg-background text-foreground/85"
731
- : isTerminal
732
- ? "text-slate-400 hover:text-slate-300"
733
- : "text-text-muted hover:text-foreground"
734
- )}
574
+ className={osButtonClass(os === "windows")}
575
>
576
Windows (PowerShell)
577
</button>
@@ -739,23 +579,11 @@ function FullTunnelCommandForm({
579
</div>
580
581
<div className="space-y-2">
742
- <label
743
- className={cn(
744
- "text-sm font-medium",
745
- isTerminal ? "text-slate-200" : "text-foreground"
746
- )}
747
- >
582
+ <label className={sectionLabelClass}>
583
Generated Command
584
</label>
750
- <div className="relative">
751
- <pre
752
- className={cn(
753
- "overflow-x-auto whitespace-pre-wrap break-all rounded-xl p-4 pr-12 font-mono text-sm leading-7",
754
- isTerminal
755
- ? "border border-white/10 bg-black/30 text-white"
756
- : "bg-border text-foreground"
757
- )}
758
- >
585
+ <div className={commandPanelClass}>
586
+ <pre className={commandPreClass}>
587
<span className="block">{installBlock}</span>
588
<span className="mt-2 block">{runBlock}</span>
589
</pre>
@@ -763,19 +591,112 @@ function FullTunnelCommandForm({
591
type="button"
592
onClick={handleCopy}
593
className={cn(
766
- "absolute right-2 top-2 rounded-md p-2 transition-colors",
767
- isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
594
+ "absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-2 transition-colors",
595
+ isTerminal ? "hover:bg-white/10" : "hover:bg-background/70 dark:hover:bg-black/20"
596
)}
597
aria-label="Copy command"
598
>
599
{copied ? (
772
- <Check className="h-4 w-4 text-green-600" />
600
+ <Check className={cn("h-4 w-4", isTerminal ? "text-green-400" : "text-emerald-400")} />
601
) : (
774
- <Copy className="h-4 w-4 text-text-muted" />
602
+ <Copy className={cn("h-4 w-4", isTerminal ? "text-slate-400" : "text-text-muted dark:text-zinc-500")} />
603
)}
604
</button>
605
</div>
606
</div>
607
+
608
+ {!isTerminal && (
609
+ <div className="pt-1">
610
+ <button
611
+ type="button"
612
+ onClick={() => setShowAdvanced((current) => !current)}
613
+ className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
614
+ >
615
+ {showAdvanced ? "Hide advanced options" : "Advanced options"}
616
+ </button>
617
+ {showAdvanced && (
618
+ <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-[#101010]">
619
+ <label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
620
+ <input
621
+ type="checkbox"
622
+ checked={discoveryEnabled}
623
+ onChange={(event) => {
624
+ const nextEnabled = event.target.checked;
625
+ setDiscoveryEnabled(nextEnabled);
626
+ if (!nextEnabled && relayUrls.length === 0) {
627
+ setRelayUrls([currentOrigin]);
628
+ }
629
+ }}
630
+ className="h-4 w-4"
631
+ />
632
+ <span>Enable relay discovery</span>
633
+ </label>
634
+
635
+ <div className="space-y-2">
636
+ <label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
637
+ <input
638
+ type="checkbox"
639
+ checked={enableUDP}
640
+ onChange={(event) => {
641
+ const nextEnabled = event.target.checked;
642
+ setEnableUDP(nextEnabled);
643
+ if (!nextEnabled) {
644
+ setUDPPort("");
645
+ }
646
+ }}
647
+ className="h-4 w-4"
648
+ />
649
+ <span>Enable UDP transport</span>
650
+ </label>
651
+ {enableUDP && (
652
+ <div className="space-y-1.5">
653
+ <Input
654
+ id={`${inputId}-udp-port`}
655
+ type="text"
656
+ value={udpPort}
657
+ onChange={(event) => setUDPPort(event.target.value)}
658
+ placeholder={target.trim() || DEFAULT_HOST}
659
+ className={advancedInputClass}
660
+ />
661
+ <p className={helpTextClass}>
662
+ Local UDP port to forward. Defaults to the same as Host.
663
+ </p>
664
+ </div>
665
+ )}
666
+ </div>
667
+
668
+ <div className="space-y-2">
669
+ <label
670
+ htmlFor={`${inputId}-thumbnail`}
671
+ className="text-sm font-medium text-foreground dark:text-zinc-300"
672
+ >
673
+ Thumbnail URL
674
+ </label>
675
+ <Input
676
+ id={`${inputId}-thumbnail`}
677
+ type="url"
678
+ value={thumbnailURL}
679
+ onChange={(event) => setThumbnailURL(event.target.value)}
680
+ placeholder="https://cdn.example.com/thumb.png"
681
+ className={advancedInputClass}
682
+ />
683
+ {normalizedThumbnailURL && (
684
+ <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-[#171717]">
685
+ <img
686
+ src={normalizedThumbnailURL}
687
+ alt="Thumbnail preview"
688
+ className="h-full w-full object-cover"
689
+ />
690
+ </div>
691
+ )}
692
+ {thumbnailError && (
693
+ <p className="text-xs text-destructive">{thumbnailError}</p>
694
+ )}
695
+ </div>
696
+ </div>
697
+ )}
698
+ </div>
699
+ )}
700
</div>
701
);
702
}
frontend/src/components/TunnelCommandModal.tsx
+11
-10
@@ -6,6 +6,7 @@ import {
6
DialogTitle,
7
DialogTrigger,
8
} from "@/components/ui/dialog";
9
+import { Terminal } from "lucide-react";
10
import { Button } from "@/components/ui/button";
11
import { TunnelCommandForm } from "@/components/TunnelCommandForm";
12
@@ -20,19 +21,19 @@ export function TunnelCommandModal() {
21
Add Your Server
22
</Button>
23
</DialogTrigger>
23
- <DialogContent className="sm:max-w-140 max-h-[85vh] overflow-y-auto rounded-3xl border border-border bg-card p-0">
24
- <DialogHeader className="border-b border-border px-5 py-4 text-left">
25
- <DialogTitle className="text-xl font-bold">Add Your Server</DialogTitle>
26
- <DialogDescription className="pt-1 leading-6">
27
- Start your local app, for example on
28
- <span className="mx-1 font-mono text-foreground">
29
- localhost:3000
30
- </span>
31
- , then copy and run the generated command.
24
+ <DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto rounded-sm border border-border bg-card p-0 text-card-foreground shadow-[0_24px_64px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:shadow-[0_28px_80px_rgba(0,0,0,0.5)] [&>button]:right-5 [&>button]:top-5 [&>button]:rounded-md [&>button]:text-text-muted [&>button]:ring-offset-card [&>button]:hover:bg-foreground/5 [&>button]:hover:text-foreground [&>button]:focus:ring-ring/30 [&>button]:data-[state=open]:bg-transparent [&>button]:data-[state=open]:text-text-muted dark:[&>button]:text-zinc-500 dark:[&>button]:ring-offset-[#0b0b0b] dark:[&>button]:hover:bg-white/5 dark:[&>button]:hover:text-zinc-300 dark:[&>button]:focus:ring-white/20 dark:[&>button]:data-[state=open]:text-zinc-500">
25
+ <DialogHeader className="px-6 pt-6 pb-4 text-left">
26
+ <DialogTitle className="flex items-center gap-2 text-[1.05rem] font-semibold tracking-tight text-foreground dark:text-zinc-100">
27
+ <Terminal className="h-4 w-4" />
28
+ Tunnel Setup Command
29
+ </DialogTitle>
30
+ <DialogDescription className="max-w-[34ch] pt-1 text-sm leading-6 text-muted-foreground dark:text-zinc-400">
31
+ Configure your tunnel settings and copy the command to start
32
+ exposing your local server.
33
</DialogDescription>
34
</DialogHeader>
35
35
- <div className="px-5 pb-5 pt-4">
36
+ <div className="px-6 pb-6">
37
<TunnelCommandForm />
38
</div>
39
</DialogContent>
portal/discovery/refresher.go
+31
-8
@@ -5,6 +5,7 @@ import (
5
"crypto/tls"
6
"crypto/x509"
7
"errors"
8
+ "net"
9
"net/http"
10
"net/url"
11
"time"
@@ -30,10 +31,11 @@ type Refresher struct {
31
relaySet *RelaySet
32
httpClient *http.Client
33
overlay OverlayRuntime
34
+ sourceBaseURL *url.URL
35
directRecoveryFailures int
36
}
37
36
-func NewRefresher(relaySet *RelaySet, rootCAPEM []byte, overlay OverlayRuntime) (*Refresher, error) {
38
+func NewRefresher(relaySet *RelaySet, rootCAPEM []byte, overlay OverlayRuntime, sourceBaseURL string) (*Refresher, error) {
39
if relaySet == nil {
40
return nil, errors.New("relay set is required")
41
}
@@ -44,6 +46,18 @@ func NewRefresher(relaySet *RelaySet, rootCAPEM []byte, overlay OverlayRuntime)
46
return nil, errors.New("failed to parse relay root ca")
47
}
48
}
49
+ var parsedSourceBaseURL *url.URL
50
+ if sourceBaseURL != "" {
51
+ normalizedSourceBaseURL, err := utils.NormalizeRelayURL(sourceBaseURL)
52
+ if err != nil {
53
+ return nil, err
54
+ }
55
+ parsed, err := url.Parse(normalizedSourceBaseURL)
56
+ if err != nil {
57
+ return nil, err
58
+ }
59
+ parsedSourceBaseURL = parsed
60
+ }
61
return &Refresher{
62
relaySet: relaySet,
63
httpClient: &http.Client{
@@ -58,11 +72,12 @@ func NewRefresher(relaySet *RelaySet, rootCAPEM []byte, overlay OverlayRuntime)
72
Timeout: defaultRequestTimeout,
73
},
74
overlay: overlay,
75
+ sourceBaseURL: parsedSourceBaseURL,
76
directRecoveryFailures: defaultRecoveryFailures,
77
}, nil
78
}
79
65
-func (r *Refresher) Refresh(ctx context.Context, extraSourceURLs ...string) error {
80
+func (r *Refresher) Refresh(ctx context.Context, extraSourceHosts ...string) error {
81
if r.overlay != nil {
82
if err := r.refreshOverlay(ctx); err != nil && ctx.Err() == nil {
83
log.Warn().
@@ -73,10 +88,10 @@ func (r *Refresher) Refresh(ctx context.Context, extraSourceURLs ...string) erro
88
return ctx.Err()
89
}
90
}
76
- return r.refreshHTTPS(ctx, extraSourceURLs)
91
+ return r.refreshHTTPS(ctx, extraSourceHosts)
92
}
93
79
-func (r *Refresher) refreshHTTPS(ctx context.Context, extraSourceURLs []string) error {
94
+func (r *Refresher) refreshHTTPS(ctx context.Context, extraSourceHosts []string) error {
95
r.relaySet.mu.RLock()
96
states := r.relaySet.relayStatesLocked()
97
r.relaySet.mu.RUnlock()
@@ -133,14 +148,22 @@ func (r *Refresher) refreshHTTPS(ctx context.Context, extraSourceURLs []string)
148
r.relaySet.RecordDiscoveryRTT(relayURL, time.Since(startedAt), measuredAt)
149
}
150
136
- for _, sourceURL := range extraSourceURLs {
137
- baseURL, err := url.Parse(sourceURL)
138
- if err != nil {
151
+ for _, sourceHost := range extraSourceHosts {
152
+ if r.sourceBaseURL == nil {
153
+ break
154
+ }
155
+ sourceHost = utils.NormalizeHostname(sourceHost)
156
+ if sourceHost == "" {
157
continue
158
}
159
+ baseURL := *r.sourceBaseURL
160
+ baseURL.Host = sourceHost
161
+ if port := r.sourceBaseURL.Port(); port != "" {
162
+ baseURL.Host = net.JoinHostPort(sourceHost, port)
163
+ }
164
165
var resp types.DiscoveryResponse
143
- if err := utils.HTTPDoAPIPath(ctx, r.httpClient, baseURL, http.MethodGet, types.PathDiscovery, nil, nil, &resp); err != nil {
166
+ if err := utils.HTTPDoAPIPath(ctx, r.httpClient, &baseURL, http.MethodGet, types.PathDiscovery, nil, nil, &resp); err != nil {
167
if ctx.Err() != nil {
168
return ctx.Err()
169
}
portal/lease.go
-29
@@ -4,8 +4,6 @@ import (
4
"context"
5
"errors"
6
"fmt"
7
- "net"
8
- "net/url"
7
"strings"
8
"sync"
9
"time"
@@ -342,33 +340,6 @@ func (r *leaseRegistry) AdminLeaseSnapshots(now time.Time) []types.AdminLease {
340
return snapshots
341
}
342
345
-func (r *leaseRegistry) DiscoverySourceURLs(portalURL string) []string {
346
- baseURL, err := url.Parse(portalURL)
347
- if err != nil {
348
- return nil
349
- }
350
-
351
- r.mu.RLock()
352
- defer r.mu.RUnlock()
353
-
354
- sources := make([]string, 0, len(r.leasesByKey))
355
- for _, record := range r.leasesByKey {
356
- snapshot, ok := r.snapshot(record, time.Now())
357
- if !ok {
358
- continue
359
- }
360
-
361
- sourceURL := *baseURL
362
- sourceURL.Host = snapshot.Hostname
363
- if port := baseURL.Port(); port != "" {
364
- sourceURL.Host = net.JoinHostPort(snapshot.Hostname, port)
365
- }
366
-
367
- sources = append(sources, sourceURL.String())
368
- }
369
- return sources
370
-}
371
-
343
func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
344
snapshot := types.Lease{
345
Name: record.Name,
portal/server.go
+7
-2
@@ -596,7 +596,7 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
596
<-ctx.Done()
597
return nil
598
}
599
- refresher, err := discovery.NewRefresher(s.relaySet, nil, s.overlay)
599
+ refresher, err := discovery.NewRefresher(s.relaySet, nil, s.overlay, s.PortalURL())
600
if err != nil {
601
return err
602
}
@@ -604,7 +604,12 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
604
defer ticker.Stop()
605
606
for {
607
- if err := refresher.Refresh(ctx, s.registry.DiscoverySourceURLs(s.PortalURL())...); err != nil {
607
+ snapshots := s.registry.LeaseSnapshots(time.Now())
608
+ sourceHosts := make([]string, 0, len(snapshots))
609
+ for _, snapshot := range snapshots {
610
+ sourceHosts = append(sourceHosts, snapshot.Hostname)
611
+ }
612
+ if err := refresher.Refresh(ctx, sourceHosts...); err != nil {
613
if ctx.Err() != nil {
614
return nil
615
}
sdk/expose.go
+1
-1
@@ -150,7 +150,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
150
}
151
152
func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
153
- refresher, err := discovery.NewRefresher(e.relaySet, e.rootCAPEM, nil)
153
+ refresher, err := discovery.NewRefresher(e.relaySet, e.rootCAPEM, nil, "")
154
if err != nil {
155
return
156
}