Skip to content

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.

Terminal window
harn tunnel up --allow-path / # start the default instance, publishing the whole upstream
harn tunnel up --target 127.0.0.1:4276 --vhost localhost:4276 \
--allow-path /decisions --allow-path /_next # share one Harnery web page and its assets
harn tunnel up --name web --allow-path / # a second, independently-managed instance
harn tunnel up --provider cloudflare --allow-path / # default: random *.trycloudflare.com URL + IP allowlist
harn tunnel up --provider tailscale --allow-path / # tailnet-only Tailscale Serve URL
harn tunnel up --provider tailscale --visibility public --allow-path / # public Tailscale Funnel URL
harn tunnel up --provider tailscale --name web --path /web --allow-path / # named instance at a path
harn tunnel status # table of running instances + their URLs
harn tunnel down [--name <id>|--all] # stop one / all
harn tunnel reload [--name <id>|--all] # pick up allowlist edits, keeping the URL
harn tunnel logs [-f] # tail the gate log (--provider for the provider log)
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.

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.

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:

Terminal window
harn tunnel allow # list allowed entries
harn tunnel allow add 1.2.3.4 # add, then `harn tunnel reload --all` to apply
harn tunnel allow add 203.0.113.0/24 # a whole IPv4 range
harn tunnel allow add 2601:db8:1:2::/64 # a whole IPv6 network
harn tunnel allow add --current # this machine, detected automatically
harn tunnel allow rm 1.2.3.4 # remove

Entries 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.

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

harn tunnel reload restarts an instance’s gate in place and leaves the provider process alone:

Terminal window
harn tunnel reload # the default instance
harn tunnel reload --name web # one named instance
harn tunnel reload --all # every live instance

The 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:

Terminal window
harn tunnel logs # find the `deny: ... ip=<addr>` line
harn 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.

  • Quick tunnels reroll their hostname on every restart. Use harn tunnel reload for allowlist changes so the URL survives. A hostname that persists across a full down/up would need a named Cloudflare tunnel tied to a domain, which isn’t built today.
  • cloudflared is taken from $HARNERY_CLOUDFLARED, then cloudflared_path in the tunnel config, then PATH, 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/ps and ss or lsof on Linux and macOS, and PowerShell and netstat on Windows. A missing tool is tolerated: ports are then probed by connecting to them.
  • Tailscale mode requires the tailscale CLI to be installed, signed in, and reporting a MagicDNS name from tailscale status --json.
  • Tailscale Funnel is public internet exposure. Use Tailscale Serve when the reviewing device is in the tailnet.
  • State lives under .cache/tunnel/. Set HARNERY_TUNNEL_DIR to keep it elsewhere.