Hardline architecture

Inside Hardline: the snapshot, atomic write, validate and confirm transaction, the boot guard, state on disk, the fixed program list and the threat model.

Hardline is a single static Rust binary with no daemon and no network access. This page is how it works inside: the transaction every change goes through, what it keeps on disk, the programs it is allowed to run, and what it defends against. The full design document is ARCHITECTURE.md.

Four layers

  • Controls: declarative TOML files in controls/, compiled into the binary with include_str!. Nothing is loaded from disk at run time, so a writable directory can't add a control.
  • Probes: read-only checks (sshd_effective, sysctl, module_disabled, file_mode, …). audit only ever runs these.
  • Actions: typed fixes (sshd_directive, sysctl, module_disable, …), each with a matching undo.
  • Guard: the revert timer, the pending marker and the boot guard that make SSH and firewall changes provable.

A control, exactly as shipped

A control names a check kind and a fix kind. It cannot contain a command, a path to execute or a script, because the schema has no field for one. Unknown keys are a build error.

schema = 1
id = "ssh-x11-forwarding"
phase = 1
title = "Disable SSH X11 forwarding"
severity = "high"
profiles = ["baseline", "stig"]
guide = "ssh-x11"
preflight = ["sshd-config-valid"]

[refs]
stig = ["UBTU-24-300022"]
cci = ["CCI-000366"]

[check]
kind = "sshd_effective"
key = "x11forwarding"
expect = { equals = "no" }

[fix]
kind = "sshd_directive"
directive = "X11Forwarding"
value = "no"
schema = 1
id = "kernel-dccp"
phase = 7
title = "The DCCP network protocol can't be loaded"
severity = "low"
profiles = ["baseline"]
guide = "kernel-modules"

[check]
kind = "module_disabled"
module = "dccp"

[fix]
kind = "module_disable"
module = "dccp"

The guide key points at the shared text behind hardline explain and /hardline/controls/, so the CLI and this site can't disagree.

The apply transaction

hardline apply --phase N --commit
  │
  ├─ plan        controls × profile × phase × distro, minus config skips
  ├─ probe       read-only: sshd -T per user, nft list, sysctl, files…
  ├─ preflight   key present? sshd config valid? not cutting own root login?
  ├─ snapshot    copy every target → /var/lib/hardline/snapshots/phase<N>-<ts>/
  ├─ write       temp file in same dir → fsync → rename(2) → fsync dir → restorecon (SELinux)
  ├─ validate    sshd -t / nft -c / firewall-offline-cmd --check-config / dockerd --validate
  │                └─ fail → restore snapshot, nothing reloaded
  ├─ arm guard   pending.json + systemd-run revert timer (+ hardline-guard.service at boot)
  ├─ activate    systemctl reload ssh / nft -f / firewall-cmd --reload / sysctl --system
  └─ wait        `hardline confirm` from a NEW SSH connection → disarm, write baseline
                 no confirm before the timer → revert snapshot, reload, clear pending

Phases with no lockout risk (kernel, audit, accounts, containers) still snapshot, write atomically and validate, but commit immediately; there is nothing to confirm. Rollback restores bytes, mode, owner and SELinux label from the manifest, refuses if a file changed since apply unless you pass --force, and puts back package-created config files instead of deleting them. Packages Hardline installed are left installed.

Why confirm needs a new connection

apply records SSH_CONNECTION (client IP and port, server IP and port). confirm compares its own and refuses a match: surviving an sshd reload proves an existing session is fine, not that a new login works. The new connection is the proof. On a console, confirm --from-console is the explicit escape hatch.

# session 1
sudo hardline apply --phase 1 --commit     # countdown starts (120 s)
sudo hardline confirm                      # refused: same connection

# session 2, a brand-new ssh login
sudo hardline confirm                      # accepted, guard disarmed

Reboots and crashes

The revert timer is a transient systemd unit, so it fires even if Hardline, your shell or your network dies. A reboot clears transient units, so hardline-guard.service runs early at boot, ordered before sysinit.target, ssh/sshd, ufw, firewalld and Hardline's own nftables loader; if pending.json exists it reverts before anything can listen. Both paths are exercised on real VMs before every release.

State on disk

/var/lib/hardline/                 0700 root:root
├── baseline.json                  last committed audit (diff + watch compare against it)
├── pending.json                   only while a change waits for confirm
└── snapshots/phase<N>-<timestamp>/
    ├── manifest.json              per file: path, existed, mode, uid, gid, sha256; plus units, packages, firewall + sysctl state
    └── files/…                    byte-for-byte copies of every touched file
/run/hardline.lock                 flock: one Hardline at a time
/etc/hardline/config.toml          optional; can only skip controls, with a reason
/etc/systemd/system/hardline-guard.service   boot-time revert, ordered before ssh + firewall

hardline diff compares the live server with baseline.json and each managed file with its recorded SHA-256.

Running programs

Every process starts from one module. Arguments are a vector, never a string, so there is no shell and no quoting to get wrong. The environment is cleared, which also stops PATH and LD_PRELOAD tricks.

// src/exec.rs: the only place Hardline starts a process.
pub enum Program { Sshd, Systemctl, SystemdRun, Ufw, Nft, Sysctl, Modprobe, /* … */ }

impl Program {
    fn path(self) -> &'static str {
        match self {
            Program::Sshd => "/usr/sbin/sshd",
            Program::Nft => "/usr/sbin/nft",
            // …one absolute path per program, nothing else can run
        }
    }
}
// Command::new(prog.path()).args(argv).env_clear().env("LC_ALL", "C")

The complete list of programs Hardline can run: sshd systemctl systemd-run ufw nft firewall-cmd firewall-offline-cmd sysctl modprobe auditctl augenrules aa-status getenforce restorecon apt-get dpkg-query dnf rpm passwd docker dockerd nginx apache2ctl openssl timeout. Each is used only by the distros and controls that need it.

Threat model

ThreatDefence
The change locks the operator outPreflight, validation, new-connection confirm, revert timer, boot guard.
A non-root user swaps the binary the root timer will runExecutable and every parent directory must be root-owned and not group/other-writable before a guard is armed.
Command injection through config, hostnames or file contentsNo shell; fixed argv; controls compiled in; config can only skip.
Memory corruptionRust with #![forbid(unsafe_code)].
Half-written file after a crash or full diskWrite to a temp file, fsync, rename(2); write errors fail the run.
Two Hardlines racingflock on /run/hardline.lock.
Clobbering someone else's configDrop-ins and owned objects only: sshd_config.d, table inet hardline, merged daemon.json.
Leaking server detailsNo network calls or telemetry; reports 0600 with default-src 'none' CSP.

Out of scope: an attacker who already has root, kernel exploits, and anything outside the controls listed. Hardline reduces attack surface; it is not intrusion detection.