harn tunnel
harn tunnel up | down | reload | status | logs | allow [list | add | rm]harn tunnel puts a local reverse-proxy gate in front of a provider tunnel, so you can reach a local service (the web UI, a dev server, etc.) from your phone or another machine without opening a firewall port or deploying. The gate rewrites the upstream Host header so vhost-routed servers see the review host, and it copies the browser Host to X-Forwarded-Host so the upstream can still see the public hostname. The gate also enforces a path scope: it forwards only the URL paths you name with --allow-path and refuses every other request, so sharing one page does not publish the rest of the app. Cloudflare mode uses a Cloudflare quick tunnel plus an IP allowlist checked against CF-Connecting-IP; Tailscale mode uses Tailscale Serve/Funnel and lets Tailscale own the access boundary.
The tunnel runs on Linux, macOS, and Windows. A host CLI shipped as a single-file Bun bundle works too: the gate has no file of its own there, so the CLI re-executes itself with a hidden tunnel gate task and the running Bun binary starts the worker in-process.
Commands
Section titled “Commands”harn tunnel up --allow-path / # start the default instance, publishing the whole upstreamharn tunnel up --target 127.0.0.1:4276 --vhost localhost:4276 \ --allow-path /decisions --allow-path /_next # share one Harnery web page and its assetsharn tunnel up --name web --allow-path / # a second, independently-managed instanceharn tunnel up --provider cloudflare --allow-path / # default: random *.trycloudflare.com URL + IP allowlistharn tunnel up --provider tailscale --allow-path / # tailnet-only Tailscale Serve URLharn tunnel up --provider tailscale --visibility public --allow-path / # public Tailscale Funnel URLharn tunnel up --provider tailscale --name web --path /web --allow-path / # named instance at a pathharn tunnel status # table of running instances + their URLsharn tunnel down [--name <id>|--all] # stop one / allharn tunnel reload [--name <id>|--all] # pick up allowlist edits, keeping the URLharn tunnel logs [-f] # tail the gate log (--provider for the provider log)Providers
Section titled “Providers”| Provider | Command | URL shape | Access model |
|---|---|---|---|
| Cloudflare | harn tunnel up --provider cloudflare |
random https://*.trycloudflare.com |
gate requires Cloudflare’s CF-Connecting-IP to match harn tunnel allow |
| Tailscale Serve | harn tunnel up --provider tailscale |
https://<machine>.<tailnet>.ts.net/ |
tailnet members only |
| Tailscale Funnel | harn tunnel up --provider tailscale --visibility public |
https://<machine>.<tailnet>.ts.net/ |
public internet, if Funnel is enabled in the tailnet policy |
Cloudflare gives each instance a separate random hostname. Tailscale gives the machine one MagicDNS hostname, so named instances default to path mounts: the default instance uses /, and --name web uses /web unless you pass --path.
Path Scope
Section titled “Path Scope”harn tunnel up refuses to start without at least one --allow-path <prefix>. The gate forwards a request only when its path equals a prefix or sits below it on a segment boundary: --allow-path /decisions serves /decisions and /decisions/42, never /decisionsX or /files. Every other request gets a 403 page that says the address is outside what the tunnel shares, and the gate logs a path-deny: line. The check runs after the IP allowlist and applies to WebSocket upgrades as well as ordinary requests, for every provider.
--allow-path / is the explicit way to publish the whole upstream. There is no default scope, because a tunnel forwards to an entire local app, and an app such as the Harnery web UI includes a repository file viewer that should reach the internet only when you say so.
Prefixes are plain paths. A prefix with ?, #, %, a backslash, or an empty, ., or .. segment is rejected at up. The gate also refuses any request path that carries an encoded slash, backslash, dot, or percent sign (%2F, %5C, %2E, %25), because the gate and the upstream router could decode it differently.
A page usually needs more than its own route. A Next.js page, for example, also loads /_next/ assets and calls its own API routes, so list each prefix the page fetches. Anything under a listed prefix is reachable, so keep prefixes as narrow as the page allows.
The scope is saved with the instance, so harn tunnel reload keeps it and harn tunnel status shows it. To change the scope, stop the instance and start it again. An instance saved without a scope cannot be reloaded, because its gate would refuse every request; start it again with up.
Cloudflare Allowlist
Section titled “Cloudflare Allowlist”The Cloudflare provider denies every request whose origin IP isn’t on the allowlist (shared across Cloudflare instances). An entry is an exact address or a CIDR range, IPv4 or IPv6:
harn tunnel allow # list allowed entriesharn tunnel allow add 1.2.3.4 # add, then `harn tunnel reload --all` to applyharn tunnel allow add 203.0.113.0/24 # a whole IPv4 rangeharn tunnel allow add 2601:db8:1:2::/64 # a whole IPv6 networkharn tunnel allow add --current # this machine, detected automaticallyharn tunnel allow rm 1.2.3.4 # removeEntries are normalized when added, so 2601:DB8:0:0::1 and 2601:db8::1 are one entry, and 203.0.113.77/24 becomes the network 203.0.113.0/24. On IPv6, a phone and a laptop on one Wi-Fi network have different addresses but share a /64 prefix, so a /64 entry admits both.
Each gate reads the allowlist once, when it starts, so editing the config alone changes nothing for a tunnel that is already running.
allow add --current
Section titled “allow add --current”--current asks Cloudflare which public addresses this machine reaches it from (https://1.1.1.1/cdn-cgi/trace over IPv4 and https://[2606:4700:4700::1111]/cdn-cgi/trace over IPv6, the same address the gate later sees in CF-Connecting-IP). It allows the IPv4 address as a /32 and the IPv6 address as its /64, then reloads every live Cloudflare gate so the public URLs stay the same.
Entries it adds are listed as (automatic) and recorded in the config’s auto_allowed field. The next --current replaces exactly those entries, so moving to another network leaves no stale address behind, and an entry you added by hand is never removed. An address you already listed by hand stays yours. A family Cloudflare did not answer for (an IPv4-only network has no IPv6) counts as absent from that network. If neither family answers, the allowlist is left unchanged and the command prints a warning; it never empties the list.
Reload
Section titled “Reload”harn tunnel reload restarts an instance’s gate in place and leaves the provider process alone:
harn tunnel reload # the default instanceharn tunnel reload --name web # one named instanceharn tunnel reload --all # every live instanceThe reason to reach for this instead of down then up is the URL. A Cloudflare quick tunnel’s hostname is minted by cloudflared when it starts, so a full restart hands back a different *.trycloudflare.com address and invalidates every link you have already shared. cloudflared only ever forwards to the gate’s local port, so swapping the gate underneath it keeps the hostname intact and the edge reconnects on its own.
Denied requests are logged with the client IP, so the usual sequence after someone hits a 403 is to read the IP out of the gate log, add it, and reload:
harn tunnel logs # find the `deny: ... ip=<addr>` lineharn tunnel allow add <addr>harn tunnel reload --all--all covers live instances only. Stale state files from earlier runs are counted and skipped rather than reported as failures. Reloading a single stale instance refuses instead, because a dead provider means the public URL is already gone and only harn tunnel up can mint a new one.
Caveats
Section titled “Caveats”- Quick tunnels reroll their hostname on every restart. Use
harn tunnel reloadfor allowlist changes so the URL survives. A hostname that persists across a fulldown/upwould need a named Cloudflare tunnel tied to a domain, which isn’t built today. cloudflaredis taken from$HARNERY_CLOUDFLARED, thencloudflared_pathin the tunnel config, thenPATH, then~/.local/bin/. When none exists it auto-installs to~/.local/bin/on Linux; on macOS,brew install cloudflared, and on Windows,winget install Cloudflare.cloudflared. A path named explicitly that does not exist is an error rather than a fall-through to another copy.- Process and port discovery uses
pgrep/psandssorlsofon Linux and macOS, and PowerShell andnetstaton Windows. A missing tool is tolerated: ports are then probed by connecting to them. - Tailscale mode requires the
tailscaleCLI to be installed, signed in, and reporting a MagicDNS name fromtailscale status --json. - Tailscale Funnel is public internet exposure. Use Tailscale Serve when the reviewing device is in the tailnet.
- State lives under
.cache/tunnel/. SetHARNERY_TUNNEL_DIRto keep it elsewhere.