diff --git a/app/Livewire/Admin/PaymentProblems.php b/app/Livewire/Admin/PaymentProblems.php index 5b126aa..560bc54 100644 --- a/app/Livewire/Admin/PaymentProblems.php +++ b/app/Livewire/Admin/PaymentProblems.php @@ -49,7 +49,15 @@ class PaymentProblems extends Component $this->days = DunningSchedule::days(); $this->feeFromLevel = DunningSchedule::feeFromLevel(); - $this->fees = [2 => DunningSchedule::feeCents(2), 3 => DunningSchedule::feeCents(3)]; + // In EURO ins Formular, in Cent in die Datenbank. + // + // Hier stand Cent, und im Feld stand „500". Wer eine Mahngebühr + // einträgt, denkt in Euro — und tippt „5". Aus fünf Euro wurden damit + // fünf Cent, ohne dass irgendetwas widersprochen hätte. + $this->fees = [ + 2 => self::centsToEuro(DunningSchedule::feeCents(2)), + 3 => self::centsToEuro(DunningSchedule::feeCents(3)), + ]; } /** @@ -61,6 +69,20 @@ class PaymentProblems extends Component * sehen, statt eine andere Reihenfolge gespeichert zu bekommen als die, * die er gelesen hat. */ + /** Cent → das, was im Feld stehen soll: „5" statt „5,00", aber „5,50" wenn nötig. */ + private static function centsToEuro(int $cents): string + { + return $cents % 100 === 0 + ? (string) intdiv($cents, 100) + : number_format($cents / 100, 2, ',', ''); + } + + /** Was im Feld steht → Zahl. Komma und Punkt gelten beide als Dezimaltrenner. */ + private static function euroToFloat(string $eingabe): float + { + return (float) str_replace(',', '.', trim($eingabe)); + } + public function saveSchedule(): void { $this->authorize('billing.manage'); @@ -69,7 +91,9 @@ class PaymentProblems extends Component 'days' => ['required', 'array', 'size:5'], 'days.*' => ['required', 'integer', 'min:0', 'max:365'], 'feeFromLevel' => ['required', 'integer', 'min:1', 'max:3'], - 'fees.*' => ['required', 'integer', 'min:0', 'max:100000'], + // Euro, mit Komma oder Punkt, höchstens zwei Nachkommastellen. + // 1000,00 € ist die Obergrenze — dieselbe wie vorher, nur lesbar. + 'fees.*' => ['required', 'regex:/^\\d{1,4}([.,]\\d{1,2})?$/'], ]); $tage = array_map('intval', array_values($this->days)); @@ -82,7 +106,13 @@ class PaymentProblems extends Component Settings::set(DunningSchedule::DAYS, $tage); Settings::set(DunningSchedule::FEE_FROM_LEVEL, (int) $this->feeFromLevel); - Settings::set(DunningSchedule::FEES, array_map('intval', $this->fees)); + // Gerundet, nicht abgeschnitten: (int) (19.99 * 100) ist in PHP 1998, + // weil 19.99 als Gleitkommazahl knapp darunter liegt. Ein Cent, der bei + // jedem Speichern verschwindet, fällt erst nach Monaten auf. + Settings::set(DunningSchedule::FEES, array_map( + fn ($euro) => (int) round(self::euroToFloat((string) $euro) * 100), + $this->fees, + )); $this->dispatch('notify', message: __('payment_problems.schedule_saved')); } diff --git a/app/Livewire/Admin/Provisioning.php b/app/Livewire/Admin/Provisioning.php index 23556da..b617c31 100644 --- a/app/Livewire/Admin/Provisioning.php +++ b/app/Livewire/Admin/Provisioning.php @@ -128,7 +128,18 @@ class Provisioning extends Component 'customer' => $this->subjectLabel($r), 'pipeline' => $r->pipeline, 'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r), - 'n' => ($r->current_step + 1).'/'.$total, + // Bei einem fertigen Lauf dasselbe wie die Karte daneben. + // + // Hier stand `current_step + 1`, und das liest sich als + // „dieser Schritt läuft gerade" — richtig, solange einer + // läuft. Ist der Lauf durch, läuft keiner mehr, und die + // Zeile behauptete trotzdem einen. In der Liste stand dann + // 15/16, in der Karte daneben „16 von 16 abgeschlossen", bei + // Status „Fertig" und 100 %. Zwei Zahlen für denselben Lauf, + // und keine davon falsch genug, um aufzufallen. + 'n' => ($r->status === ProvisioningRun::STATUS_COMPLETED + ? $total + : min($r->current_step + 1, $total)).'/'.$total, 'percent' => (int) round($done / $total * 100), 'attempt' => $r->attempt, 'state' => $this->runState($r), diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 31c6cf7..b190b49 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -627,7 +627,19 @@ class Settings extends Component // shown so the consequence of a change is visible before it is made. 'consoleRestricted' => RestrictConsoleNetwork::isRestricted(), 'consoleIps' => (array) AppSettings::get('console.allowed_ips', []), - 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), + // Getrennt, nicht in einen Topf: nur das Management-Netz und der + // eigene Rechner sind wirklich unveraenderlich. Was sonst in + // TRUSTED_RANGES steht, hat jemand von Hand eingetragen und muss + // auch von Hand wieder heraus — die Oberflaeche sagt das jetzt, + // statt es als „nicht entfernbar" auszugeben. + 'consoleVpnRanges' => array_values(array_intersect( + (array) config('admin_access.trusted_ranges', []), + (array) config('admin_access.structural_ranges', []), + )), + 'consoleEnvRanges' => array_values(array_diff( + (array) config('admin_access.trusted_ranges', []), + (array) config('admin_access.structural_ranges', []), + )), 'viewerIp' => (string) request()->ip(), 'staff' => $staff, 'roles' => Operator::operatorRoles(), diff --git a/config/admin_access.php b/config/admin_access.php index e40caa5..9be07a7 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -179,4 +179,19 @@ return [ explode(',', (string) env('TRUSTED_RANGES', '10.66.0.0/24,127.0.0.1')), ))), + /* + | Die zwei Einträge, die STRUKTURELL sind: das Management-Netz und der + | eigene Rechner. Nur die dürfen in der Konsole als „nicht entfernbar" + | dastehen. + | + | Alles andere, was jemand in TRUSTED_RANGES geschrieben hat, ist eine + | Eintragung von Hand — und die gehört in die Liste, die der Betreiber in + | der Konsole selbst pflegen kann. Der Unterschied war unsichtbar: eine + | Büroadresse, die einmal in der .env gelandet war, stand in der Oberfläche + | neben dem VPN-Netz mit demselben Vermerk „nicht entfernbar" — und der + | Betreiber hätte sie beim nächsten Umzug nicht mehr ändern können, ohne zu + | wissen, dass sie überhaupt aus einer Datei kommt. + */ + 'structural_ranges' => ['10.66.0.0/24', '127.0.0.1'], + ]; diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index b6808d8..da15f98 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -261,6 +261,54 @@ Unit=clupilot-update-agent.service WantedBy=paths.target EOF +# ── Der Waechter ───────────────────────────────────────────────────────────── +# +# Jede Minute nachsehen, ob steht, was stehen soll — und richten, was nicht +# steht. Eigener Dienst, nicht Teil des Update-Agenten: der Agent macht etwas, +# wenn er gefragt wird, der Waechter, wenn niemand hinsieht. +# +# Auf dem WIRT, nicht in einem Container. Ein Waechter im Container braeuchte den +# Docker-Socket hineingereicht — das ist Root auf dem Wirt fuer jeden, der je in +# diesen Container kommt — und waere genau dann tot, wenn man ihn braucht, +# naemlich wenn der Stapel unten liegt. +# +# Er nimmt dieselbe Sperre wie der Agent und geht weg, wenn er sie nicht +# bekommt; waehrend eines Updates fasst er also nichts an. +cat > /etc/systemd/system/clupilot-watchdog.service < /etc/systemd/system/clupilot-watchdog.service.d/rate-limit.conf <<'EOF' +[Unit] +StartLimitIntervalSec=0 +EOF + +cat > /etc/systemd/system/clupilot-watchdog.timer <<'EOF' +[Unit] +Description=Nachsehen, ob der CluPilot-Stapel steht — und richten, was nicht steht + +[Timer] +# Zwei Minuten nach dem Booten, dann jede Minute. Der Bootabstand ist wichtig: +# direkt nach dem Start sind Container noch am Hochfahren, und ein Waechter, der +# das fuer einen Ausfall haelt, greift in einen normalen Start ein. +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=10s + +[Install] +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 @@ -486,6 +534,7 @@ fi systemctl daemon-reload systemctl enable --now clupilot-update-agent.timer >/dev/null +systemctl enable --now clupilot-watchdog.timer >/dev/null systemctl enable --now clupilot-update-agent.path >/dev/null # Run it once now, so the panel has a status to show instead of "never reported @@ -493,6 +542,7 @@ systemctl enable --now clupilot-update-agent.path >/dev/null systemctl start clupilot-update-agent.service || true echo "Update agent installed and running (clupilot-update-agent.timer)." +echo "Watchdog installed and running (clupilot-watchdog.timer) — checks every minute." # ── Die von der Konsole verwalteten Hostnamen ──────────────────────────────── # Die Datei MUSS existieren, bevor irgendetwas sie importiert: ein Import ins diff --git a/deploy/update.sh b/deploy/update.sh index 98217a7..ef5179f 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -84,7 +84,13 @@ warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } # A step, announced to the terminal and recorded for the console. phase() { local key="$1"; shift - mkdir -p "$(dirname "$PHASE_FILE")" + # `|| true`, wie beim Schreiben darunter: gehoert storage/ nach einem + # frueheren Fehltritt root, scheitert das mkdir — und mit `set -e` starb das + # ganze Update an seiner ERSTEN Zeile, ohne eine einzige Ausgabe. Von aussen + # sah das aus wie "haengengeblieben"; in Wahrheit war es nach einer + # Millisekunde vorbei. Eine fehlende Fortschrittsanzeige ist kein Grund, + # ein Deployment abzubrechen. + mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true # Not atomic on purpose: this is a hint for a progress line, and a torn read # costs a single poll. Writing a temp file per step would be more moving # parts than the thing is worth. @@ -398,7 +404,34 @@ if [[ "$before" == "$target" && "$deployed" == "$target" ]]; then else log "Already up to date ($(git rev-parse --short HEAD), ${source_ref})" fi - exit 0 + + # ABER: derselbe Commit heisst nicht, dass der letzte Lauf FERTIG geworden + # ist. Bricht ein Update mittendrin ab, steht der Checkout schon auf dem + # Ziel — und ein zweiter Aufruf ging bis hierher davon aus, es sei alles + # erledigt, und tat gar nichts. Genau so stand ein Server eine Stunde lang + # halb unten, waehrend `bash deploy/update.sh` freundlich "Already up to + # date" meldete. + # + # Deshalb wird hier nicht der Commit gefragt, sondern der Zustand: laeuft + # jeder Dienst, und ist der Wartungsmodus aus? Wenn nein, laeuft der Rest + # dieses Skripts trotzdem und richtet es. + unhealthy="" + soll_services="$(docker compose config --services 2>/dev/null | sort || true)" + ist_services="$(docker compose ps --services --status running 2>/dev/null | sort || true)" + if [[ -n "$soll_services" ]]; then + fehlende="$(comm -23 <(printf '%s\n' "$soll_services") <(printf '%s\n' "$ist_services") | tr '\n' ' ' | sed 's/ *$//')" + [[ -n "$fehlende" ]] && unhealthy="es fehlen Dienste: $fehlende" + fi + if [[ -z "$unhealthy" ]] && docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then + unhealthy="der Wartungsmodus ist noch an" + fi + + if [[ -z "$unhealthy" ]]; then + exit 0 + fi + + warn "Derselbe Stand, aber $unhealthy — der letzte Lauf ist offenbar abgebrochen." + warn "Ich fahre den Rest trotzdem, statt 'fertig' zu melden." fi # What the change checks below compare against. After a failed run the checkout @@ -553,7 +586,14 @@ if [[ -n "$have_subnet" && "$have_subnet" != "$want_subnet" ]]; then fi hub_before="$(docker compose ps -q vpn-hub 2>/dev/null || true)" -docker compose up -d +# Nach einer Netz-Umstellung NEU ERZEUGEN, nicht nur starten: ein Container, der +# nur neu gestartet wird, haengt weiter am alten Netz. Alle laufen dann, und +# trotzdem loest kein Name mehr auf — das teuerste Fehlerbild dieses Tages. +if [[ "$net_migrated" == true ]]; then + docker compose up -d --force-recreate +else + docker compose up -d +fi hub_after="$(docker compose ps -q vpn-hub 2>/dev/null || true)" # Seit der Tunnel einen eigenen Container mit eigenem, selten wechselndem Abbild @@ -722,6 +762,14 @@ if [[ -z "$agent_hint" ]] && command -v caddy >/dev/null 2>&1 \ agent_hint="the console's access list does not reach the reverse proxy" fi +# Der Wächter. Ohne ihn bleibt ein Stapel, den ein abgebrochenes Update halb +# unten liegen ließ, genau so liegen — bis jemand nachsieht. Genau das ist +# einmal passiert und hat eine Stunde gekostet. +if [[ -z "$agent_hint" ]] \ + && ! systemctl list-unit-files clupilot-watchdog.timer >/dev/null 2>&1; then + agent_hint="der Wächter läuft nicht — ein abgebrochenes Update heilt dann nicht von allein" +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" diff --git a/deploy/watchdog.sh b/deploy/watchdog.sh new file mode 100755 index 0000000..45aebe2 --- /dev/null +++ b/deploy/watchdog.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# CluPilot — der Wächter. +# +# Läuft jede Minute und stellt genau eine Frage: steht, was stehen soll? Wenn +# nein, richtet er es. Er ist die Antwort auf einen Vorfall, bei dem ein +# abgebrochenes Update den Stapel halb unten liegen ließ, der Tunnel weg war und +# die Seite eine Stunde lang 500 antwortete — bis jemand von Hand nachsah. +# +# ER LÄUFT AUF DEM WIRT, nicht in einem Container. Das ist Absicht: ein Wächter +# im Container müsste den Docker-Socket hineingereicht bekommen, und das ist +# Root auf dem Wirt für jeden, der je in diesen Container kommt. Und er wäre +# genau dann tot, wenn man ihn braucht — nämlich wenn der Stapel unten ist. +# +# WAS ER NICHT TUT: raten. Er kennt vier Fehlerbilder, alle vier sind heute +# schon einmal echt passiert, und für jedes gibt es genau einen Griff. Was er +# nicht kennt, protokolliert er und lässt es in Ruhe. Ein Wächter, der bei +# Unbekanntem herumprobiert, ist der nächste Ausfall. +# +# Er fasst NICHTS an, solange ein Update läuft — er nimmt dieselbe Sperre wie +# der Update-Agent und geht weg, wenn er sie nicht bekommt. +set -uo pipefail + +cd "$(cd "$(dirname "$0")/.." && pwd)" + +STATE_DIR="storage/app/deploy" +LOCK="$STATE_DIR/.agent.lock" +HOLD="$STATE_DIR/maintenance-hold" +PHASE_FILE="$STATE_DIR/update-phase" +LOG_TAG="clupilot-watchdog" + +# Ins Journal, nicht in eine eigene Datei: der Wächter läuft als systemd-Dienst, +# und dort gehört seine Stimme hin. `logger` fehlt praktisch nie; wenn doch, +# reicht die Standardausgabe, die systemd ohnehin einsammelt. +say() { + if command -v logger >/dev/null 2>&1; then + logger -t "$LOG_TAG" -- "$*" + fi + printf '%s\n' "$*" +} + +# Solange ein Update läuft, ist Stillstand normal und Eingreifen schädlich. +mkdir -p "$STATE_DIR" 2>/dev/null || true +exec 9>"$LOCK" 2>/dev/null || exit 0 +flock -n 9 || exit 0 + +geheilt=false + +# ── 1. Fehlt ein Dienst? ───────────────────────────────────────────────────── +# +# `config --services` liest die Profile aus der .env mit, vpn-dns und +# vpn-gateway zählen also nur, wo der Tunnel überhaupt eingerichtet ist. +soll="$(docker compose config --services 2>/dev/null | sort || true)" +ist="$(docker compose ps --services --status running 2>/dev/null | sort || true)" + +if [[ -n "$soll" ]]; then + fehlt="$(comm -23 <(printf '%s\n' "$soll") <(printf '%s\n' "$ist") | tr '\n' ' ' | sed 's/ *$//')" + if [[ -n "$fehlt" ]]; then + say "Es fehlen Dienste: $fehlt — starte sie." + docker compose up -d >/dev/null 2>&1 || true + geheilt=true + sleep 10 + ist="$(docker compose ps --services --status running 2>/dev/null | sort || true)" + fi +fi + +# ── 2. Finden die Container einander noch? ─────────────────────────────────── +# +# Das Fehlerbild, das am teuersten war und am harmlosesten aussah: nach einem +# Neuaufbau des Compose-Netzes hingen die nur NEU GESTARTETEN Container noch am +# alten. Alle liefen, `docker compose ps` sah tadellos aus — und jede +# Namensauflösung scheiterte. Die Seite antwortete 500, der Warteschlangen- +# Arbeiter startete im Kreis, und nichts davon nannte die Ursache. +# +# Ein Neustart hilft dabei NICHT; die Container müssen neu erzeugt werden. +if printf '%s\n' "$ist" | grep -qx app && printf '%s\n' "$ist" | grep -qx redis; then + if ! docker compose exec -T -u www-data app getent hosts redis >/dev/null 2>&1; then + say "Die Container finden einander nicht mehr (redis nicht auflösbar) — erzeuge sie neu." + docker compose up -d --force-recreate >/dev/null 2>&1 || true + geheilt=true + sleep 15 + fi +fi + +# ── 3. Steht der Tunnel? ───────────────────────────────────────────────────── +# +# Der wichtigste Punkt: ohne wg0 ist kein Host erreichbar, keine Provisionierung +# möglich und kein Terminal. Ohne Konfiguration ist der Tunnel auf diesem Server +# schlicht nicht eingerichtet — dann gibt es nichts zu heilen. +if docker compose ps --services --status running 2>/dev/null | grep -qx vpn-hub; then + if docker compose exec -T vpn-hub test -f /etc/wireguard/wg0.conf >/dev/null 2>&1; then + if ! docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then + say "wg0 steht nicht — ziehe den Tunnel hoch." + docker compose exec -T vpn-hub wg-quick up wg0 >/dev/null 2>&1 || true + geheilt=true + + if docker compose exec -T vpn-hub wg show wg0 >/dev/null 2>&1; then + say "wg0 steht wieder." + else + say "ACHTUNG: wg0 liess sich nicht hochziehen. Siehe docs/runbooks/tunnel-recovery.md." + fi + fi + fi +fi + +# ── 4. Hängt der Wartungsmodus? ────────────────────────────────────────────── +# +# Ein Update, das mittendrin abbricht, lässt die Seite unten. Bis hierher blieb +# sie das, bis jemand nachsah — im Ernstfall stundenlang. +# +# Die Sperre oben ist der eigentliche Schutz: läuft ein Update, kommt dieser +# Code gar nicht erst dran. Die halbe Stunde darunter ist der zweite Riegel für +# einen Lauf, der ohne Aufräumen gestorben ist und seine Sperre nie freigab. +# +# `maintenance-hold` ist die Handbremse: wer die Seite bewusst unten haben will, +# legt die Datei an, und der Wächter fasst den Wartungsmodus nicht mehr an. +if [[ ! -f "$HOLD" ]] && docker compose exec -T -u www-data app test -f storage/framework/down >/dev/null 2>&1; then + phase_alt=true + if [[ -f "$PHASE_FILE" ]]; then + alter=$(( $(date +%s) - $(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0) )) + [[ "$alter" -lt 1800 ]] && phase_alt=false + fi + + if [[ "$phase_alt" == true ]]; then + say "Der Wartungsmodus haengt seit ueber einer halben Stunde ohne laufendes Update — beende ihn." + docker compose exec -T -u www-data app php artisan up >/dev/null 2>&1 || true + geheilt=true + fi +fi + +# Nur reden, wenn es etwas zu sagen gab. Ein Waechter, der jede Minute meldet, +# dass alles in Ordnung ist, wird nach zwei Tagen nicht mehr gelesen — und dann +# auch nicht mehr an dem Tag, an dem er etwas Wichtiges sagt. +if [[ "$geheilt" == true ]]; then + say "Nachgesehen und eingegriffen." +fi diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index d0b20d1..4aa5bbd 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -63,6 +63,7 @@ return [ 'console_unlock' => 'Freigeben', 'console_always' => 'Immer erlaubt', 'console_vpn_note' => 'Management-VPN, nicht entfernbar', + 'console_env_note' => 'Steht in TRUSTED_RANGES in der .env — gehört in die Liste darunter, wo du sie ändern kannst.', 'console_extra' => 'Zusätzlich erlaubt', 'console_none' => 'Noch keine weitere Adresse — ohne VPN kommt dann niemand herein.', 'console_add' => 'Hinzufügen', diff --git a/lang/de/integrations.php b/lang/de/integrations.php index 74966e5..4172b31 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -44,7 +44,7 @@ return [ 'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.', 'monitoring_title' => 'Monitoring', - 'monitoring_body' => 'API-Token und wo die Kuma-Bridge erreichbar ist.', + 'monitoring_body' => 'Damit merkt CluPilot, dass die Cloud eines Kunden steht — bevor der Kunde anruft. Bei jeder neuen Instanz entsteht automatisch eine Überwachung auf deren Statusseite; fällt sie aus, steht die Meldung auf der Übersicht. Ohne Kunden-Instanzen gibt es hier folglich nichts zu sehen. Leer lassen schaltet die Überwachung ab, sonst ändert sich nichts.', 'monitoring_url' => 'Monitoring-Bridge-URL', 'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.', diff --git a/lang/de/payment_problems.php b/lang/de/payment_problems.php index c243e9b..b4cef3d 100644 --- a/lang/de/payment_problems.php +++ b/lang/de/payment_problems.php @@ -25,6 +25,10 @@ return [ 'resolve_body' => 'Der Vorgang verschwindet aus dieser Liste. Was Sie hier eintragen, steht später daneben — zusammen mit Ihrer Adresse.', 'note_hint' => 'Zum Beispiel: hat überwiesen, hat neu bestellt, nicht erreichbar.', 'schedule_title' => 'Fristen und Gebühren', + 'schedule_days_title' => 'Fristen', + 'schedule_fees_title' => 'Gebühren', + 'schedule_fees_body' => 'In Euro. Ab welcher Stufe überhaupt eine Gebühr anfällt, steht links — davor kostet eine Mahnung nichts.', + 'unit_days' => 'Tage', 'schedule_body' => 'Tage seit der ersten gescheiterten Abbuchung. Stufe 0 ist der Hinweis und kostet nie etwas — eine abgelaufene Karte ist keine Zahlungsverweigerung.', 'day_0' => 'Hinweis', 'day_1' => '1. Mahnung', @@ -32,8 +36,8 @@ return [ 'day_3' => '3. Mahnung', 'day_4' => 'Abschaltung', 'fee_from' => 'Gebühr ab Stufe', - 'fee_2' => 'Gebühr 2. Mahnung (Cent)', - 'fee_3' => 'Gebühr 3. Mahnung (Cent)', + 'fee_2' => 'Gebühr 2. Mahnung', + 'fee_3' => 'Gebühr 3. Mahnung', 'schedule_saved' => 'Fristen und Gebühren gespeichert. Sie gelten ab dem nächsten Tageslauf.', 'days_out_of_order' => 'Die Fristen müssen aufsteigend sein. So stünde eine spätere Mahnung vor einer früheren.', 'extend' => 'Frist verlängern', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 5530bd8..ed8f930 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -63,6 +63,7 @@ return [ 'console_unlock' => 'Open up', 'console_always' => 'Always allowed', 'console_vpn_note' => 'Management VPN, not removable', + 'console_env_note' => 'Set in TRUSTED_RANGES in the .env — belongs in the list below, where you can change it.', 'console_extra' => 'Additionally allowed', 'console_none' => 'No further address yet — without the VPN, nobody gets in.', 'console_add' => 'Add', diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 12f5032..54c3e08 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -44,7 +44,7 @@ return [ 'traefik_path_hint' => 'Directory on the traffic host that routes get written into.', 'monitoring_title' => 'Monitoring', - 'monitoring_body' => 'The API token and where the Kuma bridge is reachable.', + 'monitoring_body' => 'This is how CluPilot notices that a customer cloud is down — before the customer calls. Every new instance automatically gets a check on its status page; when one fails, the notice appears on the overview. With no customer instances there is nothing to see here. Leaving it empty turns monitoring off, nothing else changes.', 'monitoring_url' => 'Monitoring bridge URL', 'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.', diff --git a/lang/en/payment_problems.php b/lang/en/payment_problems.php index f29a572..d820cd5 100644 --- a/lang/en/payment_problems.php +++ b/lang/en/payment_problems.php @@ -25,6 +25,10 @@ return [ 'resolve_body' => 'It leaves this list. What you write here stays with it afterwards, together with your address.', 'note_hint' => 'For example: paid by transfer, ordered again, unreachable.', 'schedule_title' => 'Deadlines and fees', + 'schedule_days_title' => 'Deadlines', + 'schedule_fees_title' => 'Fees', + 'schedule_fees_body' => 'In euros. Which level starts charging at all is on the left — before it, a reminder costs nothing.', + 'unit_days' => 'days', 'schedule_body' => 'Days since the first failed charge. Level 0 is the notice and never costs anything — an expired card is not a refusal to pay.', 'day_0' => 'Notice', 'day_1' => '1st reminder', @@ -32,8 +36,8 @@ return [ 'day_3' => '3rd reminder', 'day_4' => 'Shutdown', 'fee_from' => 'Fee from level', - 'fee_2' => 'Fee, 2nd reminder (cents)', - 'fee_3' => 'Fee, 3rd reminder (cents)', + 'fee_2' => 'Fee, 2nd reminder', + 'fee_3' => 'Fee, 3rd reminder', 'schedule_saved' => 'Deadlines and fees saved. They apply from the next daily run.', 'days_out_of_order' => 'Deadlines must ascend. As entered, a later reminder would come before an earlier one.', 'extend' => 'Extend deadline', diff --git a/resources/views/components/admin/vpn-peer-row.blade.php b/resources/views/components/admin/vpn-peer-row.blade.php index fc71c6a..529c569 100644 --- a/resources/views/components/admin/vpn-peer-row.blade.php +++ b/resources/views/components/admin/vpn-peer-row.blade.php @@ -38,7 +38,13 @@
- {{ $peer->name }} + {{-- Bei einem Host-Zugang der Name des HOSTS, nicht der gespeicherte. + Der Zugang bekommt seinen Namen einmal beim Anlegen; wird der + Host später umbenannt, zeigt die Liste weiter den alten. Genau + so stand hier „pve-fns-1", während der Host längst „fsn-01" + hieß — zwei Namen für dieselbe Maschine, und man muss erst + wissen, dass es dieselbe ist. --}} + {{ $peer->host?->name ?? $peer->name }} {{-- Die Plakette bleibt, obwohl die Gruppe daneben dasselbe sagt: eine Zeile wandert beim Suchen aus ihrer Überschrift heraus, diff --git a/resources/views/components/ui/input.blade.php b/resources/views/components/ui/input.blade.php index ba76336..cdd7e55 100644 --- a/resources/views/components/ui/input.blade.php +++ b/resources/views/components/ui/input.blade.php @@ -3,6 +3,12 @@ 'label' => null, 'type' => 'text', 'hint' => null, + // Die Einheit, im Feld statt in der Beschriftung: „Gebühr 2. Mahnung (Cent)" + // erklärt die Einheit an einer Stelle, die man beim schnellen Ändern nicht + // mehr liest. Ein € am rechten Rand des Feldes steht dort, wo der Blick + // ohnehin hinfällt — und lässt die Beschriftung wieder das benennen, worum + // es geht. + 'suffix' => null, ]) @php // $errors is shared on web requests; default it so the component also renders @@ -18,13 +24,29 @@ @endif - merge(['class' => $field]) }} - > + @if ($suffix) +
+ merge(['class' => $field.' pr-12']) }} + > + {{-- aria-hidden: die Einheit gehört für einen Screenreader in die + Beschriftung, nicht als loses Wort hinter das Feld. --}} + +
+ @else + merge(['class' => $field]) }} + > + @endif @if ($hint && ! $hasError)

{{ $hint }}

diff --git a/resources/views/livewire/admin/payment-problems.blade.php b/resources/views/livewire/admin/payment-problems.blade.php index ec12f52..db0c427 100644 --- a/resources/views/livewire/admin/payment-problems.blade.php +++ b/resources/views/livewire/admin/payment-problems.blade.php @@ -21,23 +21,40 @@
-

{{ __('payment_problems.schedule_body') }}

+ {{-- Zwei Blöcke mit eigener Überschrift statt zwei Reihen untereinander. + Vorher standen Tage und Geldbeträge in derselben Gruppe: oben fünf + Felder mit Tagen, direkt darunter drei mit einer Stufennummer und + zwei Beträgen. Was welche Einheit hat, stand nur in den einzelnen + Beschriftungen — und wer schnell etwas ändert, liest die nicht. --}} +
+
+

{{ __('payment_problems.schedule_days_title') }}

+

{{ __('payment_problems.schedule_body') }}

-
- @foreach ([0, 1, 2, 3, 4] as $stufe) - - @endforeach -
- @error('days')

{{ $message }}

@enderror +
+ @foreach ([0, 1, 2, 3, 4] as $stufe) + + @endforeach +
+ @error('days')

{{ $message }}

@enderror +
-
- - - +
+

{{ __('payment_problems.schedule_fees_title') }}

+

{{ __('payment_problems.schedule_fees_body') }}

+ +
+ + + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 25a102a..23b5d85 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -385,6 +385,20 @@
@endforeach + {{-- Eintraege, die jemand von Hand in TRUSTED_RANGES + geschrieben hat. Sie standen bis hierher neben dem + Management-Netz mit demselben Vermerk „nicht + entfernbar" — und waren damit fuer den Betreiber + unveraenderlich, obwohl sie genau das nicht sind. + Wer umzieht, muss seine Bueroadresse aendern + koennen; sie gehoert in die Liste darunter. --}} + @foreach ($consoleEnvRanges as $range) +
+ {{ $range }} + {{ __('admin_settings.console_env_note') }} +
+ @endforeach +

{{ __('admin_settings.console_extra') }}

@forelse ($consoleIps as $ip)
diff --git a/tests/Feature/Admin/ConsoleAccessListTest.php b/tests/Feature/Admin/ConsoleAccessListTest.php new file mode 100644 index 0000000..b404404 --- /dev/null +++ b/tests/Feature/Admin/ConsoleAccessListTest.php @@ -0,0 +1,57 @@ +set('admin_access.trusted_ranges', ['10.66.0.0/24', '127.0.0.1', '62.178.50.33']); + config()->set('admin_access.structural_ranges', ['10.66.0.0/24', '127.0.0.1']); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.settings', ['tab' => 'security'])) + ->assertOk() + ->getContent(); + + // Der Vermerk „nicht entfernbar" darf nicht mehr auf die Bueroadresse + // zutreffen — sie steht in der Warnzeile, mit dem Weg heraus. + expect($html)->toContain(__('admin_settings.console_env_note')) + ->and($html)->toContain('62.178.50.33'); +}); + +it('laesst die zwei strukturellen Eintraege in Ruhe', function () { + config()->set('admin_access.trusted_ranges', ['10.66.0.0/24', '127.0.0.1']); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.settings', ['tab' => 'security'])) + ->assertOk() + ->getContent(); + + expect($html)->toContain(__('admin_settings.console_vpn_note')) + // Ohne handgemachte Eintraege gibt es auch nichts zu warnen. + ->and($html)->not->toContain(__('admin_settings.console_env_note')); +}); + +it('nennt einen Host-Zugang so, wie der Host heute heisst', function () { + // Ein Zugang bekommt seinen Namen einmal beim Anlegen. Wird der Host spaeter + // umbenannt, zeigte die Liste weiter den alten: „pve-fns-1" neben einem + // Host, der laengst „fsn-01" hiess. Zwei Namen fuer dieselbe Maschine. + $host = \App\Models\Host::factory()->create(['name' => 'fsn-01']); + \App\Models\VpnPeer::factory()->create([ + 'host_id' => $host->id, + 'name' => 'pve-fns-1', + 'kind' => 'host', + ]); + + $html = $this->actingAs(admin(), 'operator')->get(route('admin.vpn'))->assertOk()->getContent(); + + expect($html)->toContain('fsn-01') + ->and($html)->not->toContain('pve-fns-1'); +}); diff --git a/tests/Feature/Admin/DunningFeesInEurosTest.php b/tests/Feature/Admin/DunningFeesInEurosTest.php new file mode 100644 index 0000000..4cb925b --- /dev/null +++ b/tests/Feature/Admin/DunningFeesInEurosTest.php @@ -0,0 +1,77 @@ + 500, 3 => 1000]); + + $fees = Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class)->get('fees'); + + expect($fees[2])->toBe('5') + ->and($fees[3])->toBe('10'); +}); + +it('speichert eine Eingabe in Euro als Cent', function () { + Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class) + ->set('days', [0, 3, 10, 17, 24]) + ->set('feeFromLevel', 2) + ->set('fees.2', '7,50') + ->set('fees.3', '12') + ->call('saveSchedule') + ->assertHasNoErrors(); + + expect(DunningSchedule::feeCents(2))->toBe(750) + ->and(DunningSchedule::feeCents(3))->toBe(1200); +}); + +it('rundet statt abzuschneiden', function () { + // (int) (19.99 * 100) ist in PHP 1998 — die Gleitkommazahl liegt knapp + // darunter. Ein Cent, der bei jedem Speichern verschwindet, faellt erst nach + // Monaten auf. + Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class) + ->set('days', [0, 3, 10, 17, 24]) + ->set('feeFromLevel', 2) + ->set('fees.2', '19,99') + ->set('fees.3', '0') + ->call('saveSchedule') + ->assertHasNoErrors(); + + expect(DunningSchedule::feeCents(2))->toBe(1999); +}); + +it('nimmt auch einen Punkt als Dezimaltrenner', function () { + Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class) + ->set('days', [0, 3, 10, 17, 24]) + ->set('feeFromLevel', 2) + ->set('fees.2', '5.25') + ->set('fees.3', '0') + ->call('saveSchedule') + ->assertHasNoErrors(); + + expect(DunningSchedule::feeCents(2))->toBe(525); +}); + +it('weist Unfug zurueck, statt ihn auf null zu runden', function () { + Livewire::actingAs(admin(), 'operator')->test(PaymentProblems::class) + ->set('days', [0, 3, 10, 17, 24]) + ->set('feeFromLevel', 2) + ->set('fees.2', 'fuenf') + ->set('fees.3', '0') + ->call('saveSchedule') + ->assertHasErrors('fees.2'); +}); diff --git a/tests/Feature/Admin/ProvisioningActionsTest.php b/tests/Feature/Admin/ProvisioningActionsTest.php index b95b629..eaeaabc 100644 --- a/tests/Feature/Admin/ProvisioningActionsTest.php +++ b/tests/Feature/Admin/ProvisioningActionsTest.php @@ -86,3 +86,41 @@ it('ignores retry on a run that is not failed', function () { expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_RUNNING); Queue::assertNotPushed(AdvanceRunJob::class); }); + +it('zaehlt einen fertigen Lauf in Liste und Karte gleich', function () { + // Zwei Zahlen fuer denselben Lauf: in der Liste stand 15/16, in der Karte + // daneben „16 von 16 abgeschlossen" — bei Status „Fertig" und 100 %. Die + // Liste rechnete `current_step + 1`, also „dieser Schritt laeuft gerade"; + // bei einem fertigen Lauf laeuft aber keiner mehr. + $host = Host::factory()->create(['status' => 'active']); + $total = count(config('provisioning.pipelines.host')); + + ProvisioningRun::factory()->create([ + 'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host', + 'status' => ProvisioningRun::STATUS_COMPLETED, + // Der letzte ausgefuehrte Schritt, NICHT die Gesamtzahl — genau der + // Stand, bei dem die beiden Zahlen auseinanderliefen. + 'current_step' => $total - 2, + ]); + + $rows = Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->viewData('rows'); + + expect($rows[0]['n'])->toBe($total.'/'.$total) + ->and($rows[0]['percent'])->toBe(100); +}); + +it('zeigt bei einem laufenden Lauf weiter den Schritt, der gerade dran ist', function () { + // Die Gegenprobe: solange etwas laeuft, ist „dieser Schritt laeuft gerade" + // genau richtig, und die Korrektur oben darf das nicht kaputtmachen. + $host = Host::factory()->create(['status' => 'onboarding']); + $total = count(config('provisioning.pipelines.host')); + + ProvisioningRun::factory()->create([ + 'subject_type' => Host::class, 'subject_id' => $host->id, 'pipeline' => 'host', + 'status' => ProvisioningRun::STATUS_RUNNING, 'current_step' => 3, + ]); + + $rows = Livewire::actingAs(admin(), 'operator')->test(Provisioning::class)->viewData('rows'); + + expect($rows[0]['n'])->toBe('4/'.$total); +}); diff --git a/tests/Feature/Billing/DunningControlsTest.php b/tests/Feature/Billing/DunningControlsTest.php index 321db0b..a7799b4 100644 --- a/tests/Feature/Billing/DunningControlsTest.php +++ b/tests/Feature/Billing/DunningControlsTest.php @@ -115,12 +115,17 @@ it('lets the console set the deadlines and the fees', function () { ->test(PaymentProblems::class) ->set('days', [0, 5, 12, 20, 30]) ->set('feeFromLevel', 3) - ->set('fees', [2 => 500, 3 => 1500]) + // EURO ins Feld, Cent in die Datenbank. Hier stand einmal 500 und 1500 — + // also Cent —, weil das Feld selbst in Cent rechnete. Wer eine + // Mahngebuehr eintraegt, denkt aber in Euro und tippt „5"; daraus wurden + // fuenf Cent, ohne dass etwas widersprochen haette. + ->set('fees', [2 => '5', 3 => '15']) ->call('saveSchedule') ->assertHasNoErrors(); expect(DunningSchedule::dayOfLevel(1))->toBe(5) ->and(DunningSchedule::feeFromLevel())->toBe(3) + // Stufe 2 kostet nichts, weil die Gebuehr erst ab Stufe 3 greift. ->and(DunningSchedule::feeCents(2))->toBe(0) ->and(DunningSchedule::feeCents(3))->toBe(1500); }); diff --git a/tests/Feature/DeploymentRunsAsTheAppUserTest.php b/tests/Feature/DeploymentRunsAsTheAppUserTest.php index 649e719..6c4a0f1 100644 --- a/tests/Feature/DeploymentRunsAsTheAppUserTest.php +++ b/tests/Feature/DeploymentRunsAsTheAppUserTest.php @@ -334,3 +334,48 @@ it('gibt dem Tunnel eine feste Adresse, damit gemerkte Stroeme nie ins Leere zei // dabei nicht, also muss das Aufraeumen ausdruecklich ausgeloest werden. ->and($update)->toContain('"$net_migrated" == true'); }); + +it('haelt einen Waechter bereit, der einen halb liegengebliebenen Stapel selbst aufrichtet', function () { + // Der Vorfall: ein Update brach ab, der Stapel lag halb unten, der Tunnel + // war weg und die Seite antwortete eine Stunde lang 500 — bis jemand von + // Hand nachsah. Genau das soll nie wieder auf einen Menschen warten. + $watchdog = File::get(base_path('deploy/watchdog.sh')); + + // Er fasst nichts an, solange ein Update laeuft: dieselbe Sperre wie der + // Update-Agent, und weg, wenn er sie nicht bekommt. + expect($watchdog)->toContain('flock -n 9 || exit 0') + // Die vier Fehlerbilder, die heute wirklich passiert sind. + ->and($watchdog)->toContain('docker compose up -d') + ->and($watchdog)->toContain('--force-recreate') + ->and($watchdog)->toContain('wg-quick up wg0') + ->and($watchdog)->toContain('php artisan up') + // Und eine Handbremse: wer die Seite bewusst unten haben will, soll sie + // unten halten koennen. + ->and($watchdog)->toContain('maintenance-hold'); + + // Auf dem Wirt, per systemd — nicht als Container mit Docker-Socket, der + // genau dann tot waere, wenn man ihn braucht. + $installer = File::get(base_path('deploy/install-agent.sh')); + + expect($installer)->toContain('clupilot-watchdog.service') + ->and($installer)->toContain('clupilot-watchdog.timer') + ->and($installer)->toContain('systemctl enable --now clupilot-watchdog.timer'); +}); + +it('meldet nicht fertig, wenn der letzte Lauf abgebrochen ist', function () { + // Bricht ein Update mittendrin ab, steht der Checkout schon auf dem Ziel. + // Ein zweiter Aufruf meldete deshalb "Already up to date" und tat NICHTS — + // waehrend der Server halb unten stand. Der Commit ist die falsche Frage; + // die richtige ist, ob alles laeuft. + $update = File::get(base_path('deploy/update.sh')); + + expect($update)->toContain('Already up to date') + ->and($update)->toContain('unhealthy') + ->and($update)->toContain('der Wartungsmodus ist noch an'); + + // Und die Fortschrittsanzeige darf ein Deployment nicht mehr toeten: gehoert + // storage/ nach einem frueheren Fehltritt root, scheiterte das mkdir in + // phase() — und mit `set -e` starb der Lauf an seiner ersten Zeile, ohne + // eine einzige Ausgabe. + expect($update)->toContain('mkdir -p "$(dirname "$PHASE_FILE")" 2>/dev/null || true'); +});