Documentation

Getting Started

Install the server, put it on your tailnet, run the setup wizard, scan the QR.

Overview

Muxr is two pieces that talk to each other over TLS-secured gRPC:

  • The Muxr app — a Frust-native client (Frust is a Rust UI framework) for Android and iOS that renders your sessions and sends input.
  • muxr-core (open-source MIT) — a Rust gRPC backend (muxrd) that relays over your terminal multiplexer's native IPC — zellij or herdr — plus the setup TUI (dashboard + wizard), muxrctl.

The server speaks the muxr.v1 protobuf contract and must be running alongside a zellij or herdr instance on the machine you want to control — at startup it auto-detects whichever backends are available. The app connects over your network to that server.

The shortest path from a bare machine to a terminal on your phone is four moves: install the server, put it on a tailnet, run the setup wizard, scan the QR. Each has its own section below, in that order.

Prerequisites

  • A terminal multiplexer — at least one of:
    • zellij 0.45.1 on your PATH for the zellij backend. The server validates the version at startup and refuses to start against a mismatched binary; set MUXRD_SKIP_VERSION_CHECK=1 to bypass this check in development.
    • herdr 0.9.0 (wire protocol 22) running for the herdr backend — a separate, unmodified, user-installed binary (Apache-2.0). muxrd discovers herdr's wire protocol version at runtime over the JSON control socket and echoes it back on the handshake, since herdr enforces strict protocol equality and rejects clients that are either older or newer than itself.
    muxrd auto-detects whichever are present and serves all available backends simultaneously; pass --backend / MUXRD_BACKEND to restrict to one.
  • Tailscale on the server and on your phone — the recommended way to reach muxrd (see Reach it over Tailscale).
  • Rust 1.95 (edition 2024) — required only if building from source.
  • Docker — optional; use the dev rig instead of building if you prefer (see Docker dev rig).

Install the server

The server is the open-source muxr-core project. The quickest install is the release script, which downloads a checksum-verified prebuilt suite for your platform (Linux x86_64/aarch64, macOS aarch64):

curl -fsSL https://raw.githubusercontent.com/f0x-it-llc/muxr-core/main/install.sh | bash

Pin a specific release with --version X.Y.Z, or override the install location with MUXR_INSTALL_DIR. Either way you get two binaries:

  • muxrd — the gRPC backend daemon.
  • muxrctl — the setup TUI (dashboard + wizard).

Prefer to build from source? Clone the repository:

git clone https://github.com/f0x-it-llc/muxr-core

Then compile it with a Rust 1.95+ toolchain, from the clone:

cargo build --release

Or use the Docker dev rig — it builds and runs both in a container without a local Rust toolchain.

Reach it over Tailscale — recommended

muxrd binds 127.0.0.1:50051 by default, which only accepts connections from the same machine. Something has to carry your phone to the server — in order of preference that's a tailnet (this section), your LAN, or, least recommended, a public domain — and a Tailscale tailnet is the one we recommend:

  • Every device in a tailnet can reach every other one by default — no port forwarding, no router configuration, nothing exposed publicly.
  • The traffic runs over a private, encrypted network between your own devices.
  • The address is stable — it stays with the machine until you remove it from the tailnet, so a paired phone keeps working.
  • It works from mobile data, not just from your own Wi-Fi, so the same pairing follows you out of the house.
  1. Install Tailscale on the server. On Linux:
    curl -fsSL https://tailscale.com/install.sh | sh
    On macOS, download it from tailscale.com/download.
  2. Sign the server into your tailnet. This prints a login URL — open it and authenticate:
    sudo tailscale up
  3. Install Tailscale on your phone from the App Store or Google Play (both are linked from tailscale.com/download), and sign it into the same tailnet as the server.
  4. Read the server's tailnet address. On the server:
    tailscale ip -4
    That 100.x.y.z address is what you use everywhere below — as the bind host in the wizard, as the certificate SAN, and as the host the pairing QR advertises.
MagicDNS name — optional

With MagicDNS enabled, the machine also answers to <machine>.<tailnet>.ts.net. If you would rather dial that name than the address, add it as an extra Subject Alternative Name so the certificate covers it — MUXRD_SAN takes a comma-separated list, and everything in it is folded into the certificate. Tailscale's tailscale cert can also issue a publicly trusted certificate for that name, which lets the app use ordinary CA trust instead of a pinned fingerprint.

Device keys expire after 180 days

By default a Tailscale device key expires after 180 days, and the machine drops off the tailnet until you sign it in again — which would cut your phone off from a server you are not sitting in front of. For an always-on server, disable key expiry for that machine in the Tailscale admin console. The rest of Tailscale's documentation lives at tailscale.com/kb.

Set up with muxrctl

muxrctl is the setup TUI. It takes no arguments — launch it on the server, over SSH if the machine is headless:

muxrctl

On a fresh data directory — no certificate and no tokens — the setup wizard opens by itself. If you have set things up before, or you closed the wizard and want it back, press w on the dashboard to reopen it.

The setup wizard

The wizard is six steps. A stepper at the top counts them (Step 1 of 6 … Step 6 of 6), a Back / Next row moves through them — the last step offers Done instead of Next — and the footer reminds you of the controls: Esc cancels · Tab moves · Enter presses.

  1. Welcome — it says what it is about to do and what it found on this machine: whether a certificate is present or absent, how many tokens exist, and whether the daemon is running or stopped. Nothing to fill in; Next moves on.
  2. Network — Bind host (placeholder 0.0.0.0), Port (placeholder 50051), and a picker of the reachable addresses found on this machine's interfaces. Your tailnet address is in that picker, because tailscaled assigns it to a local interface — pick it. The step explains the choice in three lines:
    • 127.0.0.1 — only this machine; a phone cannot reach it.
    • 0.0.0.0 — every interface; the QR advertises a real address for you.
    • A specific IP — only that interface; the QR advertises it. Port: 50051.

    0.0.0.0 listens on every interface — including a public one on an internet-facing host — so prefer the concrete tailnet or LAN address, and firewall port 50051 if you must use it.

    Binding the tailnet address is the recommended choice: muxrd then listens only on the tailnet, and the pairing QR advertises exactly that address. Next saves the bind address to muxrd's configuration.
  3. Certificate — shows what is being served, the Planned SANs a new certificate would claim (127.0.0.1, localhost, every reachable address, the bind host when it is a concrete one, and anything in MUXRD_SAN), and an Advertised trust control cycling Auto / CA / Pin. It resolves to one of two outcomes, spelled out under the control: tm=pin: the QR carries the SHA-256 fingerprint, or tm=ca: no fingerprint in the QR. If a certificate already exists, replacing it costs every paired phone, so the step makes you tick Regenerate anyway (every phone must re-pair) first. Next creates the certificate when there is none.
  4. Pairing token — three controls, each with a sentence under it. Name is optional: A label for the phone or person; a name is generated when empty. The Read-only checkbox: A read-only token can navigate tabs, panes and spaces, scroll and size its own view, but never types or changes the session. Expires cycles never / 30 minutes / 1 hour / 24 hours / 7 days: After the window the token can no longer pair a new device; a live session runs to its own TTL. Only Revoke disconnects a phone; ‘never’ lasts until revoked. Next mints the token.
  5. Daemon — if muxrd is stopped, the step reads Next starts muxrd with the bind address from the Network step.; if it is already up, Already running — Next goes on to pairing. Starting it here runs muxrd start --daemonize with the bind address and SANs you chose, so there is no separate command to remember.
  6. Pair your phone — the pairing QR, with the server it points at and the certificate it pins printed beneath it as Server: <host>:<port> Cert: <fingerprint>, and the instruction Scan it with the Muxr app to pair this phone. Scan it now: The secret is shown once and stored only as a hash. Scan the QR now, or copy it; you cannot retrieve it later. Done closes the wizard.

The host in the QR follows the bind address: a concrete bind host wins outright; with 0.0.0.0 the code advertises the first concrete MUXRD_SAN entry, and failing that the first reachable IPv4. Bind the tailnet address and the question does not arise.

The dashboard

Behind the wizard is the dashboard — five panels that read the live state of the server: Daemon, Network, Certificate, Tokens and Devices. Every key in the footer opens one dialog over it:

KeyAction
sstart/stop — starts the daemon, or asks to confirm a stop: Every attached phone is disconnected until you start it again.
cconfig — the bind host, the port and the same address picker the wizard's Network step uses; Save or Cancel.
ecert — the serving mode, the full fingerprint, the current versus planned SANs, the advertised-trust control and a Help button. Regenerate… sits behind a confirm that lists the planned SANs and the re-pair warning.
ttokens — the token list. Create… takes a name, the read-only flag and an expiry; the minted-secret dialog that follows offers Show pairing QR. Revoke… is behind a confirm: Any phone using it is disconnected and must pair again.
ddevices — the phones registered for push notifications and the relay they use; Remove sits behind a confirm.
wwizard — reopens the six-step setup wizard.
rrefresh — re-reads the server state.
qquit.
The QR is offered for the token you just minted

Show pairing QR appears on the minted-secret dialog only — the one that opens the moment a token is created. The secret is never stored in plaintext, only as a hash, so the code cannot be rebuilt later for a token you have already dismissed. A token minted on the command line is paired by hand instead: enter the host and paste the secret on the app's Connect screen. If you miss the scan, revoke the token and mint a new one.

The CLI route

Everything the wizard does is available as muxrd subcommands, which is what you want for a scripted or configuration-managed install. The examples below assume the tailnet address from step 4.

Initialise the data directory and generate a self-signed certificate that covers your tailnet address:

muxrd init --san 100.x.y.z

--san is repeatable and takes a DNS name or an IP; MUXRD_SAN accepts the same entries as a comma-separated list. To serve a certificate you already own instead, pass --tls-cert and --tls-key. The data directory is ~/.local/share/zellij/muxrd/ on Linux.

Start the daemon on the tailnet address, detached:

muxrd start --bind 100.x.y.z:50051 --daemonize

The bind address also comes from MUXRD_BIND, and defaults to 127.0.0.1:50051. Drop --daemonize to run it in the foreground. Restrict it to one multiplexer with --backend / MUXRD_BACKEND. Check on it:

muxrd status

And stop it:

muxrd stop

Mint a token for a device:

muxrd create-token --name mobile

Add --read-only for a view-only token — it can navigate and scroll but never types or changes the session:

muxrd create-token --name mobile --read-only

Give it a window with --expires-in, which takes 30m, 24h, 7d or never (the default):

muxrd create-token --name guest --expires-in 24h

List what exists:

muxrd list-tokens

Revoke one by name:

muxrd revoke-token mobile
Secret shown once

The plaintext token secret is printed only when the token is created; the server keeps nothing but a hash. Copy it immediately — it cannot be retrieved afterwards. If you lose it, revoke the token and create a new one.

During development, to bypass the zellij version check:

MUXRD_SKIP_VERSION_CHECK=1 muxrd start

LAN instead of a tailnet

If you would rather keep the server on your local network only, bind a LAN address instead of a tailnet one. The rule is the same: the address the phone dials must be in the certificate's SANs, or TLS verification fails.

Bind one specific LAN address:

muxrd start --bind 192.168.0.42:50051

Or listen on every interface and let the QR pick a real address to advertise:

muxrd start --bind 0.0.0.0:50051

On a host with a public interface that also exposes the port to the internet — firewall it.

Either way the certificate has to list that LAN address:

muxrd init --san 192.168.0.42
Regenerate, then restart

Changing the SANs means a new certificate. Run muxrd init again with --san <your-lan-ip>, or regenerate from muxrctl's cert dialog, then restart the daemon — and re-pair every phone, because the fingerprint they pinned no longer exists. A LAN address is also only good on that LAN: leave the network and the phone cannot reach the server, which is the problem a tailnet solves.

Public domain — least recommended

A public domain puts muxrd on the open internet: anyone who has your DNS name and a token can reach it. The certificate is publicly trusted, the app connects with Public domain (CA) trust, and the pairing QR carries tm=ca with no fingerprint. This is how the public demo at demo.muxr.app runs — a read-only sandbox in a hardened container — and it is the right shape only for something you would be comfortable making public. For your own machine, use the tailnet instead.

Read this before you expose muxrd

A public listener changes the threat model. Before you point a DNS name at muxrd:

  • Every host on the internet can reach the login endpoint — the bearer token is the only barrier. Use read-only tokens for anyone you do not fully trust, and keep muxrd revoke-token <name> at hand: it is the kill switch, invalidating the token and every live session minted from it in one step.
  • muxrd has no per-login session isolation — everyone presenting the same token attaches to the same session and sees the same activity.
  • Whatever terminates TLS in front of muxrd sees your terminal traffic and bearer tokens in plaintext.
  • Edge proxies commonly drop idle connections after a minute or two, and an attached terminal is a long-lived stream, so a viewer parked on a silent pane can be cut off — test on a real device before handing out a QR.

Behind a TLS-terminating reverse proxy (how the demo runs)

The proxy owns the public certificate — from Let's Encrypt or another public CA — and terminates TLS for the whole internet-facing hop. It must proxy gRPC over HTTP/2 end to end and speak plaintext HTTP/2 (h2c) to muxrd; muxrd itself listens only on a private address the proxy can reach, and that port is never published to the internet.

muxrd start --bind <private-address>:50051 --insecure-h2c --i-know-this-is-behind-a-proxy

MUXRD_H2C=1 and MUXRD_H2C_ALLOW_PUBLIC=1 are the environment equivalents of the two flags. muxrd refuses a non-loopback h2c bind without that acknowledgment, because the proxy-to-muxrd hop is plaintext and the flag is the operator confirming a trusted proxy is the only thing that can reach it.

The phone dials your.host:443 — 443 is the default port under Public domain (CA) trust. In muxrctl the Certificate dialog reads h2c behind a proxy · CA trust, and the pairing QR carries tm=ca.

If a self-signed muxrd sits behind a proxy like this instead of running h2c, set Advertised trust to CA in the Certificate dialog: “Choose CA yourself when this daemon sits behind a TLS-terminating proxy that presents its own certificate.”

Serve a public certificate yourself

Obtain a certificate for your DNS name — from Let's Encrypt or another public CA — and point muxrd at the fullchain and private key directly, with no proxy in front:

muxrd init --tls-cert /path/fullchain.pem --tls-key /path/privkey.pem
muxrd start --bind 0.0.0.0:50051 --tls-cert /path/fullchain.pem --tls-key /path/privkey.pem

MUXRD_TLS_CERT and MUXRD_TLS_KEY are the environment equivalents; both files are validated at init and at start, and the pair is mutually exclusive with h2c. Firewall the host so only 50051 is reachable.

The phone dials your.host:50051 — type the port, because a bare host defaults to 443 under Public domain (CA). muxrd reads the certificate files at start, so restart it after every renewal.

A pinned public server

A self-signed certificate works too: give it your public host as a SAN with muxrd init --san your.host and publish the port. The pairing QR then pins the fingerprint (tm=pin) instead of using CA trust, but the host is exposed in exactly the same way — this is the demo's other mode.

Harden it like the demo

  • Firewall to 22, 80 and 443 behind a proxy, or 50051 alone for a direct certificate — nothing else inbound.
  • Run muxrd as an unprivileged user inside a container with a read-only root filesystem, no Linux capabilities and resource limits. The demo's compose files are the template.
  • Mint read-only tokens for anyone you do not fully trust.
  • Keep muxrd revoke-token at hand.
  • Test the long-lived attach through your proxy on a real device before publishing a QR.

Pair your device (QR)

QR pairing is the recommended way to connect — it encodes the server address and token in a single scan. For a self-signed server it also carries the TLS certificate fingerprint so the app pins trust to your exact cert; for a CA-signed server it uses system trust instead.

  1. Run the wizard's last step, Pair your phone, which shows the code for the token it just minted — or, from the dashboard, press t, choose Create…, and press Show pairing QR on the minted-secret dialog.
  2. Check the caption: it names the host and port the code points at and the certificate it pins.
  3. Scan the code from the app's Connect screen.

The QR encodes a URI of the form:

muxr://pair?v=2&h=<host>&p=<port>&t=<token-base64url>&ro=<0|1>&n=<name>&tm=<pin|ca>[&fp=<sha256-hex>]

The tm field selects the trust mode. For a self-signed server it is tm=pin and the fp field carries the SHA-256 fingerprint of the server's TLS certificate — the app pins trust to exactly that certificate, so a CA-valid impostor cannot bypass the pin. For a CA-signed server (Let's Encrypt or another public CA) it is tm=ca with no fp, and the app uses system CA trust so the connection survives cert renewals.

Manual fallback

If you cannot scan a QR, enter the server address and token by hand on the Connect screen and pick a trust mode: Public domain (CA) for a CA-signed server, or Trust anyway — which the app marks with an insecure badge — to skip verification. Fingerprint pinning is available through QR pairing only, and Trust anyway is for development, not production.

Docker dev rig

The muxr-core repository includes a Docker Compose file that builds and runs muxrd in a container — useful for trying it out without a local Rust toolchain or a running zellij instance. Run these from the cloned muxr-core directory:

docker compose -f docker/compose.yaml up --build

To reach it from a phone, pass the address to publish on as an environment variable — your tailnet address, for instance:

BIND_ADDR=100.x.y.z docker compose -f docker/compose.yaml up --build

The rig folds that same address into the certificate's SANs for you, so a phone dialling it gets a certificate that matches; override MUXRD_SAN explicitly only if the certificate must cover something else.

The rig exposes two ports:

PortPurpose
50051gRPC (muxrd)
2222SSH access into the container

Reset all container state (volumes included):

docker compose -f docker/compose.yaml down -v
Dev-only — never expose on an untrusted network

The Docker rig uses passwordless SSH and self-signed certificates. It is intended for local development only. Do not expose it on the public internet or any untrusted network.

Connect from the app

With the server running and a token in hand, connect from the Muxr app:

  1. QR scan (recommended): tap the QR icon on the Connect screen and scan the code from muxrctl. The app connects immediately — pinning a self-signed cert or trusting a CA-signed one as the QR specifies, with no further prompts.
  2. Manual: enter the server as host or host:port and paste the token, then choose Public domain (CA) or Trust anyway. A bare host without a port defaults to 443 under Public domain (CA) and to 50051 under Trust anyway — so for a self-hosted server on your tailnet type the port explicitly, 100.x.y.z:50051. Keep a verifying trust mode: scan the pairing QR (it pins the certificate fingerprint), or use Public domain (CA) with a CA-issued certificate. Trust anyway switches certificate verification off and is for development only.

The Connect screen also has a Remember for 4 weeks toggle, which keeps you signed in to that server for four weeks rather than re-authenticating each time. After a successful connection the app offers a biometric prompt to save the credential for one-tap reconnect — the saved server appears on the Connect screen as a pill you can tap to re-enter with biometric unlock.

Once connected, see the Usage Guide for day-to-day terminal control: gestures, the command center, keyboard bar, fullscreen modes, and tablet differences.