33 Commits (v1.3.46)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
a4626d2569 |
Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
── The 500 ───────────────────────────────────────────────────────────────── "touch(): Utime failed: Operation not permitted", from BladeCompiler, on every request for the affected view. Blade compiles a view and then touch()es the compiled file to the source's mtime. touch() with an explicit time needs ownership of the file. A compose service without `user:` runs as ROOT — and queue, scheduler and reverb had none — so the first worker to render a view wrote a root-owned compiled file that the web process, as www-data, could never refresh again. The queue is what renders mails, which is why this surfaced now. queue, reverb and scheduler run as www-data. queue-provisioning stays root and says why: it brings wg0 up and runs `wg set` for every peer change, which needs NET_ADMIN on the running process. It renders no mail. And update.sh normalises ownership at the END of a run as well as at the start. The first call heals what a previous run left; the second heals what this one made — `git checkout` rewrites the tree as the service account while the old build is still serving. ── The panel that would not go away ───────────────────────────────────────── The same 500 is why it kept coming back: every poll failed, the watcher treats a failed request as "still restarting" (which it normally is), and the overlay stayed up over a console nobody could then reach to find out why. A restart is seconds. After two minutes of nothing the panel now says so and offers a way out — for an operator only, behind the same flag as the step and the log, so a customer on the 503 page is not shown a button suggesting they can call the deployment off. A successful answer clears it again: one bad minute must not leave a "something is wrong" notice sitting there for the rest of the run. Not an always-present close button. The deployment does not stop because somebody dismissed a panel, and offering that at the wrong moment is a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f8f0de4892 |
Let an update install host packages, without handing out root
Asked for directly: "später wenn ich etwas lokal erweitere will ich mich nicht auf alle adminseiten einloggen müssen und extra rsync installieren". An update runs as the service account, so anything root-owned on the host is out of its reach and turns into "log into every server once and paste this". The obvious fix — sudo on deploy/install-agent.sh — is not a grant at all: the service account owns the checkout and can rewrite the very script it would be allowed to run as root. So the privileged part is written OUT of the checkout by install-agent.sh, to /usr/local/sbin/clupilot-host-step, root:root 0755, from a quoted here-document. The service account cannot influence a byte of it. sudoers names one exact command line including its argument, so a step added to the helper later is not covered by a grant written before it existed, and the helper refuses anything not on its own list as well. update.sh uses it only when rsync is actually missing, after the restart and never fatally: the archive is collected hours later, and an update that died over a package would be the bigger problem. A helper older than the steps the updater wants is reported through the existing one-time-setup hint — silently doing nothing is worse than an error. Existing servers still need `sudo bash deploy/install-agent.sh` once, because that is the run that puts the helper there. After it, they do not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
15b536f393 |
Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Setting a NAS up to collect the invoice archive meant three machines and a dozen commands: generate a keypair there, carry the public half to the server, write it into authorized_keys with a restriction, install rsync, and get every path right on the first try. Every one of those steps was a place to be told "permission denied" with no clue which of the three was wrong. It was, and several times over. One button now. The panel asks, the host does it — because the host is where all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself. The panel is www-data in a container and owns none of that, so it uses the mailbox it already uses for updates. The private half is shown exactly once, alongside the finished rsync command, and is never written to the database: it exists to be copied into a NAS, and storing it "for convenience" would put a working credential in every backup of that database. rrsync does the restricting, not a pinned rsync option string. That string differs between rsync versions and fails silently — a refusal with no reason given, which is the shape of the afternoon this replaces. If rrsync is missing the agent refuses rather than issuing an unrestricted key while the panel says it is restricted. rsync now comes with install-agent.sh, which already runs as root once per machine. It has to be on the HOST: a NAS connects by ssh and sshd starts `rsync --server` here, so without it the pull fails with "command not found" from a NAS whose own setup is perfectly correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33e5b15099 |
Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4d4d1dd5d5 |
Stop the update agent dying on its own tag arithmetic
tests / pest (push) Failing after 8m4s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Since 1.2.0 the agent has ended with a non-zero status on every tick, before writing anything. The console showed a check that never finished and a last-checked time frozen at the moment of the deployment, and the update button could not be reached at all — the one mechanism that would have delivered the fix. Two instances of the same trap, both mine, both under `set -Eeuo pipefail`: The tag count was `release_version_gt … && echo`. The LAST iteration is almost always a tag that is not newer, so the loop ended non-zero, pipefail carried it out of the pipeline, the command substitution inherited it, and set -e ended the agent. `if` rather than `&&` is the whole fix: an if whose condition is false still returns 0. The newest tag came from `git tag … | head -1`. head exits after one line, git takes SIGPIPE, pipefail does the rest. install-agent.sh has carried a written warning about exactly this since it was written; I read it and wrote it anyway. Both now live in deploy/lib/release.sh, because the reason neither was caught is that no PHP test can reach a bash pipeline. tests/Feature/ReleaseComparisonTest.php runs the real functions in a real throwaway repository under the agent's own set -Eeuo pipefail, and asserts the EXIT CODE as well as the answer — an answer nobody lives to read is not an answer. The SIGPIPE case needs four hundred tags to reproduce, because a short list finishes writing before head leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6998a22527 |
Repair ownership by looking inside, not at the door
tests / pest (push) Failing after 8m2s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The ownership repair added in 1.1.1 sampled the owner of each top-level directory and skipped the recursion when it already matched. node_modules was owned by www-data; node_modules/.vite-temp, left behind by an earlier root build, was not. So the repair walked past it, `npm run build` failed with EACCES, and the deployment stopped in maintenance mode — on the release whose whole point was that this could not happen. A directory's owner says nothing about its contents. It now walks each tree once with find and changes only the entries that are wrong, which is also cheaper than the detect-then-chown-everything it replaces. Verified against the shape that actually failed: a root-owned file inside a www-data-owned node_modules, repaired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
00b9107ee3 |
Deploy as the application's user, and take back what root already took
tests / pest (push) Failing after 7m19s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The 500 on the VPN config download was never in the VPN code. storage/logs/ laravel.log was owned by root, mode 644, since 27.07 04:16 — the moment a failed `artisan optimize` wrote its error there during a deployment. From then on the application could not append to its own log: Monolog threw on every attempt, and a throw while logging is a 500 on any page that logs, with nothing written down to say why. The error page said "Er wurde protokolliert". It was not. It surfaced on the config download because that is one of the few pages writing a log line on its way through — on success and on a wrong password alike. Pages that log nothing were unaffected, which is exactly why it looked like a VPN fault for days. `docker compose exec` is root unless told otherwise, and every deployment script relied on that default — including the agent's allowlist sync, which runs every minute. docker/entrypoint.sh had it right all along and drops to www-data for precisely these commands. Now so does everything else, help text included: telling an operator to run artisan as root is how the file ends up owned by root in the first place. update.sh also repairs what an earlier run left behind, before its first unprivileged step rather than after — a root-owned log file is not self-healing, the page that trips over it is nowhere near the deployment that caused it, and with in_app unprivileged a root-owned vendor/ would break the next composer step outright. tests/Feature/DeploymentRunsAsTheAppUserTest.php holds the line. It was checked against the previous commit and finds all nine places that were wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
21b7aaca42 |
Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Asked for directly: a commit on main must change nothing on a server, and only a tag whose version is higher than the installed one may be installed. The agent asked two different questions before this. A pinned server compared tags; everything else counted commits on its branch — so a console following main offered an update for every push, verified or not, and "Jetzt aktualisieren" meant "take whatever is on the branch right now". Landing on main and being released are different events, and only the second is a decision somebody made. It is one question now, regardless of what the checkout is attached to: is there a tag whose version is higher than the deployed one? Compared with sort -V against the version in the deployment manifest — not the VERSION file, which an update moves before it migrates, so a run that failed in between would leave the checkout claiming a version it never finished installing. A run always targets that tag; the branch path is gone. A request that arrives with nothing to install is answered rather than obeyed, before anything announces a run. The console follows: the button is disabled with nothing released, says so in a line rather than leaving a grey button to be guessed at, names the version it would install rather than a count, and refuses the action server-side as well — a Livewire action is a public endpoint, and one place a rule may not live is only in the disabled attribute of a button. VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07634c8a1d |
Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real update, collected by the same agent on the same systemd timer — so asking what was new was answered on the next tick, and the console, having nothing else to show, counted down to it. An operator who asked a question was told an update would start in 3:44. A systemd path unit now wakes the agent the moment the request file is written, for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms as soon as the service goes inactive, so a request the agent could not consume — it holds a lock for the length of an update — would restart it in a tight loop. The timer stays as the fallback, and a drop-in disables systemd's start rate limit, because two triggers on one oneshot service can otherwise wedge the unit and leave neither of them able to run. The countdown is gone from both the settings card and the maintenance overlay. It could not be made honest: its target was recomputed on every poll, and where the interval the agent reports is shorter than the timer actually installed on that host it had already gone by, so the fallback moved it forward again each time. It ran backwards four seconds and snapped back to a full minute, forever — reported exactly that way. A queued run says it is starting; a queued check says nothing beyond its badge, because a check starts nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f05547921d | Separate checking for updates from applying them, and show live progress | |
|
|
bd779f00e5 |
Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s
Details
tests / assets (push) Successful in 30s
Details
tests / release (push) Successful in 6s
Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.
Two of those views looked as though they had handled it. They called
->timezone(config('app.timezone'))
which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.
Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.
The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.
The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.
Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.
APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
b85c12e8b9 |
Give the readiness probe long enough for a restart to finish
tests / pest (push) Successful in 7m13s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
The gateway is restarted twice in a deployment — once by `up -d`, then again after the hub whose network namespace it lives in — and `docker compose exec` into a container that is still coming up fails outright rather than waiting. Twenty seconds did not cover that, so the first run of the new probe reported a gateway that answers as down, and the resolver stayed out of client configs for one more deployment. The health site also now binds the hub address instead of only matching on it. Nothing was published either way, but the file should do what its comment says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3699c7cdb9 |
Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
The console has been unreachable over the VPN, and the cause was the readiness probe rather than the tunnel. The gateway binds to the WireGuard hub address alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only fail, so VPN_READY stayed false, and on that basis the application withheld the `DNS = 10.66.0.1` line from every client config it issued. A phone then built the tunnel, asked its normal resolver for the console hostname, got the public address and had its connection closed — indistinguishable from "this site does not exist", which is exactly what it looked like. Probing over TLS on the bare address would not have worked either: the site matches on the console's hostname, so a request without SNI is offered no certificate. The gateway now answers a plain-HTTP health port on the hub address, which removes TLS, SNI and name resolution from the question and answers only what is being asked — is this gateway listening, in this network namespace, right now. Caddy refuses to start when the certificate is unreadable, so a health port that answers still proves the whole file loaded. Also here, all found while looking: - Icons pushed their label onto a second line and rendered a size larger than asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and `.size-5` have equal specificity, so stylesheet order decided — and it emits size-4 first. Every icon written as 16px was silently 20px. Recorded as R18. - Four error pages printed `errors.404.hint` in place of a sentence: the lang files give those codes a null hint and Laravel returns the key for a null line. - The Developer role had no label in either language, so the dropdown showed `admin_settings.role_developer`. - The secrets area held one key, which is not worth a password gate. It now carries the credentials that actually stop the business when they expire — DNS, monitoring, SMTP — and the test button appears only where a checker exists, instead of reporting on Stripe whatever was being looked at. - The update button said nothing about when a queued run would start or where a running one had got to; both are now shown, and a failure names its step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2bb1d7cc3b |
Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s
Details
tests / assets (push) Successful in 27s
Details
tests / release (push) Successful in 4s
Details
An empty 404 is itself information: it says something terminates TLS here and chose not to answer. The console and the websocket endpoint now close the connection instead, which is what a hostname that serves nothing looks like. The websocket endpoint answers a genuine upgrade — verified live, 101 — and nothing else; a browser opening it gets a closed connection. Reviewing that turned up two holes that mattered more than the thing being reviewed. The compose defaults published the application and Reverb on every interface. Docker publishes ports ahead of UFW, so those backends were reachable from the internet with the firewall closed — and reaching a backend directly skips the proxy, and with it every hostname and address rule keeping the console private. The defaults are loopback now, and an update rebinds an existing installation that is behind a proxy actually running and holding 443. A development box without one is left alone, because taking its port away would look like the machine had broken. And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's allowlist. One click would have put the console on the public internet, which is the exact state the owner's rule exists to prevent. Switching it off relaxes the application's check; the proxy keeps its list, always. The reference proxy config carries the arrangement: QUIC early data off, since an address-based decision taken on 0-RTT can answer 425 and some browsers do not retry — an intermittent lockout from the console is the worst kind — and explicit handling for plain HTTP, whose automatic redirect otherwise announces both hidden names to anyone who asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c4649571df |
Wait for the tunnel interface, and prove the gateway answers
tests / pest (push) Successful in 7m23s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Successful in 5s
Details
Two things reported success while the tunnel console was unreachable. The services were restarted the moment the hub container counted as started, but the hub brings wg0 up as part of its start command — so they were binding an address that did not exist yet. They now wait for the interface. And readiness was a container-state check. A container can be up and running while the process inside it listens in a network namespace that was torn down underneath it: nothing errors, the address simply refuses connections, and "running" reports everything fine. It asks the gateway now, and only a real answer counts — because the consequence of getting this wrong is a client config naming a resolver that is not there, which takes the device's whole name resolution with it for as long as the tunnel is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d2d79b4f5f |
Restart the tunnel services after the hub they live inside
tests / pest (push) Successful in 10m42s
Details
tests / assets (push) Successful in 18s
Details
tests / release (push) Successful in 6s
Details
Both VPN services share the provisioning container's network namespace, and a process holds the namespace it started in. Restarting the hub — which every deploy does, to pick up new code — therefore leaves them listening inside a namespace that no longer exists. Nothing errors. The containers stay up, Caddy's log still reports it is serving on the tunnel address, and connections to that address are simply refused. It looks precisely like a gateway that never worked, which is how it presented on the live server after the first successful start. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
457eeeeaef |
Serve the console inside the tunnel, without publishing the internal network
tests / pest (push) Successful in 10m12s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Successful in 5s
Details
The owner's phone shows a blank page on the VPN. That page is the reverse proxy refusing the request, and it refuses because the phone is not on the tunnel: the client config routes only the management subnet, so a request to the console's public hostname leaves over the mobile network and arrives from a carrier address. It is also why the peer reads "last contact: never" — WireGuard only handshakes when it has traffic to send, and nothing is ever routed in. The obvious fixes are both wrong. Adding the server's public address to AllowedIPs routes the WireGuard endpoint into the tunnel it is trying to establish. A public DNS record pointing at 10.66.0.1 publishes the internal subnet to anyone enumerating the domain — the owner raised that himself, and he was right. So the console is served INSIDE the tunnel instead. A small resolver and a small gateway share the hub container's network namespace, which is where wg0 lives, and answer on 10.66.0.1 directly. A client therefore needs no route beyond the subnet it already has, the host's docker bridge is never exposed, and the request reaches the application with its real 10.66.0.x source — which is what the console's own allowlist checks. The gateway reuses the certificate the public Caddy already renews: a certificate is bound to the name, not to the address serving it, so nothing new is issued and nothing internal reaches a public zone. Most of this commit is the arithmetic of not lying about it. Review found fourteen ways the arrangement could report itself working while it was not: enabling the compose profile after the services were started rather than before; starting a gateway with empty certificate paths, which can only crash; treating "container created" as "container running"; carrying a stale readiness marker through a failed restart; reusing the previous hostname's certificate after a rename; leaving the profile enabled when the hostname was cleared, in a script that exits early precisely when nothing else would fix it; reading the renewal timestamp as a user who cannot traverse Caddy's storage; and a `find | head` that aborts the whole installer under pipefail the moment two issuers hold a certificate for the same name. One of them was mine and worth naming: to let the container read the certificate, I had made the TLS private key world-readable. On a multi-user host that hands the console's identity to every local account. The client is told about the resolver only when both services are confirmed running, because a config naming a resolver that does not exist takes the device's entire name resolution with it for as long as the tunnel is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6082164f8b |
Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
STATUS_HOST binds /status to a single hostname. Every other host redirects there rather than 404ing: a status page is the one address people keep in a bookmark and reach for when something is already wrong, so breaking old links to prove a point about separation would be exactly backwards. Unset, nothing changes. The /legal/status redirect goes through route() instead of a literal path. A relative redirect lands on whatever host the visitor is already on — which, once the page has a hostname of its own, is precisely where it no longer answers. The installer told the operator to point Stripe's webhook at the customer portal. Stripe posts server-to-server and never sees the portal; the endpoint is the api hostname, which the installer never even asked for. It asks now, and prints the address that actually receives the events. Getting this wrong does not fail loudly — payments simply stop being recorded. It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the console onto its own hostname before the DNS for it exists makes the console unreachable under any other name, and that is not a thing to have on by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bc8bbc56a5 |
Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The console was unreachable, and not for the reason it looked like. Two causes, both mine. A failed `artisan optimize` — the one that hit duplicate route names — left a broken route cache behind, so every request to the console host answered 500. Rebuilt. Underneath that: the reverse proxy has its own console allowlist, hard-coded, and it runs BEFORE the application. Everything added on the console's own access page was therefore ineffective, and when the owner's address changed they were turned away by the proxy before reaching the page that would have fixed it. The proxy now imports a fragment generated from the same list the console manages, regenerated by the host agent and reloaded when it changes. Getting that safe took most of the review. It refuses to rewrite an ambiguous Caddyfile rather than replacing some other site's matcher; it never falls back to a loopback-only list when the application cannot be reached, because that list validates cleanly and locks out every remote operator; it retries a reload that failed instead of assuming it worked; and an installation that upgrades without rerunning the installer is told, because otherwise the whole mechanism is invisibly absent. Prices are entered in euros. The form asked for cents, so €799 was typed as 79900 and one slipped digit was a factor of ten on an invoice. Conversion happens in one place, on the string rather than through a float — (float) '79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on exactly the prices people charge — and it refuses an amount the column cannot hold instead of failing at the database. Every error code has a page now, in the site's own language and typeface, self-contained so it still renders when the asset manifest is the thing that broke. There was only a bare white 404. The VPN list stops being a seven-column table nobody could fit: names broke across two lines and so did the headings. These are attributes of one access, not quantities compared down a column, so each access is a row — identity on one line, measurements in mono on the next. The status page says what it measures rather than what it promises. "New orders are delivered without failures" read as a marketing claim on a page whose only job is to be believed, and said nothing about the last 24 hours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
276f926d57 |
Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
The panel had no way to update the installation, and could not have one on its own: it runs as www-data inside a container, and the update restarts that container. A button that shelled out would kill its own web server mid-response, and would need the application to hold host-level credentials — a far worse thing to own than an out-of-date checkout. So the button is a mailbox. The panel writes a request into the checkout; a systemd timer on the host, running as the service account, consumes it, runs deploy/update.sh and writes back what happened. The same timer answers the question the panel cannot answer alone — is there anything to update — which needs a git fetch, and therefore credentials the application does not have. The parts that were wrong before review, all of which would have shipped as a button that looks fine and does nothing: - The agent was only installed by install.sh, which the installed base never runs again. Every existing server would have shown the button permanently disabled. It has its own root entry point now, install.sh delegates to it, and update.sh says so when the unit is missing. - On a release-pinned server the checkout is detached, so it followed "origin/HEAD" and then ran an update that deliberately stays on the pinned tag: updates advertised, nothing applied. It now compares TAGS in release mode and passes the target release through. - On a branch other than main it advertised that branch's commits and then deployed main's, because update.sh defaults to main. - A request written while the agent was down was executed whenever the agent next started, days later. - A single status file meant the routine five-minute check overwrote a failed update with "idle" before anyone saw it. - An agent that had been stopped left the button enabled forever, because a status file written once counted as an agent. - The agent's error messages were German strings rendered into an English interface; it reports codes now and the panel translates them. Also: the add-address button in the console-access panel was as tall as the field plus its hint, because the hint was a sibling inside the flex row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
875fd11982 |
fix(deploy): an answer file with a space in a value killed the install
tests / pest (push) Successful in 7m2s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
The file is sourced, so ADMIN_NAME=Boban Blaskovic is read as a command and dies with "Blaskovic: command not found" — naming neither the file nor the variable — and set -e ends the install there. A display name with a space is entirely ordinary; the example just happened to use "Administrator", one word, so nobody hit it. Found while installing on the live server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
35307e64cc |
fix(deploy): the installer could never finish, twice over
tests / pest (push) Successful in 6m55s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Successful in 5s
Details
Found by running it on a fresh Debian 12 with systemd, no docker, no git and no users — which is the first time it has ever been run anywhere. `optional_env` ended in `[[ -n "$2" ]] && set_env …`. With an empty value the function returns 1, and under `set -e` that ends the install, silently, with a half-written root-owned .env. Absent is the NORMAL case for those variables — they are the optional ones — so this killed every installation that did not happen to supply a Hetzner token. An `if` returns 0 and does not. Then it waited for `php -v` before migrating. PHP answers in seconds; the entrypoint is still running `composer install` on a fresh checkout, which takes minutes. So `artisan migrate` ran against a checkout with no vendor/ and died on a missing autoload.php. It now waits for vendor/autoload.php, exactly as the dependent containers already do, and says where to look if it never appears. After both: exit 0, 59 migrations applied, the plan catalogue seeded and consistent, the Owner account created with its role, and the portal answering 200 while the console redirects to login and the unconfigured Stripe webhook fails closed with 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e9240c1324 |
feat(deploy): releases you can pin to, and a version that tells the truth
VERSION at the repository root is the release number and the only place it is written down — a file rather than `git describe`, because a source archive, a shallow CI checkout and a container built without .git have no tags to describe. Cutting a release is editing that file and merging it; CI creates the annotated v<VERSION> tag once green, only on the commit that raised the number, and never moves it. Servers are pinned to these. Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an immutable tag, checked out detached: it moves when someone decides it moves. BRANCH=main follows the edge, where CI tags a commit only after it is pushed. The mode is remembered, so re-running the updater on a pinned box does not quietly walk it back onto main. Going backwards is refused, by commit ancestry rather than by comparing version strings — a tag can be cut from anywhere, and only ancestry says whether history is going back. The schema has already moved forward by then, and the migrations needed to reverse it are not in the older checkout at all. What the console reports comes from a manifest written atomically after every step succeeded, never from live git: git says what the files are, not whether the deployment came up. So a failed update keeps reporting the version that is actually serving — including its version number, not the newer one the checkout has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)"; anything else reads "1.0.0-dev (abc1234) · main", because every commit after the tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is how a bug gets filed against the wrong code. Codex reviewed the design before it was written and the result six times after. Its findings, all real: the version had to come from the manifest too, not just the commit; branch names need JSON escaping or the manifest silently vanishes; a `case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a server already sits on skipped recording the mode, and then skipped detaching; a manifest that failed to write would never be repaired; an unparseable timestamp would 500 every admin page; and an empty tag list read as a connectivity failure. 466 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c119e8d96e |
fix(deploy): say which branches exist instead of failing inside git
The installer clones BRANCH, which defaults to main — and the repository has no main. Git's own "Remote branch not found" reads like a bad token or an unreachable server, and the branch is the one thing here that is routinely wrong. It now asks first and, when the branch is missing, prints the ones that do exist and how to pass one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4f3e11ae5e |
docs(deploy): the installer's Stripe steps match what phase 5 actually needs
It still named two webhook events; the application handles six, and the four new ones are the billing cycle itself — renewals, failed payments, status changes and endings. It also said nothing about stripe:sync-catalogue, without which Stripe has no prices and nothing can be sold at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8117630a65 |
feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy: - artisan down now renders a branded page that says an update is running and reloads itself, instead of Laravel's bare 503. - A page left open across a deploy carries a Livewire snapshot and CSRF token the new code rejects — the user got "419 Page Expired" and a dead interface. A 419 from a /livewire/ request now reloads the page; the session is still valid, so that is all it takes. Hooked at the fetch layer rather than through Livewire's request hook, whose failure callback is not invoked for this case in the installed version — verified against a real 419 in the browser, not a simulated one. - Vite no longer empties its output directory: wiping it is what left an open page without its stylesheet mid-deploy, which is the "design is completely broken" symptom. update.sh also rebuilds the caches with optimize:clear + optimize instead of leaving them half-warm. Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only on a green main) and an opt-in act_runner service under the ci profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
ac86efd9c3 |
feat(deploy): unattended install from a single answers file
--env-file makes the whole run non-interactive, which is what a bare server needs: install git, clone, run, done. The file also carries the operational secrets (Hetzner DNS, Stripe, SMTP, the Proxmox key path); those are optional, so a first install can happen before the Stripe account exists and the features stay dark until the values are filled in. The installer warns when the file is readable by more than its owner, and clupilot.env is gitignored — it holds every secret an installation will need. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
2415dde288 |
fix(deploy,traffic): clear caches before restarts, hide the token, widen the lock
- update.sh restarted the long-lived services before clearing the caches, so they booted from the old cached config and kept it for the life of the process — the release's configuration never reached the workers. - install.sh put the Gitea token in the clone URL, which is visible in the process list to every local user while the clone runs. It goes through a short-lived askpass helper now. - The collector's uniqueness lock expired at exactly the collection interval, so a delayed queue could run two collectors on the same baseline and count the same delta twice — throttling a customer for traffic they never used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
25c4c5df5a |
fix(deploy): restart the worker after the hub key, diff against what was deployed
- The provisioning worker boots before install.sh writes CLUPILOT_WG_HUB_PUBKEY and holds the empty value for the life of its process, so every host onboarded afterwards would have got an empty PublicKey in its wg0.conf. - A failed update left the checkout at the target, so the rerun compared a commit with itself and skipped exactly the dependency and image steps that had not finished. The comparison base is now the last commit actually deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
4681b135db |
feat(deploy): run under a dedicated account instead of root
The installer still needs root for apt and the firewall, but it now creates a clupilot service account that owns the checkout and runs Docker, maps the container user to it (HOST_UID/GID), and update.sh refuses to run as root — root-owned files in the checkout are files the application cannot write. The account has no password login: docker group membership is root-equivalent on the host, so it is reachable only via sudo -u or an SSH key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
0219e3c987 |
fix(deploy): recreate the container, keep the root password secret, forget failures
- update.sh recreated the app container only at the very end, so an update that changes the PHP runtime would have installed dependencies and migrated inside the old one. - install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value committed in .env.example — the same root credential on every installation, reachable from any container on the compose network. - Settings cached the fallback after a database failure, so one blip could leave a hidden site publicly visible until someone cleared the cache. Only a successful read is cached now, proven by a test that drops the table and brings it back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
773ef4bd5f |
fix(deploy): migrate behind maintenance mode, resume a failed update, install deps
Codex was right on all three, and the first was a design error on my part: the checkout is bind-mounted into every container, so 'git merge' swaps the running code instantly — 'migrations before traffic' was not achievable by ordering. The updater now goes down → merge → dependencies → migrate → assets → restart → up, trading a short deliberate outage for never serving code whose schema does not exist yet. If a step fails the site STAYS down: coming back up with new code on an old schema is worse than staying dark until someone looks. It also records the last successfully deployed commit. A run that died halfway left the checkout ahead, so the next run said 'already up to date' and skipped the rest forever. And it installs dependencies when a lockfile moved: vendor/ and node_modules/ live in the bind mount and shadow the image, so rebuilding the image never updated them — migrations could run against stale packages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
834abcec40 |
feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git, WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner account and a closed firewall. Re-runnable: it keeps an existing .env and never regenerates the hub key, which would disconnect every onboarded host. update.sh pulls and applies. Migrations run before the new containers take traffic, the image is rebuilt only when its definition changed, and the queue workers are restarted — they are long-running processes that otherwise keep the old classes in memory, which cost us an hour during development. clupilot:create-admin creates or promotes an Owner, so re-running the installer fixes a lost role instead of failing on a taken address. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |