Declarative nftables for a whole fleet, with a commit → confirm → auto-rollback
control plane borrowed from network gear and made to work on Linux hosts.
The heliopause is the boundary where the Sun's protective bubble meets interstellar space — the edge of the region it shields. That is what this manages: the edge of your hosts.
- The problem
- What it does
- Safety invariants
- Quick start
- How it fits together
- The policy model
- Two enforcement points, reported separately
- Staged rollout
- Trust, identity and the chain of custody
- Command line
- The console
- Development
- Status & limitations
- License
Managing host firewalls at scale usually means one of three things:
| approach | how it fails | |
|---|---|---|
| 🧨 | hand-written nftables over SSH |
no way back once the rule that severs your session lands |
| 🏚️ | firewalld / ufw |
fine locally; no central policy, no audit trail, no "apply everywhere then verify" |
| 🤖 | config management | happily converges a host into unreachability, then reports success |
All three share one failure mode: the change that locks you out is applied exactly like any other change.
heliopause makes that structurally hard. Every apply carries its own undo, and the undo does not depend on anything off the host still working.
sequenceDiagram
autonumber
participant M as 🧠 manager
participant R as 📡 relay
participant A as 🛡️ agent
participant K as 🐧 kernel
M->>M: render → validate → sign (Ed25519)
M->>R: POST /publish · signed bundle
A->>R: POST /heartbeat · mTLS, every 15s
R-->>A: apply generation g
A->>A: verify signature + structural allowlist
A->>K: nft -j -f · atomic
A->>A: ⏲️ arm rollback timer (≥ 90s)
A->>R: heartbeat · state = pending
alt 🟢 the path survived
R-->>A: 200 OK
A->>A: disarm timer → confirmed
else 🔴 the ruleset severed the path
A--xR: heartbeat never lands
Note over A,K: nothing off this host is needed
A->>K: restore the previous table
A->>A: rolled-back · reason recorded
end
The last branch is the product. Everything else in this repository is arrangements around it —
and it is exercised against a real kernel in CI on every pull request
(scripts/rollback-test.sh).
| Declarative fleet firewalls | One policy document renders nftables for every host in the site. Policies name sources, destinations and ports — never nftables |
| Commit → confirm → auto-rollback | Every apply arms a rollback timer that the host disarms only by reaching the relay. The undo needs nothing off the host still working |
| Two enforcement points | Host traffic renders to nft -j -f; pod and ClusterIP traffic, which never reaches a netfilter hook under Cilium, renders to CiliumNetworkPolicy. The two are tracked as separate states because they genuinely disagree |
| Endpoint kinds | host · host-group · cidr · object · internet · any, four Kubernetes selectors, geofeed, and Cloudflare WARP devices and users |
| Staged rollout | canary → general → gateway, each stage gated on every host at the previous one having confirmed. A policy that locks hosts out locks out the canary and stops there |
| Two-person publishing | The approver may not be the proposer, and both name the same bytes. Plans are content-addressed with a 10-minute TTL |
| Artifact signing | Ed25519 over the exact host payload, verified by the agent before the kernel is touched. The relay is an untrusted courier that never holds the key |
| Built-in PKI | ca · relay · agent · operator roles, ECDSA P-256, issued from the host list in your own site module. No external PKI is required or assumed |
| Fail-closed revocation | A locked, socket-activated writer owns the denylist; updates may add rows but never omit or rewrite one |
| Console and CLI | One SvelteKit console in English and Korean that loads nothing from a CDN, and 15 executables covering PKI, publishing, approval, status, enrollment, revocation, feeds and coverage |
| No build step | Node 22 runs the .ts sources directly, and the library has zero runtime dependencies. The console is optional on top |
Not aspirations. Each is enforced in code, and each came from a measured failure.
| ✅ | invariant | what it prevents |
|---|---|---|
| 🔒 | Only ever touches its own table. The agent refuses a submitted ruleset naming any other table | your existing firewalld / iptables rules quietly disappearing |
| 🚫 | flush ruleset is refused, always |
one line away from wiping a host's firewall |
| 🧱 | Baseline allows are non-removable. They render ahead of policy, and an overlapping policy is rejected with a reason | a rule that silently does nothing, next to a dashboard that says it works |
| 🔁 | ct state established,related accept is first, in both chains |
a broad deny killing the replies to connections the host opened — SSH dies mid-session |
| 🏠 | iif lo accept is prepended under policy drop |
127.0.0.1 becoming unreachable and every localhost-over-TCP service breaking |
| 📣 | Nothing is silently skipped. A policy that cannot render comes back with a reason | believing in controls that do not exist. A skipped deny is an open port |
| 🧭 | Render errors fail the whole generation rather than emitting a partial ruleset | a host open while every screen reports it closed |
| ⚖️ | "not known" ≠ "none" — null and [] never collapse |
an unreadable cluster reading as a clean apply |
| 🛑 | Protected hosts require an explicit opt-in — but rollback is never gated | a batch job that forgot a flag taking out the router; being unable to undo during the incident |
The one configuration that is certainly wrong — and is refused at construction
defineConfig throws rather than letting you build it:
hookPolicy.input is 'drop' with an empty baseline — this locks every host out of itself.
Define the management paths in `baseline` first.
It also bounds the confirm window against the heartbeat interval, because a window shorter than two beats means every apply rolls back on schedule no matter how healthy the host is — which reads as "heliopause is broken" rather than "this number is wrong".
examples/site.ts is a complete, runnable site module — two hosts, a staged
rollout, a dropping input hook with a real baseline, and every address an RFC 5737 documentation
range. It is the file to copy when starting a real policy, and the first thing to change in it is
every address.
git clone https://github.com/henryj-dev/heliopause && cd heliopause
npm ci
npm test # renders the example and asserts the invariants above
node bin/heliopause-publish.ts examples/site.ts ./artifacts --dry-run --allow-dirty
node bin/heliopause-ui.ts examples/site.ts # → http://127.0.0.1:8500The dry run prints what would be published and writes nothing:
generation 1981732-dirty-9b73df1b (2 hosts)
web-01.example.com canary 2 policy rules sha256:af470349ff80a
web-02.example.com general 2 policy rules sha256:af470349ff80a
dry run — nothing written
Both hosts render the same digest because they enforce the same two policies — content-addressing is what lets drift, approval and cross-generation comparison all be one question about bytes.
flowchart LR
subgraph WS["🖥️ workstation — where the policy is"]
POL["site module<br/>policy/*.ts"]
UI["heliopause-ui<br/>loopback only"]
PUB["heliopause-publish<br/>renders · proposes"]
APR["heliopause-approve<br/>second person"]
end
subgraph CLU["☸️ cluster"]
MGR["🧠 manager :8444<br/>plans · approvals · signing<br/>console · enrollment"]
REN["🧾 policy-render<br/>a checkout, zero credentials"]
end
subgraph VPC["🌐 one VPC (× N)"]
REL["📡 relay :8443<br/>gateway · serves + gates"]
AG1["🛡️ agent"]
AG2["🛡️ agent"]
AG3["🛡️ agent + Cilium applier"]
end
POL --> PUB
POL --> UI
PUB -- "POST /plan" --> MGR
APR -- "POST /approve → /publish" --> MGR
MGR <-. "JSON, read-only" .-> REN
MGR == "signed bundle" ==> REL
AG1 -- "heartbeat ⇢ mTLS" --> REL
AG2 --> REL
AG3 --> REL
The control plane is a library, not a service. You embed it in whatever already knows your inventory — which host has which address, which zone it is in. heliopause does not discover your infrastructure; you inject a resolver.
| process | runs where | holds | may |
|---|---|---|---|
| 🧠 manager | one per site | signing key, operator certs, approvals | render, sign, publish, serve the console |
| 📡 relay | one per gateway | published artifacts only | serve what it was given, gate on what agents reported |
| 🛡️ agent | every host | its own certificate | apply, observe, roll itself back |
| 🧾 policy-render | beside the manager | a policy checkout, nothing else | answer one question in JSON |
| 🖥️ heliopause-ui | operator laptop | the working tree | read your uncommitted policy |
The manager is optional in the strongest sense: relays keep serving without it, and
heliopause-publish --break-glass writes straight to a gateway's artifact directory — so the way
out of an incident never depends on the manager being healthy.
Why the relay is deliberately unable to decide anything
A gateway is the most exposed machine in each VPC. So the blast radius of it being wrong — or owned — is kept to "the hosts behind it stop receiving updates", never "the hosts behind it get told anything an attacker likes".
POST /publish does not change that: it accepts a generation rather than producing one, and
the bundle is checked against the digests its own manifest carries — the same digests the agents
check. On top of that, every host payload is signed by the manager's Ed25519 key, which the relay
never holds. What a compromised relay can do — refuse pushes, serve a stale generation — it could
already do by being unreachable, which is why the fleet view reports generations per VPC.
Why the agent pulls, and why the project runs on Node rather than Bun
The agent listens on nothing. The previous design POSTed rulesets to an HTTP server on every host — an inbound attack surface on a firewall host, which the control plane also had to be able to reach, so it could not be firewalled off either. Here the agent opens every connection, and three properties follow:
- Nothing to reach, nothing to authenticate inbound.
- The heartbeat is the confirm signal, so rollback survives the control plane being down.
- Rollback is armed only for the generation just applied — once confirmed, a later heartbeat failure means "updates have stopped", not "undo". Without that distinction a gateway outage would make every host in the VPC revert a change that was fine.
Bun cannot read the client certificate. Bun.serve enforces requestCert/rejectUnauthorized,
but neither it nor Bun's node:https shim exposes the peer certificate to the handler — so there
is no way to learn which agent is calling. Node's req.socket.getPeerCertificate() returns it,
and that single fact decides the security model: without it, every heartbeat's host field is an
unverified self-assertion, a compromised low-value host reports as the canary, and staged rollout
becomes decorative.
A policy says "this source, to this destination, on these ports → allow or deny." It never mentions nftables. Rendering happens separately; resolving names to addresses is injected.
const publicWeb: Policy = {
id: "example-web",
name: "public web",
src: { kind: "internet" },
dst: { kind: "cidr", value: "198.51.100.0/24" },
proto: "tcp",
ports: "80,443",
action: "allow",
denyMode: "drop",
priority: 100,
enabled: true,
};ports accepts "22", "80,443", "1000:2000", "@service-object", or "" for every port.
priority only becomes significant once the chain default is deny. denyMode is a sub-mode of
deny rather than a third action, so that enforcement layers which can express "block" but not
"reject" degrade in style instead of in meaning.
| kind | means | side |
|---|---|---|
host | a single host by name — resolved by your resolver | either |
host-group | a named set of hosts, also resolver-defined | either |
cidr | a literal prefix, 10.0.0.0/8 | either |
object | a reusable named address object | either |
internet | everything outside internalSupernet | either |
any | no address constraint at all | either |
k8s-service | namespace/name — the stable unit; pod addresses die in hours | 🎯 dst only |
k8s-namespace | every pod in a namespace — covers workloads that only send, such as CI runners | either |
k8s-label | a pod label selector, several ANDed with commas | either |
k8s-entity | Cilium's reserved host identity — keeps kubelet probes alive under a default-deny ingress | either |
geofeed | <feed>:<selector>, e.g. cloudflare:KR, from a hashed snapshot | 📍 src only |
cf-device | one approved WARP device, by device id — never by name, which its owner can rename | either |
cf-user | one approved person — the union of their registered devices | either |
Warning
geofeed narrows an origin; it does not authenticate one. vultr:KR contains every other
customer renting an instance in Seoul. cloudflare:KR includes an attacker who pointed their own
zone at your origin. The real effect is a smaller attack surface — internet-wide scanning and
direct hits go away. Authentication is Authenticated Origin Pulls or a shared secret, not an
address list.
hookPolicy |
what renders | what it is for |
|---|---|---|
accept (default) |
denies only — allow policies are reported as skipped, never dropped |
coexisting with firewalld while you migrate; drop is terminal but accept is chain-local |
drop |
default-deny — allow becomes a real rule, everything unlisted is refused |
the configuration that actually closes ports, and the one that locks you out if the baseline is wrong |
Important
The intended migration is asymmetric: input drops, output keeps accepting. Closing inbound is the goal. Closing outbound in the same change would cut the agent's heartbeat — which is what confirms an apply, and what carries the instruction to undo one.
The forward hook is untouched by default, and that default is deliberate: a default-deny forward
chain would be a firewall for traffic nobody described — container networking, virtual machines,
whatever the host routes tomorrow. Where a gateway genuinely needs it, forward.guardInternal
refuses to route a packet into the internal supernet unless it came from there, and
forward.hosts names exactly which hosts get the chain rather than defaulting to all of them.
On a node running Cilium, pod and ClusterIP traffic is resolved in eBPF and never reaches a
netfilter hook. No nftables rule can govern it. So a policy naming a pod destination is rendered
to CiliumNetworkPolicy instead — and that layer has nothing behind it if it fails.
| 🐧 host layer | ☸️ workload layer | |
|---|---|---|
| renders to | nft -j -f JSON document |
CiliumNetworkPolicy CRDs |
| applied by | every agent | one designated applier node |
| rollback timer | confirmTimeoutSec (≥ 90s) |
workload.confirmTimeoutSec, strictly longer |
| readback proof | rule comments must be present (mustContain) |
objects re-read by name and exact content (mustExist) |
| failure mode | SSH and the relay die → short timer | app traffic breaks, node access survives → Cilium needs time to converge |
The two halves are tracked as separate states, because they genuinely disagree: the ruleset
confirms while kubectl apply fails, or the reverse. A single field would have to pick one, and
either choice hides a half-enforced generation behind a clean status.
A Kubernetes write returning
0is not evidence the policy is in force. It proves the API server accepted the documents — Cilium will happily accept a policy that selects nothing.
Rollback there is identity-bound: an existing object must already carry heliopause's managed-by/cluster/name identity before it is touched, and a controller replacement under the same name is left alone rather than overwritten.
flowchart LR
C["🐤 canary"] -->|"every host confirmed<br/>at this generation"| G["🌍 general"]
G -->|"every host confirmed"| W["🚪 gateway"]
C -.->|"the canary never confirms"| X["🛑 the fleet never receives it"]
The value is entirely in the failure case: a policy that locks hosts out locks out the canary, the canary never confirms, and nothing else ever sees it.
The relay evaluates the gate on every heartbeat, as a pure function of recorded state — a rollout that needed the manager online to advance would stall the moment the manager restarted mid-deploy.
What a host status can say, and why silence outranks the stored state
ApplyState — none · pending · confirmed · rolled-back · unsupported.
But a verdict is not the same as a state. state is the last thing a host said, not a statement
about now. One host was dead for nine hours while the site view printed a green confirmed,
because that genuinely was its last word. So one ordering decides both views:
drift ▸ rolled-back ▸ maintenance ▸ never-seen ▸ silent (>90s) ▸ behind ▸ confirmed
drift outranks silence: a host that drifted and then went quiet has a specific problem, and
reporting only the silence would lose it. maintenance outranks silence because it is a sentence a
person wrote on purpose — but stays below drift and rollback, which are evidence the host itself
produced.
flowchart TD
OP1["👤 operator A"] -->|"POST /plan · content-addressed"| PL["📋 plan sha256:…"]
OP2["👤 operator B<br/>a different certificate"] -->|"POST /approve"| PL
PL -->|"POST /publish"| SG["✍️ Ed25519 authorization<br/>bound to the exact host payload"]
SG --> RLY["📡 relay · an untrusted courier"]
RLY --> AGT["🛡️ agent verifies the signature<br/>before touching the kernel"]
| mechanism | what it says | where |
|---|---|---|
| 🤝 mutual TLS, both directions | the relay knows which host reports; the agent knows it is not being fed a ruleset by an impostor | RelayConfig · TlsConfig |
| 🪪 identity binding | the claimed host must equal the certificate subject CN |
relay, on every heartbeat |
| 👥 two-person publishing | the approver may not be the proposer, and both name the same bytes | approval.ts, 10-minute plan TTL |
| ✍️ artifact signing | "the bytes that arrived are the bytes that were approved" — hashes alone only catch accidents | artifact-signature.ts |
| 🔑 two signing rings | manager (online, ≤ 7 days) and break-glass (offline, ≤ 24 hours) |
agents trust the set, which makes rotation zero-downtime |
| 🧾 built-in PKI | ca · relay · agent · operator roles, ECDSA P-256, CA 3650 days / leaves 90 |
heliopause-pki |
| 🚫 fail-closed revocation | a locked, socket-activated writer owns the denylist; updates may add rows but never omit or rewrite one | revocation-writer.ts |
Note
No external PKI is required and none is assumed. heliopause-pki issues everything the
control channel needs, reading the host list straight from your site module — so the set that
gets certificates and the set that gets rendered come from one source. An operator with a
corporate CA ignores the command entirely and points the config at their own files; the relay and
agent only ever read paths.
Why an agent certificate cannot read the fleet view
An agent fetches its own ruleset and reports its own state. The fleet view names every host, its generation, and whether its ruleset has drifted — for whoever owns one machine, that is the list of next targets, annotated with which of them is currently unprotected.
So operator is a separate role, checked by exact CN against an explicit allowlist. No wildcards,
no prefix rules. An agent certificate gets 403.
| command | does | |
|---|---|---|
| 🏗️ | heliopause-pki |
init · issue · site · status · renew · sign-csr — the control channel's own certificates |
| 📤 | heliopause-publish |
render every host; --propose to a manager, or --break-glass straight to an artifact directory |
| ✅ | heliopause-approve |
list pending plans, --approve one, --push an approved one — from a different certificate |
| 📊 | heliopause-status |
read a relay's /status, or --site for every VPC at once; --watch, --json |
| 🧠 | heliopause-manager |
the site manager: plans, approvals, signing, enrollment, console |
| 📡 | heliopause-relay |
one per gateway: serve artifacts, collect heartbeats, evaluate the gate |
| 🧾 | heliopause-policy-render |
the isolated renderer the manager console reads from |
| 🖥️ | heliopause-ui |
the workstation policy screens, loopback only |
| 📝 | heliopause-policy |
the managed policy document: init · export-site · put · placement-add · … |
| 🎫 | heliopause-enrollment |
node tokens, CSR queue, certificate upload, revocation — locally or over the API |
| 🔒 | heliopause-revocations |
init a relay's denylist; compact one that filled up |
| 🔐 | heliopause-revocation-writer |
the socket-activated, privilege-separated denylist owner |
| 🌍 | heliopause-feed |
list · refresh · show — geofeed snapshots, fetched deliberately, never at render time |
| 💻 | heliopause-devices |
diff the approved device registry against Cloudflare and print the edit to paste |
| 🎯 | heliopause-coverage |
probe from outside and record what was observed — the only reading that believes nothing the fleet says |
-
Certificates for everything the site module names.
node bin/heliopause-pki.ts site ./pki policy/dev.ts
-
Render and submit for review — nothing has changed yet.
node bin/heliopause-publish.ts policy/dev.ts dev \ --propose=https://manager.example:8444 --pki=./pki --operator=ops-alice
-
A different person, a different certificate.
node bin/heliopause-approve.ts https://manager.example:8444 --pki=./pki --operator=ops-bob node bin/heliopause-approve.ts https://manager.example:8444 <plan-hash> --approve node bin/heliopause-approve.ts https://manager.example:8444 <plan-hash> --push
-
Watch it land, stage by stage.
node bin/heliopause-status.ts https://manager.example:8444 --site --pki=./pki --watch
Approving and pushing are separate flags on purpose. They could be one step — except the approver is the person who did not render the plan, and making their action also push means the fleet changes at the moment somebody finishes reading a diff.
Managed policy document · standalone enrollment · offline CSR signing
Managed policy document. Schema 2 stores policy definitions and their host-ingress, host-egress or workload placements. Publishing refuses an unplaced policy or an unknown host. Exporting an existing site preserves CIDR order, and therefore produces byte-identical nftables and Cilium artifacts.
node bin/heliopause-policy.ts export-site policy/dev.ts ./policies.json
node bin/heliopause-ui.ts policy/dev.ts --policies=./policies.json
node bin/heliopause-publish.ts policy/dev.ts dev --policies=./policies.json \
--propose=https://manager.example:8444 --pki=./pki --operator=ops-aliceStandalone enrollment. Set HELIOPAUSE_ENROLLMENT_STORE on the manager. No dispatcher,
database or identity provider is required; OIDC and TOTP remain optional. The same store is
operated locally or over the manager API:
node bin/heliopause-enrollment.ts init ./enrollment.json
node bin/heliopause-enrollment.ts token-create ./enrollment.json host-01.example --actor=ops-alice
node bin/heliopause-enrollment.ts csr-list https://manager.example:8444 --pki=./pki
node bin/heliopause-enrollment.ts cert-upload https://manager.example:8444 REQUEST_ID \
--cert=./host.pem --ca-name=site --pki=./pki
node bin/heliopause-enrollment.ts cert-revoke https://manager.example:8444 \
--cert=./host.pem --reason=retired --pki=./pkiinit is an explicit, one-time deployment step and refuses to overwrite an existing file. The
manager and every write command refuse a missing or malformed store — they never guess that a
deleted revocation ledger is a harmless first boot. Back this file up and restore it; do not
run init again once it has held any certificate revocation.
App tokens — the credential a program holds. POST /enrollment/tokens otherwise needs an
operator: a certificate or a browser session, plus a one-time code bound to that person's identity
provider account. A dispatcher provisioning a host is not a person and has neither. An app token is
the third principal — a scoped bearer token, issued by an operator, presented as
Authorization: Bearer hpapp_… with no certificate and no code:
node bin/heliopause-enrollment.ts app-token-create https://manager.example:8444 \
--label=dispatcher --scopes=enrollment:token-create,enrollment:requests-read \
--hostname-pattern='*.dev' --ttl-sec=7776000 --pki=./pki --otp=123456
node bin/heliopause-enrollment.ts app-token-list https://manager.example:8444 --pki=./pki
node bin/heliopause-enrollment.ts app-token-revoke https://manager.example:8444 TOKEN_ID \
--pki=./pki --otp=123456The plaintext is printed once and only its SHA-256 is stored, like a node token. Creating and revoking one keeps every operator check there is — that is where the grant is decided — and using one has none of them.
Two scopes exist and no more. enrollment:token-create reaches POST /enrollment/tokens;
enrollment:requests-read reaches GET /enrollment/requests, which takes ?status= and
?hostname= in any combination — a caller that just planted a token can wait for that host's CSR to
appear instead of reading the whole queue. That read is narrowed to the token's own hostname
pattern: a *.dev token sees dev CSRs and is not told that prod or util exist. Asking for a host
outside the pattern returns an empty list rather than a refusal, so the route cannot be used to map
where the boundary is. Any other route answers 403 naming the token, and an
unknown, expired or revoked token answers 401 saying only that. An app token cannot sign, upload,
reject or revoke anything — the worst a leaked one can do is mint node tokens inside its hostname
pattern and read the CSR queue, and a certificate still requires an operator holding a one-time code.
The hostname pattern is an exact hostname or one leading wildcard label: *.dev covers
k3s-01.dev, and covers neither dev nor a.b.dev. Node tokens issued this way are recorded with
createdBy: app:<label>#<id> and an appTokenId in the audit row's detail — the label alone is
not an identifier, because two live tokens may share one so that a rotation has no gap.
Every accepted answer carries X-Heliopause-App-Token-Expires-At. The caller already holds the
token, so this discloses nothing, and without it the first sign of a lapsed credential is a 401 in
the middle of a provisioning run. Refusals never carry it.
Two limits worth knowing before writing a caller. lastUsedAt on an app token records minting
only: reading the CSR queue takes no lock and writes nothing, so a poller leaves no trace in that
field — it answers "is anything still issuing with this credential", the question asked before
revoking one. And app-token requests are counted by the same per-source bound as the certificate-less
enrollment routes: 30 per minute per address, per manager process, refused with 429.
That bound is per address, not per token, and minting and polling spend the same budget. A dispatcher that does both from one egress address should poll no faster than about once every four seconds, and should treat a 429 as backpressure rather than a failure — the CSR it is waiting for will still be there. Several callers behind one NAT share the budget, which is the case to size for. A per-token bucket would remove that coupling; it is a planned follow-up and is not present today, so do not design a caller that assumes it.
Point the manager at its revocation source with HELIOPAUSE_REVOCATION_FILE. It replicates a
minimal snapshot to every relay over the existing publisher mTLS identity at startup, after each
revocation, and once per minute. Provision each relay's empty denylist exactly once with
heliopause-revocations init — normal relay startup never creates or repairs that file. A
separate locked service account owns it and accepts only bounded, strict, monotonic snapshots; the
network-facing relay holds read access plus permission to connect to one Unix socket, and never
opens the file for writing. Relays reload it on every request, and a missing or malformed file
fails closed rather than restoring a revoked credential. See
packaging/systemd/README.md for the required sysusers,
initialization, socket and enable order.
When one publicly trusted certificate covers more than one entry name, set the exact SNI allowlist
with HELIOPAUSE_PUBLIC_SERVER_NAMES=manager.example,node-enroll.example. The singular
HELIOPAUSE_PUBLIC_SERVER_NAME remains accepted for one-name deployments.
Signing a host-generated CSR offline. The host keeps its private key. After comparing the CSR SHA-256 shown by the host against the value in the enrollment queue over a separate channel:
node bin/heliopause-pki.ts sign-csr ./offline-ca ./host.csr ./host.pem \
--name=host-01.example \
--expect-sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefThe fingerprint is mandatory. The command verifies the CSR signature, the exact single-CN
subject and the ECDSA P-256 key, refuses to overwrite an output, and applies the same 90-day
clientAuth-only profile as heliopause-pki issue. CSR-requested extensions are never copied. The
CA private key stays in ./offline-ca; the host private key never reaches it.
One SvelteKit build, served by both the manager and the workstation UI. English and Korean,
switchable at runtime. Icons are generated into the source tree so a firewall console loads
nothing from a CDN — and npm run icons:check fails the build if any of the 20 names stops
existing upstream, because a missing icon renders as nothing at all.
| 🚦 fleet | 🔍 evidence | 📜 policy |
|---|---|---|
/fleet · /changes · /enrollment |
/lookup · /traffic · /routing |
/policy |
| what is the fleet doing | what does the evidence say | what do the rules say |
The policy screen carries 17 tables — policies · rules · files · baseline · zones ·
crossings · coverage · devices · users · workload · hosts · membership · objects ·
services · feeds · address-space · history — each addressable as a path
(/app/policy/zones) so a link can be copied and sent to the person who has to approve what it
shows.
Two surfaces, and why one of them binds to loopback with no login
heliopause-ui is not the other half of the product. It is what reads your uncommitted
working tree, and what still works when the cluster does not.
Anyone who can run it can already read policy/*.ts — it is a file in a repository they have
checked out, so a login would guard a door beside an open wall. The bind address is the
control: on 0.0.0.0 this becomes an unauthenticated policy-disclosure service, and the policy is
a map of every allowed path into the site. There is therefore no flag to change the host.
It also runs the renderer rather than reading the site module's lists, because "listed against 3 hosts" and "renders on 3 hosts" are different sentences — and a policy that protects nothing would otherwise look identical to one that works.
The manager renders the policy screen without holding the policy.
heliopause-policy-render is a separate deployment carrying a checkout and no credential; the
console reads JSON from it. That process refuses to start if it finds credential-shaped
environment, a Kubernetes service-account token, or a signing key — all three are deployment facts
a manifest can get wrong silently, and every one of them has been got wrong here at least once.
Failing to start costs the console; coming up armed costs the fleet.
npm ci
npm run typecheck # tsc --noEmit
npm test # renderers · protocol · gating · relay · publisher · PKI · console
npm run check:web # Svelte template + component diagnostics (outside the root tsconfig)
npm run build:web # type-checking the console is not the same as building it
npm run icons:check # every icon name still exists in lucide-static
python3 agent/test_validate.py # the agent's validator and rollback state machine
python3 agent/test_enroll.py # host-generated key, durable CSR enrollment
./scripts/e2e-roundtrip.sh # python agent → mTLS → node relay (openssl, python3, curl)
./scripts/rollback-test.sh # auto-rollback against a real kernel (docker)No build step. Node 22 strips types and runs .ts directly, which is why the runtime version
is part of the contract rather than an implementation detail.
| suite | tests | needs |
|---|---|---|
npm test — library · example · console |
1,714 | a checkout |
test_validate.py + test_enroll.py |
205 | python3 |
e2e-roundtrip.sh |
behavioural | a real TLS handshake |
rollback-test.sh |
behavioural | a real kernel |
Measured 2026-08-23. An operational checkout that symlinks a private policy/
reports 1,801 instead — the extra 87 are site-policy tests, and node --test
passes a glob matching nothing rather than failing on it, so they vanish silently
elsewhere. Watch the count, not the colour. On macOS 12 of the agent's tests skip themselves:
LibreSSL's openssl pkeyutl has no -rawin, so the Ed25519 cases cannot
run there.
The last two suites are separate because of what they need, not how long they take: identity binding cannot be unit-tested without a real TLS handshake, and whether a locking ruleset is actually reverted cannot be tested without a real kernel. Both run in CI on every pull request, including one from a fork.
src/ the library — pure renderers, protocol, gating, stores. Zero runtime dependencies
bin/ 15 executables, run directly by node 22
agent/ the host agent — python3 standard library only, plus the nft binary
examples/ a worked site module a public clone can actually run
packages/ web console (SvelteKit) · shared i18n · workspace boxes for the split
packaging/ systemd units, Kubernetes RBAC, container image, signing-key runbook
scripts/ e2e and rollback harnesses, icon generation, fleet deploy, repository guards
See CONTRIBUTING.md for the conventions and why they are what they are, and SECURITY.md for what this promises, what it does not, and where to send a vulnerability.
What works. The core — policy model, renderer, agent, orchestration — is extracted from a system running on production hosts. Zero runtime dependencies, no build step. The console is optional on top, because installing this as a library must not pull a frontend toolchain, and everything the console can do has the same core API and a CLI caller.
Not frozen. The packaging, the configuration surface and the public API are newer than the core and will change. Pin exact versions. The wire protocol is at schema 4, and agents below it are refused work rather than sent something they would misread — with the reason on the fleet view, not only in one host's journal.
Apache-2.0. See LICENSE.
Every address in this repository is an RFC 5737 documentation range and every name an RFC 2606 example. That is enforced, not conventional.