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.
-
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 theid,nonce,notBeforeandnotAfterit 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.
-
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'sapprovalMode. A new fleet requires nothing; seeSECURITY.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 | no — packages.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.