Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions cmd/relay-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,28 @@ func main() {
}

type relayServerConfig struct {
PortalURL string
APIPort int
SNIPort int
UDPPortCount int
LandingPageEnabled bool
Bootstraps string
DiscoveryEnabled bool
OwnerPrivateKey string
AdminSecretKey string
TrustProxyHeaders bool
TrustedProxyCIDRs string
KeylessDir string
AdminSettingsPath string
ACMEDNSProvider string
CloudflareToken string
AWSAccessKeyID string
AWSSecretAccessKey string
AWSSessionToken string
AWSRegion string
AWSHostedZoneID string
PortalURL string
APIPort int
SNIPort int
UDPPortCount int
LandingPageEnabled bool
Bootstraps string
DiscoveryEnabled bool
OwnerPrivateKey string
WireGuardPrivateKey string
DiscoveryPort int
AdminSecretKey string
TrustProxyHeaders bool
TrustedProxyCIDRs string
AdminSettingsPath string
KeylessDir string
ACMEDNSProvider string
CloudflareToken string
AWSAccessKeyID string
AWSSecretAccessKey string
AWSSessionToken string
AWSRegion string
AWSHostedZoneID string
}

func runServeCommand(args []string) error {
Expand All @@ -65,6 +67,8 @@ func runServeCommand(args []string) error {
utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
utils.StringFlagEnv(fs, &cfg.WireGuardPrivateKey, "wireguard-private-key", "", "wireguard private key for relay peer overlay", "WIREGUARD_PRIVATE_KEY")
utils.IntFlagEnv(fs, &cfg.DiscoveryPort, "discovery-port", 0, utils.ParsePortNumber, "public UDP listen port advertised for relay-peer discovery overlay (defaults to 51820 when wireguard is enabled)", "DISCOVERY_PORT")
utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
utils.StringFlagEnv(fs, &cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", "", "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled", "TRUSTED_PROXY_CIDRS")
Expand Down Expand Up @@ -96,6 +100,7 @@ func runServeCommand(args []string) error {
Str("admin_settings_path", cfg.AdminSettingsPath).
Bool("landing_page_enabled", cfg.LandingPageEnabled).
Bool("discovery_enabled", cfg.DiscoveryEnabled).
Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
Bool("udp_enabled", cfg.UDPPortCount > 0).
Msg("configured relay server")

Expand All @@ -112,9 +117,11 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
}

server, err := portal.NewServer(portal.ServerConfig{
PortalURL: cfg.PortalURL,
OwnerPrivateKey: cfg.OwnerPrivateKey,
Bootstraps: bootstraps,
PortalURL: cfg.PortalURL,
OwnerPrivateKey: cfg.OwnerPrivateKey,
Bootstraps: bootstraps,
WireGuardPrivateKey: cfg.WireGuardPrivateKey,
DiscoveryPort: cfg.DiscoveryPort,
ACME: acme.Config{
KeyDir: cfg.KeylessDir,
DNSProvider: cfg.ACMEDNSProvider,
Expand Down Expand Up @@ -152,6 +159,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
Str("root_host", rootHost).
Str("acme_dns_provider", cfg.ACMEDNSProvider).
Bool("discovery_enabled", server.DiscoveryEnabled()).
Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
Bool("udp_enabled", cfg.UDPPortCount > 0).
Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1")
if quicAddr := server.QUICTunnelAddr(); quicAddr != "" {
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
ports:
- "${API_PORT:-4017}:${API_PORT:-4017}"
- "${SNI_PORT:-443}:${SNI_PORT:-443}"
- "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
# Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
# - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
Expand All @@ -16,6 +17,8 @@ services:
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
BOOTSTRAPS: ${BOOTSTRAPS:-}
DISCOVERY: ${DISCOVERY:-true}
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}

# Listener ports (published to the host below)
API_PORT: ${API_PORT:-4017}
Expand Down
4 changes: 4 additions & 0 deletions docs/examples/nginx-proxy-multi-service/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ services:
# NAT-traversal relay server.
# TCP (4017, 4443) is reached by nginx via host.docker.internal.
# SNI_PORT is 4443 to avoid conflicting with nginx on 443.
# If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
# If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
portal:
image: ghcr.io/gosuda/portal:latest
container_name: portal
ports:
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
- "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
# Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
# - "443:${SNI_PORT:-4443}/udp"
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
Expand All @@ -68,6 +70,8 @@ services:
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
API_PORT: ${API_PORT:-4017}
SNI_PORT: ${SNI_PORT:-4443}
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
Expand Down
4 changes: 4 additions & 0 deletions docs/examples/nginx-proxy/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ services:
# NAT-traversal relay server.
# TCP (4017, 4443) is reached by nginx via 127.0.0.1.
# SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
# If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
# If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
portal:
image: ghcr.io/gosuda/portal:latest
container_name: portal
ports:
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
- "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
# Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
# - "443:${SNI_PORT:-4443}/udp"
# - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
Expand All @@ -62,6 +64,8 @@ services:
API_PORT: ${API_PORT:-4017}
# Use a non-443 port to avoid conflict with nginx on the host.
SNI_PORT: ${SNI_PORT:-4443}
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}

# UDP transport (0 = disabled, set count to enable).
UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/LandingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const heroFeatures = [
{
title: "End-to-end TLS",
description:
"Traffic is routed via SNI with keyless TLS, while TLS still terminates on your app.",
"End-to-end TLS via SNI routing, keyless TLS, and built-in MITM detection.",
},
{
title: "Permissionless hosting",
Expand Down
132 changes: 109 additions & 23 deletions frontend/src/components/ServerListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type ListServer = ClientServer | AdminServer;

interface OfficialRegistryRelay {
url: string;
status: "online" | "unreachable";
status: "online" | "disconnected" | "checking";
releaseVersion?: string;
}

Expand All @@ -44,12 +44,20 @@ const OFFICIAL_REGISTRY_SOURCE_URL =
const REPOSITORY_URL = "https://github.com/gosuda/portal";

async function loadOfficialRegistryRelay(
relayURL: string
relayURL: string,
timeoutMs: number = 5000
): Promise<OfficialRegistryRelay> {
const domainURL = new URL(API_PATHS.sdk.domain, relayURL).toString();

const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), timeoutMs);
});

try {
const domain = await apiClient.get<RelayDomainResponse>(domainURL);
const domain = await Promise.race([
apiClient.get<RelayDomainResponse>(domainURL),
timeoutPromise,
]);
return {
url: relayURL,
status: "online",
Expand All @@ -61,15 +69,13 @@ async function loadOfficialRegistryRelay(
} catch {
return {
url: relayURL,
status: "unreachable",
status: "disconnected",
releaseVersion: "",
};
}
}

async function loadOfficialRegistryRelays(
sourceURL: string
): Promise<OfficialRegistryRelay[]> {
async function loadOfficialRegistryRelayURLs(sourceURL: string): Promise<string[]> {
const response = await fetch(sourceURL, {
headers: { Accept: "application/json" },
});
Expand All @@ -85,11 +91,46 @@ async function loadOfficialRegistryRelays(
)
: [];

return Promise.all(
relayURLs.map((relayURL) => loadOfficialRegistryRelay(relayURL.trim()))
return relayURLs.map((relayURL) => relayURL.trim());
}

function replaceOfficialRegistryRelay(
currentRelays: OfficialRegistryRelay[] | null,
nextRelay: OfficialRegistryRelay
): OfficialRegistryRelay[] | null {
if (!currentRelays) {
return currentRelays;
}

return currentRelays.map((relay) =>
relay.url === nextRelay.url ? nextRelay : relay
);
}

async function retryDisconnectedRelays(
currentRelays: OfficialRegistryRelay[]
): Promise<OfficialRegistryRelay[]> {
const disconnectedRelays = currentRelays.filter(
(relay) => relay.status === "disconnected"
);

if (disconnectedRelays.length === 0) {
return currentRelays;
}

const retriedResults = await Promise.all(
disconnectedRelays.map((relay) =>
loadOfficialRegistryRelay(relay.url, 5000)
)
);

const resultMap = new Map<string, OfficialRegistryRelay>();
currentRelays.forEach((relay) => resultMap.set(relay.url, relay));
retriedResults.forEach((relay) => resultMap.set(relay.url, relay));

return Array.from(resultMap.values());
}

interface ServerListViewProps {
title?: string;
searchQuery: string;
Expand Down Expand Up @@ -261,10 +302,26 @@ export function ServerListView({
let cancelled = false;
setOfficialRegistryRelays(null);

void loadOfficialRegistryRelays(OFFICIAL_REGISTRY_SOURCE_URL)
.then((relays) => {
void loadOfficialRegistryRelayURLs(OFFICIAL_REGISTRY_SOURCE_URL)
.then((relayURLs) => {
if (!cancelled) {
setOfficialRegistryRelays(relays);
setOfficialRegistryRelays(
relayURLs.map((relayURL) => ({
url: relayURL,
status: "checking",
releaseVersion: "",
}))
);

relayURLs.forEach((relayURL) => {
void loadOfficialRegistryRelay(relayURL).then((relay) => {
if (!cancelled) {
setOfficialRegistryRelays((currentRelays) =>
replaceOfficialRegistryRelay(currentRelays, relay)
);
}
});
});
}
})
.catch((error) => {
Expand All @@ -279,10 +336,39 @@ export function ServerListView({
};
}, [isAdmin]);

useEffect(() => {
if (isAdmin || !officialRegistryRelays) {
return;
}

const hasDisconnected = officialRegistryRelays.some(
(relay) => relay.status === "disconnected"
);

if (!hasDisconnected) {
return;
}

const intervalId = setInterval(() => {
void retryDisconnectedRelays(officialRegistryRelays)
.then((updatedRelays) => {
setOfficialRegistryRelays(updatedRelays);
})
.catch((error) => {
console.error("Failed to retry disconnected relays", error);
});
}, 30000);

return () => {
clearInterval(intervalId);
};
}, [isAdmin, officialRegistryRelays]);

const officialRegistryList = officialRegistryRelays ?? [];
const isAllSelected =
allLeaseIds.length > 0 &&
allLeaseIds.every((id) => selectedLeaseIds.has(id));
const officialRegistryAvailable = (officialRegistryRelays?.length ?? 0) > 0;
const officialRegistryAvailable = officialRegistryList.length > 0;

const handleSelectAll = () => {
if (isAllSelected) {
Expand Down Expand Up @@ -722,13 +808,9 @@ export function ServerListView({
</div>

<div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
{officialRegistryRelays === null ? (
<p className="text-sm text-text-muted">
Loading official registry...
</p>
) : officialRegistryAvailable ? (
{officialRegistryAvailable ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{officialRegistryRelays.map((relay) => {
{officialRegistryList.map((relay) => {
return (
<div
key={relay.url}
Expand All @@ -743,9 +825,13 @@ export function ServerListView({
{relay.url}
</a>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{relay.status === "unreachable" ? (
{relay.status === "checking" ? (
<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">
Checking
</span>
) : relay.status === "disconnected" ? (
<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">
Offline
Disconnected
</span>
) : relay.releaseVersion ? (
<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">
Expand All @@ -757,11 +843,11 @@ export function ServerListView({
);
})}
</div>
) : (
) : officialRegistryRelays !== null ? (
<p className="text-sm text-text-muted">
Registry entries are unavailable right now.
</p>
)}
) : null}
</div>
</section>
</main>
Expand Down
Loading
Loading