LPM CLI

Tunneling

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

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.

Architecture

[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

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
Included requests20,000/UTC month100,000/billing period100,000/billed seat/billing period
Account-wide rate4,000 req/min20,000 req/min20,000 req/min/billed seat
Per visitor IP within the account600 req/min600 req/min600 req/min
After included requestshard stop$1/100,000 proportional overage$1/100,000 proportional overage
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.

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

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

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

The LPM.dev Registry 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. 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:

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
Relay → clienthelloFirst JSON frame — assigned domain, full tunnel_url, session_id, plan, expiry, limits, and current usage
Relay → clienthttp_requestIncoming HTTP request to proxy
Client → relayhttp_responseThe local server's response
Relay → clientws_upgradeIncoming WebSocket upgrade request from a remote client
Client → relayws_readyConfirms that the local server accepted the WebSocket upgrade
Client → relayws_rejectReports that the local server rejected the WebSocket upgrade
Bothws_frameWebSocket frames in either direction
Bothws_closeWebSocket close events in either direction (with optional code + reason)
Client → relaypingKeepalive
Relay → clientusage_noticeUpdated 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

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

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