From 1aa6e9d05f42d8593e3a71cd179d9ae6c7e4ca29 Mon Sep 17 00:00:00 2001 From: Luxit Date: Fri, 25 Sep 2026 23:18:16 +0200 Subject: [PATCH] Sync the self-hosting stack --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++ kubernetes/01-secret.example.yaml | 26 ++++++++++++++++------ kubernetes/README.md | 14 +++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18a6db..db37fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,43 @@ one of them changed. An entry says which artefact and which version, so `web 0.1 Every version published to a channel appears here. Nothing is written from memory afterwards: a build cannot be published without its entry. +## 2026-09-25 — A fresh Kubernetes install no longer asks for its own vault key `api 0.114.1` + +**On the published Kubernetes stack, creating the first administrator asked for the last characters +of the vault master key, every time.** That proof exists for one case: an instance whose database has +gone missing while its key survived, which looks brand new from the outside. A genuine first +installation is never meant to be asked. On Kubernetes it always was. The minting Job writes +`VAULT_KEY_CREATED_AT` beside the key it generates, but the api read that date only when the key came +from a file, and this stack passes the key as an environment variable. The date went unread, the +key's age was unknown, and an unknown age counts as "cannot prove this instance is new". + +**The api now reads `VAULT_KEY_CREATED_AT` wherever the key comes from.** A fresh install whose api +starts within fifteen minutes of the key being minted is claimed without it. The case the proof +exists for is unchanged: a `jarvis-generated` Secret that outlives its database still carries its +old date, and the claim still asks for the key. + +**The Job now binds its date to its key**, with a fingerprint in the same value +(` sha256:<16 hex>`), and on this stack the api counts a date only when it is bound to the key +in use. The api merges `jarvis-generated` and `jarvis-secrets` name by name, so a key you put in +`jarvis-secrets` yourself would otherwise meet the date the Job minted for another key; a key +restored before its database would then read as brand new, and its first administrator would be +free. A date without the fingerprint, or bound to another key, is ignored, and the claim asks for +the key as before. To have a first install on the manual path claimed without it, bind the date as +`01-secret.example.yaml` shows. A date more than five minutes in the future is refused too. + +**If you apply the files one by one, move `01-secret-job.yaml` to the same api digest as the rest.** +A Job on an older image writes a date without the fingerprint, and a first install keeps asking for +the key. Kustomize and Argo CD already give the Job the api's image, from the one `images:` entry. + +**An instance deployed before this version and not installed yet will still ask.** Its +`jarvis-generated` was minted by an older Job, whose date carries no fingerprint and does not count. +Give the key's last characters. The key is in `jarvis-secrets` if you set +`VAULT_MASTER_KEY` there, and in `jarvis-generated` otherwise: +`kubectl -n get secret jarvis-generated -o jsonpath='{.data.VAULT_MASTER_KEY}' | base64 -d`. + +**The published Compose stack is not affected.** There the key is a file, and its age was already read +from it. + ## 2026-09-25 — The licence report counts operators, and the paid plans are Business and MSP `api 0.114.0` `web 0.139.0` **The licence report sends one more number.** Next to organizations, enabled users, agents and assets, diff --git a/kubernetes/01-secret.example.yaml b/kubernetes/01-secret.example.yaml index a708727..c407a91 100644 --- a/kubernetes/01-secret.example.yaml +++ b/kubernetes/01-secret.example.yaml @@ -44,18 +44,30 @@ stringData: JWT_ACCESS_SECRET: "REPLACE_ME" JWT_REFRESH_SECRET: "REPLACE_ME" - # WHEN THE VAULT KEY WAS CREATED, as an ISO 8601 timestamp or epoch seconds. + # WHEN THE VAULT KEY WAS CREATED, bound to that key. Manual path only: if `01-secret-job.yaml` + # mints your key, delete this line with the other `VAULT_*` ones — left here, even as + # "REPLACE_ME", it overrides the value the Job wrote beside its key. # - # Set it when you mint the key, and never move it afterwards. On Compose this is inferred from the - # key file's modification time, which is a good answer on a volume nobody rewrites. Here it is a - # wrong one: the kubelet writes a projected Secret into every pod at start, so the mtime is the - # POD's age and every pod reports a key created seconds ago however old it is. + # The value is the moment you generate the key and the key's fingerprint, one space apart: + # + # FP=$(printf %s "$VAULT_MASTER_KEY" | sha256sum | cut -c1-16) + # echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) sha256:$FP" + # + # Restoring a key you backed up? Restore the value saved with it, word for word, and never compute + # a new one: today's date bound to an old key is the one mistake this line can make dangerous. A + # date without the fingerprint, or bound to another key, is ignored. + # + # On Compose this is inferred from the key file's modification time. Here there is no file: the + # api receives the key through `envFrom`, so this line is the only evidence of the key's age. + # (Mounting the Secret as a file would not do better — the kubelet writes it into every pod at + # start, so its mtime is the POD's age.) # # What that number decides is narrow and serious. An instance with no accounts can have its first # super-admin claimed by whoever reaches the install screen — unless the vault key is old, which # means the database has gone missing from an instance that already existed (a bad restore, a PVC - # pointed at the wrong volume) and the claim needs proof of possession instead. Without this line, - # that check reads "brand new instance" on exactly the estate it exists to protect. + # pointed at the wrong volume) and the claim needs proof of possession instead. Without this line + # the age is unknown and the claim always asks for the key, which costs you nothing but its last + # characters. VAULT_KEY_CREATED_AT: "REPLACE_ME" # `connection_limit` is not optional here, and it is the reason this URL is in the Secret rather diff --git a/kubernetes/README.md b/kubernetes/README.md index 69bcd10..268395b 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -280,11 +280,23 @@ outcome. Run it again. ## Backups Everything in [the main README](../README.md#backups-and-restoring-one) applies, with one -substitution: the vault key is not in a Docker volume, it is in the `jarvis-generated` Secret. +substitution: the vault key is not in a Docker volume, it is in a Secret — `jarvis-secrets` if you +set `VAULT_MASTER_KEY` there yourself, `jarvis-generated` otherwise. The kustomization mints +`jarvis-generated` either way, so on the manual path it holds a key nothing uses: back up the one +the api reads. ```sh kubectl -n jarvis get secret jarvis-generated -o jsonpath='{.data.VAULT_MASTER_KEY}' | base64 -d +kubectl -n jarvis get secret jarvis-generated -o jsonpath='{.data.VAULT_KEY_CREATED_AT}' | base64 -d ``` Back that up somewhere that is not this cluster, and not alongside your database dump. A database backup that travelled with its own key is a backup that decrypts itself. + +The second line is when the key was created, bound to that key by its fingerprint — on a Secret +minted from api 0.114.1 on. An older one carries a bare date, which the api ignores here, so a +restored copy of it changes nothing and the claim asks for the key. If you ever restore the key by +hand into `jarvis-secrets`, restore that value beside it word for word, or leave it out — never +compute a new one. It is what tells the install screen whether an empty database is a +first install or a lost one, and an old key dated today makes the first administrator free to +whoever reaches the address before your dump is loaded.