diff --git a/VERSION b/VERSION index 6357ac0..cbbcc34 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.67 +1.3.69 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 bbca7f6..e69f782 100644 --- a/deploy/caddy/Caddyfile.example +++ b/deploy/caddy/Caddyfile.example @@ -93,6 +93,43 @@ 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: +# +# - Rechtsdokumente (AGB, AV, TOM). Ihre Adressen stehen in Verträgen und auf +# Rechnungen und müssen Jahre halten. +# - Das Bootstrap-Archiv für die Host-Übernahme. Es wird von einem Server im +# RETTUNGSSYSTEM geholt — einer Maschine ohne Tunnel, mit einer öffentlichen +# Adresse, die in keiner Freigabeliste steht. Genau deshalb steht hier KEIN +# `import clupilot-console-allow.conf`: die Sperre der Konsole wäre hier die +# Sperre gegen den einzigen, der es braucht. +# +# Der Schutz sitzt in der Anwendung, nicht im Proxy: ohne gültigen Einmal-Code +# antwortet das Archiv mit 404, und die Dokumente sind ohnehin öffentlich. +# +# Auch KEIN `http://… { abort }` wie bei admin und ws weiter unten. Dieser Name +# soll bekannt sein — und ein abgewürgtes Port 80 nimmt der ACME-Prüfung den +# Weg, wenn Caddys eigener Handler einmal nicht als Erster drankommt. +files.example.com { + reverse_proxy 127.0.0.1:8080 +} + # Plain HTTP for the two hidden names. Without this Caddy answers port 80 with # an automatic redirect to https, which announces that both hostnames are # served here — the one thing this file exists to avoid. The ACME challenge is 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(); +});