Installing HostSeal

What you get from this is a fleet that reports: inventory, systemd unit state, pending updates with security separated from the rest, and which services still hold replaced libraries.

The privileged operations are real now — applying updates, starting, stopping and restarting a unit, rebooting — and each is bounded by a root-owned file the control plane cannot modify. The control plane can ask for them: POST /api/v1/jobs queues one, and whether it then waits for somebody to release it is a setting on your fleet — see SECURITY.md §3.

A destructive job carries a signature made offline by a key the control plane does not hold. hostseal sign is what produces one, and it never contacts the server — it renders what you are about to authorise from the same payload it then signs.

The control plane

You need PostgreSQL 14 or newer, and one binary.

# 1. The certificate authority that issues agent certificates.
sudo hostseal-server ca init --ca-dir /var/lib/hostseal-server/ca

# 2. A database. An ordinary role that owns the schema — not the postgres superuser.
sudo -u postgres createuser hostseal --pwprompt
sudo -u postgres createdb --owner hostseal hostseal

# 3. Run it. The schema is created on first start, and so is the first account —
#    whose password is printed once, on this terminal, and nowhere else.
export HOSTSEAL_DATABASE_URL='postgres://hostseal:...@localhost/hostseal?sslmode=disable'
hostseal-server serve --addr :8443 --ca-dir /var/lib/hostseal-server/ca

Connect as an ordinary role, not as postgres. Fleets are isolated from one another by PostgreSQL row-level security, and a superuser — or any role with BYPASSRLS — is exempt from every policy in the schema. The exemption has no symptom whatsoever: the policies are still there, the queries still carry their predicates, and every query returns every fleet's rows. hostseal-server checks its own role at startup and refuses to run on either, so you will be told rather than left to find out. See SECURITY.md §5.

Two things about TLS are worth knowing before you reach them.

The agent protocol authenticates hosts with client certificates, which do not exist without TLS — so a control plane with no certificate does not serve agents insecurely, it cannot serve them at all. hostseal-server serve therefore refuses to start without one, and issues one from its own CA if you do not supply one. An enrolled agent trusts that automatically, because it is handed the CA bundle at enrolment. A browser will not, so pass --tls-cert and --tls-key from whatever issues your public certificates before operators use the interface in earnest.

Back up ca.key separately from the database. An attacker with both can impersonate hosts to this control plane; an attacker with the database alone cannot. Neither lets them run code on a host: an agent authorises a job by its class and its signature, not by who asked.

Back up template.key beside it, from the same directory, and treat it as part of the backup rather than as part of the database. It encrypts provisioning template bodies at rest — which is why a database dump is not a set of provisioning scripts — and a restore without it leaves every stored template permanently unopenable. The control plane says exactly that when it happens, which is the difference between an operator fixing their restore and filing a bug about templates being corrupt.

The third key in that directory asks nothing of you. online.key is generated on first start, and it signs the one privileged operation that carries no offline signature: packages.applySecurity. There is no command that creates it and nothing to distribute — its public half reaches agents in the enrolment response and on every heartbeat, so rotating it is deleting the file and restarting: hosts pick the new one up on their next heartbeat, and a routine job queued before the change stops verifying. It is not a trust anchor and never belongs in a host's trusted-signers: an agent that accepted an online-key signature for a reboot would be the backdoor the rest of this design is arranged against, and it refuses one. What bounds a routine job instead is the host's own policy — see SECURITY.md §3.

Operators, and how they sign in

Everybody gets an account: an address, a password, and a session in an HttpOnly cookie. There is no shared credential and deliberately no way to configure one. HOSTSEAL_ADMIN_TOKEN and HOSTSEAL_PLATFORM_TOKEN used to be here, and they are gone rather than deprecated — one string for a whole fleet names nobody in the audit trail, cannot be taken away from one person who has left, never expires, and made the two-person approval rule unsatisfiable by construction, because it compares the approver against the job's creator and under one token those were always the same string.

The first start of an empty database creates one account so that there is a way in, and prints its password once:

This control plane had no accounts, so one has been created.

  address:  admin@localhost
  password: <24 characters, printed here and nowhere else>

Set HOSTSEAL_BOOTSTRAP_EMAIL, and HOSTSEAL_BOOTSTRAP_PASSWORD or --bootstrap-password-file, to choose them yourself. Either way the accounts table is the truth afterwards: neither is read again.

Then give each person their own:

sudo -u hostseal hostseal-server accounts add --email ops@example.org --name "Ops"
# Password: (typed, twice, never a flag: argv is world-readable in ps)

accounts list shows who has one and when they last signed in, accounts passwd sets a new password, and accounts remove deletes the account together with every session and token it holds — which is the whole of "somebody has left". Somebody who is signed in can change their own password from the account page without any of this.

Accounts are created here, on the machine, and by no API. That is deliberate and SECURITY.md §5.3 says why: the credential that administers fleets must not be able to authenticate as a customer, so it is given no route that would let it. --tenant names the fleet; it defaults to the one the control plane serves.

What a script uses

Not a password, and not a shared token. An operator issues an API token from the account page in the web interface: it belongs to that account, acts as that account in the audit trail, may expire, and is revoked from the same page in a second.

export HOSTSEAL_TOKEN=hsl_...                      # shown once, when it is issued
curl -s https://hostseal.example.org/api/v1/hosts \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN"

It is a bearer token and that is not a contradiction with the paragraph above. What was wrong with the old one was never the word "bearer": it was one credential for a whole fleet, held in a flag, naming nobody, never expiring, and withdrawable only by restarting the control plane with a new one and telling everybody.

One thing a token deliberately cannot do is reach /api/v1/account — the routes that mint and revoke tokens, change a password and end sessions. A token that could issue another, with no expiry, would outlive its own revocation. Those routes want a signed-in browser, and answer a token with 403 and a sentence saying so.

In containers, if that is how you run things

The same two pieces — one binary and PostgreSQL — as a Compose stack, in deploy/:

cd deploy
cp .env.example .env      # four passwords; `openssl rand -hex 32` for each
docker compose up -d

The first start builds the image from the checkout, creates the certificate authority, creates an ordinary database role and the database it owns, and serves on https://localhost:8443. The role is the part worth reading about before you deviate from it: the PostgreSQL image's own superuser is exempt from every row-level security policy in the schema, which is the paragraph above with the failure mode that has no symptom.

Traefik is optional, in an overlay of its own, and routes rather than terminates — a TCP router with tls.passthrough=true. A proxy that terminated TLS would end the connection carrying an agent's client certificate and open one that does not, and the only way for the server to keep identifying hosts across that would be to believe a header that anything reaching the proxy's back end can set. What follows from passthrough — which certificate a browser sees, and why enrolling a rack at once through a proxy meets the rate limiter — is in deploy/README.md.

A certificate a browser trusts comes from giving the interface a second hostname, where Traefik does terminate and Let's Encrypt applies normally. Agents keep the passthrough name and need no public certificate at all, because they verify against the CA bundle they were handed at enrolment. That is a second overlay, and the agent endpoints are refused on the interface name.

A streaming replica is a second overlay. The database is configured for one from its first start, so adding it later needs no restart of a primary that is by then serving a fleet.

Mail, for alerts that reach somebody who is not looking

Optional, and off until you configure it. Alerting rules are per fleet and editable in the interface; which relay this installation may speak to is yours:

hostseal-server serve \
  --smtp-host smtp.example.com --smtp-port 587 \
  --smtp-from hostseal@example.com --smtp-username hostseal \
  --smtp-password-file /etc/hostseal-server/smtp.password \
  ...

Port 465 speaks TLS from the first byte and anything else — 587 in practice — upgrades with STARTTLS. Plaintext SMTP is not offered: an alert legitimately carries hostnames and failure text, and a relay that does not offer STARTTLS is refused rather than downgraded to. The password comes from a file, or from HOSTSEAL_SMTP_PASSWORD, and never from a flag, because argv is world-readable in ps.

Without a relay, every other route still works — the event inbox, the live feed in the interface, and each fleet's webhook — and a rule that names recipients says on the rule that its mail did not go out.

A screen, for the people who are

The wallboard is the fleet in one glance — how many hosts are fine, how many are not, how many nobody can answer for, and the handful worth walking towards — refreshed every fifteen seconds and legible from the other side of a room. Operators reach it at /wallboard with the credential they already have. A television in a corridor cannot sign in, so it gets a published link instead:

# The label is the heading the screen shows, so name the fleet rather than the link.
curl -sX POST https://hostseal.example.org/api/v1/wallboard/shares \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label":"Production — Frankfurt","days":90,"passphrase":"the one on the sticker"}'
{
  "share": {
    "id": "01JAV0Q7X4M2R6P9T3K5N8W1YB",
    "label": "Production — Frankfurt",
    "createdAt": "2026-08-28T09:14:02Z",
    "createdBy": "local-account:ops@example.org",
    "expiresAt": "2026-11-26T09:14:02Z",
    "lastSeenAt": null,
    "passphrase": true,
    "expired": false
  },
  "link": "https://hostseal.example.org/board#hsb_01JAV0….k7q2…"
}

Keep the link now — it is shown once and cannot be recovered. Only its digest is stored, exactly as for an enrolment token and an API token, so there is nothing to print it from later. If you lose it, publish another and withdraw this one; that is a second of work and the only way back.

The secret is the part after the #, and a fragment is never sent to a server. It is in no access log here, none at your reverse proxy, no Referer, and not in the fetch a chat client makes to build a preview when somebody pastes the link into a channel. It is still in the browser's history and still in the address bar, so treat a photograph of the screen as a copy of the link.

days is optional — ninety by default, 365 at most, and deliberately no "never". passphrase is optional too: with one, the board asks for it once on that screen and remembers the answer in a cookie until the link expires or you change the passphrase, which drops every screen that was unlocked under the old one.

Listing and withdrawing are the other two:

curl -s https://hostseal.example.org/api/v1/wallboard/shares \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN"

# Withdrawing is a delete, and takes effect at the next poll — within fifteen seconds.
curl -sX DELETE https://hostseal.example.org/api/v1/wallboard/shares/01JAV0Q7X4M2R6P9T3K5N8W1YB \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN"

The listing includes expired links as well as live ones, because a screen that has gone dark is the first thing somebody comes looking for and "there is no such link" is the wrong answer to give the person holding it. lastSeenAt says that a screen is still polling, roughly; it does not say who is watching, and cannot.

A fleet may hold twenty live links at once. That is not a capacity limit — it keeps the list short enough that you recognise every line of it, which is how you would notice one you did not publish.

Read SECURITY.md §4.6 before publishing a link outside the building it hangs in. The short version is that a leaked link shows a remote stranger roughly what somebody standing in the corridor already sees, continuously, until it expires or you delete it — and that it names whoever published it and can never name who read it.

More than one fleet

The command above gives you a fleet called default, and if that is all you want you can stop reading this section — everything below is optional and nothing above changes.

One control plane can serve several independent fleets. They share the binary, the database and the certificate authority, and they share nothing else: no fleet can see another's hosts, tokens, jobs or results, and an operator credential reaches exactly one of them. There is no fleet in any URL, so there is nothing an operator could edit to be somewhere else.

Provisioning one is a separate account's job. A platform administrator carries no fleet at all, and that is the whole of what makes them one:

sudo -u hostseal hostseal-server accounts add --platform --email you@example.org --name "You"

They sign in exactly like anybody else, and get a fleets screen — create, rename, retire, and set each fleet's approval mode and webhook — and nothing else, because a platform credential is refused by every route that reaches a fleet's hosts or jobs. The same thing from a terminal, with an API token that account issued for itself:

# $HOSTSEAL_TOKEN is an API token that account issued for itself, from its own account page.
curl -sX POST https://control.example.org/api/v1/tenants \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"acme","displayName":"Acme Ltd","approvalMode":"second_person"}'

A platform administrator administers fleets and reaches no fleet's hosts or jobs — every operator route refuses them, and every fleet route refuses an operator. That separation is the point of having two kinds of account rather than one: running HostSeal for other people should not require being able to read what they run.

They also cannot create a fleet's operator. That happens on the machine, like every other account:

hostseal-server accounts add --tenant acme --email ops@acme.example --name "Acme Ops"

Which is not an inconvenience to be routed around — SECURITY.md §5.3 turns on it, because an API that handed out a fleet's credentials would make whoever runs the installation able to authenticate as any customer.

approvalMode is that fleet's answer to "who has to agree before a host may act on a destructive job", and it is per fleet because a one-person shop and a regulated customer cannot share an answer. The three values and the reasoning are in SECURITY.md §3.

A host

The interface has this as a panel: Fleet → Add a host mints the token, fills in this control plane's own address and gives you the three commands with a copy button on each. What follows is the same thing for a script, and the same thing to read when you want to know what those commands do.

# On the control plane, or through the web interface:
curl -sX POST https://hostseal.example.org/api/v1/tokens \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label":"web tier","group":"web-prod"}'

The token comes back once and is not recoverable: only its hash is stored, so a database dump does not let its holder enrol hosts. It is single-use and expires in a day by default.

# On the host:
curl -fsSL https://hostseal.io/apt/hostseal-archive-keyring.gpg \
  | sudo tee /usr/share/keyrings/hostseal-archive-keyring.gpg > /dev/null
curl -fsSL https://hostseal.io/apt/hostseal.sources \
  | sudo tee /etc/apt/sources.list.d/hostseal.sources > /dev/null
sudo apt-get update && sudo apt-get install hostseal-agent

# Trust this control plane's authority, before enrolling rather than after: an agent that starts
# without it fails to verify the server and retries, so you get a running service and a host that
# never appears — which reads as a control-plane fault. The certificate is public; it is handed to
# every enrolling agent before that agent has a credential at all.
#
# Fetch it over a connection you can already verify — see below, because on the agent hostname you
# cannot, and that is not a fault either.
curl -fsSL https://hostseal.example.org/api/v1/ca.crt \
  | sudo install -D -o root -g root -m 0644 /dev/stdin /etc/hostseal/server-ca.crt

sudo hostseal enroll --server https://hostseal.example.org --token hsl_…
sudo systemctl restart hostseal-agent

hostseal enroll reads that path without being told, so the two commands are the ordering they look like: install the authority, then enrol against it. --ca overrides it, and a --ca naming a file that is not there fails rather than quietly falling back to the system roots — which would verify a chain you did not ask for at the one moment you were being specific about which to trust.

Skip the certificate step only when the control plane has a publicly trusted certificate, which is what the Traefik overlay in deploy/ gives the interface and deliberately not the agent hostname: agents verify against the CA they were handed, so they need no public certificate and get no benefit from one.

Where to fetch it from

That curl verifies the control plane like any other client, so it works from a hostname whose certificate the host already trusts and not from one whose certificate is the thing being fetched. On a control plane serving its own certificate — the default, and the agent hostname in the Traefik deployment, where TLS passes through to hostseal-server untouched — it fails with unable to get local issuer certificate. That is the tool being right: nothing has told this host to trust that authority yet, which is the entire reason for the step.

There are three honest ways round it, in order of preference.

From the interface hostname, when the two-hostname Traefik deployment gave it a publicly trusted certificate. /api/v1/ca.crt is unauthenticated and outside the /agent prefix that hostname refuses, so the command above works unchanged with that name in it.

By copying the file, which needs no TLS at all. On the control plane:

docker compose cp hostseal-server:/var/lib/hostseal-server/ca/ca.crt ./hostseal-ca.crt

Then move it to each host and install it at the same path. It is a public document, so it needs no protection in transit beyond arriving unmodified.

By fetching it unverified and checking the fingerprint, when the host can reach neither. This is what Fleet → Add a host prints for you when it detects that case, with the expected digest filled in — it knows its own, and your session is the channel that carries it. Written out:

curl -fsSLk https://agents.hostseal.example.org/api/v1/ca.crt -o /tmp/hostseal-ca.crt
if [ "$(openssl x509 -in /tmp/hostseal-ca.crt -noout -fingerprint -sha256)" \
     = "sha256 Fingerprint=<the digest the panel shows>" ]; then
  sudo install -D -o root -g root -m 0644 /tmp/hostseal-ca.crt /etc/hostseal/server-ca.crt
else
  echo "FINGERPRINT MISMATCH - do not install this certificate" >&2
  false
fi

The comparison is in the shell rather than in your eyes on purpose: two 64-character hex strings are compared by looking at the first four characters, and that is not a comparison. It is an if rather than the shorter test … && install || echo, because in that form the || binds to the whole list — so a missing sudo or a full disk fails the install, prints an attack that did not happen, and then exits 0, leaving a script free to enrol against a certificate it never installed. If you have no panel to read the digest from, take it from the control plane itself:

docker compose exec hostseal-server \
  openssl x509 -in /var/lib/hostseal-server/ca/ca.crt -noout -fingerprint -sha256

-k with no comparison is not a shortcut, it is the enrolment trusting whoever answered the name. The window is one request wide and the consequence lasts the life of the host: an authority accepted here is what every later connection is checked against, so an attacker who owned that one response owns the host's idea of the control plane permanently.

When the agent says "not enrolled" on a host that enrolled

hostseal enroll is run with sudo and the service runs as the unprivileged hostseal account, so everything enrolment writes is handed to that account before it returns. On an agent built before that was true, the credential stayed root-owned at mode 0600 and the service could not open it — which looks like nothing at all: the control plane lists the host, because the enrolment did succeed, and the unit is active, because an unenrolled agent idles rather than exiting. Only the absence of facts says anything is wrong. If you have a host in that state, it needs no second enrolment and no second token:

sudo chown -R hostseal:hostseal /var/lib/hostseal
sudo systemctl restart hostseal-agent

The .sources file uses deb822 with Signed-By: naming an explicit keyring, so the HostSeal key is trusted for the HostSeal repository only. apt-key is never used: it installs a key that is trusted for every repository on the system, which turns one compromised project into root on the machine.

Asking a host to do something

# Read-only work needs nothing but an operator credential.
curl -sX POST https://hostseal.example.org/api/v1/jobs \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"hostId":"01J…","intent":"facts.collect","params":{}}'

curl -s "https://hostseal.example.org/api/v1/jobs?host=01J…" \
  -H "Authorization: Bearer $HOSTSEAL_TOKEN"

A job comes back with a state: queued, awaiting_approval, running, or whatever status the host reported. The result is null until the host reports one, which is how "not reported yet" stays distinguishable from "reported nothing".

A destructive job — applying every update, starting, stopping or restarting a unit, rebooting — needs two more things, and neither of them is the control plane's to give.

  1. A signature over the job, made offline with a key listed in that host's own /etc/hostseal/trusted-signers. It is sent with the request, along with the id, nonce, notBefore and notAfter it covers; every one of those comes from the signer, because a value chosen by the control plane would invalidate the signature.

    hostseal sign --key ~/.hostseal/ops.key --host 01J9ABC… \
      --intent service.restart --params '{"unit":"nginx.service"}' \
    | curl -sX POST "$HOSTSEAL_URL/api/v1/jobs" \
        -H "Authorization: Bearer $HOSTSEAL_TOKEN" \
        -H 'Content-Type: application/json' -d @-
    

    It shows you the operation, the host, the window and the exact bytes it is about to sign, and asks before signing. It never talks to the control plane: if it signed a digest the server handed it, a compromised control plane could display one operation and have another authorised.

  2. Release, if your fleet asks for it — POST /api/v1/jobs/{id}/approve. Whether that is required at all, and whether the releaser must be somebody other than the job's creator, is your fleet's approvalMode. A new fleet requires nothing; see SECURITY.md §3.

A routine job — packages.applySecurity — needs neither. The control plane signs it with its own key, and what bounds it is your host's updates.allow: the worst the control plane can do is make a host apply security updates sooner than its own timer would have.

What a fresh host will and will not do

Straight after installation, before you change anything:

Reports inventory, services, pending updates and reboot state yes
Applies security updates on its own timer, via unattended-upgrades yes — and it keeps doing this if the control plane is unreachable, or if you never enrol it at all
Applies security updates because the control plane asked only if updates.allow permits it — the control plane signs the request, and the host's own policy decides
Applies every update because the control plane asked nopackages.applyAll needs a signature from a key you place on the host
Applies updates because an administrator ran the helper on the host only security updates, and only what the policy below allows
Restarts a service, or reboots no, from anyone, until you change the two files below
Reports which of its units are in the failed state yes, for every unit. [services] watched narrows which changes become events, not what is reported
Reports the containers running on it no, until [containers] report = true

The two files are the whole of it:

/etc/hostseal/policy.toml — root-owned, a dpkg conffile, and the control plane cannot modify it. It ships permitting security updates, no reboots, and no restartable units. Effective permission for any job is min(what the control plane asked for, what this file allows) — never the maximum. Check an edit with hostseal-agent policy check before restarting anything: a file that does not parse makes the host refuse all privileged work rather than fall back to a default, which is deliberate and is a miserable way to discover a typo.

Two keys in it are the exception to everything the paragraph above says, because they bound what this host says rather than what may be done to it. Neither is a permission, and neither involves a signature.

[services] watched decides which unit-state changes become events, and its empty default means every unit rather than none: permitting an action and reporting a fact are different questions, and a fresh host should surface a failed unit rather than hide it. Narrowing it quietens a noisy machine; widening it grants nothing.

[containers] report is the other way round: it ships false, and a host reports nothing about the containers on it until somebody writes true. A container list describes what a business runs, which is a different disclosure from a package count. Turning it on reports each container's id, its main process's name — never its command line, which is where credentials end up — when it started, its resource use, and four things docker ps will not tell you: whether it is privileged, whether its seccomp filter is off, whether it runs as root, and whether the Docker socket is bind-mounted into it. It cannot report image names, exit codes, restart counts or health, because those live behind the socket that hostseal is deliberately not in the group for.

Two practical notes. The resource figures change on every collection, so a host that opts in sends a full report on every heartbeat rather than the digest it would otherwise send. And upgrade the agent before you add the key: the policy parser refuses a file it does not understand and falls closed, so writing [containers] into a host still running an older agent turns that host's update permission off until the agent catches up.

/etc/hostseal/trusted-signers — root-owned, a dpkg conffile, and empty. Every destructive operation needs a signature from a key listed here, and the control plane holds none of them. Generate one on your own machine:

hostseal key generate --out ~/.config/hostseal/signing.key --id ops-laptop
# prints the line to paste into /etc/hostseal/trusted-signers on the hosts that key may act on

Keys that are not files

A key file on a laptop is a real improvement over a shared credential and it is still a file: it can be copied, and nobody would know. --key takes a reference rather than a path, so the same command signs with a hardware token or a cloud key store, and hostseal key show --in <reference> prints the trusted-signers line for any of them.

# A PKCS#11 token — YubiKey PIV, Nitrokey, SoftHSM. The URI is RFC 7512, which is what
# OpenSSL, GnuTLS and p11-kit already speak, so an existing one can be pasted.
hostseal key show --in "pkcs11:token=ops;object=ops-yubikey-1?module-path=/usr/lib/opensc-pkcs11.so"

# A cloud key store. The #fragment is the identity the audit log records and every host lists —
# a resource name is not one, and it is required rather than derived.
hostseal key show --in "awskms:arn:aws:kms:eu-central-1:123456789012:key/abcd-1234#ops-kms-1"
hostseal key show --in "gcpkms:projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1#ops-kms-1"
hostseal key show --in "azurekms:ops.vault.azure.net/keys/hostseal-signing/9885aa55#ops-kms-1"

A PIN is prompted for, or read from a file with pin-source=/path. It is never a URI attribute and never a flag: a secret on a command line is readable from the process list by every user on the machine.

A token label does not have to be unique, and two identically provisioned tokens — an operator with a spare — carry the same one. serial= names one physical token, so token=ops;serial=d276000124010200 means "the token labelled ops whose serial is that" and finds nothing rather than falling back to the other ops. Where the reference has not narrowed and two tokens answer to the label, hostseal refuses and prints the serials it found, rather than signing with whichever the module enumerated first. The other token attributes an RFC 7512 URL carries — model=, manufacturer=, library-version and the rest of that set — are accepted and ignored, because p11tool --list-tokens prints them for every token and they name a product line rather than a token. pin-value and module-name are the two the reference refuses outright, for the reasons the error messages give.

A YubiKey, from empty to a trusted-signers line. HostSeal does not generate a key on a token — ykman does, and the key has no way out of the device afterwards.

# 9c is PIV's signature slot: it asks for the PIN on every operation rather than caching it.
# ED25519 needs firmware 5.7 or newer, and ECCP256 is what everything before it can do — which is why
# two algorithms exist on the wire at all.
ykman piv keys generate --algorithm ECCP256 --touch-policy always 9c signing.pub

# A self-signed certificate in the same slot: most PKCS#11 modules enumerate a PIV slot only once it
# holds one, so a key without a certificate is a key nothing can find.
ykman piv certificates generate --subject "CN=ops-yubikey-1" 9c signing.pub

hostseal key show --in "pkcs11:id=%02?module-path=/usr/lib/x86_64-linux-gnu/libykcs11.so"

Slot 9c is CKA_ID 02, and the reference names that rather than a label, because a PIV token's labels are worded by whoever wrote the module — SIGN key under OpenSC, Private key for Digital Signature under Yubico's own libykcs11 — and matching on one is fragile across vendors in the way this backend exists not to be. What follows from that is worth knowing before anything is engraved on a host: the identity recorded in trusted-signers and in the audit log is then pkcs11:02, derived from the id. object=<label> supplies a name instead, where the module lets you choose one — SoftHSM does, PIV does not.

With --touch-policy always, a signature needs the finger as well as the PIN, which is the property worth having on a key that authorises reboots. hostseal sign waits thirty seconds for it and then gives up, rather than blocking on a token nobody is standing at.

Cloud credentials come from the environment, then the provider's own well-known file, then the instance metadata service — which is the order that answers promptly on a laptop, where the metadata address does not refuse a connection but black-holes it. The flows that are not implemented, because they are most of what a vendor SDK weighs, have one escape hatch each:

eval "$(aws configure export-credentials --profile ops --format env)"                    # SSO, assume-role, federation
export HOSTSEAL_KMS_BEARER_TOKEN="$(gcloud auth print-access-token)"                       # workload identity
export HOSTSEAL_KMS_BEARER_TOKEN="$(az account get-access-token   --resource https://vault.azure.net --query accessToken -o tsv)"                         # certificate auth, federation

Where a cloud key lives matters more than which cloud it is in. A KMS key the control plane's own identity can call Sign on is a key the control plane holds, whatever the console says about custody — see SECURITY.md §9. Put it in an account the control plane has no role in, and check it the way that catches the mistake: assume the control plane's identity and confirm that signing is denied.

Note that Azure Key Vault has no EdDSA algorithm at all, so a Key Vault key must be P-256 and its trusted-signers line will read ecdsa-p256. AWS KMS and Cloud KMS can do either.

A package upgrade never replaces either file. There is a test in testfleet/ that asserts it, and for trusted-signers that is a security test rather than a convenience one.

Stopping a host

sudo touch /etc/hostseal/paused      # refuse everything, immediately
sudo systemctl stop hostseal-agent   # or just stop it

Neither can be undone by the control plane. There is deliberately no agent.resume operation: an off switch that something else can flip back on is not an off switch. The host keeps patching from its local policy either way — a paused host should not become an unpatched host.

Upgrading the agent

Through APT, on the host's own schedule, like any other package. There is no agent.updateFromURL and there never will be: a self-update from a control-plane-supplied URL replaces the binary that enforces every other rule.

Building from source

make build    # all binaries into ./dist
make web      # the Angular application, embedded into hostseal-server
make deb      # the hostseal-agent package
make test lint guarantee

Go 1.26 or newer. make deb needs nfpm; make web needs Node and pnpm.

Where to look when something is wrong

journalctl -u hostseal-agent -n 200     # the agent logs JSON; paste it whole rather than summarising
hostseal-agent policy check             # most "it refused" reports are the policy working correctly
hostseal-server catalogue               # everything the control plane can ask a host to do

The last one is worth running once even when nothing is wrong. It prints the complete, closed set of operations plus the list this project has permanently refused to implement, and it is the fastest way to check that the claim on the front page is true of the binary you actually installed.