mod_shell provides two independent shell subsystems that share a single
module but solve different problems:
- Dial-back shell channel — what
shell <peer>in the CLI uses today. A dedicated TCP+TLS connection between initiator and target carries raw PTY bytes; the target device is the one that dials back to the initiator on a configurable port, so NAT and firewall rules on the device side never need to accept inbound connections. Authentication happens on the target via/bin/su -l <user>after a privilege-drop step, so PAM (pam_unixagainst/etc/shadowby default) enforces the password. - Legacy message-based PTY — HTTP/API driven
open/write/read/closepaths for scripts and automation that need a shell without holding an interactive TCP connection. Same module, different use case.
The two are orthogonal. The dial-back channel is the canonical way to get an interactive shell on a peer; the message-based API is kept for non-interactive automation.
The old shell <peer> command routed through mod_node
(/node/functions/shell → /tunnel/shell), borrowing a worker from the
federation pool as a raw byte transport. That came with three real
problems:
- Worker-pool burn: each shell permanently took a pool worker out
of rotation (the fd was in raw-bytes mode, not PORTAL02-framed any
more). After
worker_countshells, the peer looked "unavailable" until the pool was rebuilt by a reconnect cycle. - Federation disruption: rebuilding the pool meant tearing down and
re-establishing the entire ctrl+workers connection. Ongoing reports,
notifications, and
get_dbrequests to that peer paused during the cycle. - Unauthenticated
root: the remote side ranexecl("/bin/bash", "bash", "-l")directly — whoever could reach the federation socket got a root shell with no password.
The dial-back channel addresses all three.
INITIATOR (Portal running the CLI) TARGET (remote peer)
────────────────────────────────────────────────────────────────
shell <peer>
│ 1. open_remote:
│ • generate 32-byte random
│ session_id (hex, from
│ OpenSSL RAND_bytes)
│ • register pending entry
│ • send
│ /<peer>/shell/functions/dialback_request
│ via federation ─────────▶
│ (one small signal;
│ NO shell data flows
│ through federation) 2. handle_dialback_request
│ • wait on condvar spawns a new pthread
│ (shell_dial_timeout) (dialback_thread)
│ │
│ │ 3. connect() to
│ │ reply_host:reply_port
│ │ (blocking, own thread)
│ │ 4. SSL_connect (TLS 1.2+)
│ │ 5. SSL_write session_id + '\n'
│ │
│ 6. listener_thread accept() │
│ 7. accept_handler_thread: │
│ • SSL_accept │
│ • SSL_read session_id ◀────────┘
│ • pending_take(id) → match
│ • socketpair(plain, tls_bridge)
│ • hand plain side to waiter
│ • run tls_plain_relay
│ on its own pthread
│
│ 8. open_remote returns
│ plain fd to CLI as
│ response body
│
Connected to <peer> 9. forkpty + child:
(CLI relays user terminal ↔ • print "<host> login: "
plain fd; accept_handler_thread • read username (alnum._-)
relays plain ↔ TLS fd; target's • setgid/setuid to nobody
dialback_thread relays TLS ↔ • execl /bin/su -l <user>
PTY master) (su is SUID root, so it
validates via PAM and
drops to the target user)
• PAM prompts password
• su runs user's login shell
• writes utmp/wtmp
10. tls_plain_relay:
PTY master ↔ TLS fd
(its own pthread)
Either side closes → both
pthreads exit cleanly; the
session_id is one-shot and
cannot be reused.
| Path | Who calls it | Purpose |
|---|---|---|
/shell/functions/open_remote |
CLI locally (shell <peer>) |
Generate session, signal peer, wait for dial-back, return bridge fd |
/shell/functions/dialback_request |
Remote initiator via federation | Spawn dialback_thread → TCP+TLS back to initiator + run login |
Both carry the access_label set in config (default root).
| Thread | Side | Lifetime |
|---|---|---|
listener_thread |
Initiator | One for the module; accept() loop |
accept_handler_thread |
Initiator | One per dial-back; TLS handshake, session lookup, then runs tls_plain_relay |
dialback_thread |
Target | One per incoming request; TCP/TLS/PTY fork + tls_plain_relay |
Each thread cleans up its own resources on exit. The Portal event loop is never touched for shell data; only the one-shot signal message uses federation.
The target-side PTY child does the following before execing
/bin/su:
- Prompt
"<hostname> login: "on its stdout (= PTY slave). - Read a username from stdin one byte at a time, accepting only
[A-Za-z0-9._-](the POSIX portable username set), capped at 32 characters, with backspace handling. - Validate the resulting buffer a second time defensively.
- Drop privileges —
setgroups(0, NULL),setgid(nobody_gid),setuid(nobody_uid). If the drop fails, abort the login (do NOT continue as root; see below). - Close any inherited fds ≥ 3 (Portal internal sockets must not leak into the login shell).
execl("/bin/su", "su", "-l", user, NULL).
Why drop to nobody before /bin/su:
/bin/suis SUID root. When invoked from root (euid 0), PAM'spam_rootok.soletssuskip the password prompt entirely — a root caller is trusted to know what it's doing. If we didn't drop privileges, a user typing any valid username + Enter would land in a shell with no password check: a full remote unauthenticated privilege escalation.- As
nobodywe lose the root-trust exemption, so/bin/sugoes all the way through the PAM auth stack (pam_unix→/etc/shadow) and only becomes the target user after a correct password.
Why we refuse to continue if setuid/setgid fails: exec'ing su while
still root is exactly the vulnerability the drop is defending against.
A failed drop is a hard stop; the dial-back session closes cleanly and
the user sees an error.
The password itself is read by /bin/su directly from the PTY slave
(stdin) with echo disabled — we never see it, never buffer it, never
log it.
TLS:
- Both server (initiator's listener) and client (target's dial-back socket) use OpenSSL ≥ TLS 1.2.
- The cert/key default to the instance's shared federation cert
(
node.cert_file/node.key_file), so operators don't need a separate PKI for this channel — butshell_tls_cert/shell_tls_keycan override. - Peer cert verification is OFF by default (
SSL_VERIFY_NONE). The per-session nonce (32 bytes ofRAND_bytes, sent as the first line after TLS handshake) is what actually authenticates the channel. A pending session can only be consumed once and only withinshell_dial_timeoutseconds; otherwisepending_takereturns NULL and the handler logs a warning + drops the connection.
portal:/> shell ssip867
Connected to ssip867 (Ctrl-] to disconnect)
ssip867 login: monitor
Password: ← /bin/su + PAM, we never see it
[monitor@ssip867 ~]$ whoami
monitor
[monitor@ssip867 ~]$ exit
Session ended
portal:/>
File: /etc/portal/<instance>/modules/mod_shell.conf
enabled = true
[mod_shell]
# ── Legacy message-based PTY ──
timeout = 10 # Max seconds for /shell/functions/exec
shell = /bin/sh # Used by /shell/functions/exec + /open
allow_exec = true # Hard safety switch for exec path
max_output = 65536 # Max bytes per read() on API sessions
session_ttl = 3600 # Auto-close idle API PTY sessions (seconds)
access_label = root # Group label required for all shell paths
# ── Dial-back shell channel ──
shell_port = 2223 # Listener port (0 = disabled)
shell_bind = 0.0.0.0 # Bind address (LAN/overlay only if preferred)
shell_tls_cert = # Default: instance federation cert
shell_tls_key = # Default: instance federation key
shell_advertise_host = # Host/IP the target dials back to;
# empty = first non-loopback IPv4 (guess).
# Set explicitly on multi-homed hosts.
shell_login_binary = /bin/su # /bin/login DOES NOT WORK — see note
shell_dial_timeout = 10 # Max seconds to wait for dial-back (1-60)util-linux 2.40+ (AlmaLinux 10, recent Debians) runs check_tty() at
startup and rejects any PTY not allocated by getty with FATAL: bad tty. forkpty + execl("/bin/login") hits this immediately, and the
session drops on the first keystroke. /bin/su uses the same PAM
stack (account, auth, session from system-auth) but has no
TTY-origin restrictions, so it works on any PTY the kernel will give
us.
If a deployment has a getty-style wrapper and wants /bin/login back,
override shell_login_binary.
The target must be able to connect() to
<shell_advertise_host>:<shell_port>. Portal's auto-detect picks the
first non-loopback IPv4 from getifaddrs(), which on multi-homed
hosts may be the wrong interface (e.g., a public WAN IP when the
target reaches you only over LAN or a WireGuard overlay). Examples:
# futurepbx devtest — reachable from ssip-hub over LAN
shell_advertise_host = 192.168.1.198
# core.tucall.com hub — reachable from ssip867 over public internet
shell_advertise_host = 213.162.195.20
# An SSIP appliance — reachable only on the WG overlay
shell_advertise_host = 10.200.1.79The initiator must accept inbound TCP on shell_port. On core1
(AlmaLinux with /etc/init.d/firewall) that means an explicit
iptables -A INPUT -p TCP --dport 2223 -j ACCEPT. The target side
never needs to accept inbound — it only opens outbound connections, so
NAT'd devices work without any port-forwarding.
The original API remains registered and functional. Useful for HTTP clients and scripts that can't hold an interactive TCP:
# Stateless exec
curl -u root:<pass> -X PUT "http://host:8080/api/shell/functions/exec?cmd=hostname"
# Long-lived PTY session
SID=$(curl -s -u root:<pass> -X PUT "http://host:8080/api/shell/functions/open?rows=24&cols=80")
curl -s -u root:<pass> -X PUT "http://host:8080/api/shell/functions/write?session=$SID" -d "ls -la"
curl -s -u root:<pass> -X PUT "http://host:8080/api/shell/functions/read?session=$SID"
curl -s -u root:<pass> -X PUT "http://host:8080/api/shell/functions/close?session=$SID"| Path | Access | Headers | Description |
|---|---|---|---|
/shell/functions/exec |
RW (access_label) |
cmd, cwd, timeout |
Stateless popen |
/shell/functions/open |
RW (access_label) |
rows, cols |
Allocate PTY, returns session_id |
/shell/functions/write |
RW (access_label) |
session + raw body |
Write to PTY |
/shell/functions/read |
RW (access_label) |
session |
Read available output |
/shell/functions/close |
RW (access_label) |
session |
Terminate |
/shell/functions/resize |
RW (access_label) |
session, rows, cols |
ioctl(TIOCSWINSZ) |
The legacy path runs g_cfg.shell (default /bin/sh) as root —
it's assumed the caller has already authenticated via Portal's
/auth/login and has the access_label group. Use only on trusted
control planes.
| Event | When |
|---|---|
/events/shell/exec |
Stateless command executed (legacy) |
/events/shell/session |
Legacy PTY session opened/closed |
The dial-back path logs informational lines via core->log but does
not emit separate events today — every dial-back appears in the main
Portal log with the session_id prefix (first 8 hex chars) for
correlation.
- The dial-back listener is disabled if
shell_port = 0. Useful for Portal instances that should never initiate outbound shells (e.g., a secondary test instance that only needs the target role — setshell_port = 0there, the target-sidedialback_requesthandler is still registered and works). - Two Portal instances on the same host need different
shell_portvalues or one of them set to 0 — the bind otherwise fails withAddress already in useand the listener silently stays disabled (the target-side handler still works, butopen_remoterefuses withlistener not enabled). - On restart the listener re-binds
shell_portand re-loads the TLS context; in-flight sessions die cleanly because their dedicated pthreads see EOF on their fds.
Copyright (c) 2026 — 7kas servicios de internet SL