Compare commits

...

2 Commits

Author SHA1 Message Date
nexxo cfaea7dc82 Fill the hostname page from the installation, not from a blank form
The page was empty, and that was a design fault rather than a missing button. I
built a register that starts blank — while the installation already serves half
a dozen names whose certificates were exactly what the operator wanted to see.
An overview you have to populate first does not answer "what do I have".

So the names are derived now, from the same configuration routes/web.php builds
its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a
name is in the environment, the application answers on it, and then it belongs
in this list without anybody typing it a second time. Opening the page syncs
them; the list is never empty again.

Syncing and measuring are deliberately separate. The sync costs nothing and runs
on page load. The measurement goes out over the network and runs on the button
or on a schedule — doing it on page load would mean waiting through half a dozen
TLS handshakes, and one of them is always the name that currently does not
resolve.

Which is the other half of what was missing: there was no overview because
nothing measured unless somebody pressed a button. A daily run at 04:17 fills
it, because the question that matters is not "is it valid right now" but "is
renewal running" — a certificate expiring in forty days is fine, the same one at
twenty means something has been broken for a week. Only a measurement taken
while nobody is looking can tell those apart.

The page now opens with four counts — total, valid, expiring soon, without a
certificate — and says when it last measured. A name that comes from the
environment is marked as such and cannot be removed here: it would come back at
the next sync, and a button that does nothing is worse than no button, because
it gets believed once.

2040 tests pass, assets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:53:12 +02:00
nexxo 1e3199b72e Release v1.3.72
tests / pest (push) Failing after 8m45s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
install-agent.sh now creates /etc/caddy/clupilot-proxy-hosts.conf and adds the
import line itself, validating afterwards and taking the line back out if Caddy
rejects it. The previous release told the operator to add that line by hand
while nothing created the file — and an import of a missing file makes the whole
proxy configuration invalid, which would have taken the portal down with it.

A hostname already written by hand in the Caddyfile is skipped when the managed
file is rendered. Two blocks for one name make Caddy reject everything.

Run once per server:

  sudo bash /opt/clupilot/deploy/install-agent.sh

2037 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:35:38 +02:00
12 changed files with 416 additions and 24 deletions

View File

@ -1 +1 @@
1.3.71
1.3.72

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Proxy\CertificateSweep;
use Illuminate\Console\Command;
/**
* Täglich: welche Namen bedient diese Installation, und wie lange gelten ihre
* Zertifikate noch.
*
* Nach Zeitplan und nicht nur auf Knopfdruck, weil die Frage, auf die es
* ankommt, nicht „ist es gerade gültig" lautet, sondern „läuft die Erneuerung".
* Die beantwortet nur eine Messung, die auch dann läuft, wenn niemand hinsieht.
*/
class CheckCertificates extends Command
{
protected $signature = 'clupilot:check-certificates';
protected $description = 'Syncs the known hostnames and measures their certificates';
public function handle(CertificateSweep $sweep): int
{
$added = $sweep->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;
}
}

View File

@ -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

View File

@ -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
{

View File

@ -0,0 +1,85 @@
<?php
namespace App\Services\Proxy;
use App\Models\ProxyHost;
use App\Support\KnownHostnames;
/**
* Bringt die Liste auf den Stand der Installation und misst, was jeder Name
* gerade wirklich ausliefert.
*
* Zwei getrennte Hälften, mit Absicht: `sync()` kostet nichts und darf beim
* Öffnen der Seite laufen, `check()` geht ins Netz und läuft auf Knopfdruck
* oder nach Zeitplan. Beim Seitenaufruf zu messen hieße, den Betreiber auf ein
* halbes Dutzend TLS-Handschläge warten zu lassen und einer davon ist immer
* der Name, der gerade nicht auflöst.
*/
class CertificateSweep
{
public function __construct(private CertificateInspector $inspector) {}
/**
* Die Namen aus der Umgebung in die Liste übernehmen.
*
* Anlegen und aktualisieren, nie löschen: ein Name, der aus der Umgebung
* verschwindet, bleibt als Zeile stehen. Sein Zertifikat läuft ja weiter,
* und ein Eintrag, der still verschwindet, ist genau das Gegenteil einer
* Übersicht.
*
* @return int wie viele Zeilen neu hinzugekommen sind
*/
public function sync(): int
{
$added = 0;
foreach (KnownHostnames::all() as $hostname => $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];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Support;
/**
* Die Hostnamen, die diese Installation ohnehin schon bedient.
*
* Abgeleitet, nie getippt. Genau das war der Fehler in der ersten Fassung der
* Hostnamen-Seite: sie war ein leeres Register und zeigte deshalb nichts
* während die Installation längst ein halbes Dutzend Namen bediente, deren
* Zertifikate der Betreiber gerade sehen wollte. Eine Übersicht, die erst
* befüllt werden muss, beantwortet die Frage „was habe ich" nicht.
*
* Die Quellen sind dieselben, aus denen `routes/web.php` seine Domain-Bindungen
* baut. Steht ein Name in der Umgebung, antwortet die Anwendung darauf und
* dann gehört er in diese Liste, ohne dass jemand ihn ein zweites Mal einträgt.
*/
final class KnownHostnames
{
/**
* @return array<string, string> 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;
}
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Woher ein Hostname kommt und damit, ob er sich entfernen lässt.
*
* `config` aus der Umgebung dieser Installation abgeleitet (APP_HOST,
* SITE_HOSTS, FILES_HOST, ADMIN_HOSTS ). Er steht in der Liste,
* damit sein Zertifikat überwacht wird, und lässt sich hier nicht
* löschen: gelöscht würde er beim nächsten Abgleich wiederkommen,
* und ein Knopf, der nichts bewirkt, ist schlimmer als keiner.
* `manual` vom Betreiber angelegt. Der geht auch wieder weg.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('proxy_hosts', function (Blueprint $table) {
$table->string('source')->default('manual')->after('purpose');
});
}
public function down(): void
{
Schema::table('proxy_hosts', function (Blueprint $table) {
$table->dropColumn('source');
});
}
};

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -9,6 +9,23 @@
<x-ui.alert variant="info" class="animate-rise">{{ $notice }}</x-ui.alert>
@endif
{{-- Die Übersicht in Zahlen, vor der Liste. Was ein Betreiber wissen will,
bevor er zwölf Zeilen liest: ist etwas kaputt, läuft etwas bald ab. --}}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 animate-rise [animation-delay:20ms]">
<x-ui.stat-tile :label="__('proxy.stat.total')" :value="$summary['total']" />
<x-ui.stat-tile :label="__('proxy.stat.valid')" :value="$summary['valid']" />
<x-ui.stat-tile :label="__('proxy.stat.soon')" :value="$summary['soon']"
:tone="$summary['soon'] > 0 ? 'warning' : null" />
<x-ui.stat-tile :label="__('proxy.stat.failing')" :value="$summary['failing']"
:tone="$summary['failing'] > 0 ? 'danger' : null" />
</div>
@if ($summary['unchecked'] > 0)
<x-ui.alert variant="info" class="animate-rise [animation-delay:30ms]">
{{ __('proxy.never_checked', ['count' => $summary['unchecked']]) }}
</x-ui.alert>
@endif
<section class="animate-rise [animation-delay:40ms]">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@ -36,6 +53,9 @@
@if ($host->isConsole())
<x-ui.badge status="warning">{{ __('proxy.purpose.console') }}</x-ui.badge>
@endif
@if ($host->isFromConfig())
<span class="text-xs text-muted">{{ __('proxy.from_config') }}</span>
@endif
</div>
{{-- Der Zustand steht als Satz, nicht als Farbe: ein
@ -59,17 +79,24 @@
</p>
</div>
@unless ($host->isFromConfig())
<button type="button" wire:click="remove({{ $host->id }})"
class="justify-self-start text-xs font-medium text-muted transition hover:text-danger sm:justify-self-end">
{{ __('proxy.remove') }}
</button>
@endunless
</div>
@empty
<div class="px-6 py-8 text-center text-sm text-muted">{{ __('proxy.empty') }}</div>
@endforelse
</x-ui.panel>
<p class="mt-2 text-xs leading-relaxed text-muted">{{ __('proxy.apply_hint') }}</p>
<p class="mt-2 text-xs leading-relaxed text-muted">
@if ($summary['checkedAt'])
{{ __('proxy.last_checked', ['when' => $summary['checkedAt']->local()->isoFormat('D. MMM YYYY, HH:mm')]) }}
@endif
{{ __('proxy.apply_hint') }}
</p>
</section>
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">

View File

@ -225,3 +225,19 @@ Schedule::command('clupilot:verify-vat-ids')
Schedule::command('clupilot:end-due-services')
->hourly()
->withoutOverlapping();
// Zertifikate: welche Namen bedient diese Installation, und wie lange gelten
// sie noch.
//
// Nach Zeitplan und nicht nur auf Knopfdruck. Die Frage, auf die es ankommt,
// lautet nicht „ist es gerade gültig" — sondern „läuft die Erneuerung". Ein
// Zertifikat, das in vierzig Tagen abläuft, ist in Ordnung; dasselbe in
// zwanzig Tagen heißt, dass seit einer Woche etwas nicht funktioniert. Das
// sieht nur, wer misst, wenn niemand hinsieht.
//
// Früh, aber nicht zur vollen Stunde: Let's Encrypt bittet ausdrücklich darum,
// nicht auf den Punkt zu prüfen.
Schedule::command('clupilot:check-certificates')
->dailyAt('04:17')
->withoutOverlapping()
->name('check-certificates');

View File

@ -40,7 +40,9 @@ it('refuses anything that is not a hostname', function () {
proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname');
}
expect(ProxyHost::query()->count())->toBe(0);
// Nur die von Hand angelegten zählen: die Seite gleicht beim Öffnen die
// Namen aus der Umgebung ein, also ist die Tabelle nie leer.
expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0);
});
it('refuses the same hostname twice', function () {
@ -58,7 +60,7 @@ it('removes from the list without touching the running proxy', function () {
proxyPage()->call('remove', $host->id)->assertSee(__('proxy.removed'));
expect(ProxyHost::query()->count())->toBe(0);
expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0);
});
/**
@ -139,3 +141,60 @@ it('is closed to an operator without the permission', function () {
->test(ProxyHosts::class)
->assertForbidden();
});
/**
* Der Fund, der diese Seite zweimal falsch gemacht hat.
*
* 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.
*/
it('shows the hostnames this installation already serves, without being told', function () {
expect(ProxyHost::query()->count())->toBe(0);
proxyPage()->assertSee('files.clupilot.test');
$entdeckt = ProxyHost::query()->where('hostname', 'files.clupilot.test')->first();
expect($entdeckt)->not->toBeNull()
->and($entdeckt->isFromConfig())->toBeTrue();
});
/**
* Ein Name aus der Umgebung käme beim nächsten Abgleich wieder. Ein Knopf, der
* nichts bewirkt, ist schlimmer als keiner er wird einmal geglaubt.
*/
it('will not let go of a hostname that comes from the environment', function () {
proxyPage();
$ausDerKonfiguration = ProxyHost::query()->where('source', 'config')->firstOrFail();
proxyPage()->call('remove', $ausDerKonfiguration->id)
->assertSee(__('proxy.remove_from_config'));
expect(ProxyHost::query()->whereKey($ausDerKonfiguration->id)->exists())->toBeTrue();
});
/**
* Die Übersicht in Zahlen das, was ein Betreiber wissen will, bevor er eine
* Liste liest.
*/
it('counts what is valid, what expires soon and what is broken', function () {
proxyPage();
ProxyHost::query()->delete();
ProxyHost::create(['hostname' => 'gut.clupilot.com', 'purpose' => 'public'])
->forceFill(['certificate_expires_at' => now()->addDays(70), 'certificate_checked_at' => now()])->save();
ProxyHost::create(['hostname' => 'knapp.clupilot.com', 'purpose' => 'public'])
->forceFill(['certificate_expires_at' => now()->addDays(9), 'certificate_checked_at' => now()])->save();
ProxyHost::create(['hostname' => 'kaputt.clupilot.com', 'purpose' => 'public'])
->forceFill(['certificate_checked_at' => now(), 'certificate_error' => 'tlsv1 alert internal error'])->save();
$summary = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(ProxyHosts::class)
->viewData('summary');
expect($summary['valid'])->toBe(1)
->and($summary['soon'])->toBe(1)
->and($summary['failing'])->toBe(1);
});