25 KiB
shellm and the secpanel Heritage: A Faithful Port, a Modern SCP Browser, and the Case for Shelling Out
A technical walkthrough of how shellm 1.1.0 — a Rust/iced port of Steffen Leich-Nienhaus's classic Tcl/Tk secpanel — preserves the original's command-line semantics byte-for-byte while modernizing the visuals, finishing the stubs the original left undone, and grafting on a Midnight-Commander-style dual-pane SCP browser. Grounded in the source code, not in marketing claims.
There is a particular category of Unix desktop tool that was effectively frozen in amber around 2005. The terminals evolved — rxvt gave way to urxvt, urxvt to Alacritty, Alacritty to Kitty and WezTerm and Foot — and the underlying OpenSSH client gained ciphers, key-exchange algorithms, and config-file directives that the original Tcl scripts never heard of. The toolkits evolved: Tk 8.4 gave way to GTK 2, then GTK 3, then GTK 4, with Qt taking a parallel path through versions 3, 4, 5, and 6. But the SSH connection managers themselves stayed on Tk 8.4, with grey groove borders and beveled buttons, reading their state from flat text files in ~/.secpanel/ or ~/.ssh-gui/ or wherever the particular application chose to put them. The design was sound — these tools were thin wrappers around ssh and scp and ssh-agent, and they worked. The implementation was sound. The look, by 2026 standards, was a museum piece.
shellm is a faithful Rust port of one of those tools — Steffen Leich-Nienhaus's secpanel 0.6.1 — to the iced GUI toolkit. It is not a rewrite in the loose sense. The SSH command builder in src/ssh/command.rs emits -A/-a, -X/-x, -1/-2, -4/-6, -p, -c, -i, -F, -P, -v, -q, -f, -g, -C, -N, -o StrictHostKeyChecking=yes, -o CompressionLevel=N, -L ..., -R ... byte-for-byte as the original Tcl connect proc did, including the asymmetric syntax OpenSSH uses for CompressionLevel versus the equals-sign syntax other SSH implementations expect. The on-disk layout under ~/.shellm/ mirrors the original secpanel's ~/.secpanel/ structure — the mode#unixtime#text history file format, the time#cmd trace log format, the runproc.<ts> runner-script naming — so existing secpanel 0.6.x users can cp -a ~/.secpanel/. ~/.shellm/ and it will Just Work. The Rust port adds a new SCP browser tab, modern terminal auto-detection, a flat-design refresh, and finishes several features the original had stubbed out, but the SSH semantics underneath are unchanged.
This post is a technical walkthrough of how those pieces fit together, grounded in the source code rather than marketing claims. It is organized around the three decisions that most heavily shape shellm's identity: the decision to mirror the original ~/.secpanel/ directory and file formats under a new ~/.shellm/ name, the decision to build the SCP browser on top of sftp -b - rather than a Rust SFTP library, and the decision to ship without any TLS or certificate management anywhere in the stack.
The Heritage Directory: Why ~/.shellm/ Mirrors ~/.secpanel/
The first design decision a port author has to make is whether to keep the original's on-disk layout or rename it to match the new project name. shellm uses its own directory ~/.shellm/ — but the layout, file formats, and naming conventions inside that directory are preserved verbatim from the original secpanel's ~/.secpanel/. The data directory is ~/.shellm/, the config file is ~/.shellm/config, the profiles live in ~/.shellm/profiles/*.profile, the history is ~/.shellm/history, the runner scripts are in ~/.shellm/.runfiles/, and the GPG-encrypted data vault (when enabled) is ~/.shellm/spdata.lck. The internal Rust identifiers reflect the new name throughout: the state struct is Shellm (defined in src/state.rs), the path utilities are in src/data/paths.rs and the functions are named shellm_dir(), ensure_shellm_tree(), profiles_dir(), runfiles_dir(), and so on. The original secpanel identifiers appear only in heritage comments and docs.
This is a deliberate backwards-compatibility decision. The original secpanel used ~/.secpanel/ for two decades; shellm uses ~/.shellm/ as its own directory but mirrors the original's internal layout exactly so a cp -a ~/.secpanel/. ~/.shellm/ brings every profile, config, history entry, and trace log forward without conversion. The legacy Tcl-style config syntax (set configs(key) value) and the Tcl-style profile syntax (set title "...", array set lfs {...}) are still accepted by the loader in src/data/config.rs and src/data/profile.rs, so even hand-edited secpanel files migrate cleanly.
The loader's backwards-compatibility story is the more interesting half of this decision. The original secpanel wrote its config as Tcl-style set configs(key) value lines and its profiles as a mix of set title "...", array set lfs {...}, and similar Tcl idioms. shellm writes both as TOML — the toml 0.8 crate, with serde derives on the Configs and Profile structs — but the loader accepts the original Tcl syntax and converts it in-memory. This means a user with a working secpanel 0.6.x installation can install shellm, run it once, and see all their profiles appear in the Connections tab without any conversion step. The Tcl-style config is parsed with a simple line-based scanner that looks for set configs\(([^)]+)\) (.*) patterns; the Tcl-style profile is parsed with a similar scanner that handles set <field> <value>, array set <name> { ... }, and the few other idioms secpanel used. Once loaded, the data is written back as TOML on the next save, so the migration is transparent and one-way.
The history file format is preserved for the same reason. The original secpanel wrote mode#unixtime#text lines to ~/.secpanel/history — for example, connect#1700000000#ssh -A -X user@host. shellm writes the same format to ~/.shellm/history, with the same field separators, so the history viewer dialog can read both old and new entries without knowing which version wrote them (and a migrated history file Just Works). The trace log format is similarly preserved: time#cmd lines in ~/.shellm/.runfiles/trace.log, one per executed command. The runner-script naming convention (runproc.<unix-timestamp> for the inner script, runproc.<unix-timestamp>-run for the outer wrapper that execs the terminal) is preserved from the original's provrunfile proc. The version marker (.init) is preserved. None of this is technically necessary — shellm could have invented its own formats — but the cost of preservation is low and the benefit (drop-in compatibility with two decades of existing secpanel installations) is high.
The SCP Browser: Why ssh+scp (and Optional sftp -b -) Instead of a Rust Library
The new SCP Browser tab is the largest single feature added in shellm 1.1.0, and the implementation choice that most defines its character is the decision to shell out to the OpenSSH ssh, scp, and (optionally) sftp command-line tools rather than pull in a Rust SFTP library. The implementation lives in src/scp_browser/mod.rs — about 600 lines of Rust that default to ssh host -- ls -la / mkdir -p / rm -rf for remote file operations and scp for file transfers, plus std::fs for local operations. The sftp -b - backend is opt-in via the ScpBackend config toggle for users whose sshd restricts shell access. All operations run inside tokio::task::spawn_blocking so the UI stays responsive.
The alternative — using a Rust SFTP library like russh-sftp or ssh2 — would have meant re-implementing the SSH connection lifecycle that shellm already manages via ssh and ssh-agent. The Rust SSH libraries are fine pieces of software, but they have their own connection state, their own key management, their own agent protocol implementations, and their own cipher suites. Pulling one in would mean shellm has two SSH codepaths: the command-line ssh path used for terminal sessions and the library path used for the SCP browser. They would diverge in subtle ways — different hostkey verification behavior, different config-file parsing, different agent forwarding semantics — and the user would have to know which path was in use at any given moment. The shell-out approach keeps a single SSH codepath: the system ssh binary, configured exactly the same way for terminal sessions and for SFTP operations.
The sftp -b - interface is the key to making this work cleanly. The -b - flag tells sftp to read batch commands from standard input rather than from a file or interactive prompt, and sftp returns a non-zero exit code if any command in the batch fails. This makes it trivial to compose multi-command operations: to list a remote directory, shellm writes cd <path>\nls -la\n to sftp's stdin, captures the stdout, and parses the ls -la output to extract name, size, permissions, and modified date. To make a directory, it writes mkdir "<path>"\n. To delete a file or directory, it writes rm "<path>"\n — rm works for both files and directories in OpenSSH 5.4 and later, which covers every OpenSSH installation still in use in 2026.
For file transfers, shellm uses scp directly rather than going through sftp. Upload uses scp -P port local user@host:remote, auto-detecting directories and adding -r when needed; download uses scp -r -P port user@host:remote local. The scp protocol is older and less featureful than SFTP, but it handles the bulk-transfer case more efficiently — scp opens a single data channel and streams bytes, while sftp does chunked reads and writes with per-chunk acknowledgments. For a 4 GiB log file transfer, the difference is measurable.
The UI side of the SCP browser is a dual-pane file manager with Midnight-Commander-style single-click navigation. The top bar has a profile picker, Connect/Disconnect buttons, and a live connection status indicator (green dot plus user@host:port). Two panes sit side-by-side — Local on the left, Remote on the right — and each pane has a path bar with a Go button, a toolbar with up/refresh/new-folder/delete actions, and a three-column file list (Name, Size, Modified). Directories are bold blue; symlinks are cyan; regular files use the default text color; the selected row gets a light-blue tint with an accent border. The center transfer column has large Upload (→) and Download (←) buttons that activate only when a transferable file or directory is selected, and a bottom status bar shows entry counts or the last operation result.
The navigation model is worth calling out because it differs from most modern file managers. Single-click on a row selects it for upload, download, or delete; single-click on a directory navigates into it; single-click on the .. entry (always at the top of the list, except at the filesystem root) goes up one level. This is the Midnight Commander idiom, not the Finder/Explorer double-click idiom, and it takes a few minutes to get used to if you have not used MC before. The choice was deliberate: the SCP browser is meant for power users who want to move files quickly, and the MC idiom is faster once you internalize it — you select-and-navigate in one motion rather than two.
The SSH Command Builder: Byte-for-Byte Faithful to the Original
The build_ssh_command function in src/ssh/command.rs is the load-bearing piece of the port. It takes a Profile and a Configs struct and produces an SshCommand — a vector of string tokens, rendered as a shell-quoted line for the runner script. The order of arguments, the conditional emission of each flag, the asymmetric CompressionLevel syntax between OpenSSH and other SSH implementations, the <TARGET-HOST> and <LOCAL-HOST> placeholder substitution in port forwards — all of this is preserved from the original Tcl connect proc.
The argument ordering matters because ssh parses its command line in a specific order, and some flags interact. The original secpanel emitted flags in this order: ssh binary, -l user (if user is set and Ask is off), -A or -a (agent forwarding, always emit one), -X or -x (X11 forwarding — note the inverted sense: x11forward=true means no X11, so -x), -N (no remote command, OpenSSH only, only if no command and no subsystem), -o StrictHostKeyChecking=yes (if strict hostkey is on), protocol version flag (-1, -2, or -o Protocol=), IP version flag (-4 or -6), -p port (if not 22), -c cipher (if not default), -i identity (if set), -F cfgfile (if set), -P (no privileged source port), -v/-q/-f/-g (verbose/quiet/fork/gateway), -C and CompressionLevel=N (if compress is on), -L entries (local forwards), -R entries (remote forwards), host, and finally the command or subsystem (subsystem takes priority, with -s prefix). shellm emits the same flags in the same order.
The CompressionLevel asymmetry is the kind of detail that would be easy to get wrong in a rewrite. OpenSSH accepts -o CompressionLevel 6 (space-separated), while other SSH implementations (the commercial SSH Tectia, the older SSH Communications Security versions) accept -o CompressionLevel=6 (equals-sign). The original secpanel handled this with a Tcl if based on the configured SSH implementation; shellm handles it with a Rust match on the SshImpl enum (OpenSsh versus Other) in the same place, emitting the same syntax for each case. The test suite in src/ssh/command.rs verifies this — basic_command, port_forward, shell_quote_basic, and custom_identity_and_cfgfile cover the common cases.
Port forward placeholder substitution is similarly preserved. The original secpanel allowed <TARGET-HOST> and <LOCAL-HOST> placeholders in forward entries, substituted at connect time with the profile's host and the local machine's hostname respectively. This lets you write a forward like 8080:<TARGET-HOST>:80 that tunnels HTTP to whatever host you happen to be connecting to — useful for profile reuse across environments. shellm's resolve_forwards function in src/ssh/command.rs does the same substitution, emitting -L 8080:<host>:80 or -R ... as appropriate. The forward struct itself (in src/data/profile.rs) preserves the placeholder string in the host field — the substitution happens at command-build time, not at profile-save time, so the profile on disk still says <TARGET-HOST> and works correctly if you connect to a different host later.
The Runner Scripts: A Two-Layer Wrapper
The runner-script mechanism is one of the more old-fashioned pieces of shellm's design, and it is preserved from the original secpanel without modification. When you click Connect, shellm does not exec ssh directly — it writes a small shell script to ~/.shellm/.runfiles/runproc.<unix-timestamp> containing the ssh command line, writes a second wrapper script to runproc.<unix-timestamp>-run that execs the configured terminal with the inner script as its argument, and execs the wrapper. The terminal opens, runs the inner script, and the SSH session appears.
This indirection serves two purposes. First, it leaves a forensic record on disk: every SSH command shellm has ever launched is recoverable from ~/.shellm/.runfiles/, with timestamps, which is useful for debugging and auditing. The trace log at ~/.shellm/.runfiles/trace.log records the same information in time#cmd format for quick grep-based analysis. Second, it allows the runner script to be modified before launch — for example, the key-distribution wizard generates a multi-target script that loops over selected profiles, and that script is launched via the same runner mechanism as a simple ssh connection. The build_keydist_script function in src/ssh/command.rs generates this multi-target script, complete with mkdir $HOME/.ssh 2>/dev/null, the grep check for existing authorized_keys entries, and the if/elif/else branching on the return code (0 = already present, 255 = connection error, anything else = append the key with the correct permissions).
The wrapper layer exists because terminals do not agree on a common syntax for "run this command and stay open". xterm uses -e command args...; gnome-terminal uses -- command args...; Alacritty uses -e command args...; Kitty uses command args... directly without a flag. The terminal.rs module has a command_for_terminal function that takes a TerminalKind and a runner script path and produces the correct argument vector for that specific terminal. This is the kind of glue code that is not interesting but is unavoidable — every terminal launcher in the history of Unix has had to solve it, and there is no standard.
Modernization: Terminals, Visuals, and Stubs
The modernization work in shellm 1.1.0 falls into three buckets: the terminal list, the visual design, and the stub finishing.
The terminal list in src/data/terminal.rs has 22 entries — the original 11 from secpanel's termdefs.txt (aterm, Eterm, multi-aterm, Konsole, gnome-terminal, xterm, rxvt, mrxvt, xvt, XFCE Terminal, PuTTY's terminal) plus ten modern additions (rs-mrxvt, rxvt-unicode, Alacritty, Kitty, WezTerm, Foot, GNOME Console, xfce4-terminal, Tabby, Ghostty, Warp). The default terminal is auto-detected at first run with a fallback chain that prefers modern, GPU-accelerated terminals: rs-mrxvt first (the preferred default), then alacritty, kitty, wezterm, ghostty, warp, tabby, foot, kgx, rxvt-unicode, xfce4-terminal, then the original mrxvt and xterm as always-available fallbacks. The Configs tab shows availability indicators (green dot for present, red dot for absent) next to each terminal in the dropdown, so the user can see at a glance what will actually work on their system. This was a recurring support issue with the original secpanel — users would pick a terminal from the list, click Connect, and nothing would happen because that terminal was not installed.
The visual design refresh is the most visible change. The 1995 Tk look — grey groove borders, beveled buttons, Motif-style scrollbars — is gone. The new design language is flat surfaces with subtle 1px borders, 6px corner radius on cards/buttons/inputs, a modern blue accent (#2563EB) for primary actions, card-style containers, an underline-style tab bar where the active tab gets a blue underline and dark text, a dot-style agent indicator (a small colored dot — green for our agent, red for no agent, yellow for external — with a label), modal panels with shadow that float on a dimmed backdrop, four button variants (Primary, Secondary, Danger, Ghost) for a modern hierarchy of emphasis, and section labels in small uppercase grey text for grouping. The style functions live in src/widgets/styles.rs, and the palette helpers (hex strings to iced::Color) live in src/theme.rs and src/theme_style.rs. None of this changes the underlying functionality — it is a pure visual refresh, and the SSH command lines produced are identical to what the original secpanel produced.
The stub finishing is the most substantive change. The original secpanel 0.6.1 had several features that were present in the menu but not actually wired up — the key distribution wizard's final step, the Ask-for-user modal at connect time, the color and font settings save, the profile export, the remote account manager, the GPG data protection, the AddIdentity and SetDefIdent file pickers, and the right-click context menu. shellm finishes all of them. The key distribution wizard's final step now calls build_keydist_script and launches the result via the standard launch_interactive runner. The Ask-for-user modal is a real modal flow that defers the connect until the user submits a username. The color and font dialogs write through to configs.toml. The profile export generates all four formats: shell script, SSH config entry, GNOME .desktop, KDE .desktop. The remote account manager reads ~/.shosts and ~/.ssh/authorized_keys via scp, lets the user edit them in a dialog, and writes them back via scp. The GPG data protection is a full round-trip encrypt/decrypt of the entire ~/.shellm/ tree using GnuPG AES256, triggered by the Protect menu entry.
Security: No TLS, No Certificates, No Problem
One of the more striking architectural decisions in shellm is the complete absence of TLS or certificate management anywhere in the stack. There is no rustls, no openssl, no crypto/tls, no certificate authority, no key rotation, no certificate pinning. Transport security is delegated entirely to SSH itself — the SSH connection between shellm's host and the remote server is secured by SSH's own transport layer (which has its own key exchange, its own hostkey verification, its own cipher suite), and there is no other network communication for TLS to protect.
This is the same security model the original secpanel used, and it is the right model for an SSH connection manager. shellm does not run a server. It does not listen on any port. It does not make outbound network connections except by execing the system ssh binary, which then makes the connection using its own configuration and its own key material. The only secrets shellm holds are the paths to private key files (which it never reads — it just passes the paths to ssh -i), the passphrases typed into the GPG data protection dialog (which are passed to gpg via stdin and never written to disk), and the contents of ~/.shellm/config and ~/.shellm/profiles/*.profile (which may contain hostnames, usernames, and port forward specifications, but never private keys or passphrases unless the user has explicitly put them there). The GPG data protection, when enabled, encrypts the entire ~/.shellm/ tree into spdata.lck using GnuPG AES256 with a user-supplied passphrase — this is symmetric encryption, no public-key infrastructure, no certificate management.
The defense-in-depth model is the standard Unix one: filesystem permissions on ~/.shellm/ (mode 0700, set by ensure_shellm_tree()), filesystem permissions on ~/.ssh/ (the user is responsible for keeping this 0700, and OpenSSH will refuse to use private keys with looser permissions), and the SSH protocol itself for transport security. There is no application-layer TLS because there is no application-layer network communication to protect.
This is not a security model that works for every tool. If shellm were a multi-user SSH gateway with a web UI, it would need TLS. If it were a centralized configuration server pushing profiles to multiple workstations, it would need TLS. But it is a single-user desktop application that execs ssh, and SSH already has a transport security layer that has been battle-tested for three decades. Adding a second transport security layer on top of it would be worse than useless — it would add operational complexity (certificate renewal, cipher suite negotiation, SNI mismatches) without adding any actual security, because the SSH layer is already the trust boundary.
Putting It All Together
The canonical shellm workflow ties all these pieces together:
- First run creates
~/.shellm/with mode 0700, writes the default config, and opens the main window. - Profile creation writes a TOML file to
~/.shellm/profiles/<name>.profile, with all the SSH-relevant fields the user filled in. - Connect calls
build_ssh_command(profile, configs, local_host)insrc/ssh/command.rsto produce thesshargument vector, writes a runner script to~/.shellm/.runfiles/runproc.<ts>, writes a wrapper torunproc.<ts>-runthat execs the configured terminal, and execs the wrapper. - The terminal opens, runs the runner script, and the SSH session appears. The user interacts with the remote host as usual.
- SCP Browser (optional) opens a second connection via
ssh+scpfor file operations, using the same profile's host/user/port/identity. List, mkdir, delete usessh host -- ls/mkdir/rm(orsftp -b -if the user opted in via the Configs tab); upload/download usescp. - History and trace logs record what happened:
mode#unixtime#textlines in~/.shellm/history,time#cmdlines in~/.shellm/.runfiles/trace.log, and the runner scripts themselves left on disk in~/.shellm/.runfiles/for forensic inspection. - On exit (optional, if GPG data protection is enabled) the entire
~/.shellm/tree is tarred and encrypted intospdata.lckusing GnuPG AES256 with the user-supplied passphrase. The plaintext files are deleted. On next launch, the user is prompted for the passphrase and the tree is decrypted back into place.
Each piece of this pipeline is independently simple — ssh is ssh, sftp is sftp, scp is scp, ssh-keygen is ssh-keygen, ssh-agent is ssh-agent, gpg is gpg. The value shellm adds is not in reimplementing any of them; it is in the GUI that ties them together, the profile persistence that remembers your configurations, the runner-script mechanism that leaves a forensic trail, and the backwards-compatible file formats that let two decades of secpanel users upgrade without losing their data. The Rust/iced port modernizes the visuals and finishes the stubs, but the underlying tool is the same one Steffen Leich-Nienhaus designed in the early 2000s — and that design has held up remarkably well.
shellm 1.1.0 is developed by Jeremy Anderson at dcos.net and released under GPL-2.0-or-later. The original secpanel was developed by Steffen Leich-Nienhaus at themediahost.de and is also GPL-2.0-or-later. shellm is a faithful Rust/iced modernization of secpanel and would not exist without Steffen's original work; all credit for the design and the SSH command-line semantics belongs to him. This article was written in 2026 against the 1.1.0 release.