Architecture
Overview
Portal publishes local services on public subdomains, optional dedicated TCP ports, and optional UDP ports through a relay. Backends connect outward to the relay. Uncached stream traffic is routed by SNI, and tenant TLS remains end-to-end between the client and the tunnel endpoint. Opt-in static caches terminate browser TLS at the relay.
With IVNP-backed overlay networking, Portal can publish through that same ingress while delegating the gateway-to-ingress network path to IVNP. Identity, leases, and authorization stay in Portal; internal routers and hop ordering stay in IVNP. The detailed overlay architecture describes this boundary and the unchanged SDK contract.
High-level paths with direct reverse transport:
Stream client
-> Relay SNI listener (:443 by default)
-> Claimed reverse session
-> SDK / portal-tunnel
-> Local service
TCP port client
-> Relay lease TCP port (within configured MIN_PORT-MAX_PORT)
-> Claimed reverse session (raw TCP, no TLS)
-> SDK / portal-tunnel
-> Local service
UDP client
-> Relay lease UDP port (within configured MIN_PORT-MAX_PORT)
-> Internal QUIC tunnel
-> SDK / portal-tunnel
-> Local UDP service Architecture Invariants
Transport and Routing
- Raw TCP reverse-connect is the canonical stream transport.
- Do not introduce websocket or legacy compatibility paths by default.
- Derive lease hostnames from the full normalized
PORTAL_URLhost, not from apex extraction. - Preserve explicit root-host fallback through SNI no-route handling to the admin/API handler.
- Stream ingress is TLS-only. UDP exposure, when enabled, is raw UDP.
- Portal endpoint policy stops at endpoint selection; IVNP owns the network path. Discovery routes and leases carry no IVNP-internal topology.
TLS and Identity
- Relay terminates admin/API TLS on the root host and exposes
/v1/signfor tenant-side keyless signing. - Control-plane HTTP (
/sdk/*), reverse-session establishment (/sdk/connect), and tenant TLS are separate connections with different trust boundaries. - Relay API TLS, SDK relay-client TLS, SDK tenant-server TLS, and QUIC tunnel TLS are distinct configs even when they reuse the same relay certificate material.
- For uncached HTTPS tunnels, the relay peeks ClientHello for SNI and bridges encrypted bytes; tenant TLS terminates at the tunnel. Opt-in static caches terminate browser TLS at the relay.
- SDK/tunnel endpoints terminate tenant TLS locally with a keyless-backed signer that calls the relay.
- In keyless TLS, the relay performs certificate private-key signing through
/v1/sign, but the SDK/tunnel endpoint still runs the TLS server handshake and derives tenant TLS session keys locally. - Lease operations require a relay-issued access token whose identity and lease ID both match the active lease instance.
/sdk/connectuses a separate reverse-only capability returned as part of a generic reverse endpoint. /sdk/registeris authenticated by a SIWE challenge/response flow using the SDK identity secp256k1 key. On success, the relay issues separate signed credentials for lease operations and reverse connection establishment.- Relay URLs must use
https://. - HTTP/2 stays disabled on the admin/API TLS route. Keyless TLS certificate sharing and
/sdk/connectboth depend on the current HTTP/1.1-only transport contract.
Reverse Session Protocol
- SNI wildcard matching is one level only.
*.parent.example.commatchesfoo.parent.example.com, not deeper labels. - Reverse TCP marker bytes remain protocol state:
0x00= idle keepalive0x01= raw TCP activation (non-TLS port routing)0x02= TLS activation (followed by a 16-byte binding that every/v1/signrequest must echo)
/sdk/connectremains HTTP/1.1 only.
JSON and Shared Contract
- Portal JSON control-plane responses use
APIEnvelope:{ ok, data?, error? }. Delegated facilitator responses, streams, installers, and some cache HTTP errors use their endpoint-specific formats. - JSON handlers should write responses through the shared API helpers.
types/is reserved for shared wire/public types and cross-package constants only.- Shared control-plane and public route constants belong in
types/paths.go. - Relay-local frontend asset filenames stay local to
cmd/relay-server. - Do not import
portalfromcmd/*orsdkjust to reach shared DTOs or constants.
Operational Constraints
- For non-localhost deployments, relay TLS can run from manual certificate files in the relay
IDENTITY_PATHdirectory or from managed ACME. - The canonical/default managed DNS provider is
embedded: NS-delegated authoritative DNS with persistent CSK signing and parent DS export, without DNS API secrets. Externalcloudflare,gcloud,hetzner,njalla,route53, andvultrbackends are supported first-class alternatives. - ENS gasless automation reuses
ACME_DNS_PROVIDERfor DNSSEC and ENS TXT sync when the selected provider supports DNSSEC. - Relay stores its state under
IDENTITY_PATH, includingidentity.json,policy.json, and certificate material. Tunnel and demo-app identities still useIDENTITY_PATH/--identity-pathas a direct JSON file path. - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
- Relay certificate material lives under
IDENTITY_PATHasfullchain.pemandprivatekey.pem. - Localhost uses the development certificate path instead of public managed/manual certificate setup.
Connection Model
Portal has three distinct network roles:
- Control-plane HTTP requests
POST /sdk/register/challengePOST /sdk/registerPOST /sdk/renewPOST /sdk/reversePOST /sdk/unregisterGET /sdk/domain
- Reverse session connection
GET /sdk/connect- HTTP/1.1 only
- hijacked into a long-lived raw TCP session
- starts idle in the per-lease stream ready queue, then becomes the tenant data path when claimed
- Internal datagram tunnel
- QUIC to the relay URL authority (default HTTPS port 443), with ALPN
portal-tunnel - authenticated by a first-stream control message carrying
access_token - carries relay-to-SDK/tunnel datagram traffic only
- QUIC to the relay URL authority (default HTTPS port 443), with ALPN
That distinction matters because /sdk/connect stops being ordinary HTTP once hijacked, while the UDP backhaul is a separate internal QUIC carrier.
Package Layout
The relay runtime lives in portal/ (server, route table, transport runtimes, ACME, keyless, auth, discovery, policy).
The SDK client library lives in sdk/ (listener, exposure, relay API client, MITM self-probe, transport clients).
CLI entry points live in cmd/relay-server and cmd/portal-tunnel; they import portal/ and sdk/ respectively but never each other.
Shared wire types, API envelope, error codes, path constants, and transport frame codec live in types/.
Opt-in Static Cache
portal/cache owns snapshot creation and synchronization on the origin side,
and admission, storage, expiry, and serving on the relay side. A lease must
explicitly opt in before the relay accepts its files. Eligible snapshots route
through the relay HTTP handler and terminate browser TLS there; ordinary
uncached connections retain TLS passthrough. Snapshots are routed only by the
identity-bound canonical hostname; friendly aliases always use the live origin.
See the cache trust boundary and cache limits and expiry.
Transport Model
Raw reverse transport (TLS only)
- SDK/tunnel registers one lease per relay through
POST /sdk/register/challengefollowed byPOST /sdk/register. - SDK opens one or more reverse sessions per registered lease with
GET /sdk/connect. - Each relay hijacks
/sdk/connectrequests and places the connection in the per-lease stream ready queue. - While idle, the relay writes
0x00keepalive markers. - A stream client connects to the relay SNI listener.
- Relay extracts SNI from ClientHello, resolves a lease, and waits up to
ClaimTimeoutfor one reverse session from that lease stream queue. - Relay writes
0x02plus a 16-byte binding to activate the claimed session. - SDK/tunnel receives
0x02and the binding, terminates tenant TLS locally via thekeyless_tlst13server (transcript signing through the binding-checked/v1/sign), and the relay bridges raw encrypted bytes end-to-end.
Result: the relay decides routing, but tenant TLS termination still happens at the SDK/tunnel side.
Browser reverse transport
Browser runtimes cannot open the raw TCP connection used by the native reverse
transport. When the SDK runs with GOOS=js, it keeps the same registration,
lease-renewal, reverse-endpoint, and tenant-stream contracts but changes the
carrier:
- The SDK opens one WebSocket to the lease’s reverse endpoint and authenticates
the handshake with the reverse capability and the
portal.reverse.v1subprotocol marker. - A yamux session runs inside that WebSocket. Each yamux stream represents one reverse connection that native runtimes would open as a separate raw connection.
- Every logical stream presents the capability current when that stream is opened. The relay verifies its signature, expiry, active lease, and lease identity before offering the stream to ingress.
- Tenant protocol bytes then use the same activation markers and tenant TLS path as the native transport.
The WebSocket and yamux layers are therefore a browser-compatible carrier, not a separate lease or application protocol. Native runtimes continue to use the raw reverse path and do not pay the multiplexing cost.
Connection and resource ownership
Native HTTP hijacks and authenticated WebSocket/yamux streams enter the same
per-lease ReversePool as net.Conn values. The pool manages capacity,
idle keepalives, acquisition, and closing queued connections. Acquiring a
connection stops its idle writer and transfers ownership to the caller without
writing a session-start frame. Overlay admission reserves capacity before
acknowledging an offer and commits only after the acknowledgement succeeds;
pool shutdown waits for that decision.
Reverse framing has one owner in portal/transport/reverse_framing.go: the
relay writes raw/TLS start markers, and the SDK reads the same contract directly
from the original connection. The acquired connection’s carrier does not affect
tenant TLS, raw TCP forwarding, or static-cache fallback.
The lease record owns the reverse pool, the active WebSocket/yamux carrier, the
raw TCP net.Listener, and UDP ingress. Replacing or closing a lease closes its
carrier; a replaced carrier’s late cleanup cannot detach the current one. Raw
TCP accepts and reverse acquisition are composed by the server before bridging
the two net.Conn values. The registry owns TCP/UDP port reservations and access
policy. Reservation bookkeeping uses the registry lock and canonical service
identity key; socket shutdown happens outside that lock before ports are
released. UDP ingress receives only an enabled/disabled gate.
UDP endpoint construction starts no workers or sockets. Start acquires ingress
and starts dispatch/cleanup; Close stops the endpoint and its QUIC backhaul.
The relay and SDK share DatagramSession for a replaceable QUIC connection and
decoded frames. UDP flows store client addresses, and the endpoint writes replies
through its own socket. Portal retains datagram routing metadata instead of
encoding flow IDs or relay identity into artificial stream or address types.
IVNP-backed overlay networking
Separating endpoint policy from network routing lets Portal expose services through a public ingress without managing the path behind it. The overlay can change its internal routes without changing Portal’s lease, identity, or SDK-facing reverse-endpoint contract.
Portal endpoint policy stops at endpoint selection; IVNP owns the network path.
The diagram shows alternative stream paths. The tunnel opens the reverse connection outward to the ingress or selected gateway; in overlay mode the gateway opens an IVNP stream to the ingress. Public clients still use the ingress, and the SDK still forwards streams to the local service.
Ownership split:
- Portal: selects and authorizes the public ingress, selects an eligible gateway, issues the delegated reverse capability, and owns identity, lease, admission, health, and fallback semantics.
- IVNP: owns destination reachability, the gateway-to-ingress path, and intermediate router selection and tunnel construction; may use multiple internal network hops without exposing that topology to Portal.
- SDK: receives the same generic reverse endpoint, selects no intermediate hops, and sees no IVNP route topology.
The Gateway -> Ingress edge is one logical Portal transport edge. IVNP may
carry it over multiple internal hops, but that topology is opaque to Portal.
Portal does not construct an ordered relay chain or persist IVNP’s internal
topology in discovery or lease state.
With IVNP_CONFIG enabled, the ingress may return
a gateway URL in the same reverse_endpoint contract. The SDK neither selects
the gateway nor sees an IVNP destination. Direct reverse transport is the
default and fallback. A lease with overlay=true prefers an available overlay
gateway. A failed gateway is reported through POST /sdk/reverse; the ingress
applies the lease’s overlay preference while rotating the endpoint without
replacing the lease.
Protocol flow:
Tenant TLS Self-Probe Detection
- After a real tenant connection begins I/O, the SDK may start one asynchronous self-probe for that listener if no probe is in flight and the 30-second cooldown has expired.
- The SDK opens a new TLS connection to its own public URL using the same tenant-facing TLS characteristics as normal traffic.
- The probe client exports TLS keying material (
ExportKeyingMaterial) from that probe connection and stores it under a random nonce. - The first encrypted probe payload is
16-byte nonce + random padding; there is no fixed probe magic or dedicated ALPN. - When the probe connection comes back through the relay and reaches the SDK-side tenant TLS terminator, the SDK peeks only the first 16 encrypted application bytes while a probe is pending.
- If those bytes match a pending nonce, the SDK exports keying material on the server side and compares it with the client-side exporter value.
- Matching exporter values mean the probe observed passthrough for that connection. A mismatch is logged as suspected relay-side TLS termination. A timeout is logged as probe failure, not proof of MITM.
Result: this is a detect-only signal by default. It raises the cost of adaptive relay-side TLS termination, but it does not prove passthrough for every user connection. Callers that need stricter behavior can opt into relay banning.
TCP Port Transport (non-TLS)
- SDK/tunnel requests a register challenge with
tcp_enabled=true, signs the returned SIWE message, and completes registration. - Relay validates that the TCP port plane is enabled, allocates a TCP port, and creates a per-lease TCP listener.
- Registration response includes
tcp_addr(public TCP endpoint). - An external TCP client connects to
tcp_addr. - The relay accepts the connection, claims a reverse session from the lease stream queue, and writes
0x01(raw TCP activation marker). - SDK-side receives
0x01and passes the raw connection directly without TLS handshake. - Data is copied bidirectionally between the external client and the reverse session.
Result: the relay allocates a dedicated TCP port per lease and bridges raw TCP without TLS. This is ideal for non-TLS protocols like Minecraft, game servers, or any raw TCP service.
UDP/QUIC Datagram Transport
- SDK/tunnel requests a register challenge with
udp_enabled=true, signs the returned SIWE message, and completes registration. - Relay validates that the datagram plane is enabled, allocates a UDP port, and creates a per-lease datagram runtime.
- Registration response includes
udp_addrandaccess_token. The SDK dials QUIC to the relay URL authority, using port 443 when the URL has no explicit port; the relay may bind a different localSNI_PORTbehind NAT or a load balancer. - SDK opens a QUIC connection with ALPN
portal-tunneland DATAGRAM support enabled. - Authentication: SDK sends
{access_token}JSON on the first QUIC stream; relay validates before accepting the tunnel. - External UDP client sends a packet to
udp_addr-> relay assigns a flow ID -> QUIC DATAGRAM frame to SDK. - SDK-side decodes frames and delivers to local UDP target.
- Return path: local response -> SDK -> QUIC DATAGRAM -> relay ->
WriteToUDPto the original client.
Result: raw public UDP exposure with an internal QUIC datagram backhaul. UDP and TCP port allocations are independent from the same MIN_PORT-MAX_PORT range.
Relay Discovery Boundary
- Discovery bootstraps from public HTTPS relay URLs, then expands through relay-to-relay
/discoverypolling and periodic self-announces to bootstrap relays through/discovery/announce. - SDK exposures consume relay discovery results to choose relays, but they do not announce themselves and do not serve
/discovery. - Discovery descriptors are signed relay self-descriptions. They bind public relay metadata such as
api_https_addrand transport support to the relay identity. Lease access tokens remain separate and authorize tenant lease operations only. /discovery/announceaccepts only signed relay descriptors. Loopback or localhost relay descriptors are rejected because they cannot join the public discovery mesh.
Control Plane Flow
1. Register
POST /sdk/register/challengethenPOST /sdk/register.- Caller signs the returned SIWE message with the identity secp256k1 key (
personal_sign). namemust normalize to a valid single DNS label of at most 22 ASCII characters. The relay publishes the friendly<name>.<root host>route when available and always derives<name>-<40 lowercase address hex>.<root host>from the SIWE-authenticated identity.- Registration publishes the identity-bound route immediately. A friendly-name conflict does not transfer or block the canonical origin; if no reverse session is ready yet, inbound SNI claims wait up to
ClaimTimeout. - On success, the relay issues a lease-scoped ES256K JWT access token for lease operations and a separate reverse-only capability for the returned reverse endpoint.
- UDP registration requires server
UDP_ENABLED=true, a validMIN_PORT/MAX_PORTrange, and admin enablement. Failures:udp_disabled(403),udp_capacity_exceeded(503),udp_port_exhausted(503). - TCP port registration has equivalent three-condition gating. Failures:
tcp_port_disabled(403),tcp_port_capacity_exceeded(503),tcp_port_exhausted(503). PORTAL_URLis normalized to its host component only; path/query segments are ignored for routing.
2. Reverse Connect
GET reverse_endpoint.url(currently/sdk/connect, HTTP/1.1 only) with theX-Portal-Reverse-Capabilityheader.- Direct endpoints validate the lease instance and reverse-only capability. Overlay gateways validate the ingress-signed delegated capability before dialing IVNP; the ingress then verifies the authenticated gateway peer and lease instance before admitting the stream.
- Overlay gateway requests are limited per source IP to 120/minute with a burst of 16, before signature verification. At most 16 pending or bridged connections per source share the gateway’s 128 outbound slots. Exceeding either budget returns HTTP 429. Source IP follows the configured trusted-proxy policy; callers behind the same NAT share a budget.
- After claim, relay writes
0x02plus a 16-byte binding before switching the session into tenant TLS passthrough. - After hijack, the connection becomes a broker-managed reverse session.
3. Renew
POST /sdk/renewwithaccess_token. Extends lease TTL and returns refreshed lease and reverse credentials.
POST /sdk/reverse replaces only a failed reverse endpoint. Gateway replacement
therefore preserves the ingress lease and public hostname.
4. Unregister
POST /sdk/unregisterwithaccess_token. Removes the lease, routes, and ready reverse sessions.
Routing Behavior
Route lookup order:
- Exact friendly or identity-bound hostname match
- Single-label wildcard match (
*.example.com) - Root-host fallback to the admin/API handler
Notes:
- Wildcards are one level only.
- The exact root host is never served by the wildcard route.
- For non-apex
PORTAL_URLvalues such ashttps://portal.example.com:8443/admin, a lease nameddemois published atdemo.portal.example.com.
Admin API Surface
The relay server owns both the selected static SPA and the relay API. Public
state, policy, status, installer, and admin-auth endpoints use reserved paths;
all other non-reserved paths fall back to the SPA. Route paths are enumerated
in types/paths.go and cmd/relay-server.
Keyless TLS Trust Model
For uncached HTTPS tunnels, the relay signs handshake transcripts via /v1/sign without receiving tenant TLS traffic secrets. The SDK/tunnel endpoint runs the full TLS server handshake and derives session keys locally. Relay control-plane TLS and reverse-session setup terminate on the relay’s admin/API route and are not protected by the tenant keyless path.
Design Properties
- Reverse-only backend connectivity
- One canonical raw TCP reverse transport
- Dedicated TCP port allocation for non-TLS services with raw TCP bridging
- Raw public UDP exposure with an internal QUIC datagram backhaul
- SNI-based routing with root-host fallback
- End-to-end tenant TLS with relay-backed keyless signing
- Traffic-triggered MITM self-probing for probable relay-side TLS termination; the keyless tenant TLS exports keying material on both sides, and callers can opt into relay banning
- SIWE identity proof for registration plus relay-issued ES256K JWT access tokens for the lease lifecycle
- Lease-owned reverse pools, carriers, TCP listeners, and UDP endpoints
- Optional QUIC/UDP datagram transport coexisting with TCP on the same lease
- Per-lease UDP and TCP port allocation with sticky service-identity reservations
- QUIC tunnel authentication via control stream (
access_token)