# Tunneling (/docs/infra/tunneling)



`lpm tunnel` exposes a local port to the public internet through the LPM.dev Registry tunnel relay. This is the conceptual deep-dive — the relay architecture, the per-plan limits matrix, how domains work for free vs Pro/Org, the wire protocol, the webhook capture/replay model, and the free-tier interstitial. For the CLI flag reference, see [`lpm tunnel`](/docs/infra/tunnel).

## Architecture [#architecture]

```text
[your machine]                         [LPM.dev Registry relay — Cloudflare]

  verified local endpoint ◀──────▶ TunnelRoom DO ◀──────▶ public HTTP/WebSocket
           ▲                            │
           │                            └────▶ TunnelCounter DO
           └── CLI WebSocket                         account-wide slots,
                                                      rates, and usage
```

The discovered endpoint includes scheme, loopback address, port, and base path. Tunnel forwarding accepts verified plain HTTP children; framework HTTPS children are rejected without weakening certificate verification. `lpm dev --https` remains compatible because LPM CLI owns browser-facing TLS and gives the tunnel the underlying HTTP child. Bare `lpm tunnel` discovers the only active `lpm dev` session; an explicit numeric port targets `http://127.0.0.1:<port>/`.

The relay runs as a **Cloudflare Worker with two Durable Object roles**:

* A domain-keyed `TunnelRoom` holds the CLI WebSocket and proxies public HTTP and WebSocket traffic for that domain.
* An account-keyed `TunnelCounter` owns the concurrent slots, per-minute rate buckets, monthly/billing-period usage, and request-ID idempotency for `user:<uuid>` or `org:<uuid>`.

The split keeps domain routing isolated while making limits truly account-wide. Every domain owned by the same personal or organization account reaches the same counter. Durable Object SQLite preserves quota state across object eviction and restarts; KV still caches token validation and domain ownership lookups.

Both CLI and public WebSockets use Cloudflare's WebSocket Hibernation API. The room can sleep while connections are idle and wake on the next message. For the end user, nothing changes: the URL, CLI workflow, open connection, and WebSocket protocol stay the same. Hibernation is an implementation and provider-cost optimization; it does not change the LPM.dev Registry request allowance.

The relay URL is `wss://relay.lpm.fyi/connect`. You don't need to configure it — it's the default the CLI uses.

### Overriding the relay URL [#overriding-the-relay-url]

For local development against a custom worker, staging, or any future regional endpoint, point the CLI at a different relay without rebuilding:

```bash
LPM_TUNNEL_RELAY=ws://localhost:8787/connect lpm tunnel 3000
```

Or persistently in `~/.lpm/config.toml`:

```toml
[tunnel]
relay-url = "wss://relay-eu.lpm.fyi/connect"
```

Precedence is `LPM_TUNNEL_RELAY` env > `~/.lpm/config.toml` > built-in default. Empty / whitespace values fall through to the next tier — accidentally `export LPM_TUNNEL_RELAY=""` won't break the tunnel.

The CLI pins each relay's certificate independently — see [Certificate pinning](#certificate-pinning) below.

## Plan and relay limits [#plan-and-relay-limits]

Tunnels are gated by plan tier and relay-wide abuse controls:

| Dimension                         | Free                 | Pro                             | Org                                    |
| --------------------------------- | -------------------- | ------------------------------- | -------------------------------------- |
| Concurrent tunnels                | **1**                | **3**                           | **10**                                 |
| Included requests                 | **20,000/UTC month** | **100,000/billing period**      | **100,000/billed seat/billing period** |
| Account-wide rate                 | **4,000 req/min**    | **20,000 req/min**              | **20,000 req/min/billed seat**         |
| Per visitor IP within the account | 600 req/min          | 600 req/min                     | 600 req/min                            |
| After included requests           | hard stop            | $1/100,000 proportional overage | $1/100,000 proportional overage        |
| Max request body size             | **10 MB**            | 100 MB                          | 100 MB                                 |
| Max session length                | **1 hour**           | unlimited                       | unlimited                              |
| Custom subdomain claims           | **0**                | 3 total                         | 10 total                               |
| `--tunnel-auth` flag              | not available        | available                       | available                              |

Limits are enforced at the relay (the Cloudflare Worker), not the CLI. Product constants live in `config/plans.js`, and the Worker derives its runtime limit table from `workers/tunnel/src/limits.js`.

The 600/minute visitor limit is scoped to the account and source IP. The same visitor cannot reset that budget by switching between two domains owned by the same account.

### Request usage and overage [#request-usage-and-overage]

The included allowance belongs to the account, not each tunnel. A Pro account with three active tunnels still has 100,000 included requests total. Reducing the concurrent limit to one would not reduce the allowance or the fixed Pro subscription price; more live tunnels can only consume the shared allowance faster.

The relay counts:

* One admitted HTTP request, even if the local server later returns an error, is unavailable, or times out.
* One successful public WebSocket upgrade confirmed by a current protocol-v2 CLI. Frames on that WebSocket consume zero LPM.dev Registry tunnel requests.

It does not count:

* Authentication failures or Free-tier interstitial responses.
* Oversized request bodies.
* Requests rejected by the account-wide, per-account/IP, or period allowance limit.
* Internal reconcile probes, CLI keepalive pings/pongs, or public WebSocket frames.
* Public WebSocket attempts against a tunnel connected by an older protocol-v1 CLI. The relay returns `426 Upgrade Required` before quota admission; upgrade LPM CLI and reconnect the tunnel.

Free usage resets at the start of each UTC calendar month and hard-stops at 20,000. Pro and Org usage follows the active Stripe subscription-item period. Paid overage is enabled by default, billed proportionally at $1 per 100,000 requests, and can be turned off from the personal or organization tunnel dashboard. With overage off, the relay hard-stops at the included allowance. Toggling overage affects future admissions only; it never retroactively bills an earlier period.

Organization usage belongs to the organization account selected by the claimed domain. A member's personal Free or Pro plan never controls an organization tunnel. When an organization usage period is first opened, LPM.dev Registry snapshots the subscription's billed Stripe seat quantity; the included requests and account-wide rate stay fixed from that snapshot for the rest of the billing period. The 10-tunnel and 10-claim caps do not scale per seat.

If an organization enters its billing grace period, tunnel access continues until the grace deadline with paid overage disabled. The grace window has one stable allowance and hard-stops when that allowance is exhausted; requests during grace are not reported as paid Stripe overage.

The CLI startup block shows current accepted/included usage and whether overage is on or the account is at a hard stop. Current CLI versions also render live allowance and overage threshold notices as visible warnings. The dashboard is based on monotonic Worker snapshots and may lag the request path by approximately one minute.

### Reconnecting to your own domain [#reconnecting-to-your-own-domain]

Concurrent caps are enforced atomically by the account-wide `TunnelCounter`. Slots are keyed by domain, so a **same-domain reconnect is a 0-net replacement** — never a +1 against your cap, regardless of plan. Concretely: if you're a Pro user with three live tunnels and one of them disconnects (laptop sleep, network blip), reconnecting it doesn't temporarily inflate your count to 4-and-then-deny. The replacement gets a fresh session ID, and a late close from the old session cannot release the new slot.

The URL itself stays the same across reconnects whenever it's deterministic from your identity. That covers explicitly claimed Pro/Org subdomains (held by your database claim) and the implicit Pro default of `<username>.lpm.fyi` (held by your username). Continuity comes from the deterministic source, not from a transient grace timer.

Free-tier random subdomains are different: they're freshly minted each connect with no claim and no implicit binding, so every restart hands you a new 10-character subdomain.

## Base domains [#base-domains]

The LPM.dev Registry operates two base domains today:

| Domain    | Default plan required | Notes                                               |
| --------- | --------------------- | --------------------------------------------------- |
| `lpm.fyi` | `free`                | The default base. Free random subdomains live here. |
| `lpm.llc` | `pro`                 | Available for claimed Pro/Org domains.              |

List what's available right now:

```bash
lpm tunnel domains       # show every enabled base domain + the plan tier required
```

Pro/Org claims sit inside one shared pool — your 3 (Pro) or 10 (Org) claims can be distributed across `lpm.fyi` and `lpm.llc` however you want. The namespace is **per base domain**, so `tolga.lpm.fyi` and `tolga.lpm.llc` are two independent claims that consume two slots from your pool.

## Free random subdomains [#free-random-subdomains]

Free-tier tunnels get a **10-character lowercase alphanumeric** subdomain on `lpm.fyi`, like:

```text
k8f2m9x1ab.lpm.fyi
9j3vq2bpqr.lpm.fyi
```

Generated cryptographically per session with rejection sampling over a 36-symbol alphabet, so the distribution is uniform. Different on every reconnect — free random subdomains are not claimed and aren't preserved across restarts, so each one hands you a fresh URL.

These domains are **not stored in any database** — they exist for the lifetime of the WebSocket connection (capped at 1 hour under the free session cap) and then they're gone.

## Free-tier session cap [#free-tier-session-cap]

Free tunnels are capped at **1 hour per session**. The relay enforces this by scheduling a Cloudflare Durable Object alarm when the WebSocket opens; once the alarm fires, the relay closes the WebSocket with reason `session time limit reached` and any in-flight HTTP request gets a `503 Service Unavailable` with a `Retry-After: 5` header.

When this happens:

* The CLI surfaces the close reason in its retry/backoff log.
* Reconnecting starts a fresh session with a new random subdomain (free tier has no reconnect-grace window).
* The relay tracks `session_expires_at` and sends it in the WebSocket `hello` frame; current CLI versions show the remaining time in the startup block.

Pro and Org tunnels are uncapped.

## Subdomain rules (claimed domains) [#subdomain-rules-claimed-domains]

Claimed subdomains must match this regex (enforced at the claim API):

```text
^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$
```

* Lowercase ASCII letters, digits, hyphens
* 3–32 characters total (must start AND end with an alphanumeric)
* No dots, underscores, uppercase, or special characters

Examples that work: `acme-api`, `staging-1`, `team-shared`. Examples that don't: `Acme_API`, `--leading-hyphen`, `staging.preview`, `xy` (too short).

The relay-side validator is broader — it accepts any RFC 1123 hostname label (1–63 chars). The 3–32 cap exists at the claim API to keep human-typed subdomains short and consistent. Random free-tier subdomains use the relay's looser rules and land at exactly 10 chars.

## Free-tier interstitial [#free-tier-interstitial]

The first browser visit to a free random tunnel URL sees a **"Visit Site" interstitial page** — anti-phishing measure, same shape as ngrok's:

```text
First request:
  Browser → k8f2m9x1ab.lpm.fyi/foo  →  HTML interstitial with "Visit Site" button
                                        (because Accept: text/html)

After click:
  /__lpm_pass?to=/foo  →  sets HttpOnly cookie  →  302 redirect to /foo
                                                    (Worker now lets requests pass)
```

Skipped automatically for:

* **API/webhook requests** (no `Accept: text/html` header) — these hit your local server directly.
* **Pro/Org tunnels** — your domain is claimed, you've authenticated, no interstitial.

Without it, anyone could spin up a free tunnel and aim it at a phishing site that looks like a major brand (the random URL provides no signal of trust either way). The interstitial forces an explicit "yes I want to visit this URL" click.

### Cookie scope, TTL, and rotation [#cookie-scope-ttl-and-rotation]

`/__lpm_pass` sets `lpm_tunnel_pass=<nonce>` with these properties:

* **Host-only.** No `Domain=` attribute — the cookie is keyed to the exact subdomain that set it. Trusting `acme.lpm.fyi` does NOT propagate to `bravo.lpm.fyi` or any other free tunnel.
* **Session-bound nonce.** The cookie value is a per-session UUID minted in the relay when the WebSocket opens, not the static `=1` of older versions. When the session ends (clean disconnect or session-cap alarm), the relay clears its nonce; any cookie a previous visitor still holds is no longer valid against a new session, even if the random subdomain happens to repeat.
* **TTL clamped to session length.** The cookie's `Max-Age` is set to the remaining session time (free tier: ≤ 1 hour, capped at 4 hours hard ceiling), with a 60-second floor so a visit seconds before expiry doesn't waste the cookie.
* `SameSite=Lax`, `Secure`, `HttpOnly`.

## Claiming a domain [#claiming-a-domain]

```bash
lpm tunnel claim acme-api.lpm.llc
lpm tunnel claim staging.lpm.fyi --org acme    # claim under an org
lpm tunnel list                                 # show your claims
lpm tunnel unclaim acme-api.lpm.llc
```

Claims are scoped:

* **Personal claims** — owned by the authenticated user. Pro accounts can hold 3; organization membership does not add organization limits to the member's personal account.
* **Org claims** — owned by an org. Counted against the org's pool. Anyone in the org can use the domain; permissions to claim/unclaim depend on the org role.

Race-safe: the underlying `claimPersonalDomain` / `claimOrgDomain` queries use `INSERT … ON CONFLICT DO NOTHING` against a unique index on `domain`, so two simultaneous claims for the same domain produce one win and one no-op.

After claiming:

```json title="lpm.json"
{ "tunnel": { "domain": "acme-api.lpm.llc" } }
```

Or pass `--domain` per invocation. Every `lpm tunnel 3000` (and `lpm dev --tunnel`) routes through that domain.

## Wire protocol [#wire-protocol]

The CLI ↔ relay protocol is **JSON over WebSocket**. Each message has a discriminating `type` field:

| Direction      | Type            | Purpose                                                                                                      |
| -------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| Relay → client | `hello`         | First JSON frame — assigned domain, full `tunnel_url`, `session_id`, plan, expiry, limits, and current usage |
| Relay → client | `http_request`  | Incoming HTTP request to proxy                                                                               |
| Client → relay | `http_response` | The local server's response                                                                                  |
| Relay → client | `ws_upgrade`    | Incoming WebSocket upgrade request from a remote client                                                      |
| Client → relay | `ws_ready`      | Confirms that the local server accepted the WebSocket upgrade                                                |
| Client → relay | `ws_reject`     | Reports that the local server rejected the WebSocket upgrade                                                 |
| Both           | `ws_frame`      | WebSocket frames in either direction                                                                         |
| Both           | `ws_close`      | WebSocket close events in either direction (with optional `code` + `reason`)                                 |
| Client → relay | `ping`          | Keepalive                                                                                                    |
| Relay → client | `usage_notice`  | Updated account usage at allowance and overage thresholds                                                    |

Bodies are base64-encoded for binary safety. Authentication and the requested domain are carried by the HTTP WebSocket upgrade (`Authorization` header plus URL query), before JSON frames begin. The CLI maintains a request-ID map so concurrent requests don't conflict.

The relay `hello` frame includes additive metadata fields when the relay knows them: `plan`, `base_domain`, `domain_kind`, `session_expires_at`, `session_max_ms`, an account-wide `usage` object, and a `limits` object with concurrency, account-wide rate, per-IP rate, request body, custom-domain, and tunnel-auth availability. Older relays may omit these fields; the CLI treats them as optional.

Public WebSocket proxying requires the protocol-v2 `ws_ready` / `ws_reject` acknowledgement. If a tunnel is connected through a protocol-v1 client, public WebSocket requests receive `426 Upgrade Required` and do not consume the request allowance. HTTP proxying is unaffected.

The protocol is purposely simple. There's no streaming-body support today; large requests are buffered up to the per-plan body cap (10 MB free / 100 MB Pro+Org) before forwarding. Most webhook payloads are small enough that this is fine; large file uploads are the edge case.

## Token validation [#token-validation]

When the CLI opens the relay WebSocket, the HTTP upgrade carries the LPM.dev Registry auth token in the `Authorization` header. The relay:

1. Hashes the token (so it can be cached safely).
2. Looks up `tunnel_auth:{tokenHash}` in KV (60s TTL).
3. On miss: calls back to the LPM.dev Registry origin's `/api/registry/-/worker/validate-token` and `/api/registry/-/whoami` to fetch user identity, personal plan, and org memberships.
4. Resolves the authoritative personal or organization tunnel account after selecting the domain. This fetch supplies billing status, billed seats, period boundaries, allowance, and current usage.
5. Caches the result for 60s; billing and overage changes explicitly invalidate the account cache.

The domain selects the billing account. An organization-owned domain always uses the organization plan and seat quantity, even when the connecting member has a different personal plan.

Connection rejections such as invalid auth, plan requirement, domain ownership, inactive billing, and concurrent limit are permanent for that invocation, so the CLI reports them instead of retrying forever. Transport errors, relay 5xx responses, and temporary account/quota/usage-service failures remain retryable.

## Domain ownership verification [#domain-ownership-verification]

For claimed domains, the relay checks that the claim's `userId` (or one of `orgId`s your user belongs to) matches the connecting user. Cached at `tunnel:{fullDomain}` in KV (5-min TTL). On miss, falls back to `GET /api/tunnel/domains/{domain}` on the origin.

Org ownership is matched by **UUID**, not slug — the cache stores the org's UUID, the user's `whoami` returns their org memberships with UUIDs, and the relay compares them with a `Set` lookup. This is correct across slug renames.

## Webhook capture (client side) [#webhook-capture-client-side]

Every request the relay forwards to your local server is captured to disk on **your** machine — the relay doesn't store request bodies:

```text
<project>/.lpm/
├── webhook-log.jsonl     ← compact newest-first event index
└── webhooks/
  ├── <id>.json         ← full request + response bodies and headers
  └── ...
```

Browse:

```bash
lpm tunnel inspect             # terminal table
lpm tunnel inspect -- --last 10
lpm tunnel inspect -- --filter stripe
lpm tunnel inspect -- --status 4xx
lpm tunnel log                 # full event log
lpm tunnel inspect -- --ui     # browser inspector (URL printed on startup)
```

Replay a captured event back to your local server:

```bash
lpm tunnel replay 3
lpm tunnel replay 3 -- --port 4000
```

Replays are useful for iterating on webhook handlers — fix the bug, replay the failed event, see if it lands. No need to wait for the upstream provider to fire again. Local `inspect`, `log`, and `replay` flags are forwarded after `--`.

## `--auto-ack` [#--auto-ack]

```bash
lpm tunnel 3000 --auto-ack
```

If your local server is down (or you're not running one yet), the **CLI** returns a 200 OK to incoming requests automatically. Useful for keeping webhook providers from deactivating your endpoint after repeated 5xx errors.

The captured events still land on disk; `lpm tunnel inspect` shows them as auto-acked. Replay them when your server is back up.

## `--tunnel-auth` (Pro/Org) [#--tunnel-auth-proorg]

```bash
lpm tunnel 3000 --tunnel-auth
```

Requires every incoming request to carry an auth token (generated per-session, printed in the tunnel banner). The relay enforces the check before forwarding.

Free tier doesn't have `--tunnel-auth` (per the plan-limits matrix above). Useful for in-progress demos where you don't want random people who guess the URL to hit your server.

## The inspector [#the-inspector]

`lpm tunnel inspect -- --ui` starts a small web UI on a free ephemeral port (the URL is printed on startup) — pass `--inspect-port N` for a stable bookmark. It shows captured events with full headers, body, response status, and replay controls. Same data as the terminal `inspect`, just easier to navigate for big captures.

The inspector is auto-started alongside the tunnel by default and binds to a free port chosen by the OS — race-free against the dev server's own port. Pass `--inspect-port N` to bind a specific port (strict — fails loudly if `N` is in use), or `--no-inspect` to skip it entirely. Both `lpm tunnel` and `lpm dev --tunnel` accept the same flags.

When the tunnel is running, press `o` in the tunnel terminal to open the inspector in a browser, or `q` to quit the tunnel. The same URL is also printed in the startup banner.

## Certificate pinning [#certificate-pinning]

The CLI verifies the relay's TLS certificate against a stored Trust-On-First-Use (TOFU) pin so a hijacked or MITM-substituted certificate is rejected even if the attacker's chain is signed by a public CA. Pins are stored per relay host:

```text
~/.lpm/relay-pins/relay.lpm.fyi
~/.lpm/relay-pins/<other-host>     # one file per relay you've connected to
```

The first connection to a host stores its certificate's SPKI hash; subsequent connections to that same host must present the same SPKI or the CLI refuses to connect. Each host is independent — overriding the relay URL never silently inherits a pin meant for a different server.

If a relay legitimately rotates its certificate, delete the matching pin file and reconnect:

```bash
rm ~/.lpm/relay-pins/relay.lpm.fyi
```

The pin error message names the exact path to remove.

> **Backward compatibility.** Earlier versions stored a single pin at `~/.lpm/relay-pin` (no `s`, no subdirectory). The CLI still reads that file on the canonical default relay so existing installs keep working; the next successful verification migrates the pin to the per-host layout. The legacy file is read but never written — it can be deleted any time after the migration takes effect.

## Sessions [#sessions]

Each `lpm tunnel` invocation is a session. With `--session <name>`, the inspector groups events by session name — useful for "stripe-test" vs "github-test" iteration loops.

Sessions persist on disk under `<project>/.lpm/tunnel/sessions/`. Old sessions stay around until you clean them up by hand.

## CI / scripted [#ci--scripted]

Tunnels aren't a CI primitive — they need an interactive process to hold the WebSocket open, and the per-session timeout (1h on free, unlimited on Pro/Org) doesn't fit a typical CI lifecycle. For CI integrations that need to receive webhooks, look at provider-side options (Stripe webhook signing + delivery logs, GitHub webhook delivery logs, etc.) rather than tunneling.

## See also [#see-also]

* [`lpm tunnel`](/docs/infra/tunnel) — CLI command reference
* [`lpm dev --tunnel`](/docs/dev/dev) — start a tunnel as part of the dev pipeline
* [`lpm.json` tunnel](/docs/reference/lpm-json#tunnel) — domain config field
* [`lpm cert`](/docs/infra/cert) — local HTTPS for non-tunnel localhost
