LINBIT support engineers need interactive shell access to customer Linux systems that are not directly reachable from the internet. Setting up VPN exceptions involves customer network departments and delays, hurting response times.
The existing workaround (manual ssh -R instructions)
requires too much customer-side expertise and gives support
unconstrained access to the customer's system -- more access than many
compliance postures allow.
Customer system has direct outbound internet access.
Three startup flags determine the customer's tunnel-permission state at session start. Within share mode, the customer can transition between sub-states at runtime; the table below lists all four runtime states and how they are reached.
--restricted)Support can watch the terminal and exchange chat/status messages
only. Cannot type in the main shell and cannot request a tunnel upgrade.
Both the customer daemon and the relay broker enforce this: the broker
drops K and UPGRADE from the support side
immediately (returning TUNNEL_REJECTED to the support
engineer instead of timing out); the customer side drops them too as
defence-in-depth. This is a one-way state; the customer cannot promote
out of it within the running session.
Support can watch the terminal, type in the main shell, and request a
tunnel upgrade at any time. Tunnel upgrade is not
auto-granted by default: each UPGRADE from support raises a
display-menu popup on the customer side asking the customer
to Grant or Deny that request
individually.
The customer can change the policy mid-session:
Ctrl-b u -- pre-grant. Subsequent
UPGRADE requests skip the popup and proceed. Equivalent to
--tunnel behaviour for the rest of the session.Ctrl-b U -- revoke. Closes any active
port forward, removes installed support keys, returns to the
popup-on-each-request state.Ctrl-b X -- auto-reject. Subsequent
UPGRADE requests are rejected without prompting until the
customer presses Ctrl-b u to unblock.--tunnel)Customer passes --tunnel at session start. The session
still goes through the share broker, but the ControlMaster upgrade
happens immediately at connect time and the runtime state is
ALLOWED from the first moment. Support gets a full
reverse-SSH shell as soon as the relay-side keys propagate. Subsequent
UPGRADE requests auto-grant. The customer can revoke with
Ctrl-b U. This is also the only mode that works without
tmux on the customer side.
The customer daemon tracks four _tunnel_perm states; the
startup flags choose the initial one.
| State | Initial when... | Effect on UPGRADE |
|---|---|---|
BLOCKED |
--restricted |
Always rejected; not exitable in this session |
CONFIRM |
default share | Each request shows a Grant/Deny popup |
ALLOWED |
--tunnel, or Ctrl-b u from
CONFIRM |
Auto-granted |
AUTO_REJECT |
Ctrl-b X from CONFIRM /
ALLOWED |
Rejected without prompt |
One ssh invocation from the customer script connects as
tunl@relay. The relay's ForceCommand is always
relay-share.py produce:
ssh -o ControlMaster=auto -S /tmp/linbit-tunl-<hash>/ssh-ctl.sock \
-o ControlPersist=30s \
tunl@tunl.linbit.com \
"linbit-tunl/1 id=SESSION_ID ..."
The SSH stdio carries the tmux control-mode byte stream.
relay-share.py produce parses the context string, registers
the session via the relay API, and splices the stream to the per-session
broker.
When the support side sends UPGRADE\n (no port), the
broker picks a free port and forwards the request to the customer
side:
support broker producer (customer daemon)
|-- UPGRADE ---->| |
| | pick free port N |
| |--- UPGRADE N ----------->|
| | | ssh -S ctl.sock -O forward
| | | -R N:localhost:22 tunl@relay
| |<-- LINBIT_TUNNEL_PORT N--|
| |--- PATCH /api/v1/sessions (tunnel_port=N, mode=tunnel)
| |--- SUPPORT_KEY key1 ----->|
| |--- TUNNEL_CONFIRMED ------>|
| | | authkeys_add_session()
|<- TUNNEL_PORT N| |
The broker selects a free port by attempting to bind 30000-39999 at
random; the support side never needs to know or specify a port number.
The existing ControlMaster socket allows adding -R without
a new authentication round. The customer daemon reports success
(LINBIT_TUNNEL_PORT N) or failure
(LINBIT_TUNNEL_FAILED) back through the tmux control-mode
channel.
$SSH_ORIGINAL_COMMAND for context?It is the natural channel: the client specifies a command string, the
server reads it via $SSH_ORIGINAL_COMMAND even when
ForceCommand overrides it. No side-channel needed; the
context travels in the authenticated SSH session.
A four-word phrase in adjective-noun-verb-noun order generated by the
relay when support runs tunl create-session, e.g.
swift-fox-jumps-barrel. The relay uses four curated word
lists (~200 words each). Support can also supply an explicit ID; the
relay validates the format.
Properties:
The customer-side tmux session is named
linbit-{sha256(session_id)[:12]} so the real session ID is
not visible in ps output to other local users. The mapping
is deterministic: restarting linbit-tunl.py with the same
session ID (the one provided by support) reattaches to the same tmux
session, preserving the customer's shell state.
Passed as the SSH "command" (becomes
$SSH_ORIGINAL_COMMAND on the relay):
linbit-tunl/1 id=swift-fox-jumps-barrel host=srv1.example.com ips=10.0.1.5,192.168.1.10 nets=10.0.1.0/24 dns=10.0.1.1 mode=share case=CASE-4711 user=alice
Fields:
| Field | Description |
|---|---|
| header | linbit-tunl/1 -- protocol ID and version |
id |
4-word session identifier |
host |
$(hostname -f) of the customer system |
ips |
local non-loopback IPv4 addresses |
nets |
local network prefixes in CIDR notation |
dns |
DNS resolver addresses from /etc/resolv.conf |
mode |
share or restricted (the customer reports
the initial flag-based state; tunnel and
no-tunnel are runtime values the broker later patches into
the session record -- see "Session Modes" above) |
port |
chosen reverse-tunnel port (30000-39999); tunnel mode only |
case |
support case number / brief context (optional) |
user |
$USER on the customer system |
token |
pre-registration token (optional) |
The relay's SSH host key is trusted via an SSH host CA, distributed as a package through the LINBIT repositories.
SSH host certificate (primary): The relay's sshd
presents a host certificate signed by the LINBIT Host CA (a separate CA
from the one used to sign customer session certificates). The customer
package (linbit-tunl) installs the CA public key and a
known_hosts fragment:
@cert-authority tunl.linbit.com <LINBIT_HOST_CA_PUBKEY>
Every ssh invocation uses
-o StrictHostKeyChecking=yes with this
known_hosts file.
Rotation:
linbit-tunl package with the
new CA public key. Delivered via normal package manager update; no
additional trust step required.All customer SSH connections use the single tunl sshd
user. Authentication uses one of two paths. There is no shared static
key embedded in the customer script.
Path A -- CA session certificate (preferred):
linbit-tunl.py generates a fresh ephemeral ed25519 keypair
at startup and calls POST /api/v1/sign on the relay API.
The API verifies the token (consuming it), then signs a short-lived
certificate (default TTL: 60 min) with principal
tunl-customer and critical options:
force-command: relay-share.py producepermit-port-forwardingpermit-listen=localhost:30000-39999Path B -- keyboard-interactive token: If certificate
signing fails, ssh uses SSH_ASKPASS set to a
helper that echoes the support token. sshd triggers keyboard-interactive
auth via pam_exec.so running
validate-token.py.
Match User tunl)| Directive | Value | Effect |
|---|---|---|
ForceCommand |
relay-share.py produce |
No shell; broker process runs |
AllowTcpForwarding |
remote |
Permits -R; forbids -L and
-D/SOCKS |
GatewayPorts |
no |
Forwarded ports bind to relay localhost only |
PermitOpen |
none |
Belt-and-suspenders: blocks -L TCP destinations |
AllowStreamLocalForwarding |
remote |
Allows -R Unix socket forwards |
TrustedUserCAKeys |
tunl-ca.pub |
CA cert auth |
AuthorizedPrincipalsFile |
tunl-principals |
Requires tunl-customer |
KbdInteractiveAuthentication |
yes |
Token fallback via PAM |
Note on compliance guarantees:
AllowTcpForwarding remote permits the ControlMaster upgrade
to add a -R port forward. The restriction that support
cannot obtain a reverse tunnel without the customer's involvement is
enforced by the customer-side daemon's allowlist (the
_tunnel_perm gate that drops UPGRADE in
--restricted and in default share mode until the customer
either confirms the popup or pre-grants via Ctrl-b u), not
by sshd itself. The certificate permitlisten option
constrains which ports may be forwarded (30000-39999, relay localhost
only); it does not prevent a -R forward from being
added.
The support user retains
AllowTcpForwarding yes and
AllowStreamLocalForwarding yes so they can pull tunnels and
sockets down from the relay.
Keys are installed in ~/.ssh/authorized_keys (the home
directory of the user who ran linbit-tunl) on the customer
system, bracketed by session markers, when the tunnel is confirmed. On
cleanup the marker block is removed. On startup stale markers from
previous sessions are removed first. The key installation is also
announced in the customer's chat pane so the registered key is visible
and can be copied to other endpoints if needed.
When a support engineer connects to the relay (as
support@relay), sshd writes the authentication method to a
temporary file and sets SSH_USER_AUTH to its path (requires
ExposeAuthInfo yes in sshd_config). The file
contains a line
publickey <type> <base64-pubkey>.
relay-share.py consume extracts this and sends it to the
broker as:
ADD_KEY from="127.0.0.1,::1" <type> <base64>\n
The from= restriction scopes the key to connections
arriving from localhost -- i.e. via the reverse tunnel. The key cannot
be used to SSH directly to the customer system from any other source
IP.
The broker stores received keys in _support_keys per
session. On tunnel confirm (after LINBIT_TUNNEL_PORT N from
producer):
SUPPORT_KEY <key>\n for each key in
_support_keysTUNNEL_CONFIRMED\nauthorized_keysIf a second engineer joins mid-session after the tunnel is already
active, the broker delivers their ADD_KEY immediately to
the producer as a live SUPPORT_KEY +
TUNNEL_CONFIRMED update.
If no ADD_KEY messages have been received (e.g. relay is
older than OpenSSH 8.9+, or the support connection is a non-interactive
upgrade trigger), the broker falls back to reading all keys from
/etc/linbit-tunl/support-keys.pub on the relay.
In --restricted mode (always) and in default share mode
(until the customer either confirms the per-request popup or pre-grants
with Ctrl-b u), UPGRADE is blocked, so support keys are
never installed.
The script creates (or reattaches to) a named tmux session using a
hash of the session ID: linbit-{sha256(session_id)[:12]}.
Two visible panes plus a hidden background window for the daemon:
+----------------------------------------------+
| |
| user@customer-srv1:~# | <- main pane (top, focused)
| | customer's working shell
|----------------------------------------------|
| [LINBIT SHARE] swift-fox-jumps-barrel * 1v | <- chat pane (9 rows, bottom)
| relay connected | CASE-4711 | mode=share | pinned header rows show
|----------------------------------------------| session status, viewers,
| support: can you run `dmesg | tail`? | mode, connection state
| > _ | scrollback below = chat
+----------------------------------------------+
Main pane: the customer's interactive shell. Support sends keystrokes here.
Chat pane: a curses-based UI. The top one or two
rows are pinned to show session status (connection state, viewer count,
mode, case number, etc., fed by SESSION_INFO frames from the broker).
Below the pinned rows is the chat scrollback; an input line at the
bottom is where the customer types. Support keystrokes arrive as v4 CTRL
KEYS chat frames; the customer's input goes out as
CUSTOMER_CHAT frames.
Hidden daemon window: the bridge process
(ShareSession.run()) runs in a background window tagged
@linbit-role=inner, created via break-pane -d.
It never appears in the visible layout; its job is to read tmux
control-mode events from the main pane and forward them over SSH to the
relay broker.
Session persistence: the tmux session is NOT killed
when the daemon disconnects from the relay. The customer's shell,
history, and cwd are preserved. Restarting linbit-tunl.py
with the same session ID reattaches the daemon to the same tmux session.
This makes brief relay disconnects invisible to the customer.
Key bindings (session-local):
Ctrl-b Q -- disconnect from relay only; tmux and
customer shell stay aliveCtrl-b K -- kill the entire tmux session (for customers
wanting a clean exit)The SSH subprocess is managed by a watchdog loop with exponential
backoff (1s, 2s, 4s, ... max 30s). On reconnect: re-establish
ControlMaster, re-run relay-share.py produce. The chat-pane
status header and terminal show relay state live.
Sessions auto-close after 2 hours of no traffic on the customer SSH
connection (ClientAliveInterval 30,
ClientAliveCountMax 240 in relay sshd_config). This handles
the "customer machine went away" case.
The "support finished but customer forgot to close" case is benign --
the connection stays open but idle; the customer can press
Ctrl-b Q to end.
tunl join)tunl join SESSION_ID creates a local 2-window tmux
session tunl-join-SID that mirrors the customer's terminal.
Status metadata is folded into the chat window as pinned header rows;
there is no separate status window.
window 0 (main):
+--------------------------------------------------+
| |
| customer's shell (full VT stream) | <- keys -> customer main pane
| |
+--------------------------------------------------+
window 1 (chat):
+--------------------------------------------------+
| 14:32 acme-srv1 mode=share 1 viewer CASE-... | <- pinned status rows
| connected | last frame 0.2s ago | (from SESSION_INFO,
+--------------------------------------------------+ auto-refresh)
| customer: pip install completed |
| > type here to send to customer's chat pane | <- keys -> customer chat pane
+--------------------------------------------------+
Two SSH connections from the support side connect to the broker:
relay-share.py consume SID -- broker
fans VT stream out to it; keystrokes typed here go to the customer's
main pane.relay-share.py consume SID --chat --
carries chat messages, status updates (SESSION_INFO), and other control
frames; keystrokes typed here go to the customer's chat pane.Both use ControlPath=none to prevent the user's
~/.ssh/config from multiplexing them over an unrelated
master.
Read-write (default): support can type in main or chat window.
Read-only (--ro): explicit opt-in for
observers or compliance auditors.
Multiple concurrent viewers: the broker fans out to all support-side clients. No single-writer enforcement; coordination among support engineers is verbal.
Idempotent: if the local tunl-join-SID
session already exists, tunl join reattaches instead of
creating a new one.
Main menu (Ctrl-b Enter): provides SSH to customer, proxy shell, browser, zoom toggle, force-my-dimensions override, viewport toggle, detach, and close actions -- all accessible without leaving the join session.
tunl ssh,
tunl browser)Only available after the broker has confirmed the tunnel upgrade
(customer's authorized_keys has been updated).
tunl upgrade SESSION_IDSSHes to the relay and sends UPGRADE\n to the broker
from the support side. The broker picks a free port and forwards the
request to the customer side. The customer's daemon uses the
ControlMaster to add the reverse port forward, reports back via
LINBIT_TUNNEL_PORT, and the broker delivers support
keys.
tunl ssh SESSION_IDIf the tunnel is not yet active, triggers the upgrade automatically
(same as tunl upgrade) before proceeding. Then SSHes
through the relay to the customer system via the TCP reverse tunnel.
The reverse tunnel (relay port N) is what enables
direct access to the customer machine. The SSH ControlMaster jumps
through the relay using an explicit ProxyCommand with
ControlPath=none (not -J) so the user's
~/.ssh/config cannot hijack the jump leg.
A SOCKS5 proxy on
127.0.0.1:TUNL_SOCKS_PORT rides the same SSH ControlMaster
connection. It provides broader access to other hosts reachable from the
customer machine (curl to internal web UIs, SSH to other nodes via
SOCKS5).
The proxy-env shell (opened by tunl ssh
inside tmux, or via the join menu x) pre-defines bash
helper functions:
| Function | Purpose |
|---|---|
cssh [USER] |
SSH to customer (default: session user) |
cscp FILE... USER@127.0.0.1: |
scp to/from customer via tunnel |
crsync SRC... DEST |
rsync via tunnel |
cproxy USER@HOST |
SSH to a host reachable from customer via SOCKS5 |
The c prefix stands for "customer". All functions carry
the correct ProxyCommand and host-key options. Proxy
environment variables (HTTPS_PROXY, ALL_PROXY)
are exported for tools like curl.
These capabilities are also available from within a
tunl join session via the main menu (Ctrl-b Enter):
s opens an SSH window, x opens the proxy-env
shell, and b launches a proxied browser.
Reaching a machine accessible from the customer workstation but not directly reachable from the internet: the customer can add a second reverse port forward on the existing ControlMaster connection, exposing any host reachable from their machine:
# Customer runs -- pick an unused port in 30000-39999:
ssh -S /tmp/linbit-tunl-*/ssh-ctl.sock -O forward \
-R PORT2:target-node:22 tunl@tunl.linbit.com
# Support then connects via cproxy or directly:
cproxy USER@target-nodeThe customer's SSH certificate authorizes -R forwards on
localhost:30000-39999, so additional forwards follow the
same constraints.
tunl browser SESSION_IDOpens a SOCKS5 proxy through the TCP tunnel and launches Chrome/Chromium with a named persistent profile. All browser traffic is routed through the SOCKS5 proxy so DNS resolves on the customer side.
The producer shim runs on the customer machine under customer control. It is the canonical trust enforcement point; the broker also enforces the same policy for efficiency (avoiding a round-trip to the customer before rejecting).
Producer/broker allowlist (all v4 CTRL frames):
| Command | Effect | Blocked in --restricted |
Blocked in default share until granted |
|---|---|---|---|
SNAPSHOT |
Trigger capture-pane | No | No |
KEYS main (1 binary arg: keys) |
Inject keystrokes into main shell | Yes | No |
KEYS chat (1 binary arg: keys) |
Inject keystrokes into chat pane | No | No |
RESIZE C R [override] |
Resize tmux window | No | No |
STATUS (1 binary arg: text) |
Overlay status notice on chat pane | No | No |
UPGRADE N |
Add -R port forward via ControlMaster | Yes | Yes (until customer confirms popup or pre-grants
via Ctrl-b u) |
SUPPORT_KEY (1 binary arg: key) |
Accumulate support key | N/A | N/A |
TUNNEL_CONFIRMED |
Install accumulated keys in authorized_keys | N/A | N/A |
| Anything else | -- | Logged and dropped | Logged and dropped |
When UPGRADE is blocked, both the broker and producer
respond with an immediate TUNNEL_REJECTED CTRL frame (1
binary arg: reason) rather than a timeout. The reason propagates to the
support engineer's terminal.
Audit log: every inbound command is logged on the
relay to /var/log/linbit-tunl/<id>.log (timestamped,
appends across reconnects).
Cast file: broker records session output to
/var/log/linbit-tunl/<id>.cast (asciinema v2 format)
on the relay side. The customer-side daemon also records via
tmux pipe-pane to a local .cast file in the
session directory.
/var/log/linbit-tunl/<id>.log
on relay/tmp/linbit-tunl-<hash>/ssh.log -- timestamped SSH
stderr, append-only, survives reconnects. Its path is announced in the
chat-pane status header at session start. Errors (auth failures,
connection refused) appear in the log and are briefly surfaced in the
chat-pane status header in red./tmp/tunl-ssh.log (override
with TUNL_SSH_LOG) -- stderr from API tunnel SSH
(timestamped by _SshStderrLogger) plus stderr from
consume/connect/upgrade SSH (appended by OpenSSH via
-E SSH_LOG_PATH)./var/log/linbit-tunl/<id>.cast on
relay (asciinema v2)/tmp/linbit-tunl-<hash>/<id>.cast (via
tmux pipe-pane)A minimal Python HTTP server (relay-api.py) listens on
127.0.0.1:8080 inside the relay pod. It is reachable from
the public internet only via nginx (port 443), which whitelists a small
set of customer-bootstrap endpoints; everything else is loopback-only
and is reached by support tooling over an SSH port-forward. The
endpoint-exposure policy is owned by the nginx configuration
(docs/deployment.html "API endpoint exposure policy").
relay-api.py itself does no per-request access control
beyond its own auth checks -- it answers identical handlers on every
interface it listens on. It persists sessions and uploads in a SQLite
database (TUNL_DB, default
/var/lib/linbit-tunl/tunl.db). Sessions have a
status column: pending ->
active -> closed ->
archived.
Endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/info |
relay identity + SSH host key |
| GET | /api/v1/ca.pub |
session-cert CA public key |
| GET | /api/v1/host-ca.pub |
host CA public key |
| GET | /api/v1/support-keys |
current support public keys |
| POST | /api/v1/sign |
sign customer ephemeral key with CA |
| POST | /api/v1/sessions |
register a new session |
| GET | /api/v1/sessions |
list sessions (filterable by status, customer, case, time) |
| GET | /api/v1/sessions/<id> |
get one session's details |
| PATCH | /api/v1/sessions/<id> |
update tunnel_port / mode after upgrade |
| DELETE | /api/v1/sessions/<id> |
deregister a session (sets status=closed) |
| POST | /api/v1/pending |
pre-register session; relay generates ID if omitted |
| GET | /api/v1/pending |
list non-expired pending sessions |
| GET | /api/v1/pending/<id> |
get one pending session record |
| DELETE | /api/v1/pending/<id> |
cancel a pending session |
| POST | /api/v1/expected |
create pre-registration token |
| GET | /api/v1/expected/<token> |
validate token (without consuming) |
| DELETE | /api/v1/expected/<token> |
consume or revoke a token |
| GET | /api/v1/cases/<id> |
validate a support case number (stub) |
| POST | /api/v1/uploads |
request upload slot for sosreport |
| GET | /api/v1/uploads |
list upload records |
| GET | /api/v1/uploads/<id> |
get one upload record |
| PUT | /api/v1/uploads/<id>/data |
receive upload data (local storage) |
| POST | /api/v1/uploads/<id>/complete |
confirm upload, record metadata |
Session record fields (JSON):
| Field | Description |
|---|---|
id |
session ID |
registered_at |
Unix timestamp |
mode |
share, no-tunnel, restricted,
or tunnel |
port |
reverse-tunnel port (tunnel mode; None in share-only) |
tunnel_port |
port after ControlMaster upgrade (share->tunnel; None until upgrade) |
host, ips, nets,
dns |
customer network context |
case, customer, user |
case/contact metadata |
remote_ip |
customer's public IP |
status |
pending, active, closed, or
archived |
closed_at |
timestamp of session close (null while active) |
relay/
relay-api.py [C1] session registry HTTP API (Python, stdlib only, SQLite)
relay-share.py [C2] ForceCommand; per-session broker:
produce: register session, splice tmux stream
consume: relay-side bridge to the support side
close-hook-archive.sh close hook: bundles .log/.chat/.cast into .tar.zst
setup.sh relay provisioning (idempotent)
validate-token.py PAM exec script for KI token validation
sshd_config.d/ [C3] drop-in sshd config: tunl user, support user
control_mode.py tmux control-mode codec (encode/decode %output)
linbit-tunl.py [C4-C12]
generate_session_id() [C4] 4-word adj-noun-verb-noun ID from curated word lists
pick_port() [C5] random port 30000-39999 (tunnel mode only)
gather_context() [C6] hostname, IPs, networks, DNS, $USER, case arg
setup_known_hosts() [C7] SSH host CA from package known_hosts fragment
ShareSession [C8] share-mode daemon:
ControlMaster SSH (-M -S ctl.sock)
tmux control-mode client (-C -r)
cmd_forwarder: allowlist + UPGRADE handler
_run_upgrade(): ssh -O forward on live socket
_launch_in_tmux_share() [C9] 2-pane visible tmux (main + chat) plus hidden
inner window for the daemon; hash-based session
name; session reuse on reconnect
authkeys_*() [C10] add/remove marker blocks in authorized_keys
cleanup() [C11] EXIT: remove keys, kill tmux server (non-attach mode)
tunl.py [C12] support-side CLI
tunl join 2-window local tmux (main, chat); chat-window pinned
header carries the status info; two SSH connections
main menu (Ctrl-b Enter): SSH, proxy shell, browser,
zoom, resize, detach, close
tunl ssh SSH to customer via reverse tunnel + SOCKS5 proxy
tunl browser Chrome + named profile + SOCKS5 via TCP tunnel
tunl list list sessions from relay API
tunl show detail for one session
tunl pick interactive session picker
tunl expect create pre-registration token
tunl upgrade send UPGRADE to broker (broker picks port); no port arg
tunl create-session pre-register a session ID via /api/v1/pending
TUNL_FORWARD_AGENT=1 opt-in to SSH agent forwarding (default: disabled)
linbit-sos.py customer sosreport collector + uploader
validate_case_id() case ID validation (3-64 chars, alphanumeric + hyphens/underscores)
detect_sos_binary() find sos or sosreport on the system
generate_sosreport() run sosreport, return archive path
request_upload_slot() POST /api/v1/uploads (relay allocates upload ID)
put_upload() PUT file to upload URL (with retry)
confirm_upload() POST /api/v1/uploads/<id>/complete
linbit-tunl/
linbit-tunl.py customer-side script (Python 3.6+, stdlib only)
tunl.py support-side CLI (Python 3.6+)
relay/
relay-api.py session registry HTTP server (SQLite)
relay-share.py ForceCommand; per-session broker
control_mode.py tmux control-mode codec
close-hook-archive.sh close hook: archives .log/.chat/.cast to .tar.zst
setup.sh relay provisioning (idempotent)
validate-token.py PAM exec script for KI token validation
sshd_config.d/tunl.conf sshd drop-in config (tunl + support users)
pam.d/linbit-tunl PAM service config
packaging/
linbit-tunl.spec RPM spec (3 sub-packages)
linbit-tunl-api.service systemd unit for relay-api
debian/ Debian packaging files
tests/
test_relay_api.py pytest
test_relay_share.py pytest
test_linbit_tunl.py pytest
test_tunl.py pytest
test_sos.py pytest
test_control_mode.py pytest
test_validate_token.py pytest
test_utf8_local.py pytest
integration/
test_full_flow.sh local-process integration
test_container_flow.sh podman container integration
test_share_flow.sh share-mode container integration
test_share_extended.sh extended share-mode tests
test_session_flow.sh session lifecycle tests
docs/
architecture.html this document
share.html tmux share mode: design and protocol details
tmux-control-mode.html tmux control-mode protocol reference
security.html threat model and security analysis
relay-api.html relay API endpoint reference
relay-share.html ForceCommand: produce (customer leg), broker, consume (support leg)
linbit-tunl.html customer script man page
tunl.html support CLI man page
quickref.html one-page command reference
setup.html relay provisioning guide
walkthrough.html end-to-end session narrative
ssh <id>@tunl.linbit.com)
is not implemented.ip
vs ifconfig, tmux not pre-installed).linbit-tunl.py)Python requirement: 3.6+ (f-strings; script enforces at startup).
OpenSSH client:
os.setsid() detaches ssh from the
terminal; works on all versions.ssh -O forward: requires OpenSSH 6.7+ (ControlMaster
multiplexed forwarding).| Distro | Python 3 | OpenSSH client | Status |
|---|---|---|---|
| RHEL / AlmaLinux 8 | 3.6 | 8.0p1 | OK |
| RHEL / AlmaLinux 9 | 3.9 | 8.7p1 | OK |
| RHEL / AlmaLinux 10 | 3.12 | 9.6p1 | OK |
| Debian 10 (buster) | 3.7 | 7.9p1 | OK |
| Debian 11 (bullseye) | 3.9 | 8.4p1 | OK |
| Debian 12 (bookworm) | 3.11 | 9.2p1 | OK |
| Ubuntu 18.04 LTS | 3.6 | 7.6p1 | OK |
| Ubuntu 20.04 LTS | 3.8 | 8.2p1 | OK |
| Ubuntu 22.04 LTS | 3.10 | 8.9p1 | OK |
| Ubuntu 24.04 LTS | 3.12 | 9.6p1 | OK |
Hard requirement: OpenSSH 8.7+
(PAMServiceName in Match User block).
Suitable OS choices:
tunl.py)Python 3.6+; any modern OpenSSH client. Support staff run on LINBIT-managed systems.
Nearly all of the customer script works unmodified on macOS (Python
3.6+, current OpenSSH built in). Two gaps need platform detection:
ip -4 addr vs ifconfig for IP enumeration;
tmux not pre-installed (Homebrew). Package delivery would need a
Homebrew formula or .pkg installer. Implement when a
customer requests it.
Customer workstation reachable; target host is behind it. Customer
runs linbit-tunl.py on the workstation; support attaches
via share mode, then SSHes to the target from the shared shell. Customer
can enter passphrases and watch. No additional implementation needed;
the share model handles this naturally.