Compare commits

..

No commits in common. "cfaea7dc82301d77ade12c04da20594ead90eb77" and "a21278eb520ab34aff5435ab22e572c32378a8b6" have entirely different histories.

12 changed files with 24 additions and 416 deletions

View File

@ -1 +1 @@
1.3.72
1.3.71

View File

@ -1,36 +0,0 @@
<?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\CertificateSweep;
use App\Services\Proxy\CertificateInspector;
use App\Support\Settings;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -43,22 +43,10 @@ class ProxyHosts extends Component
public ?string $notice = null;
public function mount(CertificateSweep $sweep): void
public function mount(): 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
@ -92,18 +80,7 @@ class ProxyHosts extends Component
{
$this->authorize('site.manage');
$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();
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
@ -128,14 +105,21 @@ class ProxyHosts extends Component
* ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang
* blockieren.
*/
public function checkCertificates(CertificateSweep $sweep): void
public function checkCertificates(CertificateInspector $inspector): void
{
$this->authorize('site.manage');
$sweep->sync();
$result = $sweep->check();
foreach (ProxyHost::query()->get() as $host) {
$result = $inspector->inspect($host->hostname);
$this->notice = __('proxy.checked_count', $result);
$host->forceFill([
'certificate_expires_at' => $result['expires_at'],
'certificate_checked_at' => now(),
'certificate_error' => $result['error'],
])->save();
}
$this->notice = __('proxy.checked');
}
/**
@ -158,21 +142,8 @@ class ProxyHosts extends Component
public function render()
{
$hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get();
return view('livewire.admin.proxy-hosts', [
'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'),
],
'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

View File

@ -13,23 +13,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class ProxyHost extends Model
{
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 $fillable = ['hostname', 'purpose', 'note'];
protected function casts(): array
{

View File

@ -1,85 +0,0 @@
<?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

@ -1,68 +0,0 @@
<?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

@ -1,32 +0,0 @@
<?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,16 +50,4 @@ 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,16 +50,4 @@ 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,23 +9,6 @@
<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>
@ -53,9 +36,6 @@
@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
@ -79,24 +59,17 @@
</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
<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>
</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">
@if ($summary['checkedAt'])
{{ __('proxy.last_checked', ['when' => $summary['checkedAt']->local()->isoFormat('D. MMM YYYY, HH:mm')]) }}
@endif
{{ __('proxy.apply_hint') }}
</p>
<p class="mt-2 text-xs leading-relaxed text-muted">{{ __('proxy.apply_hint') }}</p>
</section>
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">

View File

@ -225,19 +225,3 @@ 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,9 +40,7 @@ it('refuses anything that is not a hostname', function () {
proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname');
}
// 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);
expect(ProxyHost::query()->count())->toBe(0);
});
it('refuses the same hostname twice', function () {
@ -60,7 +58,7 @@ it('removes from the list without touching the running proxy', function () {
proxyPage()->call('remove', $host->id)->assertSee(__('proxy.removed'));
expect(ProxyHost::query()->where('source', 'manual')->count())->toBe(0);
expect(ProxyHost::query()->count())->toBe(0);
});
/**
@ -141,60 +139,3 @@ 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);
});