Tags: nicodes/komizo
Tags
feat: logs verify against a set the registry key is not in (#187, #18… …8) (#100) komizo-be#180 put the registry key into the one set every route verified against. Correct for commands -- it is what lets any signed-in device work -- and wrong for exactly one route. Logs are the only bytes on a box that never leave it any other way. Reports, history and metrics are PUSHED to the service by the agent, so it holds them already and a signature changes nothing about who can see them. app-only.md §5 said what that makes logs: putting the most sensitive bytes on the machine behind the same single env var, and then calling the protection structural, would be false. So there are two sets now. OperatorKeys carries the registry key; LogKeys is operator keys plus ACCOUNT log keys and does not. BUILT, NOT FILTERED. Deriving the log set by removing the registry key from the command set would be shorter and would fail OPEN the day somebody refactors the derivation -- and failing open here is silent, because a log that can be read looks exactly like a log that should be readable. AND PASSED, NOT DEFAULTED. verifiedRead now takes its key set as an argument, so every route names the one it uses and a new route cannot avoid the decision. A default would mean whoever forgot got the wider set, in the direction that does not announce itself. An account key is portable where a device key is not: its private half comes from a passphrase, so it works on any device the person signs in from. That is the property that makes this possible at all -- it was device keys being per-browser that broke the product flow in the first place, and #180 removed them from the required path for that reason. Planting one is a root action and there is no op that does it. If the service could push a log key it would push its own, and all of the above would be theatre. TestNoSignedCommandCanPlantALogKey holds the op set to it. The central test asserts BOTH halves on one box: the registry key reads the report and cannot read the log, and the account key does the opposite. Asserting only the refusal would pass against a box that refuses everything, which is this codebase's recurring failure -- the passing result indistinguishable from the not-running result. A box that commands and serves no logs is NORMAL, not broken. It is every box between #180 and an account setting a passphrase. Both enrol and serve say so in their own words, because an empty log screen otherwise sends somebody to the machine to fix something that is working as designed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: serve.go warned about a state it can no longer be in (#99) It printed "this box has no device keys, so it answers nothing -- not reads, not commands" on startup, and komizo-be#180 made that false. It also made it UNREACHABLE: CanServe requires a registry key and a server id and returns early without them, and CanCommand requires a server id and either kind of key, so anything past the first check satisfies the second by construction. Dead code that prints a false sentence is worse than dead code, because the way it gets found is somebody reading it and believing it. The pair of conditions is asserted in serve_test.go instead. If somebody narrows CanCommand later, serve.go is silently missing a guard it once had, and the symptom is a box that opens a socket and refuses every request with nothing on stderr to say why -- which is precisely what the warning existed to prevent. Narrowing it back fails the test. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: komizo add died on a real box because shell does not hoist (#97) `komizo add` against a live server created the deploy account, the app directory, the deploy scripts and the doas rules, and then: sh: komizo_sshd_conf_is_ours: not found nicodes/komizo-be#164 put that function inside the sshd-validation block and called it from the section ABOVE, under a comment saying it was "defined further down" as though that were fine. Shell reads top to bottom. `sh -n` is happy with it, because it is not a syntax error -- it is a run-time failure on somebody's server, half way through provisioning. alpine-remove.sh had it too, so `komizo remove` was broken the same way. The block moves above its first use in both; its contents are unchanged, so the byte-for-byte comparison across the three scripts still holds. AND THE TEST THAT COVERED THAT FUNCTION WAS GREEN THROUGHOUT. It extracts the block and appends the call after it -- an order the real script never runs in. A test that builds its own arrangement of the code under test can only tell you that arrangement works. So the new check reads the scripts AS THEY SHIP, in order, and fails when a function is called above its definition. Confirmed against the shipped bug: putting the block back where it was reports alpine calls komizo_sshd_conf_is_ours() at line 1554 and defines it at line 1724 Comments corrected rather than left: both said the definition was further down, which was the mistake written out in prose beside itself. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: a test that saw two different worlds and only complained in one (#… …96) The v0.0.18 release failed at Test on a commit whose CI had just passed. TestAnAgentIsNotBuiltWithoutAVersionToPinTo assumed no agents were embedded, and asserted Get refuses a module reference it cannot resolve. That holds in CI, where .github/actions/test does not run `make agents`. It does not hold in the release, where .github/actions/build runs `make agents` and Build comes BEFORE Test -- so Get finds an embedded agent, hands it back, and never looks at the version at all. The test was right about the behaviour and wrong to assume which world it was in. CI could not have caught it: CI only ever runs the world the test assumed. It branches on the fact now instead, and both branches assert something worth asserting: agents embedded -- Get uses one and NEVER consults the module version, because a release archive must not need a toolchain or a network to set a box up. An unusable module reference is irrelevant there and must not be turned into a failure. none embedded -- Get refuses rather than guessing a version, because a guess installs an agent that need not match the CLI managing the box. Ran both ways locally: `make agents` then the suite, and again with the binaries removed. Green in each. The embedded run also exercises TestStampCoversEveryAgentOrNone, which skips itself in CI and had therefore never run outside a release. No tag was created -- the release runs Test before it creates one, which is the ordering working as intended. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: the ceiling fix missed release.yml, and its own claim was false (#… …93) nicodes/komizo-be#147 moved the timeout off the job and onto the steps in ci.yml, and I called that issue done. It was an instance, not the shape. release.yml had the same job-level ceiling and no step bounds at all -- and a kill there lands somewhere particular, because that job creates a TAG, and a Go module version is permanent once anything has fetched it. AND THE COMMENT I WROTE IN ci.yml WAS FALSE. It says the job number is "deliberately larger than the sum of them, so it can only fire when one of them has failed to". It was 45 against 55 minutes of step bounds. So the backstop could still fire while every step was inside its own -- the same defect #147 exists to remove, one size smaller, shipped in the fix for it. The claim is a CHECK now rather than a sentence, because the arithmetic changes every time somebody adds a step and a comment cannot notice that. It bounds the slack both ways: a backstop below the sum is not a backstop, and one far above it lets a stuck job hold a runner for most of an hour after every step has given up. The same check in komizo-be found a third workflow on its first run. caught ci.yml's backstop drops below its step bounds (the shipped bug) caught release.yml bounds the job and no step (the state it shipped in) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: validate the sshd config with the binary that will load it (#78) * fix: validate the sshd config with the binary that will load it Closes #77 Found while verifying v0.0.16 on a real box before cutting it, which is the only reason it was found at all: every test passes either way. `sshd -t` resolves to /usr/sbin/sshd. Alpine's init script runs /usr/sbin/sshd.pam when the config says `UsePAM yes` -- a different binary, not a link, that disagrees about which options exist. `UsePAM yes` is Alpine's own default, so on every box with openssh-server-pam installed this check was validating a program that was never going to read the file. The symptom was printed on every update and read as noise: /etc/ssh/sshd_config line 88: Unsupported option UsePAM Both directions are wrong and only one is safe. A good config rejected just reverts an edit. A config the running daemon will NOT accept passing this check is a reload into a broken sshd -- which is precisely what komizo#65's deferred reload leans on this to prevent. IT ASKS THE INIT SCRIPT rather than reimplementing its choice. Alpine's update_command() does not merely look for the binary, it tests the config to decide, and copying that here would be vendor logic drifting out from under us. The service already exposes `checkconfig`, which runs that selection and then validates, so the guard defers to it and falls back to `sshd -t` where the action does not exist -- no worse than before. The scripts are embedded whole with no splicing, so the function exists three times. Three copies of a security check is three chances for two of them to be right, so they are delimited and compared byte for byte, and each script is asserted to CALL it and to run no bare `sshd -t` beside it. Not a regression: the per-app `sshd -t` had this from the start, and #65 did not worsen it. It did make the guard more load-bearing, which is why this goes in before v0.0.16 rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: assert what the guard concluded, on the branch that runs in production Review 1 on #78, both blocking findings. They are the same shape and it is the one this repository keeps hitting: the branch that runs on a real box is the branch nothing executed. B1. Nothing asserted the exit status of the rc-service path. Adding `|| true` to only that branch left every test, gofmt and shellcheck clean -- so the guard could pass on every production box while the suite said it worked. The one end-to-end failing-checker test runs on a host with no /etc/init.d/sshd, which is the FALLBACK, which is why the gap was invisible from a developer machine. The behavioural test now drives both branches for both answers and asserts what the guard concluded, not just which tool it asked. B2. "each script calls it" was substring presence, so `: komizo_sshd_config_ok` beside an `if sshd -t` reintroduced the exact bug with the guard left in the file as decoration. It must now be what the `if` TESTS, and the bare-sshd check strips quotes first, because `sshd "-t"` is the same command and passed. Also from the review: the grep is pinned to ^extra_commands=, since a looser pattern matches extra_started_commands -- an action OpenRC only offers on a started service, so a stopped sshd would take the wrong branch. Two comments still said the edit was validated with `sshd -t`. And checkconfig runs `ssh-keygen -A`, so it is not side-effect free: a no-op on a box komizo is already reaching over SSH, but a write on a path named validate, during removals too. Written down where the function is rather than left to be discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: the agent, and enrolment (#10) * feat: the agent, and enrolment v1's sending half. A box can now point itself at a komizo service and report to it, which closes the loop the service PR opened. Three new modes on komizo-box. `agent` runs as komizo_monitor and is the process that talks to the internet: it reads report.json and posts it, and that is all it can do. It cannot probe the machine, run anything, read the state directory, or replace its own credential -- compromise it and you get a document that already says nothing secret. `enrol` and `unenrol` run as root. The exchange happens on the BOX, so the long-lived token is written by root on the machine it belongs to and never touches the operator's laptop; the single-use one that does travel expires in fifteen minutes. `komizo enrol` in the CLI carries two values over SSH and runs it, passing them on stdin rather than a remote command line, which is visible in the process table to every account on the box. The agent does not queue and does not retry a 401. A dropped report leaves a real gap, which is honest and costs nothing -- the box still has every reading in history.jsonl. A refused credential is not transient, and retrying one turns a removed server into a machine quietly hammering an endpoint forever. Two corrections to design/enrolment.md, both from building it: the exchange cannot happen as komizo_monitor, because /etc/komizo is 0750 root:komizo_monitor and making it writable by the agent would let the process that talks to the internet replace its own identity; and agent.token became agent.json, because a URL and a token issued together in two files are two things that can disagree about which server this is. Found on a real Alpine box, the THIRD instance of one shape: the credential was written 0640 root:root, so the agent could not read it. Mode is not the boundary on its own -- a file root writes is owned by root, and 0640 means nothing to an account that is in no relevant group. WriteAgentConf now sets the group, and agent-enrol.sh proves it by reading the file as komizo_monitor and failing the enrolment if it cannot, the way the installer already proves the report is reachable. Verified end to end against the real service: enrol over SSH, credential written 640 root:komizo_monitor, agent enabled and reporting, the server appearing on the dashboard with its state and agent version, and the agent stopping cleanly when its token is revoked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: A && B || C in unenrol, and a local check that would have caught it shellcheck is right: in `A && B || C`, C also runs when B FAILS -- so an unenrol that errored would fall through to the rm and hide why. Written as if-then-else. The more useful half is why CI found it and I did not. The Go test that runs shellcheck skips itself when the tool is absent, and a skip is a green tick, so `make check` passed locally with the gate never having run. It now runs shellcheck through docker when it is not installed, and fails rather than skipping -- verified by pointing it at a script with this exact defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: the first report claimed komizo was not installed (#9) Found by running the released v0.0.13 against a real Alpine box: the live report showed "komizo 0.0.13, agent 0.0.13" and `--cached` -- the one the installer had just produced -- showed nothing. The installer ran `rootd --once` and then wrote /var/lib/komizo/version, so the first report read a file that did not exist yet. A freshly installed box reported itself as having no komizo on it until the timer ticked a minute later, which is the one minute somebody is most likely to be looking, having just run the installer. Two lines, swapped. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: v0 — the agent, and only the agent (#8) * feat(box): the report, in Go v0 of design/appify.md §11: komizo-rootd probes the machine and writes report.json, and everything that used to be shell doing it is now a typed document. internal/box is shared by two programs that are not upgraded together -- the binary on the server writes a report, the CLI reads one -- so the schema is a Go type the compiler checks rather than a wire format two codebases agree about in prose. Version states the rule: add fields, never repurpose one. The shell had no such constraint because it was shipped fresh from the laptop on every poll. Ported: the inventory, the per-container cgroup stats (v1 and v2), disk, listening ports read from /proc/<pid>/net/tcp, volume sizes, and the access-log request counts. Diagnose is new as a place rather than as logic -- alias clashes and the rest were computed by each reader, and there are about to be four readers. Two properties carried across deliberately, with tests that fail if either is lost: no secret and no request detail can reach the report, and report.json is 0644 so the reporting account needs no privileges. Found on the way: reading a seeked file dropped the first line whether or not a seek had happened, which silently lost the oldest reading of every history and access log small enough to be read whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: read servers through the agent Switches every read path -- `komizo list`, the TUI poll, the monitor -- off piping shell over SSH and onto komizo-box. `komizo report` is new: one reading of a box, in one screen, with --json for anything else. init installs the agent instead of the sampler. The binary is embedded (internal/agent) rather than downloaded, for the same reason the provisioning shell is: init has to work on a box with nothing on it, and adding a release URL to that would add a network dependency and a supply-chain step to the one operation meant to work when little else does. Two connections -- the binary on its own, then the installer -- because base64 in a heredoc turns a failed transfer into a syntax error in the middle of a script running as root. The agents must exist before the CLI compiles, since //go:embed cannot invoke a compiler. `make agents` builds them and the release action runs it first; a CLI built without gets one clear message at the moment somebody installs an agent, not a broken build. Both binaries are 2.6MB, so the CLI grows by ~5MB for amd64 and arm64. komizo_monitor is created with no shell, no doas and no docker group -- appify.md §3's account, which nothing uses yet because there is no agent process to run as it. The per-minute crontab entry goes; the sampler's log stays, being the only record of what happened before the update. Fixed on the way: rootd created /var/lib/komizo even when --report pointed elsewhere, which made it unrunnable as anyone but root and untestable. It now creates only what it writes, and laying out the box is the installer's job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: the agent, not the sampler The README described a server komizo installs almost nothing on and reads by piping shell. Half of that is still true -- provisioning is still shell -- and the reading half is not. Says the trade out loud rather than only the win: the shell arrived fresh on every poll, so a newer komizo read new things off an untouched box, and an agent has to be updated to learn anything new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: one way to read a box Draws the line at v0. Nothing here preserves anything the old path did; what the agent reports is what komizo knows. Deleted: ~530 lines of telemetry shell (inventory, metrics, system-log, storage, sampler, sampler-install and the three probe libs), their Go builders, and the six record parsers in internal/app. About 1,300 lines of test went with them -- not because the behaviour stopped mattering, but because they asserted on a wire format that no longer exists. The tests that DID matter were fixture-bound rather than format-bound, so they now build box.Reports and go through inventoryFromReport. That is strictly better than before: the display tests exercise the one path a real box's facts actually take, instead of a hand-written tab record no box ever emitted. Two of them had drifted far enough to assert an upstream of "blog-web" when every box has always written "<app>-gate". Compat removed: the one-line version file, the history file that had to coexist with system.log, the crontab line that displaced the sampler. Fixed, a regression from the port: parseInventory scrubbed control characters at the boundary and inventoryFromReport did not, so an escape sequence in a container name or docker's status prose would have reached the terminal. Scrubbing is back, in the one place values cross. Fixed, a second one: disk folding lived in the deleted parseSystem. The agent already folds by device in Probe.disks, so the reader now trusts what it is sent -- and the rule finally has a test where it lives. And the optimisation the port had regressed: containers were found with a `compose ps` per app, then an `inspect` per container for its service and pid, then another for its mounts. Roughly eighty docker calls per report on a six-app box, on a five-second poll. Membership now comes from compose's own project.working_dir label, and aliases, mounts, pids and timestamps all come out of one `inspect` over every id at once. Five calls per report, flat, at one app and at twenty. There is a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the agent check never fired, and two documents skipped the version gate errNoAgent matched "not found" against the captured stdout and the Go error string. Neither can hold it: the message is written to the far end's stderr, which runCapture passes through to the terminal, and the error this side is "exit status 127". So a box with no agent produced a raw exit-status error, and the one instruction that would have fixed it was unreachable. Now it matches the exit CODE. 127 is what a shell reports for a command it could not find, and ssh passes the remote status through unchanged. Stderr is captured instead of passed through, which also stops a failing agent from writing into the middle of the TUI's frame. The poll and the monitor decoded with a bare json.Unmarshal, so only the report was ever checked against the schema version -- and the two that skipped it are the ones that would have rendered a plausible screen of nothing. Every document now states its version and goes through one generic box.Decode. A document with no version at all is refused rather than read as v0: some other JSON would otherwise decode into a report full of zeroes and draw as a broken server. Report.Stale had no caller. It does now: `komizo report --cached` says when a reading is old, which is the one case where it can be, and a stale report of a healthy box is exactly what a box that died five minutes ago looks like. Also removed: `komizo-box metrics` and `history`, which `monitor` subsumes and nothing called; the awk-execution tests and helpers left behind by the deleted metrics.sh; three copies of the agent-detection logic, now one askBox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: cover what the polish pass left untested, and fix what that found Volumes and dirBytes had no test at all, and the rules in them are the subtle kind: a volume shared by two containers is walked once and attributed twice, hard links count once, a sparse gigabyte is measured by what it occupies rather than what it claims, and a volume whose host directory is gone reports nothing rather than zero. internal/agent had none either. It now asserts that every architecture komizo claims to ship is actually embedded and is actually an ELF binary -- a release missing one would otherwise be found by whoever ran init on an arm box. lastLine was wrong for the one-line case: it searched the trimmed string and sliced the original, so a single line ending in a newline fell through untouched and would have carried the newline into a status bar. Also: a test helper reimplementing strings.Contains, and the last two comments describing code that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: the report was not actually readable by the account that reads it First run on a real Alpine box, which is what this was waiting for. report.json was 0644 inside /var/lib/komizo, which is 0750 root:root -- correctly, because apps/<app>.env names every app's deploy account and directory. Nothing unprivileged can traverse into it, so the mode on the file meant nothing: `su komizo_monitor -c "cat report.json"` is Permission denied. That is the property design/appify.md §3 is built on, and it did not hold. The test that was supposed to catch it asserted the file's mode, which is a proxy for the property rather than the property, and passed the whole time. The report moves to /run/komizo/report.json, in a 0755 directory. /run because that is what /run is for, and because a report is a claim about NOW -- tmpfs clears it on reboot and rootd rewrites it seconds later, which is right rather than a cost. The history stays under /var/lib: it has to survive a reboot, and nothing unprivileged needs it, because the agent posts each report as it is written. The installer now PROVES the boundary on the box rather than asserting it -- it reads the report as komizo_monitor and fails the install if it cannot. The Go test walks the real directory chain, and checks that the check can fail by pointing it at the old layout. Also from the same run: komizo_monitor's home field named an /home path nothing created, now /nonexistent; history.jsonl claimed 0644 inside a private directory, now 0640, because a mode that says something the directory contradicts is how this happened in the first place. Verified on alpine:3.20 with openrc: static musl binary runs, adduser flags are right, the service file is written, rc-update takes it, the account can read the report and cannot read the state directory, and a second run of the installer is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: the report schema is public internal/box was right when two programs in one module shared it. The service is a third reader and a different module, and a schema only one module can express is one the others have to restate -- which is two definitions of a wire format waiting to disagree, and the exact thing the version rule exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test the two rollback paths that nothing verified H2 and H3 were fixed earlier today with no tests. Both are root-run and both are availability-critical, and the audit's own rule -- the fix arrives with the test that would have caught it -- was honoured for H1 and quietly skipped for these two. H3, a failed `docker compose pull`. The .prev backups used to be deleted just before the pull, so when it failed only .env was restored and the new compose.yml and ROUTE FILE were not. The route is the half that matters: it is valid and sits in the directory the proxy imports, so the next deploy of any OTHER app reloads Caddy and starts serving hostnames for a stack that never started. Reintroducing the bug fails the new test on all four symptoms. H2, a rejected doas.conf. The block was deleted, re-appended and then validated, and a failed validation left the invalid file in place -- which breaks every app on the box, not just the one being set up, since doas refuses to run at all against a config it cannot parse. The doas section is executed on its own rather than through the whole of alpine.sh: the rest of that script wants Docker, adduser, chpasswd and sshd, and stubbing all of them to test a file rollback would put most of the test in the stubs. The text is extracted from alpine.sh, so a change to it changes what runs here. Four cases beyond the rollback itself, because a revert is only half the property: the other app's rules survive, the rejected rules do not stay, the failure SAYS it reverted, the backup does not outlive the run on either path, and re-running replaces the block rather than appending. Also asserts the hostname claim is taken under the box-wide lock and released once written, and that a second deploy does not block on it. Through the lock's effects rather than by racing two shells, which would pass for timing reasons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PreviousNext