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 }