Architecture
How it fits together
Six drawings of the install plane, made from the code as it stands on 30 September 2026, not from the plan. Where the code and the plan disagree, the drawing follows the code.
Each drawing has a numbered walk-through under it that says everything the drawing says. The text is the full account; the drawing illustrates it. On a narrow screen, a drawing scrolls sideways inside its frame.
What is real today
Three states, told apart by shape and line style and always written in words on each box, never by colour alone.
-
Deployed, live today. A heavy solid border and a filled dot. Live: the blunix.io website and its
/releases.jsonfunction (no release is published yet), the GitHub repo, and Authentik at adsas.id. -
Built and tested in the repo. A thin solid border and a hollow square. The portal, the API Worker and the
{label}.blnx.iobuild hosts are built and not deployed: build.blunix.io and api.blunix.io answer placeholders, and blnx.io has no DNS yet. The installer, the first-boot bootstrap, the build scripts andblunix proxyare built; the installer boots in a VM. - Designed, not built. A dashed border and a dashed square. The offline image signing key, enrolled machines and blunixservice, the Blunix Log, sysupdate from the log, and troubleshoot collect.
- A double line carries only ciphertext. A single solid line is any other call or data. A dashed line is a designed path.
- An octagon is a gate or a stop. The build, the deploy or the install stops there and says why.
-
Where the key exists. The browser that made it, the install card,
keys.txton the proxy's computer, and the machine at the console. Never a server.
1. Who talks to whom
Every place Blunix runs, what each one holds, and the fifteen connections between them.
What each zone holds
- Operator's device. The operator's browser runs the portal's JavaScript (built). It composes the node document, makes the key, and encrypts with age. The key exists here. The browser sends only ciphertext.
- adsas.id. Authentik, the OIDC issuer, runs today. The Blunix client is not set up yet:
OIDC_ISSUERis empty inplatform/api/wrangler.jsonc, so sign-in answers 503 until it is. - Cloudflare holds records and ciphertext, never a key. The blunix.io website on Pages is deployed. Its function
/releases.jsonreads GitHub, and its page asksapi.blunix.io/v1/healthwhether the API is up. - Cloudflare, continued. The build.blunix.io portal is a separate Pages project, built and not deployed; today that name answers a placeholder. It is same-site with the API, so the session cookie rides the portal's
fetchcalls. - Cloudflare, continued. The api.blunix.io Worker (
/v1) is built and not deployed; today that name answers a placeholder. It handles sign-in, labels, builds and API keys. It checks the age header and never decrypts. D1 holds accounts, labels, versions, hashed sessions and hashed API keys, audit rows and rate limits. R2 holds each build atbuilds/{label id}/{version}as the binary age file, and its sha256 is checked before it is served. - Cloudflare, continued.
{label}.blnx.iois the same Worker:GETandHEADon/only. blnx.io is a separate registrable domain, so build hosts never share cookies with the site, the portal or the API. Built, not deployed: blnx.io has no DNS yet. - GitHub. The repo
afterdarksys/blunixis live. Its Actions job, in theproductionenvironment, deployssite/andportal/. Releases will hold the ISO,blunix.raw.zst, the netboot media andSHA256SUMS; none is published yet. - Our servers. The image builder. The build scripts are built and run in privileged Docker. No builder host is set up in the tree yet.
- Offline. The image signing key: designed, not built. It is never on the builder and never on Cloudflare. Nothing is signed today.
- Customer LAN.
blunix proxyon the operator's Linux or Mac, built.publishencrypts per machine and uploads ciphertext.serverelays*.blnx.ioand serves netboot media andboot.ipxe. The keys are inkeys.txt, mode 0600, on this computer. The key exists here. - The machine at the console. The installer (ISO, USB or netboot) or the first-boot bootstrap (raw image), built and booting in a VM. It asks the hostname, then the key with echo off, fetches, decrypts on the machine, and applies. The key exists here while it is typed.
The numbered connections
- The browser loads the portal page from build.blunix.io.
- The browser calls api.blunix.io with the session cookie (
credentials: 'include'). The build upload on this path is ciphertext only. - The browser and Authentik: the OIDC sign-in redirects, both ways.
- The Worker and Authentik: the Worker trades the code and the PKCE verifier for an ID token, and fetches the JWKS to check it.
- The Worker reads and writes D1 and R2.
- The build hosts read D1 and R2.
{label}.blnx.ioto the machine: HTTPSGET /over verified TLS. Ciphertext only.{label}.blnx.ioto the proxy: the proxy fetches over verified TLS. Ciphertext only.- The proxy to the machine: plain http on the LAN. Ciphertext only. It is safe because age is authenticated; through a proxy the installer also says the full sha256 to compare with the install card.
- The proxy to the Worker:
publishreserves labels and uploads ciphertext with ablx_API key. - The site's
/releases.jsonfunction reads the GitHub Releases API and each release'sSHA256SUMS. - GitHub Actions deploys the site and the portal to Cloudflare Pages.
- The builder's assets reach Releases only when a person runs
gh release upload. The build does not upload. - The installer streams the image from a GitHub release when its medium carries only a release pin. The stream's sha256 must match the pin.
- Designed, not built: the offline key signs the builder's digests.
2. From sign-in to an applied document
The web half and the machine half, step by step, with the three places the key exists.
Six participants: the operator, the browser running the portal, Authentik at adsas.id, the api.blunix.io Worker (with D1 and R2), the {label}.blnx.io build host (the same Worker), and the machine at the console. The key exists in three places only: the browser, from step 14 until the install card closes in step 21; the operator's install card, from step 20; and the machine, from step 23 until it decrypts in step 27. It is never on a server.
In the browser, at build.blunix.io
- The operator asks the browser to sign in.
- The browser calls
GET /v1/auth/login. The Worker stores the state, the nonce and the PKCE verifier for 10 minutes. - The Worker answers 302 to Authentik, with
code_challengeand methodS256. - The operator signs in at adsas.id.
- Authentik answers 302 to
/v1/auth/callbackwith the code and the state. - The browser calls
GET /v1/auth/callback. - The Worker sends Authentik the code and the verifier for the ID token, and fetches the JWKS.
- The Worker checks the ID token: algorithm RS256, ES256 or EdDSA only;
iss,aud,expandnonce. It upserts the account by(iss, sub). - The Worker sets the cookie
__Host-blx_session(12 hours,HttpOnly) and answers 302 to build.blunix.io. - The operator fills in the form: a label and the node document fields.
- The browser calls
POST /v1/hosts {label}withx-blunix-csrf: 1and the portal'sOrigin. - The Worker answers 201. Or 409 if the label is taken, 400 if it is invalid or reserved, 429 if rate limited.
- The browser composes the node document and checks every field.
- The browser makes the key: 100 bits from
crypto.getRandomValues, 20 Crockford base32 characters. The key now exists in the browser. - The browser encrypts the document with age, scrypt passphrase mode.
- The browser calls
POST /v1/hosts/{label}/buildswith ciphertext only, up to 256 KiB. - The Worker refuses anything but an age file with one scrypt stanza. It stores the body in R2, and the version, the sha256 and an audit row in D1.
- The Worker answers 201 with the version, sha256, size, URL, and a pinned URL, or null while pinned hosts have no certificates.
- The browser checks that the sha256 matches what it sent. If not, no card is shown.
- The browser hands the operator the install card. The key now exists on the card.
- The card is shown once: the URL, the key and the sha256, with .txt and .json downloads. Closing it wipes the key from the page.
At the machine: the installer or the first-boot bootstrap
- The operator types the hostname.
adabecomesada.blnx.io. It is read back, and the operator says yes. - The operator types the key from the card. Echo is off and it is never spoken. The key now exists on the machine.
- The machine calls
GET https://ada.blnx.io/over verified TLS 1.2 or higher, capped at 256 KiB. - The build host takes the latest version from D1 and R2, and checks the body's sha256 before it leaves.
- The build host answers 200 with the ciphertext and
x-blunix-sha256. - The machine decrypts on the machine, then checks the schema. A wrong key or a refusal applies nothing. The key is dropped after decryption.
- The machine applies. The installer writes the disk first; the bootstrap applies the document to the disk it is running on.
The note at the bottom of the diagram says it plainly: no key on the server side. It is not sent, stored or logged. The Worker holds only ciphertext and its sha256.
3. Build and release
From Debian packages to a GitHub release, every gate where the build fails closed, and how the site and API deploy.
Octagons are gates: the build stops there and writes no release. The first two columns run in privileged Docker (debian:trixie-slim).
image/build-test-disk.sh --release
- Debian 13 packages: official packages, listed in
image/packages.txt. - mmdebstrap builds the root filesystem: amd64, variant apt, cached by a stamp.
- Gate: strip SSH host keys, the random seed and
credential.secret, and empty the machine-id. A host key left behind stops the build. - A GPT disk with an ESP and an ext4
blunix-root. The root, the overlay and the tools are copied in, and grub is installed. For a release, root is locked and the test fixture is removed. - Gate:
scan-root.py --releaserefuses test secrets, the fixture, an unlocked account, root or password SSH, a shipped host key, and vendor binaries. - zerofree zeroes the free blocks of
blunix-root. - Gate:
scan-raw.pyreads every byte of the disk for private keys, age secret keys, the fixture's age header and the two test secrets. - The output is
build/blunix-release.raw, the release disk. It feeds step 9.
image/build-installer.sh --release (needs BLUNIX_RELEASE_VERSION)
- Gate: no version, the word
latest, or no release disk stops the build. - Gate: the release disk is scanned again. It is mounted read-only for
scan-root.py --release, thenscan-raw.pyreads the raw file. - Gate: zstd compresses it to
blunix.raw.zst, andscan-raw.pyscans the decompressed stream byte by byte. - The digest
blunix.raw.zst.sha256and the release pinblunix.release(version, sha256, size) go into the live root. - Gate:
scan-root.pyscans the installer's own filesystem. - The ISO and netboot media: a squashfs, the boot menu with keys 1 to 5, a hybrid ISO with volume label
BLUNIX_INSTALL, andvmlinuz,initrd.img,blunix.squashfsandblunix.ipxe. - Gate: every asset must be under 2 GiB, GitHub's per-file limit.
SHA256SUMSinbuild/release/covers the ISO,blunix.raw.zst,vmlinuz,initrd.imgandblunix.squashfs. The build does not upload.
Publish
- Designed, not built: sign the digests offline. The key is never on the builder or Cloudflare. Nothing is signed today. The dashed path runs through this step; the solid path skips it.
- A manual step: a person runs
gh release uploadfrombuild/release/. - The GitHub release on
afterdarksys/blunix. blunix.io lists it through/releases.json. None is published yet.
Site and portal: push to main in site/, portal/ or brand/, or a manual run
- GitHub Actions, environment
production(the workflow is built). The job runs only onrefs/heads/main. Actions are pinned to commit SHAs, and the token iscontents: read. - Gate:
site/css/site.cssmust equalportal/css/site.css, and the node tests must pass. wrangler pages deploy:site/to the Pages projectblunix-io,portal/tobuild-blunix-io. blunix.io is live; the portal is not.scripts/deploy-site.shis the same path from a laptop.
API Worker: scripts/deploy-api.sh, a laptop only, refuses in CI
- Gate: a production config. A real D1 id, an empty
DEV_ORIGINS, the OIDC issuer and client id set, and the client secret on the Worker. - Gate:
npm ci, the typecheck, and the vitest suite in the Workers runtime. - D1 migrations, then
wrangler deployfor api.blunix.io and*.blnx.io. The script is built and has not run yet.
4. The installer
Thirteen steps from the boot menu to the reboot question, and every sentence it says when it stops.
All of this is built and boots in a VM. Each exit is the exact sentence the installer says. Every exit before the disk is written ends with Nothing applied. The key exists from step 6 to step 8.
- Boot menu, keys 1 to 5: 1 full speech, 2 console speech, 3 large print, 4 regular (the default after 3 seconds), 5 advanced. The menu beeps when it is ready. Netboot boots regular, with no menu.
- Find the image:
blunix.raw.zstwith its.sha256on the medium, or else a release pin (version, sha256, size). Exit:blunix: no image on this medium. Nothing applied. - Read
blunix.proxy=from the kernel command line. Netboot sets it. It changes only where the document is fetched from. Exit:blunix: refused proxy. Nothing applied. - Network: DHCP on
en*andeth*for 30 seconds. With no lease, type an address like10.0.0.5/24, then a gateway and a DNS server. Enter tries DHCP again. It loops until the network is up; there is no exit here. - Hostname:
ada,ada.blnx.ioorv3.ada.blnx.io. It is read back; say yes. Three tries. Exit:blunix: no hostname. Nothing applied. - Key: typed with echo off, never spoken or logged. The key now exists here. Exit, for an empty key:
blunix: no key. Nothing applied. - Fetch: directly from
https://{host}/over verified TLS 1.2 or higher, capped at 256 KiB, or with a proxy fromhttp://{proxy}/v1/build/{host}. Exits:blunix: document too large.,blunix: tls verify disabled.,blunix: fetch failed., each followed by Nothing applied. - Decrypt and check: the canonical key first, then the raw text as typed, then the schema. It says the document name and digest; through a proxy it says the full sha256 to compare with the install card. The key is dropped after this step. Exits:
blunix: could not decrypt.orblunix: document refused., then Nothing applied. - Pick the disk. Never the boot medium (the live medium, any disk named by
live-media=,bootfrom=orfromiso=, or a disk labelledBLUNIX_INSTALL), a disk in use, a read-only disk, or one too small. With several, type a number. Erasing needs yes, asked twice; silence is no. Only a target named in the document that is blank skips the question. Exits:could not list disks,target disk sdb is not usable,no disk fits the image,no disk chosen,disk sda kept, each as ablunix:sentence followed by Nothing applied. - Check the disk again: the same name, serial and size as when it was chosen. It is opened by
/dev/disk/by-id, and the opened size must match. Exit:blunix: disk sda changed since it was chosen. Nothing applied. - Write and verify. From the medium: the sha256 is checked before the first byte and again after, and
zstd -dcwrites the disk. From a release pin: the image streams from a GitHub release over verified TLS, redirects stay on GitHub, and the stream's sha256 must match the pin. Exits:blunix: image digest did not match.A digest found wrong before the write says Nothing applied; after the write, and forblunix: image write failed., it says The disk is not bootable. - Grow, apply, bootloader:
sgdisk,growpartandresize2fs; apply the node document into the new root; grub for EFI and BIOS. Exit:blunix: install failed on sda. The disk is not bootable. - Reboot:
blunix: installed ada-1. Remove the stick. Say yes to reboot.Anything else:blunix: not rebooting.
Not drawn, but in the code: any answer typed at a second console stops with blunix: another console is installing., and any unexpected error stops with blunix: install failed.
5. The build-proxy on a LAN
Publishing a site file of machines, then netbooting them. For trusted LANs only until images are signed.
Everything here is built and tested; the signing that would end the trusted-LAN rule is designed, not built. The key stays in keys.txt and on the printed card.
Cloudflare
- api.blunix.io, built and not deployed:
POST /v1/hostsreserves a label;POST /v1/hosts/{label}/buildsuploads. {label}.blnx.io, built and not deployed:GET /serves the ciphertext over verified TLS.
The operator's computer, Linux or macOS (Python stdlib, PyYAML, and age on the PATH)
blunix proxy initwritesproxy.yaml(mode 0600): the API URL, the key file and the listen address. Theblx_API key (scopehosts:write) is read with echo off into a 0600 file. Ablx_join_token, or a key given as an argument, is refused.site.yamllists, per MAC: the label, the hostname, a static address ordhcp: true, the disk, the access profile, and an optional target disk. The parser refuses unknown keys, duplicate keys, aliases and multicast MACs.blunix proxy planrenders and validates every node document. It makes no network call unless--checkreadsGET /v1/hosts.blunix proxy publishvalidates every document first, or sends nothing. Then, per machine: reserve the label, render the document with its inline static network, make a 100-bit key, encrypt withage --passphrase, write the pending card, upload, and check the returned sha256 and size.state.jsonholds no keys.keys.txt: created withO_EXCL, mode 0600, one card per machine. It is never served and is not in the media allowlist. The key exists here. Print the cards, then delete the file.blunix proxy dnsmasqrenders a dnsmasq config to stdout. It does not start dnsmasq.blunix proxy serve:GETandHEADonly, at most 64 connections, 60 requests per IP refilled at one a second, and no bodies logged./v1/build/{host}relays only{label}.blnx.ioandv{n}.{label}.blnx.ioover verified TLS 1.2 or higher, with no redirects, a 256 KiB cap, a 20-second limit and age bodies only, cached 60 seconds for latest and 1 hour for pinned; a TLS failure is a 502./media/servesvmlinuz,initrd.imgandblunix.squashfs, hashed againstSHA256SUMSat startup; a file that changes later is not served./v1/boot.ipxenames the advertise address, never the Host header. Also/v1/netconfig/{mac}and/healthz.
On the install VLAN
- dnsmasq, run by the operator with the rendered config: DNS off (
port=0), a DHCP reservation per MAC, and PXE only for listed MACs. iPXE clients gethttp://PROXY/v1/boot.ipxe; others getipxe.efiorundionly.kpxeover TFTP from/srv/tftp, which the operator supplies. - Bare-metal machines: PXE, then iPXE, then the live installer with
blunix.proxy=on its kernel line. The operator types the hostname, then the key from the card. The key exists here. The document is decrypted on the machine.
The numbered arrows
publishto api.blunix.io: reserve and upload, ciphertext only, with theBearer blx_key.serveto{label}.blnx.io: verified TLS, ciphertext only.- The rendered config goes to dnsmasq, which the operator starts.
- dnsmasq to the machines: DHCP and the PXE boot file.
- The machines fetch
boot.ipxefromserve. - The machines fetch the kernel, initrd and squashfs from
/media/over plain http. - The installer fetches
/v1/build/{host}fromserve: ciphertext only.
Trusted LANs only until images are signed. The kernel, initrd and squashfs travel as plain http. serve's startup check proves they match the SHA256SUMS you trusted, not who built them. iPXE and live-boot verify nothing. Signing is designed, not built.
6. Later: the future plane
Designed, not built. Enrolled machines, check-in, updates from the Blunix Log, and troubleshoot collect.
Everything on this diagram is designed, not built, from docs/designs/blunix-service.md and docs/designs/blunix-platform.md. One box is the exception: the API already refuses every blx_join_ bearer token with 401 on every route. That refusal is built, not deployed.
The boxes
- Clients of one API. The web console, the laptop CLI, Terraform, Ansible and the support desk. There is no second control plane. Keys by scope:
hosts:write,machines:read,troubleshoot:request,log:publish. - api.blunix.io, new routes.
PUT /v1/hosts/{label}sets visibility to public or enrolled.POST /v1/hosts/{label}/join-tokensmints ablx_join_token.POST /v1/machinesbinds a public key and consumes the token.POST /v1/machines/{hostname}/checkinandGET .../desired.GET /v1/machines?channel=stable&behind=1lists machines behind the channel head. {label}.blnx.iowith visibility enrolled. Only a bound machine's signature fetches the ciphertext. Public keeps the anonymousGET.- Troubleshoot jobs.
troubleshoot:requestmints one: a hostname, a reason (behind, decrypt, network, speech, other) and an expiry. There is no shell scope, and a command field is refused. Reading the report needstroubleshoot:read. - Enrolled machine, blunixservice.service. It dials out only and has no TCP listener. Local control is on
/run/blunix/service.sock(0660). Boot does not wait for it. - Enroll once. An Ed25519 key is made on first start, in
machine.key, mode 0600. Ablx_join_token (single use, with an expiry) is sent once over TLS, then discarded. - Check in, signed. The signature covers the method, the path, the time and the body's sha256, with a 5-minute skew, a 64 KiB cap, jitter and backoff. The reply is the desired generation.
- Apply inside the window. Fetch the ciphertext, decrypt with the local credential
blunix.build-passphrase, runblunix node apply. On failure, keep the old document. The key exists here, as that credential. - systemd-sysupdate, A/B. Stage the image in the other slot; the old slot still boots. Reboot only in the window. Speech and large-print machines ask and wait.
- Troubleshoot collect.
blunix: troubleshoot requested for lab-3. Say yes to start.A fixed collector, an allowlisted report of 64 KiB, signed. No journal text and no keys. - Blunix Log, append-only. A row holds the version, channel, artifact sha256, signature, predecessor and file names. Withdrawing is a new row.
GETis public; the site renders it. - Manifest at
updates.blunix.io/blunix. Generated from the current rows, never edited by hand. Mirrors are caches of one digest. - Cloud builder. Runs the build the repo specifies. Its only output is a log row, through
log:publish. - Offline signing key. Signs images and rows. Never on the builder or on Cloudflare.
The numbered arrows, all designed
- The clients call api.blunix.io.
- The machine enrolls with its join token.
- Check-in and the desired generation, both ways.
- The build host sends the ciphertext to the apply step.
- The collect sends its signed report to the troubleshoot job.
- The cloud builder appends a log row.
- The offline key signs the rows.
- systemd-sysupdate reads the manifest.