Compare commits

...

2 Commits

Author SHA1 Message Date
nexxo 006ce3568b Manage hostnames and watch their certificates from the console
files.clupilot.com is why this exists. DNS pointed at the right machine, the
env var was set, the release was deployed — and there was still no certificate,
because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a
second, separate step. Nothing in the console would have said so. Three settings
looked correct and the address did not answer.

So the page holds two things side by side. The WISH — which names should be
served — and the REALITY: whether the name has a certificate and for how much
longer. The second is measured by opening a TLS connection and reading the
expiry, not by reading configuration, because the configuration is exactly what
looked right while the address was dead. verify_peer stays on: a certificate
that fails validation is not a certificate for this question, and a display that
called it valid would be the fake R19 records.

Applying goes through the existing agent, not a new channel. The console writes
a request, the path unit wakes the agent within a second, and the agent calls one
fixed command line of the root-owned helper.

What that helper is allowed to do is the careful part. It fetches the list
ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy
blocks — `php artisan clupilot:proxy-hosts` prints `<name> <purpose>` and nothing
else. Each name is matched against a strict pattern before it is used, and the
template around it lives in the helper, which the service account cannot touch.
install-agent.sh already states the principle for the sudoers grant: a grant is
only worth anything if the holder cannot change what it grants. A service account
that could write proxy configuration would have everything the proxy can do —
redirects anywhere, files from any directory.

Purpose is a column rather than a habit. A console name gets the network lock,
a public one does not, and a console name published without it looks exactly
like a working page.

Removing takes the name out of the list and NOT out of the running proxy. Two
decisions in one click, and the second one takes a site off the air.

There is deliberately no "renew" button. Caddy renews on its own at two thirds
of the lifetime; what an operator actually needs is a second attempt after an
issuance has failed, and that is a reload — which is what Apply does. Thirty days
is treated as a problem rather than a warning: at ninety days' lifetime, a
renewal should long since have run, so anything under it is not a tight
certificate but a renewal that is not happening.

The ACME contact falls back to the owner's address, because a contact nobody
reads is the step before expired customer certificates.

CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server
carrying the older helper to run the installer again — caught by the guard test
that compares the two halves.

2035 tests pass, assets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:55:55 +02:00
nexxo a3b43c5175 Release v1.3.68
The reference Caddyfile now carries a block for FILES_HOST. Setting the variable
alone was never enough: the proxy has to terminate TLS for that name and forward
it, and without a site block Caddy never even asks for a certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:34:15 +02:00
16 changed files with 926 additions and 3 deletions

View File

@ -1 +1 @@
1.3.64 1.3.68

View File

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

View File

@ -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');
}
}

70
app/Models/ProxyHost.php Normal file
View File

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

View File

@ -80,6 +80,22 @@ final class UpdateChannel
*/ */
private const KIND_ARCHIVE_KEY = 'archive-key'; 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. * Where the agent leaves a freshly minted key, once.
* *
@ -404,6 +420,25 @@ final class UpdateChannel
return $this->submit($by, self::KIND_CHECK); 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 * 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 * own startup never `app`. See update-agent.sh's handling of this kind

View File

@ -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);
}
}

View File

@ -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');
}
};

View File

@ -93,6 +93,21 @@ status.example.com {
reverse_proxy 127.0.0.1:8080 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 ──────────────────────────────────────────────── # ── Dateien zum Herunterladen ────────────────────────────────────────────────
# FILES_HOST aus der .env. Zwei Dinge liegen hier, und beide müssen von überall # FILES_HOST aus der .env. Zwei Dinge liegen hier, und beide müssen von überall
# erreichbar sein: # erreichbar sein:

View File

@ -52,7 +52,7 @@ cat > "$HOST_STEP" <<'EOF'
# One argument, from the list below. Nothing takes free-form input. # One argument, from the list below. Nothing takes free-form input.
set -euo pipefail set -euo pipefail
CONTRACT=1 CONTRACT=2
case "${1:-}" in case "${1:-}" in
contract) contract)
@ -72,6 +72,77 @@ case "${1:-}" in
command -v rsync >/dev/null 2>&1 || { echo "rsync still missing after install" >&2; exit 4; } 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 # Refused by name. sudoers already permits only the exact command lines
# above, so this is the second of two locks, not the only one. # 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. # nothing else the file might ever learn to do.
cat > /etc/sudoers.d/clupilot-host-step <<EOF cat > /etc/sudoers.d/clupilot-host-step <<EOF
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP ensure-rsync $APP_USER ALL=(root) NOPASSWD: $HOST_STEP ensure-rsync
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP apply-proxy-hosts
EOF EOF
chmod 0440 /etc/sudoers.d/clupilot-host-step chmod 0440 /etc/sudoers.d/clupilot-host-step
visudo -cf /etc/sudoers.d/clupilot-host-step >/dev/null || { visudo -cf /etc/sudoers.d/clupilot-host-step >/dev/null || {

View File

@ -372,6 +372,31 @@ if [[ "$REQUEST_KIND" == "check" ]]; then
exit 0 exit 0
fi 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 if [[ "$REQUEST_KIND" == "restart" ]]; then
# queue, queue-provisioning, scheduler, reverb — and ONLY those four. # queue, queue-provisioning, scheduler, reverb — and ONLY those four.
# `app` is deliberately never in this list: it is the container serving # `app` is deliberately never in this list: it is the container serving

View File

@ -35,7 +35,7 @@ STATE_FILE="storage/app/deployed-commit"
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS # 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. # here is what makes an update tell the operator to run the installer again.
HOST_STEP=/usr/local/sbin/clupilot-host-step 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 # 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 # 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 # English line here would surface untranslated in the interface. A run that dies

53
lang/de/proxy.php Normal file
View File

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

53
lang/en/proxy.php Normal file
View File

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

View File

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

View File

@ -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/create', Admin\HostCreate::class)->name('hosts.create');
Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show');
Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); 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', Admin\Plans::class)->name('plans');
Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); 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). // POST so the identity change is CSRF-protected (a GET could be forced cross-site).

View File

@ -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();
});