Compare commits
5 Commits
fced87d23a
...
3c5bded19c
| Author | SHA1 | Date |
|---|---|---|
|
|
3c5bded19c | |
|
|
87264ef1f7 | |
|
|
006ce3568b | |
|
|
a3b43c5175 | |
|
|
20d87c0474 |
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ProxyHost;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Gibt die Hostnamen aus, die der Reverse Proxy bedienen soll — eine je Zeile,
|
||||
* mit ihrem Zweck.
|
||||
*
|
||||
* NUR die Liste, nicht die fertige Caddy-Konfiguration. Das ist der ganze Punkt.
|
||||
*
|
||||
* `install-agent.sh` begründet für die sudoers-Freigabe: „a grant is only worth
|
||||
* anything if the holder cannot change what it grants." Dürfte diese Anwendung
|
||||
* Caddy-Blöcke schreiben, die ein root-Helfer ungeprüft einbaut, hätte sie über
|
||||
* den Umweg der Konfiguration alles, was der Proxy kann — Weiterleitungen
|
||||
* irgendwohin, Dateiauslieferung aus jedem Verzeichnis. Sie gibt deshalb NAMEN
|
||||
* aus, und die Vorlage darum herum steht im root-eigenen Helfer, wo diese
|
||||
* Anwendung sie nicht anfassen kann.
|
||||
*
|
||||
* Format je Zeile: `<hostname> <purpose>`.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Operator;
|
||||
use App\Models\ProxyHost;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Services\Proxy\CertificateInspector;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Die Hostnamen dieser Installation — anlegen, ansehen, anwenden.
|
||||
*
|
||||
* Entstanden aus einem Handgriff, der teuer war: `files.clupilot.com` hatte
|
||||
* einen DNS-Eintrag, hatte `FILES_HOST` in der Umgebung, lag auf der richtigen
|
||||
* Maschine — und hatte trotzdem kein Zertifikat, weil `/etc/caddy/Caddyfile`
|
||||
* von Hand gepflegt wird und niemand daran dachte, dass das ein zweiter,
|
||||
* getrennter Schritt ist. Nichts in der Konsole hätte das verraten.
|
||||
*
|
||||
* Zwei Dinge deshalb an einer Stelle:
|
||||
*
|
||||
* - Der WUNSCH: welche Namen bedient werden sollen. Er geht über den Agenten
|
||||
* in die Proxy-Konfiguration, ohne dass sich jemand auf den Server meldet.
|
||||
* - Die WIRKLICHKEIT: ob der Name ein Zertifikat hat und wie lange noch.
|
||||
* Gemessen an dem, was ein Besucher bekommt — nicht aus der Konfiguration
|
||||
* gelesen, denn genau die sah im Fehlerfall richtig aus.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class ProxyHosts extends Component
|
||||
{
|
||||
#[Validate]
|
||||
public string $hostname = '';
|
||||
|
||||
public string $purpose = 'public';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
public string $acmeEmail = '';
|
||||
|
||||
public ?string $notice = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Ein Hostname, den der Reverse Proxy für diese Installation bedienen soll.
|
||||
*
|
||||
* Die Zeile ist der Wunsch, nicht der Zustand. Ob der Name wirklich bedient
|
||||
* wird und ob er ein Zertifikat hat, steht nicht hier — das wird gemessen, an
|
||||
* dem, was ein Besucher tatsächlich bekommt (siehe CertificateInspector).
|
||||
*/
|
||||
class ProxyHost extends Model
|
||||
{
|
||||
protected $fillable = ['hostname', 'purpose', 'note'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'certificate_expires_at' => '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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Proxy;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Was ein Besucher wirklich bekommt, wenn er diesen Namen aufruft.
|
||||
*
|
||||
* Gemessen, nicht aus der Konfiguration gelesen. Eine Zeile im Caddyfile sagt,
|
||||
* was gewollt ist; ein TLS-Handschlag sagt, was passiert. Genau zwischen diesen
|
||||
* beiden lag der Fall, der diese Klasse veranlasst hat: DNS richtig gesetzt,
|
||||
* `FILES_HOST` richtig gesetzt, Proxy auf derselben Maschine — und trotzdem
|
||||
* `tlsv1 alert internal error`, weil kein Zertifikat für den Namen existierte.
|
||||
* Keine der drei Einstellungen hätte das verraten.
|
||||
*
|
||||
* Deshalb wird von außen nach innen gefragt und nicht umgekehrt.
|
||||
*/
|
||||
class CertificateInspector
|
||||
{
|
||||
/**
|
||||
* @return array{ok: bool, expires_at: ?Carbon, issuer: ?string, error: ?string}
|
||||
*/
|
||||
public function inspect(string $hostname, int $timeoutSeconds = 8): array
|
||||
{
|
||||
// `verify_peer` bleibt AN. Ein Zertifikat, das die Prüfung nicht
|
||||
// besteht, ist für diese Frage kein Zertifikat — der Browser des
|
||||
// Kunden sieht es genauso, und eine Anzeige, die es trotzdem als
|
||||
// gültig führte, wäre die Attrappe, vor der R19 warnt.
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Die Hostnamen, die der Reverse Proxy für diese Installation bedienen soll.
|
||||
*
|
||||
* Bisher standen sie ausschließlich von Hand in `/etc/caddy/Caddyfile`. Beim
|
||||
* Einrichten von `files.clupilot.com` hat das gekostet, was es immer kostet:
|
||||
* DNS war gesetzt, die Umgebungsvariable war gesetzt, und trotzdem gab es kein
|
||||
* Zertifikat — weil der Proxy den Namen nicht kannte und niemand daran gedacht
|
||||
* hatte, dass das ein zweiter, getrennter Schritt ist.
|
||||
*
|
||||
* `purpose` ist nicht Zierde: er entscheidet, was der Name überhaupt darf. Ein
|
||||
* `files`-Name muss vom offenen Internet erreichbar sein, ein `console`-Name
|
||||
* darf es gerade nicht — und diese Unterscheidung gehört nicht in einen Kopf,
|
||||
* sondern in eine Spalte.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('proxy_hosts', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <<EOF
|
||||
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP ensure-rsync
|
||||
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP apply-proxy-hosts
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/clupilot-host-step
|
||||
visudo -cf /etc/sudoers.d/clupilot-host-step >/dev/null || {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => '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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => '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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<div class="space-y-6">
|
||||
<header class="animate-rise">
|
||||
<p class="lbl">{{ __('proxy.eyebrow') }}</p>
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">{{ __('proxy.title') }}</h1>
|
||||
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('proxy.subtitle') }}</p>
|
||||
</header>
|
||||
|
||||
@if ($notice)
|
||||
<x-ui.alert variant="info" class="animate-rise">{{ $notice }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<section class="animate-rise [animation-delay:40ms]">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('proxy.list_title') }}</h2>
|
||||
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('proxy.list_sub') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-ui.button variant="secondary" size="sm" wire:click="checkCertificates" wire:loading.attr="disabled">
|
||||
<x-slot:icon><x-ui.icon name="refresh" class="size-4" /></x-slot:icon>
|
||||
{{ __('proxy.check') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="primary" size="sm" wire:click="apply" wire:loading.attr="disabled">
|
||||
<x-slot:icon><x-ui.icon name="send" class="size-4" /></x-slot:icon>
|
||||
{{ __('proxy.apply') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-ui.panel class="mt-4">
|
||||
@forelse ($hosts as $host)
|
||||
<div class="grid gap-x-6 gap-y-2 px-6 py-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate font-mono text-sm font-medium text-ink">{{ $host->hostname }}</span>
|
||||
@if ($host->isConsole())
|
||||
<x-ui.badge status="warning">{{ __('proxy.purpose.console') }}</x-ui.badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 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. --}}
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
@if ($host->certificate_checked_at === null)
|
||||
{{ __('proxy.cert.unknown') }}
|
||||
@elseif ($host->certificate_error)
|
||||
<span class="text-danger">{{ __('proxy.cert.error', ['error' => $host->certificate_error]) }}</span>
|
||||
@elseif ($host->daysLeft() === null)
|
||||
{{ __('proxy.cert.unknown') }}
|
||||
@elseif ($host->isExpiringSoon())
|
||||
<span class="text-danger">{{ __('proxy.cert.soon', ['days' => $host->daysLeft(), 'date' => $host->certificate_expires_at->local()->isoFormat('D. MMM YYYY')]) }}</span>
|
||||
@else
|
||||
{{ __('proxy.cert.valid', ['days' => $host->daysLeft(), 'date' => $host->certificate_expires_at->local()->isoFormat('D. MMM YYYY')]) }}
|
||||
@endif
|
||||
@if ($host->note)
|
||||
· {{ $host->note }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">{{ __('proxy.apply_hint') }}</p>
|
||||
</section>
|
||||
|
||||
<form wire:submit="add" class="animate-rise [animation-delay:60ms]">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('proxy.add_title') }}</h2>
|
||||
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('proxy.add_sub') }}</p>
|
||||
|
||||
<x-ui.panel class="mt-4">
|
||||
<x-ui.row :label="__('proxy.field.hostname')" :hint="__('proxy.field.hostname_hint')" for="hostname">
|
||||
<x-ui.input name="hostname" wire:model="hostname" placeholder="files.clupilot.com" />
|
||||
</x-ui.row>
|
||||
<x-ui.row :label="__('proxy.field.purpose')" :hint="__('proxy.field.purpose_hint')" for="purpose">
|
||||
<select id="purpose" wire:model="purpose"
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
|
||||
@foreach ($purposes as $option)
|
||||
<option value="{{ $option }}">{{ __('proxy.purpose.'.$option) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</x-ui.row>
|
||||
<x-ui.row :label="__('proxy.field.note')" for="note">
|
||||
<x-ui.input name="note" wire:model="note" />
|
||||
</x-ui.row>
|
||||
</x-ui.panel>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-ui.button variant="primary" type="submit">{{ __('proxy.add') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveAcmeEmail" class="animate-rise [animation-delay:80ms]">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">{{ __('proxy.acme_title') }}</h2>
|
||||
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('proxy.acme_sub') }}</p>
|
||||
|
||||
<x-ui.panel class="mt-4">
|
||||
<x-ui.row :label="__('proxy.field.acme_email')"
|
||||
:hint="$fallbackEmail ? __('proxy.field.acme_email_hint', ['email' => $fallbackEmail]) : __('proxy.field.acme_email_hint_none')"
|
||||
for="acmeEmail">
|
||||
<x-ui.input name="acmeEmail" wire:model="acmeEmail" type="email" :placeholder="$fallbackEmail" />
|
||||
</x-ui.row>
|
||||
</x-ui.panel>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-ui.button variant="secondary" type="submit">{{ __('proxy.save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ProxyHosts;
|
||||
use App\Models\Operator;
|
||||
use App\Models\ProxyHost;
|
||||
use App\Services\Proxy\CertificateInspector;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function proxyPage(): \Livewire\Features\SupportTesting\Testable
|
||||
{
|
||||
return Livewire::actingAs(Operator::factory()->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();
|
||||
});
|
||||
Loading…
Reference in New Issue