diff --git a/app/Console/Commands/CheckCertificates.php b/app/Console/Commands/CheckCertificates.php
new file mode 100644
index 0000000..9e349e8
--- /dev/null
+++ b/app/Console/Commands/CheckCertificates.php
@@ -0,0 +1,36 @@
+sync();
+ $result = $sweep->check();
+
+ $this->info(sprintf(
+ '%d hostname(s) added, %d checked, %d without a valid certificate',
+ $added,
+ $result['checked'],
+ $result['failing'],
+ ));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Livewire/Admin/ProxyHosts.php b/app/Livewire/Admin/ProxyHosts.php
index 44dc3b5..bf5b478 100644
--- a/app/Livewire/Admin/ProxyHosts.php
+++ b/app/Livewire/Admin/ProxyHosts.php
@@ -5,7 +5,7 @@ namespace App\Livewire\Admin;
use App\Models\Operator;
use App\Models\ProxyHost;
use App\Services\Deployment\UpdateChannel;
-use App\Services\Proxy\CertificateInspector;
+use App\Services\Proxy\CertificateSweep;
use App\Support\Settings;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@@ -43,10 +43,22 @@ class ProxyHosts extends Component
public ?string $notice = null;
- public function mount(): void
+ public function mount(CertificateSweep $sweep): void
{
$this->authorize('site.manage');
$this->acmeEmail = (string) Settings::get('proxy.acme_email', '');
+
+ // Die Namen aus der Umgebung übernehmen, damit die Seite NIE leer ist.
+ //
+ // Die erste Fassung war ein leeres Register: sie zeigte nichts, während
+ // die Installation längst ein halbes Dutzend Namen bediente, deren
+ // Zertifikate der Betreiber gerade sehen wollte. Eine Übersicht, die man
+ // erst befüllen muss, beantwortet „was habe ich" nicht.
+ //
+ // Nur der Abgleich, NICHT die Messung: die geht ins Netz, und ein
+ // halbes Dutzend TLS-Handschläge beim Öffnen einer Seite bedeutet
+ // Warten — mit einem Namen darunter, der gerade nicht auflöst.
+ $sweep->sync();
}
protected function rules(): array
@@ -80,7 +92,18 @@ class ProxyHosts extends Component
{
$this->authorize('site.manage');
- ProxyHost::query()->whereKey($id)->delete();
+ $host = ProxyHost::query()->whereKey($id)->first();
+
+ // Ein Name aus der Umgebung kommt beim nächsten Abgleich wieder. Ihn
+ // löschen zu lassen wäre ein Knopf, der nichts bewirkt — schlimmer als
+ // keiner, weil er einmal geglaubt wird.
+ if ($host === null || $host->isFromConfig()) {
+ $this->notice = __('proxy.remove_from_config');
+
+ return;
+ }
+
+ $host->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
@@ -105,21 +128,14 @@ class ProxyHosts extends Component
* ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang
* blockieren.
*/
- public function checkCertificates(CertificateInspector $inspector): void
+ public function checkCertificates(CertificateSweep $sweep): void
{
$this->authorize('site.manage');
- foreach (ProxyHost::query()->get() as $host) {
- $result = $inspector->inspect($host->hostname);
+ $sweep->sync();
+ $result = $sweep->check();
- $host->forceFill([
- 'certificate_expires_at' => $result['expires_at'],
- 'certificate_checked_at' => now(),
- 'certificate_error' => $result['error'],
- ])->save();
- }
-
- $this->notice = __('proxy.checked');
+ $this->notice = __('proxy.checked_count', $result);
}
/**
@@ -142,8 +158,21 @@ class ProxyHosts extends Component
public function render()
{
+ $hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get();
+
return view('livewire.admin.proxy-hosts', [
- 'hosts' => ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get(),
+ 'hosts' => $hosts,
+ // Die Übersicht in drei Zahlen. Was ein Betreiber wissen will,
+ // bevor er eine Liste liest: ist etwas kaputt, und läuft etwas bald
+ // ab.
+ 'summary' => [
+ 'total' => $hosts->count(),
+ 'valid' => $hosts->filter(fn ($h) => $h->hasCertificate() && ! $h->isExpiringSoon())->count(),
+ 'soon' => $hosts->filter(fn ($h) => $h->hasCertificate() && $h->isExpiringSoon())->count(),
+ 'failing' => $hosts->filter(fn ($h) => $h->certificate_checked_at !== null && $h->certificate_error !== null)->count(),
+ 'unchecked' => $hosts->filter(fn ($h) => $h->certificate_checked_at === null)->count(),
+ 'checkedAt' => $hosts->max('certificate_checked_at'),
+ ],
'purposes' => ProxyHost::PURPOSES,
// Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die
// die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine
diff --git a/app/Models/ProxyHost.php b/app/Models/ProxyHost.php
index e422b7f..110ca67 100644
--- a/app/Models/ProxyHost.php
+++ b/app/Models/ProxyHost.php
@@ -13,7 +13,23 @@ use Illuminate\Database\Eloquent\Model;
*/
class ProxyHost extends Model
{
- protected $fillable = ['hostname', 'purpose', 'note'];
+ protected $fillable = ['hostname', 'purpose', 'note', 'source'];
+
+ /**
+ * Aus der Umgebung abgeleitet — nicht von Hand angelegt, also auch nicht
+ * von Hand zu entfernen: beim nächsten Abgleich käme er wieder, und ein
+ * Knopf, der nichts bewirkt, ist schlimmer als keiner.
+ */
+ public function isFromConfig(): bool
+ {
+ return $this->source === 'config';
+ }
+
+ /** Hat die Prüfung ein gültiges Zertifikat gefunden? */
+ public function hasCertificate(): bool
+ {
+ return $this->certificate_expires_at !== null && $this->certificate_error === null;
+ }
protected function casts(): array
{
diff --git a/app/Services/Proxy/CertificateSweep.php b/app/Services/Proxy/CertificateSweep.php
new file mode 100644
index 0000000..7be45ff
--- /dev/null
+++ b/app/Services/Proxy/CertificateSweep.php
@@ -0,0 +1,85 @@
+ $purpose) {
+ $existing = ProxyHost::query()->where('hostname', $hostname)->first();
+
+ if ($existing === null) {
+ ProxyHost::create([
+ 'hostname' => $hostname,
+ 'purpose' => $purpose,
+ 'source' => 'config',
+ ]);
+ $added++;
+
+ continue;
+ }
+
+ // Ein von Hand angelegter Name, der inzwischen auch in der Umgebung
+ // steht, bleibt „manual" — sonst verlöre der Betreiber die
+ // Möglichkeit, ihn wieder zu entfernen, ohne je etwas dafür getan
+ // zu haben.
+ if ($existing->isFromConfig() && $existing->purpose !== $purpose) {
+ $existing->forceFill(['purpose' => $purpose])->save();
+ }
+ }
+
+ return $added;
+ }
+
+ /** @return array{checked: int, failing: int} */
+ public function check(): array
+ {
+ $checked = 0;
+ $failing = 0;
+
+ foreach (ProxyHost::query()->get() as $host) {
+ $result = $this->inspector->inspect($host->hostname);
+
+ $host->forceFill([
+ 'certificate_expires_at' => $result['expires_at'],
+ 'certificate_checked_at' => now(),
+ 'certificate_error' => $result['error'],
+ ])->save();
+
+ $checked++;
+ if ($result['error'] !== null) {
+ $failing++;
+ }
+ }
+
+ return ['checked' => $checked, 'failing' => $failing];
+ }
+}
diff --git a/app/Support/KnownHostnames.php b/app/Support/KnownHostnames.php
new file mode 100644
index 0000000..fc9460d
--- /dev/null
+++ b/app/Support/KnownHostnames.php
@@ -0,0 +1,68 @@
+ hostname => Zweck ('public' | 'console')
+ */
+ public static function all(): array
+ {
+ $found = [];
+
+ foreach ((array) config('admin_access.site_hosts', []) as $host) {
+ self::add($found, $host, 'public');
+ }
+
+ foreach ([
+ 'admin_access.app_host',
+ 'admin_access.status_host',
+ 'admin_access.files_host',
+ ] as $key) {
+ self::add($found, (string) config($key, ''), 'public');
+ }
+
+ // Die Konsole und ihre Ausweichnamen. `console`, weil sie hinter der
+ // Netzsperre liegen — ein Name mit dem falschen Zweck sähe hier aus wie
+ // eine funktionierende Seite und wäre eine offene Konsole.
+ foreach ((array) config('admin_access.hosts', []) as $host) {
+ self::add($found, $host, 'console');
+ }
+
+ ksort($found);
+
+ return $found;
+ }
+
+ private static function add(array &$found, string $host, string $purpose): void
+ {
+ $host = strtolower(trim($host));
+
+ // Kein Schema, kein Pfad, kein Port — und mindestens ein Punkt. Ein
+ // blanker Name wie `localhost` ist ein Entwicklungsrest und hat kein
+ // Zertifikat, das jemand überwachen wollte.
+ if ($host === '' || ! str_contains($host, '.') || preg_match('/[^a-z0-9.\-]/', $host)) {
+ return;
+ }
+
+ // Konsole gewinnt gegen öffentlich, wenn ein Name in beiden Listen
+ // steht: die engere Aussage ist die richtige.
+ if (($found[$host] ?? null) !== 'console') {
+ $found[$host] = $purpose;
+ }
+ }
+}
diff --git a/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php b/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php
new file mode 100644
index 0000000..cde8ea3
--- /dev/null
+++ b/database/migrations/2026_08_03_140000_say_where_a_proxy_host_came_from.php
@@ -0,0 +1,32 @@
+string('source')->default('manual')->after('purpose');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('proxy_hosts', function (Blueprint $table) {
+ $table->dropColumn('source');
+ });
+ }
+};
diff --git a/lang/de/proxy.php b/lang/de/proxy.php
index 3e081b3..e4eed8f 100644
--- a/lang/de/proxy.php
+++ b/lang/de/proxy.php
@@ -50,4 +50,16 @@ return [
'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.',
],
+
+ 'stat' => [
+ 'total' => 'Hostnamen',
+ 'valid' => 'Zertifikat gültig',
+ 'soon' => 'Läuft bald ab',
+ 'failing' => 'Ohne Zertifikat',
+ ],
+ 'from_config' => 'aus der Konfiguration',
+ 'remove_from_config' => 'Dieser Name kommt aus der Umgebung dieser Installation und käme beim nächsten Abgleich wieder. Entfernen lässt er sich nur dort, wo er gesetzt ist.',
+ 'never_checked' => ':count Hostname(n) wurden noch nie geprüft. Die Messung läuft täglich um 04:17 — oder jetzt, über „Zertifikate prüfen".',
+ 'last_checked' => 'Zuletzt geprüft: :when.',
+ 'checked_count' => ':checked geprüft, :failing ohne gültiges Zertifikat.',
];
diff --git a/lang/en/proxy.php b/lang/en/proxy.php
index f405ed2..af8a215 100644
--- a/lang/en/proxy.php
+++ b/lang/en/proxy.php
@@ -50,4 +50,16 @@ return [
'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.',
],
+
+ 'stat' => [
+ 'total' => 'Hostnames',
+ 'valid' => 'Certificate valid',
+ 'soon' => 'Expiring soon',
+ 'failing' => 'No certificate',
+ ],
+ 'from_config' => 'from the configuration',
+ 'remove_from_config' => 'This name comes from the installation\'s environment and would return at the next sync. It can only be removed where it is set.',
+ 'never_checked' => ':count hostname(s) have never been checked. The measurement runs daily at 04:17 — or now, via "Check certificates".',
+ 'last_checked' => 'Last checked: :when.',
+ 'checked_count' => ':checked checked, :failing without a valid certificate.',
];
diff --git a/resources/views/livewire/admin/proxy-hosts.blade.php b/resources/views/livewire/admin/proxy-hosts.blade.php
index 0dfaf96..4641658 100644
--- a/resources/views/livewire/admin/proxy-hosts.blade.php
+++ b/resources/views/livewire/admin/proxy-hosts.blade.php
@@ -9,6 +9,23 @@
{{ __('proxy.apply_hint') }}
++ @if ($summary['checkedAt']) + {{ __('proxy.last_checked', ['when' => $summary['checkedAt']->local()->isoFormat('D. MMM YYYY, HH:mm')]) }} + @endif + {{ __('proxy.apply_hint') }} +