TRUSTMUX(1)

πŸ“¦
version 7.20 Β· 2 versions
, in ubuntu stonking

πŸ’– 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 configures tailscale serve for HTTPS on the daemon port (7432 by default; peep --port).
  • start β€” Starts the daemon in HTTPS mode via tailscale serve. This is the default and, lowkey, the recommended one.
  • start-local β€” Starts the daemon bound to 127.0.0.1 only, 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 β€” Sends SIGTERM to the running daemon. The tailscale serve configuration 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 in tailscale serve mode; 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. If qrencode(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 either pair accepted from IP (exit status 0) or no client paired (exit status 1).
  • 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 by setup, start, start-local, start-direct, stop, restart, status and enable.

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 by start, start-local, start-direct, restart and enable. 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:

  • HOST or HOST:PORT β€” A literal address or name. The daemon's own scheme and port fill in whatever's omitted, so --advertise 203.0.113.45 on a daemon serving HTTPS on 7432 advertises https://203.0.113.45:7432/.
  • URL β€” A full http:// or https:// URL, which overrides both scheme and port β€” for a reverse proxy terminating TLS on 443 in front of the daemon. A path, query or fragment is rejected: trustmux serves from the root, and the pairing code is carried in the fragment. 🚫
  • cmd:PROGRAM β€” Runs PROGRAM and 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 in TRUSTMUX_ADVERTISE or the instance's configuration file. Accepted wherever --advertise is. πŸ™ˆ
  • --name NAME β€” Act on the named instance instead of default. 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 by rm. Removes the instance even though its daemon is running (it is stopped first), or even though it is the default instance. πŸ’₯

🌸 ENVIRONMENT 🌸

  • TRUSTMUX_PORT β€” Default port when --port is 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 --name is not given. An invalid name is an error, not a silent fallback.
  • TRUSTMUX_ADVERTISE β€” Default advertise source when --advertise is not given. Holds a single source, not a list: no separator would be safe, since : collides with both URL schemes and the cmd: prefix while , can appear inside a command's arguments. A cmd: source already yields as many addresses as it likes, so nothing is out of reach. πŸ¦‹
  • TRUSTMUX_CONFIG_DIR
  • TRUSTMUX_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, status and list act 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 --advertise source.

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 (mode 0600).
  • $XDG_STATE_HOME/trustmux/instances/INSTANCE/cert.pem
  • $XDG_STATE_HOME/trustmux/instances/INSTANCE/key.pem β€” the self-signed TLS keypair used by start-direct.
  • $XDG_STATE_HOME/trustmux/instances/INSTANCE/trustmux.log β€” the daemon log file, which is exactly what trustmux log tails.
  • $XDG_STATE_HOME/trustmux/instances/INSTANCE/trustmux.sock β€” the admin Unix socket (mode 0600), used by pair, unpair, and by stop and status to ask the daemon which port it's on.
  • $XDG_STATE_HOME/trustmux/instances/INSTANCE/http.sock β€” the HTTP listener in start mode (mode 0600), which tailscale serve proxies 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 port stop was 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. So stop removes the mapping it created (--keep-serve leaves it), and status warns 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 πŸ‘€


🫢 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