Raw capture โ€” raw/tailport.md


url: https://github.com/gruen/tailport date_fetched: 2026-09-04


tailport

A terminal UI that lists your machine's locally listening TCP ports and lets you toggle tailscale serve --http=<port> on or off for each one โ€” a quick way to expose a local dev server to your Tailscale tailnet (your private WireGuard network) at http://<hostname>:<port>, without touching a terminal or remembering tailscale serve syntax.

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

Security model

By default, tailport exposes ports only to your tailnet โ€” the private network of devices you've authenticated into Tailscale โ€” by shelling out to tailscale serve (plain HTTP, tailnet-only).

It can also expose a port to the public internet, but only as an explicit opt-in: the P key funnels the selected port via tailscale funnel, behind a strong y/n confirmation. Funnel is HTTPS-only and uses one of Tailscale's three public ingress ports (443, 8443, 10000), so a funnelled port is reachable by anyone on the internet โ€” not just your tailnet. Public exposure is never automatic; it happens only when you press P and confirm, :22 (SSH) is refused outright, and funnelled ports are drawn with a distinct marker (โ— / ๐Ÿฆ).

Two deliberate constraints on the tailnet-serve path:

Requirements

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 using the bundled install script. Either run it after cloning:

./install.sh

or fetch and run it 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 directory with TAILPORT_INSTALL_DIR, or pin a specific release with TAILPORT_VERSION (e.g. TAILPORT_VERSION=0.1.1; a leading v is accepted too) instead of taking the latest. Release binaries are built by .github/workflows/build.yml on tagged pushes (v*); if that workflow's build matrix doesn't cover your platform, use go install instead.

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

A rolling backup (~/.local/bin/tailport.bak, or $TAILPORT_INSTALL_DIR/tailport.bak) is kept whenever the script replaces an existing install; roll back with mv ~/.local/bin/tailport.bak ~/.local/bin/tailport.

The default install directory, ~/.local/bin, isn't on every system's PATH. If the tailport command isn't found after installing, add it to your shell's rc file (~/.bashrc, ~/.zshrc, โ€ฆ):

export PATH="$HOME/.local/bin:$PATH"

Usage

Run tailport. It scans locally listening TCP ports and shows how each one is actually reachable โ€” localhost only, already on your tailnet, or served (and to whom).

Key Action
space Toggle tailscale serve (tailnet-only) on/off for the selected port โ€” only offered for a loopback-bound port; an already-reachable (tailnet/LAN) port shows an info toast instead
P Funnel the selected port to the public internet via tailscale funnel, behind a strong y/n confirm (:22 refused). Press again to drop it back to tailnet-served
c Copy the selected port's URL to the clipboard (via OSC 52, so it works over SSH). It copies the URL for the port's current exposure โ€” a published port's public https://โ€ฆ, a LAN bind's LAN address, a localhost-only/offline port's http://localhost:โ€ฆ, otherwise the tailnet URL (served/tailnet/funnel). The copy is confirmed inline with a โœ“, or by a toast naming the exact URL copied
C Tear down stale forwards โ€” ports still served with nothing listening locally. Offered only when some exist
x Lock / unlock the selected port. A locked port can't be served until unlocked; :22 is locked by default and unlocking it requires typing ssh
n Add a port by number to Favorites (even one nothing is listening on yet). It does not serve โ€” press space there to serve it once its service is up
l Label the selected port with custom text (prefilled with its current label if set, else the process name)
f Favorite the selected port, pinning it to the default view
F Forget the selected port: clears its โ˜… and drops it from the default view
u Undo the last registry edit (favorite, forget, label, lock, add) โ€” session-only, and never touches what's exposed
ctrl+r Redo the last undone registry edit
a Toggle between the default view and showing every listening port
/ Filter by port number, process, or label (fuzzy)
r Refresh the port list and serve status
? Toggle the full help overlay
q / ctrl+c Quit

Each row's leading marker encodes the port's state โ€” listening, served on tailnet, public (funnel), or served-but-nothing-listening; see Status markers below for the exact glyphs. The description below the port name spells out who can actually reach it: localhost only (loopback-bound, unserved), on tailnet (already reachable โ€” e.g. a wildcard-bound sshd on :22 โ€” no serving needed), local network only (bound to a specific LAN IP, not the tailnet), the served http://<hostname>:<port> URL, or the funnelled public HTTPS URL. A favorited port additionally shows a star (โ˜…). The name shown next to a port is its custom label if you've set one, otherwise its resolved process name (or was <name> for a favorite whose process has since exited) โ€” or ? if that can't be determined, which happens when the port belongs to a process owned by a different user (most commonly root) than the one running tailport.

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, etc.). Instead it shows the union of:

A port earns a place in the registry the moment you interact with it โ€” serving it (space), adding it by number (n), labeling it (l), favoriting it (f), or locking it (x) all add it. Once a port is in the registry it keeps showing up, marked inactive, even after you toggle it off โ€” and that persists across restarts, not just for the current session. F (forget) on a port that has no label and isn't locked 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 through them one at a time and ctrl+r steps forward. Undo covers only the registry โ€” favorites, labels, locks, adds. It never changes what's actually exposed: serve and funnel have their own keys and confirms, and undo won't flip them behind your back. For the same reason it won't unlock :22, since that needs a deliberate typed confirm.

Press a to bypass the registry entirely and see every port currently listening on the machine, whether known to tailport or not โ€” useful for finding something new to serve, label, or favorite.

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 every time 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 if you want to:

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 by default). An empty entry ({}, as for 9000 above) 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 equivalent --markers flag, which wins over the config value for that run only) selects how a port's exposure-state marker is drawn:

markers: "" # "" / mono (default) | auto | emoji | ascii

This setting governs the exposure markers only. Any other emoji/animation tailport might render (e.g. from its hidden Easter-egg overlay) always 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 equivalent --theme flag (--theme light, --theme dark, --theme auto), which wins over the config value. auto detects the background itself; when it can't tell at all, it falls back to dark -- existing dark-terminal setups see no change either way.

Publish (Caddy edge)

A caddy block configures the optional publish-to-the-internet path (see Publishing to the public internet below). Unlike the port registry, tailport writes this block in full โ€” with visible defaults and explanatory comments โ€” the first time it saves the config once the feature is present, so the available 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 you won't find a domain: line to edit. It appears on the next save โ€” any change that writes the file, e.g. favouriting or labelling a port โ€” or just paste the block below in by hand and set domain: there. (Publishing can also be the trigger: pressing p with a blank domain captures it inline and saves it for you, 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

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.

How it works

tailport has no dependencies beyond the tailscale CLI and the OS tools above โ€” no daemon, no config beyond the YAML file, and nothing is installed or modified system-wide other than the serve mappings you toggle yourself.

Publishing to the public internet (Caddy edge)

Tailnet serve and Funnel aren't the only way out to the world: tailport can also 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; see below). This is a third exposure level, architecturally independent of both serve (tailnet) and Funnel: Tailscale's role in this path is private WireGuard transport from the edge to your machine only, and nothing more โ€” Caddy owns the entire public trust plane (custom-domain DNS, :443 ingress, TLS termination and certificate issuance/renewal, hostname routing). No Funnel slots, Funnel commands, or Tailscale-managed public TLS participate in a publish.

Publishing and Funnel are mutually exclusive per port: tailport refuses to publish a currently-funnelled port (and refuses to funnel a currently-published one), naming the conflicting exposure and asking you to remove it first. There is no "publish outranks funnel" โ€” normal use never needs to rank them, and dual exposure created outside tailport (a foreign tool, or a manual edit) is surfaced as an explicit conflict rather than silently picked for you.

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 under Configuration above for what tailport needs once that edge exists. Until caddy.domain is set, the background published-state poll stays off; the first time you press p, tailport captures the domain inline and saves it before continuing, so you don't have to edit the config by hand.

Once configured, publishing a port 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 โ€” the same funnel-grade guardrails (:22 hard-blocked, public exposure never automatic) apply here too.

Publish is a toggle (p)

p behaves differently depending on the port's state:

Press e to change a published port's hostname or auth without unpublishing it first: e always runs the full setup flow (prefilled with the port's current/remembered hostname when known), ending in the same y/n confirm p uses. Confirming replaces the live route with the new hostname/auth in place โ€” the port is never briefly unpublished in between.

Troubleshooting

Dangling forward (โ–ฒ / ๐Ÿชน, "bound to tailnet, but stale")

A row marked โ–ฒ / ๐Ÿชน โ€” whose description reads "bound to tailnet, but stale โ€” space to unbind" โ€” means the serve mapping is up but no local process holds the port. Two common cases:

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

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

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

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

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