claude-code-session-mux¶
Modules
A registry plus WebSSH multiplexer for terminal sessions spread across many hosts. Each host self-registers its live sessions with a small server; from one browser page you see every running session and click to drop into a terminal on the host it lives on — the server SSHes into that host and attaches its tmux session, rendered by xterm.js in the browser.
It's built with Claude Code sessions running in tmux across many hosts in
mind, but the pattern is generic: any long-lived, per-host terminal process
you want to attach to from a single dashboard fits.
The problem¶
When you run agents (or any interactive terminal job) on a dozen machines, there
is no single place to see what is running or to jump into one. You end up
ssh-ing around by hand, remembering which host has which session, and
re-attaching tmux by name. This module gives you one URL: a live list of every
session across all hosts, each a click away from a full terminal in the browser.
The moving parts:
- A daemon (a small Go server, supplied by you via the
packageoption). It holds an in-memory registry of sessions and serves a dashboard plus a WebSocket-to-SSH bridge. - A registration client on each host (a shell loop, not shipped here) that
POSTs a session when it starts, heartbeats it, andDELETEs it when the tmux session ends.
The key insight — known_hosts is the security boundary¶
This is the trap worth internalizing before you deploy anything like this.
The bridge dials a host named in caller-supplied JSON. A host registers
itself by posting { "host": "...", "tmux_socket": "...", ... }; when an
operator clicks that session, the server opens an SSH connection to whatever
address the registration claimed and forwards the operator's terminal into it.
If the SSH client trusts host keys on first use, a malicious (or compromised) registrant can register a spoofed address and redirect an operator's terminal to a box the attacker controls — capturing keystrokes, or presenting a fake shell. So:
- The daemon dials with its own key (
sshKeyPath) and validates the remote host key against a pinnedknown_hostsfile (knownHostsPath). - There is no trust-on-first-use. An unknown host key is a hard failure. That is exactly what you want for an unattended bridge that auto-routes to hosts named in untrusted input.
knownHostsPath is therefore the real access-control list of where the bridge
may ever send a terminal. Keep it in sync with the actual host keys of the
machines you bridge to (e.g. populate it with ssh-keyscan). A host missing
from it silently fails to open a terminal — which is the safe failure mode.
Two SSH-bridge details worth remembering¶
If you write or adapt the bridge, these bit us and will bite you:
-
Constrain
HostKeyAlgorithmsfromknown_hosts. Go'scrypto/sshdefaults to offering every algorithm it supports, lets the server pick, then reports a "key mismatch" if the server's choice does not match the pinned entry. OpenSSH derives the offered algorithms from theknown_hostsentries for the host being dialed; mirror that. Without it, a host recorded under ed25519 that also exposes an RSA key produces spurious mismatches. -
Prepend
COLORTERM=truecolor TERM=xterm-256colorto the remote command. Some sshd configs (notably macOS defaults) do not pass these viaAcceptEnv, and inner TUIs render dim or wrong colors without them. xterm.js renders true-color escapes natively.
Registration wire format¶
The registration client is not shipped with this recipe, but here is the contract the server expects, so you can write one:
POST /api/sessions # register a session (Bearer token)
PUT /api/sessions/{slug}/heartbeat # keep it alive (every ~30s)
DELETE /api/sessions/{slug} # remove it when the session ends
GET /api/sessions # read: dashboard data (no auth)
POST body (JSON):
{
"slug": "host-my-session",
"host": "your-host",
"user": "operator",
"ssh_user": "operator",
"tmux_socket": "/tmp/tmux-1000/default",
"tmux_session": "my-session",
"tmux_pane": "%0",
"cwd": "/path/to/project",
"source": "some-label"
}
Notes:
tmux_socketis the full socket path, not a-Lshort name. The server runstmux -S "<tmux_socket>" attach-session …, which works regardless of how the session was created. Resolve the path before posting:tmux -L <name> display -p '#{socket_path}'.- Auth is a write gate only.
POST/PUT/DELETErequireAuthorization: Bearer <token>(thetokenFilevalue). Read endpoints — the dashboard, the session page, the WebSocket — are unauthenticated. The real front gate is your reverse proxy (TLS, and a caller-IP allowlist if you want one). The token just keeps strangers from spraying the registry. - Lifecycle / GC. The server expires an entry ~90s after its last heartbeat,
so the client must heartbeat at least once a minute. On a clean exit the
client sends a
DELETEso the entry disappears immediately instead of lingering for the full timeout.
How to use it¶
{
imports = [ ./claude-code-session-mux ];
modules.services.claude-code-mux = {
enable = true;
# You supply the daemon. This module does not vendor the binary.
package = pkgs.claude-code-mux;
# Bearer token that registrants present. Deliver out of band.
tokenFile = "/run/secrets/claude-code-mux-token";
# Key the bridge dials hosts with; its public half must be authorized
# on every host that registers.
sshKeyPath = "/var/lib/claude-code-mux/id_ed25519";
# THE security boundary — pinned host keys of every bridgeable host.
knownHostsPath = "/var/lib/claude-code-mux/known_hosts";
# Defaults are usually fine:
# addr = "127.0.0.1:17800"; # loopback; reverse-proxy it
# dataDir = "/var/lib/claude-code-mux";
# user = "claude-code-mux";
# group = "claude-code-mux";
};
}
Then reverse-proxy addr behind nginx (or similar) with TLS. Generate the
daemon's SSH key and initialize an (initially empty) known_hosts at
dataDir, then populate it with the host keys you intend to bridge to.
Caveats¶
- You must provide
package. The Go daemon source is not part of this recipe; wire in your ownbuildGoModulederivation that producesbin/claude-code-muxhonoring the flags indefault.nix. - Never expose
addrdirectly. Read endpoints are unauthenticated by design; the reverse proxy is your front door. - The registration client is yours to write. See the wire format above.
- The daemon runs as an unprivileged system user with
ProtectSystem=strictand a privateStateDirectory; the SSH key andknown_hostslive underdataDir(mode0700).
Source¶
modules/claude-code-session-mux/default.nix
{
config,
lib,
...
}:
let
cfg = config.modules.services.claude-code-mux;
inherit (lib)
types
mkOption
mkEnableOption
mkIf
;
in
{
options.modules.services.claude-code-mux = {
enable =
mkEnableOption "claude-code-mux: registry + WebSSH multiplexer for Claude Code sessions";
package = mkOption {
type = types.package;
description = ''
The claude-code-mux server package. This module deliberately does not
vendor the daemon binary — supply your own `buildGoModule` derivation
that produces `bin/claude-code-mux` accepting the flags used in the
ExecStart below (`-addr`, `-data-dir`, `-token-file`, `-ssh-key`,
`-known-hosts`).
'';
example = lib.literalExpression "pkgs.claude-code-mux";
};
addr = mkOption {
type = types.str;
default = "127.0.0.1:17800";
description = ''
Listen address. Bind loopback and reverse-proxy through nginx (or any
TLS terminator). The bearer token is the ONLY thing gating registration
writes, and the read endpoints are unauthenticated — never expose this
address directly to the network.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/claude-code-mux";
description = "Directory for the sessions snapshot file.";
};
tokenFile = mkOption {
type = types.path;
description = ''
Path to the bearer token used by the registration client on every host
that registers a session. Deliver this out of band (a secrets manager,
systemd credential, etc.) — do not commit it.
'';
example = "/run/secrets/claude-code-mux-token";
};
user = mkOption {
type = types.str;
default = "claude-code-mux";
description = "System user the daemon runs as. Owns dataDir and reads tokenFile and sshKeyPath.";
};
group = mkOption {
type = types.str;
default = "claude-code-mux";
description = "System group for the daemon user.";
};
sshKeyPath = mkOption {
type = types.path;
description = ''
Private SSH key the WebSSH bridge dials registered hosts with. Its
public half must be authorized on every host that registers a session.
'';
example = "/var/lib/claude-code-mux/id_ed25519";
};
knownHostsPath = mkOption {
type = types.path;
description = ''
known_hosts file used to verify the host keys of registered hosts.
THIS IS THE SECURITY BOUNDARY. The bridge auto-routes an operator's
terminal to a host named in caller-supplied JSON, so it refuses to dial
any host whose key is not in this file (no trust-on-first-use). Keep it
in sync with the host keys of every machine you intend to bridge to, or
new hosts silently fail to open a terminal.
'';
example = "/var/lib/claude-code-mux/known_hosts";
};
};
config = mkIf cfg.enable {
users.groups.${cfg.group} = mkIf (cfg.group == "claude-code-mux") { };
users.users.${cfg.user} = mkIf (cfg.user == "claude-code-mux") {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = false;
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
];
systemd.services.claude-code-mux = {
description = "Claude Code session registry + WebSSH bridge";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
ExecStart =
"${cfg.package}/bin/claude-code-mux"
+ " -addr ${cfg.addr}"
+ " -data-dir ${cfg.dataDir}"
+ " -token-file ${cfg.tokenFile}"
+ " -ssh-key ${cfg.sshKeyPath}"
+ " -known-hosts ${cfg.knownHostsPath}";
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
RestartSec = 5;
StateDirectory = "claude-code-mux";
StateDirectoryMode = "0700";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [ cfg.dataDir ];
};
};
};
}