tailport

module
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT

README ΒΆ

tailport

A terminal UI for exposing your machine's local dev servers across your Tailscale tailnet. It lists every locally listening TCP port and lets you flip tailscale serve on or off for each one with a keypress β€” so a server on localhost:3000 becomes reachable at http://<hostname>:3000 from your other tailnet devices, without memorizing tailscale serve syntax.

By default nothing leaves your tailnet β€” your private WireGuard network. When you want it to, tailport can also expose a port to the public internet, but only as a deliberate, per-port, confirmed opt-in β€” see Exposing a port.

This is a personal tool built for one specific home tailnet β€” a handful of Linux and macOS machines. It's shared as-is in case it's useful to someone else; it isn't a general-purpose product and makes no promises about working outside that kind of setup.

Quickstart

  1. Install it (all options below):
    brew install gruen/tap/tailport
    
  2. Let tailport call tailscale serve without root β€” a one-time grant:
    sudo tailscale set --operator=$(whoami)
    
    You'll also need the tailscale CLI installed, authenticated, and on a tailnet with MagicDNS enabled (so http://<hostname>:<port> resolves for your other devices) β€” see Requirements.
  3. Run it:
    tailport
    
    Arrow-key to a port, press t to serve it on your tailnet, then c to copy its URL. Press ? for the full keybinding overlay, or run tailport quickstart for a non-interactive tour.

Requirements

  • The tailscale CLI installed, authenticated, and connected to a tailnet with MagicDNS enabled.
  • The one-time operator grant above (sudo tailscale set --operator=$(whoami)), so tailport can toggle serve without root.
  • Linux (uses ss for port discovery) or macOS (uses lsof). Other platforms aren't supported.
  • Prebuilt release binaries are published for linux/amd64, linux/arm64, and darwin/arm64. Other OS/architecture combinations require building from source with go install.

Install

On Arch Linux, from the AUR. tailport builds from source; tailport-bin drops in the prebuilt release binary and needs no Go toolchain. They conflict with each other by design β€” install one:

paru -S tailport        # or: yay -S tailport
paru -S tailport-bin    # prebuilt

On macOS or Linux, from the Homebrew tap. It builds from source, so it works on Apple Silicon and Intel Macs alike, and on Linuxbrew:

brew install gruen/tap/tailport

With Go installed, for any supported OS/arch:

go install github.com/gruen/tailport/cmd/tailport@latest

Without Go, on Linux (amd64/arm64) or macOS (arm64), fetch a prebuilt binary from this repo's GitHub Releases with the bundled install script β€” either after cloning:

./install.sh

or directly:

curl -fsSL https://raw.githubusercontent.com/gruen/tailport/main/install.sh | sh

The script detects your OS and architecture, downloads the matching binary from the latest release, verifies it against the release's published sha256 checksum, and installs it to ~/.local/bin/tailport. Override the destination with TAILPORT_INSTALL_DIR, or pin a release with TAILPORT_VERSION (e.g. TAILPORT_VERSION=0.1.1; a leading v is accepted) instead of taking the latest.

Re-running the script is version-aware and safe to script into a cron job or dotfiles bootstrap:

  • If the installed version already matches the target, it prints already up to date and does nothing.
  • If the upgrade (or downgrade) isn't breaking, it backs up the previous binary to tailport.bak next to the install, installs the new one, and prints the old β†’ new version.
  • If the transition is breaking β€” a major version change (a 0.x minor bump is not breaking) β€” the script refuses and exits non-zero, leaving the existing binary untouched. Review the release notes, then opt in with TAILPORT_ALLOW_BREAKING=1 to install anyway (it still backs up first). This gate is skipped, with a note, only when the installed binary's version can't be determined (e.g. it predates --version support).

A rolling backup (tailport.bak next to the install) is kept whenever the script replaces an existing binary; roll back with mv ~/.local/bin/tailport.bak ~/.local/bin/tailport.

~/.local/bin isn't on every system's PATH. If tailport isn't found after installing, add it to your shell's rc file: export PATH="$HOME/.local/bin:$PATH".

Once installed by binary or script, tailport update self-updates in place (see Command-line reference). Homebrew and AUR installs update through their package manager instead.

What you see

Run tailport. It scans locally listening TCP ports β€” it never scans the network β€” and shows each service as a small record: a header line (port number, name, β˜…/πŸ”’ badges) followed by one route sub-row for every way that service is reachable right now:

Route Meaning
localhost Loopback only β€” not exposed
LAN Bound to a specific LAN IP
tailnet Reachable on your tailnet (already, or via tailscale serve)
ts.net Funnelled to the public internet
caddy Published to a custom public hostname
cloudflare Tunnelled to the public internet

A service can show several routes at once β€” including multiple public routes, since the three public paths are independent: a port can be funnelled, published, and tunnelled at the same time, each its own row with its own marker and exact URL. A down favorite (nothing listening, nothing served) shows a single offline row instead. Every route carries a leading marker glyph encoding its type β€” see Status markers. A favorited service shows a β˜… on its header.

The name next to a port is your custom label if you've set one, otherwise the resolved process name β€” or was <name> for a favorite whose process has exited, or ? when the port belongs to a process owned by a different user (commonly root) than the one running tailport.

Services are listed in ascending port-number order by default; press s to sort by that name instead (case-insensitive, ties broken by port number) β€” a session-only toggle that resets to port order on restart.

Keybindings
Key Action
↑/↓, j/k Move between route rows (a flat walk across every service's routes)
Shift+↑/Shift+↓, J/K Jump between services (land on the target's first route)
t Serve on your tailnet β€” toggle (loopback-bound ports only)
p Funnel to the public internet β€” toggle, behind a confirm
d Publish via your Caddy edge β€” toggle, behind a confirm
e Edit a published port's auth in place (can't move it to a new hostname)
o Cloudflare quick tunnel β€” toggle, behind a confirm (only when cloudflared is installed)
O Cloudflare named tunnel, from this port's config.yaml binding β€” toggle, behind a confirm (help-only; not shown in the bottom bar)
c / y Copy the selected route's URL to the clipboard (via OSC 52, so it works over SSH)
i Copy the selected port's bare PID (e.g. 12345) β€” refuses if it can't be resolved
I Copy a ready-to-run kill <pid> command (SIGTERM) β€” same refusal as i, plus refuses on a locked port
C Tear down stale forwards (served with nothing listening)
x Lock / unlock the selected port (:22 ships locked; unlocking it needs a typed ssh)
n Add a port to Favorites by number (even one nothing's listening on yet)
l Label the selected port
f Favorite the selected port (pin to the default view)
F Forget the selected port (clear β˜…, drop from the default view)
u Undo the last registry edit (favorite/forget/label/lock/add)
ctrl+r Redo the last undone registry edit
a Show every listening port β€” toggle
s Sort the list by port number or name β€” toggle (session-only)
/ Filter by port number, process, or label (fuzzy)
r Refresh the port list and serve status
h Show/hide the bottom-bar keybinding legend
? Toggle the full help overlay
q / ctrl+c Quit

Every action is service-scoped except copy (c/y), which acts on the exact route row you've navigated to. A copy is confirmed inline with a βœ“ on that route's line, or by a toast when the route has no URL yet (e.g. an offline route or a still-starting quick tunnel).

i and I copy a property of the port, not the route, so they resolve to the port even when a route sub-row is selected, and always confirm by toast (there's no per-route line to annotate). Both refuse β€” a toast naming the port, nothing copied β€” when the port's PID can't be resolved (0): a foreign-owned port, or a favorite that's currently down. I also refuses, with its own toast, on a locked port (x) β€” it hands over a ready-to-run kill command, so it carries the same lock guard as serve/funnel/publish; i (the bare PID) is informational and stays ungated even when the port is locked.

Exposing a port

tailport has four exposure levels, in increasing reach. Every one is opt-in and per-port β€” tailport never exposes anything on its own β€” and :22 (SSH) is hard-blocked from all three public paths.

Level Key Reach Transport
Serve t Your tailnet tailscale serve (plain HTTP)
Funnel p Public internet tailscale funnel (HTTPS via *.ts.net)
Publish d Public internet Your own Caddy edge β€” custom https:// hostname
Tunnel o/O Public internet Cloudflare Tunnel (cloudflared)

Serve is the default path and the reason tailport exists. Press t on a loopback-bound port and it's reachable at http://<hostname>:<port> across your tailnet. (An already-reachable port shows an info toast instead β€” there's nothing to serve.) Two deliberate constraints:

  • Plain HTTP, never HTTPS/TLS serve mode. Tailscale's WireGuard tunnel already encrypts traffic between tailnet peers, so app-layer TLS would just add certificate handling for no real confidentiality here.
  • 1:1 port mapping. A served port always keeps its own number; serve never remaps.

The three public paths (p, d, o/O) each expose a port to anyone on the internet, so each:

  • requires a strong y/n confirmation before going live, naming the resulting URL (the one exception is a Cloudflare quick tunnel, whose random *.trycloudflare.com hostname isn't known until it starts);
  • de-escalates instantly, with no confirm, when you press the same key again β€” reducing exposure is never gated;
  • is independent of the others and may coexist on the same port; and
  • refuses :22 outright.

Funnel is the one public path that maps onto a fixed public ingress port (Tailscale allows only 443/8443/10000), so a funnelled port's public number won't match its local one.

See the runbooks below for setup and per-key behavior: Publish and Tunnel.

The default view and the port registry

tailport doesn't show every listening port by default β€” that gets noisy fast (sshd, mDNS, Docker, browsers holding sockets open). Instead it shows the union of:

  • ports currently served via tailscale serve, and
  • ports in the registry: anything you've ever toggled on, labeled, favorited, locked, or added by number.

A port earns its place in the registry the moment you interact with it β€” serving (t), adding (n), labeling (l), favoriting (f), or locking (x) all add it β€” and it keeps showing up (marked inactive) even after you toggle it off, persisting across restarts. F (forget) on a port with no label and no lock reverses this: it's dropped from the registry and disappears from the default view (unless it's currently active).

Registry edits are undoable within a session: u steps back one at a time, ctrl+r forward. Undo covers only the registry β€” favorites, labels, locks, adds β€” and never changes what's actually exposed: serve and the public paths have their own keys and confirms, and undo won't flip them behind your back. For the same reason it won't unlock :22, which needs a deliberate typed confirm.

Press a to bypass the registry and see every port currently listening β€” useful for finding something new to serve, label, or favorite.

Command-line reference

tailport with no arguments launches the TUI. It also accepts:

Flags (a flag value wins over the config file for that run):

Flag Meaning
-v, --version Print version and exit
-c, --config <path> Use a specific config file (default resolves under $XDG_CONFIG_HOME, else ~/.config)
--no-color Disable ANSI color output (also honors NO_COLOR)
--markers <mode> Exposure-glyph style: auto, emoji, or ascii β€” see Status markers
--theme <mode> Color scheme: auto, light, or dark β€” see Theme

Subcommands:

  • tailport quickstart β€” non-interactive onboarding and the keybinding legend, printed to stdout. A first look, or a cheat sheet, without entering the TUI.
  • tailport status β€” a headless, read-only report of how each port is currently exposed. Add --json for machine-readable output. Changes nothing.
  • tailport update β€” self-update to the latest release (sha256-verified). --check reports whether an update is available without installing it; -y/--yes skips the confirmation prompt; --force overrides its refusal to touch a package-manager-managed install. (Installed via Homebrew or the AUR? Update through that instead.)

Configuration

On first run, tailport writes a registry seeded with :22 (SSH) locked to:

$XDG_CONFIG_HOME/tailport/config.yaml

or, if XDG_CONFIG_HOME isn't set, ~/.config/tailport/config.yaml. It won't overwrite an existing file. This is the port registry described above β€” labels, favorites, and locks keyed by port number β€” and it's rewritten automatically whenever you toggle, label, favorite/unfavorite, or lock a port from within the app. You generally shouldn't need to hand-edit it, but the format is plain YAML:

ports:
    22:
        locked: true
    3000:
        label: dev server
        favorite: true
    9000: {}

An entry can have a label, be marked favorite, and/or be locked (a locked port can't be served until you unlock it; :22 ships locked). An empty entry ({}, like 9000) means "keep this in the default view" without any of those β€” the state left behind by serving a port without labeling or favoriting it. tailport also records a last_process key per port automatically (the name it last saw listening, used for the was <name> display); you don't set that by hand.

Status markers

A top-level markers key (or the --markers flag, which wins for that run only) selects how each route sub-row's marker is drawn:

markers: "" # "" / mono (default) | auto | emoji | ascii
  • unset ("", the default) β€” mono: β—‹ localhost Β· β—” local network Β· β—‘ on tailnet Β· β—‰ served Β· ● public (funnel) Β· β—† public (published) Β· β—ˆ public (cloudflare tunnel) Β· β–² stale (dangling forward) Β· βœ• offline.
  • auto β€” detects a UTF-8-capable terminal (UTF-8 locale, and TERM isn't the bare Linux console or dumb) and switches to the moon-phase emoji ramp there, otherwise falls back to mono: πŸŒ• localhost Β· πŸŒ” local network Β· πŸŒ“ on tailnet Β· πŸŒ’ served Β· πŸŒ‘ public (funnel) Β· 🌐 public (published) Β· ☁️ public (cloudflare tunnel) Β· 🌫️ stale Β· βœ• offline.
  • emoji β€” always the moon-phase ramp, regardless of terminal.
  • ascii β€” always mono, regardless of terminal (same glyphs as unset).

This setting governs the exposure markers only. Any other emoji/animation tailport might render (e.g. its hidden Easter-egg overlay) auto-detects terminal capability on its own, independent of markers.

Theme (light/dark terminals)

tailport auto-detects your terminal's background and picks legible colors either way. If detection guesses wrong (common over SSH/tmux/some multiplexers), override it with a top-level theme key:

theme: auto # auto (default) | light | dark

or the --theme flag, which wins over the config value. auto detects the background itself; when it can't tell at all, it falls back to dark, so existing dark-terminal setups see no change either way.

Sticky service header

The single-column body scrolls by route, not by block, so the viewport's top edge can land mid-block β€” a route row (e.g. a cloudflare sub-row) with its service's header scrolled just out of view above it. A top-level sticky_header key (default on) pins that top-clipped service's header as the list's top row whenever this happens, so a route at the very top of the viewport always shows which service it belongs to:

sticky_header: true # true (default) | false

Set it to false for the plain per-route scroll (no pinned header) instead.

Publish (Caddy edge)

Advanced β€” only needed if you use the d publish path.

A caddy block configures the optional publish-to-the-internet path. Unlike the port registry, tailport writes this block in full β€” with visible defaults and explanatory comments β€” the first time it saves the config, so the knobs are discoverable without reading docs:

Upgraded from an older tailport? A config.yaml written before this feature landed has no caddy: block yet β€” that's expected, and it's why there's no domain: line to edit. It appears on the next save (any change that writes the file, e.g. favoriting or labeling a port), or paste the block below in by hand and set domain: there. Pressing d with a blank domain also captures it inline and saves it, rather than refusing.

caddy:
    # Tailnet name of the Caddy edge node; tailport reaches its admin API
    # here. Use the short MagicDNS label, not an FQDN.
    hostname: caddy

    # Public base domain used to build publish hostnames. Point its DNS
    # (typically a wildcard) at the public Caddy edge before publishing.
    domain: ""

    # Name of the shared Caddy JSON HTTP server under apps.http.servers.
    # Every tailport computer publishing through this same Caddy edge must
    # use the same value; this does not identify the source computer.
    server_name: tailport

    # Port of the Caddy admin API on the edge (reachable tailnet-only).
    admin_port: 2019

    # Skip the y/n confirm when re-publishing a port already published
    # earlier this session (remembered hostname + auth). First publish
    # always confirms. Default false (confirm shown).
    silent_republish: false
  • hostname (default caddy) β€” the edge's own private tailnet identity, used only so tailport can find its admin API at http://<hostname>:<admin_port>. Use the short MagicDNS label, not an FQDN β€” the edge admits only its short name, so an FQDN silently 403s. It has nothing to do with any published route's public hostname (e.g. app.example.com): private edge identity and public route identity are deliberately separate.
  • domain (default "") β€” the public base domain publish hostnames are built from. Blank doesn't block publishing: pressing d captures the domain inline and saves it before continuing, and until it's set the background published-state poll doesn't run (zero cost until you set it).
  • server_name (default tailport) β€” the shared Caddy HTTP server tailport manages. Every tailport computer publishing through the same edge must agree on this value; it selects the routes array, it does not identify the source computer.
  • admin_port (default 2019) β€” the Caddy admin API's port on the edge.
  • auth_user / auth_hash β€” unset (no auth) until you opt into basic auth at a publish confirmation. auth_hash is always a bcrypt hash of the password you typed then, never the plaintext; every published route that opts into auth shares this one credential β€” it isn't per-hostname.
  • silent_republish (default false) β€” skips the d key's y/n confirm when re-publishing a port already published earlier in the same session (its hostname and auth are remembered in memory only). A port's first publish this session always confirms regardless, and Funnel's confirm is unaffected.

None of this configures the edge itself β€” it only tells tailport where an already-deployed edge lives. Standing up the edge (on Fly.io or any host you run β€” Tailscale ACL and auth key, DNS) is a separate one-time operator task; see docs/caddy-edge.md.

Tunnel (Cloudflare)

Advanced β€” only needed if you use the o/O Cloudflare tunnel keys.

A cloudflared block configures the optional tunnel-to-the-internet path. Like the caddy block, tailport writes it in full β€” with visible defaults and comments β€” the first time it saves the config.

Upgraded from an older tailport? A config.yaml from before this feature has no cloudflared: block yet β€” expected. It appears on the next save, or paste the block below in by hand.

cloudflared:
    # Optional path to the cloudflared executable. Blank means tailport
    # looks up `cloudflared` on $PATH.
    binary: ""
  • binary (default "") β€” path to the cloudflared executable. Blank means tailport looks it up on $PATH; the whole feature (both keys, discovery, polling) stays dormant unless it's found there (or at this path). A wrapper script works if it execs cloudflared β€” discovery recognizes the process either as your configured binary or, after the wrapper's exec replaces it, as plain cloudflared.

cloudflared.domain is ignored since v0.3.3 (kata p7c5). It used to prefill a hostname prompt that no longer exists β€” O now reads a named tunnel's hostname straight from its per-port binding below, with no prompt at all. An old config's domain: ... still loads without error, but it's dropped the next time tailport saves the file.

A named tunnel is bound per port, not in the cloudflared block: add a cloudflare entry under that port in ports:, naming the pre-provisioned tunnel and the hostname you routed to it β€”

ports:
    3000:
        cloudflare:
            tunnel: tp-e2e
            hostname: tunnel.gruen.work

β€” and O on :3000 runs exactly that tunnel/hostname, no prompts. See Tunnelling to the public internet for the one-time operator setup (cloudflared tunnel login / create / route dns) that has to happen before this binding means anything. None of that is configured here β€” tailport only ever runs an already-provisioned named tunnel.

Publishing to the public internet (Caddy edge)

Tailnet serve and Funnel aren't the only way out: tailport can publish a port to a custom public hostname β€” https://app.example.com, no port in the URL, no *.ts.net β€” through a Caddy edge node you run yourself (on Fly.io, or any host that meets the requirements). This is a third exposure level, architecturally independent of both serve and Funnel: Tailscale's role here is private WireGuard transport from the edge to your machine, and nothing more β€” Caddy owns the entire public trust plane (custom-domain DNS, :443 ingress, TLS termination and certificate renewal, hostname routing).

Publish and Funnel are independent, not ranked, and can coexist: a port can be funnelled and published at once, each its own route row with its own marker and URL. Each still requires its own strong per-service confirm before going live, and :22 stays hard-blocked from both.

Setup is a separate, one-time operator task, not something tailport does for you: a Caddy edge deployed and reachable on your tailnet, a domain whose DNS points at it, and a Tailscale auth key for the edge itself. See docs/caddy-edge.md for the full runbook (written for a Caddy/Fly first-timer) and the caddy.* fields for what tailport needs once that edge exists.

Once configured, publishing works the same shape as Funnel: select a port, confirm the public hostname and (optionally) a shared basic-auth credential, and confirm again against the exact https:// URL before anything goes live.

Publish is a toggle (d)

d behaves differently depending on the port's state:

  • Already published β€” d unpublishes immediately. No confirm: reducing exposure is never gated.
  • Published earlier this session, then unpublished β€” tailport remembers that port's hostname and auth in memory (never written to config; the edge stays the source of truth) for as long as the process runs. Pressing d again re-publishes with that remembered config, skipping the setup prompts β€” straight to the y/n confirm naming the exact https://<hostname>, unless you've set silent_republish: true, in which case it re-publishes with no confirm.
  • Never published this session β€” d runs the full setup: hostname, optional basic auth, then the confirm. This always happens on a port's first publish, regardless of silent_republish.

Press e to change a published port's auth without unpublishing it first: e runs the setup flow (prefilled with the port's current hostname) and ends in the same y/n confirm, then updates the live route in place β€” the port is never briefly unpublished in between. e cannot move a still-published port to a new hostname: that would be a non-atomic delete-and-create that could leave the old route dangling, so tailport refuses it with a message telling you to unpublish (d) first and re-publish at the new hostname.

Tunnelling to the public internet (Cloudflare Tunnel)

serve, Funnel, and Publish still aren't the only way out: tailport can tunnel a port to the public internet through a Cloudflare Tunnel, run by the cloudflared CLI. This is a third public path, sibling to Funnel and Publish rather than layered above either β€” and architecturally different from both: cloudflared is a long-running local process tailport supervises directly, not a remote edge tailport pokes (Publish) or a Tailscale-managed ingress slot (Funnel). The cloudflared binary is the connector; a tunnel is up only while its process stays alive.

The whole feature exists only when cloudflared is installed: tailport detects it once at startup, and when it's absent neither key does anything β€” o is dropped from the bar entirely (O never appears there regardless β€” see below) and there's no discovery, no polling, zero cost. There are two flavors, matching Cloudflare's two account scenarios, and (kata p7c5) each gets its own key rather than a menu to pick between them:

  • Quick tunnel (o) β€” no Cloudflare account needed. tailport runs cloudflared tunnel --url http://localhost:<port>, which hands back a random https://<name>.trycloudflare.com hostname: unauthenticated, and ephemeral β€” a new hostname every time you start one. o goes straight to a confirm; there is no setup to do first.

  • Named tunnel (O) β€” for an authenticated account, driven entirely by a binding in config.yaml (see Tunnel (Cloudflare) above). One-time operator setup, before the binding means anything:

    1. cloudflared tunnel login (once per account; dashboard- or token-managed tunnels aren't supported β€” see below);
    2. cloudflared tunnel create <name> β€” creates the named tunnel;
    3. cloudflared tunnel route dns <name> <hostname> β€” routes a stable custom hostname to it;
    4. add ports.<port>.cloudflare: {tunnel: <name>, hostname: <hostname>} to config.yaml.

    tailport never automates any of that β€” it only runs the pre-provisioned tunnel O is bound to:

    cloudflared tunnel --config <path> --metrics 127.0.0.1:<metrics-port> --logfile <path> --no-autoupdate run --url http://localhost:<port> <name>
    

    (the tunnel-level flags, including --config, must come before run β€” cloudflared rejects them after it with Incorrect Usage and exits 0); it never mutates your Cloudflare account or DNS.

Every tailport-run tunnel gets its own per-tunnel --config β€” quick and named alike β€” pointed at a tiny file tailport writes (and rewrites, on every start) itself, next to that tunnel's log; it's never shared between tunnels. This is not optional politeness: if a config.yml with ingress: rules exists anywhere on cloudflared's own config search path (~/.cloudflared, ~/.cloudflare-warp, ~/cloudflare-warp, /etc/cloudflared, /usr/local/etc/cloudflared β€” notably including the file cloudflared service install writes to /etc/cloudflared/config.yml), cloudflared silently ignores tailport's --url and serves that config's ingress origin instead β€” no error, no warning, nothing in the log (live-verified against cloudflared 2026.9.1). Passing tailport's own --config makes it authoritative regardless of what's on that search path β€” and, for a named, LOCALLY-managed tunnel (one created with cloudflared tunnel create, as above), it also pins that tunnel's ingress to exactly the hostname you confirmed:

ingress:
  - hostname: "app.example.com"
    service: http://localhost:3000
  - service: http_status:404

so any other hostname already routed to that same tunnel gets a plain 404, not your service β€” closing the gap where --url alone makes cloudflared serve the local port for every hostname routed to the tunnel, wildcards included. (A quick tunnel's config still has nothing to pin β€” its hostname isn't known until cloudflared assigns one β€” so it stays the same hermetic {} it always was.) This pin only takes effect for a locally-managed tunnel: if a tunnel's configuration is switched to remotely-managed in the Cloudflare dashboard, Cloudflare pushes its own ingress config to it, which overrides this local --config file entirely (dashboard/token-managed tunnels are already out of scope β€” see above). Two consequences either way:

  • any settings in your own config.yml never apply to a tailport-run tunnel β€” tailport's tunnels are hermetic by design;
  • a named tunnel's credentials must sit at cloudflared's default location, next to cert.pem β€” where cloudflared tunnel create writes <UUID>.json. If they aren't there, the tunnel fails to start, and cloudflared's own error text appears in the toast.

~/.cloudflared/cert.pem (written once by cloudflared tunnel login) is an account-scoped credential: whoever holds it can create and delete tunnels and DNS records in that account, so treat it like a secret. tailport only ever checks that it exists β€” it never opens or reads its contents.

tailport refuses to start a tunnel outright if its state dir ($XDG_STATE_HOME/tailport, default ~/.local/state/tailport) is a symlink, isn't owned by you, or is group- or world-writable.

Tunnels survive tailport exiting. cloudflared is started detached, in its own session, so quitting the TUI doesn't drop the tunnel β€” it keeps running until you tear it down or kill it yourself. tailport never persists tunnel state to disk; instead it reads the OS process table live on every poll, so a tunnel started in a previous session is re-discovered next launch and stays re-toggleable β€” with o if it's quick, O if it's named. Only tailport-owned tunnels β€” the ones carrying a sentinel --logfile flag tailport always passes β€” are tracked this way; a cloudflared process started outside tailport is left alone entirely.

If a tunnel stops on its own β€” a named tunnel that can't authenticate, retries running out, a crash β€” tailport never lets it just vanish. The next poll (every few seconds) notices the port is gone and shows a toast naming cloudflared's last error, when it logged one, e.g. Cloudflare tunnel on :3000 exited β€” <error>. A routine info/debug/warning line is never presented as if it were the reason β€” if nothing more useful was logged, the toast just reads Cloudflare tunnel on :3000 exited with no fabricated cause. Full console output (not just that one line) is always in ~/.local/state/tailport/cftunnel-<port>[-<host>].console. Tearing a tunnel down yourself β€” o on a quick one, O on a named one β€” never triggers this toast.

Like the other public paths, Tunnel is independent and may coexist with Funnel and Publish on the same port, every path still requires its own per-service confirm, and :22 stays hard-blocked. A tunnelled service shows its own cloudflare route row (marker β—ˆ / ☁️) with the exact public URL once known.

Two separate toggles: o (quick) and O (named)

Cloudflare Tunnel was one key (o) through a q/n mode-select-then-two-prompts flow up to v0.3.2; kata p7c5 replaced that with two independent keys, each doing exactly one thing, with no prompts either way:

  • o β€” quick tunnel only. On a port not currently running a quick tunnel, o goes straight to the quick confirm β€” no mode prompt, even if you're logged in to Cloudflare. On a port already running a quick tunnel, o tears it down immediately, no confirm.
  • O β€” named tunnel only, from config.yaml. O reads ports.<port>.cloudflare.{tunnel, hostname} (see Tunnel (Cloudflare) above) and goes straight to the named confirm, naming the exact hostname and the tunnel it will run β€” again, no prompt. On a port already running its named tunnel, O tears it down immediately, no confirm. O refuses (with a toast naming the fix) when the port has no binding, when cloudflared tunnel login hasn't been run yet, or when the bound tunnel name/hostname is invalid.
  • Where O is shown: not in the bottom bar β€” like ctrl+r (redo), it's help-only, documented in the ? overlay and tailport quickstart and the keybinding table above, but never takes a bar slot.
  • Cross-key behavior: each key only ever touches its OWN mode on a given port. Press o on a port whose running tunnel is named, and it refuses: a named tunnel is running on :<port> β€” press O to stop it. Press O on a port whose running tunnel is quick, and it refuses the mirror way: a quick tunnel is running on :<port> β€” press o to stop it. Neither key ever tears down or starts the other's mode.

A named tunnel serves only one local port at a time: if two ports are bound to the same tunnel name and you press O on the second while the first is still running it, tailport refuses with a toast naming which port already has it. The limit: tailport only sees tunnels it started this session (or re-discovered from a prior one) β€” if the same named tunnel also runs somewhere else entirely (another machine, a system service, a dashboard connector), cloudflared just adds another connector to it, and Cloudflare may send requests to either. tailport can't see or guard against that.

Both keys end in a y/n confirm before anything goes live, and :22 is hard-blocked either way. The quick tunnel's confirm is the one deliberate exception to the "always name the exact public URL" rule: cloudflared assigns the *.trycloudflare.com hostname only after the tunnel starts, so there's no URL to name in advance. The confirm names the local port instead; tailport flashes starting Cloudflare quick tunnel for :<port>…, and the real https://… address appears in the row a few seconds later, once the next poll picks it up. The named tunnel's confirm has no such gap β€” it names the exact https://<hostname> up front (the same as Publish) plus the tunnel it will run, e.g. via tunnel "web" (from config.yaml) β€” but that hostname is simply the one config.yaml says: tailport has no way to check it's actually routed to that tunnel. For a locally-managed tunnel it does now pin the tunnel's ingress to exactly that hostname (see the per-tunnel --config above), so if it's wrong or unrouted your service is simply unreachable at it β€” the tunnel no longer falls back to serving whatever other hostname happens to already be routed to it. (A tunnel switched to remotely-managed in the Cloudflare dashboard ignores this local pin β€” see above; that's out of scope.)

How it works

  • Port discovery: ss -H -t -l -n -p on Linux, lsof -iTCP -sTCP:LISTEN -n -P on macOS, run locally β€” tailport never scans the network.
  • Serve status: tailscale serve status --json, parsed to find which ports currently have an active HTTP mapping.
  • Toggling on: tailscale serve --bg --http=<port> <port>.
  • Toggling off: tailscale serve --http=<port> off β€” a surgical removal of just that one mapping; other active mappings are left alone.
  • Registry writes: the config file is rewritten immediately after every toggle, label, favorite/unfavorite, or lock/unlock β€” there's no in-memory-only state to lose if tailport is killed rather than quit normally.

tailport has no dependencies beyond the tailscale CLI and the OS tools above (and, only if you use the o/O tunnel keys, cloudflared) β€” no daemon, nothing installed or modified system-wide other than the serve mappings you toggle yourself.

Troubleshooting

Dangling forward (β–² / 🌫️, "bound to tailnet, but stale")

A row marked β–² / 🌫️ β€” whose description reads "bound to tailnet, but stale β€” t to unbind" β€” means the serve mapping is up but no local process holds the port. Two common cases:

  • The app just isn't running (it died, or hasn't started). Start it, or unbind the port β€” t on the row, or C to clear all stale forwards. The mapping deliberately outlives the app so you can restart it freely, so tailport won't tear it down for you.

  • The app can't start with "address already in use." When you serve :8025, tailscaled binds your tailnet IP on :8025. If your app then tries to bind 0.0.0.0:8025 (all interfaces), that collides and the app fails to start β€” so the forward dangles. The mapping meant to serve the app is what's blocking it.

    The fix is to bind the app to loopback, which is what serve proxies to anyway:

    mailpit --listen 127.0.0.1:8025      # e.g. β€” bind 127.0.0.1, not 0.0.0.0
    

    This resolves the collision and keeps the app off your LAN β€” reachable only over the tailnet, through serve. If you genuinely need the app on 0.0.0.0:<port>, unbind the port first (t, or C) β€” note that once it's on 0.0.0.0 it's already reachable on the tailnet on its own (state on tailnet), so there's nothing left to serve.

Served row shows stale, but something IS listening

tailscale serve always dials http://127.0.0.1:PORT β€” never anything else. If your app is listening only on an address serve doesn't dial β€” ::1 specifically (not 127.0.0.1), a V6ONLY [::], or a non-.1 loopback address like 127.0.0.2 β€” tailport's served route correctly shows stale even though the port is genuinely listening (localhost still works from this machine, since that's a looser check). A common cause is a dev server told to listen on localhost, which can resolve to ::1 first. Bind the app to 127.0.0.1 instead, e.g. python3 -m http.server 8791 --bind 127.0.0.1 (not 0.0.0.0 β€” on a served port that collides with serve's own tailnet listener, see above).

Development

Build and test locally with the standard Go toolchain:

go build ./...
go vet ./...
go test ./...
CI and the macOS lsof path

Port discovery is OS-specific: Linux uses ss, macOS uses lsof (see How it works). The default CI runs on Linux, so the macOS lsof code path in internal/portscan is compiled when cross-compiling but is not executed there.

To keep pricey macOS runner minutes opt-in, the macOS-specific tests run on a native Apple-Silicon runner only when you ask for them, via darwin-tests.yml:

  • Include [ci darwin] in a commit message and push β€” the macOS job runs go build/vet/test on macos-14, so the darwin-tagged tests in internal/portscan (the parseLsof fixtures and the real-lsof List() smoke test) actually execute.
  • Or trigger it manually from the repository's Actions tab (workflow_dispatch).

A push without the [ci darwin] token does not start the macOS job. The token is read from the pushed commit message, so use a branch push or manual dispatch (it is not evaluated for pull-request events).

License

MIT

Directories ΒΆ

Path Synopsis
cmd
tailport command
Command tailport is a TUI for toggling tailscale serve (tailnet-only, plain HTTP) on and off per locally listening port.
Command tailport is a TUI for toggling tailscale serve (tailnet-only, plain HTTP) on and off per locally listening port.
internal
caddyedge
Package caddyedge drives a user-controlled Caddy edge node through its tailnet-only admin API to publish a local HTTP service at a custom public hostname (see kata v1z5, the `p` publish path; swapped from `P` under vzj4).
Package caddyedge drives a user-controlled Caddy edge node through its tailnet-only admin API to publish a local HTTP service at a custom public hostname (see kata v1z5, the `p` publish path; swapped from `P` under vzj4).
cftunnel
Package cftunnel supervises `cloudflared` to expose a local port to the public internet through a Cloudflare Tunnel (kata nc1j -- the `t` key).
Package cftunnel supervises `cloudflared` to expose a local port to the public internet through a Cloudflare Tunnel (kata nc1j -- the `t` key).
cftunnel/fakecloudflared command
Command fakecloudflared is a minimal, from-scratch stand-in for the real `cloudflared` binary (kata nc1j, W4 -- design-v033-final.md's Tier 2).
Command fakecloudflared is a minimal, from-scratch stand-in for the real `cloudflared` binary (kata nc1j, W4 -- design-v033-final.md's Tier 2).
clip
Package clip copies text to the system clipboard with two mechanisms, in keeping with tailport's zero-required-dependency posture:
Package clip copies text to the system clipboard with two mechanisms, in keeping with tailport's zero-required-dependency posture:
config
Package config loads and persists tailport's YAML config: a per-port registry of labels and favorites that drives the default (filtered) view.
Package config loads and persists tailport's YAML config: a per-port registry of labels and favorites that drives the default (filtered) view.
portscan
Package portscan enumerates locally listening TCP ports.
Package portscan enumerates locally listening TCP ports.
selfupdate
Package selfupdate implements the mechanism behind `tailport update`: it asks the GitHub Releases API for the latest tag, compares it to the running build's version, downloads the per-arch asset, verifies its published sha256 BEFORE touching anything on disk, and atomically swaps the running binary in place.
Package selfupdate implements the mechanism behind `tailport update`: it asks the GitHub Releases API for the latest tag, compares it to the running build's version, downloads the per-arch asset, verifies its published sha256 BEFORE touching anything on disk, and atomically swaps the running binary in place.
statusreport
Package statusreport builds tailport's headless `status` report: a READ-ONLY snapshot of every port currently exposed via `tailscale serve` (tailnet), `tailscale funnel` (public internet), or a Caddy-edge publish (public internet at a custom hostname, kata v1z5).
Package statusreport builds tailport's headless `status` report: a READ-ONLY snapshot of every port currently exposed via `tailscale serve` (tailnet), `tailscale funnel` (public internet), or a Caddy-edge publish (public internet at a custom hostname, kata v1z5).
tsserve
Package tsserve wraps the `tailscale` CLI to inspect and control `tailscale serve` and `tailscale funnel`.
Package tsserve wraps the `tailscale` CLI to inspect and control `tailscale serve` and `tailscale funnel`.
ui
Package ui implements tailport's Bubble Tea TUI: a list of locally listening ports, toggled on/off tailnet-wide via tailscale serve.
Package ui implements tailport's Bubble Tea TUI: a list of locally listening ports, toggled on/off tailnet-wide via tailscale serve.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL