fix: portals fail to load through an HTTPS remote Gateway (#150279)

* fix: portals fail to load through an HTTPS remote Gateway

* fix: preserve LAN portals and cross-site authentication

* style: apply the pinned portal formatter

* fix: include portal config helper in PR wrapper inventory
This commit is contained in:
Josh Lehman 2026-09-20 01:26:32 -07:00 • committed by GitHub
parent 9f5ebbad1e
commit 07795a1ed5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2313 additions and 259 deletions

View file

@ -1,5 +1,5 @@
{
"core": 2449,
"core": 2453,
"channel": 3740,
"plugin": 4300
}

View file

@ -1,4 +1,4 @@
98c8e2356ebb472c6c3abe0782b3eed33328a42230e03fb2b08ef0a94d70a09f config-baseline.json
b88e4dba1685d12c108f6220261a539e4cc9c7272d9099ff9a3d8bfba12545ae config-baseline.core.json
157aa34a53a5f8862de93f8dc79b41d95ab47365a4c31ac1df8b2473ebc78e51 config-baseline.json
3c340fc4f6ed3b5d09c1a85d9529b5115869f6599606dcb27d25cf083dfa64fa config-baseline.core.json
51c84b118b136dfff84993e3cee0ff76ee06b614842b125d0fde110635fc10b8 config-baseline.channel.json
d285550718a17996891e49a4f69e0291eaae904a0e32dbc60567d4bfe183de83 config-baseline.plugin.json

View file

@ -16,7 +16,7 @@ Ask the agent to open a portal:
- "Show me in a portal."
- "Start the app in a portal."
The agent opens a portal for the application's port, then starts the development server with a background `exec` call. Opening a portal only creates the proxy listener; it does not inject environment variables into your server. The agent sets `PORT` (the port it opened) and `PUBLIC_URL` (the portal's public base URL) in that `exec` command's own environment, so the app binds the expected port and generates correct absolute URLs.
The agent opens a portal for the application's port, then starts the development server with a background `exec` call. Opening a portal allocates its proxy route; it does not inject environment variables into your server. The agent sets `PORT` (the port it opened) and `PUBLIC_URL` (the returned token-free `publicUrl`, including its initial path) in that `exec` command's own environment, so the app binds the expected port and generates correct absolute URLs.
For a session on a node-backed cloud worker, including the bundled Crabbox provider, the development server runs on the worker. Each portal connection receives its own single-use ticket, which the enrolled node redeems over a TLS-pinned WebSocket to the Gateway before connecting to the selected loopback port. This uses the existing authenticated node channel without exposing the worker to inbound traffic or creating an SSH tunnel. Stopping or replacing the worker closes its portals.
@ -35,6 +35,121 @@ background processes survive completed turns; stopping the attachment closes
its apps and portals. The portal retains the same separate-origin network and
access contract described below.
Opening a route does not prove that the application is running or that a remote
browser can reach it. Open the returned URL in **Control UI → Portals** and verify
that the page, assets, and live reload work. Use the returned URLs unchanged;
replacing their scheme, hostname, or port with the Gateway's address does not
create an ingress route.
## Remote access
Prefer managed private [Tailscale Serve](/gateway/tailscale) when the operator and
Gateway share a tailnet. Otherwise, configure the private wildcard ingress below.
A tunnel or reverse proxy that exposes only the Gateway endpoint does not expose
application portals.
### Managed private Tailscale Serve
With managed Gateway Tailscale ingress active, each portal gets its own private
Serve HTTPS port on the managed hostname. The portal owns a foreground route
claim until it closes. Closing it, losing its claim, stopping its worker, or
restarting the Gateway withdraws the route. Existing routes owned by other
applications are not adopted or cleared for portals.
Portal routes use **Serve, never Funnel**, even when the Gateway uses Funnel.
The operator's browser must be on the tailnet. Gateway access on port `443` does
not prove access to the portal's separate HTTPS port: restrictive tailnet grants
or ACLs must permit the port in the returned URL. OpenClaw does not edit those
policies. Portal bearer authentication remains required; Gateway identity-header
authentication does not grant portal access.
### Private wildcard reverse proxy
Use a dedicated DNS namespace such as `preview.example.net`, separate from the
Gateway and Control UI hostnames. Configure one private HTTPS wildcard route, not
one manual route per application:
```json5
{
gateway: {
portals: {
ingress: { domain: "preview.example.net", port: 18890 },
},
},
}
```
`domain` is a bare DNS suffix, without a scheme, wildcard prefix, path, or port.
`port` is the dedicated ingress listener's TCP port on `127.0.0.1`, not the
application port or the external HTTPS port. Apply this Gateway configuration
with a Gateway restart. When configured, wildcard ingress takes precedence over
managed portal Serve routes.
Set up the operator-owned reverse proxy as follows:
1. Resolve `*.preview.example.net` to a private HTTPS edge reachable by the
operator's browser. Provision a browser-trusted TLS certificate covering that
wildcard. Do not make the preview namespace publicly accessible by default.
2. Terminate HTTPS on port `443` and forward requests to
`http://127.0.0.1:18890` on the Gateway host. Preserve the original `Host`
header, full request path and query, and WebSocket upgrades. Disable response
buffering for streaming responses. Do not strip a path prefix or forward to
the Gateway's ordinary HTTP port.
3. Keep the edge private using network access controls or an explicit
identity-aware access policy covering the wildcard. Keep the loopback backend
inaccessible to untrusted hosts. Authentication for the Gateway hostname does
not automatically protect the preview wildcard. Remove edge-injected identity
and credential headers before forwarding to the backend. The portal strips
Tailscale and Cloudflare Access headers, but cannot identify every custom
authentication edge's headers. Application `Authorization` headers are preserved.
4. Open a portal and verify the exact returned HTTPS URL from the remote browser,
including assets, navigation, and WebSocket live reload. A host-local fetch or
successful Gateway connection is not this verification.
OpenClaw does not install DNS records, issue certificates, configure the reverse
proxy, or copy Gateway access policies to this namespace. A proxy on another
machine needs a separately secured path to the loopback backend; that path is
not created by this setting. Edge login pages or third-party cookie restrictions
may prevent iframe loading even when a new tab works.
Each portal receives a random per-lifetime hostname under the configured suffix.
Only active portal hostnames route to applications; unknown or closed hostnames
cannot select local ports or Gateway APIs. Closing a portal removes its mapping
and active connections. The shared ingress listener belongs to the Gateway;
the external wildcard DNS, certificate, and proxy remain operator-owned across
portal closures and Gateway restarts.
### Direct and local listeners
Without configured wildcard ingress or managed Tailscale ingress, the returned
URL describes the actual direct listener and its HTTP or TLS scheme. Direct
listeners use the Gateway's bind interfaces. Wildcard listeners advertise the
Gateway's discovered private LAN IPv4 address when available, so browsers on that
LAN can use the returned URL without configuring ingress. The published address
stays fixed for the portal's lifetime; reopen the portal after a network change.
If no private LAN address is available, wildcard listeners publish a loopback URL.
A loopback URL works on the Gateway host, not on an unrelated remote browser.
Direct TLS listeners reuse the Gateway certificate. The service prefers a
certificate-valid hostname from `gateway.publicOrigin` or the configured Control
UI origins, then a certificate-valid bind address, then a concrete DNS name from
the certificate. Wildcard certificate names alone cannot identify a destination;
configure the existing `gateway.publicOrigin` when a concrete hostname is needed.
The hostname must resolve to the Gateway and the returned port must be reachable.
No additional portal ingress configuration is required for direct TLS.
Discovery and certificate names do not establish reachability through firewalls,
container port mappings, or remote proxies. A Gateway-only HTTPS proxy still
requires one of the ingress paths above; changing the displayed URL does not
expose its portal ports.
HTTPS portals use secure partitioned cookies so their authentication also works
when the Control UI is on another site. A direct HTTP portal can embed when the
Control UI uses the same scheme and hostname; different ports are supported.
Otherwise, the Portals page offers a new-tab launch instead of an embedded preview
whose authentication cookies may be blocked. The link keeps the service-published
URL unchanged. Applications that explicitly restrict their own cookies with
`SameSite=Strict` or `SameSite=Lax` retain that policy.
## Declare development servers
Optionally commit `.openclaw/portals.json` to the workspace repository so the agent can discover the available development servers:
@ -78,7 +193,7 @@ Streaming HTTP responses, including server-sent events, forward response headers
## Availability and configuration
Portals add no dedicated configuration key. The `portal` tool follows ordinary tool policy, described in [Tools configuration](/gateway/config-tools).
The `portal` tool follows ordinary tool policy, described in [Tools configuration](/gateway/config-tools). The optional `gateway.portals.ingress` setting configures only the private wildcard ingress described above; it does not grant tool access.
Out of the box:
@ -105,22 +220,32 @@ To turn them off for a single agent, leaving the others unchanged:
`tools.profile`, `tools.allow`, `byProvider`, and `toolsBySender` apply to `portal` as they do to any other tool, so portals can also be limited to specific providers, models, or senders without a portal-specific setting.
One consequence worth planning for: portal listeners bind the same interfaces as the Gateway. A Gateway bound to a LAN or tailnet address publishes its portal listener ports on that network too. Reaching one still requires the portal token, but deny the tool when the Gateway host must not offer operator-reachable application ports at all.
In direct mode, portal listeners bind the same interfaces as the Gateway. A Gateway bound to a LAN or tailnet address publishes its direct portal listener ports on that network too. Managed Serve uses a private loopback backend; configured wildcard ingress uses the dedicated loopback listener. Reaching one still requires the portal token, but deny the tool when the Gateway host must not offer operator-reachable application ports at all.
## Security model
Each portal uses a separate origin on its own port and binds to the same interfaces as the Gateway. Access requires the token in the portal URL. On the first request, the proxy stores that token in an HttpOnly cookie and removes it from subsequent upstream requests. The proxy validates this cookie itself and never forwards it to the application.
Each portal uses a separate origin: its own port in direct/Serve mode, or its own hostname with wildcard ingress. Never mount an arbitrary application on the Control UI origin, even under a different URL path. Access requires the token in the portal URL. On the first request, the proxy stores that token in an HttpOnly cookie and removes it from subsequent upstream requests. The proxy validates this cookie itself and never forwards it to the application.
Browser cookies are hostname-scoped rather than port-scoped, so the proxy gives each portal instance a random `oc_portal_<instance>_` cookie-name prefix. Requests forward only cookies with the current portal's prefix and strip it before reaching the application; Gateway cookies, unprefixed cookies, and cookies from sibling or closed portals are dropped. Application `Set-Cookie` responses receive the prefix, and any `Domain` attribute is removed so the cookie stays host-only.
Wildcard ingress uses `Secure; SameSite=None; Partitioned` for portal authentication so embedded requests can remain authenticated under a different top-level site. Application cookies also receive `Secure` and `Partitioned`; cookies without an explicit `SameSite` attribute receive `SameSite=None`. Explicit application `SameSite=Lax` or `SameSite=Strict` restrictions remain unchanged and may prevent cross-site embedded sessions. Partitioned cookies are scoped to the top-level site, so opening the portal in a new tab can create a separate application session. Browser policies can still block embedding; the proxy does not override them.
The service returns `publicUrl` as the authoritative token-free application URL
and `url` as its authenticated launch URL. `listenPort` is transport metadata,
not a browser URL template. Read-only listings and change events omit `url` and
`tokenQuery`; authorized clients refetch them with write access. Do not put the
bearer credential in `PUBLIC_URL` or share it in logs or screenshots.
Portals proxy only the selected development server on the Gateway host or a node-backed cloud worker. Worker connections use single-use tickets and the enrolled node's TLS-pinned Gateway connection; they never expose a public worker port or require SSH forwarding. Portals never serve Gateway data, and every portal ends when the Gateway restarts.
## Limitations
- Older node bundles without portal-stream support cannot open worker portals. Update the node bundle, or move the session back to the Gateway with `sessions.move`.
- SSH-backed `remote-exec` placements, including Codex sessions, do not run the OpenClaw worker tool loop, so the `portal` tool does not apply there. Move the session back to the Gateway with `sessions.move` when a Gateway-hosted portal is needed.
- A proxy or tunnel in front of the Gateway does not automatically expose portal listener ports. The Control UI detects this and shows a reachable URL with retry guidance instead of mounting a dead iframe.
- The prefix isolates cookies forwarded to each target; it does not create separate browser cookie jars. Browser-side code can see non-`HttpOnly` cookies for sibling portals on the same hostname through `document.cookie`. Use `HttpOnly` for sensitive application cookies. Applications that manage cookies in browser code must account for the prefix; unprefixed cookies written directly by browser code are not forwarded to the target.
- A Gateway-only proxy, SSH tunnel, or externally managed Serve route does not automatically create portal ingress. Configure private wildcard ingress or OpenClaw-managed Serve. The UI reports a remote loopback URL as requiring ingress; it does not invent a reachable URL.
- Browser reachability probes check transport only. A response can be an authentication page or a waiting page, not a rendered application. A Content Security Policy-blocked probe says nothing about iframe reachability.
- Portal ingress does not inherit Gateway trusted-proxy identities, Cloudflare Access policies, or tailnet ACL grants. Configure and verify those boundaries separately.
- The prefix isolates cookies forwarded to each target; it does not create separate browser cookie jars. In direct/Serve mode, browser-side code can see non-`HttpOnly` cookies for sibling portals on the same hostname through `document.cookie`. Wildcard ingress separates hostnames, but portals under a common DNS suffix are not necessarily separate sites, and the cookie-name prefix still applies. Use `HttpOnly` for sensitive application cookies. Applications that manage cookies in browser code must account for the prefix; unprefixed cookies written directly by browser code are not forwarded to the target.
## Troubleshooting
@ -130,7 +255,26 @@ The proxy is ready, but the application is not listening on the selected port or
### The portal is not reachable from this browser
The Control UI could reach the Gateway but could not reach the portal's separate listener port. This commonly happens when a proxy or tunnel exposes only the main Gateway port. Open the displayed portal URL from a browser on the Gateway host, or expose that portal listener port through the same network path, then select **Retry**.
Check the exact returned portal URL rather than substituting the Gateway host:
- **Loopback URL with a remote Gateway:** open it on the Gateway host, or configure
managed private Serve or wildcard ingress. Forwarding only the Gateway port
does not forward the portal.
- **Managed Serve URL times out:** verify tailnet membership and access to the
returned HTTPS port, not only `443`. Check that its managed claim remains active.
- **Wildcard URL fails DNS or TLS:** check wildcard DNS and certificate coverage.
Confirm that the private edge is reachable from the browser.
- **Wildcard URL returns an unknown-host response:** preserve the original `Host`
header and reopen the portal if its lifetime ended. Do not rewrite it to the
loopback backend hostname.
- **Page loads but streaming or live reload fails:** preserve WebSocket upgrades
and request paths, disable buffering, and check the app's `PUBLIC_URL`.
- **New tab works but the preview does not:** inspect edge authentication,
frame policies, and browser cookie restrictions. A blocked reachability probe
alone does not prove the iframe is unreachable.
After correcting ingress, select **Retry**. Reopen a portal if its route was
withdrawn, and restart the application with the new `PUBLIC_URL` when it changes.
### Close a portal

View file

@ -27,6 +27,11 @@ The Gateway WebSocket binds to **loopback** by default, on port `18789` (`gatewa
For the always-on and laptop setups, prefer keeping `gateway.bind: "loopback"` and using **Tailscale Serve** for the Control UI, or a trusted LAN/Tailnet bind with `gateway.remote.transport: "direct"`. SSH tunnel is the fallback that works from any machine.
Application previews need their own private ingress. A tunnel that forwards only
the Gateway port does not forward portals. Use [managed private Serve or wildcard
portal ingress](/gateway/portals#remote-access); the browser and application must
use the service's returned portal URLs without replacing their host or port.
## Command flow (what runs where)
One Gateway owns state and channels; nodes are peripherals. Example (Telegram message routed to a node tool):

View file

@ -136,7 +136,7 @@ This compatibility path does not grant managed Tailscale semantics. `gateway.aut
- `gateway.tailscale.preserveFunnel: true` is a deprecated migration guard. It detects an externally configured `tailscale funnel` route before reapplying Serve. If that route still targets the ordinary Gateway listener, OpenClaw leaves it unchanged and warns because the route is not managed ingress. Gateway-authenticated routes work only through the explicit `trustedProxies` compatibility path above and continue to require the configured auth. Plugin-authenticated webhook routes such as Google Chat and SMS keep using their own signature and auth checks. To migrate, first configure a durable `gateway.auth.password` (prefer a SecretRef) or `OPENCLAW_GATEWAY_PASSWORD`. Set `gateway.auth.mode` to `password`. Run `openclaw config set gateway.tailscale.mode funnel`. Then run `openclaw config unset gateway.tailscale.preserveFunnel`.
- `gateway.bind: "tailnet"` uses a direct Tailnet bind (no HTTPS, no Serve/Funnel) plus required local `127.0.0.1` when a Tailnet IPv4 is available. Otherwise it falls back to loopback only.
- `gateway.bind: "auto"` uses `0.0.0.0` in detected containers and prefers loopback otherwise. Use `tailnet` to limit direct network exposure to the Tailnet while retaining same-host loopback access.
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over the same Gateway WS endpoint, so Serve works for node access too.
- The main Serve/Funnel route exposes the **Gateway Control UI + WS**. Nodes connect over that same WS endpoint. [Portals](/gateway/portals#managed-private-tailscale-serve) allocate separate private Serve HTTPS ports; they never inherit Funnel exposure. Tailnet grants or ACLs must allow the portal ports as well as the Gateway port. Externally managed routes do not automatically allocate portal ingress.
### Tailscale prerequisites and limits

View file

@ -337,6 +337,7 @@ src/config/env-vars.ts
src/config/future-version-guard.ts
src/config/gateway-control-ui-origins.ts
src/config/gateway-env-selection.ts
src/config/gateway-portal-ingress.ts
src/config/github-identity-profile-id.ts
src/config/include-write-boundary.ts
src/config/includes.ts

View file

@ -6,7 +6,7 @@ import {
} from "../../../packages/gateway-protocol/src/schema/portals.js";
export const PORTAL_TOOL_DESCRIPTION =
"Expose a local HTTP server or a conversation-attached environment's HTTP server (environmentId); operator sees it live in Control UI. Order matters: action=open with the port first, which returns the URL; then start the dev server as a background process on the same host, passing PORT and PUBLIC_URL from that result. Workspace may declare servers in .openclaw/portals.json. Proxies HTTP and WebSockets, so hot reload works; serves retry page until port listens. action=list and action=close manage portals. Portals end at gateway restart.";
"Expose a local HTTP server or a conversation-attached environment's HTTP server (environmentId) through a portal route; verify browser access and app rendering in Control UI. Order matters: action=open with the port first, which returns the URL; then start the dev server as a background process on the same host, passing PORT and PUBLIC_URL from that result. Workspace may declare servers in .openclaw/portals.json. Proxies HTTP and WebSockets, so hot reload works; serves retry page until port listens. action=list and action=close manage portals. Use returned URLs unchanged; remote access requires private ingress or a reachable direct listener. Portals end at gateway restart.";
export const PortalToolSchema = Type.Object(
{

View file

@ -23,8 +23,8 @@ const portal: PortalSummary = {
port: 3000,
listenPort: 43123,
tokenQuery: `openclaw_portal=${"a".repeat(64)}`,
url: `http://127.0.0.1:43123/?openclaw_portal=${"a".repeat(64)}`,
publicUrl: "http://127.0.0.1:43123/",
url: `https://preview.example.test:8443/app?view=one%2Ftwo&openclaw_portal=${"a".repeat(64)}`,
publicUrl: "https://preview.example.test:8443/app?view=one%2Ftwo",
createdAtMs: 1,
};
@ -96,7 +96,7 @@ describe("portal tool", () => {
expect(opened.details).toEqual(portal);
expect(opened.content[0]).toMatchObject({
type: "text",
text: `Portal available at ${portal.url}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. The operator can see it in the Control UI Portals page.`,
text: `Portal route allocated at ${portal.url}. Pass PUBLIC_URL=${portal.publicUrl} and PORT=${portal.port} when starting the dev server. Open it in the Control UI Portals page to verify browser access and application rendering; allocation does not prove either. Remote access requires private portal ingress or a reachable direct listener.`,
});
expect(listed.details).toEqual({ portals: [portal] });
// Listing asks for write scope so the bearer URL is not redacted away from a

View file

@ -43,7 +43,7 @@ export function formatPortalResult(
): AgentToolResult<PortalSummary | PortalListResult | PortalCloseResult> {
const text =
outcome.action === "open"
? `Portal available at ${outcome.result.url}. Pass PUBLIC_URL=${outcome.result.publicUrl} and PORT=${outcome.result.port} when starting the dev server. The operator can see it in the Control UI Portals page.`
? `Portal route allocated at ${outcome.result.url}. Pass PUBLIC_URL=${outcome.result.publicUrl} and PORT=${outcome.result.port} when starting the dev server. Open it in the Control UI Portals page to verify browser access and application rendering; allocation does not prove either. Remote access requires private portal ingress or a reachable direct listener.`
: outcome.action === "list"
? `${outcome.result.portals.length} active portal${outcome.result.portals.length === 1 ? "" : "s"}. The operator can see them in the Control UI Portals page.`
: `Portal ${outcome.id} closed. The Control UI Portals page has been updated.`;

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { GatewayConfigSchema } from "./zod-schema.gateway.js";
const ingress = { domain: "previews.example.net", port: 18890 };
describe("private portal ingress config", () => {
it("accepts the dedicated private wildcard contract without additional options", () => {
expect(
GatewayConfigSchema.safeParse({
publicOrigin: "https://control.example.net",
portals: { ingress },
}).success,
).toBe(true);
expect(
GatewayConfigSchema.safeParse({ portals: { ingress: { ...ingress, public: true } } }).success,
).toBe(false);
});
it.each([
"https://previews.example.net",
"*.example.net",
"localhost",
"127.0.0.1",
"example.net:443",
"example.net/path",
"example.net.",
"-bad.example.net",
`${"a".repeat(64)}.example.net`,
])("rejects unsafe domain %s", (domain) => {
expect(
GatewayConfigSchema.safeParse({ portals: { ingress: { ...ingress, domain } } }).success,
).toBe(false);
});
it.each([0, -1, 65536, 1.5, 18789])("rejects invalid or conflicting port %s", (port) => {
expect(
GatewayConfigSchema.safeParse({ portals: { ingress: { ...ingress, port } } }).success,
).toBe(false);
});
it.each([
"https://previews.example.net",
"https://control.previews.example.net",
"https://CONTROL.PREVIEWS.EXAMPLE.NET:8443",
])("rejects Gateway and Control UI hostname collision %s", (origin) => {
expect(
GatewayConfigSchema.safeParse({ publicOrigin: origin, portals: { ingress } }).success,
).toBe(false);
expect(
GatewayConfigSchema.safeParse({
controlUi: { allowedOrigins: [origin] },
portals: { ingress },
}).success,
).toBe(false);
});
});

View file

@ -0,0 +1,29 @@
import { isIP } from "node:net";
/** Validate a bare DNS suffix with room for a random per-portal hostname label. */
export function isValidPortalIngressDomain(domain: string): boolean {
try {
if (new URL(`https://portal.${domain}`).hostname !== `portal.${domain.toLowerCase()}`) {
return false;
}
} catch {
return false;
}
return (
domain.length <= 220 &&
domain.includes(".") &&
isIP(domain) === 0 &&
domain.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))
);
}
/** Reject a portal namespace containing a known Gateway/Control UI hostname. */
export function portalIngressConflictsWithOrigin(domain: string, origin: string): boolean {
try {
const hostname = new URL(origin).hostname.toLowerCase();
const suffix = domain.toLowerCase();
return hostname === suffix || hostname.endsWith(`.${suffix}`);
} catch {
return false;
}
}

View file

@ -0,0 +1,71 @@
// Defines the Gateway config labels exposed by schema metadata.
export const GATEWAY_FIELD_LABELS: Record<string, string> = {
gateway: "Gateway",
"gateway.port": "Gateway Port",
"gateway.mode": "Gateway Mode",
"gateway.bind": "Gateway Bind Mode",
"gateway.customBindHost": "Gateway Custom Bind Host",
"gateway.portals": "Gateway Portals",
"gateway.portals.ingress": "Private Portal Ingress",
"gateway.portals.ingress.domain": "Portal Wildcard Domain",
"gateway.portals.ingress.port": "Portal Ingress Loopback Port",
"gateway.publicOrigin": "Gateway Public Origin",
"gateway.controlUi": "Control UI",
"gateway.controlUi.enabled": "Control UI Enabled",
"gateway.cliAgents": "CLI Agents",
"gateway.cliAgents.enabled": "CLI Agents Enabled",
"gateway.terminal": "Operator Terminal",
"gateway.terminal.enabled": "Operator Terminal Enabled",
"gateway.terminal.shell": "Operator Terminal Shell",
"gateway.terminal.detachedSessionTimeoutSeconds": "Operator Terminal Detached Session Timeout",
"gateway.auth": "Gateway Auth",
"gateway.auth.mode": "Gateway Auth Mode",
"gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity",
"gateway.auth.identityScopes": "Gateway Identity Scope Grants",
"gateway.auth.rateLimit": "Gateway Auth Rate Limit",
"gateway.auth.trustedProxy": "Gateway Trusted Proxy Auth",
"gateway.auth.trustedProxy.deviceAutoApprove": "Trusted Proxy Device Auto-Approval",
"gateway.auth.trustedProxy.deviceAutoApprove.enabled":
"Trusted Proxy Device Auto-Approval Enabled",
"gateway.auth.trustedProxy.deviceAutoApprove.scopes": "Trusted Proxy Device Auto-Approval Scopes",
"gateway.roles": "Gateway Operator Roles",
"gateway.roles.default": "Default Operator Role",
"gateway.roles.definitions": "Operator Role Definitions",
"gateway.roles.definitions.*": "Operator Role Definition",
"gateway.roles.definitions.*.sessions": "Operator Role Session Access",
"gateway.roles.definitions.*.sessions.others": "Operator Role Access to Other Sessions",
"gateway.roles.definitions.*.sandbox": "Operator Role Sandbox Isolation",
"gateway.roles.definitions.*.agents": "Operator Role Allowed Agents",
"gateway.roles.definitions.*.scopes": "Operator Role Scope Ceiling",
"gateway.trustedProxies": "Gateway Trusted Proxy CIDRs",
"gateway.allowRealIpFallback": "Gateway Allow x-real-ip Fallback",
"gateway.tools": "Gateway Tool Exposure Policy",
"gateway.tools.allow": "Gateway Tool Allowlist",
"gateway.tools.deny": "Gateway Tool Denylist",
"gateway.tailscale": "Gateway Tailscale",
"gateway.tailscale.mode": "Gateway Tailscale Mode",
"gateway.tailscale.preserveFunnel": "Gateway Tailscale External Funnel Migration Guard",
"gateway.remote": "Remote Gateway",
"gateway.remote.transport": "Remote Gateway Transport",
"gateway.reload": "Config Reload",
"gateway.tls": "Gateway TLS",
"gateway.tls.enabled": "Gateway TLS Enabled",
"gateway.tls.autoGenerate": "Gateway TLS Auto-Generate Cert",
"gateway.tls.certPath": "Gateway TLS Certificate Path",
"gateway.tls.keyPath": "Gateway TLS Key Path",
"gateway.tls.caPath": "Gateway TLS CA Path",
"gateway.http": "Gateway HTTP API",
"gateway.http.endpoints": "Gateway HTTP Endpoints",
"gateway.http.securityHeaders": "Gateway HTTP Security Headers",
"gateway.http.securityHeaders.strictTransportSecurity": "Strict Transport Security Header",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
"gateway.remote.sshHostKeyPolicy": "Remote Gateway SSH Host-Key Policy",
"gateway.remote.token": "Remote Gateway Token",
"gateway.remote.password": "Remote Gateway Password",
"gateway.remote.edgeAuth": "Remote Gateway Edge Auth Headers",
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
};

View file

@ -230,6 +230,13 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.",
"gateway.portals": "Portal publication and private ingress settings.",
"gateway.portals.ingress":
"Optional operator-managed private HTTPS wildcard reverse proxy. Forward original Host, paths, and WebSocket upgrades to the dedicated loopback listener. Portal bearer authentication remains required. Requires Gateway restart.",
"gateway.portals.ingress.domain":
"Bare DNS domain for random portal subdomains (for example previews.example.net). Configure wildcard DNS and HTTPS privately; do not share the Gateway or Control UI hostname namespace.",
"gateway.portals.ingress.port":
"Dedicated loopback HTTP port receiving the private wildcard HTTPS proxy. Must differ from the Gateway port. This is the backend port, not the public HTTPS port.",
"gateway.publicOrigin":
"Externally reachable HTTPS origin of the Gateway. HTTP is allowed only for localhost, 127.0.0.1, or [::1]. Per-requester MCP OAuth uses it to build the callback URL at /oauth/mcp/callback; channel session links and plugin-generated viewer links use it to reach the Control UI and Gateway routes.",
"mcp.apps":

View file

@ -1,5 +1,6 @@
// Defines user-facing config field labels used by schema metadata.
import { MEDIA_AUDIO_FIELD_LABELS } from "./media-audio-field-metadata.js";
import { GATEWAY_FIELD_LABELS } from "./schema.gateway-labels.js";
import { AGENT_MODEL_FIELD_LABELS } from "./schema.labels.agent-models.js";
import { SESSION_FIELD_LABELS } from "./schema.labels.session.js";
import { META_FIELD_LABELS } from "./schema.meta.js";
@ -117,70 +118,7 @@ export const FIELD_LABELS: Record<string, string> = {
cloudWorkers: "Cloud Workers",
...CLOUD_WORKER_FIELD_LABELS,
...DESKTOP_FIELD_LABELS,
gateway: "Gateway",
"gateway.port": "Gateway Port",
"gateway.mode": "Gateway Mode",
"gateway.bind": "Gateway Bind Mode",
"gateway.customBindHost": "Gateway Custom Bind Host",
"gateway.publicOrigin": "Gateway Public Origin",
"gateway.controlUi": "Control UI",
"gateway.controlUi.enabled": "Control UI Enabled",
"gateway.cliAgents": "CLI Agents",
"gateway.cliAgents.enabled": "CLI Agents Enabled",
"gateway.terminal": "Operator Terminal",
"gateway.terminal.enabled": "Operator Terminal Enabled",
"gateway.terminal.shell": "Operator Terminal Shell",
"gateway.terminal.detachedSessionTimeoutSeconds": "Operator Terminal Detached Session Timeout",
"gateway.auth": "Gateway Auth",
"gateway.auth.mode": "Gateway Auth Mode",
"gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity",
"gateway.auth.identityScopes": "Gateway Identity Scope Grants",
"gateway.auth.rateLimit": "Gateway Auth Rate Limit",
"gateway.auth.trustedProxy": "Gateway Trusted Proxy Auth",
"gateway.auth.trustedProxy.deviceAutoApprove": "Trusted Proxy Device Auto-Approval",
"gateway.auth.trustedProxy.deviceAutoApprove.enabled":
"Trusted Proxy Device Auto-Approval Enabled",
"gateway.auth.trustedProxy.deviceAutoApprove.scopes": "Trusted Proxy Device Auto-Approval Scopes",
"gateway.roles": "Gateway Operator Roles",
"gateway.roles.default": "Default Operator Role",
"gateway.roles.definitions": "Operator Role Definitions",
"gateway.roles.definitions.*": "Operator Role Definition",
"gateway.roles.definitions.*.sessions": "Operator Role Session Access",
"gateway.roles.definitions.*.sessions.others": "Operator Role Access to Other Sessions",
"gateway.roles.definitions.*.sandbox": "Operator Role Sandbox Isolation",
"gateway.roles.definitions.*.agents": "Operator Role Allowed Agents",
"gateway.roles.definitions.*.scopes": "Operator Role Scope Ceiling",
"gateway.trustedProxies": "Gateway Trusted Proxy CIDRs",
"gateway.allowRealIpFallback": "Gateway Allow x-real-ip Fallback",
"gateway.tools": "Gateway Tool Exposure Policy",
"gateway.tools.allow": "Gateway Tool Allowlist",
"gateway.tools.deny": "Gateway Tool Denylist",
"gateway.tailscale": "Gateway Tailscale",
"gateway.tailscale.mode": "Gateway Tailscale Mode",
"gateway.tailscale.preserveFunnel": "Gateway Tailscale External Funnel Migration Guard",
"gateway.remote": "Remote Gateway",
"gateway.remote.transport": "Remote Gateway Transport",
"gateway.reload": "Config Reload",
"gateway.tls": "Gateway TLS",
"gateway.tls.enabled": "Gateway TLS Enabled",
"gateway.tls.autoGenerate": "Gateway TLS Auto-Generate Cert",
"gateway.tls.certPath": "Gateway TLS Certificate Path",
"gateway.tls.keyPath": "Gateway TLS Key Path",
"gateway.tls.caPath": "Gateway TLS CA Path",
"gateway.http": "Gateway HTTP API",
"gateway.http.endpoints": "Gateway HTTP Endpoints",
"gateway.http.securityHeaders": "Gateway HTTP Security Headers",
"gateway.http.securityHeaders.strictTransportSecurity": "Strict Transport Security Header",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
"gateway.remote.sshHostKeyPolicy": "Remote Gateway SSH Host-Key Policy",
"gateway.remote.token": "Remote Gateway Token",
"gateway.remote.password": "Remote Gateway Password",
"gateway.remote.edgeAuth": "Remote Gateway Edge Auth Headers",
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
...GATEWAY_FIELD_LABELS,
browser: "Browser",
"browser.enabled": "Browser Enabled",
"browser.allowSystemProfileImport": "Allow System Profile Import",

View file

@ -99,6 +99,11 @@ export type GatewayTailscaleConfig = Omit<
preserveFunnel?: boolean;
};
/** Operator-provisioned private HTTPS wildcard portal ingress. */
export type GatewayPortalIngressConfig = NonNullable<
NonNullable<GatewayConfigInput["portals"]>["ingress"]
>;
export type GatewayRemoteConfig = NonNullable<GatewayConfigInput["remote"]>;
/**

View file

@ -12,6 +12,10 @@ import {
TALK_SECRETS_SCOPE,
WRITE_SCOPE,
} from "../gateway/operator-scopes.js";
import {
isValidPortalIngressDomain,
portalIngressConflictsWithOrigin,
} from "./gateway-portal-ingress.js";
import {
GatewayRemoteConfigSchema,
ResponsesEndpointUrlFetchShape,
@ -97,6 +101,22 @@ export const GatewayConfigSchema = z
"gateway.publicOrigin must be a bare HTTPS origin; HTTP is allowed only for localhost, 127.0.0.1, or [::1]",
)
.optional(),
/** Private HTTPS wildcard proxy forwarding to a dedicated loopback listener. */
portals: z
.strictObject({
ingress: z
.strictObject({
domain: z
.string()
.refine(
isValidPortalIngressDomain,
"Portal ingress domain must be a bare DNS domain",
),
port: z.number().int().min(1).max(65_535),
})
.optional(),
})
.optional(),
controlUi: z
.strictObject({
// Shipped legacy input. Doctor removes it after recording migration state.
@ -480,4 +500,27 @@ export const GatewayConfigSchema = z
})
.optional(),
})
.superRefine((gateway, ctx) => {
const ingress = gateway.portals?.ingress;
if (!ingress) {
return;
}
const origins = [gateway.publicOrigin, ...(gateway.controlUi?.allowedOrigins ?? [])];
if (
origins.some((origin) => origin && portalIngressConflictsWithOrigin(ingress.domain, origin))
) {
ctx.addIssue({
code: "custom",
path: ["portals", "ingress", "domain"],
message: "Portal ingress must use a separate domain from Gateway and Control UI origins",
});
}
if (ingress.port === (gateway.port ?? 18789)) {
ctx.addIssue({
code: "custom",
path: ["portals", "ingress", "port"],
message: "Portal ingress port must differ from the Gateway port",
});
}
})
.optional();

View file

@ -149,7 +149,7 @@ const AGENT_ROSTER_RELOAD_ACTIONS: readonly ReloadAction[] = [
const CORE_RELOAD_POLICIES: ReloadPolicy[] = [
{ prefixes: ["gateway.remote", "gateway.reload"], kind: "none" },
{
prefixes: [...AUTH_CREDENTIAL_PATHS, "mcp.apps", "secrets.egressProxy"],
prefixes: [...AUTH_CREDENTIAL_PATHS, "mcp.apps", "secrets.egressProxy", "gateway.portals"],
kind: "restart",
},
{

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildGatewayReloadPlan } from "./config-reload-plan.js";
describe("portal ingress reload ownership", () => {
it.each([
"gateway.portals",
"gateway.portals.ingress",
"gateway.portals.ingress.domain",
"gateway.portals.ingress.port",
])("restarts listener ownership for %s", (path) => {
const plan = buildGatewayReloadPlan([path]);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toContain(path);
});
});

View file

@ -26,6 +26,7 @@ let targetPort = 0;
let targetHandler: (req: IncomingMessage, res: ServerResponse) => void;
let targetWebSocketPath: string | undefined;
let targetWebSocketCookie: string | undefined;
let targetWebSocketHeaders: IncomingMessage["headers"] | undefined;
let targetWebSocketSetCookie: string | undefined;
const targetServer = createServer((req, res) => targetHandler(req, res));
const targetWss = new WebSocketServer({ server: targetServer });
@ -37,6 +38,7 @@ beforeAll(async () => {
targetWss.on("connection", (socket, req) => {
targetWebSocketPath = req.url;
targetWebSocketCookie = req.headers.cookie;
targetWebSocketHeaders = req.headers;
socket.on("message", (data) => socket.send(data));
});
targetWss.on("headers", (headers) => {
@ -70,6 +72,7 @@ afterEach(async () => {
temporaryTargetServers.clear();
targetWebSocketPath = undefined;
targetWebSocketCookie = undefined;
targetWebSocketHeaders = undefined;
targetWebSocketSetCookie = undefined;
});
@ -310,6 +313,7 @@ describe("portal HTTP proxy", () => {
});
it("streams HTTP requests and responses with rewritten safe headers", async () => {
let receivedHeaders: IncomingMessage["headers"] | undefined;
let received:
| {
host?: string;
@ -320,6 +324,7 @@ describe("portal HTTP proxy", () => {
}
| undefined;
targetHandler = (req, res) => {
receivedHeaders = req.headers;
received = {
host: req.headers.host,
cookie: req.headers.cookie,
@ -344,6 +349,13 @@ describe("portal HTTP proxy", () => {
Cookie: `openclaw_plugin_tab=secret; ${portalAuthCookie(portal)}`,
Connection: "keep-alive, x-remove-me",
"X-Remove-Me": "remove",
"Tailscale-User-Login": "private@example.test",
Forwarded: "host=forged.example;proto=https",
"X-Forwarded-Port": "444",
"X-Real-IP": "192.0.2.1",
"Cf-Access-Jwt-Assertion": "synthetic-edge-assertion",
"Cf-Access-Client-Secret": "synthetic-edge-secret",
Authorization: "Bearer synthetic-app-token",
},
});
@ -355,9 +367,21 @@ describe("portal HTTP proxy", () => {
expect(received).toMatchObject({
host: `localhost:${targetPort}`,
proto: "http",
forwardedHost: "portal.example:9999",
forwardedHost: new URL(portal.publicUrl).host,
});
expect(received?.cookie).toBeUndefined();
expect(receivedHeaders?.authorization).toBe("Bearer synthetic-app-token");
for (const name of [
"cf-access-jwt-assertion",
"cf-access-client-secret",
"tailscale-user-login",
"forwarded",
"x-forwarded-port",
"x-real-ip",
"x-remove-me",
]) {
expect(receivedHeaders?.[name]).toBeUndefined();
}
expect(received?.forwardedFor).toMatch(/127\.0\.0\.1|::ffff:127\.0\.0\.1/u);
});
@ -461,6 +485,36 @@ describe("portal HTTP proxy", () => {
expect(receivedCookiesB).toEqual([undefined, undefined, "session=portal-b"]);
});
it.each(["localhost", "127.0.0.1", "[::1]"])(
"keeps absolute %s app redirects on the published portal origin",
async (host) => {
targetHandler = (_req, res) => {
res.writeHead(302, { Location: `http://${host}:${targetPort}/nested/page?q=1#section` });
res.end();
};
const portal = await portalService().open({ targetPort });
const response = await httpCall({ port: portal.listenPort, path: `/?${portal.tokenQuery}` });
expect(response.status).toBe(302);
expect(response.headers.location).toBe(
new URL("/nested/page?q=1#section", portal.publicUrl).href,
);
},
);
it.each([
"/nested/page?q=1",
"https://accounts.example.test/login",
"//accounts.example.test/login",
])("preserves intentional redirect %s", async (location) => {
targetHandler = (_req, res) => {
res.writeHead(302, { Location: location });
res.end();
};
const portal = await portalService().open({ targetPort });
const response = await httpCall({ port: portal.listenPort, path: `/?${portal.tokenQuery}` });
expect(response.headers.location).toBe(location);
});
it("forces no-referrer and never forwards a token-bearing referrer", async () => {
let receivedReferer: string | undefined;
targetHandler = (req, res) => {
@ -846,7 +900,24 @@ describe("portal HTTP proxy", () => {
let upgradeCookies: string[] | undefined;
const ws = new WebSocket(
`ws://127.0.0.1:${portal.listenPort}/hmr?channel=dev&${portal.tokenQuery}`,
{ headers: { Cookie: "openclaw_plugin_tab=secret" } },
{
headers: {
Cookie: "openclaw_plugin_tab=secret",
"Tailscale-User-Login": "private@example.test",
"X-Forwarded-Host": "forged.example",
"X-Forwarded-Proto": "https",
Forwarded: "host=forged.example",
"Cf-Access-Jwt-Assertion": "synthetic-edge-assertion",
"Cf-Access-Authenticated-User-Email": "private@example.test",
Authorization: "Bearer synthetic-app-token",
"X-Remove-Me": "remove",
},
finishRequest: (req) => {
// ws installs its own Connection header after user headers.
req.setHeader("Connection", "Upgrade, x-remove-me");
req.end();
},
},
);
ws.once("upgrade", (response) => {
upgradeCookies = response.headers["set-cookie"];
@ -862,6 +933,18 @@ describe("portal HTTP proxy", () => {
expect(await echoed).toBe("hot reload");
expect(targetWebSocketPath).toBe("/hmr?channel=dev");
expect(targetWebSocketCookie).toBeUndefined();
expect(targetWebSocketHeaders?.["x-forwarded-host"]).toBe(new URL(portal.publicUrl).host);
expect(targetWebSocketHeaders?.["x-forwarded-proto"]).toBe("http");
expect(targetWebSocketHeaders?.authorization).toBe("Bearer synthetic-app-token");
for (const name of [
"tailscale-user-login",
"forwarded",
"x-remove-me",
"cf-access-jwt-assertion",
"cf-access-authenticated-user-email",
]) {
expect(targetWebSocketHeaders?.[name]).toBeUndefined();
}
expect(upgradeCookies).toHaveLength(1);
expect(upgradeCookies?.[0]).toMatch(
/^oc_portal_[a-f0-9]{32}_socket=ready; Path=\/; HttpOnly$/u,

View file

@ -52,6 +52,10 @@ type PortalProxyTarget = {
target: PortalTarget;
token: string;
cookieNamespace: string;
/** Published ingress authority, independent of the backend listener's TLS. */
publicOrigin?: string;
/** HTTPS wildcard ingress can be embedded under a different top-level site. */
partitionedCookies?: boolean;
};
type PortalAuthorization =
@ -108,7 +112,7 @@ function readTargetCookies(
return normalized || undefined;
}
function rewriteTargetCookie(cookie: string, cookieNamespace: string): string | undefined {
function rewriteTargetCookie(cookie: string, target: PortalProxyTarget): string | undefined {
const [cookiePair, ...attributes] = cookie.split(";");
const separator = cookiePair?.indexOf("=") ?? -1;
if (!cookiePair || separator <= 0) {
@ -119,8 +123,22 @@ function rewriteTargetCookie(cookie: string, cookieNamespace: string): string |
return undefined;
}
const retainedAttributes = attributes.filter((attribute) => !/^\s*domain\s*=/iu.test(attribute));
if (target.partitionedCookies) {
// Partition embedded state by its top-level site, retaining an app's explicit
// SameSite restriction rather than weakening its cross-site policy.
if (!retainedAttributes.some((attribute) => /^\s*samesite\s*=/iu.test(attribute))) {
retainedAttributes.push(" SameSite=None");
}
for (const attribute of ["Secure", "Partitioned"]) {
if (
!retainedAttributes.some((value) => value.trim().toLowerCase() === attribute.toLowerCase())
) {
retainedAttributes.push(` ${attribute}`);
}
}
}
const suffix = retainedAttributes.length > 0 ? `;${retainedAttributes.join(";")}` : "";
return `${portalCookiePrefix(cookieNamespace)}${name}=${cookiePair.slice(separator + 1)}${suffix}`;
return `${portalCookiePrefix(target.cookieNamespace)}${name}=${cookiePair.slice(separator + 1)}${suffix}`;
}
function parsePortalUrl(req: IncomingMessage): URL | undefined {
@ -157,14 +175,15 @@ function authorizePortalRequest(
}
function portalCookie(target: PortalProxyTarget, tls: boolean): string {
return `${portalAuthCookieName(target.listenPort)}=${target.token}; HttpOnly; SameSite=Lax; Path=/${tls ? "; Secure" : ""}`;
const sameSite = target.partitionedCookies ? "None; Partitioned" : "Lax";
return `${portalAuthCookieName(target.listenPort)}=${target.token}; HttpOnly; SameSite=${sameSite}; Path=/${tls ? "; Secure" : ""}`;
}
function setProxyResponseHeader(
res: ServerResponse,
name: string,
value: string | string[] | number,
cookieNamespace: string,
target: PortalProxyTarget,
): void {
if (name !== "set-cookie") {
res.setHeader(name, value);
@ -175,7 +194,7 @@ function setProxyResponseHeader(
existing === undefined ? [] : Array.isArray(existing) ? existing : [existing];
const targetCookies = Array.isArray(value) ? value : [String(value)];
const rewrittenCookies = targetCookies.flatMap((cookie) => {
const rewritten = rewriteTargetCookie(cookie, cookieNamespace);
const rewritten = rewriteTargetCookie(cookie, target);
return rewritten ? [rewritten] : [];
});
const cookies = [...existingCookies.map(String), ...rewrittenCookies];
@ -247,6 +266,18 @@ function proxyHeaders(headers: IncomingHttpHeaders, cookieNamespace?: string): O
) {
continue;
}
// Ingress identity and caller-supplied forwarding metadata do not belong
// to an agent-run app. The portal supplies its own forwarding facts.
if (
cookieNamespace !== undefined &&
(normalized === "forwarded" ||
normalized === "x-real-ip" ||
normalized.startsWith("x-forwarded-") ||
normalized.startsWith("tailscale-") ||
normalized.startsWith("cf-access-"))
) {
continue;
}
if (normalized === "cookie" && cookieNamespace !== undefined) {
const cookie = readTargetCookies(
Array.isArray(value) ? value.join("; ") : value,
@ -267,6 +298,60 @@ function proxyHeaders(headers: IncomingHttpHeaders, cookieNamespace?: string): O
return result;
}
function targetRequestHeaders(
req: IncomingMessage,
target: PortalProxyTarget,
tls: boolean,
): OutgoingHttpHeaders {
const headers = proxyHeaders(req.headers, target.cookieNamespace);
const publicUrl = target.publicOrigin ? new URL(target.publicOrigin) : undefined;
const targetPort = target.target.kind === "local" ? target.target.port : target.target.remotePort;
headers.host = `localhost:${targetPort}`;
headers["x-forwarded-for"] = req.socket.remoteAddress ?? "";
headers["x-forwarded-proto"] = tls ? "https" : "http";
const publicHost = publicUrl?.host ?? req.headers.host;
if (publicHost) {
headers["x-forwarded-host"] = publicHost;
}
return headers;
}
function portalRedirect(
location: string,
req: IncomingMessage,
target: PortalProxyTarget,
tls: boolean,
): string {
// Translate only the app's absolute loopback redirects. Relative navigation
// and intentionally external redirects retain their semantics.
if (!/^https?:\/\//iu.test(location) && !location.startsWith("//")) {
return location;
}
const origin = target.publicOrigin ?? `${tls ? "https" : "http"}://${req.headers.host}`;
try {
const destination = new URL(location, origin);
const targetPort =
target.target.kind === "local" ? target.target.port : target.target.remotePort;
const port = Number(destination.port || (destination.protocol === "https:" ? 443 : 80));
if (
!["localhost", "127.0.0.1", "[::1]"].includes(destination.hostname) ||
port !== targetPort ||
destination.username ||
destination.password
) {
return location;
}
const published = new URL(origin);
destination.protocol = published.protocol;
destination.host = published.host;
// The host setter preserves an existing port when the new host omits it.
destination.port = published.port;
return destination.href;
} catch {
return location;
}
}
/** Proxies one authorized portal request to its local or worker target. */
export function handlePortalProxyRequest(params: {
req: IncomingMessage;
@ -284,15 +369,8 @@ export function handlePortalProxyRequest(params: {
res.setHeader("Set-Cookie", portalCookie(target, tls));
}
const headers = proxyHeaders(req.headers, target.cookieNamespace);
const originalHost = req.headers.host;
const headers = targetRequestHeaders(req, target, tls);
const targetPort = target.target.kind === "local" ? target.target.port : target.target.remotePort;
headers.host = `localhost:${targetPort}`;
headers["x-forwarded-for"] = req.socket.remoteAddress ?? "";
headers["x-forwarded-proto"] = tls ? "https" : "http";
if (originalHost) {
headers["x-forwarded-host"] = originalHost;
}
void connectPortalTarget(target.target).then(
(targetSocket) => {
if (req.aborted || res.destroyed) {
@ -310,7 +388,11 @@ export function handlePortalProxyRequest(params: {
proxyReq.once("response", (proxyRes) => {
for (const [name, value] of Object.entries(proxyHeaders(proxyRes.headers))) {
if (value !== undefined) {
setProxyResponseHeader(res, name, value, target.cookieNamespace);
const forwarded =
name === "location" && typeof value === "string"
? portalRedirect(value, req, target, tls)
: value;
setProxyResponseHeader(res, name, forwarded, target);
}
}
// Overwrite, never default: a target answering with `unsafe-url` would otherwise
@ -349,40 +431,23 @@ export function handlePortalProxyRequest(params: {
function websocketHeaders(
req: IncomingMessage,
targetPort: number,
cookieNamespace: string,
target: PortalProxyTarget,
tls: boolean,
requestPath: string,
): string {
const lines = [`${req.method ?? "GET"} ${requestPath} HTTP/1.1`];
for (const [name, value] of Object.entries(req.headers)) {
const normalized = name.toLowerCase();
if (
value === undefined ||
normalized === "host" ||
(HOP_BY_HOP_HEADERS.has(normalized) &&
normalized !== "connection" &&
normalized !== "upgrade")
) {
continue;
}
if (normalized === "cookie") {
const cookie = readTargetCookies(
Array.isArray(value) ? value.join("; ") : value,
cookieNamespace,
);
if (cookie) {
lines.push(`cookie: ${cookie}`);
}
continue;
}
if (normalized === "referer" && String(value).includes(`${PORTAL_AUTH_NAME}=`)) {
const headers = targetRequestHeaders(req, target, tls);
headers.connection = "Upgrade";
headers.upgrade = req.headers.upgrade;
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) {
continue;
}
for (const item of Array.isArray(value) ? value : [value]) {
lines.push(`${normalized}: ${item}`);
lines.push(`${name}: ${item}`);
}
}
lines.push(`host: localhost:${targetPort}`, "", "");
lines.push("", "");
return lines.join("\r\n");
}
@ -405,7 +470,7 @@ function respondUpgradeWaiting(socket: Duplex, targetPort: number): void {
function forwardWebSocketResponse(
targetSocket: Duplex,
browserSocket: Duplex,
cookieNamespace: string,
target: PortalProxyTarget,
onResponse: () => void,
): void {
let pending = Buffer.alloc(0);
@ -427,7 +492,7 @@ function forwardWebSocketResponse(
if (separator <= 0 || line.slice(0, separator).trim().toLowerCase() !== "set-cookie") {
return [line];
}
const rewritten = rewriteTargetCookie(line.slice(separator + 1).trimStart(), cookieNamespace);
const rewritten = rewriteTargetCookie(line.slice(separator + 1).trimStart(), target);
return rewritten ? [`${line.slice(0, separator)}: ${rewritten}`] : [];
});
onResponse();
@ -448,8 +513,9 @@ export function handlePortalProxyUpgrade(params: {
head: Buffer;
target: PortalProxyTarget;
upgradedSockets: Set<Duplex>;
tls: boolean;
}): void {
const { req, socket, head, target, upgradedSockets } = params;
const { req, socket, head, target, upgradedSockets, tls } = params;
// Node releases socket errors on upgrade; own them before replies or worker attachment.
socket.once("error", () => socket.destroy());
const authorization = authorizePortalRequest(req, target);
@ -485,12 +551,10 @@ export function handlePortalProxyUpgrade(params: {
targetSocket.once("end", closeUpgrade);
targetSocket.once("error", closeUpgrade);
const spliceUpgrade = () => {
forwardWebSocketResponse(targetSocket, socket, target.cookieNamespace, () => {
forwardWebSocketResponse(targetSocket, socket, target, () => {
responseStarted = true;
});
targetSocket.write(
websocketHeaders(req, targetPort, target.cookieNamespace, authorization.requestPath),
);
targetSocket.write(websocketHeaders(req, target, tls, authorization.requestPath));
if (head.length > 0) {
targetSocket.write(head);
}

View file

@ -0,0 +1,74 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { listenGatewayHttpServer } from "../server/http-listen.js";
/** Dedicated loopback ingress; registry lookup remains owned by the portal service. */
export function createPortalIngress(params: {
port: number;
httpServers: Server[];
request: (req: IncomingMessage, res: ServerResponse) => void;
upgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
}): { start: () => Promise<number>; close: () => Promise<void> } {
const server = createServer(params.request);
server.on("upgrade", params.upgrade);
let startup: Promise<number> | undefined;
let closed = false;
return {
start: () => {
if (closed) {
return Promise.reject(new Error("Portal ingress is closed"));
}
// Registration and memoization precede binding, so concurrent opens share one listener.
if (!startup) {
params.httpServers.push(server);
startup = (async () => {
try {
await listenGatewayHttpServer({
httpServer: server,
bindHost: "127.0.0.1",
port: params.port,
retryEaddrinuse: false,
serviceName: "portal ingress",
endpointScheme: "http",
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Portal ingress did not resolve its listener port");
}
return address.port;
} catch (error) {
const index = params.httpServers.indexOf(server);
if (index >= 0) {
params.httpServers.splice(index, 1);
}
throw error;
}
})();
}
return startup;
},
close: async () => {
closed = true;
// Settle binding before closing: close during startup must not leave a late listener alive.
await startup?.catch(() => undefined);
const index = params.httpServers.indexOf(server);
if (index >= 0) {
params.httpServers.splice(index, 1);
}
if (server.listening) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
server.closeAllConnections();
});
}
},
};
}
/** Match only a single DNS hostname authority; forwarded headers never select a portal. */
export function portalIngressHostname(host: string | undefined): string | undefined {
if (!host || !/^[a-z0-9.-]+(?::443)?$/iu.test(host)) {
return undefined;
}
return host.toLowerCase().replace(/:443$/u, "");
}

View file

@ -0,0 +1,355 @@
import { request, type Server } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import * as advertisedLanHost from "../../infra/advertised-lan-host.js";
import { claimTailscaleServePort, type TailscaleRouteClaim } from "../../infra/tailscale.js";
import { withServer } from "../../plugin-sdk/test-helpers/http-test-server.js";
import * as httpListen from "../server/http-listen.js";
import { prepareTailscalePublishedOrigin } from "../tailscale-published-origin.js";
import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js";
// Every managed route operation is fake: these tests must never change the host's tailnet.
vi.mock("../../infra/tailscale.js", () => ({ claimTailscaleServePort: vi.fn() }));
const services: GatewayPortalService[] = [];
const withdraw: Array<() => void> = [];
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.closeAll()));
for (const release of withdraw.splice(0)) {
release();
}
vi.restoreAllMocks();
vi.mocked(claimTailscaleServePort).mockReset();
});
function makeService(options: Partial<Parameters<typeof createGatewayPortalService>[0]> = {}) {
const httpServers: Server[] = [];
const service = createGatewayPortalService({
httpBindHosts: ["127.0.0.1"],
httpServers,
...options,
});
services.push(service);
return { service, httpServers };
}
function publishManaged(mode: "serve" | "funnel" = "serve") {
const release = prepareTailscalePublishedOrigin({
origin: "https://gateway.example.ts.net",
mode,
});
withdraw.push(release);
return release;
}
function fakeClaim() {
const exit = createDeferred();
let active = true;
const lose = () => {
active = false;
exit.resolve();
};
const claim: TailscaleRouteClaim = {
exited: exit.promise,
isActive: () => active,
stop: vi.fn(async () => lose()),
};
return { claim, lose };
}
async function ingressRequest(port: number, url: string, host?: string) {
const parsed = new URL(url);
return await new Promise<{ status: number; body: string; cookie: string[]; location?: string }>(
(resolve, reject) => {
const req = request(
{
host: "127.0.0.1",
port,
path: `${parsed.pathname}${parsed.search}`,
headers: { host: host ?? parsed.host },
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString(),
cookie: res.headers["set-cookie"] ?? [],
location: res.headers.location,
}),
);
},
);
req.on("error", reject);
req.end();
},
);
}
describe("operator-managed private wildcard portal ingress", () => {
it("publishes authoritative HTTPS paths on random per-lifetime hosts and gates every target", async () => {
await withServer(
(req, res) => {
res.setHeader("Set-Cookie", ["session=ok; Path=/", "restricted=ok; SameSite=Strict"]);
res.end(JSON.stringify({ path: req.url, proto: req.headers["x-forwarded-proto"] }));
},
async (targetUrl) => {
const { service, httpServers } = makeService({
httpBindHosts: ["0.0.0.0"],
ingress: { domain: "previews.example.net", port: 0 },
});
const resolveHost = vi.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore");
const targetPort = Number(new URL(targetUrl).port);
const first = await service.open({ targetPort, path: "/nested/start?theme=dark" });
expect(first.publicUrl).toMatch(
/^https:\/\/[a-f0-9]{32}\.previews\.example\.net\/nested\/start\?theme=dark$/u,
);
expect(first.publicUrl).not.toContain("openclaw_portal");
expect(httpServers).toHaveLength(1);
expect(httpServers[0]?.address()).toMatchObject({
address: "127.0.0.1",
port: first.listenPort,
});
const response = await ingressRequest(first.listenPort, first.url);
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({
path: "/nested/start?theme=dark",
proto: "https",
});
expect(response.cookie.join(";")).toContain("HttpOnly");
expect(response.cookie.join(";")).toContain("Secure");
const authCookie = response.cookie.find((cookie) => cookie.startsWith("openclaw_portal_"));
expect(authCookie).toContain("SameSite=None; Partitioned");
const appCookie = response.cookie.find((cookie) => cookie.includes("_session="));
expect(appCookie).toContain("SameSite=None; Secure; Partitioned");
const restrictedCookie = response.cookie.find((cookie) => cookie.includes("_restricted="));
expect(restrictedCookie).toContain("SameSite=Strict");
expect(restrictedCookie).not.toContain("SameSite=None");
expect((await ingressRequest(first.listenPort, first.publicUrl)).status).toBe(401);
expect(
(await ingressRequest(first.listenPort, first.url, "unknown.previews.example.net"))
.status,
).toBe(404);
expect(
(await ingressRequest(first.listenPort, first.url, "gateway.example.net")).status,
).toBe(404);
expect(
(await ingressRequest(first.listenPort, first.url, `${new URL(first.url).hostname}:8443`))
.status,
).toBe(404);
const reused = await service.open({ targetPort, path: "/updated" });
expect(new URL(reused.url).origin).toBe(new URL(first.url).origin);
expect(reused.publicUrl).toBe(`${new URL(first.url).origin}/updated`);
await service.close(first.id);
expect((await ingressRequest(first.listenPort, first.url)).status).toBe(404);
const reopened = await service.open({ targetPort });
expect(new URL(reopened.url).hostname).not.toBe(new URL(first.url).hostname);
expect((await ingressRequest(first.listenPort, first.url)).status).toBe(404);
expect(claimTailscaleServePort).not.toHaveBeenCalled();
expect(resolveHost).not.toHaveBeenCalled();
},
);
});
it.each(["http://localhost", "//127.0.0.1"])(
"clears the backend port from wildcard redirects using %s",
async (prefix) => {
let targetPort = 0;
await withServer(
(_req, res) => {
res.writeHead(302, { Location: `${prefix}:${targetPort}/next?q=1#section` });
res.end();
},
async (targetUrl) => {
targetPort = Number(new URL(targetUrl).port);
const { service } = makeService({
ingress: { domain: "previews.example.net", port: 0 },
});
const portal = await service.open({ targetPort });
const response = await ingressRequest(portal.listenPort, portal.url);
expect(response.status).toBe(302);
expect(response.location).toBe(`${new URL(portal.publicUrl).origin}/next?q=1#section`);
},
);
},
);
it("shares listener startup across concurrent opens and closes only the selected portal", async () => {
const { service, httpServers } = makeService({
ingress: { domain: "previews.example.net", port: 0 },
});
const [first, second] = await Promise.all([
service.open({ targetPort: 3000 }),
service.open({ targetPort: 4000 }),
]);
expect(httpServers).toHaveLength(1);
expect(first.listenPort).toBe(second.listenPort);
expect(new URL(first.url).hostname).not.toBe(new URL(second.url).hostname);
await service.close(first.id);
expect((await ingressRequest(second.listenPort, second.publicUrl)).status).toBe(401);
const listener = httpServers[0];
await service.closeAll();
expect(listener?.listening).toBe(false);
expect(httpServers).toEqual([]);
});
it("settles an in-flight ingress bind before whole-service teardown", async () => {
const started = createDeferred();
const release = createDeferred();
const actual = httpListen.listenGatewayHttpServer;
vi.spyOn(httpListen, "listenGatewayHttpServer").mockImplementation(async (params) => {
started.resolve();
await release.promise;
await actual(params);
});
const { service, httpServers } = makeService({
ingress: { domain: "previews.example.net", port: 0 },
});
const opening = service.open({ targetPort: 3000 });
const rejected = expect(opening).rejects.toThrow("portals unavailable");
await started.promise;
const closing = service.closeAll();
release.resolve();
await rejected;
await closing;
expect(service.list()).toEqual([]);
expect(httpServers).toEqual([]);
});
it("rejects configured and managed Gateway host collisions", () => {
expect(() =>
makeService({
ingress: { domain: "example.net", port: 18890 },
gatewayOrigins: ["https://control.example.net"],
}),
).toThrow("separate DNS domain");
});
});
describe("managed private Serve portal ingress", () => {
it("partitions authentication and default app cookies for cross-site HTTPS embedding", async () => {
await withServer(
(_, res) => {
res.setHeader("Set-Cookie", "session=ok; Path=/");
res.end("app");
},
async (targetUrl) => {
publishManaged();
const { claim } = fakeClaim();
vi.mocked(claimTailscaleServePort).mockResolvedValue(claim);
const { service } = makeService({ managedTailscale: true });
const portal = await service.open({ targetPort: Number(new URL(targetUrl).port) });
const response = await ingressRequest(portal.listenPort, portal.url);
expect(response.status).toBe(200);
expect(response.cookie).toHaveLength(2);
for (const cookie of response.cookie) {
expect(cookie).toContain("SameSite=None");
expect(cookie).toContain("Secure");
expect(cookie).toContain("Partitioned");
}
},
);
});
it.each(["serve", "funnel"] as const)(
"uses a separate private Serve claim even for a %s Gateway",
async (mode) => {
const resolveHost = vi.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore");
publishManaged(mode);
const { claim } = fakeClaim();
vi.mocked(claimTailscaleServePort).mockResolvedValue(claim);
const { service, httpServers } = makeService({
managedTailscale: true,
httpBindHosts: ["0.0.0.0"],
});
const portal = await service.open({ targetPort: 3000, path: "/app" });
expect(portal.publicUrl).toBe(`https://gateway.example.ts.net:${portal.listenPort}/app`);
expect(claimTailscaleServePort).toHaveBeenCalledExactlyOnceWith(
portal.listenPort,
portal.listenPort,
expect.any(Function),
);
expect(httpServers[0]?.address()).toMatchObject({ address: "127.0.0.1" });
expect(portal.listenPort).not.toBe(443);
await service.open({ targetPort: 3000 });
expect(claimTailscaleServePort).toHaveBeenCalledTimes(1);
await service.close(portal.id);
expect(claim.stop).toHaveBeenCalledOnce();
expect(service.list()).toEqual([]);
expect(resolveHost).not.toHaveBeenCalled();
},
);
it("never silently publishes direct listener URLs when the managed route is absent", async () => {
const { service, httpServers } = makeService({ managedTailscale: true });
await expect(service.open({ targetPort: 3000 })).rejects.toThrow(
"managed Tailscale route is not active",
);
expect(httpServers).toEqual([]);
expect(claimTailscaleServePort).not.toHaveBeenCalled();
});
it("rolls back listener and target ownership on claim startup failure", async () => {
publishManaged();
vi.mocked(claimTailscaleServePort).mockRejectedValue(new Error("HTTPS port occupied"));
const releaseTarget = vi.fn();
const { service, httpServers } = makeService({ managedTailscale: true });
await expect(service.open({ targetPort: 3000, onClose: releaseTarget })).rejects.toThrow(
"HTTPS port occupied",
);
expect(httpServers).toEqual([]);
expect(service.list()).toEqual([]);
expect(releaseTarget).toHaveBeenCalledOnce();
});
it("revalidates authority after route startup and releases the unpublished claim", async () => {
publishManaged();
const { claim } = fakeClaim();
let current = true;
vi.mocked(claimTailscaleServePort).mockImplementation(async () => {
current = false;
return claim;
});
const { service, httpServers } = makeService({ managedTailscale: true });
const releaseTarget = vi.fn();
await expect(
service.open({
targetPort: 3000,
onClose: releaseTarget,
assertCurrent: () => {
if (!current) {
throw new Error("authority revoked");
}
},
}),
).rejects.toThrow("authority revoked");
expect(claim.stop).toHaveBeenCalledOnce();
expect(releaseTarget).toHaveBeenCalledOnce();
expect(httpServers).toEqual([]);
expect(service.list()).toEqual([]);
});
it.each(["claim", "gateway"])(
"withdraws publication and closes resources after %s owner loss",
async (loss) => {
const withdrawGateway = publishManaged();
const { claim, lose } = fakeClaim();
vi.mocked(claimTailscaleServePort).mockResolvedValue(claim);
const { service, httpServers } = makeService({ managedTailscale: true });
const releaseTarget = vi.fn();
await service.open({ targetPort: 3000, onClose: releaseTarget });
const listener = httpServers[0];
if (loss === "claim") {
lose();
} else {
withdrawGateway();
}
expect(service.list()).toEqual([]);
// Explicit close joins the owner teardown instead of polling the event loop.
await service.close("p3000");
expect(claim.stop).toHaveBeenCalledOnce();
expect(releaseTarget).toHaveBeenCalledOnce();
expect(listener?.listening).toBe(false);
},
);
});

View file

@ -1,6 +1,7 @@
import { request, type Server } from "node:http";
import type { Duplex } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as advertisedLanHost from "../../infra/advertised-lan-host.js";
import { readResponseWithLimit } from "../../infra/http-body.js";
import { withServer } from "../../plugin-sdk/test-helpers/http-test-server.js";
import * as httpListen from "../server/http-listen.js";
@ -97,6 +98,42 @@ describe("portal open authority fence", () => {
});
describe("gateway portal service", () => {
it("keeps explicit loopback listeners local even when a LAN address is available", async () => {
const resolveHost = vi
.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore")
.mockResolvedValue("192.168.1.20");
const { service } = makeService(["127.0.0.1"]);
const portal = await service.open({ targetPort: 3000 });
expect(portal.publicUrl).toBe(`http://127.0.0.1:${portal.listenPort}/`);
expect(resolveHost).not.toHaveBeenCalled();
});
it("revalidates authority after LAN discovery and releases unpublished listeners", async () => {
let current = true;
vi.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore").mockImplementation(async () => {
current = false;
return "192.168.1.20";
});
const { service, httpServers } = makeService(["0.0.0.0"]);
const releaseTarget = vi.fn();
await expect(
service.open({
targetPort: 3000,
onClose: releaseTarget,
assertCurrent: () => {
if (!current) {
throw new Error("authority revoked during LAN discovery");
}
},
}),
).rejects.toThrow("authority revoked during LAN discovery");
expect(service.list()).toEqual([]);
expect(httpServers).toEqual([]);
expect(releaseTarget).toHaveBeenCalledOnce();
});
it("allocates one port across every frozen bind host", async () => {
const { service, httpServers } = makeService(["127.0.0.1", "::1"]);
const portal = await service.open({ targetPort: 3000, title: "App" });
@ -412,10 +449,32 @@ describe("gateway portal service", () => {
expect(httpServers).toEqual([]);
});
it.each(["0.0.0.0", "::"])(
"publishes the advertised LAN address for wildcard listener %s",
async (bindHost) => {
const resolveHost = vi
.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore")
.mockResolvedValue("192.168.1.20");
const { service, httpServers } = makeService([bindHost]);
const portal = await service.open({ targetPort: 3000, path: "/app?view=one" });
expect(portal.publicUrl).toBe(`http://192.168.1.20:${portal.listenPort}/app?view=one`);
expect(portal.url).toBe(`${portal.publicUrl}&${portal.tokenQuery}`);
expect(httpServers[0]?.address()).toMatchObject({ address: bindHost });
expect(await getStatus("127.0.0.1", portal.listenPort, "/")).toBe(401);
// Publication belongs to this listener lifetime, not each listing or caller's hostname.
resolveHost.mockResolvedValue("192.168.1.21");
expect(service.list()).toEqual([portal]);
expect(await service.open({ targetPort: 3000 })).toEqual(portal);
expect(resolveHost).toHaveBeenCalledOnce();
},
);
it.each([
["0.0.0.0", "127.0.0.1"],
["::", "[::1]"],
])("maps wildcard bind host %s to openable host %s", async (bindHost, openableHost) => {
])("keeps wildcard %s local when no LAN address is available", async (bindHost, openableHost) => {
vi.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore").mockResolvedValue(null);
const { service } = makeService([bindHost]);
const portal = await service.open({ targetPort: 3000 });

View file

@ -0,0 +1,136 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { request } from "node:https";
import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as advertisedLanHost from "../../infra/advertised-lan-host.js";
import { withServer } from "../../plugin-sdk/test-helpers/http-test-server.js";
import { createGatewayPortalService, type GatewayPortalService } from "./portal-service.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const services: GatewayPortalService[] = [];
let certificate: { cert: string; key: string };
let ipCertificate: { cert: string; key: string };
let wildcardCertificate: { cert: string; key: string };
// Fresh material keeps CA, expiry, and hostname checks enabled in every case.
function createCertificate(subjectAltName: string) {
const directory = tempDirs.make("portal-direct-tls-");
const certPath = path.join(directory, "cert.pem");
const keyPath = path.join(directory, "key.pem");
execFileSync(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"2",
"-subj",
"/CN=gateway.example.test",
"-addext",
`subjectAltName=${subjectAltName}`,
"-keyout",
keyPath,
"-out",
certPath,
],
{ stdio: "ignore" },
);
return { cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8") };
}
beforeAll(() => {
certificate = createCertificate("DNS:gateway.example.test,DNS:alternate.example.test");
ipCertificate = createCertificate("IP:127.0.0.1");
wildcardCertificate = createCertificate("DNS:*.example.test");
});
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.closeAll()));
vi.restoreAllMocks();
});
// Only DNS routing is local to the fixture: CA, expiry, and URL hostname checks stay enabled.
async function readPortal(url: string, ca = certificate.cert) {
return await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = request(
url,
{
ca,
family: 4,
lookup: (_host, _options, callback) => callback(null, "127.0.0.1", 4),
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString() }),
);
},
);
req.on("error", reject);
req.end();
});
}
describe("direct HTTPS portal publication", () => {
it.each(["ip", "wildcard", "chain"] as const)(
"retains verified %s certificate access",
async (kind) => {
const material =
kind === "ip" ? ipCertificate : kind === "wildcard" ? wildcardCertificate : certificate;
const hostname = kind === "ip" ? "127.0.0.1" : "gateway.example.test";
const service = createGatewayPortalService({
httpBindHosts: ["127.0.0.1"],
httpServers: [],
tlsOptions: { ...material, cert: kind === "chain" ? [material.cert] : material.cert },
gatewayOrigins: kind === "wildcard" ? ["*", `https://${hostname}`] : [],
});
services.push(service);
const portal = await service.open({ targetPort: 3000 });
expect(new URL(portal.publicUrl).hostname).toBe(hostname);
expect((await readPortal(portal.publicUrl, material.cert)).status).toBe(401);
},
);
it.each(["0.0.0.0", "127.0.0.1"])(
"publishes a certificate-valid DNS name for bind %s",
async (bindHost) => {
// Keep the advertised IP locally reachable so the broken candidate fails on TLS, not routing.
vi.spyOn(advertisedLanHost, "resolveAdvertisedLanHostCore").mockResolvedValue("127.0.0.2");
await withServer(
(_req, res) => res.end("direct TLS app"),
async (targetUrl) => {
const service = createGatewayPortalService({
httpBindHosts: [bindHost],
httpServers: [],
tlsOptions: certificate,
});
services.push(service);
const portal = await service.open({ targetPort: Number(new URL(targetUrl).port) });
expect(await readPortal(portal.url)).toEqual({ status: 200, body: "direct TLS app" });
expect(new URL(portal.publicUrl).hostname).toBe("gateway.example.test");
expect(service.list()[0]?.publicUrl).toBe(portal.publicUrl);
expect((await readPortal(portal.publicUrl)).status).toBe(401);
},
);
},
);
it("prefers a configured certificate-valid Gateway name, ignoring unrelated origins", async () => {
const service = createGatewayPortalService({
httpBindHosts: ["127.0.0.1"],
httpServers: [],
tlsOptions: certificate,
gatewayOrigins: ["https://unrelated.example.test", "https://alternate.example.test:8443"],
});
services.push(service);
const portal = await service.open({ targetPort: 3000 });
expect(new URL(portal.publicUrl).hostname).toBe("alternate.example.test");
expect((await readPortal(portal.publicUrl)).status).toBe(401);
});
});

View file

@ -8,13 +8,23 @@ import type {
PortalOpenResult,
PortalSummary,
} from "../../../packages/gateway-protocol/src/index.js";
import {
isValidPortalIngressDomain,
portalIngressConflictsWithOrigin,
} from "../../config/gateway-portal-ingress.js";
import type { GatewayPortalIngressConfig } from "../../config/types.gateway.js";
import { resolveAdvertisedLanHostCore } from "../../infra/advertised-lan-host.js";
import { sha256HexPrefixCore } from "../../infra/crypto-digest.js";
import { claimTailscaleServePort, type TailscaleRouteClaim } from "../../infra/tailscale.js";
import { listenGatewayHttpServer } from "../server/http-listen.js";
import { getTailscalePublishedOrigin } from "../tailscale-published-origin.js";
import {
handlePortalProxyRequest,
handlePortalProxyUpgrade,
type PortalTarget,
} from "./portal-http-proxy.js";
import { createPortalIngress, portalIngressHostname } from "./portal-ingress.js";
import { resolvePortalTlsHostname } from "./portal-tls-hostname.js";
const PORTAL_PORT_ALLOCATION_ATTEMPTS = 10;
@ -29,6 +39,8 @@ type PortalEntry = {
cookieNamespace: string;
listenPort: number;
createdAtMs: number;
publicOrigin: string;
partitionedCookies: boolean;
};
type PortalRuntimeEntry = {
@ -36,6 +48,11 @@ type PortalRuntimeEntry = {
servers: HttpServer[];
upgradedSockets: Set<Duplex>;
onClose?: () => Promise<void> | void;
claim?: TailscaleRouteClaim;
detachIngressOwner?: () => void;
ingressSignal?: AbortSignal;
revoked?: boolean;
responses: Set<import("node:http").ServerResponse>;
};
type GatewayPortalOpenParams = {
@ -85,8 +102,14 @@ async function closeServers(servers: readonly HttpServer[]): Promise<void> {
);
}
function formatPortalHost(host: string): string {
const openableHost = host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host;
async function formatPortalHost(host: string): Promise<string> {
// Wildcard listeners already accept LAN connections. Publish their actual LAN
// address here rather than asking clients to reconstruct it from the Gateway URL.
const lanHost =
host === "0.0.0.0" || host === "::"
? await resolveAdvertisedLanHostCore().catch(() => null)
: null;
const openableHost = lanHost ?? (host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host);
return openableHost.includes(":") ? `[${openableHost}]` : openableHost;
}
@ -95,19 +118,79 @@ export function createGatewayPortalService(params: {
httpBindHosts: readonly string[];
tlsOptions?: TlsOptions;
httpServers: HttpServer[];
}): GatewayPortalService {
ingress?: GatewayPortalIngressConfig;
managedTailscale?: boolean;
gatewayOrigins?: readonly string[];
}): GatewayPortalService & { startIngress: () => Promise<void> } {
const entries = new Map<string, PortalRuntimeEntry>();
const operations = new Map<string, Promise<void>>();
let closed = false;
const isAvailable = (runtime: PortalRuntimeEntry) =>
!closed &&
!runtime.revoked &&
!runtime.ingressSignal?.aborted &&
(!runtime.claim || runtime.claim.isActive());
const ingressDomain = params.ingress?.domain.toLowerCase();
if (
ingressDomain &&
(!isValidPortalIngressDomain(ingressDomain) ||
params.gatewayOrigins?.some((origin) =>
portalIngressConflictsWithOrigin(ingressDomain, origin),
))
) {
throw new Error(
"Portal ingress must use a valid separate DNS domain from the Gateway and Control UI",
);
}
const lookupIngress = (host: string | undefined) => {
const hostname = portalIngressHostname(host);
if (!hostname || closed) {
return undefined;
}
// The portal registry is the only routing authority; unknown and retired hosts have no target.
for (const runtime of entries.values()) {
if (isAvailable(runtime) && runtime.portal.publicOrigin === `https://${hostname}`) {
return runtime;
}
}
return undefined;
};
const ingress = params.ingress
? createPortalIngress({
port: params.ingress.port,
httpServers: params.httpServers,
request: (req, res) => {
const runtime = lookupIngress(req.headers.host);
if (!runtime) {
res.writeHead(404);
res.end("Unknown portal");
return;
}
runtime.responses.add(res);
res.once("close", () => runtime.responses.delete(res));
handlePortalProxyRequest({ req, res, target: runtime.portal, tls: true });
},
upgrade: (req, socket, head) => {
const runtime = lookupIngress(req.headers.host);
if (!runtime) {
socket.destroy();
return;
}
handlePortalProxyUpgrade({
req,
socket,
head,
target: runtime.portal,
upgradedSockets: runtime.upgradedSockets,
tls: true,
});
},
})
: undefined;
const summarize = (portal: PortalEntry): PortalOpenResult => {
const host = params.httpBindHosts[0];
if (!host) {
throw new Error("Gateway listener must start before opening a portal");
}
const scheme = params.tlsOptions ? "https" : "http";
const tokenQuery = `openclaw_portal=${portal.token}`;
const publicUrl = `${scheme}://${formatPortalHost(host)}:${portal.listenPort}${portal.path ?? "/"}`;
const publicUrl = `${portal.publicOrigin}${portal.path ?? "/"}`;
const openableUrl = new URL(publicUrl);
openableUrl.searchParams.set("openclaw_portal", portal.token);
return {
@ -154,16 +237,35 @@ export function createGatewayPortalService(params: {
socket.destroy();
}
runtime.upgradedSockets.clear();
await closeServers(runtime.servers);
await runtime.onClose?.();
for (const response of runtime.responses) {
response.destroy();
}
runtime.responses.clear();
runtime.detachIngressOwner?.();
// Release every owned resource even if a route owner reports a teardown error.
const cleanup = await Promise.allSettled([
closeServers(runtime.servers),
Promise.resolve().then(() => runtime.claim?.stop()),
Promise.resolve().then(() => runtime.onClose?.()),
]);
const failure = cleanup.find((result) => result.status === "rejected");
if (failure?.status === "rejected") {
throw failure.reason;
}
};
const summarizeEntries = (selected: Iterable<PortalRuntimeEntry>): PortalSummary[] =>
Array.from(selected, ({ portal }) => summarize(portal)).toSorted(
(left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id),
);
Array.from(selected)
.filter(isAvailable)
.map(({ portal }) => summarize(portal))
.toSorted(
(left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id),
);
return {
startIngress: async () => {
await ingress?.start();
},
open: async (input) => {
const target: PortalTarget = input.target ?? { kind: "local", port: input.targetPort };
const targetPort = target.kind === "local" ? target.port : target.remotePort;
@ -178,7 +280,15 @@ export function createGatewayPortalService(params: {
throw new Error("portals unavailable");
}
input.assertCurrent?.();
const existing = entries.get(id);
let existing = entries.get(id);
if (existing && !isAvailable(existing)) {
await closeEntry(id);
input.assertCurrent?.();
if (closed) {
throw new Error("portals unavailable");
}
existing = undefined;
}
if (existing) {
existing.portal.title = input.title?.trim() || existing.portal.title;
if (input.description !== undefined) {
@ -196,6 +306,23 @@ export function createGatewayPortalService(params: {
throw new Error("Gateway listener must start before opening a portal");
}
const managed =
!ingress && params.managedTailscale ? getTailscalePublishedOrigin() : undefined;
if (!ingress && params.managedTailscale && (!managed || managed.signal.aborted)) {
throw new Error(
"Private portal ingress unavailable: the managed Tailscale route is not active",
);
}
const gatewayPublication = getTailscalePublishedOrigin();
if (
ingressDomain &&
gatewayPublication &&
portalIngressConflictsWithOrigin(ingressDomain, gatewayPublication.origin)
) {
throw new Error("Portal ingress domain conflicts with the managed Gateway hostname");
}
const bindHosts = managed ? ["127.0.0.1"] : params.httpBindHosts;
const tlsOptions = managed ? undefined : params.tlsOptions;
const portal: PortalEntry = {
id,
title: input.title?.trim() || `Port ${targetPort}`,
@ -207,84 +334,187 @@ export function createGatewayPortalService(params: {
cookieNamespace: randomBytes(16).toString("hex"),
listenPort: 0,
createdAtMs: Date.now(),
publicOrigin: "",
// Every HTTPS portal can be embedded from another site, not only wildcard ingress.
partitionedCookies: Boolean(ingress || managed || tlsOptions),
};
const upgradedSockets = new Set<Duplex>();
const responses = new Set<import("node:http").ServerResponse>();
const handler = (
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
) =>
handlePortalProxyRequest({ req, res, target: portal, tls: Boolean(params.tlsOptions) });
const servers = params.httpBindHosts.map(() =>
params.tlsOptions
? createHttpsServer(params.tlsOptions, handler)
: createHttpServer(handler),
);
) => {
const runtime = entries.get(id);
if (!runtime || runtime.portal !== portal || !isAvailable(runtime)) {
res.writeHead(404);
res.end("Unknown portal");
return;
}
responses.add(res);
res.once("close", () => responses.delete(res));
handlePortalProxyRequest({
req,
res,
target: portal,
tls: Boolean(managed || tlsOptions),
});
};
const servers = ingress
? []
: bindHosts.map(() =>
tlsOptions ? createHttpsServer(tlsOptions, handler) : createHttpServer(handler),
);
for (const server of servers) {
server.on("upgrade", (req, socket, head) =>
handlePortalProxyUpgrade({ req, socket, head, target: portal, upgradedSockets }),
);
server.on("upgrade", (req, socket, head) => {
const runtime = entries.get(id);
if (!runtime || runtime.portal !== portal || !isAvailable(runtime)) {
socket.destroy();
return;
}
handlePortalProxyUpgrade({
req,
socket,
head,
target: portal,
upgradedSockets,
tls: Boolean(managed || tlsOptions),
});
});
}
// Registration precedes every bind so whole-gateway cleanup owns partial startup.
params.httpServers.push(...servers);
let claim: TailscaleRouteClaim | undefined;
try {
const primaryServer = servers[0];
const primaryHost = params.httpBindHosts[0];
if (!primaryServer || !primaryHost) {
throw new Error("Missing primary portal HTTP server");
if (ingress) {
portal.listenPort = await ingress.start();
if (target.kind === "local" && portal.listenPort === targetPort) {
throw new Error("Portal target port must differ from the portal ingress listener");
}
portal.publicOrigin = `https://${randomBytes(16).toString("hex")}.${ingressDomain}`;
} else {
const primaryServer = servers[0];
const primaryHost = bindHosts[0];
if (!primaryServer || !primaryHost) {
throw new Error("Missing primary portal HTTP server");
}
for (let attempt = 0; attempt < PORTAL_PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
await listenGatewayHttpServer({
httpServer: primaryServer,
bindHost: primaryHost,
port: 0,
retryEaddrinuse: false,
serviceName: "portal",
endpointScheme: tlsOptions ? "https" : "http",
});
const address = primaryServer.address() as AddressInfo | null;
if (!address || typeof address === "string") {
throw new Error("Portal listener failed to resolve its port");
}
if (target.kind === "worker" || address.port !== targetPort) {
portal.listenPort = address.port;
break;
}
// A proxy cannot share its target port: it would dial itself and fail auth.
await closeServers([primaryServer]);
}
if (portal.listenPort === 0) {
throw new Error(`Portal listener repeatedly allocated target port ${targetPort}`);
}
for (const [index, host] of bindHosts.entries()) {
if (index === 0) {
continue;
}
const server = servers[index];
if (!server) {
throw new Error(`Missing portal HTTP server for bind host ${host}`);
}
await listenGatewayHttpServer({
httpServer: server,
bindHost: host,
port: portal.listenPort,
retryEaddrinuse: false,
serviceName: "portal",
endpointScheme: tlsOptions ? "https" : "http",
});
}
if (managed) {
// The backend ephemeral port selects a distinct external HTTPS port; Tailscale
// atomically rejects occupied routes rather than adopting or replacing them.
const httpsPort = portal.listenPort;
const gatewayUrl = new URL(managed.origin);
if (httpsPort === Number(gatewayUrl.port || 443)) {
throw new Error("Portal HTTPS port conflicts with the Gateway origin");
}
const assertServeCurrent = () => {
input.assertCurrent?.();
if (closed || managed.signal.aborted) {
throw new Error("Private portal ingress closed during startup");
}
};
assertServeCurrent();
claim = await claimTailscaleServePort(
portal.listenPort,
httpsPort,
assertServeCurrent,
);
if (!claim.isActive() || managed.signal.aborted) {
throw new Error("Private portal ingress lost during startup");
}
gatewayUrl.port = String(httpsPort);
if (params.gatewayOrigins?.includes(gatewayUrl.origin)) {
throw new Error("Portal HTTPS origin conflicts with the Control UI");
}
portal.publicOrigin = gatewayUrl.origin;
} else {
const bindHostname = await formatPortalHost(primaryHost);
const hostname = tlsOptions
? resolvePortalTlsHostname(tlsOptions, params.gatewayOrigins ?? [], bindHostname)
: bindHostname;
portal.publicOrigin = `${tlsOptions ? "https" : "http"}://${hostname}:${portal.listenPort}`;
}
}
for (let attempt = 0; attempt < PORTAL_PORT_ALLOCATION_ATTEMPTS; attempt += 1) {
await listenGatewayHttpServer({
httpServer: primaryServer,
bindHost: primaryHost,
port: 0,
retryEaddrinuse: false,
serviceName: "portal",
endpointScheme: params.tlsOptions ? "https" : "http",
});
const address = primaryServer.address() as AddressInfo | null;
if (!address || typeof address === "string") {
throw new Error("Portal listener failed to resolve its port");
}
if (target.kind === "worker" || address.port !== targetPort) {
portal.listenPort = address.port;
break;
}
// A proxy cannot share its target port: it would dial itself and fail auth.
await closeServers([primaryServer]);
}
if (portal.listenPort === 0) {
throw new Error(`Portal listener repeatedly allocated target port ${targetPort}`);
}
for (const [index, host] of params.httpBindHosts.entries()) {
if (index === 0) {
continue;
}
const server = servers[index];
if (!server) {
throw new Error(`Missing portal HTTP server for bind host ${host}`);
}
await listenGatewayHttpServer({
httpServer: server,
bindHost: host,
port: portal.listenPort,
retryEaddrinuse: false,
serviceName: "portal",
endpointScheme: params.tlsOptions ? "https" : "http",
});
if (closed) {
throw new Error("portals unavailable");
}
// A queued successor must not discover a portal created by a now-revoked turn.
input.assertCurrent?.();
if (managed && (managed.signal.aborted || !claim?.isActive())) {
throw new Error("Private portal ingress lost before publication");
}
} catch (error) {
removeServers(params.httpServers, servers);
await closeServers(servers);
for (const socket of upgradedSockets) {
socket.destroy();
}
await Promise.all([closeServers(servers), claim?.stop()]);
throw error;
}
entries.set(id, {
const runtime: PortalRuntimeEntry = {
portal,
servers,
upgradedSockets,
...(input.onClose ? { onClose: input.onClose } : {}),
});
claim,
responses,
ingressSignal: managed?.signal,
};
entries.set(id, runtime);
if (claim && managed) {
const retire = () => {
// Capture this exact lifetime: an old claim must never close a reopened portal.
if (entries.get(id) === runtime) {
runtime.revoked = true;
void serialize(id, async () => {
if (entries.get(id) === runtime) {
await closeEntry(id);
}
}).catch(() => undefined);
}
};
managed.signal.addEventListener("abort", retire, { once: true });
runtime.detachIngressOwner = () => managed.signal.removeEventListener("abort", retire);
void claim.exited.then(retire, retire);
}
releaseTarget = undefined;
return summarize(portal);
} finally {
@ -324,7 +554,11 @@ export function createGatewayPortalService(params: {
closeAll: async () => {
closed = true;
const ids = new Set([...entries.keys(), ...operations.keys()]);
await Promise.all([...ids].map((id) => serialize(id, () => closeEntry(id))));
try {
await Promise.all([...ids].map((id) => serialize(id, () => closeEntry(id))));
} finally {
await ingress?.close();
}
},
};
}

View file

@ -0,0 +1,45 @@
import { X509Certificate } from "node:crypto";
import { isIP } from "node:net";
import type { TlsOptions } from "node:tls";
/** Select a certificate-valid direct hostname without changing the listener or client URL. */
export function resolvePortalTlsHostname(
tlsOptions: TlsOptions,
gatewayOrigins: readonly string[],
fallbackHost: string,
): string {
const material = Array.isArray(tlsOptions.cert) ? tlsOptions.cert[0] : tlsOptions.cert;
if (!material) {
return fallbackHost;
}
const certificate = new X509Certificate(material);
const candidates: string[] = [];
for (const origin of gatewayOrigins) {
try {
candidates.push(new URL(origin).hostname.replace(/^\[|\]$/gu, ""));
} catch {
// Origin allowlists can contain non-URL entries such as "*".
}
}
candidates.push(fallbackHost.replace(/^\[|\]$/gu, ""));
// A wildcard cannot supply a concrete destination. Verify every extracted name
// against the certificate rather than treating SAN display text as authority.
for (const match of (certificate.subjectAltName ?? "").matchAll(
/(?:^|, )DNS:([a-z0-9.-]+)(?=, |$)/giu,
)) {
if (match[1]) {
candidates.push(match[1]);
}
}
for (const candidate of candidates) {
const matches = isIP(candidate)
? certificate.checkIP(candidate)
: certificate.checkHost(candidate, { subject: "never" });
if (matches) {
return candidate.includes(":") ? `[${candidate}]` : candidate;
}
}
// Self-signed/default material may have no DNS identity. Preserve its direct
// address instead of guessing a hostname or silently changing ingress modes.
return fallbackHost;
}

View file

@ -0,0 +1,102 @@
import { request } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withServer } from "../plugin-sdk/test-helpers/http-test-server.js";
import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-state.js";
vi.mock("../infra/tailscale.js", () => ({ claimTailscaleServePort: vi.fn() }));
const runtimes: Array<Awaited<ReturnType<typeof createGatewayRuntimeStateForTest>>> = [];
afterEach(async () => {
for (const runtime of runtimes.splice(0)) {
await runtime.portalService.closeAll();
await Promise.all(
runtime.httpServers.map(
(server) =>
new Promise<void>((resolve) => {
if (!server.listening) {
resolve();
return;
}
server.close(() => resolve());
server.closeAllConnections();
}),
),
);
runtime.wss.close();
}
});
async function status(port: number, host: string, path: string) {
return await new Promise<number>((resolve, reject) => {
const req = request({ host: "127.0.0.1", port, path, headers: { host } }, (res) => {
res.resume();
res.once("end", () => resolve(res.statusCode ?? 0));
});
req.once("error", reject);
req.end();
});
}
describe("Gateway portal ingress startup", () => {
it("starts one dedicated loopback listener that cannot route Gateway endpoints", async () => {
const runtime = await createGatewayRuntimeStateForTest(undefined, {
cfg: {
gateway: {
publicOrigin: "https://control.example.net",
portals: { ingress: { domain: "previews.example.net", port: 0 } },
},
},
getReadiness: () => ({ ready: true, failing: [], uptimeMs: 1 }),
});
runtimes.push(runtime);
const gatewayServers = new Set(runtime.httpServers);
await runtime.startListening();
expect(runtime.httpServers).toHaveLength(gatewayServers.size + 1);
const listener = runtime.httpServers.find((server) => !gatewayServers.has(server));
const address = listener?.address();
expect(address).toMatchObject({ address: "127.0.0.1" });
if (!address || typeof address === "string") {
throw new Error("Expected dedicated portal listener");
}
expect(await status(address.port, "unknown.previews.example.net", "/ready")).toBe(404);
const portal = await runtime.portalService.open({ targetPort: 3000 });
expect(portal.listenPort).toBe(address.port);
expect(await status(address.port, new URL(portal.url).hostname, "/ready")).toBe(401);
expect(runtime.httpServers).toHaveLength(gatewayServers.size + 1);
await runtime.portalService.closeAll();
expect(listener?.listening).toBe(false);
expect(runtime.httpServer.listening).toBe(true);
});
it("fails startup instead of reusing an occupied external ingress listener", async () => {
await withServer(
(_req, res) => res.end("unrelated listener"),
async (url) => {
const runtime = await createGatewayRuntimeStateForTest(undefined, {
cfg: {
gateway: {
portals: {
ingress: { domain: "previews.example.net", port: Number(new URL(url).port) },
},
},
},
});
runtimes.push(runtime);
await expect(runtime.startListening()).rejects.toThrow("portal ingress");
expect(runtime.portalService.list()).toEqual([]);
expect(await (await fetch(url)).text()).toBe("unrelated listener");
},
);
});
it("does not claim the live ingress port during updater canary validation", async () => {
const runtime = await createGatewayRuntimeStateForTest(undefined, {
updateCanary: true,
cfg: { gateway: { portals: { ingress: { domain: "previews.example.net", port: 0 } } } },
});
runtimes.push(runtime);
const gatewayServers = [...runtime.httpServers];
await runtime.startListening();
expect(runtime.httpServers).toEqual(gatewayServers);
});
});

View file

@ -331,6 +331,12 @@ export async function createGatewayHttpTransport(params: {
const portalService = createGatewayPortalService({
httpBindHosts,
httpServers,
ingress: params.cfg.gateway?.portals?.ingress,
managedTailscale: Boolean(managedTailscaleMode),
gatewayOrigins: [
params.cfg.gateway?.publicOrigin,
...(params.cfg.gateway?.controlUi?.allowedOrigins ?? []),
].filter((origin): origin is string => Boolean(origin)),
...(params.gatewayTls?.enabled ? { tlsOptions: params.gatewayTls.tlsOptions } : {}),
});
const reportUnattributableProxy = createGatewayUnattributableProxyReporter(params.log);
@ -571,6 +577,9 @@ export async function createGatewayHttpTransport(params: {
if (httpBindHosts.length === 0) {
throw new Error("Gateway HTTP server failed to start");
}
if (!params.updateCanary) {
await portalService.startIngress();
}
// Published updaters retain the live sandbox port but already pass --update-canary.
if (!params.updateCanary && params.cfg.mcp?.apps?.enabled === true) {
await startSandboxHost();

View file

@ -0,0 +1,343 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { createGatewayPortalService } from "../gateway/portals/portal-service.js";
import { prepareTailscalePublishedOrigin } from "../gateway/tailscale-published-origin.js";
const { forkMock, runExecMock } = vi.hoisted(() => ({
forkMock: vi.fn(),
runExecMock: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
fork: forkMock,
}));
vi.mock("../process/exec.js", () => ({ runExec: runExecMock }));
vi.mock("./runtime-worker-url.js", () => ({
resolveRuntimeWorkerUrl: () => new URL("file:///fixture/tailscale-route-owner.mjs"),
}));
import { claimTailscaleRoute, claimTailscaleServePort } from "./tailscale.js";
// Gate worker readiness and exit explicitly: no subprocesses or live daemon writes.
function queueOwner(options: { ready?: boolean; stop?: boolean; failure?: string } = {}) {
const started = createDeferred();
const stopped = createDeferred();
const owner = Object.assign(new EventEmitter(), {
connected: true,
send: vi.fn(() => {
stopped.resolve();
if (options.stop !== false) {
queueMicrotask(() => owner.emit("exit", 0, null));
}
}),
kill: vi.fn(() => owner.emit("exit", 0, "SIGTERM")),
});
forkMock.mockImplementationOnce(() => {
started.resolve();
queueMicrotask(() => {
if (options.failure) {
owner.emit("message", {
type: "failed",
code: 1,
stdout: "",
stderr: options.failure,
});
} else if (options.ready !== false) {
owner.emit("message", { type: "ready" });
}
});
return owner;
});
return { owner, started: started.promise, stopped: stopped.promise };
}
const legacyRoutes = JSON.stringify({
TCP: { "443": { HTTPS: true }, "18790": { HTTPS: true } },
Web: {
"fixture.tailnet.ts.net:443": {
Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } },
},
"fixture.tailnet.ts.net:18790": {
Handlers: { "/": { Proxy: "http://127.0.0.1:18790" } },
},
},
});
beforeEach(() => {
vi.stubEnv("VITEST", "true");
vi.stubEnv("OPENCLAW_TEST_TAILSCALE_BINARY", "tailscale");
runExecMock.mockImplementation(async (_bin: string, args: string[]) => ({
stdout: args[0] === "status" ? '{"BackendState":"Running"}' : legacyRoutes,
stderr: "",
}));
});
afterEach(() => {
vi.unstubAllEnvs();
vi.resetAllMocks();
});
describe("private Tailscale Serve claims", () => {
it.for([
{ boundary: "queue", revoke: true },
{ boundary: "queue", revoke: false },
{ boundary: "status", revoke: true },
{ boundary: "status", revoke: false },
] as const)(
"checks authority after $boundary wait (revoked: $revoke)",
async ({ boundary, revoke }) => {
const readStarted = createDeferred();
const releaseRead = createDeferred();
const previousOwner = boundary === "queue" ? queueOwner({ ready: false }) : undefined;
const previous = previousOwner
? claimTailscaleRoute("serve", 19000, 18789, vi.fn())
: undefined;
await previousOwner?.started;
const previousForks = forkMock.mock.calls.length;
if (boundary === "status") {
runExecMock.mockImplementation(async (_bin: string, args: string[]) => {
if (args[0] === "serve" && args[1] === "status") {
readStarted.resolve();
await releaseRead.promise;
}
return {
stdout: args[0] === "status" ? '{"BackendState":"Running"}' : legacyRoutes,
stderr: "",
};
});
}
queueOwner();
let current = true;
const denied = new Error("permission denied: caller authority expired");
const starting = claimTailscaleServePort(19001, 24443, () => {
if (!current) {
throw denied;
}
});
if (boundary === "status") {
await readStarted.promise;
}
current = !revoke;
releaseRead.resolve();
previousOwner?.owner.emit("message", { type: "ready" });
const settled = await starting.then(
(claim) => ({ claim }),
(error: unknown) => ({ error }),
);
try {
expect(forkMock).toHaveBeenCalledTimes(previousForks + (revoke ? 0 : 1));
if (revoke) {
expect(settled).toEqual({ error: denied });
} else {
expect("claim" in settled && settled.claim.isActive()).toBe(true);
}
expect(runExecMock.mock.calls.some(([bin]) => bin === "sudo")).toBe(false);
} finally {
if ("claim" in settled) {
await settled.claim.stop();
}
await (await previous)?.stop();
}
},
);
it.each(["caller", "gateway", "service"] as const)(
"does not create a portal route after %s authority closes during status discovery",
async (owner) => {
const readStarted = createDeferred();
const releaseRead = createDeferred();
runExecMock.mockImplementation(async (_bin: string, args: string[]) => {
if (args[0] === "serve" && args[1] === "status") {
readStarted.resolve();
await releaseRead.promise;
}
return {
stdout: args[0] === "status" ? '{"BackendState":"Running"}' : legacyRoutes,
stderr: "",
};
});
queueOwner();
const withdraw = prepareTailscalePublishedOrigin({
origin: "https://fixture.tailnet.ts.net",
mode: "serve",
});
const httpServers: import("node:http").Server[] = [];
const service = createGatewayPortalService({
managedTailscale: true,
httpBindHosts: ["127.0.0.1"],
httpServers,
});
let current = true;
const releaseTarget = vi.fn();
const opening = service.open({
targetPort: 3000,
assertCurrent: () => {
if (!current) {
throw new Error("caller authority expired");
}
},
onClose: releaseTarget,
});
const rejected = expect(opening).rejects.toThrow();
await readStarted.promise;
const listeners = [...httpServers];
let closing: Promise<void> | undefined;
if (owner === "caller") {
current = false;
} else if (owner === "gateway") {
withdraw();
} else {
closing = service.closeAll();
}
releaseRead.resolve();
try {
await rejected;
await closing;
expect(forkMock).not.toHaveBeenCalled();
expect(service.list()).toEqual([]);
expect(httpServers).toEqual([]);
expect(listeners.every((server) => !server.listening)).toBe(true);
expect(releaseTarget).toHaveBeenCalledOnce();
} finally {
withdraw();
await service.closeAll();
}
},
);
it("claims an explicit private port without adopting even a matching legacy backend", async () => {
const { owner } = queueOwner();
const claim = await claimTailscaleServePort(18789, 24443, () => {});
expect(forkMock.mock.calls[0]?.[1]).toEqual([
"--openclaw-tailscale-route-owner",
JSON.stringify({
argv: ["tailscale", "serve", "--yes", "--bg=false", "--https=24443", "18789"],
}),
]);
expect(forkMock.mock.calls[0]?.[2]).toMatchObject({
detached: process.platform !== "win32",
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
expect(runExecMock.mock.calls.map((call) => call[1])).toEqual([
["status", "--json"],
["serve", "status", "--json"],
]);
expect(claim.isActive()).toBe(true);
await Promise.all([claim.stop(), claim.stop()]);
await expect(claim.exited).resolves.toBeUndefined();
expect(claim.isActive()).toBe(false);
expect(owner.send).toHaveBeenCalledTimes(1);
expect(owner.send).toHaveBeenCalledWith({ type: "stop" }, expect.any(Function));
expect(runExecMock).toHaveBeenCalledTimes(2);
});
it.each([0, -1, 65536, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
"rejects invalid HTTPS port %s before daemon access",
async (port) => {
await expect(claimTailscaleServePort(18789, port, () => {})).rejects.toThrow(/httpsPort/);
expect(runExecMock).not.toHaveBeenCalled();
expect(forkMock).not.toHaveBeenCalled();
},
);
it.each([0, -1, 65536, 1.5, Number.NaN])("rejects invalid backend port %s", async (port) => {
await expect(claimTailscaleServePort(port, 24443, () => {})).rejects.toThrow(/target/);
expect(runExecMock).not.toHaveBeenCalled();
expect(forkMock).not.toHaveBeenCalled();
});
it.each([1, 65535])("accepts bounded HTTPS port %s", async (port) => {
queueOwner();
const claim = await claimTailscaleServePort(18789, port, () => {});
await claim.stop();
});
it("withdraws activity when the owned worker exits unexpectedly", async () => {
const { owner } = queueOwner();
const claim = await claimTailscaleServePort(18789, 24443, () => {});
owner.emit("exit", 1, null);
await claim.exited;
expect(claim.isActive()).toBe(false);
});
it("keeps explicit private arguments and operator diagnostics on permission fallback", async () => {
queueOwner({ failure: "permission denied" });
queueOwner({ failure: "sudo: a password is required" });
await expect(claimTailscaleServePort(18789, 24443, () => {})).rejects.toThrow(
/Tailscale serve needs elevated access[\s\S]*sudo tailscale set --operator=\$USER/,
);
expect(forkMock.mock.calls[1]?.[1]).toEqual([
"--openclaw-tailscale-route-owner",
JSON.stringify({
argv: ["sudo", "-n", "tailscale", "serve", "--yes", "--bg=false", "--https=24443", "18789"],
}),
]);
expect(runExecMock.mock.calls.every((call) => !call[1].includes("off"))).toBe(true);
});
it("reports an occupied port without retrying or clearing it, then allows the next claim", async () => {
queueOwner({ failure: "listener already exists for port 18790" });
queueOwner();
const failure = claimTailscaleServePort(18789, 18790, () => {});
const next = claimTailscaleServePort(18789, 24443, () => {});
await expect(failure).rejects.toThrow(/ownership OpenClaw cannot prove; it was not modified/);
const claim = await next;
await claim.stop();
expect(forkMock).toHaveBeenCalledTimes(2);
expect(runExecMock.mock.calls.every((call) => !call[1].includes("off"))).toBe(true);
});
it.each(["gateway-first", "portal-first"] as const)(
"serializes Gateway and portal startup through readiness (%s)",
async (order) => {
const first = queueOwner({ ready: false });
const second = queueOwner({ ready: false });
const gateway = () => claimTailscaleRoute("serve", 19000, 18789, vi.fn());
const portal = () => claimTailscaleServePort(19001, 24443, () => {});
const firstClaim = order === "gateway-first" ? gateway() : portal();
const secondClaim = order === "gateway-first" ? portal() : gateway();
await first.started;
expect(forkMock).toHaveBeenCalledTimes(1);
first.owner.emit("message", { type: "ready" });
await second.started;
const claimA = await firstClaim;
expect(claimA.isActive()).toBe(true);
second.owner.emit("message", { type: "ready" });
const claimB = await secondClaim;
expect(runExecMock.mock.calls.filter((call) => call[1].includes("off"))).toHaveLength(1);
await Promise.all([claimA.stop(), claimB.stop()]);
},
);
it("waits for an owned stop to finish before starting another claim", async () => {
const first = queueOwner({ stop: false });
queueOwner();
const claimA = await claimTailscaleServePort(19000, 24443, () => {});
const stopping = claimA.stop();
const starting = claimTailscaleRoute("serve", 19001, 18789, vi.fn());
await first.stopped;
expect(forkMock).toHaveBeenCalledTimes(1);
first.owner.emit("exit", 0, null);
await stopping;
const claimB = await starting;
await claimB.stop();
});
it("does not stop a sibling while another claim is reading and publishing config", async () => {
const first = queueOwner();
const second = queueOwner({ ready: false });
const claimA = await claimTailscaleRoute("serve", 19000, 18789, vi.fn());
const starting = claimTailscaleServePort(19001, 24443, () => {});
await second.started;
const stopping = claimA.stop();
await Promise.resolve();
expect(first.owner.send).not.toHaveBeenCalled();
second.owner.emit("message", { type: "ready" });
const claimB = await starting;
await stopping;
expect(claimB.isActive()).toBe(true);
await claimB.stop();
});
});

View file

@ -179,12 +179,25 @@ async function getTailscaleBinary(): Promise<string> {
return cachedTailscaleBinary ?? "tailscale";
}
type TailscaleRouteClaim = {
export type TailscaleRouteClaim = {
exited: Promise<void>;
isActive: () => boolean;
stop: () => Promise<void>;
};
// Foreground startups replace the daemon's shared Serve config using an ETag.
// Serialize our starts and owned stops, not the lifetime of each claim.
let tailscaleRouteOperation: Promise<void> = Promise.resolve();
function serializeTailscaleRouteOperation<T>(operation: () => Promise<T>): Promise<T> {
const result = tailscaleRouteOperation.then(operation);
tailscaleRouteOperation = result.then(
() => undefined,
() => undefined,
);
return result;
}
type TailscaleRouteOwnerFailure = Pick<
Extract<TailscaleRouteOwnerMessage, { type: "failed" }>,
"code" | "stdout" | "stderr"
@ -346,28 +359,93 @@ async function startTailscaleRouteOwner(
}
}
/** Claim the Gateway route, adopting only its recognized legacy root handler. */
export async function claimTailscaleRoute(
mode: "serve" | "funnel",
target: number,
gatewayPort: number,
info: (message: string) => void,
): Promise<TailscaleRouteClaim> {
return serializeTailscaleRouteOperation(() =>
claimTailscaleRouteOwned({ mode, target, gatewayPort, info }),
);
}
/** Claim a private HTTPS Serve port without adopting or clearing existing routes. */
export async function claimTailscaleServePort(
target: number,
httpsPort: number,
assertCurrent: () => void,
): Promise<TailscaleRouteClaim> {
for (const [name, port] of [
["target", target],
["httpsPort", httpsPort],
] as const) {
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new RangeError(`Tailscale ${name} must be an integer port between 1 and 65535`);
}
}
return serializeTailscaleRouteOperation(() =>
claimTailscaleRouteOwned({
mode: "serve",
target,
httpsPort,
assertCurrent,
info: () => undefined,
}),
);
}
// Startup failure cleanup stays inside the queued operation. Only a returned
// claim's stop reenters the queue, so cleanup cannot deadlock its own startup.
async function claimTailscaleRouteOwned(
params: { target: number; info: (message: string) => void; assertCurrent?: () => void } & (
| { mode: "serve" | "funnel"; gatewayPort: number; httpsPort?: never }
| { mode: "serve"; httpsPort: number; gatewayPort?: never }
),
): Promise<TailscaleRouteClaim> {
const { mode, target, info } = params;
let authorityDenied = false;
const assertCurrent = () => {
try {
params.assertCurrent?.();
} catch (error) {
// An owner denial must never be retried as a local CLI permission failure.
authorityDenied = true;
throw error;
}
};
assertCurrent();
const tailscaleBin = await getTailscaleBinary();
let adopted = false;
const start = async (bin: string, prefix: string[] = []) => {
assertCurrent();
const exec = (args: string[]) =>
runExec(bin, [...prefix, ...args], { timeoutMs: 5000, maxBuffer: 400_000 });
await waitForTailscaleBackendReady({ bin, prefix, info });
assertCurrent();
const { stdout } = await exec(["serve", "status", "--json"]);
const routes = extractTailscaleServeGatewayUrls(stdout, gatewayPort, true);
const routes =
params.gatewayPort === undefined
? undefined
: extractTailscaleServeGatewayUrls(stdout, params.gatewayPort, true);
// Foreground claims require a free port. Never clear sibling handlers or
// infer ownership from the new ephemeral backend instead of the Gateway port.
if (routes?.some((url) => !new URL(url).port)) {
await exec(["serve", "--yes", "--https=443", "--set-path=/", "off"]);
adopted = true;
}
assertCurrent();
return startTailscaleRouteOwner(
[bin, ...prefix, mode, "--yes", "--bg=false", `${target}`],
[
bin,
...prefix,
mode,
"--yes",
"--bg=false",
...(params.httpsPort === undefined ? [] : [`--https=${params.httpsPort}`]),
`${target}`,
],
stdout,
);
};
@ -375,12 +453,15 @@ export async function claimTailscaleRoute(
try {
claim = await start(tailscaleBin);
} catch (error) {
if (!isPermissionDeniedError(error)) {
if (authorityDenied || !isPermissionDeniedError(error)) {
throw error;
}
try {
claim = await start("sudo", ["-n", tailscaleBin]);
} catch (sudoError) {
if (authorityDenied) {
throw sudoError;
}
const { stderr, message } = extractExecErrorText(sudoError);
const detail = stderr.trim() || message.trim();
if (!SUDO_NONINTERACTIVE_AUTH_ERROR.test(detail)) {
@ -396,7 +477,10 @@ export async function claimTailscaleRoute(
if (adopted) {
info("Tailscale route adopted from a previous OpenClaw release");
}
return claim;
return {
...claim,
stop: () => serializeTailscaleRouteOperation(claim.stop),
};
}
/** Resolve the hostname after Serve startup, while the local daemon may still be settling. */

View file

@ -63,6 +63,7 @@ suite.define(() => {
listenPort: 43210,
tokenQuery: "openclaw_portal=synthetic",
publicUrl: "http://127.0.0.1:43210/",
url: "http://127.0.0.1:43210/?openclaw_portal=synthetic",
createdAtMs: 1,
},
],

View file

@ -22,7 +22,13 @@ const enPortals = {
closeFailed: "Could not close the portal: {error}",
unreachableTitle: "Portal not reachable from this browser",
unreachableBody:
"The Gateway is likely being accessed through a proxy or tunnel that exposes only its main port. Open this URL from a browser on the Gateway host.",
"Check the portal URL's DNS, TLS, and network access. For private Tailscale Serve, allow its HTTPS port in your tailnet policy. For a reverse proxy, check the dedicated portal ingress route, then retry.",
newTabRequiredTitle: "Open this HTTP portal in a new tab",
newTabRequiredBody:
"This portal uses HTTP with a different hostname or scheme from the Control UI. Open the link in a new tab so its authentication cookies work, or use an HTTPS portal for an embedded preview.",
ingressRequiredTitle: "Remote portal ingress required",
ingressRequiredBody:
"This Gateway returned a loopback URL, which points to this browser's machine. Use a browser on the Gateway host, enable managed private Tailscale Serve, or configure gateway.portals.ingress with a separate private HTTPS wildcard proxy. Forwarding only the Gateway port is not enough.",
writeAccessRequiredTitle: "Write access required",
writeAccessRequiredBody: "This portal requires an operator with write access.",
retry: "Retry",

View file

@ -1,14 +1,21 @@
import type { PortalSummary } from "@openclaw/gateway-protocol";
import { resolveGatewayHttpOrigin } from "../../components/sandbox-host.ts";
import { isLoopbackHostname } from "../../lib/gateway-locality.ts";
export function resolvePortalUrl(
portal: Pick<PortalSummary, "listenPort" | "path"> & { tokenQuery: string },
gatewayUrl: string,
hostOrigin: string,
): string {
const url = new URL(resolveGatewayHttpOrigin(gatewayUrl, hostOrigin));
url.port = String(portal.listenPort);
url.pathname = portal.path ?? "/";
url.search = portal.tokenQuery;
return url.href;
/** HTTP previews require a matching UI scheme and host to keep their authentication cookies. */
export function portalNeedsNewTab(portalUrl: string, controlUiUrl: string): boolean {
const portal = new URL(portalUrl);
const controlUi = new URL(controlUiUrl);
// Without secure partitioned cookies, prefer a working top-level launch over
// guessing registrable-domain relationships between different hostnames.
return (
portal.protocol === "http:" &&
(controlUi.protocol !== portal.protocol || controlUi.hostname !== portal.hostname)
);
}
/** A remote Gateway's loopback endpoint points at the browser, not the Gateway. */
export function portalNeedsRemoteIngress(portalUrl: string, gatewayUrl: string): boolean {
return (
isLoopbackHostname(new URL(portalUrl).hostname) &&
!isLoopbackHostname(new URL(gatewayUrl).hostname)
);
}

View file

@ -11,7 +11,7 @@ import type { GatewayBrowserClient, GatewayEventFrame } from "../../api/gateway.
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
import { resolvePortalUrl } from "./portal-url.ts";
import { portalNeedsNewTab, portalNeedsRemoteIngress } from "./portal-url.ts";
const probePortalReachable = vi.hoisted(() =>
vi.fn<() => Promise<"reachable" | "unreachable" | "blocked">>(),
@ -27,8 +27,8 @@ const portal = {
port: 3000,
listenPort: 43_123,
tokenQuery: "openclaw_portal=secret-token",
url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token",
publicUrl: "http://127.0.0.1:43123/app",
url: "https://preview.example.test:8443/context/app?view=one%2Ftwo&openclaw_portal=secret-token#section",
publicUrl: "https://preview.example.test:8443/context/app?view=one%2Ftwo#section",
path: "/app",
description: "Use the seeded test account.",
createdAtMs: 1_000,
@ -95,6 +95,7 @@ afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
beforeEach(() => {
@ -217,24 +218,22 @@ describe("PortalsPage", () => {
"Use the seeded test account.",
);
const frame = page.querySelector("iframe");
expect(frame?.getAttribute("src")).toBe(
"https://gateway.example.test:43123/app?openclaw_portal=secret-token",
);
expect(frame?.getAttribute("src")).toBe(portal.url);
expect(page.querySelector(".portals-preview__url")?.getAttribute("href")).toBe(portal.url);
expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(frame?.getAttribute("sandbox")).toBe(
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts",
);
expect(probePortalReachable).toHaveBeenCalledWith(
"https://gateway.example.test:43123/app?openclaw_portal=secret-token",
);
expect(probePortalReachable).toHaveBeenCalledWith(portal.url);
source.emitPortals([]);
source.emitPortals([{ ...portal, url: "https://event.example.test/untrusted" }]);
await vi.waitFor(() => {
expect(source.request).toHaveBeenCalledTimes(2);
});
expect(source.request).toHaveBeenLastCalledWith("portal.list", {});
expect(page.querySelector(".portals-rail__title")?.textContent).toBe("Seeded app");
expect(page.querySelector("iframe")?.getAttribute("src")).toBe(portal.url);
});
it("requires write access instead of opening a portal without credentials", async () => {
@ -295,6 +294,106 @@ describe("PortalsPage", () => {
expect(page.textContent).not.toContain("Portal not reachable from this browser");
});
it("explains missing remote ingress without probing the browser's loopback services", async () => {
const localPortal = {
...portal,
url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token",
publicUrl: "http://127.0.0.1:43123/app",
};
const source = createContext(["portal.list", "portal.close"], async () => ({
portals: [localPortal],
}));
const page = await mountPage(source.context);
await vi.waitFor(() => expect(page.textContent).toContain("Remote portal ingress required"));
expect(page.textContent).toContain("gateway.portals.ingress");
expect(page.querySelector("iframe")).toBeNull();
expect(probePortalReachable).not.toHaveBeenCalled();
expect(page.querySelector(".portals-preview__url")?.getAttribute("href")).toBe(localPortal.url);
});
it("offers the canonical HTTP URL in a new tab when the Control UI is on another host", async () => {
vi.stubGlobal("location", new URL("http://localhost:18789/portals"));
const localPortal = {
...portal,
url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token",
};
const source = createContext(["portal.list"], async () => ({ portals: [localPortal] }));
source.context.gateway.connection.gatewayUrl = "ws://127.0.0.1:18789/control";
const page = await mountPage(source.context);
await vi.waitFor(() =>
expect(page.textContent).toContain("Open this HTTP portal in a new tab"),
);
expect(page.querySelector("iframe")).toBeNull();
expect(probePortalReachable).not.toHaveBeenCalled();
const link = page.querySelector(".portals-preview__notice-url");
expect(link?.getAttribute("href")).toBe(localPortal.url);
expect(link?.getAttribute("target")).toBe("_blank");
});
it("preserves the actual HTTP scheme for a local listener even with an HTTPS Gateway", async () => {
vi.stubGlobal("location", new URL("http://127.0.0.1:18789/portals"));
const localPortal = {
...portal,
url: "http://127.0.0.1:43123/app?openclaw_portal=secret-token",
};
const source = createContext(["portal.list"], async () => ({ portals: [localPortal] }));
source.context.gateway.connection.gatewayUrl = "wss://localhost:18789/control";
const page = await mountPage(source.context);
await vi.waitFor(() =>
expect(page.querySelector("iframe")?.getAttribute("src")).toBe(localPortal.url),
);
expect(probePortalReachable).toHaveBeenCalledWith(localPortal.url);
});
it("opens a service-published direct LAN URL unchanged without requiring ingress", async () => {
vi.stubGlobal("location", new URL("http://192.168.1.20:18789/portals"));
const lanPortal = {
...portal,
url: "http://192.168.1.20:43123/app?openclaw_portal=secret-token",
publicUrl: "http://192.168.1.20:43123/app",
};
const source = createContext(["portal.list"], async () => ({ portals: [lanPortal] }));
source.context.gateway.connection.gatewayUrl = "ws://192.168.1.20:18789/control";
const page = await mountPage(source.context);
await vi.waitFor(() =>
expect(page.querySelector("iframe")?.getAttribute("src")).toBe(lanPortal.url),
);
expect(page.querySelector(".portals-preview__url")?.getAttribute("href")).toBe(lanPortal.url);
expect(probePortalReachable).toHaveBeenCalledWith(lanPortal.url);
expect(page.textContent).not.toContain("Remote portal ingress required");
});
it("does not publish a late probe from the previous Gateway", async () => {
let completeOldProbe!: (result: "reachable") => void;
probePortalReachable
.mockImplementationOnce(
() =>
new Promise((resolve) => {
completeOldProbe = resolve;
}),
)
.mockResolvedValueOnce("unreachable");
const first = createContext(["portal.list"], async () => ({ portals: [portal] }));
const provider = createApplicationContextProvider(first.context);
const page = document.createElement("openclaw-portals-page");
provider.append(page);
document.body.append(provider);
await page.updateComplete;
await vi.waitFor(() => expect(probePortalReachable).toHaveBeenCalledTimes(1));
const second = createContext(["portal.list"], async () => ({ portals: [portal] }));
provider.setContext(second.context);
await vi.waitFor(() =>
expect(page.textContent).toContain("Portal not reachable from this browser"),
);
completeOldProbe("reachable");
await page.updateComplete;
expect(page.querySelector("iframe")).toBeNull();
expect(page.textContent).toContain("Portal not reachable from this browser");
expect(probePortalReachable).toHaveBeenCalledTimes(2);
});
it("shows the empty prompts and an unsupported note without calling the method", async () => {
const source = createContext([], async () => ({ portals: [] }));
const page = await mountPage(source.context);
@ -308,14 +407,36 @@ describe("PortalsPage", () => {
});
});
describe("resolvePortalUrl", () => {
it("uses the resolved gateway host and scheme with the portal listener port", () => {
expect(
resolvePortalUrl(
portal,
"wss://gateway.example.test:18789/control",
"http://control-ui.example.test",
),
).toBe("https://gateway.example.test:43123/app?openclaw_portal=secret-token");
describe("portalNeedsNewTab", () => {
it.each([
["http://127.0.0.1:43123/app", "http://localhost:18789", true],
["http://127.0.0.1:43123/app", "https://127.0.0.1:18789", true],
["http://192.168.1.20:43123/app", "http://192.168.1.20:18789", false],
["https://preview.example.test/app", "http://localhost:18789", false],
])("classifies %s from the actual Control UI %s", (url, controlUiUrl, expected) => {
expect(portalNeedsNewTab(url, controlUiUrl)).toBe(expected);
});
});
describe("portalNeedsRemoteIngress", () => {
it.each(["localhost", "127.0.0.1", "127.2.3.4", "[::1]"])(
"identifies %s as browser-local for a remote Gateway",
(host) => {
expect(
portalNeedsRemoteIngress(`http://${host}:43123/app`, "wss://gateway.example.test/control"),
).toBe(true);
expect(portalNeedsRemoteIngress(`http://${host}:43123/app`, "ws://localhost:18789")).toBe(
false,
);
},
);
it("does not label authoritative external or direct network endpoints as loopback", () => {
expect(portalNeedsRemoteIngress(portal.url, "wss://gateway.example.test/control")).toBe(false);
expect(
portalNeedsRemoteIngress(
"http://192.168.1.2:43123/app",
"wss://gateway.example.test/control",
),
).toBe(false);
});
});

View file

@ -22,7 +22,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { probePortalReachable, type PortalReachability } from "./portal-reachability.ts";
import { resolvePortalUrl } from "./portal-url.ts";
import { portalNeedsNewTab, portalNeedsRemoteIngress } from "./portal-url.ts";
import "./portals.css";
registerPortalsEnglish();
@ -32,7 +32,7 @@ const PORTAL_FRAME_SANDBOX =
type PortalProbeState = {
key: string;
status: "probing" | PortalReachability;
status: "probing" | "ingress-required" | "new-tab-required" | PortalReachability;
};
class PortalsPage extends OpenClawLightDomElement {
@ -257,26 +257,27 @@ class PortalsPage extends OpenClawLightDomElement {
}
}
private portalUrl(portal: PortalSummary, tokenQuery: string): string {
return resolvePortalUrl(
{ ...portal, tokenQuery },
this.context.gateway.connection.gatewayUrl,
window.location.origin,
);
}
private ensurePortalProbe(portal: PortalSummary, force = false) {
const tokenQuery = portal.tokenQuery;
if (!tokenQuery) {
if (!portal.tokenQuery || !portal.url) {
this.portalProbeGeneration += 1;
this.portalProbeState = null;
return;
}
const url = this.portalUrl(portal, tokenQuery);
const url = portal.url;
const key = `${portal.id}\u0000${url}`;
if (!force && this.portalProbeState?.key === key) {
return;
}
if (portalNeedsRemoteIngress(url, this.context.gateway.connection.gatewayUrl)) {
this.portalProbeGeneration += 1;
this.portalProbeState = { key, status: "ingress-required" };
return;
}
if (portalNeedsNewTab(url, location.href)) {
this.portalProbeGeneration += 1;
this.portalProbeState = { key, status: "new-tab-required" };
return;
}
const cached = force ? undefined : this.portalProbeCache.get(key);
if (cached !== undefined) {
this.portalProbeState = { key, status: cached };
@ -286,8 +287,8 @@ class PortalsPage extends OpenClawLightDomElement {
const generation = ++this.portalProbeGeneration;
this.portalProbeState = { key, status: "probing" };
void probePortalReachable(url).then((reachability) => {
this.portalProbeCache.set(key, reachability);
if (generation === this.portalProbeGeneration && this.portalProbeState?.key === key) {
this.portalProbeCache.set(key, reachability);
this.portalProbeState = { key, status: reachability };
}
});
@ -405,7 +406,7 @@ class PortalsPage extends OpenClawLightDomElement {
}
private renderPortal(portal: PortalSummary) {
if (!portal.tokenQuery) {
if (!portal.tokenQuery || !portal.url) {
return html`
<section class="portals-preview">
<div class="portals-preview__notice" role="status">
@ -417,7 +418,7 @@ class PortalsPage extends OpenClawLightDomElement {
</section>
`;
}
const portalUrl = this.portalUrl(portal, portal.tokenQuery);
const portalUrl = portal.url;
const displayUrl = new URL(portalUrl);
displayUrl.search = "";
const frameKey = `${portal.id}\u0000${portalUrl}`;
@ -460,13 +461,29 @@ class PortalsPage extends OpenClawLightDomElement {
<div class="portals-empty__title">${t("portalsPage.loading")}</div>
</div>
`
: probeStatus === "unreachable"
: probeStatus === "unreachable" ||
probeStatus === "ingress-required" ||
probeStatus === "new-tab-required"
? html`
<div class="portals-preview__notice" role="status">
<div class="portals-preview__notice-title">
${t("portalsPage.unreachableTitle")}
${t(
probeStatus === "new-tab-required"
? "portalsPage.newTabRequiredTitle"
: probeStatus === "ingress-required"
? "portalsPage.ingressRequiredTitle"
: "portalsPage.unreachableTitle",
)}
</div>
<p>${t("portalsPage.unreachableBody")}</p>
<p>
${t(
probeStatus === "new-tab-required"
? "portalsPage.newTabRequiredBody"
: probeStatus === "ingress-required"
? "portalsPage.ingressRequiredBody"
: "portalsPage.unreachableBody",
)}
</p>
<a
class="portals-preview__notice-url"
href=${portalUrl}