docs(portal): Update runtime contracts and policies

- Outline plain string lease ID contract - Specify Base64URL use for admin paths - Detail tunnel installer integrity policy - Add portal root host routing notes - Admin banned leases now return plain IDs - Add test for banned leases API

cognitive committed Mar 3, 2026 at 21:57 UTC fe1701834b8157aeddb46586122c843f8a891ba1
16 files changed +384 -71
README.md
+9
@@ -46,6 +46,15 @@ For details, see [docs/glossary.md](docs/glossary.md).
46 - Raw TCP reverse-connect is the only supported relay/tunnel transport.
47 - No websocket compatibility path is provided for transport control or data-plane flow.
48
49 +## Runtime Contracts
50 +
51 +- Lease IDs in admin and SDK payloads are plain string IDs.
52 +- Base64URL lease-ID encoding is used only for admin action route path segments (`/admin/leases/{encodedLeaseID}/{action}`).
53 +- `/sdk/connect` accepts secure transport when either:
54 + - direct TLS is present, or
55 + - request comes from an allowlisted trusted proxy and forwarded HTTPS headers indicate HTTPS.
56 +- Tunnel installer scripts always fetch `${BIN_URL}.sha256` and fail closed on missing, malformed, or mismatched checksum.
57 +
58 ### Routing Notes
59
60 - SNI routing preserves an exact-match fallback for the portal root host. Requests that target the exact `PORTAL_URL` host (for example, `portal.example.com`) are handled by the admin/API listener via the no-route path.
cmd/portal-tunnel/README.md
+6
@@ -69,6 +69,12 @@ curl -fsSL https://portal.example.com/tunnel | APP_HOST=localhost:3000 APP_NAME=
69 $env:APP_HOST="localhost:3000"; $env:APP_NAME="myapp"; irm https://portal.example.com/tunnel | iex
70 ```
71
72 +Installer integrity policy:
73 +
74 +- The installer downloads `BIN_URL` and `BIN_URL.sha256`.
75 +- SHA256 verification is mandatory and fail-closed.
76 +- Missing, malformed, or mismatched checksums abort startup with a remediation hint.
77 +
78 ### Production (TLS)
79
80 ```bash
cmd/relay-server/admin.go
+1 -6
@@ -79,12 +79,7 @@ func (a *Admin) SaveSettings(serv *portal.RelayServer) {
79 defer a.settingsMu.Unlock()
80
81 lm := serv.GetLeaseManager()
82 -
83 - bannedBytes := lm.GetBannedLeases()
84 - banned := make([]string, len(bannedBytes))
85 - for i, b := range bannedBytes {
86 - banned[i] = string(b)
87 - }
82 + banned := lm.GetBannedLeases()
83
84 bpsLimits := map[string]int64{}
85 if a.bpsManager != nil {
cmd/relay-server/admin_test.go
+53
@@ -1,8 +1,17 @@
1 package main
2
3 import (
4 + "context"
5 "encoding/base64"
6 + "encoding/json"
7 + "net/http"
8 + "net/http/httptest"
9 + "slices"
10 "testing"
11 +
12 + "gosuda.org/portal/cmd/relay-server/manager"
13 + "gosuda.org/portal/portal"
14 + "gosuda.org/portal/types"
15 )
16
17 func encodeLeaseIDForAdminRoute(leaseID string) string {
@@ -81,3 +90,47 @@ func TestParseLeaseActionRoute(t *testing.T) {
90 })
91 }
92 }
93 +
94 +func TestHandleAdminRequestBannedLeasesReturnsPlainIDs(t *testing.T) {
95 + serv, err := portal.NewRelayServer(context.Background(), nil, ":0", "portal.example.com", "", "")
96 + if err != nil {
97 + t.Fatalf("create relay server: %v", err)
98 + }
99 + authManager := manager.NewAuthManager("test-secret")
100 + admin := NewAdmin(0, NewFrontend(), authManager)
101 +
102 + serv.GetLeaseManager().BanLease("lease-a")
103 + serv.GetLeaseManager().BanLease("lease-b")
104 +
105 + req := httptest.NewRequest(http.MethodGet, "/admin/leases/banned", http.NoBody)
106 + req.AddCookie(&http.Cookie{
107 + Name: adminCookieName,
108 + Value: authManager.CreateSession(),
109 + Path: "/admin",
110 + })
111 + rec := httptest.NewRecorder()
112 +
113 + admin.HandleAdminRequest(rec, req, serv)
114 +
115 + if rec.Code != http.StatusOK {
116 + t.Fatalf("HandleAdminRequest status = %d, want %d", rec.Code, http.StatusOK)
117 + }
118 +
119 + var envelope types.APIRawEnvelope
120 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
121 + t.Fatalf("decode envelope: %v (body=%q)", err, rec.Body.String())
122 + }
123 + if !envelope.OK {
124 + t.Fatalf("expected success envelope, got %+v", envelope)
125 + }
126 +
127 + var banned []string
128 + if err := json.Unmarshal(envelope.Data, &banned); err != nil {
129 + t.Fatalf("decode banned leases: %v", err)
130 + }
131 + slices.Sort(banned)
132 + want := []string{"lease-a", "lease-b"}
133 + if !slices.Equal(banned, want) {
134 + t.Fatalf("banned leases = %v, want %v", banned, want)
135 + }
136 +}
cmd/relay-server/frontend/README.md
+7
@@ -125,12 +125,19 @@ 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 +Admin lease ID contract:
129 +
130 +- `/admin/leases` rows return plain lease IDs in `Peer`.
131 +- `/admin/leases/banned` returns plain lease IDs (`[]string`).
132 +- Frontend only Base64URL-encodes lease IDs when constructing admin action routes (`/admin/leases/{encodedLeaseID}/{action}`).
133 +
134 ### SDK-Related Runtime Contract
135
136 The relay enforces a consistent anti-abuse gate for both control APIs and reverse admission:
137
138 - `/sdk/register`, `/sdk/unregister`, `/sdk/renew`, and `/sdk/domain` return JSON envelopes (`{ ok, data, error }`).
139 - `/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`).
140 +- `/sdk/connect` treats transport as secure when direct TLS is present, or when forwarded HTTPS headers are received from an allowlisted trusted proxy (`TRUST_PROXY_HEADERS=true` + trusted proxy CIDRs).
141 - `/sdk/connect` is additionally re-validated inside `ReverseHub` before pooling so token and IP authorization are applied at both admission layers.
142
143 ### Run with Relay Server
cmd/relay-server/frontend/src/hooks/useAdmin.test.ts
+10 -9
@@ -83,13 +83,13 @@ describe("useAdmin", () => {
83
84 mockGet.mockImplementation(async (path: string) => {
85 if (path === API_PATHS.admin.leases) {
86 - return [buildLease(encodeLeaseID("peer-a"))] as never;
86 + return [buildLease("peer-a")] as never;
87 }
88 if (path === API_PATHS.admin.bannedLeases) {
89 return [
90 - ` ${encodeLeaseID("peer-a")} `,
91 - encodeLeaseID("peer-a"),
92 - encodeLeaseID("peer-b"),
90 + " peer-a ",
91 + "peer-a",
92 + "peer-b",
93 ] as never;
94 }
95 if (path === API_PATHS.admin.settings) {
@@ -178,14 +178,15 @@ describe("useAdmin", () => {
178 it("keeps plain lease IDs stable when building action targets", async () => {
179 const { result } = renderHook(() => useAdmin());
180 await waitForLoaded(result);
181 + const plainLeaseID = "deadbeefcafebabe";
182
183 await act(async () => {
183 - await result.current.handleApproveStatus(" peer-a ", true);
184 + await result.current.handleApproveStatus(` ${plainLeaseID} `, true);
185 });
186
187 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
188 expect(calledPaths).toContain(
188 - adminLeasePath(encodeLeaseID("peer-a"), "approve"),
189 + adminLeasePath(encodeLeaseID(plainLeaseID), "approve"),
190 );
191 });
192
@@ -197,9 +198,9 @@ describe("useAdmin", () => {
198
199 await act(async () => {
200 await result.current.handleBulkDeny([
200 - ` ${encodeLeaseID(normalizedPeerA)} `,
201 - encodeLeaseID(normalizedPeerA),
202 - encodeLeaseID(normalizedPeerB),
201 + " peer-a ",
202 + "peer-a",
203 + "peer-b",
204 ]);
205 });
206
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+1 -41
@@ -32,14 +32,6 @@ export interface AdminServer extends BaseServer {
32 isIPBanned: boolean;
33 }
34
35 -function decodeBase64URLSafe(input: string): string {
36 - const normalized = input.trim().replace(/-/g, "+").replace(/_/g, "/");
37 - const padded =
38 - normalized.length % 4 === 0 ? normalized : normalized + "=".repeat(4 - (normalized.length % 4));
39 - return padded;
40 -}
41 -
42 -const BASE64_URL_SAFE_PATTERN = /^[A-Za-z0-9_-]+$/;
35 const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
36 invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
37 invalid_lease_id: "Selected lease identifier is invalid. Refresh and try again.",
@@ -49,33 +41,6 @@ const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
41 method_not_allowed: "This action is not supported by the current server version.",
42 };
43
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 -
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 -
44 function toAdminErrorMessage(error: unknown, fallback: string): string {
45 if (error instanceof APIClientError) {
46 const mappedMessage = ADMIN_ERROR_MESSAGE_BY_CODE[error.code];
@@ -103,12 +68,7 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
68 }
69
70 function normalizeLeaseID(raw: string): string {
106 - const value = raw.trim();
107 - if (!value) {
108 - return "";
109 - }
110 - const decoded = decodeLeaseIDIfEncoded(value).trim();
111 - return decoded || value;
71 + return raw.trim();
72 }
73
74 function encodeLeaseIDForPath(raw: string): string {
cmd/relay-server/registry.go
+17 -1
@@ -55,6 +55,22 @@ func (r *SDKRegistry) extractClientIP(req *http.Request) string {
55 return manager.ExtractClientIP(req, r.trustProxyHeaders)
56 }
57
58 +func (r *SDKRegistry) isSecureConnectRequest(req *http.Request) bool {
59 + if req == nil {
60 + return false
61 + }
62 + if req.TLS != nil {
63 + return true
64 + }
65 + if !r.trustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(req.RemoteAddr) {
66 + return false
67 + }
68 + if strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")), "https") {
69 + return true
70 + }
71 + return strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Ssl")), "on")
72 +}
73 +
74 func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
75 return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
76 }
@@ -123,7 +139,7 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
139 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
140 return
141 }
126 - if req.TLS == nil {
142 + if !r.isSecureConnectRequest(req) {
143 writeAPIError(w, http.StatusUpgradeRequired, "tls_required", "tls reverse connect required")
144 return
145 }
cmd/relay-server/registry_test.go
+67
@@ -5,6 +5,7 @@ import (
5 "context"
6 "crypto/tls"
7 "encoding/json"
8 + "net"
9 "net/http"
10 "net/http/httptest"
11 "strings"
@@ -174,6 +175,72 @@ func TestSDKRegistryHandleConnectRequiresTLS(t *testing.T) {
175 }
176 }
177
178 +func TestSDKRegistryHandleConnectAcceptsTrustedProxyHTTPS(t *testing.T) {
179 + serv := newRegistryTestRelayServer(t)
180 + registry := &SDKRegistry{trustProxyHeaders: true}
181 +
182 + _, trustedCIDR, err := net.ParseCIDR("10.0.0.0/8")
183 + if err != nil {
184 + t.Fatalf("parse trusted proxy cidr: %v", err)
185 + }
186 + manager.SetTrustedProxyCIDRs([]*net.IPNet{trustedCIDR})
187 + t.Cleanup(func() {
188 + manager.SetTrustedProxyCIDRs(nil)
189 + })
190 +
191 + req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
192 + req.RemoteAddr = "10.1.2.3:443"
193 + req.Header.Set("X-Forwarded-Proto", "https")
194 + req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
195 + rec := httptest.NewRecorder()
196 +
197 + registry.handleConnect(rec, req, serv)
198 +
199 + if rec.Code != http.StatusBadRequest {
200 + t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusBadRequest)
201 + }
202 + envelope := decodeAPIRawEnvelope(t, rec)
203 + if envelope.OK {
204 + t.Fatalf("expected missing_lease_id response to fail, got %+v", envelope)
205 + }
206 + if envelope.Error == nil || envelope.Error.Code != "missing_lease_id" || envelope.Error.Message != "lease_id is required" {
207 + t.Fatalf("unexpected missing_lease_id payload: %+v", envelope.Error)
208 + }
209 +}
210 +
211 +func TestSDKRegistryHandleConnectRejectsUntrustedProxyHTTPS(t *testing.T) {
212 + serv := newRegistryTestRelayServer(t)
213 + registry := &SDKRegistry{trustProxyHeaders: true}
214 +
215 + _, trustedCIDR, err := net.ParseCIDR("10.0.0.0/8")
216 + if err != nil {
217 + t.Fatalf("parse trusted proxy cidr: %v", err)
218 + }
219 + manager.SetTrustedProxyCIDRs([]*net.IPNet{trustedCIDR})
220 + t.Cleanup(func() {
221 + manager.SetTrustedProxyCIDRs(nil)
222 + })
223 +
224 + req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect, http.NoBody)
225 + req.RemoteAddr = "198.51.100.44:443"
226 + req.Header.Set("X-Forwarded-Proto", "https")
227 + req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
228 + rec := httptest.NewRecorder()
229 +
230 + registry.handleConnect(rec, req, serv)
231 +
232 + if rec.Code != http.StatusUpgradeRequired {
233 + t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusUpgradeRequired)
234 + }
235 + envelope := decodeAPIRawEnvelope(t, rec)
236 + if envelope.OK {
237 + t.Fatalf("expected tls_required response to fail, got %+v", envelope)
238 + }
239 + if envelope.Error == nil || envelope.Error.Code != "tls_required" || envelope.Error.Message != "tls reverse connect required" {
240 + t.Fatalf("unexpected tls_required payload: %+v", envelope.Error)
241 + }
242 +}
243 +
244 func TestSDKRegistryHandleConnectMissingLeaseIDReturnsEnvelope(t *testing.T) {
245 serv := newRegistryTestRelayServer(t)
246 registry := &SDKRegistry{}
cmd/relay-server/tunnel.go
+85 -9
@@ -1,6 +1,8 @@
1 package main
2
3 import (
4 + "crypto/sha256"
5 + "encoding/hex"
6 "fmt"
7 "net/http"
8 "strings"
@@ -34,6 +36,7 @@ esac
36 BASE_URL="${BASE_URL:-%s}"
37 RELAYS="${RELAYS:-$BASE_URL}"
38 BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
39 +CHECKSUM_URL="${BIN_URL}.sha256"
40
41 TMPDIR="${TMPDIR:-/tmp}"
42 WORKDIR="$(mktemp -d "$TMPDIR/portal-tunnel.XXXXXX" 2>/dev/null || mktemp -d -t portal-tunnel)"
@@ -43,6 +46,28 @@ trap cleanup EXIT INT TERM
46
47 echo "Downloading portal-tunnel ($TUNNEL_OS/$TUNNEL_ARCH)..." >&2
48 curl -fsSL "$BIN_URL" -o "$BIN_PATH"
49 +
50 +echo "Verifying SHA256 checksum..." >&2
51 +CHECKSUM_PAYLOAD="$(curl -fsSL "$CHECKSUM_URL")" || {
52 + echo "Failed to download checksum from $CHECKSUM_URL. Aborting (fail-closed)." >&2
53 + echo "Hint: verify relay artifact publishing or CDN cache freshness." >&2
54 + exit 1
55 +}
56 +
57 +EXPECTED_SHA="$(printf '%%s\n' "$CHECKSUM_PAYLOAD" | awk '{print $1}' | tr 'A-Z' 'a-z')"
58 +if ! printf '%%s\n' "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{64}$'; then
59 + echo "Invalid checksum payload from $CHECKSUM_URL. Aborting (fail-closed)." >&2
60 + echo "Hint: expected SHA256 sidecar format '<sha256> <filename>'." >&2
61 + exit 1
62 +fi
63 +
64 +ACTUAL_SHA="$(sha256sum "$BIN_PATH" | awk '{print $1}')"
65 +if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
66 + echo "Checksum mismatch for portal-tunnel binary. Aborting (fail-closed)." >&2
67 + echo "Hint: relay artifact and checksum may be out of sync or cached stale." >&2
68 + exit 1
69 +fi
70 +
71 chmod +x "$BIN_PATH"
72
73 set -- "$BIN_PATH" --relay "$RELAYS" --host "${APP_HOST:-localhost:3000}"
@@ -76,6 +101,7 @@ if ($Arch -eq "AMD64") {
101 }
102
103 $BinUrl = if ($env:BIN_URL) { $env:BIN_URL } else { "$BaseUrl/tunnel/bin/windows-$TunnelArch" }
104 +$ChecksumUrl = "$BinUrl.sha256"
105
106 $WorkDir = Join-Path $env:TEMP ("portal-tunnel-" + [Guid]::NewGuid().ToString())
107 New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
@@ -90,6 +116,33 @@ try {
116 exit 1
117 }
118
119 +try {
120 + Write-Host "Verifying SHA256 checksum..."
121 + $ChecksumPayload = (Invoke-WebRequest -Uri $ChecksumUrl).Content
122 +} catch {
123 + Write-Error "Failed to download checksum from $ChecksumUrl. Aborting (fail-closed)."
124 + Write-Error "Hint: verify relay artifact publishing or CDN cache freshness."
125 + Remove-Item -Recurse -Force $WorkDir
126 + exit 1
127 +}
128 +
129 +$ChecksumMatch = [regex]::Match($ChecksumPayload, '([A-Fa-f0-9]{64})')
130 +if (-not $ChecksumMatch.Success) {
131 + Write-Error "Invalid checksum payload from $ChecksumUrl. Aborting (fail-closed)."
132 + Write-Error "Hint: expected SHA256 sidecar format '<sha256> <filename>'."
133 + Remove-Item -Recurse -Force $WorkDir
134 + exit 1
135 +}
136 +
137 +$ExpectedHash = $ChecksumMatch.Groups[1].Value.ToLowerInvariant()
138 +$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $BinPath).Hash.ToLowerInvariant()
139 +if ($ActualHash -ne $ExpectedHash) {
140 + Write-Error "Checksum mismatch for portal-tunnel binary. Aborting (fail-closed)."
141 + Write-Error "Hint: relay artifact and checksum may be out of sync or cached stale."
142 + Remove-Item -Recurse -Force $WorkDir
143 + exit 1
144 +}
145 +
146 $ArgsList = @("--relay", $Relays)
147
148 if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
@@ -164,15 +217,13 @@ func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
217
218 slug := strings.TrimPrefix(r.URL.Path, "/tunnel/bin/")
219 slug = strings.Trim(slug, "/")
167 - path, ok := map[string]string{
168 - "linux-amd64": "dist/tunnel/portal-tunnel-linux-amd64",
169 - "linux-arm64": "dist/tunnel/portal-tunnel-linux-arm64",
170 - "darwin-amd64": "dist/tunnel/portal-tunnel-darwin-amd64",
171 - "darwin-arm64": "dist/tunnel/portal-tunnel-darwin-arm64",
172 - "windows-amd64": "dist/tunnel/portal-tunnel-windows-amd64.exe",
173 - "windows-arm64": "dist/tunnel/portal-tunnel-windows-arm64.exe",
174 - }[slug]
175 - if !ok {
220 + checksumRequest := strings.HasSuffix(slug, ".sha256")
221 + if checksumRequest {
222 + slug = strings.TrimSuffix(slug, ".sha256")
223 + }
224 +
225 + path, ok := tunnelBinaryAssetBySlug[slug]
226 + if !ok || strings.TrimSpace(path) == "" {
227 http.NotFound(w, r)
228 return
229 }
@@ -184,9 +235,25 @@ func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
235 return
236 }
237
238 + sum := sha256.Sum256(data)
239 + checksumHex := hex.EncodeToString(sum[:])
240 +
241 + if checksumRequest {
242 + w.Header().Set("Content-Type", "text/plain; charset=utf-8")
243 + w.Header().Set("Cache-Control", "public, max-age=600")
244 + w.WriteHeader(http.StatusOK)
245 + if r.Method == http.MethodGet {
246 + if _, err := fmt.Fprintf(w, "%s portal-tunnel-%s\n", checksumHex, slug); err != nil {
247 + log.Debug().Err(err).Str("slug", slug).Msg("failed to write tunnel checksum")
248 + }
249 + }
250 + return
251 + }
252 +
253 w.Header().Set("Content-Type", "application/octet-stream")
254 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"portal-tunnel-%s\"", slug))
255 w.Header().Set("Cache-Control", "public, max-age=600")
256 + w.Header().Set("X-Checksum-Sha256", checksumHex)
257 w.WriteHeader(http.StatusOK)
258 if r.Method == http.MethodGet {
259 if _, err := w.Write(data); err != nil {
@@ -194,3 +261,12 @@ func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
261 }
262 }
263 }
264 +
265 +var tunnelBinaryAssetBySlug = map[string]string{
266 + "linux-amd64": "dist/tunnel/portal-tunnel-linux-amd64",
267 + "linux-arm64": "dist/tunnel/portal-tunnel-linux-arm64",
268 + "darwin-amd64": "dist/tunnel/portal-tunnel-darwin-amd64",
269 + "darwin-arm64": "dist/tunnel/portal-tunnel-darwin-arm64",
270 + "windows-amd64": "dist/tunnel/portal-tunnel-windows-amd64.exe",
271 + "windows-arm64": "dist/tunnel/portal-tunnel-windows-arm64.exe",
272 +}
cmd/relay-server/tunnel_test.go new
+98
@@ -0,0 +1,98 @@
1 +package main
2 +
3 +import (
4 + "crypto/sha256"
5 + "encoding/hex"
6 + "net/http"
7 + "net/http/httptest"
8 + "strings"
9 + "testing"
10 +)
11 +
12 +func TestServeTunnelScriptIncludesShellChecksumVerification(t *testing.T) {
13 + req := httptest.NewRequest(http.MethodGet, "/tunnel?os=linux", http.NoBody)
14 + rec := httptest.NewRecorder()
15 +
16 + serveTunnelScript(rec, req)
17 +
18 + if rec.Code != http.StatusOK {
19 + t.Fatalf("serveTunnelScript status = %d, want %d", rec.Code, http.StatusOK)
20 + }
21 + body := rec.Body.String()
22 + if !strings.Contains(body, "CHECKSUM_URL=\"${BIN_URL}.sha256\"") {
23 + t.Fatalf("shell script missing checksum URL contract: %q", body)
24 + }
25 + if !strings.Contains(body, "sha256sum \"$BIN_PATH\"") {
26 + t.Fatalf("shell script missing sha256sum verification: %q", body)
27 + }
28 + if !strings.Contains(body, "fail-closed") {
29 + t.Fatalf("shell script missing fail-closed wording: %q", body)
30 + }
31 +}
32 +
33 +func TestServeTunnelScriptIncludesPowerShellChecksumVerification(t *testing.T) {
34 + req := httptest.NewRequest(http.MethodGet, "/tunnel?os=windows", http.NoBody)
35 + rec := httptest.NewRecorder()
36 +
37 + serveTunnelScript(rec, req)
38 +
39 + if rec.Code != http.StatusOK {
40 + t.Fatalf("serveTunnelScript status = %d, want %d", rec.Code, http.StatusOK)
41 + }
42 + body := rec.Body.String()
43 + if !strings.Contains(body, "$ChecksumUrl = \"$BinUrl.sha256\"") {
44 + t.Fatalf("powershell script missing checksum URL contract: %q", body)
45 + }
46 + if !strings.Contains(body, "Get-FileHash -Algorithm SHA256 -Path $BinPath") {
47 + t.Fatalf("powershell script missing SHA256 verification: %q", body)
48 + }
49 + if !strings.Contains(body, "fail-closed") {
50 + t.Fatalf("powershell script missing fail-closed wording: %q", body)
51 + }
52 +}
53 +
54 +func TestServeTunnelBinaryServesChecksumSidecarAndHeader(t *testing.T) {
55 + originalAssetMap := tunnelBinaryAssetBySlug
56 + tunnelBinaryAssetBySlug = map[string]string{
57 + "linux-amd64": "dist/app/portal.html",
58 + }
59 + t.Cleanup(func() {
60 + tunnelBinaryAssetBySlug = originalAssetMap
61 + })
62 +
63 + binaryReq := httptest.NewRequest(http.MethodGet, "/tunnel/bin/linux-amd64", http.NoBody)
64 + binaryRec := httptest.NewRecorder()
65 + serveTunnelBinary(binaryRec, binaryReq)
66 +
67 + if binaryRec.Code != http.StatusOK {
68 + t.Fatalf("serveTunnelBinary status = %d, want %d", binaryRec.Code, http.StatusOK)
69 + }
70 + sum := sha256.Sum256(binaryRec.Body.Bytes())
71 + wantChecksum := hex.EncodeToString(sum[:])
72 + if got := binaryRec.Header().Get("X-Checksum-Sha256"); got != wantChecksum {
73 + t.Fatalf("binary checksum header = %q, want %q", got, wantChecksum)
74 + }
75 +
76 + checksumReq := httptest.NewRequest(http.MethodGet, "/tunnel/bin/linux-amd64.sha256", http.NoBody)
77 + checksumRec := httptest.NewRecorder()
78 + serveTunnelBinary(checksumRec, checksumReq)
79 +
80 + if checksumRec.Code != http.StatusOK {
81 + t.Fatalf("serveTunnelBinary checksum status = %d, want %d", checksumRec.Code, http.StatusOK)
82 + }
83 + checksumBody := strings.TrimSpace(checksumRec.Body.String())
84 + if !strings.HasPrefix(checksumBody, wantChecksum+" portal-tunnel-linux-amd64") {
85 + t.Fatalf("checksum sidecar body = %q, want prefix %q", checksumBody, wantChecksum+" portal-tunnel-linux-amd64")
86 + }
87 +}
88 +
89 +func TestServeTunnelBinaryUnknownChecksumSlugReturnsNotFound(t *testing.T) {
90 + req := httptest.NewRequest(http.MethodGet, "/tunnel/bin/not-a-slug.sha256", http.NoBody)
91 + rec := httptest.NewRecorder()
92 +
93 + serveTunnelBinary(rec, req)
94 +
95 + if rec.Code != http.StatusNotFound {
96 + t.Fatalf("serveTunnelBinary status = %d, want %d", rec.Code, http.StatusNotFound)
97 + }
98 +}
cmd/relay-server/utils.go
+1 -1
@@ -222,7 +222,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
222 bannedList := serv.GetLeaseManager().GetBannedLeases()
223 bannedMap := make(map[string]struct{}, len(bannedList))
224 for _, b := range bannedList {
225 - bannedMap[string(b)] = struct{}{}
225 + bannedMap[b] = struct{}{}
226 }
227
228 for _, entry := range leaseEntries {
docs/adr/0003-security-and-anti-abuse-hardening.md
+2
@@ -15,6 +15,8 @@ Portal accepts unauthenticated internet traffic on relay/admin edges while manag
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 +- Allow trusted-proxy forwarded HTTPS as a secure `/sdk/connect` transport signal only when the peer is in the configured trusted-proxy CIDR allowlist.
19 +- Enforce installer binary integrity with mandatory SHA256 sidecar verification (`${BIN_URL}.sha256`) and fail-closed behavior on verification errors.
20
21 Operator setup remains unchanged: no new relay flags/env vars are introduced for anti-abuse behavior.
22
docs/architecture.md
+10 -1
@@ -73,21 +73,30 @@ Result: the relay handles SNI-based routing and transparent raw TCP forwarding,
73 ### 2. Reverse Connect
74
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:
76 + - `/sdk/connect` first validates secure transport + 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 + - Secure transport is accepted when either direct TLS is present, or forwarded HTTPS headers come from an allowlisted trusted proxy.
79 - `X-Portal-Reverse-Token` is validated at HTTP precheck, then validated again in `ReverseHub` with centralized policy callbacks before the connection is pooled.
80 - Connection is pooled in `ReverseHub` only after token/IP checks pass.
81
82 ### 3. Renew
83
84 - Backend sends `POST /sdk/renew` keepalive.
85 +- `/sdk/renew` requires both `lease_id` and `reverse_token`.
86 - Relay refreshes lease TTL and keeps route state current.
87
88 ### 4. Unregister
89
90 - Backend sends `POST /sdk/unregister`.
91 +- `/sdk/unregister` validates normalized `lease_id` before deletion.
92 - Relay removes lease, route, and reverse pool.
93
94 +## Admin Lease ID Contract
95 +
96 +- `/admin/leases` returns plain lease IDs in `Peer`.
97 +- `/admin/leases/banned` returns plain lease IDs (`[]string`).
98 +- Base64URL encoding is used only in admin action path segments (`/admin/leases/{encodedLeaseID}/{action}`).
99 +
100 ## Routing Behavior
101
102 `sni.Router` route lookup order:
portal/lease.go
+3 -3
@@ -284,12 +284,12 @@ func (lm *LeaseManager) UnbanLease(leaseID string) {
284 lm.leasesLock.Unlock()
285 }
286
287 -func (lm *LeaseManager) GetBannedLeases() [][]byte {
287 +func (lm *LeaseManager) GetBannedLeases() []string {
288 lm.leasesLock.RLock()
289 defer lm.leasesLock.RUnlock()
290 - banned := make([][]byte, 0, len(lm.bannedLeases))
290 + banned := make([]string, 0, len(lm.bannedLeases))
291 for id := range lm.bannedLeases {
292 - banned = append(banned, []byte(id))
292 + banned = append(banned, id)
293 }
294 return banned
295 }
portal/lease_test.go
+14
@@ -76,3 +76,17 @@ func TestLeaseManagerStopIsIdempotent(_ *testing.T) {
76 lm.Stop()
77 lm.Stop()
78 }
79 +
80 +func TestLeaseManagerGetBannedLeasesReturnsPlainLeaseIDs(t *testing.T) {
81 + lm := NewLeaseManager(time.Second)
82 + lm.BanLease("lease-a")
83 + lm.BanLease("lease-b")
84 +
85 + got := lm.GetBannedLeases()
86 + slices.Sort(got)
87 +
88 + want := []string{"lease-a", "lease-b"}
89 + if !slices.Equal(got, want) {
90 + t.Fatalf("GetBannedLeases() = %v, want %v", got, want)
91 + }
92 +}