linbit-tunl: architecture

linbit-tunl Architecture

Problem

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.

Goals

Scope Constraints

Customer system has direct outbound internet access.


Session Modes

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.

Mode 1: Restricted share (--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.

Mode 2: Share (default, no flags)

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:

Mode 3: Pre-opened reverse tunnel (--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.

Runtime tunnel-permission states

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

Design: Unified SSH Session

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.

Tunnel upgrade without reconnect

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.

Why $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.


Session ID

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:

Local tmux session naming

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.


Context String

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)

Customer Trust: Host Key Verification

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:


Relay Access Control

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.

Two authentication paths

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:

Path 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.

sshd_config constraints (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.


Support Public Key Distribution

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.

Key source (per-session, from SSH_USER_AUTH)

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):

  1. Broker sends SUPPORT_KEY <key>\n for each key in _support_keys
  2. Broker sends TUNNEL_CONFIRMED\n
  3. Producer installs all accumulated keys in authorized_keys

If 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.

Fallback (support-keys.pub)

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.


tmux Session and Customer UX

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):


Reconnect Behavior

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.


Idle Timeout

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.


Support Access: Share Mode (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:

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.

Support Access: Tunnel Mode (tunl ssh, tunl browser)

Only available after the broker has confirmed the tunnel upgrade (customer's authorized_keys has been updated).

tunl upgrade SESSION_ID

SSHes 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_ID

If 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-node

The customer's SSH certificate authorizes -R forwards on localhost:30000-39999, so additional forwards follow the same constraints.

tunl browser SESSION_ID

Opens 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.


Trust Model (Share Mode)

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.


Audit Logging


Relay Session Registry

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)

Component Map

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

File Layout

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

Known Limitations


Distribution Compatibility

Customer system (linbit-tunl.py)

Python requirement: 3.6+ (f-strings; script enforces at startup).

OpenSSH client:

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

Relay

Hard requirement: OpenSSH 8.7+ (PAMServiceName in Match User block).

Suitable OS choices:

Support CLI (tunl.py)

Python 3.6+; any modern OpenSSH client. Support staff run on LINBIT-managed systems.


Assessments

macOS client

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.

Double-hop topology

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.