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:
- Plain HTTP (
tailscale serve --http=<port>), never HTTPS/TLS serve
mode. Tailscale's WireGuard tunnel already encrypts traffic between tailnet
peers, so app-layer TLS on top wouldn't add real confidentiality here β it
would just add certificate handling for no benefit. (Funnel is necessarily
HTTPS, since it faces the public internet.)
- 1:1 port mapping. A tailnet-served port always keeps its own number
(same port in and out); serve never remaps to a different number. Funnel is
the deliberate exception β it maps your local port onto one of the public
ingress ports (443/8443/10000), which won't match the local number.
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
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:
- If the version already installed 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, or, while
tailport is still pre-1.0, a minor version change β the script refuses to
install and exits non-zero, leaving the existing binary untouched. Review
the release notes, then opt in explicitly with
TAILPORT_ALLOW_BREAKING=1
to install anyway (this also backs up the old binary first). This gate is
skipped, with a note, only when the currently-installed binary's version
can't be determined (e.g. it predates --version support).
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 tailnet URL to the clipboard (via OSC 52, so it works over SSH) |
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:
- ports currently served via
tailscale serve, and
- ports in the registry: anything you've ever toggled on, labeled, or
favorited.
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
- unset (
"", the default) β mono: β localhost Β· β local network Β·
β on tailnet Β· β served Β· β public (funnel) Β· β² stale (dangling forward) Β·
β offline.
auto β opts into detecting a UTF-8-capable terminal (locale is UTF-8 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) Β·
π«οΈ stale Β· β offline.
emoji β always the moon-phase ramp above, 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. 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.
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, or favorite/unfavorite β 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 β no daemon, no config beyond the YAML file, and nothing is 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 β space 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 β space 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 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:
- 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