🔧 The Problem
A few weeks ago a Swift-based macOS SSH tunnel manager started making the rounds here — a menu bar app that lets you spin up local/remote port forwards without touching a terminal. It's a genuinely nice pattern: SSH tunnels are one of those tools everyone reaches for constantly (jump boxes, database access, staging environments) but nobody wants to remember the flag syntax for.
bash
ssh -N -L 5432:db.internal:5432 -i ~/.ssh/jump_key jumpbox.example.com
That command is fine until you have twelve of them across three environments, and you forget which one you killed last Tuesday.
I wanted the same convenience but without being locked to macOS, so I built it twice in Rust: once as a CLI with a TOML-driven tunnel registry, and once as a native GUI using tauri. This post is about what that comparison actually cost me — not in "which is better" terms, but in concrete trade-offs around distribution, process management, and platform integration.
🧱 The Core: Managing SSH as a Child Process
Both versions share the same backend logic. Rust doesn't have a native SSH client library that's production-ready enough for arbitrary key/agent auth quirks, so both versions shell out to the system ssh binary and manage it as a subprocess — same approach the Swift app uses under the hood, incidentally.
rust
use std::process::{Command, Child, Stdio};
use std::collections::HashMap;
pub struct Tunnel {
pub name: String,
pub local_port: u16,
pub remote_host: String,
pub remote_port: u16,
pub ssh_host: String,
process: Option,
}
impl Tunnel {
pub fn start(&mut self) -> std::io::Result<()> {
let forward = format!("{}:{}:{}", self.local_port, self.remote_host, self.remote_port);
let child = Command::new("ssh")
.args(["-N", "-L", &forward, &self.ssh_host])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
self.process = Some(child);
Ok(())
}
pub fn stop(&mut self) -> std::io::Result<()> {
if let Some(mut child) = self.process.take() {
child.kill()?;
child.wait()?;
}
Ok(())
}
pub fn is_alive(&mut self) -> bool {
match &mut self.process {
Some(child) => matches!(child.try_wait(), Ok(None)),
None => false,
}
}
}
pub type TunnelRegistry = HashMap;
This part was identical effort in both builds. The divergence starts the moment you decide how a human is supposed to interact with it.
🖥️ The CLI Path
The CLI version uses clap for argument parsing and a TOML config file for tunnel definitions:
toml
[tunnels.staging_db]
ssh_host = "jumpbox.example.com"
local_port = 5432
remote_host = "db.internal"
remote_port = 5432
[tunnels.staging_redis]
ssh_host = "jumpbox.example.com"
local_port = 6379
remote_host = "cache.internal"
remote_port = 6379
bash
tunlctl up staging_db
tunlctl status
tunlctl down staging_db
tunlctl up --all
What this bought me:
-
Distribution is trivial.
cargo install tunlctl, or a single static binary viacrossfor Linux/macOS/Windows. No code signing, no notarization, no App Store review queue. -
Scriptability for free. People immediately asked for
tunlctl up staging --jsonto pipe into other tooling. A GUI can't casually do that. -
Persistence via existing tools. Backgrounding and keeping tunnels alive after the CLI exits meant either double-forking or leaning on
systemd/launchdunit files I generate on demand. That's more plumbing than a GUI app gets automatically just by staying resident.
What it cost me:
- No visual "this tunnel died" signal. You either poll
tunlctl statusor you don't find out until your app starts throwing connection refused errors. - No system tray indicator, no click-to-toggle. For a tool used dozens of times a day, keystrokes and cognitive load add up in a way a menu bar icon avoids entirely.
🪟 The GUI Path
The Tauri version wraps the same Tunnel struct behind commands, and Rust plus a lightweight HTML/CSS frontend for the tray menu and window:
rust
[tauri::command]
fn toggle_tunnel(name: String, state: tauri::State) -> Result {
let mut registry = state.registry.lock().map_err(|e| e.to_string())?;
let tunnel = registry.get_mut(&name).ok_or("tunnel not found")?;
if tunnel.is_alive() {
tunnel.stop().map_err(|e| e.to_string())?;
Ok(false)
} else {
tunnel.start().map_err(|e| e.to_string())?;
Ok(true)
}
}
What this bought me:
- Ambient status. A tray icon that turns green/red per tunnel, updated by a background poller, is genuinely better UX than typing a status command. This is the single biggest reason the Swift app resonated with people.
- Process lifetime is free. The app itself is the long-running process; tunnels live and die with it, no daemon management needed.
-
Native feel where it matters, e.g. macOS keychain integration for SSH passphrases instead of relying on
ssh-agentbeing configured correctly.
What it cost me:
-
Distribution got real overhead. Unsigned builds trigger Gatekeeper warnings on macOS and SmartScreen on Windows. Signing a macOS app means an Apple Developer account, notarization via
xcrun notarytool, and a CI step that didn't exist in the CLI world at all. -
Bundle size and build complexity jumped. Tauri is far lighter than Electron, but you're still shipping a webview-based frontend, dealing with
tauri.conf.json, and debugging IPC serialization between Rust and JS for things that were plain function calls in the CLI. -
Cross-platform tray behavior is not actually uniform. Linux tray icon support depends on the desktop environment having a functioning
StatusNotifierItemimplementation. It's a smaller thing than you'd hope for a "cross-platform" story.
⚖️ The Actual Trade-off
If I had to compress this into one sentence: the CLI wins on time-to-ship and the GUI wins on time-to-use.
For a tool I'm building for myself and a small team that already lives in a terminal, tunlctl shipped in an afternoon and required zero ongoing signing infrastructure. For something meant to be handed to less terminal-comfortable teammates, or anyone who wants "click icon, tunnel appears," the GUI's ambient status indicator justified every bit of the notarization pain.
My actual answer ended up being both, sharing the same core crate — the CLI is the source of truth and the automation-friendly interface, and the GUI is a thin, optional shell around it for people who want the tray icon. That's not a cop-out; it's the same reason docker has both a CLI and Docker Desktop.
🙋 Over to You
If you've built or maintained an internal dev tool that started as a CLI and grew a GUI (or vice versa) — where did the complexity actually show up for you? Was it distribution, state management, or something else entirely?
If you want to poke at the code, the tunnel-management core described here is intentionally backend-agnostic — happy to expand on the daemon/launchd integration in a follow-up if there's interest.
Top comments (0)