muxboard is a Flask blueprint that puts a web dashboard over tmux ls / new-session / kill-session / attach for one host or a fleet of hosts, with a live in-browser terminal backed by xterm.js. The single-host case is the trivial n = 1 instance of the same inventory model the fleet case uses - there is no separate code path for "just my laptop."
It exists because the alternative - SSH into each box, remember which tmux socket belongs to which service account, attach by hand - does not scale past about two machines, and because a running agent or build session is far easier to babysit from a browser tab than from a fan-out of terminals.
Read the threat model below before you deploy this. muxboard hands out authenticated remote-shell access over the web. A misconfigured gate is a root shell for a stranger. The defaults are built to fail closed, but the security of your deployment is a property of your configuration, not of this README.
- A dashboard at
/<prefix>/listing every configured host, each host's managed tmux users, and each user's sessions (window count, created time, last activity, attached flag). - Create a session (optionally with a startup command), kill a session (behind a type-the-name confirm gate), and attach a live terminal in a new tab.
- A background sweep that refreshes the inventory every 60 seconds so the dashboard reads from a cache and never blocks on SSH.
- Per-principal and global caps on concurrent attaches, so one account cannot exhaust file descriptors, PIDs, or RAM.
pip install muxboard # not yet on PyPI; for now:
pip install "muxboard @ git+https://github.com/JacobStephens2/muxboard"You also need, on the machine muxboard runs on: an SSH client (for remote hosts), sshpass (only if you use password auth), and tmux on every managed host.
The n = 1 case: manage tmux on the same box the app runs on.
import os
from flask import Flask
from muxboard import Host, Muxboard, token_auth
board = Muxboard(
hosts=[Host(key="local", hostname="localhost",
tmux_users=(os.environ["USER"],), local=True)],
authorize=token_auth(os.environ["MUXBOARD_TOKEN"]),
allowed_origins=["https://ops.example.com"],
)
app = Flask(__name__)
board.init_app(app, url_prefix="/mux")
board.start()export MUXBOARD_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
gunicorn -k gevent -w 1 -b 127.0.0.1:8000 app:appflask-sock needs a worker that can hold a WebSocket open for the lifetime of an attach. Use a single gevent (or eventlet) worker, not the default sync worker, and put it behind a TLS-terminating reverse proxy. See examples/single_host.py and examples/fleet.py.
More than one Host. Two transports are shown - an SSH key and a password from an environment variable:
hosts = [
Host(key="web1", hostname="web1.internal", ssh_user="ops",
password_env="WEB1_SSH_PASS", tmux_users=("ops", "deploy")),
Host(key="db1", hostname="db1.internal", ssh_user="ops",
ssh_key="/home/muxboard/.ssh/id_ed25519", tmux_users=("ops",)),
]muxboard reaches each host as one SSH login user (ssh_user, or the local process user when local=True). For that user's own tmux socket, it runs tmux directly. To read another user's socket on the same host - say deploy when you logged in as ops - it runs:
sudo -n -u deploy tmux ls ...
So every tmux user other than the login user needs a NOPASSWD sudo rule granting the login user the ability to run commands as them. A minimal /etc/sudoers.d/muxboard on a managed host might be:
ops ALL=(deploy) NOPASSWD: /usr/bin/tmux
If sudo is refused for a user, muxboard shows that user's row with a "sudo refused" badge rather than a deceptively empty session list. That distinction is deliberate: empty and forbidden are not the same fact.
This is the section that matters. muxboard's whole job is to turn an HTTP request into a process running on a host. Treat it with the seriousness you would treat sshd.
A principal who passes your authorize gate can run arbitrary commands as any of the tmux_users you configured for a host - by creating a session with a startup command, or by attaching to a session and typing. There is no "read-only" mode. If tmux_users includes root or a sudo-capable account, a passing principal has root. Configure tmux_users as the least-privileged set that does the job.
authorize is required in spirit and defaulted to deny_all in fact. An authorize callable receives the Flask request and returns either a Principal (allow) or None (deny). It runs on every HTTP route and on the WebSocket handshake - there is no route that skips it. Three gates ship in the box:
| Gate | When it is appropriate | What it is not |
|---|---|---|
deny_all |
The default. A board you have not finished configuring is inert. | Not a real gate. |
token_auth(secret) |
A single operator or a small trusted team, behind TLS. Constant-time compared; the secret is a bearer credential - treat it like a password, rotate it, never put it in a query string you would not put a password in. | Not per-user. Anyone with the token is every principal. |
allow_all() |
Only when a layer in front of muxboard already authenticated the caller - an SSO reverse proxy, mTLS, or a strict 127.0.0.1 bind. It logs a warning on every construction. |
Never safe facing the open internet. |
For anything multi-user, write your own authorize that reads your existing session or SSO and returns a Principal whose allowed_users scopes which tmux users that person may touch. examples/fleet.py shows the pattern. A scoped principal's dashboard, JSON API, attach page, and WebSocket are all filtered to their allowed_users; an out-of-scope user returns 403, not 404, because hiding the existence of the user buys nothing once you are authenticated.
- Command injection. Every session name and startup command coming from a client is passed through
shlex.quote, and no command on the muxboard side ever runs through a shell (shell=Trueis never used). Session-name creation is further restricted to[A-Za-z0-9_-]{1,64}. Attach and kill operate on existing names, which tmux itself constrains. - Cross-site WebSocket hijacking. Set
allowed_originsand the WebSocket handshake rejects any browserOriginnot on the list. If you leave it unset the check is disabled and muxboard logs a warning - do not ship to production that way. Non-browser clients (which omitOrigin) are allowed through, which is fine for ops tooling but means the Origin check is a defense for browser victims, not an authentication mechanism. - Accidental destructive POSTs. A kill requires the client to echo the exact session name in a
confirmfield, so a stray same-site POST (a future XSS, a fat-fingered curl, a malicious extension) cannot silently kill a session. - Resource exhaustion. Concurrent attaches are capped per principal (default 5) and globally (default 30). Each attach has a 6-hour hard lifetime and a 4 MiB output-queue ceiling, after which the bridge tears down the whole SSH/tmux process group - no leaked fds, no zombies.
- The authorize callable itself throwing. If your
authorizeraises, muxboard logs it and denies. Failure is closed.
- TLS. muxboard speaks plain WSGI/WebSocket. Terminate TLS in front of it. Over plain HTTP, a
token_authsecret and every keystroke are on the wire in cleartext. - Rate limiting / brute-force protection on the token.
token_authis a constant-time compare, but it does not lock out after N failures. Put a rate limiter in your proxy if the board is internet-facing. - Audit storage. muxboard emits
kill,create,attach.start, andattach.endevents to an optionalauditcallback with the principal name attached. It does not persist them - wire the callback to your logging. - Securing the SSH keys and passwords. A
password_envsecret lives in the process environment; anssh_keylives on disk. Both are as exposed as the muxboard process. Run it as a dedicated, unprivileged service user.
The attach page loads xterm.js and its fit addon. By default it pulls pinned versions (@xterm/xterm@5.5.0, @xterm/addon-fit@0.10.0) from jsDelivr. That is a third-party script running on a page that grants shell access - a real supply-chain surface. Two ways to close it, in increasing order of paranoia:
- Add Subresource Integrity. Pass your own
xterm_js_url/xterm_css_url/xterm_fit_urlpointing at URLs you have pinned with SRI hashes in your own template, or front the CDN with a CSP that pins hashes. - Self-host. Copy the three assets into your own static directory and point the
*_urlkwargs at them. Then no external origin is in the trust path at all.
I have not shipped SRI hashes baked into the template because a wrong hash silently breaks the terminal and a right-but-stale hash rots on the next xterm release; pushing that decision to the deployer who controls their own CSP seemed more honest than pretending the default is hardened. This is the part of the threat model I am least settled on - if you have a cleaner default, open an issue.
If you run muxboard under systemd with PrivateTmp=true and use a local=True host, the service gets its own /tmp namespace - and the tmux sockets created by ordinary login shells in the real /tmp are invisible to it, so the dashboard shows no sessions even though tmux ls in a normal shell lists them. Two fixes: set PrivateTmp=false for the unit, or configure the "local" host as a remote host pointed at localhost over an SSH key, which forks a fresh login session in the real /tmp namespace. The original system muxboard was extracted from used the SSH-to-localhost trick for exactly this reason.
- Is a bearer token plus TLS the right default gate, or should the shipped default refuse to start without an explicit
authorize? Right nowdeny_allmakes an unconfigured board inert, which is safe but silently useless. - Should the Origin check fail closed (reject
Origin-less clients) whenallowed_originsis set, at the cost of breaking curl-based ops scripts? The current choice favors tooling over browser-victim defense-in-depth, and I am not certain that is the right trade for every deployment.
pip install -e ".[dev]"
ruff check src tests
pytest -qThe tests cover inventory validation, the auth gates, argv construction and tmux ls parsing (no SSH or tmux required), and the blueprint's deny/allow/scope/confirm behavior through a Flask test client.
MIT. See LICENSE.