From 006ce3568b57733b347bf3c4e3e45e15830273ae Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 22:55:55 +0200 Subject: [PATCH] Manage hostnames and watch their certificates from the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit files.clupilot.com is why this exists. DNS pointed at the right machine, the env var was set, the release was deployed — and there was still no certificate, because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a second, separate step. Nothing in the console would have said so. Three settings looked correct and the address did not answer. So the page holds two things side by side. The WISH — which names should be served — and the REALITY: whether the name has a certificate and for how much longer. The second is measured by opening a TLS connection and reading the expiry, not by reading configuration, because the configuration is exactly what looked right while the address was dead. verify_peer stays on: a certificate that fails validation is not a certificate for this question, and a display that called it valid would be the fake R19 records. Applying goes through the existing agent, not a new channel. The console writes a request, the path unit wakes the agent within a second, and the agent calls one fixed command line of the root-owned helper. What that helper is allowed to do is the careful part. It fetches the list ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy blocks — `php artisan clupilot:proxy-hosts` prints ` ` and nothing else. Each name is matched against a strict pattern before it is used, and the template around it lives in the helper, which the service account cannot touch. install-agent.sh already states the principle for the sudoers grant: a grant is only worth anything if the holder cannot change what it grants. A service account that could write proxy configuration would have everything the proxy can do — redirects anywhere, files from any directory. Purpose is a column rather than a habit. A console name gets the network lock, a public one does not, and a console name published without it looks exactly like a working page. Removing takes the name out of the list and NOT out of the running proxy. Two decisions in one click, and the second one takes a site off the air. There is deliberately no "renew" button. Caddy renews on its own at two thirds of the lifetime; what an operator actually needs is a second attempt after an issuance has failed, and that is a reload — which is what Apply does. Thirty days is treated as a problem rather than a warning: at ninety days' lifetime, a renewal should long since have run, so anything under it is not a tight certificate but a renewal that is not happening. The ACME contact falls back to the owner's address, because a contact nobody reads is the step before expired customer certificates. CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server carrying the older helper to run the installer again — caught by the guard test that compares the two halves. 2035 tests pass, assets build. Co-Authored-By: Claude Opus 5 --- app/Console/Commands/ProxyHostList.php | 38 ++++ app/Livewire/Admin/ProxyHosts.php | 163 ++++++++++++++++++ app/Models/ProxyHost.php | 70 ++++++++ app/Services/Deployment/UpdateChannel.php | 35 ++++ app/Services/Proxy/CertificateInspector.php | 95 ++++++++++ ...t_the_console_manage_its_own_hostnames.php | 41 +++++ deploy/caddy/Caddyfile.example | 15 ++ deploy/install-agent.sh | 74 +++++++- deploy/update-agent.sh | 25 +++ deploy/update.sh | 2 +- lang/de/proxy.php | 53 ++++++ lang/en/proxy.php | 53 ++++++ .../livewire/admin/proxy-hosts.blade.php | 117 +++++++++++++ routes/admin.php | 5 + tests/Feature/Admin/ProxyHostsTest.php | 141 +++++++++++++++ 15 files changed, 925 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/ProxyHostList.php create mode 100644 app/Livewire/Admin/ProxyHosts.php create mode 100644 app/Models/ProxyHost.php create mode 100644 app/Services/Proxy/CertificateInspector.php create mode 100644 database/migrations/2026_08_03_100000_let_the_console_manage_its_own_hostnames.php create mode 100644 lang/de/proxy.php create mode 100644 lang/en/proxy.php create mode 100644 resources/views/livewire/admin/proxy-hosts.blade.php create mode 100644 tests/Feature/Admin/ProxyHostsTest.php diff --git a/app/Console/Commands/ProxyHostList.php b/app/Console/Commands/ProxyHostList.php new file mode 100644 index 0000000..db3db6c --- /dev/null +++ b/app/Console/Commands/ProxyHostList.php @@ -0,0 +1,38 @@ + `. + */ +class ProxyHostList extends Command +{ + protected $signature = 'clupilot:proxy-hosts'; + + protected $description = 'Lists the hostnames the reverse proxy should serve, one per line'; + + public function handle(): int + { + foreach (ProxyHost::query()->orderBy('hostname')->get() as $host) { + $this->line($host->hostname.' '.$host->purpose); + } + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/ProxyHosts.php b/app/Livewire/Admin/ProxyHosts.php new file mode 100644 index 0000000..44dc3b5 --- /dev/null +++ b/app/Livewire/Admin/ProxyHosts.php @@ -0,0 +1,163 @@ +authorize('site.manage'); + $this->acmeEmail = (string) Settings::get('proxy.acme_email', ''); + } + + protected function rules(): array + { + return [ + // Ein Hostname und nichts anderes. Der root-eigene Helfer prüft es + // ein zweites Mal — das hier ist die Höflichkeit, jenes die Sperre. + 'hostname' => [ + 'required', 'string', 'max:253', + 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/', + Rule::unique('proxy_hosts', 'hostname'), + ], + 'purpose' => ['required', Rule::in(ProxyHost::PURPOSES)], + 'note' => ['nullable', 'string', 'max:255'], + ]; + } + + public function add(): void + { + $this->authorize('site.manage'); + $data = $this->validate(); + + ProxyHost::create($data); + + $this->reset('hostname', 'note'); + $this->purpose = 'public'; + $this->notice = __('proxy.added'); + } + + public function remove(int $id): void + { + $this->authorize('site.manage'); + + ProxyHost::query()->whereKey($id)->delete(); + + // Kein Anwenden von selbst. Einen Namen aus der Liste zu nehmen und ihn + // im selben Atemzug aus dem laufenden Proxy zu entfernen wäre zwei + // Entscheidungen in einem Klick — und die zweite nimmt eine Seite vom + // Netz. + $this->notice = __('proxy.removed'); + } + + public function saveAcmeEmail(): void + { + $this->authorize('site.manage'); + $this->validateOnly('acmeEmail', ['acmeEmail' => ['nullable', 'email', 'max:255']]); + + Settings::set('proxy.acme_email', trim($this->acmeEmail)); + $this->notice = __('proxy.email_saved'); + } + + /** + * Was ein Besucher gerade wirklich bekommt. + * + * Nacheinander und mit kurzem Zeitablauf: die Liste ist zweistellig, und + * ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang + * blockieren. + */ + public function checkCertificates(CertificateInspector $inspector): void + { + $this->authorize('site.manage'); + + foreach (ProxyHost::query()->get() as $host) { + $result = $inspector->inspect($host->hostname); + + $host->forceFill([ + 'certificate_expires_at' => $result['expires_at'], + 'certificate_checked_at' => now(), + 'certificate_error' => $result['error'], + ])->save(); + } + + $this->notice = __('proxy.checked'); + } + + /** + * Den Proxy die Liste übernehmen lassen — und dabei fehlende Zertifikate + * anfordern. + * + * Ein eigener Knopf „erneuern" wäre daneben: Caddy erneuert von selbst bei + * zwei Dritteln der Laufzeit. Was ein Betreiber wirklich braucht, ist ein + * zweiter Versuch, nachdem eine Ausstellung gescheitert ist — und genau das + * ist ein Neuladen. + */ + public function apply(UpdateChannel $channel): void + { + $this->authorize('site.manage'); + + $this->notice = $channel->requestProxyHosts((string) auth('operator')->id()) + ? __('proxy.applying') + : __('proxy.apply_failed'); + } + + public function render() + { + return view('livewire.admin.proxy-hosts', [ + 'hosts' => ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(), + 'purposes' => ProxyHost::PURPOSES, + // Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die + // die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine + // Adresse, von der diese Installation sicher weiß, dass sie gelesen + // wird, ist seine. + 'fallbackEmail' => $this->ownerEmail(), + ]); + } + + private function ownerEmail(): ?string + { + return Operator::query() + ->whereHas('roles', fn ($q) => $q->where('name', 'Owner')) + ->orderBy('id') + ->value('email'); + } +} diff --git a/app/Models/ProxyHost.php b/app/Models/ProxyHost.php new file mode 100644 index 0000000..e422b7f --- /dev/null +++ b/app/Models/ProxyHost.php @@ -0,0 +1,70 @@ + 'datetime', + 'certificate_checked_at' => 'datetime', + ]; + } + + /** + * Wofür der Name da ist — und damit, was er darf. + * + * `public` — Website, Portal, Status, Dateien. Vom offenen Internet + * erreichbar, Zertifikat über die HTTP-Prüfung. + * `console` — der Adminbereich. Er bekommt die Netzsperre und ist NICHT + * öffentlich; ein Zertifikat holt er trotzdem. + * + * Getrennt, weil ein Name mit dem falschen Zweck genau die Sorte Fehler + * ist, die niemand sieht: die Konsole öffentlich zu stellen sieht aus wie + * eine funktionierende Seite. + */ + public const PURPOSES = ['public', 'console']; + + public function isConsole(): bool + { + return $this->purpose === 'console'; + } + + /** Tage bis zum Ablauf, oder null wenn nichts gemessen wurde. */ + public function daysLeft(): ?int + { + if ($this->certificate_expires_at === null) { + return null; + } + + // Aufgerundet auf ganze Tage und nie negativ dargestellt: „noch -3 Tage" + // ist keine Auskunft, „abgelaufen" ist eine. + return max(0, (int) now()->diffInDays($this->certificate_expires_at, false)); + } + + /** + * Läuft es bald ab? + * + * Dreißig Tage, weil Let's Encrypt bei neunzig Tagen Laufzeit nach dreißig + * verbleibenden längst hätte erneuern müssen. Was darunter steht, ist kein + * knappes Zertifikat mehr, sondern eine Erneuerung, die nicht stattfindet. + */ + public function isExpiringSoon(): bool + { + $days = $this->daysLeft(); + + return $days !== null && $days <= 30; + } +} diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 25969f5..4ceeadd 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -80,6 +80,22 @@ final class UpdateChannel */ private const KIND_ARCHIVE_KEY = 'archive-key'; + /** + * Die Hostnamen aus der Konsole in die Proxy-Konfiguration übernehmen. + * + * Getrennt von `run`, weil hier NICHTS aktualisiert wird: kein Wartungsmodus, + * kein Neustart, kein Anfassen des Auscheckens. Der Agent ruft eine feste + * Befehlszeile des root-eigenen Helfers auf, der die Liste liest, sie gegen + * ein strenges Muster prüft und daraus seine eigene Vorlage füllt. + * + * Die Liste, die er liest, kommt aus `php artisan clupilot:proxy-hosts` — + * NAMEN, keine Caddy-Blöcke. Dürfte diese Anwendung fertige Konfiguration + * schreiben, die ein root-Helfer ungeprüft einbaut, hätte sie damit alles, + * was der Proxy kann. `install-agent.sh` sagt es kürzer: eine Freigabe ist + * nur etwas wert, wenn der Inhaber nicht ändern kann, was sie gewährt. + */ + private const KIND_PROXY_HOSTS = 'proxy-hosts'; + /** * Where the agent leaves a freshly minted key, once. * @@ -404,6 +420,25 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Proxy die Hostnamen aus der Konsole übernehmen lassen. + * + * Damit fällt der Handgriff weg, der `files.clupilot.com` gekostet hat: + * DNS gesetzt, Umgebungsvariable gesetzt, und trotzdem kein Zertifikat, + * weil `/etc/caddy/Caddyfile` von Hand gepflegt wird und niemand daran + * dachte, dass das ein zweiter, getrennter Schritt ist. + * + * Derselbe Aufruf holt auch fehlende Zertifikate: Caddy fordert sie beim + * Neuladen für jeden Namen an, der noch keins hat. Ein eigener Knopf + * „erneuern" wäre daneben — Caddy erneuert von selbst bei zwei Dritteln der + * Laufzeit, und was ein Betreiber wirklich braucht, ist ein zweiter Versuch, + * nachdem eine Ausstellung einmal gescheitert ist. + */ + public function requestProxyHosts(string $by): bool + { + return $this->submit($by, self::KIND_PROXY_HOSTS); + } + /** * Ask the agent to restart the four workers that only read .env at their * own startup — never `app`. See update-agent.sh's handling of this kind diff --git a/app/Services/Proxy/CertificateInspector.php b/app/Services/Proxy/CertificateInspector.php new file mode 100644 index 0000000..b0fd750 --- /dev/null +++ b/app/Services/Proxy/CertificateInspector.php @@ -0,0 +1,95 @@ + [ + 'capture_peer_cert' => true, + 'SNI_enabled' => true, + 'peer_name' => $hostname, + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $client = @stream_socket_client( + 'ssl://'.$hostname.':443', + $errorNumber, + $errorMessage, + $timeoutSeconds, + STREAM_CLIENT_CONNECT, + $context, + ); + + if ($client === false) { + return [ + 'ok' => false, + 'expires_at' => null, + 'issuer' => null, + // Die Meldung von PHP ist lang und trägt Dateipfade. Was zählt, + // ist die erste Zeile — sie unterscheidet „kein Zertifikat" von + // „Name löst nicht auf", und das sind zwei ganz verschiedene + // Aufgaben für den Betreiber. + 'error' => $this->firstLine($errorMessage ?: 'kein Verbindungsaufbau'), + ]; + } + + $params = stream_context_get_params($client); + fclose($client); + + $certificate = $params['options']['ssl']['peer_certificate'] ?? null; + + if ($certificate === null) { + return ['ok' => false, 'expires_at' => null, 'issuer' => null, 'error' => 'kein Zertifikat vorgelegt']; + } + + $parsed = openssl_x509_parse($certificate); + + if ($parsed === false || ! isset($parsed['validTo_time_t'])) { + return ['ok' => false, 'expires_at' => null, 'issuer' => null, 'error' => 'Zertifikat nicht lesbar']; + } + + return [ + 'ok' => true, + 'expires_at' => Carbon::createFromTimestampUTC($parsed['validTo_time_t']), + 'issuer' => $parsed['issuer']['O'] ?? ($parsed['issuer']['CN'] ?? null), + 'error' => null, + ]; + } + + private function firstLine(string $message): string + { + $line = trim((string) preg_split('/\R/', $message)[0]); + + // Der PHP-Vorspann („stream_socket_client(): ") sagt nichts über die + // Sache und frisst die halbe Spalte. + $line = (string) preg_replace('/^stream_socket_client\(\):\s*/', '', $line); + + return mb_substr($line, 0, 190); + } +} diff --git a/database/migrations/2026_08_03_100000_let_the_console_manage_its_own_hostnames.php b/database/migrations/2026_08_03_100000_let_the_console_manage_its_own_hostnames.php new file mode 100644 index 0000000..5bf07ab --- /dev/null +++ b/database/migrations/2026_08_03_100000_let_the_console_manage_its_own_hostnames.php @@ -0,0 +1,41 @@ +id(); + $table->string('hostname')->unique(); + $table->string('purpose')->default('public'); + $table->string('note')->nullable(); + $table->timestamp('certificate_expires_at')->nullable(); + $table->timestamp('certificate_checked_at')->nullable(); + $table->string('certificate_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('proxy_hosts'); + } +}; diff --git a/deploy/caddy/Caddyfile.example b/deploy/caddy/Caddyfile.example index 4ff93f1..e69f782 100644 --- a/deploy/caddy/Caddyfile.example +++ b/deploy/caddy/Caddyfile.example @@ -93,6 +93,21 @@ status.example.com { reverse_proxy 127.0.0.1:8080 } +# ── Von der Konsole verwaltete Hostnamen ───────────────────────────────────── +# Erzeugt von `clupilot-host-step apply-proxy-hosts` aus der Liste, die der +# Betreiber in der Konsole pflegt. Damit lässt sich ein Name hinzufügen, ohne +# sich auf den Server zu melden — der Handgriff, der `files.clupilot.com` +# gekostet hat. +# +# Die Datei MUSS existieren, bevor diese Zeile sie importiert: ein Import ins +# Leere macht die ganze Proxy-Konfiguration ungültig, und das nimmt die +# öffentliche Website mit. `install-agent.sh` legt sie deshalb an, bevor es +# hierauf hinweist. Wer die Zeile von Hand einträgt, legt vorher an: +# +# sudo touch /etc/caddy/clupilot-proxy-hosts.conf +# +import /etc/caddy/clupilot-proxy-hosts.conf + # ── Dateien zum Herunterladen ──────────────────────────────────────────────── # FILES_HOST aus der .env. Zwei Dinge liegen hier, und beide müssen von überall # erreichbar sein: diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index a29d228..abf0e32 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -52,7 +52,7 @@ cat > "$HOST_STEP" <<'EOF' # One argument, from the list below. Nothing takes free-form input. set -euo pipefail -CONTRACT=1 +CONTRACT=2 case "${1:-}" in contract) @@ -72,6 +72,77 @@ case "${1:-}" in command -v rsync >/dev/null 2>&1 || { echo "rsync still missing after install" >&2; exit 4; } ;; + apply-proxy-hosts) + # Die Hostnamen aus der Konsole in die Caddy-Konfiguration übernehmen. + # + # Die Liste wird HIER geholt, nicht übergeben. Der Dienstbenutzer darf + # bestimmen, WELCHE Namen bedient werden — und sonst nichts. Dürfte er + # die Konfiguration liefern, hätte er über diesen Umweg alles, was der + # Proxy kann: Weiterleitungen irgendwohin, Dateiauslieferung aus jedem + # Verzeichnis. Die Vorlage steht deshalb in dieser Datei, die er nicht + # anfassen kann. + CADDYFILE=/etc/caddy/Caddyfile + TARGET=/etc/caddy/clupilot-proxy-hosts.conf + [[ -f "$CADDYFILE" ]] || { echo "no $CADDYFILE on this host" >&2; exit 3; } + command -v caddy >/dev/null 2>&1 || { echo "caddy is not installed" >&2; exit 3; } + + CHECKOUT=/opt/clupilot + [[ -d "$CHECKOUT" ]] || { echo "no checkout at $CHECKOUT" >&2; exit 3; } + + LIST="$(cd "$CHECKOUT" && runuser -u clupilot -- docker compose exec -T -u www-data app \ + php artisan clupilot:proxy-hosts 2>/dev/null)" \ + || { echo "could not read the hostname list from the application" >&2; exit 4; } + + TMP="$(mktemp)" + { + echo "# Von CluPilot erzeugt. Nicht von Hand ändern — der nächste" + echo "# 'apply-proxy-hosts' überschreibt diese Datei." + } > "$TMP" + + while read -r NAME PURPOSE; do + [[ -n "$NAME" ]] || continue + # Ein Hostname und nichts anderes. Das ist die Stelle, an der aus + # „der Dienstbenutzer wählt Namen" nicht „der Dienstbenutzer + # schreibt Konfiguration" werden darf. + [[ "$NAME" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]] \ + || { echo "refusing hostname: $NAME" >&2; rm -f "$TMP"; exit 5; } + + { + echo "" + echo "$NAME {" + if [[ "$PURPOSE" == "console" ]]; then + # Dieselbe Netzsperre wie der von Hand gepflegte Block. Ein + # Konsolenname, der ohne sie ins Netz geht, sieht aus wie + # eine funktionierende Seite. + echo " import /etc/caddy/clupilot-console-allow.conf" + echo " handle @allowed {" + echo " reverse_proxy 127.0.0.1:8080" + echo " }" + echo " handle {" + echo " abort" + echo " }" + else + echo " reverse_proxy 127.0.0.1:8080" + fi + echo "}" + } >> "$TMP" + done <<< "$LIST" + + install -o root -g root -m 0644 "$TMP" "$TARGET" + rm -f "$TMP" + + # Erst prüfen, dann neu laden. Ein ungültiger Import macht die GANZE + # Proxy-Konfiguration ungültig — und damit auch die öffentliche Website. + if ! caddy validate --config "$CADDYFILE" >/dev/null 2>&1; then + rm -f "$TARGET" + caddy validate --config "$CADDYFILE" >/dev/null 2>&1 || true + echo "caddy rejected the generated hosts file; it was removed again" >&2 + exit 6 + fi + + systemctl reload caddy + ;; + *) # Refused by name. sudoers already permits only the exact command lines # above, so this is the second of two locks, not the only one. @@ -89,6 +160,7 @@ chmod 0755 "$HOST_STEP" # nothing else the file might ever learn to do. cat > /etc/sudoers.d/clupilot-host-step </dev/null || { diff --git a/deploy/update-agent.sh b/deploy/update-agent.sh index 3311019..509e049 100755 --- a/deploy/update-agent.sh +++ b/deploy/update-agent.sh @@ -372,6 +372,31 @@ if [[ "$REQUEST_KIND" == "check" ]]; then exit 0 fi +if [[ "$REQUEST_KIND" == "proxy-hosts" ]]; then + # Die Hostnamen aus der Konsole in die Proxy-Konfiguration übernehmen. + # + # Nichts wird aktualisiert: kein Wartungsmodus, kein Neustart, kein + # Anfassen des Auscheckens. Der Aufruf geht an EINE feste Befehlszeile des + # root-eigenen Helfers, die in sudoers wörtlich so steht. + # + # Der Helfer holt sich die Liste selbst — er bekommt sie nicht von hier + # gereicht. Wer die Liste liefern dürfte, könnte auch liefern, was sonst + # noch in die Konfiguration soll, und die Freigabe wäre nichts mehr wert. + if [[ ! -x /usr/local/sbin/clupilot-host-step ]]; then + write_status idle "proxy-hosts: clupilot-host-step fehlt — bitte 'sudo bash deploy/install-agent.sh' ausführen" + exit 0 + fi + + if PROXY_OUT="$(sudo -n /usr/local/sbin/clupilot-host-step apply-proxy-hosts 2>&1)"; then + write_status idle "" + else + # Die erste Zeile trägt die Aussage; `caddy validate` hängt einen + # Absatz an, und der passt in keine Statuszeile. + write_status idle "proxy-hosts: $(printf '%s' "$PROXY_OUT" | head -1)" + fi + exit 0 +fi + if [[ "$REQUEST_KIND" == "restart" ]]; then # queue, queue-provisioning, scheduler, reverb — and ONLY those four. # `app` is deliberately never in this list: it is the container serving diff --git a/deploy/update.sh b/deploy/update.sh index fe819a3..cc19f5b 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -35,7 +35,7 @@ STATE_FILE="storage/app/deployed-commit" # fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS # here is what makes an update tell the operator to run the installer again. HOST_STEP=/usr/local/sbin/clupilot-host-step -HOST_STEP_NEEDS=1 +HOST_STEP_NEEDS=2 # Which step is running, for the console to show. Written as a KEY, not as the # sentence below it: the console is translated and this script is not, so an # English line here would surface untranslated in the interface. A run that dies diff --git a/lang/de/proxy.php b/lang/de/proxy.php new file mode 100644 index 0000000..3e081b3 --- /dev/null +++ b/lang/de/proxy.php @@ -0,0 +1,53 @@ + 'Betrieb', + 'title' => 'Hostnamen und Zertifikate', + 'subtitle' => 'Welche Adressen diese Installation bedient — und ob sie wirklich antworten.', + + 'list_title' => 'Bediente Hostnamen', + 'list_sub' => 'Der Zustand ist gemessen, nicht aus der Konfiguration gelesen: geprüft wird, was ein Besucher bekommt.', + 'empty' => 'Noch kein Hostname eingetragen.', + + 'check' => 'Zertifikate prüfen', + 'apply' => 'Auf den Proxy anwenden', + 'apply_hint' => 'Anwenden schreibt die Liste in die Proxy-Konfiguration und lädt sie neu. Dabei fordert der Proxy Zertifikate für alle Namen an, die noch keins haben — auch für die, bei denen es zuvor gescheitert ist. Erneuert wird von selbst, bei zwei Dritteln der Laufzeit.', + + 'add_title' => 'Hostnamen hinzufügen', + 'add_sub' => 'Der DNS-Eintrag muss vorher auf diesen Server zeigen. Sonst gibt es kein Zertifikat, und der Grund steht dann in der Zeile darüber.', + 'add' => 'Hinzufügen', + 'added' => 'Hostname eingetragen. Er wirkt erst mit „Auf den Proxy anwenden".', + 'remove' => 'Entfernen', + 'removed' => 'Hostname entfernt. Der Proxy bedient ihn bis zum nächsten Anwenden weiter.', + 'save' => 'Speichern', + 'checked' => 'Zertifikate geprüft.', + 'applying' => 'Wird angewendet. Der Agent übernimmt das binnen einer Minute.', + 'apply_failed' => 'Konnte nicht angefordert werden — läuft der Update-Agent auf diesem Server?', + 'email_saved' => 'Kontaktadresse gespeichert.', + + 'acme_title' => 'Kontaktadresse für Zertifikate', + 'acme_sub' => 'An sie gehen die Ablaufwarnungen der Zertifizierungsstelle. Sie taucht in keinem Zertifikat auf.', + + 'purpose' => [ + 'public' => 'Öffentlich', + 'console' => 'Konsole (nur aus dem Verwaltungsnetz)', + ], + + 'cert' => [ + 'unknown' => 'Noch nicht geprüft.', + 'valid' => 'Zertifikat gültig, noch :days Tage (bis :date).', + 'soon' => 'Zertifikat läuft in :days Tagen ab (:date) — eine Erneuerung hätte längst laufen müssen.', + 'error' => 'Kein gültiges Zertifikat: :error', + ], + + 'field' => [ + 'hostname' => 'Hostname', + 'hostname_hint' => 'Vollständig, ohne Schema — z. B. files.clupilot.com', + 'purpose' => 'Zweck', + 'purpose_hint' => 'Konsole bekommt die Netzsperre. Öffentlich ist von überall erreichbar.', + 'note' => 'Notiz', + 'acme_email' => 'E-Mail-Adresse', + 'acme_email_hint' => 'Leer lassen: dann wird :email genommen, die Adresse des Inhabers.', + 'acme_email_hint_none' => 'Leer lassen ist hier keine gute Wahl — es gibt kein Inhaberkonto, aus dem eine Adresse käme.', + ], +]; diff --git a/lang/en/proxy.php b/lang/en/proxy.php new file mode 100644 index 0000000..f405ed2 --- /dev/null +++ b/lang/en/proxy.php @@ -0,0 +1,53 @@ + 'Operations', + 'title' => 'Hostnames and certificates', + 'subtitle' => 'Which addresses this installation serves — and whether they actually answer.', + + 'list_title' => 'Served hostnames', + 'list_sub' => 'The state is measured, not read from the configuration: what a visitor gets is what is checked.', + 'empty' => 'No hostname added yet.', + + 'check' => 'Check certificates', + 'apply' => 'Apply to the proxy', + 'apply_hint' => 'Applying writes the list into the proxy configuration and reloads it. The proxy then requests certificates for every name that has none — including the ones where issuance failed before. Renewal happens on its own, at two thirds of the lifetime.', + + 'add_title' => 'Add a hostname', + 'add_sub' => 'The DNS record has to point at this server first. Otherwise there is no certificate, and the reason will appear in the row above.', + 'add' => 'Add', + 'added' => 'Hostname added. It takes effect with "Apply to the proxy".', + 'remove' => 'Remove', + 'removed' => 'Hostname removed. The proxy keeps serving it until the next apply.', + 'save' => 'Save', + 'checked' => 'Certificates checked.', + 'applying' => 'Applying. The agent picks it up within a minute.', + 'apply_failed' => 'Could not be requested — is the update agent running on this server?', + 'email_saved' => 'Contact address saved.', + + 'acme_title' => 'Certificate contact address', + 'acme_sub' => 'The certificate authority sends expiry warnings here. It appears in no certificate.', + + 'purpose' => [ + 'public' => 'Public', + 'console' => 'Console (management network only)', + ], + + 'cert' => [ + 'unknown' => 'Not checked yet.', + 'valid' => 'Certificate valid, :days days left (until :date).', + 'soon' => 'Certificate expires in :days days (:date) — a renewal should long since have run.', + 'error' => 'No valid certificate: :error', + ], + + 'field' => [ + 'hostname' => 'Hostname', + 'hostname_hint' => 'Fully qualified, without a scheme — e.g. files.clupilot.com', + 'purpose' => 'Purpose', + 'purpose_hint' => 'Console gets the network lock. Public is reachable from anywhere.', + 'note' => 'Note', + 'acme_email' => 'Email address', + 'acme_email_hint' => 'Leave empty to use :email, the owner\'s address.', + 'acme_email_hint_none' => 'Leaving this empty is a poor choice here — there is no owner account to take an address from.', + ], +]; diff --git a/resources/views/livewire/admin/proxy-hosts.blade.php b/resources/views/livewire/admin/proxy-hosts.blade.php new file mode 100644 index 0000000..0dfaf96 --- /dev/null +++ b/resources/views/livewire/admin/proxy-hosts.blade.php @@ -0,0 +1,117 @@ +
+
+

{{ __('proxy.eyebrow') }}

+

{{ __('proxy.title') }}

+

{{ __('proxy.subtitle') }}

+
+ + @if ($notice) + {{ $notice }} + @endif + +
+
+
+

{{ __('proxy.list_title') }}

+

{{ __('proxy.list_sub') }}

+
+
+ + + {{ __('proxy.check') }} + + + + {{ __('proxy.apply') }} + +
+
+ + + @forelse ($hosts as $host) +
+
+
+ {{ $host->hostname }} + @if ($host->isConsole()) + {{ __('proxy.purpose.console') }} + @endif +
+ + {{-- Der Zustand steht als Satz, nicht als Farbe: ein + Punkt in Grün oder Rot sagt nicht, wie lange noch, + und genau das ist hier die Frage. --}} +

+ @if ($host->certificate_checked_at === null) + {{ __('proxy.cert.unknown') }} + @elseif ($host->certificate_error) + {{ __('proxy.cert.error', ['error' => $host->certificate_error]) }} + @elseif ($host->daysLeft() === null) + {{ __('proxy.cert.unknown') }} + @elseif ($host->isExpiringSoon()) + {{ __('proxy.cert.soon', ['days' => $host->daysLeft(), 'date' => $host->certificate_expires_at->local()->isoFormat('D. MMM YYYY')]) }} + @else + {{ __('proxy.cert.valid', ['days' => $host->daysLeft(), 'date' => $host->certificate_expires_at->local()->isoFormat('D. MMM YYYY')]) }} + @endif + @if ($host->note) + · {{ $host->note }} + @endif +

+
+ + +
+ @empty +
{{ __('proxy.empty') }}
+ @endforelse +
+ +

{{ __('proxy.apply_hint') }}

+
+ +
+

{{ __('proxy.add_title') }}

+

{{ __('proxy.add_sub') }}

+ + + + + + + + + + + + + +
+ {{ __('proxy.add') }} +
+
+ +
+

{{ __('proxy.acme_title') }}

+

{{ __('proxy.acme_sub') }}

+ + + + + + + +
+ {{ __('proxy.save') }} +
+
+
diff --git a/routes/admin.php b/routes/admin.php index 26ef9de..0ac9cf2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -33,6 +33,11 @@ Route::get('/hosts', Admin\Hosts::class)->name('hosts'); Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); + +// Hostnamen und Zertifikate. Eigene Seite und nicht unter Einstellungen: was +// hier steht, entscheidet, ob eine Adresse überhaupt antwortet — das ist keine +// Voreinstellung, das ist Betrieb. +Route::get('/proxy-hosts', Admin\ProxyHosts::class)->name('proxy-hosts'); Route::get('/plans', Admin\Plans::class)->name('plans'); Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); // POST so the identity change is CSRF-protected (a GET could be forced cross-site). diff --git a/tests/Feature/Admin/ProxyHostsTest.php b/tests/Feature/Admin/ProxyHostsTest.php new file mode 100644 index 0000000..feb7be2 --- /dev/null +++ b/tests/Feature/Admin/ProxyHostsTest.php @@ -0,0 +1,141 @@ +role('Owner')->create(), 'operator')->test(ProxyHosts::class); +} + +it('adds a hostname and lists it', function () { + proxyPage() + ->set('hostname', 'files.clupilot.com') + ->set('purpose', 'public') + ->call('add') + ->assertSee('files.clupilot.com'); + + expect(ProxyHost::query()->where('hostname', 'files.clupilot.com')->exists())->toBeTrue(); +}); + +/** + * Der Dienstbenutzer wählt NAMEN, nicht Konfiguration. Wäre hier alles erlaubt, + * was durch die Prüfung schlüpft, könnte über den root-eigenen Helfer Caddy + * beigebracht werden, was immer der Proxy kann — und `install-agent.sh` hält + * fest, warum das die Freigabe wertlos machte. + */ +it('refuses anything that is not a hostname', function () { + foreach ([ + 'files.clupilot.com {\n reverse_proxy evil', + 'https://files.clupilot.com', + 'files.clupilot.com/pfad', + 'kein punkt drin', + '-anfang.clupilot.com', + '../../etc/passwd', + ] as $attempt) { + proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname'); + } + + expect(ProxyHost::query()->count())->toBe(0); +}); + +it('refuses the same hostname twice', function () { + ProxyHost::create(['hostname' => 'files.clupilot.com', 'purpose' => 'public']); + + proxyPage()->set('hostname', 'files.clupilot.com')->call('add')->assertHasErrors('hostname'); +}); + +/** + * Entfernen nimmt den Namen aus der Liste, NICHT aus dem laufenden Proxy. Zwei + * Entscheidungen in einem Klick, und die zweite nimmt eine Seite vom Netz. + */ +it('removes from the list without touching the running proxy', function () { + $host = ProxyHost::create(['hostname' => 'alt.clupilot.com', 'purpose' => 'public']); + + proxyPage()->call('remove', $host->id)->assertSee(__('proxy.removed')); + + expect(ProxyHost::query()->count())->toBe(0); +}); + +/** + * Der Zustand wird GEMESSEN. Das ist der ganze Grund für diese Seite: bei + * `files.clupilot.com` waren DNS, Umgebungsvariable und Maschine richtig, und + * es gab trotzdem kein Zertifikat — keine der drei Einstellungen hätte das + * verraten. + */ +it('records what the certificate check actually found', function () { + ProxyHost::create(['hostname' => 'gut.clupilot.com', 'purpose' => 'public']); + ProxyHost::create(['hostname' => 'kaputt.clupilot.com', 'purpose' => 'public']); + + $this->swap(CertificateInspector::class, new class extends CertificateInspector + { + public function inspect(string $hostname, int $timeoutSeconds = 8): array + { + return $hostname === 'gut.clupilot.com' + ? ['ok' => true, 'expires_at' => now()->addDays(75), 'issuer' => "Let's Encrypt", 'error' => null] + : ['ok' => false, 'expires_at' => null, 'issuer' => null, 'error' => 'tlsv1 alert internal error']; + } + }); + + proxyPage()->call('checkCertificates'); + + $gut = ProxyHost::query()->where('hostname', 'gut.clupilot.com')->first(); + $kaputt = ProxyHost::query()->where('hostname', 'kaputt.clupilot.com')->first(); + + expect($gut->daysLeft())->toBeGreaterThan(70) + ->and($gut->isExpiringSoon())->toBeFalse() + ->and($gut->certificate_error)->toBeNull() + ->and($kaputt->certificate_expires_at)->toBeNull() + ->and($kaputt->certificate_error)->toContain('internal error'); +}); + +/** + * Dreißig Tage: bei neunzig Tagen Laufzeit hätte die Erneuerung längst laufen + * müssen. Was darunter steht, ist kein knappes Zertifikat, sondern eine + * Erneuerung, die nicht stattfindet. + */ +it('calls a certificate with under thirty days left a problem', function () { + $host = new ProxyHost(['hostname' => 'x.clupilot.com']); + $host->certificate_expires_at = now()->addDays(12); + + expect($host->isExpiringSoon())->toBeTrue() + ->and($host->daysLeft())->toBeLessThan(14); + + $host->certificate_expires_at = now()->addDays(60); + expect($host->isExpiringSoon())->toBeFalse(); +}); + +/** + * Die Liste, die der root-eigene Helfer liest: NAMEN und Zweck, eine je Zeile. + * Keine Caddy-Blöcke. + */ +it('lists hostnames for the host helper, and nothing else', function () { + ProxyHost::create(['hostname' => 'files.clupilot.com', 'purpose' => 'public']); + ProxyHost::create(['hostname' => 'admin.clupilot.com', 'purpose' => 'console']); + + $this->artisan('clupilot:proxy-hosts') + ->expectsOutput('admin.clupilot.com console') + ->expectsOutput('files.clupilot.com public') + ->assertSuccessful(); +}); + +it('keeps the acme contact address, and offers the owner as the fallback', function () { + $owner = Operator::factory()->role('Owner')->create(['email' => 'inhaber@clupilot.com']); + + Livewire::actingAs($owner, 'operator')->test(ProxyHosts::class) + ->assertSee('inhaber@clupilot.com') + ->set('acmeEmail', 'ssl@clupilot.com') + ->call('saveAcmeEmail'); + + expect(Settings::get('proxy.acme_email'))->toBe('ssl@clupilot.com'); +}); + +it('is closed to an operator without the permission', function () { + Livewire::actingAs(Operator::factory()->create(), 'operator') + ->test(ProxyHosts::class) + ->assertForbidden(); +});