Compare commits
2 Commits
a21278eb52
...
cfaea7dc82
| Author | SHA1 | Date |
|---|---|---|
|
|
cfaea7dc82 | |
|
|
1e3199b72e |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ namespace App\Livewire\Admin;
|
||||||
use App\Models\Operator;
|
use App\Models\Operator;
|
||||||
use App\Models\ProxyHost;
|
use App\Models\ProxyHost;
|
||||||
use App\Services\Deployment\UpdateChannel;
|
use App\Services\Deployment\UpdateChannel;
|
||||||
use App\Services\Proxy\CertificateInspector;
|
use App\Services\Proxy\CertificateSweep;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
|
@ -43,10 +43,22 @@ class ProxyHosts extends Component
|
||||||
|
|
||||||
public ?string $notice = null;
|
public ?string $notice = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(CertificateSweep $sweep): void
|
||||||
{
|
{
|
||||||
$this->authorize('site.manage');
|
$this->authorize('site.manage');
|
||||||
$this->acmeEmail = (string) Settings::get('proxy.acme_email', '');
|
$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
|
protected function rules(): array
|
||||||
|
|
@ -80,7 +92,18 @@ class ProxyHosts extends Component
|
||||||
{
|
{
|
||||||
$this->authorize('site.manage');
|
$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
|
// 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
|
// 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
|
* ein Hostname, der nicht auflöst, darf die Seite nicht minutenlang
|
||||||
* blockieren.
|
* blockieren.
|
||||||
*/
|
*/
|
||||||
public function checkCertificates(CertificateInspector $inspector): void
|
public function checkCertificates(CertificateSweep $sweep): void
|
||||||
{
|
{
|
||||||
$this->authorize('site.manage');
|
$this->authorize('site.manage');
|
||||||
|
|
||||||
foreach (ProxyHost::query()->get() as $host) {
|
$sweep->sync();
|
||||||
$result = $inspector->inspect($host->hostname);
|
$result = $sweep->check();
|
||||||
|
|
||||||
$host->forceFill([
|
$this->notice = __('proxy.checked_count', $result);
|
||||||
'certificate_expires_at' => $result['expires_at'],
|
|
||||||
'certificate_checked_at' => now(),
|
|
||||||
'certificate_error' => $result['error'],
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->notice = __('proxy.checked');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -142,8 +158,21 @@ class ProxyHosts extends Component
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
|
$hosts = ProxyHost::query()->orderBy('purpose')->orderBy('hostname')->get();
|
||||||
|
|
||||||
return view('livewire.admin.proxy-hosts', [
|
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,
|
'purposes' => ProxyHost::PURPOSES,
|
||||||
// Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die
|
// Leer heißt: die Adresse des Inhabers. Eine Kontaktadresse, an die
|
||||||
// die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine
|
// die Ablaufwarnungen gehen, darf nicht leer bleiben, und die eine
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,23 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
*/
|
*/
|
||||||
class ProxyHost extends 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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -50,4 +50,16 @@ return [
|
||||||
'acme_email_hint' => 'Leer lassen: dann wird :email genommen, die Adresse des Inhabers.',
|
'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.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,16 @@ return [
|
||||||
'acme_email_hint' => 'Leave empty to use :email, the owner\'s 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.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,23 @@
|
||||||
<x-ui.alert variant="info" class="animate-rise">{{ $notice }}</x-ui.alert>
|
<x-ui.alert variant="info" class="animate-rise">{{ $notice }}</x-ui.alert>
|
||||||
@endif
|
@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]">
|
<section class="animate-rise [animation-delay:40ms]">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -36,6 +53,9 @@
|
||||||
@if ($host->isConsole())
|
@if ($host->isConsole())
|
||||||
<x-ui.badge status="warning">{{ __('proxy.purpose.console') }}</x-ui.badge>
|
<x-ui.badge status="warning">{{ __('proxy.purpose.console') }}</x-ui.badge>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($host->isFromConfig())
|
||||||
|
<span class="text-xs text-muted">{{ __('proxy.from_config') }}</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Der Zustand steht als Satz, nicht als Farbe: ein
|
{{-- Der Zustand steht als Satz, nicht als Farbe: ein
|
||||||
|
|
@ -59,17 +79,24 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" wire:click="remove({{ $host->id }})"
|
@unless ($host->isFromConfig())
|
||||||
class="justify-self-start text-xs font-medium text-muted transition hover:text-danger sm:justify-self-end">
|
<button type="button" wire:click="remove({{ $host->id }})"
|
||||||
{{ __('proxy.remove') }}
|
class="justify-self-start text-xs font-medium text-muted transition hover:text-danger sm:justify-self-end">
|
||||||
</button>
|
{{ __('proxy.remove') }}
|
||||||
|
</button>
|
||||||
|
@endunless
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
<div class="px-6 py-8 text-center text-sm text-muted">{{ __('proxy.empty') }}</div>
|
<div class="px-6 py-8 text-center text-sm text-muted">{{ __('proxy.empty') }}</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
</x-ui.panel>
|
</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>
|
</section>
|
||||||
|
|
||||||
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">
|
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">
|
||||||
|
|
|
||||||
|
|
@ -225,3 +225,19 @@ Schedule::command('clupilot:verify-vat-ids')
|
||||||
Schedule::command('clupilot:end-due-services')
|
Schedule::command('clupilot:end-due-services')
|
||||||
->hourly()
|
->hourly()
|
||||||
->withoutOverlapping();
|
->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');
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ it('refuses anything that is not a hostname', function () {
|
||||||
proxyPage()->set('hostname', $attempt)->call('add')->assertHasErrors('hostname');
|
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 () {
|
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'));
|
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)
|
->test(ProxyHosts::class)
|
||||||
->assertForbidden();
|
->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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue