README
ΒΆ
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
- The
tailscaleCLI installed, authenticated, and connected to a tailnet with MagicDNS enabled (sohttp://<hostname>:<port>resolves for your other tailnet devices). - Run this once so tailport can call
tailscale servewithout root:sudo tailscale set --operator=$(whoami) - Linux (uses
ssfor port discovery) or macOS (useslsof). Other platforms aren't supported. - Prebuilt release binaries are published for
linux/amd64,linux/arm64, anddarwin/arm64(see Install below). Other OS/architecture combinations require building from source withgo 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 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 dateand does nothing. - If the upgrade (or downgrade) isn't breaking, it backs up the previous
binary to
tailport.baknext to the install, installs the new one, and prints the old β new version. - If the transition is breaking β a major version change (a
0.xminor bump is not breaking; it upgrades like any other) β the script refuses to install and exits non-zero, leaving the existing binary untouched. Review the release notes, then opt in explicitly withTAILPORT_ALLOW_BREAKING=1to 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--versionsupport).
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 |
|---|---|
β/β, j/k |
Move the selection between route sub-rows β a flattened walk across every service's active routes |
Shift+β/Shift+β, J/K |
Jump the selection between services, landing on the target service's first route |
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 |
p |
Publish the selected port to the public internet via a Caddy edge (a custom https:// hostname, no port in the URL), behind a strong y/n confirm (:22 refused). Press again to unpublish |
e |
Change a published port's hostname or auth without unpublishing it first β runs the same setup flow as p, ending in the same confirm |
t |
Tunnel the selected port to the public internet via a Cloudflare Tunnel (cloudflared), quick (random *.trycloudflare.com) or named (your routed hostname). Press again to tear it down. Only offered when cloudflared is installed; :22 refused |
c / y |
Copy the selected route's exact URL to the clipboard (via OSC 52, so it works over SSH) β y is a yank alias for c. Every other action stays service-scoped; only copy acts on the specific route sub-row you've navigated to. The copy is confirmed inline with a β on that route's line, or by a toast (e.g. an offline route or a still-starting quick tunnel has no URL yet) |
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 service is shown as a small record: a header line (port number, name,
β
/π badges) followed by one route sub-row for every way it's actually
reachable right now β localhost (loopback), LAN (bound to a specific LAN
IP), tailnet (already reachable, or served via tailscale serve), ts.net
(funnelled), caddy (published), and cloudflare (tunnelled). A service can
show several routes at once, and β since the three public paths are
independent of each other β that includes multiple public routes
simultaneously: a port can be funnelled, published, and tunnelled all at the
same time, each its own row with its own marker and its own exact URL. A down
favorite (nothing listening, nothing served) shows a single offline
pseudo-route instead. Every route has its own leading marker encoding its
type; see Status markers below for the exact glyphs. A
favorited service additionally shows a star (β
) on its header. 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 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β opts into detecting a UTF-8-capable terminal (locale is UTF-8 andTERMisn't the bare Linux console ordumb) 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 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.
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.yamlwritten before this feature landed has nocaddy:block yet β that's expected, and it's why you won't find adomain: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 setdomain:there. (Publishing can also be the trigger: pressingpwith a blankdomaincaptures 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
hostname(defaultcaddy) β the edge's own private tailnet identity, used only so tailport can find its admin API athttp://<hostname>:<admin_port>. Use the short MagicDNS label, not an FQDN β the edge admits only its short name, so an FQDN silently 403s and publishing rejects one. 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"", blank) β the public base domain publish hostnames are built from. Blank doesn't block publishing: pressingpcaptures the domain inline and saves it before continuing (rather than refusing), and until it's set the background published-state poll doesn't run (zero cost until you set it).server_name(defaulttailport) β 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(default2019) β 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_hashis 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(defaultfalse) β skips thepkey's y/n confirm when RE-publishing a port that was already published earlier in the SAME session (its hostname and auth are remembered in memory only; see Publish is a toggle below). A port's first publish this session always confirms regardless of this setting, and Funnel's confirm is unaffected. Off by default: the confirm is shown unless you explicitly opt in.
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)
A cloudflared block configures the optional tunnel-to-the-internet path
(see Tunnelling to the public internet
below). Like the caddy block, 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 both knobs are discoverable without
reading docs.
Upgraded from an older tailport? A
config.yamlwritten before this feature landed has nocloudflared:block yet β that's expected. 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. Unlikecaddy.domain,cloudflared.domaingates nothing: it's a pure convenience prefill, so there's no equivalent of publish's "captures it inline on first use" behavior here.
cloudflared:
# Optional path to the cloudflared executable. Blank means tailport
# looks up `cloudflared` on $PATH.
binary: ""
# Optional public base domain used to prefill the hostname prompt when
# starting a named (authenticated) tunnel. Blank by default; quick
# (unauthenticated) tunnels ignore it.
domain: ""
binary(default"", blank) β path to the cloudflared executable. Blank means tailport looks upcloudflaredon$PATH; the wholetfeature (key, discovery, polling) stays dormant unless it's found there (or at this path).domain(default"", blank) β a public base domain used only to prefill the hostname prompt when starting a named tunnel (see The tunnel toggle below). Purely a convenience: leaving it blank doesn't block anything, and a quick tunnel ignores it entirely β its hostname is assigned by Cloudflare, not built from this domain.
None of this configures Cloudflare itself β for a named tunnel, logging in
(cloudflared tunnel login), creating the tunnel, and routing its hostname
(cloudflared tunnel route dns) are a separate, one-time operator task;
tailport only ever runs an already-provisioned named tunnel. See
Tunnelling to the public internet
below.
How it works
- Port discovery:
ss -H -t -l -n -pon Linux,lsof -iTCP -sTCP:LISTEN -n -Pon 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.
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 independent, not ranked β and, since kata th05,
no longer mutually exclusive: a port can be funnelled AND published at the
same time, each showing as its own route sub-row with its own marker and
exact URL. There is no "publish outranks funnel" β normal use never needs to
rank them β and each still requires its own strong per-service confirm before
going live; :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 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:
- Already published β
punpublishes 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
pagain re-publishes with that remembered config, skipping the hostname/auth setup prompts entirely β straight to the same y/n confirm naming the exacthttps://<hostname>, unless you've setsilent_republish: true(see Configuration above), in which case it re-publishes with no confirm at all. - Never published this session β
pruns the full setup: hostname, optional basic auth, then the confirm. This always happens on a port's first publish, regardless ofsilent_republish.
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.
Tunnelling to the public internet (Cloudflare Tunnel)
Tailnet serve, Funnel, and Publish aren't the only way out to the world:
tailport can also tunnel a port to the public internet through a
Cloudflare Tunnel,
run by the cloudflared CLI. This is a third exposure path, sibling to
Funnel and Publish rather than layered above or below either β and
architecturally different from both: cloudflared is a long-running local
process tailport supervises directly, not a remote edge tailport pokes (as
with Publish) or a Tailscale-managed public ingress slot (as with 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 actually installed:
tailport detects it once at startup, and when it's absent the t key is
dropped from the bar entirely β no key, no discovery, no polling, zero cost.
There are two flavors, matching Cloudflare's two account scenarios:
- Quick tunnel β no Cloudflare account needed. tailport runs
cloudflared tunnel --url http://localhost:<port>, which hands back a randomhttps://<name>.trycloudflare.comhostname: unauthenticated, and ephemeral β a new hostname every time you start one. - Named tunnel β for an authenticated account. You've already run
cloudflared tunnel login, created a tunnel, and routed a stable custom hostname to it (cloudflared tunnel route dns) β a separate, one-time operator task, exactly like standing up the Caddy edge, that tailport does not do for you and never automates. tailport only runs that pre-provisioned tunnel βcloudflared tunnel run --url http://localhost:<port> <name>β bound to your stable hostname. It never mutates your Cloudflare account or DNS.
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 tailport session is re-discovered the next time
you launch it and stays re-toggleable with t. 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, never signalled or touched.
Tunnelling, Funnel, and Publish are independent, not ranked β and, since
kata th05, no longer mutually exclusive: a port can carry all three public
exposures at once, each showing as its own route sub-row. There is no
implicit precedence between any of the three, and multiple public routes on
one port is a legitimate, expected state, not something tailport flags or
collapses. Every public path still requires its own strong per-service
confirm before going live, and :22 stays hard-blocked from all three.
A tunnelled service shows its own cloudflare route sub-row with a distinct
marker (β / βοΈ) β see Status markers above β with the
exact public URL once known, alongside any other active routes for that same
service.
The tunnel toggle (t)
t behaves differently depending on the port's state:
- Already tunnelled β
ttears the tunnel down immediately. No confirm: reducing exposure is never gated. - Tunnelled earlier this SESSION, then torn down β tailport remembers
that port's mode (and, for a named tunnel, its hostname) in memory only,
for as long as the process runs. Pressing
tagain re-raises it, skipping the setup prompts entirely β straight to the same confirm. - Never tunnelled this session β
truns the full setup. If you're logged in to Cloudflare (a credential fromcloudflared tunnel loginis present), you're asked to pick quick or named; choosing named then asks for the hostname you've already routed and the tunnel's name. Without an account, only the quick path exists, so setup skips straight to its confirm.
Every path ends in a y/n confirm before anything goes live, and :22 (SSH)
is hard-blocked β the same funnel/publish-grade guardrails. The quick
tunnel's confirm is a deliberate exception to the "always name the exact
public URL" rule: cloudflared assigns the *.trycloudflare.com hostname only
after the tunnel actually starts, so there is no URL to name in advance. The
confirm instead names the local port; tailport flashes starting Cloudflare quick tunnel for :<port>β¦, and the real https://β¦ address appears in the
row's description a few seconds later, once the next poll picks it up. A
named tunnel's confirm has no such gap β it names the exact
https://<hostname> up front, the same as Publish.
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 β
spaceon the row, orCto 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 bind0.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
serveproxies to anyway:mailpit --listen 127.0.0.1:8025 # e.g. β bind 127.0.0.1, not 0.0.0.0This 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 on0.0.0.0:<port>, unbind the port first (space, orC) β note that once it's bound to0.0.0.0, it's already reachable on the tailnet on its own (stateon 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 runsgo build/vet/testonmacos-14, so the darwin-tagged tests ininternal/portscan(theparseLsoffixtures and the real-lsofList()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
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). |
|
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. |