TRUSTMUX(1)
π trustmux(1) π
okay bestie, gather round πΈ this is the manual for trustmux, straight outta section 1 (User Commands), and I'm gonna walk you through every single line because you deserve to know your tools. No skimming, no skipping, we're eating the whole plate. πβ¨
β¨ NAME β¨
trustmux β manage the Trustmux daemon π
π SYNOPSIS π
trustmux COMMAND [options]
π¦ DESCRIPTION π¦
trustmux is the single command-line interface for the Trustmux daemon β a lightweight little server that lets you monitor and interact with your tmux/Byobu sessions from a phone browser, over your Tailscale network. no cap, it's genuinely iconic π±β¨
Every operation lives as a subcommand of trustmux: initial setup, starting and stopping the daemon, login-hook management, and device pairing. That's the whole vibe.
π COMMANDS π
π Daemon lifecycle
setupβ One-time setup, bestie. It verifies Tailscale is installed and connected, then configurestailscale servefor HTTPS on the daemon port (7432by default; peep--port).startβ Starts the daemon in HTTPS mode viatailscale serve. This is the default and, lowkey, the recommended one.start-localβ Starts the daemon bound to127.0.0.1only, for access via an SSH port-forward. No TLS certificate gets generated here; the tunnel itself is what provides the encryption. πstart-directβ Starts the daemon with a self-signed TLS certificate, binding on all interfaces for LAN/direct access with no Tailscale in the picture.stopβ SendsSIGTERMto the running daemon. Thetailscale serveconfiguration is left in place, untouched, periodt.restartβ Stop, then start the daemon in HTTPS mode. the classic one-two.statusβ Shows whether the daemon is running, plus the URL to connect to. πlogβ Tails this instance's daemon log (see FILES).listβ Lists every instance that has a state directory, along with the port, scheme and pid of any that are running. Instances whose daemon is stopped get shown with dashes.rmβ Deletes this instance's state directory β session tokens, TLS keypair, log, socket and pid file β and removes its login hook so it doesn't start again at the next login.
An instance is created by its first start and then persists until it's removed, so this is genuinely the only way to retire one. It gets refused while the instance's daemon is running, and refused for the default instance. --force overrides both, stopping a running daemon first. π
π§ Login hook
enableβ Runs first-time setup if needed, starts the daemon, and installs a hook in the user's shell login file (~/.profile,~/.bash_profile, or~/.zprofile) so the daemon starts automatically on each login. This one is only available for the default instance, since it starts the daemon intailscale servemode; see--name.disableβ Stops the running daemon and removes the login hook so the daemon no longer starts automatically. Only this instance's hook gets removed, so disabling one leaves any others exactly where they were. Paired device tokens are preserved. π«Ά
π Device pairing
pairβ Asks the running daemon to generate a one-time 6-digit pairing code (valid for 60 seconds) and displays it along with the URL your phone should open. Ifqrencode(1)is installed, a QR code gets printed for one-scan pairing. β¨ The command then waits for the code to be used and returns as soon as a device pairs, or when the code expires β whichever comes first. It clears the screen and prints eitherpair accepted from IP(exit status0) orno client paired(exit status1).unpairβ Lists all currently paired devices (IP address, browser label, and pairing time) and offers an interactive menu to remove one or all of them.
π OPTIONS π
setup --quietβ Suppresses the "Next steps" output after a successful setup. π€«--port PORTβ The TCP port the daemon listens on (default:7432). Accepted bysetup,start,start-local,start-direct,stop,restart,statusandenable.
Only the port changes β each mode binds the same address it always has. stop and status don't normally need the flag: they ask the running daemon over the admin socket which port it's on, so a daemon started on a non-default port is still found without you repeating --port. That same answer is what gives pair the correct URL and QR code. π―
enable records the port in the login hook it writes, so a non-default port survives logout; re-running enable --port rewrites an existing hook rather than leaving the old port sitting in place.
--advertise SOURCEβ The address a phone should use, for when it's not the one this host can see. Accepted bystart,start-local,start-direct,restartandenable. Repeatable. π
Behind NAT β a cloud instance, most often β the daemon only ever sees an internal address, so both the URL pair prints and the names in the certificate start-direct generates describe a host that nothing outside can route to. SOURCE overrides that. It does not change what the daemon binds; combine it with a firewall rule that admits the port. π₯
SOURCE is one of:
HOSTorHOST:PORTβ A literal address or name. The daemon's own scheme and port fill in whatever's omitted, so--advertise 203.0.113.45on a daemon serving HTTPS on7432advertiseshttps://203.0.113.45:7432/.URLβ A fullhttp://orhttps://URL, which overrides both scheme and port β for a reverse proxy terminating TLS on443in front of the daemon. A path, query or fragment is rejected:trustmuxserves from the root, and the pairing code is carried in the fragment. π«cmd:PROGRAMβ RunsPROGRAMand takes one address per line of its standard output.
cmd: is how an address that changes stays correct. A cloud instance with an ephemeral public address gets a different one across a stop/start cycle, so a literal written down once would quietly go stale β while cmd: is re-run on every daemon start. π
PROGRAM is split into arguments with shell-style quoting but is not run by a shell: no pipelines, no redirection, no $(...). Those characters are rejected rather than passed through to the program, so put a pipeline in a script and name the script. An absolute path is safest, a daemon's PATH being whatever started it. π
Resolution is strict, and any failure stops the daemon rather than falling back to the address it would have guessed: a non-zero exit, no output, a blank line, a value that is not an address, or more than 5 seconds across all sources. This is deliberate. A shell wrapper whose curl fails typically prints an empty line and still exits 0, and treating that as "nothing to advertise" would hand out an unreachable URL and a certificate for the wrong host β neither of which a warning would fix. π
Every resolved address is added to the certificate as a subject alternative name; the first supplies the URL that pair, status and start print. A browser rejects a certificate that omits the name in the URL bar outright, rather than offering the click-through a self-signed certificate gets, so advertising an address without certifying it would be worse than useless. π
--no-advertiseβ Advertise nothing, ignoring any source inTRUSTMUX_ADVERTISEor the instance's configuration file. Accepted wherever--advertiseis. π--name NAMEβ Act on the named instance instead ofdefault. Accepted by every subcommand. Each instance keeps its own pid file, admin socket, log, session tokens and TLS certificate, so several daemons can run at once β on different ports, or on the same port at different bind addresses. π
NAME must match [A-Za-z0-9][A-Za-z0-9._-]{0,31}: it becomes a directory name and is written into the login hook, so separators, leading dots and shell metacharacters are rejected rather than sanitised. π¨
Only the default instance can use tailscale serve mode. serve publishes on the tailnet's port 443, which just one daemon can own β a second would silently take over the mapping and leave the first running but unreachable. Named instances therefore use start-direct or start-local, and setup, start, restart and enable refuse them with a message pointing at those modes. Note that --port does not lift this restriction: it moves only the loopback backend that tailscale serve proxies to, not the tailnet-facing port. π
--forceβ Accepted byrm. Removes the instance even though its daemon is running (it is stopped first), or even though it is thedefaultinstance. π₯
πΈ ENVIRONMENT πΈ
TRUSTMUX_PORTβ Default port when--portis not given, for users who always want a different port. An out-of-range or non-numeric value is an error, not a silent fallback. β οΈTRUSTMUX_INSTANCEβ Default instance when--nameis not given. An invalid name is an error, not a silent fallback.TRUSTMUX_ADVERTISEβ Default advertise source when--advertiseis not given. Holds a single source, not a list: no separator would be safe, since:collides with both URL schemes and thecmd:prefix while,can appear inside a command's arguments. Acmd:source already yields as many addresses as it likes, so nothing is out of reach. π¦TRUSTMUX_CONFIG_DIRTRUSTMUX_STATE_DIRβ Override the configuration and state base directories individually. Each takes precedence over the corresponding XDG variable. Pointing both at a scratch directory is the supported way to run a throwaway daemon β from a development tree, for instance β without touching the real one.start,stop,statusandlistact only on a daemon that this instance itself started, determined from its own admin socket and pid file rather than from who holds a port, so the isolation holds even when the scratch daemon shares a port with the real one. π§ͺβ¨
πΎ FILES πΎ
Ok bestie, so all the files live in the XDG base directories, and there's one subdirectory per instance β cute, organized, iconic. $XDG_CONFIG_HOME (default ~/.config) holds the one single file you write by hand, and $XDG_STATE_HOME (default ~/.local/state) holds everything the daemon owns. It's giving "you touch your stuff, the daemon touches its stuff." Periodt.
And just so we're clear: everywhere below, INSTANCE means the --name name, or default when it's unset. π
π Configuration
$XDG_CONFIG_HOME/trustmux/machines.jsonβ Optional! It's the sibling machine list for the in-app machine selector. Every instance shares this one.$XDG_CONFIG_HOME/trustmux/instances/INSTANCE.jsonβ Optional per-instance settings, hand-written by you. There's literally one key so far:advertise, which is a string or a list of strings, and each one is a--advertisesource.
Like so:
{"advertise": ["cmd:/usr/local/bin/external-ip", "tmux.example.com"]}
This file is the durable home for a source, and ngl it's the only one a login hook will ever find β because --advertise applies to one daemon and then it is gone. Sources get re-resolved on every single start, which is literally what makes writing one down safe even when the address behind it changes. β¨
Heads up though: because a source can name a program to run, a file that's writable by group or other gets refused rather than read. Keep it mode 0600, bestie. π
And why is this Configuration and not State? Because it records intent, not a result. Vibe check passed.
πΈ State
$XDG_STATE_HOME/trustmux/instances/INSTANCE/tokens.jsonβ Paired device session tokens (mode0600).$XDG_STATE_HOME/trustmux/instances/INSTANCE/cert.pem$XDG_STATE_HOME/trustmux/instances/INSTANCE/key.pemβ the self-signed TLS keypair used bystart-direct.$XDG_STATE_HOME/trustmux/instances/INSTANCE/trustmux.logβ the daemon log file, which is exactly whattrustmux logtails.$XDG_STATE_HOME/trustmux/instances/INSTANCE/trustmux.sockβ the admin Unix socket (mode0600), used bypair,unpair, and bystopandstatusto ask the daemon which port it's on.$XDG_STATE_HOME/trustmux/instances/INSTANCE/http.sockβ the HTTP listener in start mode (mode0600), whichtailscale serveproxies the tailnet name to. Removed when the daemon exits. π§Ή$XDG_STATE_HOME/trustmux/instances/INSTANCE/trustmux.pidβ PID file written at daemon startup, holding"pid port". The port is recorded so that a daemon which has stopped replying on its admin socket can still be matched against the portstopwas asked about. So smart. π
Now, why are the socket and pid file deliberately here instead of under $XDG_RUNTIME_DIR, where the spec would put them? Because systemd-logind deletes /run/user/$UID when a user's last login session ends β unless you've got loginctl enable-linger set β and that would strand a still-running daemon with no socket to reach it by. And trustmux exists precisely to be started and then reached later. Also that directory doesn't even exist on macOS or in most containers, so. π
So a socket or pid file left behind by a killed daemon is detected rather than swept away: the socket refuses connections once its daemon is gone, and a recorded pid is only believed when it's confirmed to hold the port in question. Never trust a ghost pid, fr fr.
π¦ Migration
Earlier versions kept everything directly in ~/.config/trustmux. On first run, tokens.json, cert.pem, key.pem and trustmux.log get moved from there into the default instance's state directory, preserving their modes. And any stale trustmux.pid and trustmux.sock are deliberately left in place β because a daemon predating the upgrade may still be serving on that socket. Don't evict a working daemon, bestie. π
β¨ EXAMPLES β¨
Typical first-time setup, the little ritual:
trustmux setup
trustmux enable
trustmux pair
Daily use, the lowkey rotation:
trustmux status
trustmux restart
trustmux log
Run it on a different port β and later commands find it without the flag, no cap:
trustmux start-direct --port 3389
trustmux status
trustmux pair
trustmux stop
A second daemon alongside the first, with its own tokens and log (polyamory but for daemons π):
trustmux start-direct --name work --port 3389
trustmux pair --name work
trustmux list
trustmux stop --name work
A throwaway daemon that touches none of the above β nothing, zero, untouched:
export TRUSTMUX_CONFIG_DIR=$(mktemp -d)
export TRUSTMUX_STATE_DIR=$TRUSTMUX_CONFIG_DIR/state
trustmux start-local --port 3389
βοΈ The cloud instance one
Ok so imagine a cloud instance whose public address the host itself cannot see. You write a program that prints it β here it's a one-liner against the metadata service, which unlike a shell pipeline reports its own failure. Iconic behaviour.
$ cat /usr/local/bin/external-ip
#!/bin/sh
exec curl --fail --silent -H Metadata-Flavor:Google \
http://metadata.google.internal/computeMetadata/v1/instance/\
network-interfaces/0/access-configs/0/external-ip
$ trustmux start-direct --advertise cmd:/usr/local/bin/external-ip
$ trustmux pair
To keep it across reboots, put the source in the instance's configuration file (see FILES) rather than in the command line β and then the address is looked up afresh each time. Slay.
mkdir -p ~/.config/trustmux/instances
echo '{"advertise": ["cmd:/usr/local/bin/external-ip"]}' \
> ~/.config/trustmux/instances/default.json
chmod 600 ~/.config/trustmux/instances/default.json
trustmux start-direct
π SECURITY π
π’ Pairing code
The one-time pairing code is a 6-digit decimal value drawn from secrets.randbelow(1,000,000), giving 1,000,000 possible values. The code is valid for 60 seconds. After three incorrect guesses from one address, or nine in total, the code is invalidated and a new one must be generated with trustmux pair.
Counting per address keeps one peer β or one web page open in some browser on the tailnet β from spending the real phone's guesses. The total still makes brute force infeasible, at under a 0.001% chance of success per pairing window, and each new window requires the machine owner to run trustmux pair again. An incorrect guess also incurs a 0.5-second artificial delay. Slow them down, bestie. π
The pairing endpoint refuses requests a browser labels as coming from another site, and accepts only JSON bodies β so a page on another origin cannot submit guesses through a visitor's browser. Not on our watch. π
π« Session tokens
After a successful pairing, the daemon issues a 256-bit URL-safe session token (secrets.token_urlsafe(32)) stored in this instance's tokens.json (mode 0600; see FILES) and delivered to the browser as an httponly, SameSite=Strict cookie. Tokens are validated with a constant-time comparison to prevent timing attacks. Sessions expire after 90 days of inactivity and can be revoked at any time with trustmux unpair. Clean, secure, and honestly? A little bit iconic.
π Network exposure
In the default start mode the daemon binds only to the Tailscale IP (100.x.x.x), so the pairing endpoint is unreachable from the public internet or from other local-network devices. Bye! π¦
Behind tailscale serve, the daemon listens on a Unix socket in its state directory (http.sock, mode 0600 inside a 0700 directory) and the serve mapping carries the tailnet name to it; no TCP port is bound, so there's nothing another local user could bind in its place while the daemon is down. Love that for us.
When the installed tailscale cannot proxy to a Unix socket, the daemon falls back to plain HTTP on 127.0.0.1.
And on that fallback β listen up, this part matters β a mapping left in place while the daemon is not listening would forward the tailnet, and the phone's session cookie, to whichever local user binds the loopback port first. Sostopremoves the mapping it created (--keep-serveleaves it), andstatuswarns while a mapping points at nothing. π
In start-direct mode the daemon binds on all interfaces; the pairing-code and session-token protections above remain in force, but the network attack surface is broader. --advertise does not itself widen this β it changes only what is published β but advertising a public address is normally paired with a firewall rule admitting the port, and that does: the daemon then faces the internet with the pairing code and session token as its only protection, rather than a local network. π«£
Prefer start or start-local where either will do. In start-local mode the daemon binds to 127.0.0.1 only and is accessible solely through an explicit SSH port-forward. Safe, cozy, contained. π
π SEE ALSO π
trustmuxd(1)tailscale(1)tmux(1)byobu(1)
π«Ά AUTHOR π«Ά
Dustin Kirkland <kirkland@ubuntu.com>
And that's the whole page, bestie β this was trustmux, dated 2026-06-09, and you've officially read all of TRUSTMUX(1). Go forth and pair responsibly. πβ¨ππ¦
π¦ where this page comes from
- ubuntu stonking: byobu 7.20-0ubuntu1 Β· GPL-3.0-only
- π girlified by deepseek-ai/DeepSeek-V4.1-Flash on : a language model's rewrite of the original, under its terms