Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Trustmux

A mobile companion for tmux / Byobu sessions. Run a lightweight daemon on your workstation; monitor and interact with your terminal sessions from your phone over your Tailscale network. No relay server — terminal data stays between your devices.

Works with plain tmux and with byobu. Byobu users get the live status bar chips; plain tmux users get everything else.

Two tiers:

  • Free — PWA. Install one icon per machine directly from the browser.
  • Paid — Native Flutter app with full ANSI colors, multi-machine management, and biometric auth.

Requirements

  • tmux (byobu optional but recommended)
  • Python 3.10+
  • Tailscale — only for the default start mode, which serves over your tailnet. start-direct (self-signed HTTPS, binds all interfaces — reachable from anywhere the host is) and start-local (loopback only, reached through an SSH tunnel) need no Tailscale at all

Install

Homebrew (macOS / Linux)

brew tap dustinkirkland/trustmux
brew install trustmux
trustmux enable    # configure tailscale serve + start daemon
trustmux pair      # generate pairing code; enter on phone

pip (PyPI)

pip install trustmux
trustmux enable
trustmux pair

Debian / Ubuntu (.deb)

Trustmux is bundled with byobu — installing byobu brings trustmux along:

sudo apt install byobu
trustmux enable
trustmux pair

Or with the PPA for the latest release:

sudo add-apt-repository ppa:dustinkirkland/byobu
sudo apt install byobu
trustmux enable
trustmux pair

Daily use

trustmux start      # start daemon
trustmux stop       # stop daemon
trustmux restart    # restart daemon
trustmux status     # show URL and running status
trustmux log        # tail the daemon log

trustmux pair           # generate a pairing code for a new device
trustmux unpair         # list paired devices and remove them

A different port

The daemon listens on 7432 by default. --port (or $TRUSTMUX_PORT) changes it for setup, start, start-local, start-direct, stop, restart, status and enable:

trustmux start --port 3389
trustmux status              # finds it — no need to repeat --port

stop, status and pair ask the running daemon which port it is on, so only the start command needs the flag. enable --port records it in the login hook.

An address this host cannot see

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 start-direct certificate describe a host nothing outside can route to. --advertise says what a phone should use instead:

trustmux start-direct --advertise 203.0.113.45
trustmux start-direct --advertise https://tmux.example.com/   # proxy on :443

A bare host takes the daemon's own scheme and port; a full URL overrides both, which is how a reverse proxy terminating TLS on 443 is expressed. Repeat the flag for more than one, and the first supplies the printed URL.

Every advertised address also goes into the self-signed certificate. It has to: a browser rejects a certificate that omits the name in the URL bar outright, rather than offering the click-through a self-signed one gets.

--advertise changes only what is published, not what the daemon binds — you still need a firewall rule admitting the port, which does put the daemon on the public internet behind just the pairing code and session token.

An address that changes

A cloud instance with an ephemeral public address gets a different one across a stop/start cycle, so a literal written down once goes quietly stale. cmd: names a program to run instead, re-run on every daemon start, one address per line of its output:

cat > /usr/local/bin/external-ip <<'EOF'
#!/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
EOF
chmod +x /usr/local/bin/external-ip

trustmux start-direct --advertise cmd:/usr/local/bin/external-ip

The program is split into arguments with shell-style quoting but is not run by a shell — no pipelines, no $(...); those characters are rejected rather than passed through, so put a pipeline in a script and name the script. Note --fail above: without it curl prints an error page and exits 0.

Resolution is strict and any failure stops the daemon: a non-zero exit, no output, a blank line, a value that is not an address, or more than 5 seconds across all sources. A shell wrapper whose curl fails typically prints an empty line and still exits 0, and reading 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.

To keep a source across reboots put it in the instance's config file, which is also the only place a login hook will find it:

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

--advertise then $TRUSTMUX_ADVERTISE then that file, and whichever is set replaces the others outright rather than adding to them — otherwise a repeatable flag could never drop a name the file still lists. --no-advertise advertises nothing despite a configured source.

Several daemons at once

--name NAME (or $TRUSTMUX_INSTANCE) gives a daemon its own pid file, admin socket, log, session tokens and TLS certificate, so more than one can run side by side — on different ports, or on the same port at different addresses:

trustmux start-direct --name work --port 3389
trustmux pair  --name work
trustmux list
trustmux stop  --name work
trustmux rm    --name work    # delete its state directory for good

An instance is created by its first start and otherwise lasts forever, so rm is how you get rid of one — it deletes the state directory (session tokens and TLS keypair included) and removes that instance's login hook, so it does not come back at the next login. It refuses while the daemon is running, and refuses on default; --force overrides both, stopping the daemon first.

The unnamed instance is called default; it is not special-cased, and lives under instances/default/ like any other.

Only default can use tailscale serve mode, because serve publishes on the tailnet's port 443 and only one daemon can own it — a second would silently take over the mapping. Named instances use start-direct or start-local; setup, start, restart and enable refuse them with a message saying so. Note --port does not lift this: it moves the loopback backend that tailscale serve proxies to, not the tailnet-facing port.


Setup from source

cd mobile/
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python trustmux.in --help

trustmux.in and trustmuxd.in find the package in the sibling trustmux/ directory, so no install step is needed. To run a throwaway daemon that leaves your real one untouched, point both base directories at a scratch tree:

export TRUSTMUX_CONFIG_DIR=$(mktemp -d)
export TRUSTMUX_STATE_DIR=$TRUSTMUX_CONFIG_DIR/state
.venv/bin/python trustmux.in start-local --port 3389

start, stop, status and list only ever act on a daemon the instance itself started, which they establish from that instance's own admin socket and pid file — never by asking the system who holds a port. So a scratch tree is isolated even if it shares a port with your real daemon; --port above just avoids the two fighting over the bind.


Files

Trustmux follows the XDG base directory spec, with one subdirectory per instance. <I> below is the --name name, or default.

Path Purpose
$XDG_CONFIG_HOME/trustmux/machines.json Optional: sibling machines for the machine selector. Shared by all instances
$XDG_CONFIG_HOME/trustmux/instances/<I>.json Optional: per-instance settings, hand-written. {"advertise": [...]}. Refused if group- or world-writable, since a source can name a program to run
$XDG_STATE_HOME/trustmux/instances/<I>/tokens.json Paired device session tokens (mode 0600)
$XDG_STATE_HOME/trustmux/instances/<I>/cert.pem, key.pem Self-signed TLS keypair for start-direct
$XDG_STATE_HOME/trustmux/instances/<I>/trustmux.log Daemon log (mode 0600)
$XDG_STATE_HOME/trustmux/instances/<I>/trustmux.sock Admin Unix socket (mode 0600)
$XDG_STATE_HOME/trustmux/instances/<I>/trustmux.pid PID file — <pid> <port>

Defaults are ~/.config and ~/.local/state. Config holds only the files you write by hand; everything the daemon owns lives together under state, as it always has — just no longer mixed in with configuration. An advertise source is config rather than state because it records intent, not a result: it is re-resolved on every start, which is what makes writing one down safe even when the address behind it changes.

The socket and pid file stay here rather than in $XDG_RUNTIME_DIR, where the spec would put them. systemd-logind deletes /run/user/$UID when your last login session ends unless loginctl enable-linger is set, which would strand a still-running daemon with no socket to reach it by — and being started and then reached later is the whole point of trustmux. That directory also doesn't exist on macOS or in most containers. Leftovers are detected instead of swept away: once a daemon is gone its socket refuses connections, which is how a stale one is told from a live one. A daemon that still accepts connections but has stopped replying is still running, so the pid file records <pid> <port> — enough to stop a hung daemon without having to ask the system who holds a port. Asking that question is what an earlier version did, via lsof; it answered for the whole machine, so it could not tell one instance's daemon from another, went blind across network namespaces, and needed a binary that isn't always installed and doesn't always support -ti:<port>.

TRUSTMUX_CONFIG_DIR and TRUSTMUX_STATE_DIR override each base, taking precedence over the XDG variables.

Multiple machines

[
  { "name": "work",     "url": "https://work-machine.tail1234.ts.net" },
  { "name": "personal", "url": "https://personal.tail1234.ts.net" }
]

Upgrading: earlier versions kept everything directly in ~/.config/trustmux. On first run tokens.json, cert.pem, key.pem and trustmux.log are moved into the default instance's state directory with their modes preserved. A stale trustmux.pid/trustmux.sock is left alone, in case a daemon predating the upgrade is still serving on it.


Security

  • In the default mode the daemon binds to 127.0.0.1 only — not reachable from the network
  • All traffic encrypted by Tailscale WireGuard; HTTPS via tailscale serve
  • No relay server — terminal data never leaves your Tailscale mesh
  • Pairing codes: 6-digit, 60-second TTL, single-use, 3 wrong guesses per address and 9 in total before the code is void; cross-site browser requests to the pairing endpoint are refused
  • Session tokens: 256-bit random, stored at mode 0600
  • stop removes the tailscale serve mapping it created. In start mode the daemon is plain HTTP on 127.0.0.1, and a mapping left pointing at that port while nothing listens would let another local user bind it and receive your phone's session cookie. stop --keep-serve keeps it anyway; status warns while a mapping points at nothing. Between boot and your first login the mapping exists and the daemon does not, so on a host shared with people you do not trust prefer start-local or a systemd user unit that starts it at boot.
  • The start-direct self-signed keypair is kept across restarts; status prints its SHA-256 fingerprint so a browser's certificate warning can be checked against it rather than clicked through

Tests

cd mobile/
python3 -m unittest discover -s tests -t .

Needs tornado and cryptography (pip install -r requirements.txt). The suite points TRUSTMUX_CONFIG_DIR/TRUSTMUX_STATE_DIR at a temporary tree, so it never reads or writes your real trustmux state.


Troubleshooting

502 Bad Gateway — tailscale serve is running but daemon isn't: trustmux start

"Serve not enabled" — visit the URL printed by tailscale serve --bg 7432

Phone can't reach URL — ensure Tailscale is active on the phone

Need to re-pair — run trustmux pair and enter the new code on the device

Release files for trustmux 7.19rc13

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for trustmux 7.19rc13
File Size Uploaded
trustmux-7.19rc13.tar.gz 157.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for trustmux 7.19rc13
File Interpreter ABI Platform
trustmux-7.19rc13-py3-none-any.whl Python 3 none any Details

Total release size: 273.0 kB

Release files / trustmux-7.19rc13.tar.gz

Download URL trustmux-7.19rc13.tar.gz
Size 157.6 kB
Tags Source
SHA-256 checksum
How to use checksums
51e7335cc4b530689a937680b251238bacc926ed7cb755368cecba5560011834
BLAKE2b-256 checksum
How to use checksums
4e23241fd918d8bbd2fd2445bc661407bc03106d7c5c13e6226f60f87b3e534b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release files / trustmux-7.19rc13-py3-none-any.whl

Download URL trustmux-7.19rc13-py3-none-any.whl
Size 115.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
154085858e0bb3387588f593dadb507c38a5667b425f8f9eaa27de408ce27aaa
BLAKE2b-256 checksum
How to use checksums
f5e763a5022b3a931761060901de10aa06960d8422278c07f0790688339e3b20
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release history Release notifications | RSS feed

7.19

2 release files

This release

7.19rc13 This release

2 release files

7.18

2 release files

7.17

2 release files

7.15

2 release files

7.14

2 release files

7.12

2 release files

7.11

2 release files

7.10

2 release files

7.9

2 release files

7.8

2 release files

7.7

2 release files

7.6

2 release files

7.5

2 release files

7.4

2 release files

7.3

2 release files

7.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page