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.
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.
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.
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.
Requires Rust 1.96+ and Cargo.
cargo build --release --workspaceBinaries land at target/release/lychgate and target/release/lychgated.
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.
lychgated --inventory /usr/local/etc/lychgate/inventory.toml \
--state-dir /var/db/lychgateValidates 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 prefixThe [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 = [ ... ] # optionalThe 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.
./tools/check.shruns 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.
BSD-2-Clause. Copyright (c) 2026 Axonibyte Innovations, LLC.