LPM-cli

Tunneling

How LPM exposes localhost — Cloudflare Worker relay, base domains, plan limits, claim model, webhook capture.

lpm tunnel exposes a local port to the public internet through LPM's 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.

Architecture

[your machine]                 [LPM relay — Cloudflare]              [internet]

  lpm tunnel 3000 ──WebSocket──▶  Worker → Durable Object  ◀──HTTPS──── public users
       │                                  │                              │
       ▼                          one DO per tunnel domain               │
  localhost:3000                  routes by Host header                  │

       ▲                                  │                              │
       └─── HTTP responses through the same WebSocket ───────────────────┘

The relay runs as a Cloudflare Worker with Durable Objects. Each tunnel domain (e.g., acme-api.lpm.llc) maps to its own Durable Object instance — that DO holds the WebSocket connection from your CLI and proxies HTTP requests through it.

The DO model gives the relay strong consistency for the "which session owns which domain" question without round-tripping to a central service. KV caches token validation (60s TTL) and domain ownership (5-min TTL).

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

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

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

Or persistently in ~/.lpm/config.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 below.

Plan and relay limits

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

DimensionFreeProOrg
Concurrent tunnels1310
Per-tunnel rate limit100 req/minunlimitedunlimited
Global per-IP cap600 req/min/IP600 req/min/IP600 req/min/IP
Max request body size10 MB100 MB100 MB
Max session length1 hourunlimitedunlimited
Custom subdomain claims03 total10 total
--tunnel-auth flagnot availableavailableavailable

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.

Reconnecting to your own domain

Concurrent caps are enforced atomically by a per-user quota DO. 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 slot stays reserved for that domain across the disconnect/reconnect cycle.

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

LPM operates two base domains today:

DomainDefault plan requiredNotes
lpm.fyifreeThe default base. Free random subdomains live here.
lpm.llcproAvailable for claimed Pro/Org domains.

List what's available right now:

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-tier tunnels get a 10-character lowercase alphanumeric subdomain on lpm.fyi, like:

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 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)

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

^[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

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

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.

/__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

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. Counted against the user's per-plan claim pool (Pro=3, Org members=10 inherited from any of their orgs, plus their own Pro pool if they have one).
  • 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:

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

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

DirectionTypePurpose
Client → relayhelloInitial handshake — token, requested domain (wire field: subdomain), local port
Relay → clienthelloHandshake response — assigned domain, full tunnel_url, session_id, plan, expiry, and limits
Relay → clienthttp_requestIncoming HTTP request to proxy
Client → relayhttp_responseThe local server's response
Relay → clientws_upgradeIncoming WebSocket upgrade request from a remote client
Bothws_frameWebSocket frames in either direction
Bothws_closeWebSocket close events in either direction (with optional code + reason)
Client → relaypingKeepalive

Bodies are base64-encoded for binary safety. 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, and a limits object with concurrency, per-tunnel rate, global per-IP rate, request body, custom-domain, and tunnel-auth availability. Older relays may omit these fields; the CLI treats them as optional.

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

When the CLI's WebSocket connects to the relay, the hello message includes the LPM auth token. 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 origin's /api/registry/-/worker/validate-token and /api/registry/-/whoami to fetch user identity, plan tier, and org memberships.
  4. Caches the result for 60s.

Plan tier from whoami is what selects the right tunnel limit row.

Domain ownership verification

For claimed domains, the relay checks that the claim's userId (or one of orgIds 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)

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

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

Browse:

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:

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

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)

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

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

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:

~/.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:

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

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

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