installer v1.28.0: the removal genuinely reverses the installation (R-316)
gates / gates (push) Successful in 13s
gates / gates (push) Successful in 13s
v1.27.0's fix worked exactly once per machine. Measured on drill-r50 from virgin, on the PUBLISHED v1.27.0, before anything was changed: cycle 1 recorded 'no' and freed :53; cycle 2 recorded 'yes' and left dnsmasq running on 0.0.0.0:53; cycle 3 refused, exit 1. Every box already in the field is at cycle 2, and a reinstall onto a machine that has had Felhom is cycle 2 by definition. Why cycle 2 says yes: the preflight's ownership question is dpkg-query package presence and nothing else - not the absence of a record. Stopping the unit and leaving the package made our own package read as the household's one cycle later. Now the uninstall removes the package when the record says we installed it. Order unchanged and load-bearing: read the record, act, then delete the state file that holds it. TWO packages are recorded, because dnsmasq ships the unit and dnsmasq-base ships /usr/sbin/dnsmasq, and each is taken back only if we added it. The dependency check is a SIMULATION, not a guess: apt-get -s purge is asked what it would remove and the purge proceeds only if that set is a subset of ours; otherwise stop+disable, naming the package that blocked it. Never interactive, never fatal, and the success is re-queried rather than read off an exit code. Watched: three fixed cycles -> install 3 PASSES; a household resolver untouched; a dependent package not purged and named; no record -> untouched with the command named. Red-proofs with the mutation asserted applied: remove the purge -> cycle 3 refuses in those exact words; remove the ownership check -> a household resolver is purged; infer ownership -> the guess is taken. Also: R-317 (the agent stats a path dnsmasq-base owns to decide whether to install dnsmasq - pre-existing, now reachable), R-318 (no honest ownership marker exists for existing boxes; the preflight message is the mechanism), and the status page's decisions section rewritten to say what each decision costs and what doing nothing selects.
This commit is contained in:
+20
@@ -38,6 +38,26 @@ route, and each has been separately absent in this project. **Before claiming a
|
|||||||
of the three you have evidence for** — and if a comment or a card asserts the third, find the code path
|
of the three you have evidence for** — and if a comment or a card asserts the third, find the code path
|
||||||
that performs it or move the claim.
|
that performs it or move the claim.
|
||||||
|
|
||||||
|
## A removal that leaves the package leaves the next install a second-cycle machine (2026-08-13)
|
||||||
|
|
||||||
|
**Stopping is not removing, and the difference does not show up until the cycle after next.** R-300
|
||||||
|
stopped and disabled the resolver we install and left the package in place — deliberately, as the
|
||||||
|
conservative act. Cycle 1 looked fixed and *was* fixed. But the next install asks dpkg "is this
|
||||||
|
installed?", gets yes, records the package as the household's, and the uninstall after that leaves it
|
||||||
|
running: cycle 3 refuses with the message that blames the customer's network. **Measured, three cycles,
|
||||||
|
before anything was changed.**
|
||||||
|
|
||||||
|
**The general shape:** a teardown that leaves an artifact behind does not merely fail to clean up — it
|
||||||
|
*changes what the next run concludes about who owns that artifact*. Ask of any "we left it in place,
|
||||||
|
to be safe": **what will the next install think this is?** If the answer is "ours, misread as theirs",
|
||||||
|
the conservative act has quietly become the destructive one, one cycle downstream.
|
||||||
|
|
||||||
|
**And the corollary that nearly shipped a second defect:** the thing you remove and the thing something
|
||||||
|
else probes for may be in **different packages**. `dnsmasq` ships the unit; `dnsmasq-base` ships the
|
||||||
|
binary the agent stats to decide whether to install. Removing one and not the other satisfies the
|
||||||
|
uninstall and breaks the next install silently (R-317). **Before removing a package, ask what else
|
||||||
|
tests for the files it shares with its siblings.**
|
||||||
|
|
||||||
## Two rules earned twice each (2026-08-12)
|
## Two rules earned twice each (2026-08-12)
|
||||||
|
|
||||||
**A fact must be recorded at the moment it is a fact, not inferred later from something adjacent.**
|
**A fact must be recorded at the moment it is a fact, not inferred later from something adjacent.**
|
||||||
|
|||||||
@@ -8,24 +8,44 @@
|
|||||||
|
|
||||||
## Waiting on you
|
## Waiting on you
|
||||||
|
|
||||||
*(Vouched and delivered 2026-08-12 night: golden **0.214.0**, agent **0.129.0**, min agent **0.129.0**,
|
*Three decisions. Each says what it would cost to leave alone, because two of them quietly choose an
|
||||||
floor **0.214.0**. **Both demo machines are now on controller 0.214.0 + agent 0.129.0** — checked on the
|
outcome if you do not answer. The register row is the detail, not the decision.*
|
||||||
boxes, not assumed. The safety guard was watched doing its job on the way: `demo-hp` was **held** at
|
|
||||||
0.213.0 while its agent was still 0.128.0 (*"agent 0.128.0 < MinAgent 0.129.0 — controller floor
|
|
||||||
withheld"*), and took the update 6 seconds after the agent caught up. That guard exists because a box
|
|
||||||
once landed on a controller newer than its agent and a customer was told a correct recovery code was
|
|
||||||
wrong; this is the first time it has been seen firing.)*
|
|
||||||
|
|
||||||
- **R-312 — one decision, and it is the interesting one.** The customer is now told the truth about an
|
### Should a customer be able to get their old backups back themselves, or is that a phone call?
|
||||||
older code, but there is still no button: restoring from a set-aside copy means either threading an
|
|
||||||
alternative location through the restore code, or adopting that copy as the machine's current one.
|
Since Tuesday a machine recognises an older recovery code and says so honestly, but it cannot hand the
|
||||||
The second is a different product decision. **Nothing is broken while this waits.**
|
files over — it tells the person to write to us, and we can do it by hand.
|
||||||
- **R-313 — the copy you told me to keep cannot be opened by anyone.** `demo-felhom`'s set-aside store
|
|
||||||
holds 36 snapshots and one key, and that key was destroyed by the bug we fixed on 4 August. Keeping
|
- **Build it:** the restore code has to accept a second location and password instead of only its own.
|
||||||
it is still the right call; it should be a decision, not an accumulation.
|
Contained — three functions and a screen — plus one genuine design question: what a customer sees
|
||||||
- **R-303 — one coherence decision**, ranked low: a box can still raise the "we cannot open your new
|
when they have several old sets and must pick one.
|
||||||
backups" card while counting down to deleting the old ones. The two no longer contradict each other,
|
- **Leave it:** nothing breaks. Every customer in this position becomes a support conversation, and we
|
||||||
but the state is odd and the wrong fix would hide a real second fault.
|
keep a promise we can only keep manually.
|
||||||
|
|
||||||
|
**If you do nothing:** the honest message stays and the work never gets scheduled. Nobody is blocked;
|
||||||
|
this is the one decision here with no deadline of any kind. *(register: R-312)*
|
||||||
|
|
||||||
|
### The old copy on the demo machine cannot be opened by anyone. Keep paying to store it, or delete it?
|
||||||
|
|
||||||
|
You told me to keep it, and I did. Then I found out what it is: 36 backups and a single key, and that
|
||||||
|
key was destroyed by the bug we fixed on 4 August. **No recovery code in existence opens it.**
|
||||||
|
|
||||||
|
- **Keep it:** pennies of storage, and it stays as the one physical example of what that bug cost.
|
||||||
|
- **Delete it:** irreversible, and the example goes with it.
|
||||||
|
|
||||||
|
**If you do nothing:** it stays forever and stops being a decision — which is how five scratch
|
||||||
|
customers accumulated. Nobody is blocked. *(register: R-313)*
|
||||||
|
|
||||||
|
### When a machine is in two kinds of trouble at once, should it say both things?
|
||||||
|
|
||||||
|
A machine can count down to deleting its old backups while also reporting that it cannot open its new
|
||||||
|
ones. Both cards are true; together they are bewildering.
|
||||||
|
|
||||||
|
- **Leave it:** two true statements, confusing side by side. Nobody has been hurt by it.
|
||||||
|
- **Hide the older card during a countdown:** tidier, and it risks hiding a real second failure — which
|
||||||
|
is why I have not done it.
|
||||||
|
|
||||||
|
**If you do nothing:** both keep showing. Ranked low on purpose. *(register: R-303)*
|
||||||
|
|
||||||
## What works
|
## What works
|
||||||
|
|
||||||
@@ -52,6 +72,10 @@ repository still opens with the machine's own key. `drill-r50` is reverted to `v
|
|||||||
fine, write to us*. It deliberately promises no restore, because there is no button yet.
|
fine, write to us*. It deliberately promises no restore, because there is no button yet.
|
||||||
- **The countdown on `demo-felhom` is cancelled** on your ruling (R-307). Nothing was deleted; the
|
- **The countdown on `demo-felhom` is cancelled** on your ruling (R-307). Nothing was deleted; the
|
||||||
24 August deadline is gone. See R-313 for what that copy turns out to be.
|
24 August deadline is gone. See R-313 for what that copy turns out to be.
|
||||||
|
- **Vouched and delivered 2026-08-12**: golden 0.214.0, agent 0.129.0, floor 0.214.0 — both machines
|
||||||
|
took it themselves. The version guard was watched working on the way: `demo-hp` was **held** back
|
||||||
|
while its agent was older, and updated 6 seconds after the agent caught up. That guard exists because
|
||||||
|
a machine once ran ahead of its agent and a customer was told a correct code was wrong; first sighting.
|
||||||
- **Both installer fixes are now PUBLISHED** as `installer-v1.27.0` (R-297 + R-300). Each fault was
|
- **Both installer fixes are now PUBLISHED** as `installer-v1.27.0` (R-297 + R-300). Each fault was
|
||||||
watched happening first, on a machine reset to factory state: the old installer really did build a
|
watched happening first, on a machine reset to factory state: the old installer really did build a
|
||||||
machine on a base image from July, and our own uninstall really did block our own next install.
|
machine on a base image from July, and our own uninstall really did block our own next install.
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
|
|||||||
| **R-302** | **The abandon banner promised retrieval it could not see was still true — fixed by PINNING a fingerprint at the decision.** The countdown strip said *„Addig még visszaszerezheted őket a helyreállítási kóddal"* unconditionally, on every page, and R-301 established it can co-render with the orphan card correctly saying we cannot tell. **THE OBVIOUS CONDITION WAS REJECTED AND HERE IS WHY, so nobody re-proposes it:** the proxy *"does the hub hold a key different from the one I use?"* asks about the WRONG key — the set-aside copies were written under an OLDER key the box no longer has, which is why they were set aside — so on a twice-rebuilt box it answers "promise it" about copies no key on file can open. **Demonstrated, not argued:** under the proxy both Scenario B (package replaced) and Scenario D (legacy countdown) flip back to promising. Instead `startAbandonCountdown` pins `AbandonPinnedEscrowKeySHA256` — the hub's escrow key fingerprint AS CACHED AT THE DECISION — written once, never refreshed; the banner then asks only *"is the hub still holding that same package?"*. **⚠ THE PIN IS A RECORDED ASSUMPTION:** nothing on the box records which key wrote the set-aside copies, so it presumes the package held at the decision is that one — true in the ordinary rebuilt-box story, NOT provable, wrong on a twice-rebuilt box. Recorded in the field comment so it can be narrowed rather than hardening into a fact. Empty is not a match on either side; a countdown started before v0.213.0 carries no pin and takes the cautious branch (deliberately NOT backfilled). **A template sweep found a FOURTH instance** (the backups-page abandon block — same condition applied, since fixing the strip and not the page would leave them contradicting) **and a FIFTH** (the abandon confirmation screen, deliberately LEFT: it renders at the moment of the decision, where the promise is true by construction because that is the package about to be pinned) | **CLOSED — controller v0.213.0** | R-301 | — | CC |
|
| **R-302** | **The abandon banner promised retrieval it could not see was still true — fixed by PINNING a fingerprint at the decision.** The countdown strip said *„Addig még visszaszerezheted őket a helyreállítási kóddal"* unconditionally, on every page, and R-301 established it can co-render with the orphan card correctly saying we cannot tell. **THE OBVIOUS CONDITION WAS REJECTED AND HERE IS WHY, so nobody re-proposes it:** the proxy *"does the hub hold a key different from the one I use?"* asks about the WRONG key — the set-aside copies were written under an OLDER key the box no longer has, which is why they were set aside — so on a twice-rebuilt box it answers "promise it" about copies no key on file can open. **Demonstrated, not argued:** under the proxy both Scenario B (package replaced) and Scenario D (legacy countdown) flip back to promising. Instead `startAbandonCountdown` pins `AbandonPinnedEscrowKeySHA256` — the hub's escrow key fingerprint AS CACHED AT THE DECISION — written once, never refreshed; the banner then asks only *"is the hub still holding that same package?"*. **⚠ THE PIN IS A RECORDED ASSUMPTION:** nothing on the box records which key wrote the set-aside copies, so it presumes the package held at the decision is that one — true in the ordinary rebuilt-box story, NOT provable, wrong on a twice-rebuilt box. Recorded in the field comment so it can be narrowed rather than hardening into a fact. Empty is not a match on either side; a countdown started before v0.213.0 carries no pin and takes the cautious branch (deliberately NOT backfilled). **A template sweep found a FOURTH instance** (the backups-page abandon block — same condition applied, since fixing the strip and not the page would leave them contradicting) **and a FIFTH** (the abandon confirmation screen, deliberately LEFT: it renders at the moment of the decision, where the promise is true by construction because that is the package about to be pinned) | **CLOSED — controller v0.213.0** | R-301 | — | CC |
|
||||||
| **R-303** | **`markOrphaned` has no guard against an active abandon countdown — the co-render is made HARMLESS, not IMPOSSIBLE.** `ensureOffboxRepo` calls `markOrphaned()` for a claimed box (`offbox.go:804`) with no check on `AbandonAt`, so a later run finding the FRESH store unopenable re-raises the orphan card while the countdown runs. R-302 ensures the two surfaces no longer contradict each other in that state, but the state itself is still reachable and is arguably incoherent: a box counting down to deleting its old history while simultaneously reporting its NEW history is unopenable is in trouble in two ways at once and says so in two separate cards. **Ranked LOW deliberately** — it is a coherence question, not a correctness one, and the wrong fix (suppressing the orphan card during a countdown) would hide a real second fault | **READY (S) — NEW 2026-08-12** | R-302 | Decide whether a countdown should suppress, defer or annotate the orphan card — and beware hiding a genuine second failure | operator + CC |
|
| **R-303** | **`markOrphaned` has no guard against an active abandon countdown — the co-render is made HARMLESS, not IMPOSSIBLE.** `ensureOffboxRepo` calls `markOrphaned()` for a claimed box (`offbox.go:804`) with no check on `AbandonAt`, so a later run finding the FRESH store unopenable re-raises the orphan card while the countdown runs. R-302 ensures the two surfaces no longer contradict each other in that state, but the state itself is still reachable and is arguably incoherent: a box counting down to deleting its old history while simultaneously reporting its NEW history is unopenable is in trouble in two ways at once and says so in two separate cards. **Ranked LOW deliberately** — it is a coherence question, not a correctness one, and the wrong fix (suppressing the orphan card during a countdown) would hide a real second fault | **READY (S) — NEW 2026-08-12** | R-302 | Decide whether a countdown should suppress, defer or annotate the orphan card — and beware hiding a genuine second failure | operator + CC |
|
||||||
| **R-304** | **The retained escrow key works, and the customer is told their correct code is wrong.** DRILL 2026-08-12 answered the three questions separately, on `demo-felhom`, with planted data. **(a) retention: WORKS** — the first retained row in fleet history to carry material (`host_escrow_superseded` id 11, `identity_blob` 572 B), byte-identical (`sha256 a10032341c8584ed…`) to the pre-supersession `host_escrow` row. **(b) the material opens the old store: YES** — unsealed with the OLD recovery code it yielded a password byte-identical to the pre-change one (`sha c60c8bc737a6b7c6…`), and restored three planted files **byte-identical** from a store the box itself could no longer open (negative control first: `Fatal: wrong password or no key found`), **including a Hungarian accented filename verified as raw bytes**. **(c) the customer's route: DOES NOT EXIST, and misinforms.** `ListSupersededEscrow` (`store.go:2841`) is the only reader of a retained `identity_blob` and has **zero production callers** — five call sites, all `_test.go`; the product path (`POST /escrow/recover-offsite-password` → `FetchIdentityEscrow` → `GetHostDRBundle`, `store.go:3152`) selects `FROM host_escrow` — the CURRENT row only. Asked for the old password with the code that demonstrably opens the retained row, the product answered **"the recovery code did not open the sealed bundle — nothing was written"**. **This is the R-224 class again**: there an unreachable hub was reported as a bad code; here a VALID code for retained history is reported as a bad code, and the customer's attempt ends there. **Consequence:** the census answer stands (it was about retention); the countdown banner's promise is true in substance and false in practice; **any capability-map claim that the customer can recover the old history with their recovery code is false today and must move** | **READY (L) — NEW 2026-08-12, RANK 1** | R-198, R-199, R-224, R-241 | Decide the shape: serve retained rows on the recovery path (needs a "which package?" choice — a customer may have several), or stop promising retrieval anywhere the customer cannot perform it. **Until one of those, the honest position is that retention is an operator-only capability.** At minimum, the refusal must stop asserting the code is wrong when the hub simply never looked | operator + CC |
|
| **R-304** | **The retained escrow key works, and the customer is told their correct code is wrong.** DRILL 2026-08-12 answered the three questions separately, on `demo-felhom`, with planted data. **(a) retention: WORKS** — the first retained row in fleet history to carry material (`host_escrow_superseded` id 11, `identity_blob` 572 B), byte-identical (`sha256 a10032341c8584ed…`) to the pre-supersession `host_escrow` row. **(b) the material opens the old store: YES** — unsealed with the OLD recovery code it yielded a password byte-identical to the pre-change one (`sha c60c8bc737a6b7c6…`), and restored three planted files **byte-identical** from a store the box itself could no longer open (negative control first: `Fatal: wrong password or no key found`), **including a Hungarian accented filename verified as raw bytes**. **(c) the customer's route: DOES NOT EXIST, and misinforms.** `ListSupersededEscrow` (`store.go:2841`) is the only reader of a retained `identity_blob` and has **zero production callers** — five call sites, all `_test.go`; the product path (`POST /escrow/recover-offsite-password` → `FetchIdentityEscrow` → `GetHostDRBundle`, `store.go:3152`) selects `FROM host_escrow` — the CURRENT row only. Asked for the old password with the code that demonstrably opens the retained row, the product answered **"the recovery code did not open the sealed bundle — nothing was written"**. **This is the R-224 class again**: there an unreachable hub was reported as a bad code; here a VALID code for retained history is reported as a bad code, and the customer's attempt ends there. **Consequence:** the census answer stands (it was about retention); the countdown banner's promise is true in substance and false in practice; **any capability-map claim that the customer can recover the old history with their recovery code is false today and must move** | **READY (L) — NEW 2026-08-12, RANK 1** | R-198, R-199, R-224, R-241 | Decide the shape: serve retained rows on the recovery path (needs a "which package?" choice — a customer may have several), or stop promising retrieval anywhere the customer cannot perform it. **Until one of those, the honest position is that retention is an operator-only capability.** At minimum, the refusal must stop asserting the code is wrong when the hub simply never looked | operator + CC |
|
||||||
| **R-305** | **The R-300 cleanup fires exactly once per machine, and the second reinstall hits the original wall.** PROVEN on `drill-r50` 2026-08-12, not deduced. The uninstall stops+disables dnsmasq but deliberately never **purges** the package, so on every later install `dpkg-query` reports it installed, preflight records `dnsmasq_preexisting=yes`, the agent's `EnsureDnsmasq` re-enables it, and the uninstall then only *restarts* it — leaving it `enabled`/`active` on `0.0.0.0:53` exactly as before the fix. Cycle 1: `no` → disabled → `:53 FREE` → next install PASSES. Cycle 2: `yes` → left running → **cycle-3 byo preflight FAILED, exit 1**. The improved refusal does fire and names the command, so the customer is not stranded — but they must run by hand a cleanup Felhom could have run itself, on a box Felhom does own | **READY (M) — NEW 2026-08-12, RANK 2** | R-300 | Distinguish "the package pre-dates Felhom" from "a previous **Felhom** install left it" — e.g. keep the ownership record outside `state.json` (which the uninstall deletes), or record Felhom-installed-ness where the agent installs it. **Do not fix by purging on hosts we may not own** | CC |
|
| **R-305** | **The R-300 cleanup fires exactly once per machine, and the second reinstall hits the original wall.** PROVEN on `drill-r50` 2026-08-12, not deduced. The uninstall stops+disables dnsmasq but deliberately never **purges** the package, so on every later install `dpkg-query` reports it installed, preflight records `dnsmasq_preexisting=yes`, the agent's `EnsureDnsmasq` re-enables it, and the uninstall then only *restarts* it — leaving it `enabled`/`active` on `0.0.0.0:53` exactly as before the fix. Cycle 1: `no` → disabled → `:53 FREE` → next install PASSES. Cycle 2: `yes` → left running → **cycle-3 byo preflight FAILED, exit 1**. The improved refusal does fire and names the command, so the customer is not stranded — but they must run by hand a cleanup Felhom could have run itself, on a box Felhom does own **CLOSED 2026-08-13 by R-316** — reproduced on `drill-r50` from `virgin` on the published v1.27.0 (cycle 2 recorded `yes`, cycle 3 refused exit 1), then fixed by removing the package the record says we installed, and watched passing on cycle 3. | **CLOSED — superseded by R-316** | R-300 | Distinguish "the package pre-dates Felhom" from "a previous **Felhom** install left it" — e.g. keep the ownership record outside `state.json` (which the uninstall deletes), or record Felhom-installed-ness where the agent installs it. **Do not fix by purging on hosts we may not own** | CC |
|
||||||
| **R-306** | **`--preflight-only` says "no state written" and writes state — with an answer that can be wrong.** `_state_put` short-circuits on `DRY_RUN` only (`felhom-host-install.sh:418`), so a preflight-only run creates `/var/lib/felhom-install/state.json`. Observed live: after a run whose banner read `PRE-FLIGHT PASS (mode=byo) — no state written, no install step executed`, the file existed containing `{"completed": [], "dnsmasq_preexisting": "yes"}`. Both the banner and the flag's own comment at line 226 assert the opposite. **The harm is not the file, it is the value**: the runbook recommends preflight-only first, then the same command without the flag, so on a box carrying a Felhom leftover the wrong ownership answer is baked in before the real install begins | **READY (S) — NEW 2026-08-12, RANK 3** | R-300, R-305 | Either make `_state_put` a no-op under `PREFLIGHT_ONLY` (and record ownership at install instead), or correct both claims. A comment asserting an invariant needs a test pinning it | CC |
|
| **R-306** | **`--preflight-only` says "no state written" and writes state — with an answer that can be wrong.** `_state_put` short-circuits on `DRY_RUN` only (`felhom-host-install.sh:418`), so a preflight-only run creates `/var/lib/felhom-install/state.json`. Observed live: after a run whose banner read `PRE-FLIGHT PASS (mode=byo) — no state written, no install step executed`, the file existed containing `{"completed": [], "dnsmasq_preexisting": "yes"}`. Both the banner and the flag's own comment at line 226 assert the opposite. **The harm is not the file, it is the value**: the runbook recommends preflight-only first, then the same command without the flag, so on a box carrying a Felhom leftover the wrong ownership answer is baked in before the real install begins | **READY (S) — NEW 2026-08-12, RANK 3** | R-300, R-305 | Either make `_state_put` a no-op under `PREFLIGHT_ONLY` (and record ownership at install instead), or correct both claims. A comment asserting an invariant needs a test pinning it | CC |
|
||||||
| **R-307** | **`demo-felhom` carries a LIVE abandon countdown that this drill did not start — and the end state says there should be none.** `settings.json`: `abandon_started_at 2026-08-10T08:06:31Z`, `abandon_at 2026-08-24T08:06:31Z`, `abandon_repo_path /home/felhom-repo.orphaned-20260810`. The drill's fence forbade starting, shortening or triggering a countdown, and none was; but its required end state was *"no abandon countdown anywhere"*, and one exists. **The two ways to satisfy that are not equivalent and the choice is the operator's:** cancel it (the orphaned repository is kept indefinitely — storage cost, no data risk) or let it run (on **2026-08-24** that repository is deleted, **irreversibly**). **Doing nothing selects deletion by default.** Deliberately not resolved by CC. **RULED 2026-08-12 (operator): KEEP the set-aside history — cancel the countdown.** Done the same evening through the product's own operator path (`--abandon-stop`, which refuses rather than silently no-opping), with the container stopped first so the running controller could not overwrite `settings.json` from memory. **Proved, not trusted to the exit code:** `abandon_started_at` and `abandon_at` are GONE (`AbandonStatus` returns `Active=false` when `AbandonAt` is empty — `offbox_abandon.go:111-113`, so no countdown renders); `abandon_repo_path` deliberately REMAINS as the pointer to the preserved store; the store still holds **36 snapshot objects** and its full `config/data/index/keys/locks/snapshots` structure; both repositories still present on the endpoint; **nothing deleted anywhere**. See R-313 — what was preserved cannot currently be opened by anyone | **CLOSED — countdown cancelled 2026-08-12 on the operator's ruling** | R-301, R-302, R-303, R-313 | — | CC |
|
| **R-307** | **`demo-felhom` carries a LIVE abandon countdown that this drill did not start — and the end state says there should be none.** `settings.json`: `abandon_started_at 2026-08-10T08:06:31Z`, `abandon_at 2026-08-24T08:06:31Z`, `abandon_repo_path /home/felhom-repo.orphaned-20260810`. The drill's fence forbade starting, shortening or triggering a countdown, and none was; but its required end state was *"no abandon countdown anywhere"*, and one exists. **The two ways to satisfy that are not equivalent and the choice is the operator's:** cancel it (the orphaned repository is kept indefinitely — storage cost, no data risk) or let it run (on **2026-08-24** that repository is deleted, **irreversibly**). **Doing nothing selects deletion by default.** Deliberately not resolved by CC. **RULED 2026-08-12 (operator): KEEP the set-aside history — cancel the countdown.** Done the same evening through the product's own operator path (`--abandon-stop`, which refuses rather than silently no-opping), with the container stopped first so the running controller could not overwrite `settings.json` from memory. **Proved, not trusted to the exit code:** `abandon_started_at` and `abandon_at` are GONE (`AbandonStatus` returns `Active=false` when `AbandonAt` is empty — `offbox_abandon.go:111-113`, so no countdown renders); `abandon_repo_path` deliberately REMAINS as the pointer to the preserved store; the store still holds **36 snapshot objects** and its full `config/data/index/keys/locks/snapshots` structure; both repositories still present on the endpoint; **nothing deleted anywhere**. See R-313 — what was preserved cannot currently be opened by anyone | **CLOSED — countdown cancelled 2026-08-12 on the operator's ruling** | R-301, R-302, R-303, R-313 | — | CC |
|
||||||
| **R-308** | **~~The stored controller password no longer opens `demo-felhom`~~ — WITHDRAWN 2026-08-12, this was MY BUG, not a defect.** The original diagnosis (`Hibás jelszó`) came from stripping only DOUBLE quotes off the `~/.config/credentials` value; the values are wrapped in **SINGLE** quotes, so a literal `'` was sent as part of the password. Correctly unquoted the value is 13 characters and logs in first try — **HTTP 302 with a `felhom_session` cookie**, measured. **This is the third time this project has produced a wrong 'the credential is stale' verdict from that exact trap**, and the standing memory about it is what caught it here — but only after the same mistake had also made the R-311 live test read as a FAILURE (HTTP 400) for twenty minutes. **The lesson is not 'strip quotes' but 'never let a shell decide what a secret is'**: parse the credentials file in Python, strip a matched pair of EITHER quote, and verify the length before use | **WITHDRAWN — not a defect (my error)** | — | Consider a tiny `read_credential.py` helper so no session hand-rolls the parse again | CC |
|
| **R-308** | **~~The stored controller password no longer opens `demo-felhom`~~ — WITHDRAWN 2026-08-12, this was MY BUG, not a defect.** The original diagnosis (`Hibás jelszó`) came from stripping only DOUBLE quotes off the `~/.config/credentials` value; the values are wrapped in **SINGLE** quotes, so a literal `'` was sent as part of the password. Correctly unquoted the value is 13 characters and logs in first try — **HTTP 302 with a `felhom_session` cookie**, measured. **This is the third time this project has produced a wrong 'the credential is stale' verdict from that exact trap**, and the standing memory about it is what caught it here — but only after the same mistake had also made the R-311 live test read as a FAILURE (HTTP 400) for twenty minutes. **The lesson is not 'strip quotes' but 'never let a shell decide what a secret is'**: parse the credentials file in Python, strip a matched pair of EITHER quote, and verify the length before use | **WITHDRAWN — not a defect (my error)** | — | Consider a tiny `read_credential.py` helper so no session hand-rolls the parse again | CC |
|
||||||
@@ -616,3 +616,6 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
|
|||||||
| **R-313** | **`demo-felhom`'s set-aside store is UNRECOVERABLE — 36 snapshots whose key we destroyed ourselves.** `/home/felhom-repo.orphaned-20260810` holds **36 snapshot objects and exactly one key slot**, and it does NOT open with the box's current password (`Fatal: wrong password or no key found`, exit 1 — measured). Its password is the one hashed `48741892f0ef4d59…`, which is retained row id 4 — **`identity_blob` NULL**, a pre-v0.93.0 row. So the material was dropped by the R-198 defect during its two-month window, and no recovery code in existence opens that store. **This is the concrete, still-present cost of R-198**, sitting on the endpoint rather than in a post-mortem. It also means the operator's ruling to KEEP it (R-307, countdown cancelled — see below) preserves bytes nobody can read: correct as a decision, and worth knowing as a fact | **READY (S) — NEW 2026-08-12** | R-198, R-307 | Decide whether to keep paying storage for 36 unreadable snapshots, or delete them deliberately and record why. **Not urgent and not automatic — but it should be a decision rather than an accumulation** | operator |
|
| **R-313** | **`demo-felhom`'s set-aside store is UNRECOVERABLE — 36 snapshots whose key we destroyed ourselves.** `/home/felhom-repo.orphaned-20260810` holds **36 snapshot objects and exactly one key slot**, and it does NOT open with the box's current password (`Fatal: wrong password or no key found`, exit 1 — measured). Its password is the one hashed `48741892f0ef4d59…`, which is retained row id 4 — **`identity_blob` NULL**, a pre-v0.93.0 row. So the material was dropped by the R-198 defect during its two-month window, and no recovery code in existence opens that store. **This is the concrete, still-present cost of R-198**, sitting on the endpoint rather than in a post-mortem. It also means the operator's ruling to KEEP it (R-307, countdown cancelled — see below) preserves bytes nobody can read: correct as a decision, and worth knowing as a fact | **READY (S) — NEW 2026-08-12** | R-198, R-307 | Decide whether to keep paying storage for 36 unreadable snapshots, or delete them deliberately and record why. **Not urgent and not automatic — but it should be a decision rather than an accumulation** | operator |
|
||||||
| **R-314** | **`StopAbandon` has no web route — a customer who telephones is served by a command line.** `--abandon-stop` exists on the controller binary (`cmd/controller/main.go:86`) and refuses rather than silently no-opping, which is right. But there is no handler: the only in-product way to cancel a countdown is the customer finding their recovery code (Scenario G cancels it at the moment the code proves they still have it). **An operator who is telephoned instead has to reach a shell on the customer's machine.** Used this session on the operator's ruling, container stopped first so the running controller could not overwrite `settings.json` from memory — a sequencing subtlety that is itself an argument for a route | **READY (S) — NEW 2026-08-12, RANK 3** | R-241, R-307 | An operator-authenticated POST that calls the same `StopAbandon`, so the telephone path and the code path converge | CC |
|
| **R-314** | **`StopAbandon` has no web route — a customer who telephones is served by a command line.** `--abandon-stop` exists on the controller binary (`cmd/controller/main.go:86`) and refuses rather than silently no-opping, which is right. But there is no handler: the only in-product way to cancel a countdown is the customer finding their recovery code (Scenario G cancels it at the moment the code proves they still have it). **An operator who is telephoned instead has to reach a shell on the customer's machine.** Used this session on the operator's ruling, container stopped first so the running controller could not overwrite `settings.json` from memory — a sequencing subtlety that is itself an argument for a route | **READY (S) — NEW 2026-08-12, RANK 3** | R-241, R-307 | An operator-authenticated POST that calls the same `StopAbandon`, so the telephone path and the code path converge | CC |
|
||||||
| **R-315** | **The wire-contract gate's positive control FAILS on the new wire: it checks name-presence, not decodability.** R-311 declared `hub -> agent (GET /escrow/retained)` as a fourth ROOT, and the gate's tag count rose 174 → 182, so the fields ARE inspected. But renaming the agent-side `superseded_at` json tag to `superseded_at_RENAMED` **still passed** — because the string `superseded_at` also occurs as a map key in the agent's local-API response, and the check is a repo-wide name search. The gate documents this ("name-reachability is not use"), so it is a known limit rather than a regression — but it means **declaring this wire bought documentation, not enforcement**, and a report that claimed coverage would have been wrong. The mutation was asserted to have applied before the run | **READY (M) — NEW 2026-08-12, RANK 3** | R-311 | Make the check resolve the RECEIVER'S mirror type and compare field-by-field, or state per-root which kind of check it got. **A gate whose positive control fails is an instrument nobody has calibrated** | CC |
|
| **R-315** | **The wire-contract gate's positive control FAILS on the new wire: it checks name-presence, not decodability.** R-311 declared `hub -> agent (GET /escrow/retained)` as a fourth ROOT, and the gate's tag count rose 174 → 182, so the fields ARE inspected. But renaming the agent-side `superseded_at` json tag to `superseded_at_RENAMED` **still passed** — because the string `superseded_at` also occurs as a map key in the agent's local-API response, and the check is a repo-wide name search. The gate documents this ("name-reachability is not use"), so it is a known limit rather than a regression — but it means **declaring this wire bought documentation, not enforcement**, and a report that claimed coverage would have been wrong. The mutation was asserted to have applied before the run | **READY (M) — NEW 2026-08-12, RANK 3** | R-311 | Make the check resolve the RECEIVER'S mirror type and compare field-by-field, or state per-root which kind of check it got. **A gate whose positive control fails is an instrument nobody has calibrated** | CC |
|
||||||
|
| **R-316** | **The removal now genuinely reverses the installation — R-305's once-per-machine defect closed.** v1.27.0 stopped and disabled the unit but left the PACKAGE, and the preflight's ownership question is `dpkg-query … "install ok installed"` and **nothing else — package presence alone** (`felhom-host-install.sh:1718`), so cycle 2 recorded `yes`, the second uninstall treated our own package as the household's, and cycle 3 refused. **Measured on `drill-r50` from `virgin` on the PUBLISHED v1.27.0 before anything was changed:** cycle 1 `no` → `:53` FREE; cycle 2 **`yes`** → left running on `0.0.0.0:53`; cycle 3 **PRE-FLIGHT FAIL exit 1**. **v1.28.0:** when the record says we installed it, the uninstall removes the package too. Order unchanged and load-bearing — read the record, act, THEN delete the state file (`:1268`) that holds it. **TWO packages are now recorded** (`dnsmasq` ships the unit, **`dnsmasq-base` ships `/usr/sbin/dnsmasq`**) and each is taken back only if we added it. **The dependency check is a SIMULATION** (`apt-get -s purge`, proceed only if the removal set is a subset of ours) rather than a guess; it falls back to stop+disable and NAMES the blocking package. Never interactive, never fatal — a wedged apt is recorded and restated in the closing NOTE. The success is re-queried rather than read off apt's exit code. **Watched:** three fixed cycles → install 3 PASSES; household resolver → untouched; dependent package → not purged and named; no record → untouched with the command named. **Red-proofs, mutations asserted applied:** remove the purge → cycle 3 refuses in those exact words; remove the ownership check → a household resolver is PURGED; infer ownership when absent → the guess is taken. **Published as `installer-v1.28.0`** | **CLOSED — shipped + published, observed on the cycle that actually fails** | R-300, R-305 | — | CC |
|
||||||
|
| **R-317** | **The agent decides whether to install dnsmasq by stat-ing a file the OTHER package owns.** `EnsureDnsmasq` (`felhom-agent/internal/lanresolver/lanresolver.go:105`) does `os.Stat("/usr/sbin/dnsmasq")` and skips the apt install when it exists — but that path is shipped by **`dnsmasq-base`**, while the systemd unit comes from **`dnsmasq`** (confirmed on the box: `dpkg -S /usr/sbin/dnsmasq` → `dnsmasq-base`; `dpkg -S /usr/lib/systemd/system/dnsmasq.service` → `dnsmasq`). So on any host carrying `dnsmasq-base` without `dnsmasq`, the agent skips the install and then runs `systemctl enable --now dnsmasq` against a unit that is not there: the resolver never comes up and the failure is a retried WARN in the journal rather than anything a customer or the install sees. **Pre-existing, NOT introduced by R-316** — but R-316 makes the shape reachable, because a host whose `dnsmasq-base` pre-dated Felhom now keeps it while `dnsmasq` is removed. R-316's uninstall says so explicitly instead of leaving it to be found from a silent resolver. **Ranked 2 (costs time), not 1:** the box installs fine, only LAN name resolution is missing | **READY (S) — NEW 2026-08-13** | R-316 | Probe what is actually needed — the unit or the `dnsmasq` package — rather than a path a sibling package owns. One-line change in the agent; deliberately NOT made here to keep this session to one repo | CC |
|
||||||
|
| **R-318** | **No honest marker exists that says Felhom installed dnsmasq on a machine already in the field, and none can be invented.** Established from source and on the box: the Felhom `/etc/dnsmasq.d/felhom-*.conf` snippets are deleted by the uninstall's own loop (`:1162`) BEFORE the ownership decision and do not survive it; the install state file that carries the record is deleted at `:1268`; nothing under `/etc/felhom*` remains. `/var/log/dpkg.log` does record the install — and is a **timestamp**, which the standing rule refuses as a heuristic dressed as a fact. **So for every box installed before v1.27.0 the answer is the preflight message, and that is a mechanism rather than a rule:** it names the finding, keeps its two routes and its promise not to touch DNS on a host we do not own, and adds *"THIS LOOKS LIKE OURS … systemctl disable --now dnsmasq"*. **Judged as a customer would:** it is honest, it hedges correctly (*looks like*), and it names one exact command — the one thing that gets that person moving. Its weakness is that it asks them to answer *"did this host have dnsmasq before Felhom?"*, which is precisely the question we can no longer answer for them | **CLOSED — established, no action possible for existing boxes** | R-300, R-316 | — | CC |
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
|
## felhom-host-install.sh v1.28.0 — the removal genuinely reverses the installation (2026-08-13, R-316)
|
||||||
|
|
||||||
|
**v1.27.0's fix worked exactly once per machine, and this is the measurement.** Three full cycles on
|
||||||
|
`drill-r50` from `virgin`, on the PUBLISHED v1.27.0, before anything was changed:
|
||||||
|
|
||||||
|
| cycle | recorded | uninstall did | result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `no` | stop + disable | `:53` FREE |
|
||||||
|
| 2 | **`yes`** | **left it running** | `0.0.0.0:53` taken |
|
||||||
|
| 3 | — | — | **PRE-FLIGHT FAIL (exit 1)** — *"a resolver is already bound to :53"* |
|
||||||
|
|
||||||
|
**Why cycle 2 says `yes`:** the preflight asks `dpkg-query` whether `dnsmasq` is installed and nothing
|
||||||
|
else — **package presence alone** (not the absence of a record). v1.27.0 stopped the unit and left the
|
||||||
|
package, so the answer stayed yes and our own package became "the household's" one cycle later. **Every
|
||||||
|
box already in the field is at cycle 2**, and a reinstall onto a machine that has had Felhom is cycle 2
|
||||||
|
by definition.
|
||||||
|
|
||||||
|
**Now:** when the install-time record says we installed it, the uninstall **removes the package** as
|
||||||
|
well as stopping the unit — which is what makes the next install a first install again. Order is
|
||||||
|
unchanged and load-bearing: read the record, act, and only then delete the state file that holds it.
|
||||||
|
|
||||||
|
**TWO packages are recorded, not one.** `dnsmasq` ships the systemd unit; **`dnsmasq-base` ships
|
||||||
|
`/usr/sbin/dnsmasq`**. They are separately installable, so each is recorded at preflight and taken back
|
||||||
|
only if we added it.
|
||||||
|
|
||||||
|
**Guard rails, all measured on the box rather than reasoned:**
|
||||||
|
|
||||||
|
- **Ownership is read, never inferred** — no mtimes, no package metadata, no file dates.
|
||||||
|
- **The dependency check is a SIMULATION.** `apt-get -s purge` is asked what it would remove, and the
|
||||||
|
purge proceeds only if the answer is a subset of what we are entitled to remove. Otherwise: stop +
|
||||||
|
disable, and the log names the package that stopped us.
|
||||||
|
- **Never interactive, never fatal.** A wedged apt cannot strand a teardown — the failure is recorded
|
||||||
|
and restated in the closing NOTE, because a half-completed teardown is how cycle 3 refuses.
|
||||||
|
- **The success is asserted, not assumed:** after `apt-get` exits 0 the package is re-queried, because
|
||||||
|
an exit code is not an observation.
|
||||||
|
|
||||||
|
**Scenarios, each watched:** three fixed cycles → **install 3 PASSES**; a household resolver →
|
||||||
|
untouched; a dependent package (`household-dns-thing`) → **not purged, named in the log**, `:53` still
|
||||||
|
freed; no ownership record → untouched, reason logged, exact command named.
|
||||||
|
|
||||||
|
**Red-proofs, mutation asserted applied each time:** remove the purge → **cycle 3 refuses again in
|
||||||
|
those exact words**; remove the ownership check → **a household resolver is purged**; infer ownership
|
||||||
|
when there is no record → **the guess is taken and a field box loses its own DNS**.
|
||||||
|
|
||||||
|
**Known residue, filed as R-317:** where `dnsmasq-base` pre-dated Felhom we correctly keep it, but it
|
||||||
|
owns `/usr/sbin/dnsmasq`, which the agent stats to decide whether to install
|
||||||
|
(`felhom-agent internal/lanresolver/lanresolver.go:105`). On that host shape the next install can skip
|
||||||
|
the apt step and then fail to enable a unit that is gone. The uninstall says so explicitly rather than
|
||||||
|
leaving it to be rediscovered from a silent resolver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## PUBLISHED — `installer-v1.27.0` (2026-08-12)
|
## PUBLISHED — `installer-v1.27.0` (2026-08-12)
|
||||||
|
|
||||||
**v1.26.0 (R-297) and v1.27.0 (R-300) were both written, pushed and deliberately left unpublished
|
**v1.26.0 (R-297) and v1.27.0 (R-300) were both written, pushed and deliberately left unpublished
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_VERSION="1.27.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
SCRIPT_VERSION="1.28.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
||||||
# The hub used to carry a copy for its Setup tab; R-94 DELETED it
|
# The hub used to carry a copy for its Setup tab; R-94 DELETED it
|
||||||
# (2026-08-02) because the hub cannot know which version a box runs —
|
# (2026-08-02) because the hub cannot know which version a box runs —
|
||||||
# the Setup command fetches this script at run time. scripts/
|
# the Setup command fetches this script at run time. scripts/
|
||||||
@@ -842,6 +842,92 @@ _uninstall_statement() {
|
|||||||
# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it
|
# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it
|
||||||
# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user,
|
# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user,
|
||||||
# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8.
|
# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8.
|
||||||
|
# _dnsmasq_purge_owned — remove the dnsmasq packages THIS install added, and nothing else (R-316).
|
||||||
|
#
|
||||||
|
# Called only from the `no` branch, i.e. only when the preflight RECORDED that we installed it. There
|
||||||
|
# is no inference here and there must never be: no mtimes, no package metadata, no file dates. That
|
||||||
|
# rule was earned and it is why the record exists at all.
|
||||||
|
#
|
||||||
|
# THE DEPENDENCY GUARD IS A SIMULATION, NOT A GUESS. `apt-get -s purge` is asked what it would do, and
|
||||||
|
# the purge proceeds only if the answer names a subset of the packages we are entitled to remove. If
|
||||||
|
# anything else would be dragged out, we STOP AND DISABLE instead and say which package stopped us —
|
||||||
|
# a silent downgrade to the weaker action would be a warning standing beside a success.
|
||||||
|
#
|
||||||
|
# It can fail and the uninstall still succeeds: a wedged apt must not strand a teardown. Failure is
|
||||||
|
# recorded in _DNSMASQ_PURGE_NOTE and reported in the closing statement, never swallowed.
|
||||||
|
_DNSMASQ_PURGE_NOTE=""
|
||||||
|
_dnsmasq_purge_owned() {
|
||||||
|
local want=("dnsmasq")
|
||||||
|
# dnsmasq-base ships /usr/sbin/dnsmasq and is separately installable — take it back only if we
|
||||||
|
# added it too. Leaving a pre-existing base package is correct; see the WARN below for what that
|
||||||
|
# costs on the next install.
|
||||||
|
if [[ "$(_state_get dnsmasq_base_preexisting)" == "no" ]]; then
|
||||||
|
want+=("dnsmasq-base")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
_DNSMASQ_PURGE_NOTE="apt-get absent — dnsmasq left installed (stopped + disabled)"
|
||||||
|
log_warn " dnsmasq: apt-get not available — the package stays installed; the unit is stopped + disabled."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if $DRY_RUN; then
|
||||||
|
log_dry "DEBIAN_FRONTEND=noninteractive apt-get -y purge ${want[*]} (after simulating the blast radius)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# What would come out? Every Remv/Purg line is a package this would remove.
|
||||||
|
local sim removed extra=()
|
||||||
|
sim=$(DEBIAN_FRONTEND=noninteractive apt-get -s purge "${want[@]}" 2>/dev/null) || sim=""
|
||||||
|
if [[ -z "$sim" ]]; then
|
||||||
|
_DNSMASQ_PURGE_NOTE="purge simulation failed — dnsmasq left installed (stopped + disabled)"
|
||||||
|
log_warn " dnsmasq: could not simulate the removal — leaving the package installed (unit stopped + disabled)."
|
||||||
|
log_warn " A reinstall will read it as pre-existing; clear it with: apt-get -y purge ${want[*]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
removed=$(printf '%s\n' "$sim" | awk '/^(Remv|Purg) /{print $2}')
|
||||||
|
local p
|
||||||
|
for p in $removed; do
|
||||||
|
case " ${want[*]} " in
|
||||||
|
*" $p "*) ;;
|
||||||
|
*) extra+=("$p") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if (( ${#extra[@]} > 0 )); then
|
||||||
|
_DNSMASQ_PURGE_NOTE="purge would also remove ${extra[*]} — dnsmasq left installed (stopped + disabled)"
|
||||||
|
log_warn " dnsmasq: NOT purging — something else now depends on it. Removing it would also take out:"
|
||||||
|
log_warn " ${extra[*]}"
|
||||||
|
log_warn " The unit is stopped + disabled, so :53 is free. The package stays, deliberately."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info " dnsmasq: removing the package(s) Felhom installed: ${want[*]}"
|
||||||
|
if run env DEBIAN_FRONTEND=noninteractive apt-get -y purge "${want[@]}"; then
|
||||||
|
# Positive observable: assert it is actually gone rather than trusting apt's exit code.
|
||||||
|
if dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then
|
||||||
|
_DNSMASQ_PURGE_NOTE="apt-get reported success but dnsmasq is still installed"
|
||||||
|
log_warn " dnsmasq: apt-get exited 0 but the package is STILL installed — treat as not removed."
|
||||||
|
else
|
||||||
|
log_success " dnsmasq removed — the next install starts from a clean slate, as a first install"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
_DNSMASQ_PURGE_NOTE="apt-get purge failed — dnsmasq left installed (stopped + disabled)"
|
||||||
|
log_warn " dnsmasq: the package removal FAILED. The unit is stopped + disabled, so :53 is free,"
|
||||||
|
log_warn " but a reinstall will read the package as pre-existing. Clear it with:"
|
||||||
|
log_warn " apt-get -y purge ${want[*]}"
|
||||||
|
fi
|
||||||
|
# R-316: the residual case, stated because it is invisible otherwise. If dnsmasq-base pre-dated us
|
||||||
|
# we correctly leave it — but it owns /usr/sbin/dnsmasq, and the agent decides whether to install
|
||||||
|
# by stat-ing exactly that path (felhom-agent internal/lanresolver/lanresolver.go:105). So on such
|
||||||
|
# a host the next install skips the apt step and then fails to enable a unit that is no longer
|
||||||
|
# there. Filed as R-317; named here so nobody has to rediscover it from a silent resolver.
|
||||||
|
if [[ "$(_state_get dnsmasq_base_preexisting)" == "yes" ]]; then
|
||||||
|
log_warn " NOTE: dnsmasq-base pre-dates Felhom and is deliberately kept. It owns /usr/sbin/dnsmasq,"
|
||||||
|
log_warn " which the agent stats to decide whether to install — so on this host the next install"
|
||||||
|
log_warn " may skip the install step and fail to enable the unit (R-317)."
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
run_uninstall() {
|
run_uninstall() {
|
||||||
log_step "UNINSTALL — local host teardown"
|
log_step "UNINSTALL — local host teardown"
|
||||||
|
|
||||||
@@ -1098,8 +1184,22 @@ run_uninstall() {
|
|||||||
if $_dnsmasq_touched || systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
|
if $_dnsmasq_touched || systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
|
||||||
case "$_dnsmasq_owner" in
|
case "$_dnsmasq_owner" in
|
||||||
no)
|
no)
|
||||||
|
# R-316 — STOPPING IS NOT REMOVING, AND THE DIFFERENCE COSTS THE *NEXT* CYCLE.
|
||||||
|
#
|
||||||
|
# R-300 stopped + disabled the unit and left the PACKAGE installed. That frees :53, so
|
||||||
|
# cycle 1 looks fixed — and it is. But the next install's preflight asks dpkg "is
|
||||||
|
# dnsmasq installed?", the answer is still yes, so it records `dnsmasq_preexisting=yes`,
|
||||||
|
# the second uninstall then treats OUR OWN package as the household's and leaves it
|
||||||
|
# running, and the third install refuses again with the message that blames the
|
||||||
|
# customer's network. **Measured on drill-r50 2026-08-13, all three cycles, before this
|
||||||
|
# was written.** Every box already in the field is at cycle 2.
|
||||||
|
#
|
||||||
|
# So: stop first (the port must free even if the package operation fails), then remove
|
||||||
|
# what we added. Order matters — the state file that carries this record is deleted
|
||||||
|
# later in this same function, so the record is read before it can be lost.
|
||||||
log_info " dnsmasq was installed by Felhom (recorded at install) — stopping + disabling it"
|
log_info " dnsmasq was installed by Felhom (recorded at install) — stopping + disabling it"
|
||||||
run systemctl disable --now dnsmasq || true
|
run systemctl disable --now dnsmasq || true
|
||||||
|
_dnsmasq_purge_owned
|
||||||
;;
|
;;
|
||||||
yes)
|
yes)
|
||||||
log_info " dnsmasq pre-dates Felhom (recorded at install) — leaving it running, restarting only"
|
log_info " dnsmasq pre-dates Felhom (recorded at install) — leaving it running, restarting only"
|
||||||
@@ -1175,7 +1275,14 @@ run_uninstall() {
|
|||||||
# R-300: the packages are still not PURGED (they are system packages and purging them on a host we
|
# R-300: the packages are still not PURGED (they are system packages and purging them on a host we
|
||||||
# may not own is the wrong blast radius) — but dnsmasq's UNIT is now stopped+disabled when the
|
# may not own is the wrong blast radius) — but dnsmasq's UNIT is now stopped+disabled when the
|
||||||
# install-time record says Felhom installed it, so our own leftover no longer blocks our own reinstall.
|
# install-time record says Felhom installed it, so our own leftover no longer blocks our own reinstall.
|
||||||
log_info " NOTE: the 'sudo' and 'dnsmasq' packages were left INSTALLED (system packages, not purged); dnsmasq's unit was stopped+disabled only if Felhom installed it. The host record still exists in the hub — remove it there if desired."
|
# R-316: this line used to say dnsmasq was left installed, and that was the defect in one sentence.
|
||||||
|
# `sudo` genuinely is a system package and stays. dnsmasq is now taken back when the install-time
|
||||||
|
# record says we added it — and when that could not be done, the reason is stated HERE rather than
|
||||||
|
# scrolling past, because a teardown that quietly half-completed is how cycle 3 refuses.
|
||||||
|
if [[ -n "$_DNSMASQ_PURGE_NOTE" ]]; then
|
||||||
|
log_warn " NOTE: dnsmasq was NOT removed — $_DNSMASQ_PURGE_NOTE. Its unit is stopped + disabled, so :53 is free, but the NEXT install will read the package as pre-existing."
|
||||||
|
fi
|
||||||
|
log_info " NOTE: the 'sudo' package was left INSTALLED (a system package, never ours to remove). dnsmasq is removed only when the install-time record says Felhom installed it; a resolver that pre-dated Felhom is never touched. The host record still exists in the hub — remove it there if desired."
|
||||||
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
|
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -1715,12 +1822,24 @@ step_preflight() {
|
|||||||
# heuristic dressed as a fact, and this project has been bitten by exactly that shape.
|
# heuristic dressed as a fact, and this project has been bitten by exactly that shape.
|
||||||
#
|
#
|
||||||
# Recorded on EVERY mode, because appliance is where the agent actually installs it.
|
# Recorded on EVERY mode, because appliance is where the agent actually installs it.
|
||||||
|
# R-316: TWO packages are recorded, not one, and the second is not pedantry.
|
||||||
|
#
|
||||||
|
# `dnsmasq` ships the systemd unit; **`dnsmasq-base` ships /usr/sbin/dnsmasq**. They are separately
|
||||||
|
# installable, and a host can carry the base package for some unrelated reason. The removal must
|
||||||
|
# take back only what WE added, so what we found has to be recorded package by package — the same
|
||||||
|
# rule as before, applied one level finer.
|
||||||
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then
|
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then
|
||||||
_state_put dnsmasq_preexisting yes
|
_state_put dnsmasq_preexisting yes
|
||||||
log_info " dnsmasq: already installed BEFORE Felhom — recorded; uninstall will not touch it"
|
log_info " dnsmasq: already installed BEFORE Felhom — recorded; uninstall will not touch it"
|
||||||
else
|
else
|
||||||
_state_put dnsmasq_preexisting no
|
_state_put dnsmasq_preexisting no
|
||||||
log_info " dnsmasq: not present before Felhom — recorded; uninstall may stop+disable it if we install it"
|
log_info " dnsmasq: not present before Felhom — recorded; uninstall will remove it again if we install it"
|
||||||
|
fi
|
||||||
|
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq-base 2>/dev/null | grep -q "install ok installed"; then
|
||||||
|
_state_put dnsmasq_base_preexisting yes
|
||||||
|
log_info " dnsmasq-base: already installed BEFORE Felhom — recorded; uninstall will leave it"
|
||||||
|
else
|
||||||
|
_state_put dnsmasq_base_preexisting no
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config
|
# Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config
|
||||||
|
|||||||
Reference in New Issue
Block a user