feat: update API routing and CORS settings across deployment configurations
Kim committed
May 29, 2026 at 16:27 UTC
6903d4eb0662120b51d2b91748ef8b3fb874a077
9 files changed
+23
-9
docs/src/routes/deployment/+page.md
+2
-2
@@ -44,7 +44,7 @@ Tunnel clients and public app visitors
44
| Public request | nginx behavior | Upstream |
45
|---|---|---|
46
| `portal.example.com/`, `/admin`, SPA assets | Terminate TLS, HTTP proxy | `portal-frontend:8080` |
47
-| `/admin/auth/*`, `/sdk/*`, `/install.*`, `/discovery`, `/healthz`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
47
+| `/admin/auth/*`, `/sdk/*`, `/install.*`, `/discovery`, `/discovery/*`, `/healthz`, `/v1/sign`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
48
| `/state`, `/policy/*`, `/service/status`, `/thumbnail/*` | Terminate TLS, HTTP proxy | `portal-frontend:8080`, then `portal-api:8081` |
49
| `*.portal.example.com` | Raw TCP passthrough with `ssl_preread` | `portal` SNI listener |
50
@@ -165,7 +165,7 @@ TRUSTED_PROXY_CIDRS=
165
LANDING_PAGE_ENABLED=false
166
```
167
168
-Keep `SNI_PORT=443` with the bundled nginx example because this is the public SNI port advertised to tunnel clients. The single-domain Compose example maps the relay container's SNI listener to `127.0.0.1:4443` on the host so nginx can own public `443/tcp` and still pass wildcard TCP traffic to the relay. Do not open `4443/tcp` publicly; it is only a host-local upstream in that example.
168
+`API_PORT` defaults to `4017`. If you change it, update the `portal_api` upstream in the bundled `nginx.conf` to the same port. Keep `SNI_PORT=443` because this is the public SNI port advertised to tunnel clients. The single-domain Compose example maps the relay container's SNI listener to `127.0.0.1:4443` on the host so nginx can own public `443/tcp` and still pass wildcard TCP traffic to the relay. Do not open `4443/tcp` publicly; it is only a host-local upstream in that example.
169
170
If the relay joins public discovery, set `BOOTSTRAPS` to at least one reachable relay URL and keep `WIREGUARD_PORT/udp` open.
171
docs/static/examples/nginx-proxy-multi-service/.env.example
+1
@@ -8,6 +8,7 @@ DISCOVERY=true
8
IDENTITY_PATH=/portal-certs
9
10
# Listener ports
11
+# If API_PORT changes, also update the portal_api upstream in nginx.conf.
12
API_PORT=4017
13
SNI_PORT=443
14
WIREGUARD_PORT=51820
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+2
-2
@@ -5,7 +5,7 @@
5
# Architecture:
6
# nginx:443 (L4 stream, ssl_preread)
7
# - portal.example.com -> nginx:8443 (L7 path split)
8
-# - relay-owned API paths -> portal:4017 (portal, HTTPS, Docker network only)
8
+# - relay-owned API paths -> portal:${API_PORT:-4017} (portal, HTTPS, Docker network only)
9
# - frontend/UI paths -> portal-frontend:8080 (HTTP)
10
# - *.portal.example.com -> portal:443 (portal SNI passthrough, Docker network only)
11
# - everything else -> nginx:8443 (L7 for other apps)
@@ -117,7 +117,7 @@ services:
117
# - headless-shell
118
environment:
119
PORT: 8081
120
- PORTAL_API_BASE_URL: https://portal:4017
120
+ PORTAL_API_BASE_URL: "https://portal:${API_PORT:-4017}"
121
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
122
PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
123
HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
docs/static/examples/nginx-proxy-multi-service/nginx.conf
+1
-1
@@ -170,7 +170,7 @@ http {
170
proxy_read_timeout 60s;
171
}
172
173
- location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
173
+ location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
174
proxy_pass https://portal_api;
175
proxy_ssl_verify off;
176
proxy_ssl_server_name on;
docs/static/examples/nginx-proxy/.env.example
+1
@@ -8,6 +8,7 @@ DISCOVERY=true
8
IDENTITY_PATH=/portal-certs
9
10
# Listener ports
11
+# If API_PORT changes, also update the portal_api upstream in nginx.conf.
12
API_PORT=4017
13
SNI_PORT=443
14
WIREGUARD_PORT=51820
docs/static/examples/nginx-proxy/docker-compose.yaml
+2
-2
@@ -4,7 +4,7 @@
4
# Architecture:
5
# nginx:443/tcp (L4 stream, ssl_preread)
6
# - portal.example.com -> 127.0.0.1:8443 (nginx L7, TLS termination)
7
-# - relay-owned API paths -> 127.0.0.1:4017 (portal, HTTPS, loopback only)
7
+# - relay-owned API paths -> 127.0.0.1:${API_PORT:-4017} (portal, HTTPS, loopback only)
8
# - frontend/UI paths -> 127.0.0.1:8080 (portal-frontend, HTTP, loopback only)
9
# - *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
10
# portal:443/udp (QUIC tunnel listener, only if UDP_ENABLED=true)
@@ -113,7 +113,7 @@ services:
113
# - headless-shell
114
environment:
115
PORT: 8081
116
- PORTAL_API_BASE_URL: https://portal:4017
116
+ PORTAL_API_BASE_URL: "https://portal:${API_PORT:-4017}"
117
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
118
PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
119
HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
docs/static/examples/nginx-proxy/nginx.conf
+1
-1
@@ -135,7 +135,7 @@ http {
135
# Relay API/control endpoints belong to portal. Exact /admin is
136
# intentionally not matched so the React admin route can be served by
137
# portal-frontend.
138
- location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
138
+ location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
139
proxy_pass https://portal_api;
140
proxy_ssl_verify off;
141
proxy_ssl_server_name on;
frontend/api/server.ts
+12
@@ -32,6 +32,8 @@ const CDP_TIMEOUT_MS = 5_000;
32
const HTTP_TIMEOUT_MS = 5_000;
33
const JSON_LIMIT = 1 << 20;
34
const THUMBNAIL_CONTENT_TYPE = "image/jpeg";
35
+const CORS_ALLOW_HEADERS = "Accept, Authorization, Content-Type, X-Portal-Access-Token";
36
+const CORS_ALLOW_METHODS = "GET, HEAD, POST, DELETE, OPTIONS";
37
38
type APIEnvelope<T> =
39
| { ok: true; data: T }
@@ -841,6 +843,16 @@ async function serveThumbnail(req: IncomingMessage, res: ServerResponse): Promis
843
844
const server = createServer((req, res) => {
845
void (async () => {
846
+ res.setHeader("Access-Control-Allow-Origin", "*");
847
+ res.setHeader("Access-Control-Allow-Methods", CORS_ALLOW_METHODS);
848
+ res.setHeader("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS);
849
+ res.setHeader("Access-Control-Max-Age", "600");
850
+ if (req.method === "OPTIONS") {
851
+ res.writeHead(204);
852
+ res.end();
853
+ return;
854
+ }
855
+
856
const url = new URL(req.url || "/", "http://api.local");
857
if (url.pathname === "/healthz") {
858
writeData(res, 200, { status: "ok" });
frontend/nginx.conf
+1
-1
@@ -15,7 +15,7 @@ server {
15
proxy_pass http://portal-api:8081;
16
}
17
18
- location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
18
+ location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery($|/)|healthz$|v1/sign$|x402(/|$)) {
19
proxy_http_version 1.1;
20
proxy_set_header Host $host;
21
proxy_set_header Authorization $http_authorization;