refactor(portal): centralize anti-abuse enforcement
- Unify admin API lease action handling - Validate secure relay URLs for reverse connect - Align policy enforcement for SDK connect/register - Clarify no operator setup changes required - Improve tunnel listener error handling
cognitive committed
Mar 3, 2026 at 21:28 UTC
776bae8bac036eef0e296d780b0a7291e6d2269c
25 files changed
+844
-132
AGENTS.md
+5
@@ -31,6 +31,11 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
31
32
3. **Reverse connection authorization must remain lease-token validated before bridge/forwarding.**
33
- Why: prevents unauthorized tunnel attachment (ADR-0003).
34
+4. **`/sdk/connect` must share the same policy source as `/sdk/register`.**
35
+ - Why: ensures registration and reverse admission apply identical IP-ban + token checks in one enforcement pipeline.
36
+
37
+5. **Operator setup is not changed by this hardening.**
38
+ - Why: anti-abuse changes are behavior-only and reuse existing flags/env/settings for policy management.
39
40
## Operational Truths (CI-Aligned, Minimal)
41
README.md
+3
@@ -30,6 +30,9 @@ and routes incoming traffic while preserving end-to-end TLS.
30
- **End-to-end encryption**: Supports TLS passthrough with relay keyless certificates
31
- **Self-hosted by design**: You can run your own Portal relay
32
- **Fast setup**: Expose a local app with a short command flow
33
+- **Central anti-abuse enforcement**: `/sdk/register` and `/sdk/connect` use the same admin-managed policy controls (IP bans, lease authorization) before accepting a tunnel
34
+
35
+Security policy hardening in this refactor does not require operator setup changes.
36
37
## Components
38
cmd/portal-tunnel/main.go
+4
@@ -140,6 +140,10 @@ loop:
140
141
relayConn, err := listener.Accept()
142
if err != nil {
143
+ if errors.Is(err, net.ErrClosed) {
144
+ log.Info().Msg("[tunnel] listener closed")
145
+ break loop
146
+ }
147
select {
148
case <-ctx.Done():
149
break loop
cmd/portal-tunnel/main_test.go
new
+49
@@ -0,0 +1,49 @@
1
+package main
2
+
3
+import "testing"
4
+
5
+func TestValidateRelayURLsForReverseConnect(t *testing.T) {
6
+ tests := []struct {
7
+ name string
8
+ relayURLs []string
9
+ wantErr bool
10
+ }{
11
+ {
12
+ name: "single https relay",
13
+ relayURLs: []string{"https://relay.example.com"},
14
+ wantErr: false,
15
+ },
16
+ {
17
+ name: "multiple https relays",
18
+ relayURLs: []string{"https://relay-a.example.com", "https://relay-b.example.com"},
19
+ wantErr: false,
20
+ },
21
+ {
22
+ name: "reject http relay",
23
+ relayURLs: []string{"http://relay.example.com"},
24
+ wantErr: true,
25
+ },
26
+ {
27
+ name: "reject websocket relay",
28
+ relayURLs: []string{"wss://relay.example.com"},
29
+ wantErr: true,
30
+ },
31
+ {
32
+ name: "reject malformed relay URL",
33
+ relayURLs: []string{"://not-a-valid-url"},
34
+ wantErr: true,
35
+ },
36
+ }
37
+
38
+ for _, tt := range tests {
39
+ t.Run(tt.name, func(t *testing.T) {
40
+ err := validateRelayURLsForReverseConnect(tt.relayURLs)
41
+ if tt.wantErr && err == nil {
42
+ t.Fatalf("validateRelayURLsForReverseConnect(%v) expected error, got nil", tt.relayURLs)
43
+ }
44
+ if !tt.wantErr && err != nil {
45
+ t.Fatalf("validateRelayURLsForReverseConnect(%v) unexpected error: %v", tt.relayURLs, err)
46
+ }
47
+ })
48
+ }
49
+}
cmd/relay-server/admin.go
+62
-39
@@ -249,14 +249,10 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
249
a.handleGetSettings(w)
250
case route == "settings/approval-mode":
251
a.handleApprovalModeRequest(w, r, serv)
252
- case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/ban"):
253
- a.handleLeaseBanRequest(w, r, serv, route)
254
- case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/bps"):
255
- a.handleLeaseBPSRequest(w, r, serv, route)
256
- case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/approve"):
257
- a.handleLeaseApproveRequest(w, r, serv, route)
258
- case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/deny"):
259
- a.handleLeaseDenyRequest(w, r, serv, route)
252
+ case strings.HasPrefix(route, "leases/"):
253
+ if !a.handleLeaseActionRouteRequest(w, r, serv, route) {
254
+ http.NotFound(w, r)
255
+ }
256
case strings.HasPrefix(route, "ips/") && strings.HasSuffix(route, "/ban"):
257
a.handleIPBanRequest(w, r, serv, route)
258
default:
@@ -359,15 +355,63 @@ func (a *Admin) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
355
})
356
}
357
362
-func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
358
+type leaseActionRouteStatus uint8
359
+
360
+const (
361
+ leaseActionRouteNotFound leaseActionRouteStatus = iota
362
+ leaseActionRouteInvalidLeaseID
363
+ leaseActionRouteOK
364
+)
365
+
366
+func parseLeaseActionRoute(route string) (leaseID, action string, status leaseActionRouteStatus) {
367
parts := strings.Split(route, "/")
364
- if len(parts) != 3 {
365
- http.NotFound(w, r)
366
- return
368
+ if len(parts) != 3 || parts[0] != "leases" {
369
+ return "", "", leaseActionRouteNotFound
370
+ }
371
+
372
+ action = parts[2]
373
+ switch action {
374
+ case "ban", "bps", "approve", "deny":
375
+ default:
376
+ return "", "", leaseActionRouteNotFound
377
}
378
379
leaseID, ok := decodeLeaseID(parts[1])
380
if !ok {
381
+ return "", action, leaseActionRouteInvalidLeaseID
382
+ }
383
+
384
+ return leaseID, action, leaseActionRouteOK
385
+}
386
+
387
+func (a *Admin) handleLeaseActionRouteRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) bool {
388
+ leaseID, action, status := parseLeaseActionRoute(route)
389
+ switch status {
390
+ case leaseActionRouteNotFound:
391
+ return false
392
+ case leaseActionRouteInvalidLeaseID:
393
+ writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
394
+ return true
395
+ }
396
+
397
+ switch action {
398
+ case "ban":
399
+ a.handleLeaseBanRequest(w, r, serv, leaseID)
400
+ case "bps":
401
+ a.handleLeaseBPSRequest(w, r, serv, leaseID)
402
+ case "approve":
403
+ a.handleLeaseApproveRequest(w, r, serv, leaseID)
404
+ case "deny":
405
+ a.handleLeaseDenyRequest(w, r, serv, leaseID)
406
+ default:
407
+ return false
408
+ }
409
+
410
+ return true
411
+}
412
+
413
+func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
414
+ if strings.TrimSpace(leaseID) == "" {
415
writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
416
return
417
}
@@ -422,15 +466,8 @@ func (a *Admin) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request
466
}
467
}
468
425
-func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
426
- parts := strings.Split(route, "/")
427
- if len(parts) != 3 {
428
- http.NotFound(w, r)
429
- return
430
- }
431
-
432
- leaseID, ok := decodeLeaseID(parts[1])
433
- if !ok {
469
+func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
470
+ if strings.TrimSpace(leaseID) == "" {
471
writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
472
return
473
}
@@ -452,15 +489,8 @@ func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request
489
}
490
}
491
455
-func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
456
- parts := strings.Split(route, "/")
457
- if len(parts) != 3 {
458
- http.NotFound(w, r)
459
- return
460
- }
461
-
462
- leaseID, ok := decodeLeaseID(parts[1])
463
- if !ok {
492
+func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
493
+ if strings.TrimSpace(leaseID) == "" {
494
writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
495
return
496
}
@@ -481,15 +511,8 @@ func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, s
511
}
512
}
513
484
-func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
485
- parts := strings.Split(route, "/")
486
- if len(parts) != 3 {
487
- http.NotFound(w, r)
488
- return
489
- }
490
-
491
- leaseID, ok := decodeLeaseID(parts[1])
492
- if !ok {
514
+func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
515
+ if strings.TrimSpace(leaseID) == "" {
516
writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
517
return
518
}
cmd/relay-server/admin_test.go
new
+83
@@ -0,0 +1,83 @@
1
+package main
2
+
3
+import (
4
+ "encoding/base64"
5
+ "testing"
6
+)
7
+
8
+func encodeLeaseIDForAdminRoute(leaseID string) string {
9
+ return base64.RawURLEncoding.EncodeToString([]byte(leaseID))
10
+}
11
+
12
+func TestParseLeaseActionRoute(t *testing.T) {
13
+ encodedLeaseID := encodeLeaseIDForAdminRoute("lease-123")
14
+
15
+ tests := []struct {
16
+ name string
17
+ route string
18
+ wantLease string
19
+ wantAction string
20
+ wantStatus leaseActionRouteStatus
21
+ }{
22
+ {
23
+ name: "ban action",
24
+ route: "leases/" + encodedLeaseID + "/ban",
25
+ wantLease: "lease-123",
26
+ wantAction: "ban",
27
+ wantStatus: leaseActionRouteOK,
28
+ },
29
+ {
30
+ name: "bps action",
31
+ route: "leases/" + encodedLeaseID + "/bps",
32
+ wantLease: "lease-123",
33
+ wantAction: "bps",
34
+ wantStatus: leaseActionRouteOK,
35
+ },
36
+ {
37
+ name: "approve action",
38
+ route: "leases/" + encodedLeaseID + "/approve",
39
+ wantLease: "lease-123",
40
+ wantAction: "approve",
41
+ wantStatus: leaseActionRouteOK,
42
+ },
43
+ {
44
+ name: "deny action",
45
+ route: "leases/" + encodedLeaseID + "/deny",
46
+ wantLease: "lease-123",
47
+ wantAction: "deny",
48
+ wantStatus: leaseActionRouteOK,
49
+ },
50
+ {
51
+ name: "unsupported action",
52
+ route: "leases/" + encodedLeaseID + "/noop",
53
+ wantStatus: leaseActionRouteNotFound,
54
+ },
55
+ {
56
+ name: "invalid route shape",
57
+ route: "leases/" + encodedLeaseID,
58
+ wantStatus: leaseActionRouteNotFound,
59
+ },
60
+ {
61
+ name: "invalid encoded lease id",
62
+ route: "leases/not_base64!/ban",
63
+ wantAction: "ban",
64
+ wantStatus: leaseActionRouteInvalidLeaseID,
65
+ },
66
+ }
67
+
68
+ for _, tt := range tests {
69
+ t.Run(tt.name, func(t *testing.T) {
70
+ gotLease, gotAction, gotStatus := parseLeaseActionRoute(tt.route)
71
+
72
+ if gotStatus != tt.wantStatus {
73
+ t.Fatalf("parseLeaseActionRoute(%q) status=%v, want %v", tt.route, gotStatus, tt.wantStatus)
74
+ }
75
+ if gotLease != tt.wantLease {
76
+ t.Fatalf("parseLeaseActionRoute(%q) leaseID=%q, want %q", tt.route, gotLease, tt.wantLease)
77
+ }
78
+ if gotAction != tt.wantAction {
79
+ t.Fatalf("parseLeaseActionRoute(%q) action=%q, want %q", tt.route, gotAction, tt.wantAction)
80
+ }
81
+ })
82
+ }
83
+}
cmd/relay-server/frontend/README.md
+8
@@ -125,6 +125,14 @@ Relay server exposes:
125
126
Admin endpoints use a JSON envelope contract (`{ ok, data, error }`) and reject malformed or non-JSON responses with explicit API client errors.
127
128
+### SDK-Related Runtime Contract
129
+
130
+The relay enforces a consistent anti-abuse gate for both control APIs and reverse admission:
131
+
132
+- `/sdk/register`, `/sdk/unregister`, `/sdk/renew`, and `/sdk/domain` return JSON envelopes (`{ ok, data, error }`).
133
+- `/sdk/connect` is the raw transport endpoint and returns HTTP status + JSON envelope errors for validation failures before connection hijack (`tls_required`, `missing_lease_id`, `missing_reverse_token`, `unsupported_transport`, `ip_banned`, `lease_not_found`, `unauthorized`).
134
+- `/sdk/connect` is additionally re-validated inside `ReverseHub` before pooling so token and IP authorization are applied at both admission layers.
135
+
136
### Run with Relay Server
137
138
```bash
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+12
-49
@@ -1,4 +1,4 @@
1
-import { useState, useMemo, useEffect } from "react";
1
+import { useState, useMemo } from "react";
2
import { Copy, Check, Terminal, X } from "lucide-react";
3
import { cn } from "@/lib/utils";
4
import {
@@ -11,6 +11,7 @@ import {
11
} from "@/components/ui/dialog";
12
import { Button } from "@/components/ui/button";
13
import { Input } from "@/components/ui/input";
14
+import { API_PATHS } from "@/lib/apiPaths";
15
16
interface TunnelCommandModalProps {
17
trigger?: React.ReactNode;
@@ -34,25 +35,6 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
35
const [urlInput, setUrlInput] = useState("");
36
const [copied, setCopied] = useState(false);
37
const [os, setOs] = useState<"unix" | "windows">("unix");
37
- const [tls, setTls] = useState(true);
38
- const tlsAvailable = useMemo(() => {
39
- if (relayUrls.length === 0) {
40
- return false;
41
- }
42
- return relayUrls.every((raw) => {
43
- try {
44
- return new URL(raw).protocol === "https:";
45
- } catch {
46
- return false;
47
- }
48
- });
49
- }, [relayUrls]);
50
-
51
- useEffect(() => {
52
- if (!tlsAvailable && tls) {
53
- setTls(false);
54
- }
55
- }, [tlsAvailable, tls]);
38
39
const addRelayUrl = (url: string) => {
40
const trimmed = url.trim();
@@ -91,15 +73,16 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
73
const nameVal = name === "" ? defaultName : name;
74
const relayUrlVal =
75
relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
76
+ const tunnelScriptURL = new URL(API_PATHS.tunnel, currentOrigin).toString();
77
78
if (os === "windows") {
96
- const tlsEnv = tls ? `$env:TLS="1"; ` : "";
97
- return `$ProgressPreference = 'SilentlyContinue'; ${tlsEnv}$env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; irm ${currentOrigin}/tunnel?os=windows | iex`;
79
+ const windowsScriptURL = new URL(tunnelScriptURL);
80
+ windowsScriptURL.searchParams.set("os", "windows");
81
+ return `$ProgressPreference = 'SilentlyContinue'; $env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; irm ${windowsScriptURL.toString()} | iex`;
82
}
83
100
- const tlsEnv = tls ? "TLS=1 " : "";
101
- return `curl -fsSL ${currentOrigin}/tunnel | ${tlsEnv}APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
102
- }, [currentOrigin, host, name, relayUrls, os, tls]);
84
+ return `curl -fsSL ${tunnelScriptURL} | APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
85
+ }, [currentOrigin, host, name, relayUrls, os]);
86
87
const handleCopy = async () => {
88
try {
@@ -247,34 +230,14 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
230
</div>
231
</div>
232
250
- {/* TLS Mode */}
233
+ {/* Transport */}
234
<div className="space-y-2">
235
<label className="text-sm font-medium text-foreground">
253
- TLS Mode
236
+ Transport
237
</label>
255
- <div className="space-y-2 rounded-md border border-input/80 px-3 py-2">
256
- <label className="flex items-center justify-between gap-3 text-sm text-foreground">
257
- <span className="flex items-center gap-2">
258
- <input
259
- type="checkbox"
260
- checked={tls}
261
- onChange={(e) => setTls(e.target.checked)}
262
- disabled={!tlsAvailable}
263
- className="h-4 w-4 disabled:opacity-50 disabled:cursor-not-allowed"
264
- />
265
- TLS Enabled
266
- </span>
267
- <span className="text-xs text-text-muted text-right">
268
- {tlsAvailable
269
- ? "Recommended: keyless TLS."
270
- : "TLS is not available on this relay."}
271
- </span>
272
- </label>
273
- </div>
238
<p className="text-xs text-text-muted">
275
- {tls
276
- ? "TLS is enabled (keyless mode)."
277
- : "TLS is disabled (no-tls mode)."}
239
+ Reverse connect is TLS-only. Generated commands run tunnel in
240
+ keyless TLS mode.
241
</p>
242
</div>
243
cmd/relay-server/frontend/src/hooks/useAdmin.test.ts
+35
@@ -140,6 +140,27 @@ describe("useAdmin", () => {
140
expect(result.current.error).toBe("failed to load leases");
141
});
142
143
+ it("maps contract error codes to resilient admin messages", async () => {
144
+ const { result } = renderHook(() => useAdmin());
145
+ await waitForLoaded(result);
146
+
147
+ mockPost.mockRejectedValueOnce(
148
+ new APIClientError("request failed", 400, "invalid_mode"),
149
+ );
150
+
151
+ await act(async () => {
152
+ await expect(result.current.handleApprovalModeChange("manual")).rejects.toBeInstanceOf(
153
+ APIClientError,
154
+ );
155
+ });
156
+
157
+ await waitFor(() => {
158
+ expect(result.current.error).toBe(
159
+ "Invalid approval mode. Choose auto or manual and retry.",
160
+ );
161
+ });
162
+ });
163
+
164
it("validates missing IP in handleIPBanStatus", async () => {
165
const { result } = renderHook(() => useAdmin());
166
await waitForLoaded(result);
@@ -154,6 +175,20 @@ describe("useAdmin", () => {
175
});
176
});
177
178
+ it("keeps plain lease IDs stable when building action targets", async () => {
179
+ const { result } = renderHook(() => useAdmin());
180
+ await waitForLoaded(result);
181
+
182
+ await act(async () => {
183
+ await result.current.handleApproveStatus(" peer-a ", true);
184
+ });
185
+
186
+ const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
187
+ expect(calledPaths).toContain(
188
+ adminLeasePath(encodeLeaseID("peer-a"), "approve"),
189
+ );
190
+ });
191
+
192
it("bulk deny posts normalized, deduped lease IDs", async () => {
193
const { result } = renderHook(() => useAdmin());
194
await waitForLoaded(result);
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+56
-15
@@ -39,17 +39,67 @@ function decodeBase64URLSafe(input: string): string {
39
return padded;
40
}
41
42
+const BASE64_URL_SAFE_PATTERN = /^[A-Za-z0-9_-]+$/;
43
+const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
44
+ invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
45
+ invalid_lease_id: "Selected lease identifier is invalid. Refresh and try again.",
46
+ lease_rejected: "Request was rejected by policy. Review conflicts and retry.",
47
+ ip_banned: "Request denied because the source IP is banned.",
48
+ unauthorized: "Admin authorization failed. Sign in again and retry.",
49
+ method_not_allowed: "This action is not supported by the current server version.",
50
+};
51
+
52
function decodeLeaseID(raw: string): string {
53
+ try {
54
+ return atob(decodeBase64URLSafe(raw));
55
+ } catch {
56
+ return "";
57
+ }
58
+}
59
+
60
+function decodeLeaseIDIfEncoded(raw: string): string {
61
const value = raw.trim();
62
if (!value) {
63
return "";
64
}
65
48
- try {
49
- return atob(decodeBase64URLSafe(value));
50
- } catch {
66
+ const unpadded = value.replace(/=+$/u, "");
67
+ if (!BASE64_URL_SAFE_PATTERN.test(unpadded)) {
68
+ return value;
69
+ }
70
+
71
+ const decoded = decodeLeaseID(unpadded);
72
+ if (!decoded) {
73
return value;
74
}
75
+
76
+ return encodeLeaseID(decoded) === unpadded ? decoded : value;
77
+}
78
+
79
+function toAdminErrorMessage(error: unknown, fallback: string): string {
80
+ if (error instanceof APIClientError) {
81
+ const mappedMessage = ADMIN_ERROR_MESSAGE_BY_CODE[error.code];
82
+ if (mappedMessage) {
83
+ return mappedMessage;
84
+ }
85
+
86
+ if (error.status === 401 || error.status === 403) {
87
+ return "Admin authorization failed. Sign in again and retry.";
88
+ }
89
+ if (error.status === 409) {
90
+ return "Request was rejected by policy. Refresh and retry.";
91
+ }
92
+
93
+ const message = error.message.trim();
94
+ return message || fallback;
95
+ }
96
+
97
+ if (error instanceof Error) {
98
+ const message = error.message.trim();
99
+ return message || fallback;
100
+ }
101
+
102
+ return fallback;
103
}
104
105
function normalizeLeaseID(raw: string): string {
@@ -57,7 +107,7 @@ function normalizeLeaseID(raw: string): string {
107
if (!value) {
108
return "";
109
}
60
- const decoded = decodeLeaseID(value).trim();
110
+ const decoded = decodeLeaseIDIfEncoded(value).trim();
111
return decoded || value;
112
}
113
@@ -205,11 +255,7 @@ export function useAdmin() {
255
setBannedLeases(dedupeStrings(normalizedBans));
256
setApprovalMode(normalizeApprovalMode(settings?.approval_mode));
257
} catch (err: unknown) {
208
- if (err instanceof APIClientError) {
209
- setError(err.message);
210
- } else {
211
- setError(err instanceof Error ? err.message : String(err));
212
- }
258
+ setError(toAdminErrorMessage(err, "Failed to load admin data"));
259
} finally {
260
setLoading(false);
261
}
@@ -257,12 +303,7 @@ export function useAdmin() {
303
await action();
304
await fetchData();
305
} catch (err: unknown) {
260
- const message =
261
- err instanceof APIClientError
262
- ? err.message
263
- : err instanceof Error
264
- ? err.message
265
- : "Action failed";
306
+ const message = toAdminErrorMessage(err, "Action failed");
307
console.error(err);
308
setError(message);
309
throw err;
cmd/relay-server/frontend/src/lib/apiClient.test.ts
+18
-2
@@ -55,9 +55,25 @@ describe("apiClient", () => {
55
} satisfies Partial<APIClientError>);
56
});
57
58
- it("throws invalid_envelope when a failed response has no envelope", async () => {
58
+ it("parses structured non-envelope errors for resilience", async () => {
59
fetchMock.mockResolvedValueOnce(
60
- jsonResponse({ message: "not wrapped" }, { status: 400 }),
60
+ jsonResponse(
61
+ { code: "lease_rejected", message: "failed to register lease" },
62
+ { status: 409, statusText: "Conflict" },
63
+ ),
64
+ );
65
+
66
+ await expect(apiClient.get("/api/test")).rejects.toMatchObject({
67
+ name: "APIClientError",
68
+ status: 409,
69
+ code: "lease_rejected",
70
+ message: "failed to register lease",
71
+ } satisfies Partial<APIClientError>);
72
+ });
73
+
74
+ it("throws invalid_envelope when a failed response has no parseable error payload", async () => {
75
+ fetchMock.mockResolvedValueOnce(
76
+ jsonResponse({ detail: "not wrapped" }, { status: 400 }),
77
);
78
79
await expect(apiClient.get("/api/test")).rejects.toMatchObject({
cmd/relay-server/frontend/src/lib/apiClient.ts
+43
@@ -85,6 +85,37 @@ 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
+
119
async function decodeEnvelope<T>(path: string, response: Response): Promise<APIEnvelope<T>> {
120
const text = await response.text();
121
if (!text) {
@@ -121,6 +152,18 @@ async function decodeEnvelope<T>(path: string, response: Response): Promise<APIE
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,
cmd/relay-server/frontend/src/lib/apiPaths.test.ts
new
+20
@@ -0,0 +1,20 @@
1
+import { describe, expect, it } from "vitest";
2
+
3
+import { API_PATHS } from "@/lib/apiPaths";
4
+
5
+describe("API_PATHS contract alignment", () => {
6
+ it("keeps sdk endpoint paths aligned", () => {
7
+ expect(API_PATHS.sdk).toEqual({
8
+ prefix: "/sdk",
9
+ register: "/sdk/register",
10
+ unregister: "/sdk/unregister",
11
+ renew: "/sdk/renew",
12
+ domain: "/sdk/domain",
13
+ connect: "/sdk/connect",
14
+ });
15
+ });
16
+
17
+ it("keeps tunnel installer endpoint aligned", () => {
18
+ expect(API_PATHS.tunnel).toBe("/tunnel");
19
+ });
20
+});
cmd/relay-server/main.go
+1
-1
@@ -135,7 +135,7 @@ func runServer(cfg relayServerConfig) error {
135
})
136
if ipMgr != nil {
137
serv.GetReverseHub().SetIPBanChecker(func(ip string) bool {
138
- return ipMgr.IsIPBanned(ip)
138
+ return manager.IsIPBannedByPolicy(ipMgr, ip)
139
})
140
serv.GetReverseHub().SetOnAccepted(func(leaseID, ip string) {
141
if strings.TrimSpace(leaseID) == "" || strings.TrimSpace(ip) == "" {
cmd/relay-server/manager/ip_manager.go
+12
@@ -124,6 +124,18 @@ func (m *IPManager) IsIPBanned(ip string) bool {
124
return banned
125
}
126
127
+// IsIPBannedByPolicy applies shared runtime policy rules before checking the ban map.
128
+func IsIPBannedByPolicy(ipManager *IPManager, candidate string) bool {
129
+ if ipManager == nil {
130
+ return false
131
+ }
132
+ candidate = strings.TrimSpace(candidate)
133
+ if candidate == "" {
134
+ return false
135
+ }
136
+ return ipManager.IsIPBanned(candidate)
137
+}
138
+
139
// GetBannedIPs returns all banned IPs.
140
func (m *IPManager) GetBannedIPs() []string {
141
m.mu.RLock()
cmd/relay-server/manager/ip_manager_test.go
+45
@@ -71,3 +71,48 @@ func TestIsTrustedProxyRemoteAddr(t *testing.T) {
71
t.Fatal("did not expect non-allowlisted remote to be trusted")
72
}
73
}
74
+
75
+func TestIsIPBannedByPolicy(t *testing.T) {
76
+ ipManager := NewIPManager()
77
+ ipManager.BanIP("203.0.113.22")
78
+
79
+ tests := []struct {
80
+ name string
81
+ manager *IPManager
82
+ candidate string
83
+ want bool
84
+ }{
85
+ {
86
+ name: "nil manager",
87
+ manager: nil,
88
+ candidate: "203.0.113.22",
89
+ want: false,
90
+ },
91
+ {
92
+ name: "empty candidate",
93
+ manager: ipManager,
94
+ candidate: " ",
95
+ want: false,
96
+ },
97
+ {
98
+ name: "trimmed banned ip",
99
+ manager: ipManager,
100
+ candidate: " 203.0.113.22 ",
101
+ want: true,
102
+ },
103
+ {
104
+ name: "not banned ip",
105
+ manager: ipManager,
106
+ candidate: "203.0.113.99",
107
+ want: false,
108
+ },
109
+ }
110
+
111
+ for _, tt := range tests {
112
+ t.Run(tt.name, func(t *testing.T) {
113
+ if got := IsIPBannedByPolicy(tt.manager, tt.candidate); got != tt.want {
114
+ t.Fatalf("IsIPBannedByPolicy(%q)=%v, want %v", tt.candidate, got, tt.want)
115
+ }
116
+ })
117
+ }
118
+}
cmd/relay-server/registry.go
+17
-14
@@ -56,10 +56,7 @@ func (r *SDKRegistry) extractClientIP(req *http.Request) string {
56
}
57
58
func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
59
- if r.ipManager == nil || clientIP == "" {
60
- return false
61
- }
62
- return r.ipManager.IsIPBanned(clientIP)
59
+ return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
60
}
61
62
func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
@@ -121,7 +118,13 @@ func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request,
118
}
119
120
func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
124
- if !r.requireMethod(w, req, http.MethodGet) {
121
+ if req.Method != http.MethodGet {
122
+ w.Header().Set("Allow", http.MethodGet)
123
+ writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
124
+ return
125
+ }
126
+ if req.TLS == nil {
127
+ writeAPIError(w, http.StatusUpgradeRequired, "tls_required", "tls reverse connect required")
128
return
129
}
130
@@ -130,42 +133,42 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
133
req.Header.Get(portal.ReverseConnectTokenHeader),
134
)
135
if leaseID == "" {
133
- http.Error(w, "missing lease_id", http.StatusBadRequest)
136
+ writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
137
return
138
}
139
if token == "" {
137
- http.Error(w, "missing reverse token", http.StatusUnauthorized)
140
+ writeAPIError(w, http.StatusUnauthorized, "missing_reverse_token", "reverse_token is required")
141
return
142
}
143
if isWebSocketUpgrade(req) {
141
- http.Error(w, "websocket transport is not supported", http.StatusBadRequest)
144
+ writeAPIError(w, http.StatusBadRequest, "unsupported_transport", "websocket transport is not supported")
145
return
146
}
147
145
- clientIP := strings.TrimSpace(r.extractClientIP(req))
148
+ clientIP := r.extractClientIP(req)
149
if r.isClientIPBanned(clientIP) {
147
- http.Error(w, "ip is banned", http.StatusForbidden)
150
+ writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
151
return
152
}
153
154
entry, ok := lookupLeaseEntry(serv, leaseID)
155
if !ok {
153
- http.Error(w, "lease not found", http.StatusNotFound)
156
+ writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
157
return
158
}
159
if !reverseTokenMatches(entry.Lease.ReverseToken, token) {
157
- http.Error(w, "unauthorized reverse connect", http.StatusUnauthorized)
160
+ writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
161
return
162
}
163
164
hijacker, ok := w.(http.Hijacker)
165
if !ok {
163
- http.Error(w, "server does not support connection hijacking", http.StatusInternalServerError)
166
+ writeAPIError(w, http.StatusInternalServerError, "hijacker_unavailable", "server does not support connection hijacking")
167
return
168
}
169
conn, rw, err := hijacker.Hijack()
170
if err != nil {
168
- http.Error(w, "failed to hijack connection", http.StatusInternalServerError)
171
+ writeAPIError(w, http.StatusInternalServerError, "hijack_failed", "failed to hijack connection")
172
return
173
}
174
if _, err := rw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n"); err != nil {
cmd/relay-server/registry_test.go
+73
-4
@@ -3,6 +3,7 @@ package main
3
import (
4
"bytes"
5
"context"
6
+ "crypto/tls"
7
"encoding/json"
8
"net/http"
9
"net/http/httptest"
@@ -15,6 +16,16 @@ import (
16
"gosuda.org/portal/types"
17
)
18
19
+func decodeAPIRawEnvelope(t *testing.T, rec *httptest.ResponseRecorder) types.APIRawEnvelope {
20
+ t.Helper()
21
+
22
+ var envelope types.APIRawEnvelope
23
+ if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
24
+ t.Fatalf("decode API envelope: %v (body=%q)", err, rec.Body.String())
25
+ }
26
+ return envelope
27
+}
28
+
29
func newRegistryTestRelayServer(t *testing.T) *portal.RelayServer {
30
t.Helper()
31
@@ -122,6 +133,7 @@ func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
133
}
134
135
req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-ban", http.NoBody)
136
+ req.TLS = &tls.ConnectionState{}
137
req.RemoteAddr = "203.0.113.22:45000"
138
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
139
rec := httptest.NewRecorder()
@@ -131,8 +143,57 @@ func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
143
if rec.Code != http.StatusForbidden {
144
t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusForbidden)
145
}
134
- if !strings.Contains(rec.Body.String(), "ip is banned") {
135
- t.Fatalf("expected banned ip error body, got %q", rec.Body.String())
146
+ envelope := decodeAPIRawEnvelope(t, rec)
147
+ if envelope.OK {
148
+ t.Fatalf("expected banned IP response to fail, got %+v", envelope)
149
+ }
150
+ if envelope.Error == nil || envelope.Error.Code != "ip_banned" || envelope.Error.Message != "ip is banned" {
151
+ t.Fatalf("unexpected banned IP error payload: %+v", envelope.Error)
152
+ }
153
+}
154
+
155
+func TestSDKRegistryHandleConnectRequiresTLS(t *testing.T) {
156
+ serv := newRegistryTestRelayServer(t)
157
+ registry := &SDKRegistry{}
158
+
159
+ req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-tls", http.NoBody)
160
+ req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
161
+ rec := httptest.NewRecorder()
162
+
163
+ registry.handleConnect(rec, req, serv)
164
+
165
+ if rec.Code != http.StatusUpgradeRequired {
166
+ t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUpgradeRequired)
167
+ }
168
+ envelope := decodeAPIRawEnvelope(t, rec)
169
+ if envelope.OK {
170
+ t.Fatalf("expected tls_required response to fail, got %+v", envelope)
171
+ }
172
+ if envelope.Error == nil || envelope.Error.Code != "tls_required" || envelope.Error.Message != "tls reverse connect required" {
173
+ t.Fatalf("unexpected tls_required payload: %+v", envelope.Error)
174
+ }
175
+}
176
+
177
+func TestSDKRegistryHandleConnectMissingLeaseIDReturnsEnvelope(t *testing.T) {
178
+ serv := newRegistryTestRelayServer(t)
179
+ registry := &SDKRegistry{}
180
+
181
+ req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
182
+ req.TLS = &tls.ConnectionState{}
183
+ req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
184
+ rec := httptest.NewRecorder()
185
+
186
+ registry.handleConnect(rec, req, serv)
187
+
188
+ if rec.Code != http.StatusBadRequest {
189
+ t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
190
+ }
191
+ envelope := decodeAPIRawEnvelope(t, rec)
192
+ if envelope.OK {
193
+ t.Fatalf("expected missing lease_id response to fail, got %+v", envelope)
194
+ }
195
+ if envelope.Error == nil || envelope.Error.Code != "missing_lease_id" || envelope.Error.Message != "lease_id is required" {
196
+ t.Fatalf("unexpected missing lease_id payload: %+v", envelope.Error)
197
}
198
}
199
@@ -170,6 +231,7 @@ func TestSDKRegistryHandleConnectRejectsWebSocketUpgrade(t *testing.T) {
231
registry := &SDKRegistry{}
232
233
req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-websocket", http.NoBody)
234
+ req.TLS = &tls.ConnectionState{}
235
req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
236
req.Header.Set("Upgrade", "websocket")
237
rec := httptest.NewRecorder()
@@ -179,7 +241,14 @@ func TestSDKRegistryHandleConnectRejectsWebSocketUpgrade(t *testing.T) {
241
if rec.Code != http.StatusBadRequest {
242
t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
243
}
182
- if !strings.Contains(strings.ToLower(rec.Body.String()), "websocket") {
183
- t.Fatalf("expected websocket rejection body, got %q", rec.Body.String())
244
+ envelope := decodeAPIRawEnvelope(t, rec)
245
+ if envelope.OK {
246
+ t.Fatalf("expected websocket rejection to fail, got %+v", envelope)
247
+ }
248
+ if envelope.Error == nil || envelope.Error.Code != "unsupported_transport" {
249
+ t.Fatalf("unexpected websocket rejection payload: %+v", envelope.Error)
250
+ }
251
+ if !strings.Contains(strings.ToLower(envelope.Error.Message), "websocket") {
252
+ t.Fatalf("unexpected websocket rejection message: %+v", envelope.Error)
253
}
254
}
cmd/relay-server/tunnel.go
+2
-4
@@ -54,9 +54,7 @@ set -- "$BIN_PATH" --relay "$RELAYS" --host "${APP_HOST:-localhost:3000}"
54
if [ "${APP_HIDE:-}" = "1" ] || [ "${APP_HIDE:-}" = "true" ]; then
55
set -- "$@" --hide
56
fi
57
-if [ "${TLS:-}" = "1" ] || [ "${TLS:-}" = "true" ]; then
58
- set -- "$@" --tls
59
-fi
57
+set -- "$@" --tls
58
59
echo "Starting portal-tunnel..." >&2
60
exec "$@"
@@ -101,7 +99,7 @@ if ($env:APP_TAGS) { $ArgsList += "--tags", $env:APP_TAGS }
99
if ($env:APP_THUMBNAIL) { $ArgsList += "--thumbnail", $env:APP_THUMBNAIL }
100
if ($env:APP_OWNER) { $ArgsList += "--owner", $env:APP_OWNER }
101
if ($env:APP_HIDE -eq "1" -or $env:APP_HIDE -eq "true") { $ArgsList += "--hide" }
104
-if ($env:TLS -eq "1" -or $env:TLS -eq "true") { $ArgsList += "--tls" }
102
+$ArgsList += "--tls"
103
104
Write-Host "Starting portal-tunnel..."
105
try {
docs/adr/0003-security-and-anti-abuse-hardening.md
+6
@@ -14,6 +14,9 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
14
- Wire IP ban checks into SDK registration and reverse-connection acceptance paths.
15
- Enforce lease-token validation before bridging reverse connections.
16
- Keep root-domain and tenant-subdomain traffic split through SNI routing rules to prevent accidental cross-path handling.
17
+- Standardize SDK endpoint handling: `/sdk/register` (and related SDK APIs) and `/sdk/connect` validation failures return JSON envelopes (`{ ok, error }`) with explicit error codes prior to connection hijack, and `/sdk/connect` remains subject to `ReverseHub` authorization before pooling.
18
+
19
+Operator setup remains unchanged: no new relay flags/env vars are introduced for anti-abuse behavior.
20
21
## Consequences
22
@@ -27,6 +30,7 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
30
31
- Extra checks in critical paths may increase operational complexity during debugging.
32
- Incorrect ban-list management can block legitimate clients if policy operations are misused.
33
+- SDK clients must classify `/sdk/connect` rejection codes and statuses into fatal vs retryable outcomes for stable reverse-worker behavior.
34
35
### Risks and Mitigations
36
@@ -34,6 +38,8 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
38
Mitigation: initialize runtime components from admin-managed settings and keep a single IP manager source.
39
- Risk: abuse pressure shifts from one endpoint to another.
40
Mitigation: enforce checks at multiple ingress points (SDK registration and reverse-hub admission).
41
+- Risk: policy behavior drift from operator confusion.
42
+ Mitigation: keep policy source single-owner (`admin` settings + IP manager) and document that operator bootstrap/setup remains stable.
43
- Risk: accidental weakening during refactors.
44
Mitigation: require explicit ADR-aware review for security-sensitive path changes.
45
docs/architecture.md
+9
-3
@@ -30,6 +30,8 @@ Client (Browser)
30
- `sni.Router`: TCP listener that peeks SNI and routes to lease backends
31
- `acme` + `keyless`: ACME provisioning and remote signing support
32
33
+Anti-abuse policy is driven from admin-managed state and applied consistently for both registration and reverse admission.
34
+
35
### SDK (`sdk/`)
36
37
- `Client`: bootstrap relay URLs and optional TLS/keyless setup
@@ -66,12 +68,15 @@ Result: the relay handles SNI-based routing and transparent raw TCP forwarding,
68
- `reverse_token`
69
- Relay stores lease and (TLS only) registers SNI route.
70
- Route hostnames are generated from normalized lease + normalized `PORTAL_URL` host (extract host from URL without scheme/port/path); path segments are ignored, so `https://portal.example.com:8443/admin` and `https://portal.example.com` both map to `portal.example.com`.
71
+- `/sdk/register` and `/sdk/connect` both apply the admin policy gate path before a tunnel is allowed to stay active.
72
73
### 2. Reverse Connect
74
72
-- Backend opens a raw TCP reverse channel to `GET /sdk/connect?lease_id=...` and streams traffic over that long-lived connection
73
-- `X-Portal-Reverse-Token` is validated server-side.
74
-- Connection is pooled in `ReverseHub`.
75
+- Backend opens a raw TCP reverse connection to `GET /sdk/connect` and streams traffic over that long-lived connection
76
+ - `/sdk/connect` first validates TLS + lease/token/IP policy and rejects invalid attempts with HTTP status plus JSON envelope errors before hijacking:
77
+ - `tls_required` (`426`), `missing_lease_id` (`400`), `missing_reverse_token` (`401`), `unsupported_transport` (`400`), `ip_banned` (`403`), `lease_not_found` (`404`), `unauthorized` (`401`)
78
+- `X-Portal-Reverse-Token` is validated at HTTP precheck, then validated again in `ReverseHub` with centralized policy callbacks before the connection is pooled.
79
+- Connection is pooled in `ReverseHub` only after token/IP checks pass.
80
81
### 3. Renew
82
@@ -109,6 +114,7 @@ Note: wildcard does not match the portal root host itself (`example.com` or `por
114
- Separation of control plane (`/sdk/*`) and data plane (SNI + raw TCP forwarding)
115
- Single transport policy: raw TCP reverse-connect only (no websocket/legacy compatibility mode)
116
- Unified lease abstraction for routing, metadata, and lifecycle
117
+- Shared anti-abuse path: admin-managed bans and lease authorization are enforced both in SDK registration and reverse admission
118
119
## ADRs
120
portal/reverse_hub.go
+1
@@ -185,6 +185,7 @@ func (h *ReverseHub) isIPBanned(ip string) bool {
185
h.mu.RLock()
186
checker := h.ipBanChecker
187
h.mu.RUnlock()
188
+ ip = strings.TrimSpace(ip)
189
if checker == nil || ip == "" {
190
return false
191
}
portal/reverse_hub_test.go
+27
@@ -27,6 +27,33 @@ func TestReverseHubAuthorization(t *testing.T) {
27
}
28
}
29
30
+func TestReverseHubIsIPBannedTrimsInput(t *testing.T) {
31
+ hub := NewReverseHub()
32
+ hub.SetIPBanChecker(func(ip string) bool {
33
+ return ip == "203.0.113.50"
34
+ })
35
+
36
+ if !hub.isIPBanned(" 203.0.113.50 ") {
37
+ t.Fatal("expected trimmed IP to be checked as banned")
38
+ }
39
+}
40
+
41
+func TestReverseHubIsIPBannedSkipsEmptyInput(t *testing.T) {
42
+ hub := NewReverseHub()
43
+ called := false
44
+ hub.SetIPBanChecker(func(string) bool {
45
+ called = true
46
+ return true
47
+ })
48
+
49
+ if hub.isIPBanned(" ") {
50
+ t.Fatal("expected whitespace IP to be treated as not banned")
51
+ }
52
+ if called {
53
+ t.Fatal("expected checker to be skipped for empty IP candidate")
54
+ }
55
+}
56
+
57
func TestReverseHubOfferRejectsInvalidInput(t *testing.T) {
58
hub := NewReverseHub()
59
sdk/listener.go
+99
-1
@@ -30,6 +30,53 @@ const (
30
defaultTLSHandshakeTimeout = 10 * time.Second
31
)
32
33
+var fatalReverseConnectRejectionCodes = map[string]struct{}{
34
+ "ip_banned": {},
35
+ "lease_not_found": {},
36
+ "method_not_allowed": {},
37
+ "missing_lease_id": {},
38
+ "missing_reverse_token": {},
39
+ "tls_required": {},
40
+ "unauthorized": {},
41
+ "unsupported_transport": {},
42
+}
43
+
44
+type reverseConnectRejectionError struct {
45
+ code string
46
+ detail string
47
+ statusCode int
48
+}
49
+
50
+func (e *reverseConnectRejectionError) Error() string {
51
+ if e == nil {
52
+ return "reverse connect rejected"
53
+ }
54
+ if e.detail == "" {
55
+ return fmt.Sprintf("reverse connect rejected: status=%d", e.statusCode)
56
+ }
57
+ return fmt.Sprintf("reverse connect rejected: status=%d error=%s", e.statusCode, e.detail)
58
+}
59
+
60
+func (e *reverseConnectRejectionError) IsFatal() bool {
61
+ if e == nil {
62
+ return false
63
+ }
64
+ if _, ok := fatalReverseConnectRejectionCodes[e.code]; ok {
65
+ return true
66
+ }
67
+ switch e.statusCode {
68
+ case http.StatusBadRequest,
69
+ http.StatusUnauthorized,
70
+ http.StatusForbidden,
71
+ http.StatusNotFound,
72
+ http.StatusMethodNotAllowed,
73
+ http.StatusUpgradeRequired:
74
+ return true
75
+ default:
76
+ return false
77
+ }
78
+}
79
+
80
// Listener is a net.Listener backed by relay tunnel registration.
81
// The relay connects to this listener after SNI routing resolves the lease.
82
type Listener struct {
@@ -229,6 +276,19 @@ func (l *Listener) reverseAcceptWorker(workerID int) {
276
277
conn, err := l.openReverseConnection()
278
if err != nil {
279
+ var rejectionErr *reverseConnectRejectionError
280
+ if errors.As(err, &rejectionErr) && rejectionErr.IsFatal() {
281
+ event := log.Error().
282
+ Err(err).
283
+ Str("lease_id", l.lease.ID).
284
+ Int("worker_id", workerID).
285
+ Int("status_code", rejectionErr.statusCode)
286
+ if rejectionErr.code != "" {
287
+ event = event.Str("relay_error_code", rejectionErr.code)
288
+ }
289
+ event.Msg("[SDK] Fatal reverse connect rejection; stopping worker")
290
+ return
291
+ }
292
select {
293
case <-l.stopCh:
294
return
@@ -455,11 +515,49 @@ func (l *Listener) readReverseConnectResponse(conn net.Conn) (*bufio.Reader, err
515
defer resp.Body.Close()
516
if resp.StatusCode != http.StatusOK {
517
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
458
- return nil, fmt.Errorf("reverse connect rejected: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
518
+ code, detail := parseReverseConnectRejection(body)
519
+ if detail == "" {
520
+ detail = strings.TrimSpace(http.StatusText(resp.StatusCode))
521
+ }
522
+ return nil, &reverseConnectRejectionError{
523
+ statusCode: resp.StatusCode,
524
+ code: code,
525
+ detail: detail,
526
+ }
527
}
528
return reader, nil
529
}
530
531
+func parseReverseConnectRejection(body []byte) (string, string) {
532
+ trimmedBody := strings.TrimSpace(string(body))
533
+ if trimmedBody == "" {
534
+ return "", ""
535
+ }
536
+
537
+ var envelope types.APIRawEnvelope
538
+ if err := json.Unmarshal(body, &envelope); err != nil || envelope.Error == nil {
539
+ return "", trimmedBody
540
+ }
541
+
542
+ code := strings.TrimSpace(envelope.Error.Code)
543
+ message := strings.TrimSpace(envelope.Error.Message)
544
+ switch {
545
+ case message != "" && code != "":
546
+ return code, fmt.Sprintf("%s (code=%s)", message, code)
547
+ case message != "":
548
+ return code, message
549
+ case code != "":
550
+ return code, code
551
+ default:
552
+ return "", trimmedBody
553
+ }
554
+}
555
+
556
+func formatReverseConnectRejectionDetail(body []byte) string {
557
+ _, detail := parseReverseConnectRejection(body)
558
+ return detail
559
+}
560
+
561
func (l *Listener) reverseSetupTimeout() time.Duration {
562
if l.reverseDialTimeout <= 0 {
563
return defaultReverseDialTimeout
sdk/listener_test.go
+154
@@ -3,6 +3,8 @@ package sdk
3
import (
4
"crypto/tls"
5
"errors"
6
+ "fmt"
7
+ "io"
8
"net"
9
"net/http"
10
"net/url"
@@ -310,6 +312,158 @@ func TestReadReverseConnectResponse_RespectsReadDeadline(t *testing.T) {
312
}
313
}
314
315
+func TestFormatReverseConnectRejectionDetail(t *testing.T) {
316
+ t.Parallel()
317
+
318
+ tests := []struct {
319
+ name string
320
+ body string
321
+ want string
322
+ }{
323
+ {
324
+ name: "envelope with code and message",
325
+ body: `{"ok":false,"error":{"code":"ip_banned","message":"ip is banned"}}`,
326
+ want: "ip is banned (code=ip_banned)",
327
+ },
328
+ {
329
+ name: "envelope with message only",
330
+ body: `{"ok":false,"error":{"code":"","message":"missing lease_id"}}`,
331
+ want: "missing lease_id",
332
+ },
333
+ {
334
+ name: "plain text body",
335
+ body: " unauthorized reverse connect ",
336
+ want: "unauthorized reverse connect",
337
+ },
338
+ {
339
+ name: "empty body",
340
+ body: " ",
341
+ want: "",
342
+ },
343
+ }
344
+
345
+ for _, tt := range tests {
346
+ t.Run(tt.name, func(t *testing.T) {
347
+ 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)
350
+ }
351
+ })
352
+ }
353
+}
354
+
355
+func TestReadReverseConnectResponseParsesEnvelopeError(t *testing.T) {
356
+ t.Parallel()
357
+
358
+ local, peer := net.Pipe()
359
+ defer local.Close()
360
+ defer peer.Close()
361
+
362
+ l := &Listener{
363
+ reverseDialTimeout: 500 * time.Millisecond,
364
+ stopCh: make(chan struct{}),
365
+ }
366
+
367
+ errCh := make(chan error, 1)
368
+ go func() {
369
+ _, err := l.readReverseConnectResponse(local)
370
+ errCh <- err
371
+ }()
372
+
373
+ body := `{"ok":false,"error":{"code":"ip_banned","message":"ip is banned"}}`
374
+ response := fmt.Sprintf(
375
+ "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s",
376
+ len(body),
377
+ body,
378
+ )
379
+ if _, err := io.WriteString(peer, response); err != nil {
380
+ t.Fatalf("write response: %v", err)
381
+ }
382
+
383
+ select {
384
+ case err := <-errCh:
385
+ if err == nil {
386
+ t.Fatal("expected readReverseConnectResponse to fail for 403 response")
387
+ }
388
+ var rejectionErr *reverseConnectRejectionError
389
+ if !errors.As(err, &rejectionErr) {
390
+ t.Fatalf("expected reverseConnectRejectionError, got: %T %v", err, err)
391
+ }
392
+ if rejectionErr.statusCode != http.StatusForbidden {
393
+ t.Fatalf("rejection statusCode=%d, want %d", rejectionErr.statusCode, http.StatusForbidden)
394
+ }
395
+ if rejectionErr.code != "ip_banned" {
396
+ t.Fatalf("rejection code=%q, want %q", rejectionErr.code, "ip_banned")
397
+ }
398
+ if rejectionErr.detail != "ip is banned (code=ip_banned)" {
399
+ t.Fatalf("rejection detail=%q, want %q", rejectionErr.detail, "ip is banned (code=ip_banned)")
400
+ }
401
+ if !rejectionErr.IsFatal() {
402
+ t.Fatalf("expected ip_banned rejection to be fatal: %+v", rejectionErr)
403
+ }
404
+ if strings.Contains(err.Error(), `{\"ok\":false`) {
405
+ t.Fatalf("expected formatted error detail instead of raw JSON: %v", err)
406
+ }
407
+ case <-time.After(1 * time.Second):
408
+ t.Fatal("timed out waiting for readReverseConnectResponse error")
409
+ }
410
+}
411
+
412
+func TestReverseConnectRejectionErrorIsFatal(t *testing.T) {
413
+ t.Parallel()
414
+
415
+ var nilErr *reverseConnectRejectionError
416
+ if nilErr.IsFatal() {
417
+ t.Fatal("nil rejection error must not be fatal")
418
+ }
419
+
420
+ tests := []struct {
421
+ err *reverseConnectRejectionError
422
+ name string
423
+ want bool
424
+ }{
425
+ {
426
+ name: "fatal by known code",
427
+ err: &reverseConnectRejectionError{
428
+ statusCode: http.StatusTooManyRequests,
429
+ code: "ip_banned",
430
+ },
431
+ want: true,
432
+ },
433
+ {
434
+ name: "fatal by status code",
435
+ err: &reverseConnectRejectionError{
436
+ statusCode: http.StatusUpgradeRequired,
437
+ },
438
+ want: true,
439
+ },
440
+ {
441
+ name: "transient retry status",
442
+ err: &reverseConnectRejectionError{
443
+ statusCode: http.StatusServiceUnavailable,
444
+ },
445
+ want: false,
446
+ },
447
+ {
448
+ name: "unknown code and status",
449
+ err: &reverseConnectRejectionError{
450
+ statusCode: http.StatusInternalServerError,
451
+ code: "unexpected_failure",
452
+ },
453
+ want: false,
454
+ },
455
+ }
456
+
457
+ for _, tt := range tests {
458
+ t.Run(tt.name, func(t *testing.T) {
459
+ t.Parallel()
460
+ if got := tt.err.IsFatal(); got != tt.want {
461
+ t.Fatalf("IsFatal()=%t, want %t for %+v", got, tt.want, tt.err)
462
+ }
463
+ })
464
+ }
465
+}
466
+
467
func TestWaitForReverseStart_HTTPMode(t *testing.T) {
468
t.Parallel()
469