refactor(portal)!: finalize strict tunnel/sdk/admin contracts

cognitive committed Mar 4, 2026 at 05:35 UTC 80e4b3af79e4511496951cb766047e81cd25fe52
17 files changed +171 -322
cmd/portal-tunnel/README.md
+8 -34
@@ -17,26 +17,14 @@ You can run the tunnel using command-line flags or a configuration file.
17 --thumbnail https://example.com/thumb.png
18 ```
19
20 -### TLS Mode (End-to-End Encryption)
20 +### Transport Model
21
22 -Enable TLS for end-to-end encryption from client to your local service:
22 +Portal tunnel always runs in TLS reverse-connect mode:
23
24 -```bash
25 -./bin/portal-tunnel --host localhost:8080 \
26 - --relay https://portal.example.com \
27 - --name myapp \
28 - --tls
29 -```
30 -
31 -TLS options:
32 -- TLS disabled (default): plain TCP/HTTP proxying without TLS termination
33 -- TLS enabled (`--tls`): keyless TLS mode with auto-discovered certificate chain and remote signing
34 -- TLS is terminated at the tunnel, then proxied to your local service via TCP
35 -- Access via `https://myapp.example.com` directly on port 443
36 -
37 -**Requirements:**
38 -- TLS enabled: no local cert/key required in default mode (SDK auto-discovers signer certificate chain)
39 -- Keyless auto-discovery expects an HTTPS signer endpoint.
24 +- Reverse admission requires HTTPS relay endpoints.
25 +- Tunnel-side TLS uses keyless signing with auto-discovered signer materials.
26 +- Traffic is proxied from tunnel to local `--host` over TCP.
27 +- Public access is `https://<service>.<portal-root-host>/`.
28
29 ## Flags
30
@@ -48,7 +36,6 @@ Options:
36 --relay Portal relay server API URLs (comma-separated, https only) [default: https://localhost:4017] [env: RELAYS]
37 --host Target host to proxy to (host:port or URL) [env: APP_HOST]
38 --name Service name [env: APP_NAME]
51 - --tls Enable keyless TLS mode [env: TLS]
39 --description Service description metadata [env: APP_DESCRIPTION]
40 --tags Service tags metadata (comma-separated) [env: APP_TAGS]
41 --thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
@@ -75,24 +62,12 @@ Installer integrity policy:
62 - SHA256 verification is mandatory and fail-closed.
63 - Missing, malformed, or mismatched checksums abort startup with a remediation hint.
64
78 -### Production (TLS)
79 -
80 -```bash
81 -export RELAYS=https://portal.example.com
82 -export APP_HOST=localhost:3000
83 -export APP_NAME=myapp
84 -export TLS=1
85 -
86 -./bin/portal-tunnel
87 -```
88 -
89 -### Production (Keyless TLS)
65 +### Production
66
67 ```bash
68 export RELAYS=https://portal.example.com
69 export APP_HOST=localhost:3000
70 export APP_NAME=myapp
95 -export TLS=1
71
72 ./bin/portal-tunnel
73 ```
@@ -123,6 +98,5 @@ Expected signer API contract (`/v1/sign`):
98 ./bin/portal-tunnel \
99 --host localhost:3000 \
100 --name myapp \
126 - --relay https://portal1.example.com,https://portal2.example.com \
127 - --tls
101 + --relay https://portal1.example.com,https://portal2.example.com
102 ```
cmd/portal-tunnel/main.go
+10 -33
@@ -7,7 +7,6 @@ import (
7 "fmt"
8 "io"
9 "net"
10 - "net/url"
10 "os"
11 "os/signal"
12 "strings"
@@ -31,7 +30,6 @@ var (
30 flagThumbnail string
31 flagOwner string
32 flagHide bool
34 - flagTLS bool
33 )
34
35 func main() {
@@ -46,13 +44,6 @@ func main() {
44 flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
45 flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
46
49 - tlsEnv := strings.TrimSpace(os.Getenv("TLS"))
50 - defaultTLS := true
51 - if tlsEnv != "" {
52 - defaultTLS = strings.EqualFold(tlsEnv, "true") || tlsEnv == "1"
53 - }
54 - flag.BoolVar(&flagTLS, "tls", defaultTLS, "Enable TLS (keyless) [env: TLS]")
55 -
47 flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
48 flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
49 flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
@@ -76,10 +67,8 @@ func runTunnel() error {
67 if len(relayURLs) == 0 {
68 return errors.New("no relay URLs provided")
69 }
79 - if !flagTLS {
80 - return errors.New("reverse connect architecture requires TLS; set --tls=true")
81 - }
82 - if err := validateRelayURLsForReverseConnect(relayURLs); err != nil {
70 + relayURLs, err := normalizeRelayURLsForReverseConnect(relayURLs)
71 + if err != nil {
72 return err
73 }
74
@@ -87,14 +76,12 @@ func runTunnel() error {
76 log.Info().Msg("Starting Portal Tunnel...")
77 log.Info().Msgf(" Local: %s", flagHost)
78 log.Info().Msgf(" Relays: %s", strings.Join(relayURLs, ", "))
90 - log.Info().Msgf(" TLS: %t", flagTLS)
79
80 opts := []sdk.ClientOption{sdk.WithBootstrapServers(relayURLs)}
81 sdkClient, err := sdk.NewClient(opts...)
82 if err != nil {
83 return fmt.Errorf("service %s: failed to create client: %w", flagName, err)
84 }
97 - defer sdkClient.Close()
85
86 listener, err := sdkClient.Listen(
87 flagName,
@@ -120,10 +107,6 @@ func runTunnel() error {
107 if leaseAware, ok := listener.(interface{ LeaseID() string }); ok {
108 log.Info().Msgf("- Lease ID: %s", leaseAware.LeaseID())
109 }
123 - if flagTLS {
124 - log.Info().Msg("- TLS: Enabled")
125 - }
126 -
110 log.Info().Str("service", flagName).Msg("")
111
112 connCount := 0
@@ -159,15 +142,10 @@ loop:
142 connWG.Add(1)
143 go func(relayConn net.Conn) {
144 defer connWG.Done()
162 - tlsEnabled := flagTLS
163 - proxyType := "TCP"
164 - if tlsEnabled {
165 - proxyType = "TLS→TCP"
166 - }
145 if err := proxyConnection(ctx, flagHost, relayConn); err != nil {
168 - log.Error().Str("proxy", proxyType).Err(err).Msg("Proxy error")
146 + log.Error().Str("proxy", "TLS→TCP").Err(err).Msg("Proxy error")
147 }
170 - log.Info().Str("proxy", proxyType).Msg("Connection closed")
148 + log.Info().Str("proxy", "TLS→TCP").Msg("Connection closed")
149 }(relayConn)
150 }
151
@@ -187,17 +165,16 @@ loop:
165 return nil
166 }
167
190 -func validateRelayURLsForReverseConnect(relayURLs []string) error {
168 +func normalizeRelayURLsForReverseConnect(relayURLs []string) ([]string, error) {
169 + normalized := make([]string, 0, len(relayURLs))
170 for _, relayURL := range relayURLs {
192 - parsedURL, err := url.Parse(relayURL)
171 + normalizedURL, err := types.NormalizeRelayAPIURL(relayURL)
172 if err != nil {
194 - return fmt.Errorf("invalid relay URL %q: %w", relayURL, err)
195 - }
196 - if !strings.EqualFold(parsedURL.Scheme, "https") {
197 - return fmt.Errorf("reverse connect requires https relay URLs, got %q", relayURL)
173 + return nil, fmt.Errorf("invalid relay URL %q: %w", relayURL, err)
174 }
175 + normalized = append(normalized, normalizedURL)
176 }
200 - return nil
177 + return normalized, nil
178 }
179
180 var bufferPool = sync.Pool{
cmd/relay-server/frontend/src/hooks/useAdmin.test.ts
+6 -12
@@ -87,16 +87,13 @@ describe("useAdmin", () => {
87 }
88 if (path === API_PATHS.admin.bannedLeases) {
89 return [
90 - " peer-a ",
90 + "peer-a",
91 "peer-a",
92 "peer-b",
93 ] as never;
94 }
95 - if (path === API_PATHS.admin.settings) {
96 - return { approval_mode: "not-a-mode" } as never;
97 - }
95 if (path === API_PATHS.admin.approvalMode) {
99 - return { approval_mode: "manual" } as never;
96 + return { approval_mode: "not-a-mode" } as never;
97 }
98 throw new Error(`Unexpected GET path: ${path}`);
99 });
@@ -124,9 +121,6 @@ describe("useAdmin", () => {
121 if (path === API_PATHS.admin.bannedLeases) {
122 return [] as never;
123 }
127 - if (path === API_PATHS.admin.settings) {
128 - return { approval_mode: "manual" } as never;
129 - }
124 if (path === API_PATHS.admin.approvalMode) {
125 return { approval_mode: "manual" } as never;
126 }
@@ -175,13 +169,13 @@ describe("useAdmin", () => {
169 });
170 });
171
178 - it("keeps plain lease IDs stable when building action targets", async () => {
172 + it("encodes peer IDs for action routes", async () => {
173 const { result } = renderHook(() => useAdmin());
174 await waitForLoaded(result);
175 const plainLeaseID = "deadbeefcafebabe";
176
177 await act(async () => {
184 - await result.current.handleApproveStatus(` ${plainLeaseID} `, true);
178 + await result.current.handleApproveStatus(plainLeaseID, true);
179 });
180
181 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
@@ -190,7 +184,7 @@ describe("useAdmin", () => {
184 );
185 });
186
193 - it("bulk deny posts normalized, deduped lease IDs", async () => {
187 + it("bulk deny posts deduped lease IDs to action routes", async () => {
188 const { result } = renderHook(() => useAdmin());
189 await waitForLoaded(result);
190 const normalizedPeerA = encodeLeaseID("peer-a");
@@ -198,7 +192,7 @@ describe("useAdmin", () => {
192
193 await act(async () => {
194 await result.current.handleBulkDeny([
201 - " peer-a ",
195 + "peer-a",
196 "peer-a",
197 "peer-b",
198 ]);
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+13 -81
@@ -1,5 +1,5 @@
1 import { useCallback, useEffect, useMemo, useState } from "react";
2 -import type { ServerData, Metadata } from "@/hooks/useSSRData";
2 +import type { ServerData } from "@/hooks/useSSRData";
3 import { useList, type BaseServer } from "@/hooks/useList";
4 import type { BanFilter } from "@/components/ServerListView";
5 import {
@@ -9,6 +9,7 @@ import {
9 encodeLeaseID,
10 } from "@/lib/apiPaths";
11 import { APIClientError, apiClient } from "@/lib/apiClient";
12 +import { parseLeaseMetadata } from "@/lib/metadata";
13
14 export type ApprovalMode = "auto" | "manual";
15
@@ -67,64 +68,11 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
68 return fallback;
69 }
70
70 -function normalizeLeaseID(raw: string): string {
71 - return raw.trim();
72 -}
73 -
71 function encodeLeaseIDForPath(raw: string): string {
75 - const leaseID = normalizeLeaseID(raw);
76 - if (!leaseID) {
72 + if (!raw) {
73 throw new Error("Missing lease ID");
74 }
79 - return encodeLeaseID(leaseID);
80 -}
81 -
82 -function sanitizeMetadata(row: ServerData): Metadata {
83 - const isRecord = (value: unknown): value is Record<string, unknown> => {
84 - return (
85 - typeof value === "object" &&
86 - value !== null &&
87 - !Array.isArray(value)
88 - );
89 - };
90 -
91 - const fallback: Metadata = {
92 - description: "",
93 - tags: [],
94 - thumbnail: "",
95 - owner: "",
96 - hide: false,
97 - };
98 -
99 - if (!row.Metadata) {
100 - return fallback;
101 - }
102 -
103 - try {
104 - const parsed = JSON.parse(row.Metadata);
105 - if (!isRecord(parsed)) {
106 - return fallback;
107 - }
108 -
109 - const rawTags = parsed.tags;
110 - const tags = Array.isArray(rawTags)
111 - ? rawTags
112 - .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
113 - .filter(Boolean)
114 - : [];
115 -
116 - return {
117 - description:
118 - typeof parsed.description === "string" ? parsed.description : "",
119 - tags,
120 - thumbnail:
121 - typeof parsed.thumbnail === "string" ? parsed.thumbnail : "",
122 - owner: typeof parsed.owner === "string" ? parsed.owner : "",
123 - hide: typeof parsed.hide === "boolean" ? parsed.hide : false,
124 - };
125 - } catch {
126 - return fallback;
127 - }
75 + return encodeLeaseID(raw);
76 }
77
78 function toAdminServer(
@@ -132,8 +80,7 @@ function toAdminServer(
80 index: number,
81 bannedLeases: Set<string>
82 ): AdminServer {
135 - const metadata = sanitizeMetadata(row);
136 - const peerId = normalizeLeaseID(row.Peer);
83 + const metadata = parseLeaseMetadata(row.Metadata);
84
85 return {
86 id: index + 1,
@@ -147,8 +94,8 @@ function toAdminServer(
94 link: row.Link,
95 lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
96 firstSeen: row.FirstSeenISO || undefined,
150 - peerId,
151 - isBanned: bannedLeases.has(peerId),
97 + peerId: row.Peer,
98 + isBanned: bannedLeases.has(row.Peer),
99 bps: row.BPS || 0,
100 isApproved: row.IsApproved || false,
101 isDenied: row.IsDenied || false,
@@ -190,26 +137,15 @@ export function useAdmin() {
137 setLoading(true);
138
139 try {
193 - const settingsRequest = apiClient
194 - .get<SettingsResponse>(API_PATHS.admin.settings)
195 - .catch(async (err) => {
196 - if (err instanceof APIClientError && err.status === 404) {
197 - return apiClient.get<SettingsResponse>(API_PATHS.admin.approvalMode);
198 - }
199 - throw err;
200 - });
201 -
140 const [leasesData, bannedData, settings] = await Promise.all([
141 apiClient.get<ServerData[]>(API_PATHS.admin.leases),
142 apiClient.get<string[]>(API_PATHS.admin.bannedLeases),
205 - settingsRequest,
143 + apiClient.get<SettingsResponse>(API_PATHS.admin.approvalMode),
144 ]);
145
208 - const normalizedBans = (Array.isArray(bannedData) ? bannedData : [])
209 - .map((leaseID) =>
210 - typeof leaseID === "string" ? normalizeLeaseID(leaseID) : ""
211 - )
212 - .filter(Boolean);
146 + const normalizedBans = (Array.isArray(bannedData) ? bannedData : []).filter(
147 + (leaseID): leaseID is string => typeof leaseID === "string"
148 + );
149
150 setServerData(Array.isArray(leasesData) ? leasesData : []);
151 setBannedLeases(dedupeStrings(normalizedBans));
@@ -226,7 +162,7 @@ export function useAdmin() {
162 }, [fetchData]);
163
164 const bannedLeaseSet = useMemo(
229 - () => new Set(bannedLeases.map((leaseID) => normalizeLeaseID(leaseID))),
165 + () => new Set(bannedLeases),
166 [bannedLeases]
167 );
168
@@ -353,11 +289,7 @@ export function useAdmin() {
289
290 const runBulkLeaseAction = useCallback(
291 async (peerIds: string[], action: LeaseAction) => {
356 - const normalizedPeerIDs = dedupeStrings(
357 - peerIds
358 - .map((peerId) => normalizeLeaseID(peerId))
359 - .filter(Boolean)
360 - );
292 + const normalizedPeerIDs = dedupeStrings(peerIds.filter((peerId) => peerId.length > 0));
293 if (normalizedPeerIDs.length === 0) {
294 throw new Error("No valid leases selected");
295 }
cmd/relay-server/frontend/src/hooks/useServerList.ts
+4 -23
@@ -1,8 +1,9 @@
1 import { useMemo } from "react";
2 import { useSSRData } from "@/hooks/useSSRData";
3 -import type { ServerData, Metadata } from "@/hooks/useSSRData";
3 +import type { ServerData } from "@/hooks/useSSRData";
4 import { useList, type BaseServer } from "@/hooks/useList";
5 import { generateRandomServers } from "@/lib/testUtils";
6 +import { parseLeaseMetadata } from "@/lib/metadata";
7
8 const useDebug = false;
9
@@ -10,33 +11,13 @@ export type ClientServer = BaseServer;
11
12 function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
13 return ssrData.map((row, index) => {
13 - let metadata: Metadata = {
14 - description: "",
15 - tags: [],
16 - thumbnail: "",
17 - owner: "",
18 - hide: false,
19 - };
20 -
21 - try {
22 - if (row.Metadata) {
23 - metadata = JSON.parse(row.Metadata);
24 - }
25 - } catch (err) {
26 - console.error("[App] Failed to parse metadata:", err, row.Metadata);
27 - }
28 -
29 - const normalizedTags = Array.isArray(metadata.tags)
30 - ? metadata.tags
31 - .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
32 - .filter(Boolean)
33 - : [];
14 + const metadata = parseLeaseMetadata(row.Metadata);
15
16 return {
17 id: index + 1,
18 name: row.Name || row.DNS || "(unnamed)",
19 description: metadata.description || "",
39 - tags: normalizedTags,
20 + tags: metadata.tags,
21 thumbnail: metadata.thumbnail || "",
22 owner: metadata.owner || "",
23 online: row.Connected,
cmd/relay-server/frontend/src/lib/apiClient.test.ts
+18 -7
@@ -31,12 +31,14 @@ describe("apiClient", () => {
31 expect(init.headers).toEqual({ Accept: "application/json" });
32 });
33
34 - it("accepts successful non-envelope JSON payloads", async () => {
34 + it("rejects successful non-envelope JSON payloads", async () => {
35 fetchMock.mockResolvedValueOnce(jsonResponse({ direct: true }));
36
37 - const data = await apiClient.get<{ direct: boolean }>("/api/test");
38 -
39 - expect(data).toEqual({ direct: true });
37 + await expect(apiClient.get<{ direct: boolean }>("/api/test")).rejects.toMatchObject({
38 + name: "APIClientError",
39 + status: 200,
40 + code: "invalid_envelope",
41 + } satisfies Partial<APIClientError>);
42 });
43
44 it("throws APIClientError for server-side envelope failures", async () => {
@@ -55,7 +57,7 @@ describe("apiClient", () => {
57 } satisfies Partial<APIClientError>);
58 });
59
58 - it("parses structured non-envelope errors for resilience", async () => {
60 + it("rejects structured non-envelope errors as invalid envelopes", async () => {
61 fetchMock.mockResolvedValueOnce(
62 jsonResponse(
63 { code: "lease_rejected", message: "failed to register lease" },
@@ -66,8 +68,7 @@ describe("apiClient", () => {
68 await expect(apiClient.get("/api/test")).rejects.toMatchObject({
69 name: "APIClientError",
70 status: 409,
69 - code: "lease_rejected",
70 - message: "failed to register lease",
71 + code: "invalid_envelope",
72 } satisfies Partial<APIClientError>);
73 });
74
@@ -83,6 +84,16 @@ describe("apiClient", () => {
84 } satisfies Partial<APIClientError>);
85 });
86
87 + it("treats empty responses as invalid envelopes", async () => {
88 + fetchMock.mockResolvedValueOnce(new Response("", { status: 200 }));
89 +
90 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
91 + name: "APIClientError",
92 + status: 200,
93 + code: "invalid_envelope",
94 + } satisfies Partial<APIClientError>);
95 + });
96 +
97 it("throws invalid_json when response body is not parseable JSON", async () => {
98 fetchMock.mockResolvedValueOnce(
99 new Response("not-json", {
cmd/relay-server/frontend/src/lib/apiClient.ts
+8 -69
@@ -40,11 +40,11 @@ function headersToObject(headers?: HeadersInit): Record<string, string> {
40 return { ...headers };
41 }
42
43 -function ensureJsonEnvelope<T>(raw: unknown, path: string): APIEnvelope<T> {
43 +function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIEnvelope<T> {
44 if (!isRecord(raw)) {
45 throw new APIClientError(
46 `Unexpected API response for ${path}: envelope is not an object`,
47 - 0,
47 + status,
48 "invalid_envelope",
49 raw
50 );
@@ -54,7 +54,7 @@ function ensureJsonEnvelope<T>(raw: unknown, path: string): APIEnvelope<T> {
54 if (typeof okValue !== "boolean") {
55 throw new APIClientError(
56 `Unexpected API response for ${path}: missing ok flag`,
57 - 0,
57 + status,
58 "invalid_envelope",
59 raw
60 );
@@ -64,7 +64,7 @@ function ensureJsonEnvelope<T>(raw: unknown, path: string): APIEnvelope<T> {
64 if (errorValue !== undefined && !isRecord(errorValue)) {
65 throw new APIClientError(
66 `Unexpected API response for ${path}: invalid error payload`,
67 - 0,
67 + status,
68 "invalid_envelope",
69 errorValue
70 );
@@ -85,47 +85,14 @@ function ensureJsonEnvelope<T>(raw: unknown, path: string): APIEnvelope<T> {
85 };
86 }
87
88 -function coerceErrorPayload(value: unknown): APIErrorPayload | null {
89 - if (!isRecord(value)) {
90 - return null;
91 - }
92 -
93 - const code = typeof value.code === "string" ? value.code.trim() : "";
94 - const message = typeof value.message === "string" ? value.message.trim() : "";
95 -
96 - if (!code && !message) {
97 - return null;
98 - }
99 -
100 - return {
101 - code: code || undefined,
102 - message: message || undefined,
103 - };
104 -}
105 -
106 -function extractErrorPayload(raw: unknown): APIErrorPayload | null {
107 - if (!isRecord(raw)) {
108 - return null;
109 - }
110 -
111 - const nested = coerceErrorPayload(raw.error);
112 - if (nested) {
113 - return nested;
114 - }
115 -
116 - return coerceErrorPayload(raw);
117 -}
118 -
88 async function decodeEnvelope<T>(path: string, response: Response): Promise<APIEnvelope<T>> {
89 const text = await response.text();
121 - if (!text) {
122 - if (response.ok) {
123 - return { ok: true };
124 - }
90 + if (!text.trim()) {
91 throw new APIClientError(
92 `Empty API response from ${path}`,
93 response.status,
128 - "empty_response"
94 + "invalid_envelope",
95 + text
96 );
97 }
98
@@ -141,35 +108,7 @@ async function decodeEnvelope<T>(path: string, response: Response): Promise<APIE
108 );
109 }
110
144 - if (isRecord(payload) && typeof payload.ok === "boolean") {
145 - return ensureJsonEnvelope<T>(payload, path);
146 - }
147 -
148 - if (response.ok) {
149 - return {
150 - ok: true,
151 - data: payload as T,
152 - };
153 - }
154 -
155 - const fallbackError = extractErrorPayload(payload);
156 - if (fallbackError) {
157 - return {
158 - ok: false,
159 - data: payload as T,
160 - error: {
161 - code: fallbackError.code || "request_failed",
162 - message: fallbackError.message || response.statusText || "Request failed",
163 - },
164 - };
165 - }
166 -
167 - throw new APIClientError(
168 - `Unexpected API response for ${path}: missing ok envelope`,
169 - response.status,
170 - "invalid_envelope",
171 - payload
172 - );
111 + return ensureJsonEnvelope<T>(payload, path, response.status);
112 }
113
114 async function request<T>(path: string, init: RequestInit): Promise<T> {
cmd/relay-server/frontend/src/lib/apiPaths.ts
+1 -1
@@ -38,7 +38,7 @@ export function adminLeasePath(
38 encodedLeaseID: string,
39 action: "ban" | "bps" | "approve" | "deny"
40 ): string {
41 - return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedLeaseID.trim())}/${action}`;
41 + return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedLeaseID)}/${action}`;
42 }
43
44 export function adminIPBanPath(ip: string): string {
cmd/relay-server/frontend/src/lib/metadata.ts new
+43
@@ -0,0 +1,43 @@
1 +import type { Metadata } from "@/hooks/useSSRData";
2 +
3 +const EMPTY_METADATA: Metadata = {
4 + description: "",
5 + tags: [],
6 + thumbnail: "",
7 + owner: "",
8 + hide: false,
9 +};
10 +
11 +function isRecord(value: unknown): value is Record<string, unknown> {
12 + return typeof value === "object" && value !== null && !Array.isArray(value);
13 +}
14 +
15 +export function parseLeaseMetadata(metadataJSON: string): Metadata {
16 + if (!metadataJSON) {
17 + return EMPTY_METADATA;
18 + }
19 +
20 + try {
21 + const parsed = JSON.parse(metadataJSON);
22 + if (!isRecord(parsed)) {
23 + return EMPTY_METADATA;
24 + }
25 +
26 + const rawTags = parsed.tags;
27 + const tags = Array.isArray(rawTags)
28 + ? rawTags
29 + .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
30 + .filter(Boolean)
31 + : [];
32 +
33 + return {
34 + description: typeof parsed.description === "string" ? parsed.description : "",
35 + tags,
36 + thumbnail: typeof parsed.thumbnail === "string" ? parsed.thumbnail : "",
37 + owner: typeof parsed.owner === "string" ? parsed.owner : "",
38 + hide: typeof parsed.hide === "boolean" ? parsed.hide : false,
39 + };
40 + } catch {
41 + return EMPTY_METADATA;
42 + }
43 +}
cmd/relay-server/frontend/src/lib/testUtils.ts
+1 -1
@@ -1,4 +1,4 @@
1 -import { ClientServer } from "@/pages/ServerList";
1 +import type { ClientServer } from "@/hooks/useServerList";
2
3 // Generate random sample servers
4 export const generateRandomServers = (
cmd/relay-server/frontend/src/pages/ServerList.tsx
-3
@@ -2,9 +2,6 @@ import { SsgoiTransition } from "@ssgoi/react";
2 import { useServerList } from "@/hooks/useServerList";
3 import { ServerListView } from "@/components/ServerListView";
4
5 -// Re-export for backwards compatibility
6 -export type { ClientServer } from "@/hooks/useServerList";
7 -
5 export function ServerList() {
6 // Controller: useServerList hook handles all server list logic
7 const {
cmd/relay-server/registry.go
+2 -14
@@ -56,19 +56,7 @@ func (r *SDKRegistry) extractClientIP(req *http.Request) string {
56 }
57
58 func (r *SDKRegistry) isSecureConnectRequest(req *http.Request) bool {
59 - if req == nil {
60 - return false
61 - }
62 - if req.TLS != nil {
63 - return true
64 - }
65 - if !r.trustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(req.RemoteAddr) {
66 - return false
67 - }
68 - if strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")), "https") {
69 - return true
70 - }
71 - return strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Ssl")), "on")
59 + return isSecureRequestWithPolicy(req, r.trustProxyHeaders)
60 }
61
62 func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
@@ -110,7 +98,7 @@ func isWebSocketUpgrade(req *http.Request) bool {
98 if req == nil {
99 return false
100 }
113 - return strings.EqualFold(strings.TrimSpace(req.Header.Get("Upgrade")), "websocket")
101 + return hasForwardedToken(req.Header.Get("Upgrade"), "websocket")
102 }
103
104 // HandleSDKRequest routes /sdk/* requests.
cmd/relay-server/utils.go
+16 -3
@@ -22,19 +22,32 @@ const (
22 )
23
24 func isSecureRequest(r *http.Request) bool {
25 + return isSecureRequestWithPolicy(r, flagTrustProxyHeaders)
26 +}
27 +
28 +func isSecureRequestWithPolicy(r *http.Request, trustProxyHeaders bool) bool {
29 if r == nil {
30 return false
31 }
32 if r.TLS != nil {
33 return true
34 }
31 - if !flagTrustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(r.RemoteAddr) {
35 + if !trustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(r.RemoteAddr) {
36 return false
37 }
34 - if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
38 + if hasForwardedToken(r.Header.Get("X-Forwarded-Proto"), "https") {
39 return true
40 }
37 - return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on")
41 + return hasForwardedToken(r.Header.Get("X-Forwarded-Ssl"), "on")
42 +}
43 +
44 +func hasForwardedToken(raw, target string) bool {
45 + for token := range strings.SplitSeq(raw, ",") {
46 + if strings.EqualFold(strings.TrimSpace(token), target) {
47 + return true
48 + }
49 + }
50 + return false
51 }
52
53 // getContentType returns the MIME type for a file extension.
docs/adr/0001-raw-tcp-reverse-connect-and-autocert-tls.md
+4 -4
@@ -14,7 +14,7 @@ Portal must support NAT-friendly inbound connectivity for tenant traffic while k
14 - Keep SNI routing as the ingress split for tenant subdomains.
15 - Keep root-domain fallback forwarding from SNI router to the admin/API listener.
16 - Derive relay `BaseHost` and TLS domain construction from the full portal root host (for example `portal.example.com`), not apex extraction (`example.com`).
17 -- Serve admin/API TLS from ACME-managed certificate material when files are available.
17 +- Serve admin/API exclusively over TLS using ACME/local certificate material; no HTTP fallback path.
18
19 ## Consequences
20
@@ -26,13 +26,13 @@ Portal must support NAT-friendly inbound connectivity for tenant traffic while k
26
27 ### Trade-offs
28
29 -- TLS enablement still depends on ACME certificate files being present.
29 +- Startup availability depends on certificate material being present and loadable.
30 - Non-apex portal host deployments require wildcard coverage on the full portal root host (for example `*.portal.example.com`).
31
32 ### Risks and Mitigations
33
34 -- Risk: ACME certificate files unavailable at startup.
35 - Mitigation: keep HTTP fallback when ACME/root-host prerequisites are not met and log explicit TLS enablement state.
34 +- Risk: certificate files unavailable or invalid at startup.
35 + Mitigation: fail fast during startup and surface explicit operator diagnostics; do not downgrade to HTTP.
36 - Risk: non-apex portal host deployments route to wrong TLS host if derivation drifts.
37 Mitigation: enforce portal-root-host derivation consistently in relay and SDK.
38
sdk/client.go
+5 -9
@@ -21,14 +21,8 @@ import (
21
22 // SDK-specific errors.
23 var (
24 - ErrNoAvailableRelay = errors.New("no available relay")
25 - ErrClientClosed = errors.New("client is closed")
26 - ErrListenerExists = errors.New("listener already exists for this credential")
27 - ErrRelayExists = errors.New("relay already exists")
28 - ErrRelayNotFound = errors.New("relay not found")
29 - ErrInvalidName = errors.New("lease name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
30 - ErrFailedToCreateClient = errors.New("failed to create relay client")
31 - ErrInvalidMetadata = errors.New("invalid metadata")
24 + ErrNoAvailableRelay = errors.New("no available relay")
25 + ErrInvalidName = errors.New("lease name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
26 )
27
28 // ClientConfig configures the SDK client.
@@ -206,7 +200,9 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
200 return tlsConfig, []func(){closeFn}, nil
201 }
202
209 -// Close closes the client.
203 +// Close keeps SDK lifecycle parity with callers that defer cleanup.
204 func (c *Client) Close() error {
205 return nil
206 }
207 +
208 +// Close closes the client.
sdk/listener.go
+20 -25
@@ -562,11 +562,6 @@ func parseReverseConnectRejection(body []byte) (string, string) {
562 }
563 }
564
565 -func formatReverseConnectRejectionDetail(body []byte) string {
566 - _, detail := parseReverseConnectRejection(body)
567 - return detail
568 -}
569 -
565 func (l *Listener) reverseSetupTimeout() time.Duration {
566 if l.reverseDialTimeout <= 0 {
567 return defaultReverseDialTimeout
@@ -702,30 +697,30 @@ func (l *Listener) postJSON(path string, body any) error {
697 }
698
699 var envelope types.APIRawEnvelope
705 - if err := json.Unmarshal(data, &envelope); err == nil {
706 - if envelope.OK {
707 - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
708 - return nil
709 - }
710 - return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
711 - }
712 -
713 - msg := ""
714 - if envelope.Error != nil {
715 - msg = strings.TrimSpace(envelope.Error.Message)
716 - }
717 - if msg == "" {
718 - msg = strings.TrimSpace(string(data))
700 + if err := json.Unmarshal(data, &envelope); err != nil {
701 + return fmt.Errorf("POST %s failed: invalid API envelope: %w", path, err)
702 + }
703 + if envelope.OK {
704 + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
705 + return nil
706 }
720 - return fmt.Errorf("POST %s rejected: %s", path, msg)
707 + return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
708 }
709
723 - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
724 - // Non-envelope successful payloads are treated as successful.
725 - return nil
710 + msg := ""
711 + if envelope.Error != nil {
712 + msg = strings.TrimSpace(envelope.Error.Message)
713 }
727 -
728 - return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
714 + if msg == "" {
715 + msg = strings.TrimSpace(string(data))
716 + }
717 + if msg == "" {
718 + msg = fmt.Sprintf("status=%d", resp.StatusCode)
719 + }
720 + if envelope.Error != nil && strings.TrimSpace(envelope.Error.Code) != "" {
721 + return fmt.Errorf("POST %s rejected: %s (code=%s)", path, msg, strings.TrimSpace(envelope.Error.Code))
722 + }
723 + return fmt.Errorf("POST %s rejected: %s", path, msg)
724 }
725
726 func isLeaseNotFoundError(err error) bool {
sdk/listener_test.go
+12 -3
@@ -312,32 +312,37 @@ func TestReadReverseConnectResponse_RespectsReadDeadline(t *testing.T) {
312 }
313 }
314
315 -func TestFormatReverseConnectRejectionDetail(t *testing.T) {
315 +func TestParseReverseConnectRejection(t *testing.T) {
316 t.Parallel()
317
318 tests := []struct {
319 name string
320 body string
321 + code string
322 want string
323 }{
324 {
325 name: "envelope with code and message",
326 body: `{"ok":false,"error":{"code":"ip_banned","message":"ip is banned"}}`,
327 + code: "ip_banned",
328 want: "ip is banned (code=ip_banned)",
329 },
330 {
331 name: "envelope with message only",
332 body: `{"ok":false,"error":{"code":"","message":"missing lease_id"}}`,
333 + code: "",
334 want: "missing lease_id",
335 },
336 {
337 name: "plain text body",
338 body: " unauthorized reverse connect ",
339 + code: "",
340 want: "unauthorized reverse connect",
341 },
342 {
343 name: "empty body",
344 body: " ",
345 + code: "",
346 want: "",
347 },
348 }
@@ -345,8 +350,12 @@ func TestFormatReverseConnectRejectionDetail(t *testing.T) {
350 for _, tt := range tests {
351 t.Run(tt.name, func(t *testing.T) {
352 t.Parallel()
348 - if got := formatReverseConnectRejectionDetail([]byte(tt.body)); got != tt.want {
349 - t.Fatalf("formatReverseConnectRejectionDetail(%q)=%q, want %q", tt.body, got, tt.want)
353 + code, detail := parseReverseConnectRejection([]byte(tt.body))
354 + if code != tt.code {
355 + t.Fatalf("parseReverseConnectRejection(%q) code=%q, want %q", tt.body, code, tt.code)
356 + }
357 + if detail != tt.want {
358 + t.Fatalf("parseReverseConnectRejection(%q) detail=%q, want %q", tt.body, detail, tt.want)
359 }
360 })
361 }