A macOS menu-bar app and CLI for managing SSH connections — every saved host editable from one place, per-host keys, one-click ControlMaster keepalive, recent-host import from your shell history, and Connect-in-your-terminal-of-choice.
See docs/0.1.0-design.md for the design rationale.
- Download
Bastion-X.Y.Z.dmgfrom the latest release. - Open the DMG and drag
Bastion.appto theApplicationsshortcut inside it. - Open
Bastion.appfrom/Applications. The menu-bar icon appears at the right of the status bar.
Auto-updates check daily via Sparkle 2 with EdDSA signature verification.
- Manages every SSH host you've ever connected to. Imports candidates from your shell history (
~/.zsh_history,~/.bash_history, fish history) and~/.ssh/known_hostswith a checkbox flow during onboarding. - Writes one managed file at
~/.ssh/config.d/bastion.confand injects a single sentinel-guardedIncludeline at the top of~/.ssh/config. Every other tool that reads~/.ssh/config(scp, rsync, mosh, git, VSCode Remote-SSH) sees your managed hosts automatically. - One-click ControlMaster keepalive. A global "Enable for all" toggle in onboarding, per-host override in the editor. Bastion-owned stable
ControlPath ~/.ssh/sockets/bastion-<id>-%p-%randControlPersist 8hby default. Emits a parallelMatch host <hostname>block so plainssh <full-hostname>andssh <alias>share one master with one auth touch. - First-class FIDO/SSO bastion support. Auto-detects SSO-fronted SSH hosts (WebAuthn / hardware-key challenge), routes the first connect through your terminal for the touch dance, then auto-opens a shell tab once the master comes up. Subsequent connects in the same
ControlPersistwindow are instant. - Connect opens the terminal of your choice. Runtime-detected list of installed terminals (iTerm2, Ghostty, Warp, WezTerm, kitty, Alacritty, Rio, Tabby, Hyper, Terminal.app) — your pick gets a one-click Connect from the popover.
- Expandable per-host diagnostics card: resolved address, master uptime, attached process count, last-error, opt-in remote
uname/uptime. - Generate ed25519 keys on demand, store the passphrase in macOS Keychain (
kSecAttrAccessibleWhenUnlockedThisDeviceOnly— never iCloud-synced). - Opt-in notifications when a master drops or comes up, when
ControlPersistexpires, when your SSH cert is about to expire, or when an imported host changes in~/.ssh/configfrom outside the app.
From the CLI (after make install):
bastion --version
bastion --help
# bastion list / show / add / edit / remove
# bastion connect <alias>
# bastion master start|stop|check <alias>
# bastion import --source zsh|bash|fish|known-hosts|ssh-config|all
# bastion terminal list|set <id>
# bastion config doctor
# bastion config sync
# bastion config rollback
# bastion uninstallFrom the menu bar: click the key icon to open the popover. Each host has a ▸ connect button (opens a shell in your terminal of choice) and a chevron that expands an inline diagnostics card. The footer carries the terminal picker, a refresh button, gear (re-run setup), clipboard (copy diagnostics), an update check, and quit.
Bastion is structured as two Mach-Os: the SwiftUI menu app (BastionMenuBar) and the CLI (bastion, embedded in Bastion.app/Contents/Resources/bastion).
The menu app does not invoke ssh directly. It is a controller: every read goes through ssh -G <alias> (the canonical "what would ssh actually do?" probe), every connect shells out to the user's chosen terminal app via AppleScript, and every status check uses ssh -O check. The CLI surface mirrors every operation the menu app offers so anything scriptable from the popover is also scriptable from a shell.
The menu-bar surface itself is AppKit-level — NSStatusItem + NSPopover + NSHostingController — rather than SwiftUI's MenuBarExtra(.window). That choice is empirically load-bearing on macOS 13, where MenuBarExtra(.window)'s private NSPanel does not re-negotiate its intrinsicContentSize when the popover's body shape changes (the host list grows and shrinks, cards expand and collapse). Migrating off it gives Bastion true dynamic popover sizing on every macOS Bastion supports.
Configuration writes go through a two-pass validation step (isolation pass against a tempfile, integration pass against the composed user config) with rollback to bastion.conf.prev on failure. The Bastion-owned Include block in ~/.ssh/config is sentinel-guarded so bastion uninstall removes it cleanly without disturbing the rest of the file.
For the full design rationale see docs/0.1.0-design.md.
Bastion runs as a non-sandboxed app with the hardened runtime enabled and the com.apple.security.automation.apple-events entitlement (required for AppleScript-driven terminal launches like Terminal.app / iTerm2). It does not install a privileged helper — SSH is entirely a user-space activity and Bastion has no root operations.
Auto-updates land via Sparkle 2 from a stable URL: https://github.com/dbuskariol/bastion/releases/latest/download/appcast.xml. The appcast is EdDSA-signed; the public key is baked into Info.plist (SUPublicEDKey). Sparkle refuses to install any update whose signature doesn't verify.
Bastion reads and writes the user's SSH configuration and orchestrates ssh/ssh-add/ssh-keygen on the user's behalf. A few load-bearing properties:
- Bastion owns exactly one file in
~/.ssh/—~/.ssh/config.d/bastion.conf, plus one sentinel-guardedIncludeline at the top of~/.ssh/config. Everything else (existingHostblocks,Match execrules, wildcards, identity files, certs, known_hosts) is left untouched. Hand-edits to those files do not conflict with anything Bastion writes. - The Bastion-owned file is regenerated wholesale on every save, from a single registry (
~/Library/Application Support/Bastion/hosts.json). Manual edits tobastion.confare overwritten — the comment header in the file says so explicitly. - Raw overrides (the per-host Raw tab) refuse to contain
Host,Match,Include, orControlMaster/ControlPath/ControlPersistlines. That keeps the per-host stanza shape stable so the writer can always parse/round-trip it. - Master sockets live under
~/.ssh/sockets/bastion-<stable-id>-%p-%r. The-%p-%rtokens are load-bearing: OpenSSH does not validate the(user, host, port)tuple against an existing master before mux-attaching, so the socket name is the segmentation primitive. Without-%r,ssh otheruser@hostcould attach to a master authenticated as a different user. - Private-key passphrases are stored in the macOS Keychain with
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyso they never sync to iCloud and never decrypt unless the Mac is unlocked. - FIDO/SSO bootstrap runs in the user's terminal, never in a hidden subprocess. The browser-touch dance happens in plain sight; Bastion polls
ssh -O checkafterward to detect when the master came up.
make app
open dist/Bastion.appCLI only:
swift build -c release
.build/release/bastion --versionInstall the CLI globally:
sudo make install # symlinks → /usr/local/bin/bastion
sudo make uninstallRelease builds (Developer ID + hardened runtime + Sparkle keys) are maintainer-only. See RELEASING.md.
MIT. See LICENSE.