- Python 98%
- Makefile 2%
Prepare 1.3.0: add CLI features (completion, check-config, status --json/-o, --dry-run, register --force), improve config parsing (TOML lists, token_file, strict validation for accounts/key_prefix/webhook), safer password generation and idempotent PUTs, SSH private-key backup, atomic escrow/state writes with TPM support, backoff jitter, and SELinux/systemd hardening. Update docs, tests (new tests/test_hardening.py), Makefile, pyproject, spec, and bump __version__. Several internal refactors (status rendering, dry-run flows, webhook severity/auth) to support these features and improve robustness. |
||
|---|---|---|
| .github/workflows | ||
| bin | ||
| docs | ||
| rpm | ||
| selinux | ||
| src/corvus_agent | ||
| systemd | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| LICENSE | ||
| Makefile | ||
| PLAN.md | ||
| pyproject.toml | ||
| README.md | ||
corvus-agent
Rotates local passwords on a schedule and stores them in Corvus. LAPS for Linux.
Quick start
Install the RPM, write a config, register the host, enable the timer.
sudo dnf install dist/corvus-agent-*.el9.noarch.rpm
Copy the example config and lock it down.
sudo cp /etc/corvus/agent.toml.example /etc/corvus/agent.toml
sudo chmod 0600 /etc/corvus/agent.toml
sudo chown root:root /etc/corvus/agent.toml
Edit /etc/corvus/agent.toml. You need a server URL, a project UUID, and a machine token from Corvus.
url = "https://corvus.example.com"
project = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
token = "__VG_CORVUS_MACHINE_TOKEN_018b371d4466__"
Generate a password, push it to Corvus, and rotate the local account.
sudo corvus-agent register
Enable the daily timer.
sudo systemctl enable --now corvus-agent.timer
Corvus setup
You need a project and a machine token before the agent can run.
- Create a project. Name it
linux-root-passwordsor similar. - Create a machine token with role
service-writeand key scopehosts/*. - Put the token and project UUID in
/etc/corvus/agent.toml.
Commands
run
The agent checks each account against Corvus. When a secret expires within threshold_days (7 by default), the agent generates a new password, pushes it to Corvus, then calls chpasswd. The agent always pushes before it rotates. If the push fails, it writes the password to /var/lib/corvus-agent/escrow.json and tries again on the next run.
The systemd timer calls this.
sudo corvus-agent run
Exit code is 0 when every account succeeds or has no work to do, 1 on error.
status
Prints the expiry date and days left for each account. It never prints the password.
$ sudo corvus-agent status
hosts/web01/users/root 2026-10-15 45d left due=no
If the host has no record:
$ sudo corvus-agent status
hosts/web01/users/root never-rotated (no record)
Exit code is 0 when Corvus has a record for every account, 1 otherwise.
rotate
Same as run but it ignores the threshold and rotates now.
sudo corvus-agent rotate
sudo corvus-agent rotate --dry-run # plan only, no changes
register
Creates the first secret for each account. Generates a password, pushes it, then rotates locally. When a secret already exists and you run interactively without --force, the agent asks before overwriting. Scripts and the timer path are unaffected (non-interactive runs keep the old overwrite behavior unless --dry-run).
sudo corvus-agent register
sudo corvus-agent register --force # skip the overwrite prompt
sudo corvus-agent register --dry-run # plan only, no changes
status --json
status --json (or status -o json) prints the same data as JSON (key, account, state, expires_at, days_left, due) for monitoring scripts. It never prints secrets. The default table output renders as a formatted table when the optional rich extra is installed (pip install corvus-agent[rich]) and stdout is a terminal; otherwise it falls back to plain text, so scripts and minimal servers always get stable output.
completion
Generates a shell completion script (no config needed):
corvus-agent completion bash | sudo tee /etc/bash_completion.d/corvus-agent
corvus-agent completion zsh | sudo tee /usr/share/zsh/site-functions/_corvus-agent
corvus-agent completion fish | sudo tee /usr/share/fish/vendor_completions.d/corvus-agent.fish
check-config
Validates the config file and prints a non-secret summary (URL, project, accounts, thresholds, token source). It never prints the token. Run it after editing the config and before enabling the timer.
sudo corvus-agent check-config
Global flags: --config PATH (use a different config file), --version, -v/--verbose, -q/--quiet.
Config reference
| Field | Required | Default | Description |
|---|---|---|---|
url |
yes | Corvus server URL. Must start with https://. |
|
token |
yes, unless token_file |
Machine token with service-write. Set only one of token / token_file. |
|
token_file |
yes, unless token |
File holding only the token (same 0600 root-owned rules as the config). Useful with systemd-creds so the token is not inline. |
|
project |
yes | Corvus project UUID. | |
key_prefix |
no | hosts/ |
Key is <prefix><hostname>/users/<account>. Letters, digits, /, _, - only; a trailing / is added if missing. |
expires_days |
no | 90 | Lifetime of the secret in Corvus (1-3650). |
threshold_days |
no | 7 | Agent rotates when days left fall to this value or below (0-3650). |
password_length |
no | 20 | Length of the generated password (8-128). Full entropy per character from A-Za-z0-9-_. |
webhook_url |
no | Leave empty to disable alerts. Set a Slack, Mattermost, or generic JSON webhook URL to get failure and drift alerts. HTTPS required unless webhook_insecure_http = true. |
|
webhook_insecure_http |
no | false |
Allow an http:// webhook URL (testing only). |
webhook_bearer |
no | Optional Authorization: Bearer value for the webhook. Never logged. |
|
accounts |
no | root |
Local accounts to manage. POSIX names only (validated). Comma separated string or native list: accounts = ["root", "deploy"]. |
ssh_accounts |
no | empty | Must be a subset of accounts. Those get a server generated ed25519 keypair instead of a password. Agent sends kind=ssh with no value. |
ssh_key |
no | true |
Write authorized_keys when the PUT response includes ssh_public_key. |
ssh_authorized_keys_file |
no | /etc/ssh/authorized_keys.d/%u |
Root owned file for the public key. The agent creates the directory 0755 and the file 0644 root:root. Set to empty to use ~/.ssh/authorized_keys. %u becomes the account name. The RPM installs /etc/ssh/sshd_config.d/50-corvus-agent.conf for this path. |
drift_heal |
no | false |
true makes the agent auto rotate a password account whose /etc/shadow hash changed outside the agent. false logs a warning, sends a webhook, and exits 1 for that account. State uses TPM sealing when available. |
Webhook alerts
webhook_url controls alerts. Empty means off. Set it to an incoming webhook URL for Slack, Mattermost, or any endpoint that accepts a JSON POST.
webhook_url = "https://hooks.slack.com/services/T.../B.../xxx"
What the agent sends
One POST per failure. Body is JSON with text and severity fields:
{"text": "web01: push failed, escrowed for root: PUT hosts/web01/users/root: HTTP 500 after retries", "severity": "error"}
text is always hostname: error message. The error is the same string the agent logs at CRIT, for example PUT rejected: token lacks write permission (HTTP 401) or drift detected for root. severity is error for push failures and warning for drift. Slack and Mattermost render text and ignore the extra field. The agent never includes the password or token.
Request headers and behavior:
Content-Type: application/jsonPOSTwith 10 second timeout- no retry;
Authorization: Bearer <webhook_bearer>only whenwebhook_beareris set - if the webhook returns an error or times out, the agent logs
webhook POST failed: ...atWARNINGand continues. The run still fails or succeeds based on the Corvus push, not the webhook. HTTPS_PROXY,HTTP_PROXY, andNO_PROXYapply. Set them on the service if you need a proxy.
When it fires
- Corvus PUT fails and the agent escrows the password (5xx, 429, network error), or the PUT is rejected (401, 403)
/etc/shadowhash for a password account changed outside the agent (drift). The agent records the hash withspwdafter each successfulchpasswdand compares it on the next run.
Test the URL without the agent:
curl -X POST -H "Content-Type: application/json" \
-d '{"text":"corvus-agent test"}' "$WEBHOOK_URL"
Slack and Mattermost incoming webhooks accept this payload as is.
systemd
The RPM installs two units.
corvus-agent.servicerunscorvus-agent runas root. It usesProtectSystem=full,ProtectHome=read-only,ReadWritePaths=/root /home /etc/ssh,NoNewPrivileges=true, plus kernel/host hardening (ProtectKernelTunables,ProtectKernelModules,ProtectControlGroups,ProtectHostname,ProtectClock,RestrictSUIDSGID,RestrictRealtime,LockPersonality).PrivateDevicesis deliberately off so TPM sealing keeps working.corvus-agent.timerruns the service once a day withRandomizedDelaySec=1h. It is persistent across reboots.
sudo systemctl enable --now corvus-agent.timer
sudo journalctl -u corvus-agent.service
Build from source
You need Fedora or RHEL 9+ with rpm-build.
make rpm
ls dist/
The RPM is noarch. It needs Python 3.9+ and shadow-utils. The build compiles the SELinux module and the RPM install loads it.
Security
- The agent feeds
chpasswdon stdin. It never puts the password in argv, logs, orstatusoutput. - The agent never logs or prints the token (or the webhook bearer).
- The config must be
0600and owned by root. The agent checks this and exits if it is wrong.token_file, when used, has the same rules. - The agent rejects a config with
http://forurl. It only talks TLS. Webhooks must also be HTTPS unlesswebhook_insecure_http = true. - Account names are validated (letters, digits,
_,-,.; must start with a letter or_). This blockschpasswdline injection and%upath traversal. - A
flockon/run/corvus-agent.lockstops two runs from overlapping. - The agent pushes to Corvus before it changes the local password. If the push fails, it saves the password to
/var/lib/corvus-agent/escrow.json(0600) and retries on the next run. - An existing
~/.ssh/id_ed25519that differs from the new key is backed up toid_ed25519.corvus-backupbefore replacement. - Retries use the 5s/15s/45s backoff with a small random jitter so fleets do not thundering-herd the server.
- The SELinux module scopes network access to web ports and gives the agent its own type for
/etc/ssh/authorized_keys.dinstead of genericetc_twrite access.
Troubleshooting
config: ...at startup: runsudo corvus-agent check-config. It validates permissions, HTTPS, account names, and thresholds without printing secrets.- Clock skew:
statuscomputes days-left fromexpires_atagainst local time. An NTP-skewed host rotates early or late; keepchronydhealthy. - No TPM: without
systemd-credsplus/dev/tpmrm0or/dev/tpm0, escrow and drift state are plaintext0600files. Permissions still guard them, but offline disk theft exposes them. The agent seals old plaintext files automatically once a TPM appears. - Proxy: both Corvus API calls and webhook posts use
urllib, soHTTPS_PROXY/HTTP_PROXY/NO_PROXYapply to both. A token-bearing request through a proxy means the proxy sees the ciphertext, not the token, but it does see the destination. - Homes outside
/rootand/home: the unit only allowsReadWritePaths=/root /home /etc/ssh. Add a drop-in for other home roots:sudo systemctl edit corvus-agent.serviceand appendReadWritePaths=/srv/users. drift detected: someone changed the password outside the agent. Fix by hand, thensudo corvus-agent rotateto re-seal the state (or setdrift_heal = truefor auto recovery).- SELinux denials:
ausearch -m AVC -ts recent. The agent domain iscorvus_agent_t; the key drop-in is labeledcorvus_agent_ssh_keys_tvia the shipped.fcfile (relabel withrestorecon -R /etc/ssh/authorized_keys.dafter manual moves).
HTTP proxy
The agent uses urllib. It honors HTTPS_PROXY, HTTP_PROXY, and NO_PROXY. Set them in the environment or in a systemd drop in.
# sudo systemctl edit corvus-agent.service
[Service]
Environment=HTTPS_PROXY=http://proxy.example.com:3128
Environment=NO_PROXY=localhost,127.0.0.1
Escrow (offline queue)
If Corvus is unreachable when the agent needs to rotate, it saves the new password to /var/lib/corvus-agent/escrow.json (0600). On the next run it pushes the escrow first. If that push succeeds, it applies the password locally and removes the escrow. It keeps the file until the push and local apply both succeed.
If systemd-creds and a TPM2 device (/dev/tpmrm0 or /dev/tpm0) exist, the agent seals the file with systemd-creds encrypt --with-key=tpm2 --tpm2-device=auto --name=corvus-agent-escrow. The file then holds {"encrypted": "<blob>"}. The blob only decrypts on that machine. Without a TPM the agent writes {"escrows": {...}} in plaintext. Plaintext files from before you had a TPM get sealed automatically on the next write. Drift state in /var/lib/corvus-agent/state.json uses the same seal (corvus-agent-state).
The escrow file holds the account name and the password. Filesystem permissions guard it. TPM sealing adds offline theft protection; root on the live host can still decrypt it.