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