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
cat /etc/os-release— OS identity.uname -r— kernel version.-
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
- Debian / Ubuntu:
cat /etc/ssh/sshd_config 2>/dev/null || true— best-effort; usually readable by non-root.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:
-
App Sandbox:
Processon an/usr/bin/binary is allowed, but the interactivesshprompt for unknown host keys is unusable from inside a sandboxed GUI app. Auto-accepting means disablingStrictHostKeyChecking, which is a footgun. -
Parsing:
ssh's stderr interleaves banner chatter with our command output. Easy to handle once, miserable to handle every release. -
Distribution: Swift NIO SSH compiles pure-Swift.
Citadel wraps it, stays current, and has an
executeCommand(_:)API that returns the command's stdout as aByteBuffer. No process lifecycle to manage.
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
-
No sudo escalation. Noxen reads what the
connecting user can read. If
sshd_configrequires root, it's silently skipped and noted as a partial inventory. -
No authenticated probes. We never run arbitrary
commands beyond the read-only list above. The
SSHHostProbingprotocol is deliberately narrow. -
No persistent connection. Every scan opens a
fresh SSH session and closes it when inventory is done. Keeps
state simple and makes
fail-opensemantics cleaner if a box is unreachable.
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.