Agentless SSH host inventory with Citadel

An "agentless" scanner isn't magic. It's a scanner that talks to sshd on port 22 the same way you do — and then runs four or five commands to read the parts of the filesystem that describe the host. This post is about how Noxen does that from inside a Mac app, using Citadel on top of Swift NIO SSH.

The API contract

The probe protocol is tiny:

public protocol SSHHostProbing: Sendable {
    func probe(host: String, port: Int, username: String,
               credential: SSHCredential) async throws -> HostInventory
}

SSHCredential is either a private-key file (with optional passphrase) or a password. HostInventory is a plain value type holding the OS identity, the kernel version, every installed package, an optional sshd_config dictionary, and the authorized-keys list.

What it runs on the remote host

  1. cat /etc/os-release — OS identity.
  2. uname -r — kernel version.
  3. Per OS family:
    • Debian / Ubuntu: dpkg-query -W -f='%{Package}\t%{Version}\t%{Architecture}\n'
    • RHEL / Alma / Rocky / Fedora: rpm -qa --queryformat '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n'
    • Alpine: apk info -vv
  4. cat /etc/ssh/sshd_config 2>/dev/null || true — best-effort; usually readable by non-root.
  5. cat $HOME/.ssh/authorized_keys 2>/dev/null || true — per-user keys.

Everything is tab-delimited and parsed in Swift. Parsers live in a standalone type (HostInventoryParser) so they're unit- tested without network or SSH involvement.

Why Citadel, not OpenSSH shell-out

Early prototypes exec'd /usr/bin/ssh via Process. It worked, but:

Key loading

Noxen's ED25519 path uses Swift Crypto:

let keyString = try String(contentsOf: url, encoding: .utf8)
let privateKey = try Curve25519.Signing.PrivateKey(
    sshEd25519: keyString,
    decryptionKey: passphrase?.data(using: .utf8)
)
authMethod = .ed25519(username: username, privateKey: privateKey)

Citadel's init(sshEd25519:) extension handles the OpenSSH private key encoding (the one that looks like -----BEGIN OPENSSH PRIVATE KEY-----). RSA has a parallel path via Insecure.RSA.PrivateKey(sshRsa:). Encrypted keys work too — the passphrase is passed in as Data.

Performance

On a Vagrant Ubuntu 22.04 guest over localhost, the full probe (os-release + kernel + 572-package dpkg dump + sshd_config + authorized keys) runs in 70 ms. Over a real WAN to a VPS, expect 500 ms–2 s depending on RTT.

What's intentionally not here

The probe source is single-file Swift under 200 LoC. If you want to see the whole of it, the repository will open once v1.0 ships.