DEV Community

Renato Silva
Renato Silva

Posted on

SSH Tunnel Manager in Rust: CLI vs Native GUI Trade-offs

🔧 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,
    }
}
Enter fullscreen mode Exit fullscreen mode

}

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 via cross for Linux/macOS/Windows. No code signing, no notarization, no App Store review queue.
  • Scriptability for free. People immediately asked for tunlctl up staging --json to 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/launchd unit 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 status or 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)
}
Enter fullscreen mode Exit fullscreen mode

}

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-agent being 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 StatusNotifierItem implementation. 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)