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
}