diff --git a/..env.swp b/..env.swp deleted file mode 100644 index 7569c97..0000000 Binary files a/..env.swp and /dev/null differ diff --git a/app/Console/Commands/ConsoleAccess.php b/app/Console/Commands/ConsoleAccess.php index 01a1b4b..dca514a 100644 --- a/app/Console/Commands/ConsoleAccess.php +++ b/app/Console/Commands/ConsoleAccess.php @@ -17,7 +17,7 @@ use Illuminate\Console\Command; class ConsoleAccess extends Command { protected $signature = 'clupilot:console-access - {action=show : show|allow|deny|open|close} + {action=show : show|allow|deny|open|close|caddy} {value? : an address or CIDR, for allow and deny}'; protected $description = 'Show or change who may reach the operator console'; @@ -33,10 +33,49 @@ class ConsoleAccess extends Command 'deny' => $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); + } +});