From bc8bbc56a5063c02353dd880b3113348b64948cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 06:51:05 +0200 Subject: [PATCH] Make the console's access list reach the proxy, price in euros, and answer errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ..env.swp | Bin 1024 -> 0 bytes app/Console/Commands/ConsoleAccess.php | 41 +++- app/Livewire/Admin/PlanVersions.php | 38 ++- app/Support/Money.php | 53 +++++ deploy/install-agent.sh | 90 +++++++ deploy/update-agent.sh | 37 +++ deploy/update.sh | 18 +- lang/de/errors.php | 45 ++++ lang/de/plans.php | 2 + lang/de/status.php | 18 +- lang/en/errors.php | 45 ++++ lang/en/plans.php | 2 + lang/en/status.php | 17 +- resources/views/errors/401.blade.php | 6 + resources/views/errors/403.blade.php | 6 + resources/views/errors/404.blade.php | 6 + resources/views/errors/405.blade.php | 6 + resources/views/errors/419.blade.php | 6 + resources/views/errors/429.blade.php | 6 + resources/views/errors/500.blade.php | 6 + resources/views/errors/layout.blade.php | 90 +++++++ .../livewire/admin/plan-versions.blade.php | 4 +- resources/views/livewire/admin/vpn.blade.php | 221 +++++++++--------- resources/views/status.blade.php | 5 +- tests/Feature/Admin/ConsoleNetworkTest.php | 37 +++ tests/Feature/Admin/PlanAdminTest.php | 10 +- tests/Unit/MoneyTest.php | 49 ++++ 27 files changed, 719 insertions(+), 145 deletions(-) delete mode 100644 ..env.swp create mode 100644 app/Support/Money.php create mode 100644 lang/de/errors.php create mode 100644 lang/en/errors.php create mode 100644 resources/views/errors/401.blade.php create mode 100644 resources/views/errors/403.blade.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/405.blade.php create mode 100644 resources/views/errors/419.blade.php create mode 100644 resources/views/errors/429.blade.php create mode 100644 resources/views/errors/500.blade.php create mode 100644 resources/views/errors/layout.blade.php create mode 100644 tests/Unit/MoneyTest.php diff --git a/..env.swp b/..env.swp deleted file mode 100644 index 7569c97585906b62d196b8955a364ea8a25a1955..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1024 zcmYc?$V<%2SFq4CVL$ $this->deny($value), 'open' => $this->setRestricted(false), 'close' => $this->setRestricted(true), + 'caddy' => $this->caddy(), default => $this->refuse("Unknown action: {$action}"), }; } + /** + * The allowlist as a Caddy matcher, for the reverse proxy to import. + * + * The proxy has its own allowlist, hard-coded, and it runs FIRST — so + * everything the owner adds in the console has no effect whatsoever, and + * the console's own access page is a decoration. Worse, when the owner's + * address changes they are turned away by the proxy before the application + * they could have fixed it in is ever reached. + * + * Emitting the matcher from the same list the console manages makes the + * console the single authority. The agent on the host writes this out and + * reloads the proxy. + */ + private function caddy(): int + { + // The switch has to reach the proxy too. With the restriction off the + // application lets everyone through on purpose — and a proxy still + // holding the old list would keep rejecting them before Laravel ever + // sees the request, so "open" in the console would do nothing at all. + $ranges = RestrictConsoleNetwork::isRestricted() + ? RestrictConsoleNetwork::allowedRanges() + : ['0.0.0.0/0', '::/0']; + + // Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and + // the console would become unreachable from anywhere at all — including + // from the place someone would fix it. Loopback always survives, so a + // shell on the box is always a way back. + if ($ranges === []) { + $ranges = ['127.0.0.1', '::1']; + } + + $this->line('# Generated from the console allowlist — do not edit by hand.'); + $this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.'); + $this->line('@allowed remote_ip '.implode(' ', $ranges)); + + return self::SUCCESS; + } + private function show(): int { $this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in')); diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index 19d1486..907e497 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -6,6 +6,7 @@ use App\Models\PlanFamily; use App\Models\PlanVersion; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; +use App\Support\Money; use Illuminate\Support\Carbon; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; @@ -45,9 +46,14 @@ class PlanVersions extends Component /** @var array */ public array $features = []; - public int $monthlyCents = 4900; + /** + * In EUROS, as typed. The catalogue stores cents; asking the owner to type + * them too turned €799 into "79900", where one slipped digit is a factor of + * ten on an invoice. Money::toCents does the conversion, once. + */ + public string $monthlyPrice = '49,00'; - public int $yearlyCents = 58800; + public string $yearlyPrice = '588,00'; /** Publishing: when it goes on sale, and optionally when it stops. */ public string $availableFrom = ''; @@ -80,8 +86,8 @@ class PlanVersions extends Component $monthly = $latest->priceFor(Subscription::TERM_MONTHLY); $yearly = $latest->priceFor(Subscription::TERM_YEARLY); - $this->monthlyCents = $monthly?->amount_cents ?? $this->monthlyCents; - $this->yearlyCents = $yearly?->amount_cents ?? $this->yearlyCents; + $this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900); + $this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800); } } @@ -119,12 +125,24 @@ class PlanVersions extends Component // anything, and an unknown key is frozen at publication and shown // to customers as a raw translation key. 'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))], - // 9,999,999.99 in the catalogue currency — far past anything we - // would charge, far short of overflowing an unsigned int. - 'monthlyCents' => 'required|integer|min:0|max:999999999', - 'yearlyCents' => 'required|integer|min:0|max:999999999', + // Euros with at most two decimals, either separator. The upper + // bound lives in Money's pattern: nine whole digits is far past + // anything we would charge and far short of overflowing the column. + // Checked as a rule, not afterwards: a price fault has to be + // reported alongside every other fault on the form, and a check + // that runs after validate() never runs at all once some other + // field has already thrown. + 'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) { + if (Money::toCents((string) $value) === null) { + $fail(__('plans.price_invalid')); + } + }], + 'yearlyPrice' => ['required', 'string', $price], ]); + $monthlyCents = Money::toCents($data['monthlyPrice']); + $yearlyCents = Money::toCents($data['yearlyPrice']); + $version = app(PlanCatalogue::class)->draft( $this->family(), [ @@ -142,8 +160,8 @@ class PlanVersions extends Component // priced for each — better to find that out here than in front of // a customer. [ - Subscription::TERM_MONTHLY => $data['monthlyCents'], - Subscription::TERM_YEARLY => $data['yearlyCents'], + Subscription::TERM_MONTHLY => $monthlyCents, + Subscription::TERM_YEARLY => $yearlyCents, ], ); diff --git a/app/Support/Money.php b/app/Support/Money.php new file mode 100644 index 0000000..ad778da --- /dev/null +++ b/app/Support/Money.php @@ -0,0 +1,53 @@ + self::MAX_CENTS ? null : $cents; + } + + /** Cents → the string the form shows, with a decimal comma. */ + public static function fromCents(int $cents): string + { + return number_format($cents / 100, 2, ',', ''); + } +} diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index b441b84..54b4740 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -49,6 +49,96 @@ AccuracySec=30s WantedBy=timers.target EOF +# ── The proxy's console allowlist ──────────────────────────────────────────── +# The proxy has its own allowlist and it runs BEFORE the application, so +# everything the owner adds in the console has no effect until the proxy is +# told. The agent regenerates this fragment from the console's own list. +# +# Entirely optional: an installation may sit behind nginx, Zoraxy, or nothing at +# all, and none of that is a reason for the update agent itself to fail to +# install. +CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}" +ALLOWFILE="/etc/caddy/clupilot-console-allow.conf" + +if command -v caddy >/dev/null 2>&1 && [[ -f "$CADDYFILE" ]]; then + echo " Wiring the console allowlist into Caddy" + + # Seed it before anything imports it: an import of a missing file makes the + # whole proxy config invalid, and that takes the public site down too. + ALLOWLIST_READY=0 + + if [[ -s "$ALLOWFILE" ]] && grep -q '@allowed remote_ip .' "$ALLOWFILE"; then + ALLOWLIST_READY=1 + else + # Compose discovers its own file from the working directory — naming it + # here guessed wrong once. + if ( cd "$ROOT" && runuser -u "$APP_USER" -- docker compose exec -T app \ + php artisan clupilot:console-access caddy ) > "$ALLOWFILE.new" 2>/dev/null \ + && grep -q '@allowed remote_ip .' "$ALLOWFILE.new"; then + mv -f "$ALLOWFILE.new" "$ALLOWFILE" + ALLOWLIST_READY=1 + else + # NO fallback matcher here. A loopback-only list looks like a valid + # allowlist, passes validation, and locks out every remote operator + # the moment Caddy reloads — because the application happened to be + # unavailable for the ten seconds this ran. + rm -f "$ALLOWFILE.new" + echo " ! Could not read the console allowlist from the application." + echo " Leaving the proxy configuration untouched." + fi + fi + + [[ -f "$ALLOWFILE" ]] && { chown "$APP_USER":root "$ALLOWFILE"; chmod 0644 "$ALLOWFILE"; } + + # Replace a hard-coded matcher with the import, once. Backed up and + # validated first — a broken Caddyfile is an outage of everything, not just + # of the console. + if [[ "$ALLOWLIST_READY" == "1" ]] && ! grep -q "import $ALLOWFILE" "$CADDYFILE"; then + # Exactly one, or none of them. An unqualified substitution would + # rewrite a matcher belonging to some other site — and the result would + # still validate, so the first sign of it would be that site's access + # control quietly changing. + matches="$(grep -cE '^[[:space:]]*@allowed remote_ip ' "$CADDYFILE" || true)" + + if [[ "$matches" == "1" ]]; then + cp -a "$CADDYFILE" "$CADDYFILE.clupilot.bak" + sed -i "s|^\([[:space:]]*\)@allowed remote_ip .*|\1import $ALLOWFILE|" "$CADDYFILE" + + if caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1; then + if systemctl reload caddy >/dev/null 2>&1; then + echo " Console allowlist is now managed from the console." + else + # Handed to the agent. Without this marker the file already + # matches what the agent would generate, so nothing would + # ever trigger another reload and the proxy would keep the + # old hard-coded list indefinitely. + : > "$ROOT/storage/app/deploy/.caddy-reload-pending" + chown "$APP_USER":"$APP_USER" "$ROOT/storage/app/deploy/.caddy-reload-pending" + echo " ! Caddy did not reload — the agent retries within a few minutes." + fi + else + echo " ! Caddy rejected the change — restoring the previous config." + mv -f "$CADDYFILE.clupilot.bak" "$CADDYFILE" + fi + else + # Said out loud rather than assumed. Nothing to replace, or more + # than one candidate and no way to tell which belongs to the + # console — either way, carrying on silently would leave the + # console's allowlist a decoration while the proxy kept its own. + echo " ! Found $matches single-line '@allowed remote_ip' entries in $CADDYFILE (expected exactly 1)." + echo " Put this inside the console's site block yourself, then reload Caddy:" + echo " import $ALLOWFILE" + fi + fi + + # Narrow on purpose: one command, no arguments, nothing else. + cat > /etc/sudoers.d/clupilot-caddy-reload </dev/null || rm -f /etc/sudoers.d/clupilot-caddy-reload +fi + systemctl daemon-reload systemctl enable --now clupilot-update-agent.timer >/dev/null diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 79b295f..d871867 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -32,6 +32,12 @@ RUNLOG="$STATE_DIR/update-last-run.log" # "idle" — and an operator would usually never see that the update failed. LASTRUN="$STATE_DIR/update-last-run.json" LOCK="$STATE_DIR/.agent.lock" +# The reverse proxy's console allowlist, generated from the one the owner keeps +# in the console. Without this the proxy has its own hard-coded list that runs +# FIRST, so everything added in the console has no effect at all — and when the +# owner's address changes they are turned away before reaching the page that +# would have let them fix it. +ALLOWFILE="${CONSOLE_ALLOW_FILE:-/etc/caddy/clupilot-console-allow.conf}" # A request older than this is abandoned. The panel already refuses to show one # as pending; the agent has to agree, or a request written while the agent was @@ -45,6 +51,37 @@ mkdir -p "$STATE_DIR" exec 9>"$LOCK" flock -n 9 || exit 0 +sync_console_allowlist() { + [[ -w "$(dirname "$ALLOWFILE")" || -w "$ALLOWFILE" ]] || return 0 + + local generated + generated="$(docker compose exec -T app php artisan clupilot:console-access caddy 2>/dev/null)" || return 0 + # Never write an empty matcher: in Caddy that matches nothing, and the + # console would be unreachable from everywhere including the shell. + grep -q '@allowed remote_ip .' <<<"$generated" || return 0 + + local pending="$STATE_DIR/.caddy-reload-pending" + + if [[ ! -f "$ALLOWFILE" ]] || ! diff -q <(printf '%s\n' "$generated") "$ALLOWFILE" >/dev/null 2>&1; then + printf '%s\n' "$generated" > "$ALLOWFILE" + : > "$pending" + fi + + # A reload that failed once must be retried. Without the marker the next + # run sees an unchanged file, does nothing, and the proxy keeps the old + # list indefinitely — the console and the proxy quietly disagreeing is the + # exact failure this whole mechanism exists to prevent. + if [[ -f "$pending" ]]; then + # Reload, never restart: a restart drops every connection in flight, + # including the one belonging to whoever just changed the list. + if systemctl reload caddy >/dev/null 2>&1 || sudo -n systemctl reload caddy >/dev/null 2>&1; then + rm -f "$pending" + fi + fi +} + +sync_console_allowlist + MODE="$(release_mode)" BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" TARGET_RELEASE='' diff --git a/deploy/update.sh b/deploy/update.sh index b869626..b9ec9e3 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -223,10 +223,24 @@ release_write_manifest "$after" "$source_ref" "$mode" # The panel's update button needs a host-side timer, and this script cannot # install it: it runs as the service account and /etc/systemd is root's. Say so # once per update rather than leaving the button disabled without explanation. +agent_hint='' if ! systemctl list-unit-files clupilot-update-agent.timer >/dev/null 2>&1 \ || ! systemctl is-enabled --quiet clupilot-update-agent.timer 2>/dev/null; then - printf '\033[1;33m !\033[0m %s\n' "Update agent not installed — the panel's update button stays disabled." - printf ' %s\n' "Install it once: sudo bash $(pwd)/deploy/install-agent.sh" + agent_hint="the panel's update button stays disabled" +fi + +# The proxy's console allowlist has to be wired once, as root, and an update +# never runs as root — so an existing installation would keep its hard-coded +# list and everything the owner changes in the console would do nothing. +if [[ -z "$agent_hint" ]] && command -v caddy >/dev/null 2>&1 \ + && [[ -f /etc/caddy/Caddyfile ]] \ + && ! grep -q 'clupilot-console-allow.conf' /etc/caddy/Caddyfile 2>/dev/null; then + agent_hint="the console's access list does not reach the reverse proxy" +fi + +if [[ -n "$agent_hint" ]]; then + printf '\033[1;33m !\033[0m %s\n' "One-time setup missing — $agent_hint." + printf ' %s\n' "Run once: sudo bash $(pwd)/deploy/install-agent.sh" fi log "Done — $(release_version) on $(git rev-parse --short HEAD) (${source_ref})" diff --git a/lang/de/errors.php b/lang/de/errors.php new file mode 100644 index 0000000..71fee3e --- /dev/null +++ b/lang/de/errors.php @@ -0,0 +1,45 @@ + 'Das hat nicht geklappt', + 'back' => 'Zurück', + 'home' => 'Zur Startseite', + + // Deliberately plain, and never technical: whoever reads this is already + // stuck, and "Unprocessable Entity" has never helped anyone get unstuck. + '401' => [ + 'title' => 'Bitte zuerst anmelden.', + 'body' => 'Diese Seite ist nur nach der Anmeldung erreichbar.', + 'hint' => 'Falls Sie angemeldet waren: Ihre Sitzung ist abgelaufen.', + ], + '403' => [ + 'title' => 'Dafür fehlt die Berechtigung.', + 'body' => 'Ihr Konto darf diese Seite nicht öffnen.', + 'hint' => 'Wenn das ein Irrtum ist, wenden Sie sich an den Inhaber des Kontos.', + ], + '404' => [ + 'title' => 'Diese Seite gibt es nicht.', + 'body' => 'Die Adresse führt ins Leere — vertippt, veraltet oder verschoben.', + 'hint' => null, + ], + '405' => [ + 'title' => 'So lässt sich diese Seite nicht aufrufen.', + 'body' => 'Die Anfrage passt nicht zu dieser Adresse.', + 'hint' => null, + ], + '419' => [ + 'title' => 'Die Sitzung ist abgelaufen.', + 'body' => 'Aus Sicherheitsgründen wird ein Formular nach längerer Zeit ungültig.', + 'hint' => 'Laden Sie die Seite neu und senden Sie das Formular noch einmal ab.', + ], + '429' => [ + 'title' => 'Zu viele Versuche.', + 'body' => 'Bitte warten Sie einen Moment, bevor Sie es erneut versuchen.', + 'hint' => null, + ], + '500' => [ + 'title' => 'Bei uns ist etwas schiefgegangen.', + 'body' => 'Der Fehler liegt nicht bei Ihnen. Er wurde protokolliert.', + 'hint' => 'Bleibt es dabei, schreiben Sie an office@clupilot.com.', + ], +]; diff --git a/lang/de/plans.php b/lang/de/plans.php index c90658e..817065b 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -84,4 +84,6 @@ return [ 'f_cores' => 'vCPU', 'f_disk' => 'Festplatte', 'f_template' => 'Vorlage (VMID)', + 'euro_hint' => 'In Euro, netto — z. B. 799 oder 79,90.', + 'price_invalid' => 'Bitte einen Betrag in Euro angeben, z. B. 799 oder 79,90.', ]; diff --git a/lang/de/status.php b/lang/de/status.php index 1ef0819..e5788c1 100644 --- a/lang/de/status.php +++ b/lang/de/status.php @@ -19,17 +19,21 @@ return [ ], 'component' => [ - 'portal' => 'Website & Kundenportal', - 'instances' => 'Cloud-Instanzen', - 'provisioning' => 'Bereitstellung neuer Instanzen', + 'portal' => 'Anmeldung & Kundenbereich', + 'instances' => 'Ihre Cloud', + 'provisioning' => 'Neue Bestellungen', 'backups' => 'Sicherungen', ], + // What is MEASURED, not what is promised. "Wird ohne Fehler ausgeliefert" + // read as a marketing claim on a page whose only job is to be believed — + // and it says nothing about the last 24 hours, which is what a reader is + // actually asking about. 'about' => [ - 'portal' => 'Erreichbarkeit von www.clupilot.com und dem Kundenbereich.', - 'instances' => 'Erreichbarkeit der betriebenen Kundeninstanzen.', - 'provisioning' => 'Neue Bestellungen werden ohne Fehler ausgeliefert.', - 'backups' => 'Jede Instanz hat eine Sicherung aus den letzten 48 Stunden.', + 'portal' => 'Ob Anmeldung und Kundenbereich antworten.', + 'instances' => 'Ob die laufenden Kundeninstanzen erreichbar sind.', + 'provisioning' => 'Ob Bestellungen der letzten 24 Stunden vollständig ausgeliefert wurden.', + 'backups' => 'Ob jede Instanz eine Sicherung aus den letzten 48 Stunden hat.', ], 'detail' => [ diff --git a/lang/en/errors.php b/lang/en/errors.php new file mode 100644 index 0000000..1d7ae45 --- /dev/null +++ b/lang/en/errors.php @@ -0,0 +1,45 @@ + 'That did not work', + 'back' => 'Back', + 'home' => 'To the home page', + + // Deliberately plain, and never technical: whoever reads this is already + // stuck, and "Unprocessable Entity" has never helped anyone get unstuck. + '401' => [ + 'title' => 'Please sign in first.', + 'body' => 'This page is only available once you are signed in.', + 'hint' => 'If you were signed in, your session has expired.', + ], + '403' => [ + 'title' => 'Your account may not open this.', + 'body' => 'This page needs a permission your account does not have.', + 'hint' => 'If that seems wrong, ask whoever owns the account.', + ], + '404' => [ + 'title' => 'This page does not exist.', + 'body' => 'The address leads nowhere — mistyped, out of date, or moved.', + 'hint' => null, + ], + '405' => [ + 'title' => 'This page cannot be reached that way.', + 'body' => 'The request does not match this address.', + 'hint' => null, + ], + '419' => [ + 'title' => 'The session has expired.', + 'body' => 'For security, a form stops being valid after a while.', + 'hint' => 'Reload the page and submit the form again.', + ], + '429' => [ + 'title' => 'Too many attempts.', + 'body' => 'Please wait a moment before trying again.', + 'hint' => null, + ], + '500' => [ + 'title' => 'Something went wrong on our side.', + 'body' => 'This is not your fault. It has been logged.', + 'hint' => 'If it keeps happening, write to office@clupilot.com.', + ], +]; diff --git a/lang/en/plans.php b/lang/en/plans.php index 76a06a8..4bde3f3 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -84,4 +84,6 @@ return [ 'f_cores' => 'vCPU', 'f_disk' => 'Disk', 'f_template' => 'Template (VMID)', + 'euro_hint' => 'In euros, net — e.g. 799 or 79.90.', + 'price_invalid' => 'Enter an amount in euros, e.g. 799 or 79.90.', ]; diff --git a/lang/en/status.php b/lang/en/status.php index c13941c..b086045 100644 --- a/lang/en/status.php +++ b/lang/en/status.php @@ -19,17 +19,20 @@ return [ ], 'component' => [ - 'portal' => 'Website & customer portal', - 'instances' => 'Cloud instances', - 'provisioning' => 'Provisioning of new instances', + 'portal' => 'Sign-in & customer area', + 'instances' => 'Your cloud', + 'provisioning' => 'New orders', 'backups' => 'Backups', ], + // What is MEASURED, not what is promised. "Delivered without failures" read + // as a marketing claim on a page whose only job is to be believed — and it + // says nothing about the last 24 hours, which is what a reader is asking. 'about' => [ - 'portal' => 'Availability of www.clupilot.com and the customer area.', - 'instances' => 'Availability of the customer instances we operate.', - 'provisioning' => 'New orders are being delivered without failures.', - 'backups' => 'Every instance has a backup from the last 48 hours.', + 'portal' => 'Whether sign-in and the customer area respond.', + 'instances' => 'Whether the customer instances we run are reachable.', + 'provisioning' => 'Whether orders in the last 24 hours were delivered in full.', + 'backups' => 'Whether every instance has a backup from the last 48 hours.', ], 'detail' => [ diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php new file mode 100644 index 0000000..76ea492 --- /dev/null +++ b/resources/views/errors/401.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 401, + 'title' => __('errors.401.title'), + 'body' => __('errors.401.body'), + 'hint' => __('errors.401.hint'), +]) diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..72c0f39 --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 403, + 'title' => __('errors.403.title'), + 'body' => __('errors.403.body'), + 'hint' => __('errors.403.hint'), +]) diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 0000000..05b9a59 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 404, + 'title' => __('errors.404.title'), + 'body' => __('errors.404.body'), + 'hint' => __('errors.404.hint'), +]) diff --git a/resources/views/errors/405.blade.php b/resources/views/errors/405.blade.php new file mode 100644 index 0000000..0aa0757 --- /dev/null +++ b/resources/views/errors/405.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 405, + 'title' => __('errors.405.title'), + 'body' => __('errors.405.body'), + 'hint' => __('errors.405.hint'), +]) diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php new file mode 100644 index 0000000..6168a92 --- /dev/null +++ b/resources/views/errors/419.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 419, + 'title' => __('errors.419.title'), + 'body' => __('errors.419.body'), + 'hint' => __('errors.419.hint'), +]) diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php new file mode 100644 index 0000000..8a898f8 --- /dev/null +++ b/resources/views/errors/429.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 429, + 'title' => __('errors.429.title'), + 'body' => __('errors.429.body'), + 'hint' => __('errors.429.hint'), +]) diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 0000000..d31e4e9 --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,6 @@ +@include('errors.layout', [ + 'code' => 500, + 'title' => __('errors.500.title'), + 'body' => __('errors.500.body'), + 'hint' => __('errors.500.hint'), +]) diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php new file mode 100644 index 0000000..ca24079 --- /dev/null +++ b/resources/views/errors/layout.blade.php @@ -0,0 +1,90 @@ +@props(['code', 'title', 'body', 'hint' => null]) + + + + + + +{{ $title }} — CluPilot Cloud + + +{{-- + Self-contained, like the maintenance page and for the same reason: this is + shown when something has already gone wrong, which includes "mid-deploy" and + "the asset manifest is missing". Pulling styles in through @vite would throw + a second exception on top of the first and render nothing at all. + + The fonts are the site's own, served as static files — if they are missing + the page degrades to a system serif and still says what it needs to say. +--}} +@verbatim + +@endverbatim + + +
+
+
{{ __('errors.heading') }}{{ $code }}
+
+

{{ $title }}

+

{{ $body }}

+ @if ($hint) +

{{ $hint }}

+ @endif +
+ {{-- Back first: on an error page the thing someone actually wants is + the page they came from, not the front door. --}} + {{ __('errors.back') }} + {{ __('errors.home') }} +
+
+
+

CluPilot Cloud

+
+ + diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index 3e778f1..4bd5f9c 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -172,8 +172,8 @@

{{ __('plans.pricing') }}

- - + +

{{ __('plans.features') }}

diff --git a/resources/views/livewire/admin/vpn.blade.php b/resources/views/livewire/admin/vpn.blade.php index 4eca8c6..b188f5c 100644 --- a/resources/views/livewire/admin/vpn.blade.php +++ b/resources/views/livewire/admin/vpn.blade.php @@ -72,118 +72,121 @@ @if ($peers->isEmpty())

{{ __('vpn.empty') }}

@else -
- - - - - - - - - - - - - - @foreach ($peers as $peer) - @php - $status = $peer->status(); - $tone = [ - 'online' => 'border-success-border bg-success-bg text-success', - 'idle' => 'border-line-strong bg-surface-2 text-muted', - 'blocked' => 'border-danger-border bg-danger-bg text-danger', - 'pending' => 'border-warning-border bg-warning-bg text-warning', - ][$status]; - @endphp - - - - - - - - - - @endforeach - -
{{ __('vpn.peer') }}{{ __('vpn.owner') }}{{ __('vpn.address') }}{{ __('vpn.traffic') }}{{ __('vpn.handshake') }}{{ __('vpn.status') }}{{ __('vpn.actions') }}
-
{{ $peer->name }}
-
- @if ($peer->host) - {{ __('vpn.host_peer') }} - @else - {{ __('vpn.operator_peer') }} - @endif -
-
- @if ($peer->owner) - {{ $peer->owner->name }} - @if ($peer->user_id === auth()->id()) - {{ __('vpn.you') }} - @endif - @else - {{ $peer->host ? __('vpn.host_peer') : __('vpn.no_owner') }} + {{-- + A register of accesses, not a spreadsheet. + + Seven columns in a fixed table left every one of them too + narrow: names broke across two lines, "Letzter Kontakt" + wrapped in its own heading, and the row still did not fit. + These are attributes of ONE access, not quantities anyone + compares down a column — so each access gets a row of its + own with the identity on the first line and the measurements + on the second, in mono where they line up by themselves. + --}} +
    + @foreach ($peers as $peer) + @php + $status = $peer->status(); + $tone = [ + 'online' => 'border-success-border bg-success-bg text-success', + 'idle' => 'border-line-strong bg-surface-2 text-muted', + 'blocked' => 'border-danger-border bg-danger-bg text-danger', + 'pending' => 'border-warning-border bg-warning-bg text-warning', + ][$status]; + // The rule carries the status too, so a blocked access is + // visible while scanning without reading a single pill. + $rule = [ + 'online' => 'bg-success-bright', + 'idle' => 'bg-line-strong', + 'blocked' => 'bg-danger', + 'pending' => 'bg-warning', + ][$status]; + @endphp +
  • + + +
    +
    + {{ $peer->name }} + + + + {{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }} + + + @if ($peer->owner) + {{ $peer->owner->name }} + @if ($peer->user_id === auth()->id()) + {{ __('vpn.you') }} @endif -
-
{{ $peer->allowed_ip }}
- @if ($peer->endpoint) -
{{ $peer->endpoint }}
- @endif -
- ↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}
- ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }} -
- {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} - - - - {{ __('vpn.status_'.$status) }} + @elseif (! $peer->host) + {{ __('vpn.no_owner') }} + @endif + + + {{-- The measurements, each with its own label: without a + header row they have to name themselves. --}} +
+ {{ $peer->allowed_ip }} + @if ($peer->endpoint) + {{ $peer->endpoint }} + @endif + ↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}  ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }} + {{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} +
+ + +
+ + + {{ __('vpn.status_'.$status) }} + + +
+ {{-- A stored config can be fetched again; without one there is + nothing to hand out, and saying so beats an absent button. --}} + @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id()) + + -
-
- {{-- A stored config can be fetched again; without one there is - nothing to hand out, and saying so beats an absent button. --}} - @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id()) - - - - @endif - @can('downloadConfig', $peer) - - @endcan - @can('block', $peer) - + @endcan + @can('block', $peer) + + @endcan + @can('update', $peer) + @if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF) + - @endcan - @can('update', $peer) - @if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF) - - @endif - @endcan - @can('delete', $peer) - - @endcan -
-
-
+ @endif + @endcan + @can('delete', $peer) + + @endcan + + + + @endforeach + @endif diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php index f78d545..6fe0d46 100644 --- a/resources/views/status.blade.php +++ b/resources/views/status.blade.php @@ -112,8 +112,9 @@

Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr

- Diese Seite wird bei jedem Aufruf neu ermittelt — aus Überwachung, Sicherungsprotokoll und - Bereitstellungsläufen. Wo nichts überwacht wird, steht das auch so da und nicht „in Betrieb“. + Jede Zeile wird bei jedem Aufruf neu gemessen — aus Überwachung, Sicherungsprotokoll und + Bereitstellungsläufen. Wo gerade nichts gemessen wird, steht „unbekannt“ und nicht „in Betrieb“: + eine fehlende Messung ist kein Nachweis, dass alles läuft. Eine Störung, die Sie hier nicht sehen, melden Sie bitte an office@clupilot.com — Antwort am selben Werktag.

diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php index 3c9e7c4..9f8c219 100644 --- a/tests/Feature/Admin/ConsoleNetworkTest.php +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -129,3 +129,40 @@ it('offers a way back in that does not depend on the console', function () { $this->artisan('clupilot:console-access open')->assertSuccessful(); expect(RestrictConsoleNetwork::isRestricted())->toBeFalse(); }); + +it('tells the reverse proxy the same thing it tells itself', function () { + // The proxy has its own allowlist and it runs FIRST. If the two disagree, + // everything the owner changes in the console is silently ineffective — + // and when their address changes they are turned away before reaching the + // page that would have fixed it. + Settings::set('console.network_restricted', true); + Settings::set('console.allowed_ips', ['203.0.113.7']); + + // One assertion on the whole line: the ranges have to arrive together, in + // the matcher, not merely appear somewhere in the output. + $this->artisan('clupilot:console-access caddy') + ->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7') + ->assertSuccessful(); +}); + +it('opens the proxy too when the restriction is lifted', function () { + // Otherwise "open" in the console changes nothing a visitor can feel: the + // proxy keeps rejecting them before the application is reached. + Settings::set('console.network_restricted', false); + + $this->artisan('clupilot:console-access caddy') + ->expectsOutputToContain('0.0.0.0/0') + ->assertSuccessful(); +}); + +it('never emits a matcher that lets nobody in', function () { + // An empty remote_ip matcher matches NOTHING in Caddy — the console would + // be unreachable from everywhere, including from where it gets fixed. + config()->set('admin_access.trusted_ranges', []); + Settings::set('console.network_restricted', true); + Settings::set('console.allowed_ips', []); + + $this->artisan('clupilot:console-access caddy') + ->expectsOutputToContain('127.0.0.1') + ->assertSuccessful(); +}); diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 4b7fc79..77ada69 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -86,9 +86,9 @@ it('drafts a version prefilled from the last one, priced for both terms', functi // Prefilled, so "same plan, new price" is one edit rather than nine. $page->assertSet('ramMb', 8192) ->assertSet('quotaGb', 500) - ->assertSet('monthlyCents', 17900); + ->assertSet('monthlyPrice', '179,00'); - $page->set('monthlyCents', 19900)->set('yearlyCents', 238800)->call('draft')->assertHasNoErrors(); + $page->set('monthlyPrice', '199,00')->set('yearlyPrice', '2388,00')->call('draft')->assertHasNoErrors(); $draft = teamFamily()->versions()->where('version', 2)->sole(); @@ -105,10 +105,10 @@ it('refuses a mistyped figure with a message instead of overflowing the column', // A stray keystroke on the price field: unbounded, this overflows an // unsigned int and the owner gets a 500 with no idea which number // was wrong. - ->set('monthlyCents', 1790019900) + ->set('monthlyPrice', 'neunzehn Euro') ->set('ramMb', 999999999) ->call('draft') - ->assertHasErrors(['monthlyCents', 'ramMb']); + ->assertHasErrors(['monthlyPrice', 'ramMb']); expect(teamFamily()->versions()->count())->toBe(1); }); @@ -151,7 +151,7 @@ it('refuses a disk smaller than the storage it has to hold', function () { it('publishes a draft into a window, and refuses one that overlaps', function () { $page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); - $page->set('monthlyCents', 19900)->call('draft'); + $page->set('monthlyPrice', '199,00')->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); diff --git a/tests/Unit/MoneyTest.php b/tests/Unit/MoneyTest.php new file mode 100644 index 0000000..dcd661d --- /dev/null +++ b/tests/Unit/MoneyTest.php @@ -0,0 +1,49 @@ +toBe($cents); +})->with([ + ['799', 79900], + ['79,90', 7990], // German keyboard + ['79.90', 7990], // copied from somewhere + ['0', 0], + ['0,05', 5], + [' 149 ', 14900], + ['79,9', 7990], // one decimal is still a price + ['', null], + ['neunzehn', null], + ['1.000', null], // a thousand, or one euro? refuse rather than guess + ['79,999', null], // no fractions of a cent + ['-5', null], + // Parses fine, then overflows the unsigned column — a typo must come back + // as a message beside the field, not as a database error on submit. + ['999999999', null], + ['42949672,96', null], + ['42949672,95', 4294967295], +]); + +it('does not lose a cent to binary floating point', function () { + // (float) '79.90' * 100 is 7989.999… and casting truncates to 7989 — one + // cent short, on exactly the prices people charge. + foreach (['79,90', '19,90', '0,29', '1234,56'] as $typed) { + expect(Money::toCents($typed))->toBe((int) round((float) str_replace(',', '.', $typed) * 100), $typed); + } + + expect(Money::toCents('79,90'))->toBe(7990) + ->and(Money::toCents('0,29'))->toBe(29); +}); + +it('round-trips through the form', function () { + foreach ([0, 5, 7990, 79900, 958800] as $cents) { + expect(Money::toCents(Money::fromCents($cents)))->toBe($cents); + } +});