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
+}