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.

1. Who talks to whom

Every place Blunix runs, what each one holds, and the fifteen connections between them.

Blunix ecosystem: who talks to whom Zones for the operator's browser, adsas.id, Cloudflare, GitHub, our servers, the offline signing key, the customer LAN and the machine at the console, joined by 15 numbered connections. Only blunix.io, its releases function, the GitHub repo and Authentik run today; the portal, API, build hosts, installer and proxy are built and tested; the signing key is designed, not built. The numbered walk-through after this figure says everything the diagram shows. OPERATOR'S DEVICE ADSAS.ID CLOUDFLARE Holds records and ciphertext. Never a key. GITHUB OUR SERVERS OFFLINE CUSTOMER LAN THE MACHINE AT THE CONSOLE Blunix ecosystem: who talks to whom As the code stands on 2026-09-30. Numbers match the walk-through. KEY HERE BUILT Operator's browser Runs the portal's JavaScript. Composes the node document, makes the key, encrypts with age. Sends only ciphertext. RUNS TODAY Authentik (OIDC) Signs the operator in. Blunix client not set up yet: OIDC_ISSUER is empty. DEPLOYED blunix.io website (Pages) Function /releases.json reads GitHub. Its page asks api.blunix.io/v1/health. BUILT, NOT DEPLOYED build.blunix.io portal (Pages) A separate Pages project. Same-site with the API, so the cookie rides fetch. BUILT, NOT DEPLOYED api.blunix.io Worker (/v1) Sign-in, labels, builds, API keys. Checks the age header. Never decrypts. D1 records accounts, labels, versions, hashed sessions and keys, audit rows R2 ciphertext builds/{id}/{n}: the age binary, sha256 checked before serving BUILT, NOT DEPLOYED: NO DNS YET {label}.blnx.io (the same Worker) GET / only. A separate registrable domain. REPO IS LIVE afterdarksys/blunix Actions job in the production environment deploys site/ and portal/. v0.1.0 PUBLISHED Releases ISO, blunix.raw.zst, netboot media, SHA256SUMS. SCRIPTS BUILT Image builder Privileged Docker scripts. No builder host set up yet. DESIGNED, NOT BUILT Image signing key Never on the builder or on Cloudflare. Nothing is signed. KEY HERE BUILT, NOT DEPLOYED blunix proxy (operator's Linux or Mac) publish: encrypts per machine, uploads ciphertext. serve: relays *.blnx.io, netboot media, boot.ipxe. keys.txt (mode 0600) stays on this computer. KEY HERE BUILT, BOOTS IN A VM Installer, or first-boot bootstrap Installer: ISO, USB or netboot. Bootstrap: raw image. Asks the hostname, then the key, echo off. Fetches, decrypts on the machine, applies. LEGEND Deployed, live today Built and tested in the repo Designed, not built Call or data Ciphertext only Designed path KEY HERE The key exists here 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Diagram 1. Open it as a large image.

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_ISSUER is empty in platform/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.json reads GitHub, and its page asks api.blunix.io/v1/health whether 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 fetch calls.
  • 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 at builds/{label id}/{version} as the binary age file, and its sha256 is checked before it is served.
  • Cloudflare, continued. {label}.blnx.io is the same Worker: GET and HEAD on / 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/blunix is live. Its Actions job, in the production environment, deploys site/ and portal/. Releases will hold the ISO, blunix.raw.zst, the netboot media and SHA256SUMS; 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 proxy on the operator's Linux or Mac, built. publish encrypts per machine and uploads ciphertext. serve relays *.blnx.io and serves netboot media and boot.ipxe. The keys are in keys.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

  1. The browser loads the portal page from build.blunix.io.
  2. The browser calls api.blunix.io with the session cookie (credentials: 'include'). The build upload on this path is ciphertext only.
  3. The browser and Authentik: the OIDC sign-in redirects, both ways.
  4. The Worker and Authentik: the Worker trades the code and the PKCE verifier for an ID token, and fetches the JWKS to check it.
  5. The Worker reads and writes D1 and R2.
  6. The build hosts read D1 and R2.
  7. {label}.blnx.io to the machine: HTTPS GET / over verified TLS. Ciphertext only.
  8. {label}.blnx.io to the proxy: the proxy fetches over verified TLS. Ciphertext only.
  9. 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.
  10. The proxy to the Worker: publish reserves labels and uploads ciphertext with a blx_ API key.
  11. The site's /releases.json function reads the GitHub Releases API and each release's SHA256SUMS.
  12. GitHub Actions deploys the site and the portal to Cloudflare Pages.
  13. The builder's assets reach Releases only when a person runs gh release upload. The build does not upload.
  14. 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.
  15. 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.

Web bootstrapping: from sign-in to an applied node document A sequence diagram across six participants: the operator, the browser running the portal, Authentik at adsas.id, the api.blunix.io Worker, the {label}.blnx.io build host, and the machine at the console. A green bar marks where the key exists: the browser from the moment it is made until the install card closes, the operator's install card, and the machine from typing until decryption. The server side never holds it. The numbered walk-through after this figure lists every step. Web bootstrapping: sign-in to an applied document Green bars: where the key exists. It never exists on a server. Double lines carry only ciphertext. Operator a person Browser portal JS Authentik adsas.id api.blunix.io Worker, D1, R2 {label}.blnx.io same Worker Machine at the console IN THE BROWSER, AT BUILD.BLUNIX.IO Sign in GET /v1/auth/login. Stores state, nonce and PKCE verifier for 10 minutes. 302 to Authentik, code_challenge S256 Sign in at adsas.id 302 to /v1/auth/callback with code and state GET /v1/auth/callback Code and verifier for the ID token; JWKS Check the ID token: RS256, ES256 or EdDSA; iss, aud, exp, nonce. Upsert the account by (iss, sub). Cookie __Host-blx_session, 12 h, HttpOnly; 302 to build.blunix.io Form POST /v1/hosts {label}, with x-blunix-csrf: 1 and the portal Origin 201. Or 409 taken, 400 invalid or reserved, 429 rate limited. Compose the node document and check every field. Make the key: 100 bits from crypto.getRandomValues, 20 Crockford base32 characters. Encrypt with age, scrypt passphrase mode. POST /v1/hosts/{label}/builds: ciphertext only, up to 256 KiB Refuse all but one scrypt stanza. Body to R2; version, sha256 and an audit row to D1. 201 {version, sha256, size, url, pinnedUrl or null} The sha256 must match what this browser sent, or no card. Card Install card, shown once: URL, key, sha256. Download .txt and .json. Closing it wipes the key. AT THE MACHINE: INSTALLER OR FIRST-BOOT BOOTSTRAP The operator types the hostname: ada becomes ada.blnx.io. It is read back; the operator says yes. The operator types the key from the card. Echo off. Never spoken. GET https:// ada.blnx.io/, verified TLS 1.2+ Latest version from D1 and R2. The body's sha256 is checked before it leaves. 200, the ciphertext and x-blunix-sha256 Decrypt on the machine. Then check the schema. A wrong key or a refusal applies nothing. Apply. The installer writes the disk first; the bootstrap applies the document to the running disk. KEY HERE No key on this side: not sent, stored or logged. The Worker holds only ciphertext and its sha256. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
Diagram 2. Open it as a large image.

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

  1. The operator asks the browser to sign in.
  2. The browser calls GET /v1/auth/login. The Worker stores the state, the nonce and the PKCE verifier for 10 minutes.
  3. The Worker answers 302 to Authentik, with code_challenge and method S256.
  4. The operator signs in at adsas.id.
  5. Authentik answers 302 to /v1/auth/callback with the code and the state.
  6. The browser calls GET /v1/auth/callback.
  7. The Worker sends Authentik the code and the verifier for the ID token, and fetches the JWKS.
  8. The Worker checks the ID token: algorithm RS256, ES256 or EdDSA only; iss, aud, exp and nonce. It upserts the account by (iss, sub).
  9. The Worker sets the cookie __Host-blx_session (12 hours, HttpOnly) and answers 302 to build.blunix.io.
  10. The operator fills in the form: a label and the node document fields.
  11. The browser calls POST /v1/hosts {label} with x-blunix-csrf: 1 and the portal's Origin.
  12. The Worker answers 201. Or 409 if the label is taken, 400 if it is invalid or reserved, 429 if rate limited.
  13. The browser composes the node document and checks every field.
  14. The browser makes the key: 100 bits from crypto.getRandomValues, 20 Crockford base32 characters. The key now exists in the browser.
  15. The browser encrypts the document with age, scrypt passphrase mode.
  16. The browser calls POST /v1/hosts/{label}/builds with ciphertext only, up to 256 KiB.
  17. 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.
  18. The Worker answers 201 with the version, sha256, size, URL, and a pinned URL, or null while pinned hosts have no certificates.
  19. The browser checks that the sha256 matches what it sent. If not, no card is shown.
  20. The browser hands the operator the install card. The key now exists on the card.
  21. 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

  1. The operator types the hostname. ada becomes ada.blnx.io. It is read back, and the operator says yes.
  2. The operator types the key from the card. Echo is off and it is never spoken. The key now exists on the machine.
  3. The machine calls GET https://ada.blnx.io/ over verified TLS 1.2 or higher, capped at 256 KiB.
  4. The build host takes the latest version from D1 and R2, and checks the body's sha256 before it leaves.
  5. The build host answers 200 with the ciphertext and x-blunix-sha256.
  6. The machine decrypts on the machine, then checks the schema. A wrong key or a refusal applies nothing. The key is dropped after decryption.
  7. 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.

Build and release pipeline Two build scripts run in privileged Docker. build-test-disk.sh --release turns Debian 13 packages into build/blunix-release.raw through mmdebstrap, a strip step, a GPT disk, a root scan, zerofree and a raw byte scan. build-installer.sh --release scans that disk again, compresses it, pins its digest, and makes the ISO, netboot media and SHA256SUMS. Seven gates stop the build. A person uploads the assets to a GitHub release; offline signing is designed, not built. Below, the site and portal deploy through the GitHub Actions production environment to Cloudflare Pages, and the API deploys from a laptop script. The numbered walk-through after this figure says everything the diagram shows. IMAGE/BUILD-TEST-DISK.SH --RELEASE Privileged Docker, debian:trixie-slim. IMAGE/BUILD-INSTALLER.SH --RELEASE Same Docker. Needs BLUNIX_RELEASE_VERSION. PUBLISH SITE AND PORTAL Push to main in site/, portal/ or brand/, or a manual run. API WORKER scripts/deploy-api.sh, a laptop only. Refuses in CI. Build and release pipeline Octagons are gates: the build stops there and writes no release. BUILT 1. Debian 13 packages Official packages, listed in image/packages.txt. BUILT 2. mmdebstrap root filesystem amd64, variant apt, cached by a stamp. GATE: STOPS THE BUILD 3. Strip host keys and secrets SSH host keys, random-seed, credential.secret; machine-id emptied. A host key left: stop. BUILT 4. GPT disk: ESP and ext4 blunix-root Copy root, overlay and tools; grub-install. Release: root locked, fixture removed. GATE: STOPS THE BUILD 5. scan-root.py --release Refuses test secrets, the fixture, an unlocked account, root or password SSH, a shipped host key, and vendor binaries. BUILT 6. zerofree Zeroes the free blocks of blunix-root. GATE: STOPS THE BUILD 7. scan-raw.py over every byte Private keys, age secret keys, the fixture's age header, the two test secrets. BUILT 8. build/blunix-release.raw The release disk. GATE: STOPS THE BUILD 9. Version and disk present No version, the word latest, or no release disk: stop. GATE: STOPS THE BUILD 10. Scan the release disk again Mounted read-only: scan-root.py --release. Then scan-raw.py over the raw file. GATE: STOPS THE BUILD 11. zstd, then scan-raw.py on it blunix.raw.zst. Its decompressed stream is scanned byte by byte. BUILT 12. Digest and release pin blunix.raw.zst.sha256, and blunix.release (version, sha256, size) in the live root. GATE: STOPS THE BUILD 13. scan-root.py on the live root The installer's own filesystem. BUILT 14. ISO and netboot media squashfs; boot menu keys 1 to 5; hybrid ISO, volume BLUNIX_INSTALL; vmlinuz, initrd.img, blunix.squashfs, blunix.ipxe. GATE: STOPS THE BUILD 15. Every asset under 2 GiB GitHub's per-file limit. BUILT 16. SHA256SUMS, build/release/ ISO, blunix.raw.zst, vmlinuz, initrd.img, blunix.squashfs. The build does not upload. DESIGNED, NOT BUILT 17. Sign the digests offline The key never on the builder or Cloudflare. Nothing signs today. MANUAL STEP 18. A person uploads gh release upload, by hand, from build/release/. v0.1.0 PUBLISHED 19. GitHub release afterdarksys/blunix. blunix.io lists it through /releases.json. WORKFLOW BUILT 20. GitHub Actions, environment production Job runs only on refs/heads/main. Actions pinned to commit SHAs; token contents: read. GATE: STOPS THE DEPLOY 21. Stylesheet and tests site.css equals portal.css; node --test. BLUNIX.IO IS LIVE; PORTAL IS NOT 22. wrangler pages deploy site/ to blunix-io, portal/ to build-blunix-io. GATE: STOPS THE DEPLOY 23. Production config Real D1 id, empty DEV_ORIGINS, OIDC issuer and client id set, the client secret on the Worker. GATE: STOPS THE DEPLOY 24. Typecheck and tests npm ci, tsc, vitest in the Workers runtime. SCRIPT BUILT, NOT RUN YET 25. D1 migrations, wrangler deploy api.blunix.io and *.blnx.io. LEGEND Deployed, live today Built and tested in the repo Designed, not built Call or data Designed path Gate: the build stops, fails closed
Diagram 3. Open it as a large image.

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

  1. Debian 13 packages: official packages, listed in image/packages.txt.
  2. mmdebstrap builds the root filesystem: amd64, variant apt, cached by a stamp.
  3. Gate: strip SSH host keys, the random seed and credential.secret, and empty the machine-id. A host key left behind stops the build.
  4. 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.
  5. Gate: scan-root.py --release refuses test secrets, the fixture, an unlocked account, root or password SSH, a shipped host key, and vendor binaries.
  6. zerofree zeroes the free blocks of blunix-root.
  7. Gate: scan-raw.py reads every byte of the disk for private keys, age secret keys, the fixture's age header and the two test secrets.
  8. The output is build/blunix-release.raw, the release disk. It feeds step 9.

image/build-installer.sh --release (needs BLUNIX_RELEASE_VERSION)

  1. Gate: no version, the word latest, or no release disk stops the build.
  2. Gate: the release disk is scanned again. It is mounted read-only for scan-root.py --release, then scan-raw.py reads the raw file.
  3. Gate: zstd compresses it to blunix.raw.zst, and scan-raw.py scans the decompressed stream byte by byte.
  4. The digest blunix.raw.zst.sha256 and the release pin blunix.release (version, sha256, size) go into the live root.
  5. Gate: scan-root.py scans the installer's own filesystem.
  6. The ISO and netboot media: a squashfs, the boot menu with keys 1 to 5, a hybrid ISO with volume label BLUNIX_INSTALL, and vmlinuz, initrd.img, blunix.squashfs and blunix.ipxe.
  7. Gate: every asset must be under 2 GiB, GitHub's per-file limit.
  8. SHA256SUMS in build/release/ covers the ISO, blunix.raw.zst, vmlinuz, initrd.img and blunix.squashfs. The build does not upload.

Publish

  1. 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.
  2. A manual step: a person runs gh release upload from build/release/.
  3. 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

  1. GitHub Actions, environment production (the workflow is built). The job runs only on refs/heads/main. Actions are pinned to commit SHAs, and the token is contents: read.
  2. Gate: site/css/site.css must equal portal/css/site.css, and the node tests must pass.
  3. wrangler pages deploy: site/ to the Pages project blunix-io, portal/ to build-blunix-io. blunix.io is live; the portal is not. scripts/deploy-site.sh is the same path from a laptop.

API Worker: scripts/deploy-api.sh, a laptop only, refuses in CI

  1. 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.
  2. Gate: npm ci, the typecheck, and the vitest suite in the Workers runtime.
  3. D1 migrations, then wrangler deploy for 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.

Installer flow: blunix install Thirteen steps from the boot menu to the reboot question, top to bottom on the left. Each step that can refuse has an arrow to an octagon on the right with the exact sentence the installer says. Every exit before the disk is written ends with Nothing applied; the two after it say the disk is not bootable. The key exists from step 6 to step 8. The numbered walk-through after this figure says everything the diagram shows. Installer flow: blunix install All built; it boots in a VM. Octagons are the sentences it says when it stops. 1. Boot menu, keys 1 to 5 1 full speech, 2 console speech, 3 large print, 4 regular (default after 3 s), 5 advanced. It beeps when ready. Netboot boots 4, no menu. 2. Find the image blunix.raw.zst with its .sha256 on the medium, or else a release pin: version, sha256, size. STOPS blunix: no image on this medium. Nothing applied. 3. Read blunix.proxy= from the kernel line Netboot sets it. It changes only where the document is fetched from. STOPS blunix: refused proxy. Nothing applied. 4. Network DHCP on en* and eth* for 30 s. No lease: type an address like 10.0.0.5/24, a gateway and a DNS server. Enter tries DHCP again. Loops until up. 5. Hostname ada, ada.blnx.io or v3.ada.blnx.io. It is read back; say yes. Three tries. STOPS blunix: no hostname. Nothing applied. KEY HERE 6. Key Typed with echo off. Never spoken or logged. STOPS blunix: no key. Nothing applied. KEY HERE 7. Fetch Direct: https://{host}/, verified TLS 1.2+, 256 KiB. With a proxy: http://{proxy}/v1/build/{host}. STOPS blunix: document too large. blunix: tls verify disabled. blunix: fetch failed. Nothing applied. KEY HERE 8. Decrypt and check Canonical key first, then the raw text. Then the schema. Says the name and digest; through a proxy, the full sha256 to compare with the card. STOPS blunix: could not decrypt. blunix: document refused. Nothing applied. 9. Pick the disk Never the boot medium (live medium, live-media=, bootfrom=, fromiso=, label BLUNIX_INSTALL), a disk in use, read-only or too small. Several: type a number. Erase needs yes, asked twice; silence is no. Only a named, blank target skips the question. STOPS blunix: could not list disks. blunix: target disk sdb is not usable. blunix: no disk fits the image. blunix: no disk chosen. blunix: disk sda kept. Nothing applied. 10. Check the disk again Same name, serial and size as when chosen. Open it by /dev/disk/by-id; the size must match. STOPS blunix: disk sda changed since it was chosen. Nothing applied. 11. Write and verify From the medium: sha256 before the first byte and again after, zstd -dc onto the disk. From a pin: a GitHub release over verified TLS, redirects only to GitHub; the stream's sha256 must match the pin. STOPS blunix: image digest did not match. blunix: image write failed. A digest wrong before the write: Nothing applied. Otherwise: The disk is not bootable. 12. Grow, apply, bootloader sgdisk, growpart, resize2fs; apply the node document into the new root; grub for EFI and BIOS. STOPS blunix: install failed on sda. The disk is not bootable. 13. Reboot? installed ada-1. Remove the stick. Say yes to reboot. Anything else: not rebooting. LEGEND Call or data Octagon: it stops and says this KEY HERE The key exists here
Diagram 4. Open it as a large image.

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.

  1. 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.
  2. Find the image: blunix.raw.zst with its .sha256 on the medium, or else a release pin (version, sha256, size). Exit: blunix: no image on this medium. Nothing applied.
  3. 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.
  4. Network: DHCP on en* and eth* for 30 seconds. With no lease, type an address like 10.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.
  5. Hostname: ada, ada.blnx.io or v3.ada.blnx.io. It is read back; say yes. Three tries. Exit: blunix: no hostname. Nothing applied.
  6. Key: typed with echo off, never spoken or logged. The key now exists here. Exit, for an empty key: blunix: no key. Nothing applied.
  7. Fetch: directly from https://{host}/ over verified TLS 1.2 or higher, capped at 256 KiB, or with a proxy from http://{proxy}/v1/build/{host}. Exits: blunix: document too large., blunix: tls verify disabled., blunix: fetch failed., each followed by Nothing applied.
  8. 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. or blunix: document refused., then Nothing applied.
  9. Pick the disk. Never the boot medium (the live medium, any disk named by live-media=, bootfrom= or fromiso=, or a disk labelled BLUNIX_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 a blunix: sentence followed by Nothing applied.
  10. 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.
  11. Write and verify. From the medium: the sha256 is checked before the first byte and again after, and zstd -dc writes 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 for blunix: image write failed., it says The disk is not bootable.
  12. Grow, apply, bootloader: sgdisk, growpart and resize2fs; apply the node document into the new root; grub for EFI and BIOS. Exit: blunix: install failed on sda. The disk is not bootable.
  13. 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.

blunix proxy on an install LAN Cloudflare at the top holds the API and the build hosts. On the customer LAN, the operator's Linux or macOS computer runs blunix proxy: init, a site file, plan, publish (which reserves labels and uploads ciphertext, and keeps the keys in keys.txt, mode 0600), dnsmasq (which renders a config), and serve (which relays only *.blnx.io, serves verified netboot media and boot.ipxe). Bare-metal machines get DHCP and a PXE chainload from dnsmasq, netboot the installer from serve, and fetch their ciphertext through serve. Netboot is for trusted LANs only until images are signed. The numbered walk-through after this figure says everything the diagram shows. CUSTOMER LAN, THE INSTALL VLAN CLOUDFLARE OPERATOR'S COMPUTER, LINUX OR MACOS Python stdlib, PyYAML, and age on the PATH. blunix proxy on an install LAN All built and tested. The key stays in keys.txt and on the printed card. BUILT, NOT DEPLOYED api.blunix.io POST /v1/hosts reserves; POST .../builds uploads. BUILT, NOT DEPLOYED {label}.blnx.io GET / serves the ciphertext, verified TLS. BUILT 1. blunix proxy init Writes proxy.yaml (0600): API URL, key file, listen address. The blx_ key (hosts:write) is read with echo off into a 0600 file. A blx_join_ token or a key argument is refused. BUILT 2. site.yaml Per MAC: label, hostname, a static address or dhcp: true, disk, access, optional target. Strict parser: unknown keys, duplicates, aliases and multicast MACs are refused. BUILT 3. blunix proxy plan Renders and validates every node document. No network, unless --check reads GET /v1/hosts. BUILT 4. blunix proxy publish All documents validate first, or nothing is sent. Per machine: reserve the label, render with the inline static network, make a 100-bit key, age --passphrase, write the pending card, upload, check the sha256 and size. state.json: no keys. KEY HERE BUILT 5. keys.txt O_EXCL, mode 0600, one card per machine. Never served; not in the media allowlist. Print the cards, then delete the file. BUILT 6. blunix proxy dnsmasq Renders a config to stdout. It does not start dnsmasq. BUILT 7. blunix proxy serve GET and HEAD only; 64 connections; 60 requests per IP, refilled 1 a second; no bodies logged. /v1/build/{host}: relays only {label}.blnx.io and v{n}.{label}.blnx.io over verified TLS 1.2+. No redirects, 256 KiB, 20 s, age bodies only. Cache: latest 60 s, pinned 1 h. TLS failure: 502. /media/: vmlinuz, initrd.img, blunix.squashfs, hashed against SHA256SUMS at startup. A changed file is not served. /v1/boot.ipxe names the advertise address, never the Host header. /v1/netconfig/{mac}, /healthz. CONFIG RENDERED 8. dnsmasq, run by the operator DNS off (port=0). A DHCP reservation per MAC; PXE only for listed MACs. iPXE gets http://PROXY/v1/boot.ipxe; others get ipxe.efi or undionly.kpxe over TFTP from /srv/tftp, which the operator supplies. KEY HERE BUILT 9. 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. Decrypted on the machine. SIGNING: DESIGNED, NOT BUILT Trusted LANs only until images are signed 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. boot.ipxe /media/ plain http /v1/build/ {host} DHCP, PXE Bearer blx_ key verified TLS LEGEND Deployed, live today Built and tested in the repo Designed, not built Call or data Ciphertext only Designed path KEY HERE The key exists here 10 11 12 13 14 15 16
Diagram 5. Open it as a large image.

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/hosts reserves a label; POST /v1/hosts/{label}/builds uploads.
  • {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)

  1. blunix proxy init writes proxy.yaml (mode 0600): the API URL, the key file and the listen address. The blx_ API key (scope hosts:write) is read with echo off into a 0600 file. A blx_join_ token, or a key given as an argument, is refused.
  2. site.yaml lists, per MAC: the label, the hostname, a static address or dhcp: true, the disk, the access profile, and an optional target disk. The parser refuses unknown keys, duplicate keys, aliases and multicast MACs.
  3. blunix proxy plan renders and validates every node document. It makes no network call unless --check reads GET /v1/hosts.
  4. blunix proxy publish validates 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 with age --passphrase, write the pending card, upload, and check the returned sha256 and size. state.json holds no keys.
  5. keys.txt: created with O_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.
  6. blunix proxy dnsmasq renders a dnsmasq config to stdout. It does not start dnsmasq.
  7. blunix proxy serve: GET and HEAD only, at most 64 connections, 60 requests per IP refilled at one a second, and no bodies logged. /v1/build/{host} relays only {label}.blnx.io and v{n}.{label}.blnx.io over 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/ serves vmlinuz, initrd.img and blunix.squashfs, hashed against SHA256SUMS at startup; a file that changes later is not served. /v1/boot.ipxe names the advertise address, never the Host header. Also /v1/netconfig/{mac} and /healthz.

On the install VLAN

  1. 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 get http://PROXY/v1/boot.ipxe; others get ipxe.efi or undionly.kpxe over TFTP from /srv/tftp, which the operator supplies.
  2. 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

  1. publish to api.blunix.io: reserve and upload, ciphertext only, with the Bearer blx_ key.
  2. serve to {label}.blnx.io: verified TLS, ciphertext only.
  3. The rendered config goes to dnsmasq, which the operator starts.
  4. dnsmasq to the machines: DHCP and the PXE boot file.
  5. The machines fetch boot.ipxe from serve.
  6. The machines fetch the kernel, initrd and squashfs from /media/ over plain http.
  7. The installer fetches /v1/build/{host} from serve: 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.

The future plane: designed, not built Everything on this diagram is designed, not built, except one box: the API already refuses every blx_join_ token with 401. Clients (the web, the laptop CLI, Terraform, Ansible, support) call api.blunix.io. An enrolled machine runs blunixservice, which dials out only: it enrolls once with a join token, then signs check-ins with its Ed25519 key and gets back a desired generation, fetches the ciphertext, applies it, and stages images with systemd-sysupdate from a manifest generated from the Blunix Log. A cloud builder appends log rows and an offline key signs them. Troubleshoot is a fixed collect that rides the check-in response and needs a spoken yes on speech and large-print machines. The numbered walk-through after this figure says everything the diagram shows. CLIENTS OF ONE API API.BLUNIX.IO ENROLLED MACHINE BLUNIX LOG BUILD AND SIGN The future plane DESIGNED, NOT BUILT: docs/designs/blunix-service.md and blunix-platform.md. Dashed throughout. DESIGNED, NOT BUILT Web console, laptop CLI, Terraform, Ansible, support desk No second control plane. Keys by scope: hosts:write, machines:read, troubleshoot:request, log:publish. DESIGNED, NOT BUILT api.blunix.io/v1, new routes PUT /v1/hosts/{label}: visibility public or enrolled. POST .../join-tokens mints blx_join_. POST /v1/machines: bind a public key, consume the token. POST .../checkin, GET .../desired. GET /v1/machines?channel=stable&behind=1. BUILT, NOT DEPLOYED Built today: the refusal Any blx_join_ bearer token is a 401 on every route. DESIGNED, NOT BUILT {label}.blnx.io, visibility enrolled Only a bound machine signature fetches the ciphertext. public keeps the anonymous GET. DESIGNED, NOT BUILT Troubleshoot jobs troubleshoot:request mints one: hostname, reason (behind, decrypt, network, speech, other), expiry. No shell scope; a command field is refused. Report read needs troubleshoot:read. DESIGNED, NOT BUILT blunixservice.service Dials out only; no TCP listener. Local control on /run/blunix/service.sock (0660). Boot does not wait for it. DESIGNED, NOT BUILT Enroll once Ed25519 key made on first start, machine.key 0600. A blx_join_ token (single use, expiry) is sent once over TLS, then discarded. DESIGNED, NOT BUILT Check in, signed Signature over method, path, time and body sha256; 5-minute skew; 64 KiB; jitter, backoff. Reply: the desired generation. KEY HERE DESIGNED, NOT BUILT Apply inside the window Fetch the ciphertext, decrypt with the local credential blunix.build-passphrase, run blunix node apply. On failure keep the old one. DESIGNED, NOT BUILT 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 ask and wait. DESIGNED, NOT BUILT Troubleshoot collect blunix: troubleshoot requested for lab-3. Say yes to start. A fixed collector, an allowlisted report of 64 KiB, signed. No journal, no keys. DESIGNED, NOT BUILT Blunix Log, append-only Row: version, channel, artifact sha256, signature, predecessor, files. Withdraw is a new row. GET is public; the site renders it. DESIGNED, NOT BUILT Manifest, updates.blunix.io/blunix Generated from current rows, never hand-edited. Mirrors are caches of one digest. DESIGNED, NOT BUILT Cloud builder Runs the build the repo specifies. Its only output is a log row, through log:publish. DESIGNED, NOT BUILT Offline signing key Signs images and rows. Never on the builder or on Cloudflare. LEGEND Deployed, live today Built and tested in the repo Designed, not built Call or data Designed path KEY HERE The key exists here 1 2 3 4 5 6 7 8
Diagram 6. Open it as a large image.

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-tokens mints a blx_join_ token. POST /v1/machines binds a public key and consumes the token. POST /v1/machines/{hostname}/checkin and GET .../desired. GET /v1/machines?channel=stable&behind=1 lists machines behind the channel head.
  • {label}.blnx.io with visibility enrolled. Only a bound machine's signature fetches the ciphertext. Public keeps the anonymous GET.
  • Troubleshoot jobs. troubleshoot:request mints 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 needs troubleshoot: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. A blx_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, run blunix 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. GET is 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

  1. The clients call api.blunix.io.
  2. The machine enrolls with its join token.
  3. Check-in and the desired generation, both ways.
  4. The build host sends the ciphertext to the apply step.
  5. The collect sends its signed report to the troubleshoot job.
  6. The cloud builder appends a log row.
  7. The offline key signs the rows.
  8. systemd-sysupdate reads the manifest.