Skip to content

Repository files navigation

lychgate

A break-glass emergency access orchestrator for SSH, iDRAC, and VNC.

A lychgate is the roofed churchyard gate where the dead are received. This one is the gate you open when a production server is the corpse: it grants temporary root SSH posture, BMC accounts, authorized_keys entries, and VNC console access as a single revocable unit — and slams all of it shut on a timer whether or not the thing that opened it is still alive.

Why

Emergency remediation — human or AI-driven — needs access that ordinarily must not exist: root over SSH, an enabled iDRAC account, a console. Provisioning that by hand at 03:00 is slow; leaving it standing is worse. lychgate models the whole bundle as a grant with a hard TTL: opening is deliberate and audited, closing is idempotent, and expiry is fail-closed — a grant observed at or past its expiry instant is expired, no daemon required to make it so.

Grant policy, by design:

  • TTLs are capped at 24 hours. Break-glass access is never open-ended.
  • Renewal is only accepted inside the final 2 hours before expiry, and the new window is anchored at the renewal instant — time cannot be stockpiled early.
  • An expired grant cannot be renewed. Reopening is always an explicit act.

Status

The control plane is real, and all eight channels are live. The four host-facing ones — ssh, authorized-keys, bmc, and vnc — flip the host's PermitRootLogin posture through a verified drop-in, install break-glass keys inside a lychgate-owned fence in authorized_keys, enable a break-glass iDRAC account over Redfish with a fresh one-time password, and bring up a console: a daemon-held SSH tunnel to the VM's RFB port plus a rotated one-time VNC password (set through a configurable, platform-agnostic command). The four device-facing ones — http, mqtt, serial, and device — extend the same discipline to firmware-class hardware, up to grants a cooperative device enforces ITSELF: a signed capability token whose TTL runs on the device's own clock, so a reboot closes the grant and daemon death cannot extend it (docs/EMBEDDED.md; the same engine ships as a no_std Rust crate, an ESP32-C3 reference firmware, an AVR C library, and a formally-proved FPGA gate). All are verified against the target's actual state and reverted on close or expiry, with a target-side dead-man backstopping the ssh channels and the tunnel dying with the daemon it belongs to. Opening a grant is gated on a weighted-threshold approval authority (EOS/Antelope model): open --as <profile> records a pending request and returns a challenge, and the grant opens only once the profile's threshold is met by weighted factors — operator signatures (ssh-keygen -Y sign, an Ed25519 key in the allowed-signers set), TOTP codes from an authenticator app (RFC 6238, single-use), Argon2id passwords, challenge-bound FIDO2 assertions (ES256/EdDSA — a software key, or a hardware key over USB-HID with the fido2-client build; attestation is verified and a signature-counter ledger refuses suspected clones), TPM 2.0 challenge signatures (a non-exportable key in the machine's TPM, via the tpm-client build — which can also seal the daemon's secret files at rest with --tpm-unseal), nested groups, and/or an elapsed wait, handed back through lychgate approve. Proofs accumulate across calls and a wait matures on the daemon's own loop, so a profile can demand genuine multi-factor approval. The daemon holds grant state durably, serves the CLI over an owner-only unix socket, journals every transition (never a credential or token), and re-establishes a console tunnel that outlived a restart. A --dry-run mode opens grants as pure bookkeeping, touching no host — for validating an inventory or rehearsing the lifecycle. See docs/RUNBOOK.md for the operator's end-to-end procedure (granting a Claude session emergency access), TESTING.md for exactly what is and is not proven, docs/DESIGN.md for the architecture, docs/EMBEDDED.md for the embedded-devices design of record (microcontrollers, secure elements, FPGAs), and docs/ROADMAP.md for the milestone plan of record.

Components

  • lychgate — the operator CLI. Builds for FreeBSD, Linux, and Windows.
  • lychgated — the control-plane daemon. FreeBSD and Linux only. Holds grant state, drives the channels, reverts on close and expiry, retries stuck reverts, and journals everything.
  • lychgate-mcp — the MCP front door (FreeBSD and Linux). A stdio MCP server that lets a Claude session request and use a grant, with the AI as a first-class approval factor. A low-privilege client of the daemon's dedicated MCP socket; see DESIGN.md's "MCP front door".
  • lychgate-core — the grant/TTL/inventory library the binaries share.

Installation

From source

Requires Rust 1.96+ and Cargo.

cargo build --release --workspace

Binaries land at target/release/lychgate and target/release/lychgated.

Prebuilt binaries

Tagged releases upload binaries for FreeBSD and Linux (amd64 and aarch64) and a Windows client to the repository's Downloads page, each with a .sha256 checksum sidecar.

Usage

lychgated --inventory /usr/local/etc/lychgate/inventory.toml \
          --state-dir /var/db/lychgate

Validates the inventory and grant state, binds the control socket (<state-dir>/lychgated.sock, owner-only), reaps expired grants on an interval, and journals every transition to <state-dir>/journal.jsonl. --once runs a single pass for cron; a second daemon on the same socket is refused. --approval-window <secs> bounds how long a pending request waits for its authority to be satisfied (default 300, cap 24h — long enough for a wait factor); a request not satisfied in time lapses and is reaped. --dry-run registers no channel drivers and evaluates no authority (the first proof opens), so grants open and close as pure bookkeeping and touch no host — for validating an inventory or rehearsing the lifecycle. Outside --dry-run, the daemon refuses to start with no [approval] policy configured, since a grant could then never be opened.

lychgate open  --host db-01 --as claude --ttl 4h   # prints a challenge; pending
# each operator whose factor the profile needs signs the challenge:
printf %s '<challenge>' | ssh-keygen -Y sign -n lychgate-approval -f ~/.ssh/id_ed25519
lychgate approve --host db-01          # paste a signature on stdin, then EOF
                                       # repeat per factor; the grant opens when
                                       # the profile's weighted threshold is met
lychgate status                        # shows pending progress (weight / threshold)
lychgate renew --host db-01 --ttl 2h   # accepted only within 2h of expiry
lychgate close --host db-01

--as may be omitted when the host permits exactly one profile.

Drill mode is the standing revert oracle: lychgate drill --host canary open-and-reverts a designated canary (a host marked drill = true) and exits non-zero if the revert path does not fire. Schedule it and alert on failure — a revert path never observed firing is indistinguishable from one that does not work:

*/30 * * * * lychgate drill --host canary || notify "lychgate drill failed"

The one-time secret for a channel (the BMC or VNC password) is shown once by approve, where the grant actually opens — not by open. TTLs take the forms 90s, 15m, 2h; a unit is required, and the 24-hour cap is enforced client-side before a connection is attempted and daemon-side regardless. Refusals are printed in the daemon's words verbatim and exit nonzero. --socket overrides the per-OS default socket path.

An inventory names each host, its address, its operating system (freebsd, linux, or embedded — a firmware-class device with no shell, restricted to the channels that need none), and the access channels lychgate may drive for it. Besides the four below, the generic device channels http, mqtt, and serial drive a device's management surface from request/command templates (daemon-enforced TTL, no on-device dead-man; each requires a verify probe or an explicit verify = "none", which the open response surfaces as a named narrowing), and the device channel drives a cooperative device that holds the daemon's public key: "open" delivers a signed capability token whose TTL the DEVICE enforces on its own clock — a reboot closes the grant. The token signing keys live under [signing]; see docs/EMBEDDED.md for the whole model:

[[hosts]]
name = "db-01"
address = "10.0.4.11"
os = "freebsd"
channels = ["ssh", "authorized-keys", "bmc", "vnc"]

# Required when ssh or authorized-keys channels are declared.
[hosts.ssh]
agent_user = "lychgate"              # connects via ssh(1); see become_cmd
root_posture_default = "no"          # what PermitRootLogin must be at rest
root_posture_emergency = "prohibit-password"
emergency_keys = ["ssh-ed25519 AAAA... claude-breakglass"]
become_cmd = "doas"                  # omit if the agent account is root

# Required when the bmc channel is declared.
[hosts.bmc]
endpoint = "https://10.0.4.11-idrac"
method = "redfish"                   # racadm/ipmitool reserved, not yet built
account_user = "breakglass"          # the break-glass iDRAC account
account_id = "4"                     # its AccountService slot
auth_user = "lychgate-svc"
auth_password_file = "/usr/local/etc/lychgate/db-01.bmc"   # not inline
tls = { mode = "ca-file", path = "/usr/local/etc/lychgate/idrac-ca.pem" }

# Required when the vnc channel is declared. lychgated holds an ssh -L from
# the daemon host's local_port to rfb_host:rfb_port on the hypervisor for the
# grant, and rotates a one-time VNC password via the configurable commands.
[hosts.vnc]
agent_user = "lychgate"              # ssh login on the hypervisor (host.address)
rfb_host = "127.0.0.1"               # where the VM's RFB server binds there
rfb_port = 5900
local_port = 5959                    # forwarded on the daemon host; unique per host
target = "vm-guest-01"               # VM id passed to the commands as {target}
# Agnostic: cbsd is the pilot; {password_file} is a mode-600 file lychgate
# stages (never the password on an argv). Single quotes are refused at load.
set_password_cmd = "cbsd bhyve-vnc jname={target} vncpasswordfile={password_file} apply=1"
clear_password_cmd = "cbsd bhyve-vnc jname={target} vncpassword=none apply=1"
become_cmd = "doas"                  # optional privilege prefix

The [approval] section defines the weighted-threshold authorities that gate opening (the EOS/Antelope model): authenticators (leaf proofs), groups (nested authorities), and profiles (the gate a host is opened under). An open under a profile succeeds when its satisfied factors' weights sum to at least its threshold — where a factor is an authenticator, a group, or a wait. It is required outside --dry-run; a policy with no profile refuses the daemon's start.

# Authenticators are leaf proofs. All five kinds are built: ed25519 (an SSHSIG
# signed with `ssh-keygen -Y sign -n lychgate-approval`), totp (an RFC 6238 code
# from an authenticator app), password (Argon2id), fido2 (a challenge-bound
# WebAuthn assertion), and tpm (a P-256 signature from a TPM-resident key). A public key is inline (the full openssh line, with
# comment; for fido2 a base64url key from `lychgate fido2-register`); a secret is
# always a mode-600 file path.
[[approval.authenticator]]
id = "oncall-key"
kind = "ed25519"
public-key = "ssh-ed25519 AAAA... oncall@phone"

[[approval.authenticator]]
id = "oncall-totp"
kind = "totp"
secret-file = "/usr/local/etc/lychgate/oncall.totp"   # base32, mode 600

[[approval.authenticator]]
id = "oncall-pw"
kind = "password"
# An Argon2id PHC hash: `lychgate hash-password > oncall.pw` (then chmod 600).
# A password is reusable and the weakest factor — give it low weight. It must
# not be purely numeric (that would route to the TOTP path).
hash-file = "/usr/local/etc/lychgate/oncall.pw"

[[approval.authenticator]]
id = "host-tpm"
kind = "tpm"
# A P-256 key created non-exportable inside a TPM 2.0 (`lychgate tpm-register`,
# a `tpm-client` feature build). The proof is a signature over the challenge,
# produced by `lychgate tpm-sign`; the private key cannot leave the chip.
public-key = "…base64url SEC1 P-256 point…"

[[approval.authenticator]]
id = "lab-fixture"
kind = "hmac"
# A shared HMAC-SHA256 secret (32 bytes as hex, chmod 600) — the tier-A
# embedded factor: SHA-256 fits a classic AVR where curve crypto does not.
# SYMMETRIC and honestly weak (the daemon holds the same secret), so give it
# low weight and compose it with an asymmetric human factor. The device
# proves it with `lghmac.<base64url(HMAC(secret, challenge))>`, bound to the
# per-request challenge — no replay across grants. See docs/EMBEDDED.md.
secret-file = "/usr/local/etc/lychgate/lab-fixture.hmac"

[[approval.authenticator]]
id = "oncall-fido2"
kind = "fido2"
# `lychgate fido2-register` prints this block. alg is es256 or eddsa; the public
# key and credential-id are base64url and public (inline). The strongest factor:
# the assertion binds to the challenge. Produce one at approve time with
# `lychgate fido2-assert` (a hardware key needs the `fido2-client` build).
alg = "es256"
credential-id = "…base64url…"
public-key = "…base64url SEC1 point…"

# A group is itself a threshold over weighted factors — here, MFA: an SSHSIG AND
# a TOTP code from the on-call operator.
[[approval.group]]
id = "SYSADMIN"
threshold = 2
factor = [
  { authenticator = "oncall-key",  weight = 1 },
  { authenticator = "oncall-totp", weight = 1 },
]

# A profile is the gate an open is evaluated against.
[[approval.profile]]
id = "claude"
threshold = 3
factor = [
  { group = "SYSADMIN", weight = 2 },   # a full MFA grant from a sysadmin
  { wait  = "1h",        weight = 1 },   # ...plus an hour's cool-off
]

# The AI as a factor. lychgate-mcp signs with an ed25519 key configured like any
# other authenticator; `mcp = true` lets this profile be opened through the MCP
# front door (default false — a profile is not MCP-reachable unless it opts in,
# and the daemon enforces this on its dedicated MCP socket).
[[approval.profile]]
id = "ai-assisted"
threshold = 2
mcp = true
factor = [
  { authenticator = "sysadmin-key", weight = 1 },   # a human still signs
  { authenticator = "ai",           weight = 1 },   # the AI contributes over MCP
]

A host may narrow which profiles it permits, and override a profile's authority for itself, under [hosts.access]; a host with no such block permits every profile at its default authority.

[hosts.access]
profiles = ["claude", "contractor"]
# [hosts.access.override.claude] threshold = 2 / factor = [ ... ]   # optional

The ssh channel needs the host's sshd_config to Include /etc/ssh/sshd_config.d/*.conf before any PermitRootLogin line (sshd honors the first value it reads); a missing Include is caught by the post-apply verify, not silently tolerated.

Managed hosts with an ssh-borne channel also need cron installed and running (FreeBSD ships it in base; on Debian/Ubuntu, apt install cron). lychgate installs a dead-man timer in root's crontab so break-glass access reverts on schedule even if the daemon dies — a host without cron cannot hold that guarantee, so opening a grant there is refused rather than opened without a backstop.

Testing

./tools/check.sh

runs every workstation-side phase (fmt, clippy with warnings denied, the test suite, shell lint) and reports all failures rather than stopping at the first. The project is a reaper tenant: reaper test runs the same battery on FreeBSD and Ubuntu guests. The testing ethic, tier roadmap, and the claims each suite does not carry are recorded in TESTING.md.

License

BSD-2-Clause. Copyright (c) 2026 Axonibyte Innovations, LLC.

About

[ Mirror ] A break-glass emergency agentic access orchestration suite for server machinery

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages