Skip to content

Architecture

Bastion for Apple is a shared core with three apps on top.

              +--------------------+
              |      SSHCore       |
              |  SwiftNIO SSH, NIO |
              +---------+----------+
                        |
       +----------------+-----------------+
       |                |                 |
  bastion-cli      iOS and macOS        tvOS
     Swift         App/ (SwiftUI)     App/TVApp/

SSHCore

Sources/SSHCore/ holds everything that is not user interface. The core is built and tested with Swift Package Manager, without Xcode, on Linux and macOS.

Area Examples
SSH Sessions, shell and exec, user authentication, key parsing, OpenSSH certificates, SSH agent, Secure Enclave keys
Host keys Trust-on-first-use validation (HostKeyValidator) and KnownHosts
Transfer and forwarding SFTP, archives, local, remote and dynamic (SOCKS) port forwarding, ProxyJump
Other connections Telnet, serial, Wake-on-LAN, WireGuard profiles, Tailscale status
Data The host list (Host, HostStore), snippets, command library, ~/.ssh/config import, app settings, S3 connections
Sync SyncEngine, SyncProvider, the folder transports and SyncCrypto; see Sync format
Integrations Docker, Kubernetes, Proxmox, TrueNAS, Unraid, Cloudflare, GitHub, Bitwarden and S3 clients, OAuth (PKCE) tokens

Reading ~/.ssh/config expands Include inline, skips cycles and reads only regular files. A load from disk stops at shared limits (256 file reads, 8 MiB, 100,000 entries and host patterns, 4,096 directory entries, 4,096 include patterns) and keeps what it read before the limit, so a hostile or runaway config cannot exhaust memory or time.

The package declares macOS 13 and iOS 16 as minimum platforms.

The apps

  • iOS, iPadOS and macOS share the code in App/ and are separate targets (Bastion, Bastion-macOS). The terminal is built on SwiftTerm. The macOS app is sandboxed with network client and serial device entitlements.
  • tvOS lives in App/TVApp/ (Bastion-tvOS) and must not get SwiftTerm or APIs that tvOS lacks. Cloud sign-in uses the OAuth device flow.
  • Cloud sync in the apps uses OAuth: Dropbox, Google Drive and OneDrive on iOS and macOS; Google Drive and OneDrive on tvOS.
  • Deployment targets for the apps are iOS 17, macOS 14 and tvOS 17 (App/project.yml).
  • The Xcode project is generated by XcodeGen through App/generate-project.sh, which uses App/project.dependencies.yml: it includes App/project.yml and pins SwiftTerm to the exact version in App/Package.swift. App/Package.swift is the dependency manifest that Renovate reads; it must never end up among the app's source files.

Dependency boundaries

Manifest Owns
Package.swift SSHCore and bastion-cli (swift-nio-ssh, swift-nio, swift-crypto)
App/Package.swift The apps' Swift packages, for example SwiftTerm
App/Gemfile fastlane

Update a dependency in the manifest that actually owns it. Renovate opens the updates, using the shared configuration that renovate.json extends. Two pins in Package.swift are deliberate and explained in comments: swift-nio-ssh follows a branch until upstream accepts swift-crypto 5.x, and swift-nio is pinned exactly because of a Windows compile bug upstream.

Signing and distribution

App/project.yml holds the signing settings: the development team, manual signing and the provisioning profile names for each target. App/fastlane/Fastfile has a beta lane for iOS and for tvOS that fetches certificates and profiles read-only with match and uploads to TestFlight. The App Store Connect API key, team ID and match credentials come from environment variables. The Fastfile refers to a .github/workflows/testflight.yml workflow, but no such workflow exists in the repo.

Security boundary

SSHCore owns host key verification and authentication. The user interface must never build shortcuts around the core's checks. Keys and passwords are stored in the keychain, never in files in the repo or in test data.

Key deployment (KeyManagement) appends exactly one authorized_keys record: it rejects empty keys and keys containing CR, LF or NUL before building the remote command, and generated public key lines drop comments containing those characters.

Local files reach the remote side only through SSHCore's guarded readers: SFTP uploads (SFTPUploadSource) open each entry relative to its parent directory descriptor and refuse symbolic links and special files, so a path swapped during an upload cannot redirect the read.

ZIP archives are extracted on the server by a Python 3 script (ArchiveOperations.extractZipCommand) instead of unzip: it rejects absolute paths, .., symbolic links and special files, refuses archives whose declared size exceeds the free space, and writes through directory descriptors so existing links in the destination cannot redirect writes. The server therefore needs Python 3 to extract ZIP files.