Der Tunnel-Gateway laesst Namen ohne Zertifikat aus, statt nicht zu starten
parent
22dd6df61f
commit
78768cb219
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env sh
|
||||
#
|
||||
# Erzeugt die Konfiguration des Tunnel-Gateways beim Start und startet Caddy.
|
||||
#
|
||||
# Warum erzeugt statt fest hinterlegt: die Namen stehen in der .env und sind je
|
||||
# Installation andere, und zu jedem gehört ein eigenes Zertifikat. Caddy startet
|
||||
# NICHT, wenn eine in `tls` genannte Datei fehlt — ein noch nicht ausgestelltes
|
||||
# Zertifikat für www. nähme damit den Tunnel-Zugang zur Konsole mit, und das ist
|
||||
# der Weg, auf dem sich ein ausgesperrter Betreiber zurückholt. Deshalb wird
|
||||
# jeder Name einzeln geprüft und ausgelassen, wenn sein Zertifikat fehlt.
|
||||
#
|
||||
# Die Zertifikate sind die, die der öffentliche Caddy ohnehin erneuert. Ein
|
||||
# Zertifikat hängt am NAMEN, nicht an der Adresse, die ihn ausliefert.
|
||||
set -eu
|
||||
|
||||
HUB="${VPN_HUB_ADDRESS:-10.66.0.1}"
|
||||
HEALTH="${VPN_HEALTH_PORT:-8081}"
|
||||
CERT_DIR="${VPN_CERT_DIR:-/certs}"
|
||||
OUT="${VPN_CONFIG_OUT:-/tmp/vpn.Caddyfile}"
|
||||
|
||||
# Die Zertifikate, die dieser Lauf wirklich geladen hat. Der Update-Agent
|
||||
# ueberwacht sie und startet den Gateway nach einer Erneuerung neu — Caddys
|
||||
# `tls` liest die Datei EINMAL beim Start, und ohne Neustart liefe der Tunnel
|
||||
# danach mit einem abgelaufenen Zertifikat weiter, ausgerechnet fuer die
|
||||
# einzigen Leute, die die Konsole noch erreichen.
|
||||
CERT_LIST="${VPN_CERT_LIST:-/tmp/vpn-certs.list}"
|
||||
: > "$CERT_LIST"
|
||||
|
||||
{
|
||||
echo '{'
|
||||
echo ' admin off'
|
||||
echo ' auto_https off'
|
||||
echo '}'
|
||||
} > "$OUT"
|
||||
|
||||
# Der Konsolenname ZUERST, und getrennt von der Liste: er ist der einzige, ohne
|
||||
# den der Gateway keinen Zweck hat.
|
||||
emit_site() {
|
||||
name="$1"
|
||||
# `-print -quit`, nicht `| head -1`: eine Pipe, aus der head aussteigt,
|
||||
# waehrend find noch schreibt, liefert SIGPIPE — install-agent.sh hat sich
|
||||
# daran schon einmal selbst beendet. `|| true`, damit ein leeres Ergebnis
|
||||
# unter `set -e` kein Abbruch ist.
|
||||
crt="$(find "$CERT_DIR" -name "${name}.crt" -print -quit 2>/dev/null || true)"
|
||||
|
||||
if [ -z "$crt" ] || [ ! -f "${crt%.crt}.key" ]; then
|
||||
echo " ausgelassen: $name — kein Zertifikat unter $CERT_DIR" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "https://${name}:443 {"
|
||||
echo " bind ${HUB}"
|
||||
echo " tls ${crt} ${crt%.crt}.key"
|
||||
echo " reverse_proxy app:80 {"
|
||||
echo " header_up X-Forwarded-For {remote_host}"
|
||||
echo " header_up X-Forwarded-Proto https"
|
||||
echo " header_up Host {host}"
|
||||
echo " }"
|
||||
echo "}"
|
||||
} >> "$OUT"
|
||||
|
||||
echo "$crt" >> "$CERT_LIST"
|
||||
}
|
||||
|
||||
# `if`, nicht `[ … ] && …`. Bei leerem Wert gibt die AND-OR-Liste 1 zurueck, und
|
||||
# genau diese Konstruktion hat install-agent.sh unter `set -e` schon einmal
|
||||
# beendet — dort als letzte Anweisung einer Funktion. Hier waere sie geprueft
|
||||
# unschaedlich, aber die Regel steht im Repo und eine Ausnahme davon muesste man
|
||||
# jedem Nachfolger erklaeren.
|
||||
if [ -n "${VPN_INTERNAL_HOST:-}" ]; then
|
||||
emit_site "$VPN_INTERNAL_HOST"
|
||||
fi
|
||||
|
||||
# Portal, Website und Statusseite. files. steht hier nie drin: dort holt ein
|
||||
# Server im Rettungssystem sein Archiv, und der ist nicht im Tunnel.
|
||||
echo "${VPN_TUNNEL_HOSTS:-}" | tr ',' '\n' | while read -r host; do
|
||||
[ -n "$host" ] || continue
|
||||
emit_site "$host"
|
||||
done
|
||||
|
||||
# Der Gesundheits-Port, ohne TLS und ohne Namen. Daran hängt VPN_READY und damit,
|
||||
# ob ausgegebene Client-Konfigurationen den Resolver überhaupt nennen — er darf
|
||||
# deshalb von keinem Zertifikat abhängen.
|
||||
{
|
||||
echo ""
|
||||
echo "http://${HUB}:${HEALTH} {"
|
||||
echo " bind ${HUB}"
|
||||
echo " respond /healthz 204"
|
||||
echo " respond 404"
|
||||
echo "}"
|
||||
} >> "$OUT"
|
||||
|
||||
# Nur rendern, für den Test: er prüft, was das Skript AUSLÄSST, und braucht
|
||||
# dafür kein laufendes Caddy.
|
||||
if [ -n "${VPN_RENDER_ONLY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec caddy run --config "$OUT" --adapter caddyfile
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Die Konfiguration des Tunnel-Gateways, wirklich ausgeführt.
|
||||
*
|
||||
* Nach dem Muster von HostStepTest: das Skript wird als Skript laufen gelassen,
|
||||
* denn worauf es ankommt, ist was es AUSLÄSST — und ein Auslassen sieht man nur
|
||||
* an der erzeugten Datei, nicht an der Absicht.
|
||||
*
|
||||
* Der Ausfall, um den es geht: Caddy startet nicht, wenn eine in `tls`
|
||||
* genannte Datei fehlt. Ein noch nicht ausgestelltes Zertifikat für www. würde
|
||||
* damit den Tunnel-Zugang zur KONSOLE mitnehmen — und das ist der Weg, auf dem
|
||||
* sich ein ausgesperrter Betreiber zurückholt.
|
||||
*/
|
||||
function renderVpnConfig(array $env, array $certNames): string
|
||||
{
|
||||
$dir = sys_get_temp_dir().'/clupilot-vpn-'.bin2hex(random_bytes(6));
|
||||
mkdir($dir.'/certs/certificates/acme', 0755, true);
|
||||
|
||||
foreach ($certNames as $name) {
|
||||
file_put_contents($dir."/certs/certificates/acme/{$name}.crt", 'cert');
|
||||
file_put_contents($dir."/certs/certificates/acme/{$name}.key", 'key');
|
||||
}
|
||||
|
||||
$out = $dir.'/rendered.Caddyfile';
|
||||
|
||||
$env = array_merge([
|
||||
'VPN_HUB_ADDRESS' => '10.66.0.1',
|
||||
'VPN_HEALTH_PORT' => '8081',
|
||||
'VPN_CERT_DIR' => $dir.'/certs',
|
||||
'VPN_CONFIG_OUT' => $out,
|
||||
'VPN_RENDER_ONLY' => '1',
|
||||
], $env);
|
||||
|
||||
$process = Process::fromShellCommandline(
|
||||
'bash '.escapeshellarg(base_path('docker/caddy/vpn-entrypoint.sh'))
|
||||
);
|
||||
$process->setEnv($env);
|
||||
$process->run();
|
||||
|
||||
$rendered = is_file($out) ? file_get_contents($out) : '';
|
||||
|
||||
exec('rm -rf '.escapeshellarg($dir));
|
||||
|
||||
expect($process->getExitCode())->toBe(0, $process->getErrorOutput());
|
||||
|
||||
return $rendered;
|
||||
}
|
||||
|
||||
it('bedient jeden Namen, für den ein Zertifikat da ist', function () {
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test',
|
||||
], ['admin.clupilot.test', 'app.clupilot.test', 'www.clupilot.test']);
|
||||
|
||||
expect($config)->toContain('https://admin.clupilot.test:443')
|
||||
->and($config)->toContain('https://app.clupilot.test:443')
|
||||
->and($config)->toContain('https://www.clupilot.test:443');
|
||||
});
|
||||
|
||||
it('lässt einen Namen ohne Zertifikat aus, statt gar nicht zu starten', function () {
|
||||
// Der ganze Grund für dieses Skript. Stünde www. mit einem `tls`-Pfad in
|
||||
// der Konfiguration, den es nicht gibt, startete Caddy überhaupt nicht —
|
||||
// und die Konsole wäre im Tunnel weg.
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test',
|
||||
], ['admin.clupilot.test', 'app.clupilot.test']);
|
||||
|
||||
expect($config)->toContain('https://admin.clupilot.test:443')
|
||||
->and($config)->toContain('https://app.clupilot.test:443')
|
||||
->and($config)->not->toContain('www.clupilot.test');
|
||||
});
|
||||
|
||||
it('behält die Konsole, auch wenn sonst nichts ein Zertifikat hat', function () {
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test',
|
||||
], ['admin.clupilot.test']);
|
||||
|
||||
expect($config)->toContain('https://admin.clupilot.test:443')
|
||||
->and($config)->not->toContain('app.clupilot.test')
|
||||
->and($config)->not->toContain('www.clupilot.test');
|
||||
});
|
||||
|
||||
it('schreibt den Gesundheits-Port unabhängig von jedem Zertifikat', function () {
|
||||
// Daran hängt VPN_READY, und damit ob Client-Konfigurationen den Resolver
|
||||
// überhaupt nennen. Ein Gesundheits-Port, der ein Zertifikat braucht, wäre
|
||||
// genau die Attrappe, die schon einmal VPN_READY auf einer gesunden Anlage
|
||||
// false stehen ließ.
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => '',
|
||||
], []);
|
||||
|
||||
expect($config)->toContain('http://10.66.0.1:8081')
|
||||
->and($config)->toContain('respond /healthz 204');
|
||||
});
|
||||
|
||||
it('nimmt keinen Namen auf, den niemand konfiguriert hat', function () {
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => '',
|
||||
], ['admin.clupilot.test', 'files.clupilot.test']);
|
||||
|
||||
expect($config)->not->toContain('files.clupilot.test');
|
||||
});
|
||||
|
||||
it('schreibt die geladenen Zertifikate mit, damit die Erneuerung greift', function () {
|
||||
// Der Update-Agent startet den Gateway neu, wenn sich eines der geladenen
|
||||
// Zertifikate aendert — Caddys `tls` liest die Datei nur beim Start. Bisher
|
||||
// ueberwachte er GENAU EINEN Pfad aus der .env; mit mehreren Namen liefe
|
||||
// der Tunnel nach einer Erneuerung von www. mit einem abgelaufenen
|
||||
// Zertifikat weiter. Die Liste ist, woran er sie erkennt.
|
||||
$dir = sys_get_temp_dir().'/clupilot-vpn-'.bin2hex(random_bytes(6));
|
||||
mkdir($dir.'/certs/certificates/acme', 0755, true);
|
||||
|
||||
foreach (['admin.clupilot.test', 'app.clupilot.test'] as $name) {
|
||||
file_put_contents($dir."/certs/certificates/acme/{$name}.crt", 'cert');
|
||||
file_put_contents($dir."/certs/certificates/acme/{$name}.key", 'key');
|
||||
}
|
||||
|
||||
$list = $dir.'/certs.list';
|
||||
|
||||
$process = Process::fromShellCommandline(
|
||||
'bash '.escapeshellarg(base_path('docker/caddy/vpn-entrypoint.sh'))
|
||||
);
|
||||
$process->setEnv([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
// www. hat kein Zertifikat und darf deshalb auch nicht in der Liste stehen.
|
||||
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test,www.clupilot.test',
|
||||
'VPN_HUB_ADDRESS' => '10.66.0.1',
|
||||
'VPN_HEALTH_PORT' => '8081',
|
||||
'VPN_CERT_DIR' => $dir.'/certs',
|
||||
'VPN_CONFIG_OUT' => $dir.'/rendered.Caddyfile',
|
||||
'VPN_CERT_LIST' => $list,
|
||||
'VPN_RENDER_ONLY' => '1',
|
||||
]);
|
||||
$process->run();
|
||||
|
||||
$written = is_file($list) ? file_get_contents($list) : '';
|
||||
|
||||
exec('rm -rf '.escapeshellarg($dir));
|
||||
|
||||
expect($process->getExitCode())->toBe(0, $process->getErrorOutput())
|
||||
->and($written)->toContain('admin.clupilot.test.crt')
|
||||
->and($written)->toContain('app.clupilot.test.crt')
|
||||
->and($written)->not->toContain('www.clupilot.test');
|
||||
});
|
||||
|
||||
it('gibt jedem Block die Weiterleitung mit der echten Quelladresse', function () {
|
||||
// Ohne X-Forwarded-For sähe die Anwendung den Gateway statt des Anrufers,
|
||||
// und die Freigabeliste prüfte die falsche Adresse.
|
||||
$config = renderVpnConfig([
|
||||
'VPN_INTERNAL_HOST' => 'admin.clupilot.test',
|
||||
'VPN_TUNNEL_HOSTS' => 'app.clupilot.test',
|
||||
], ['admin.clupilot.test', 'app.clupilot.test']);
|
||||
|
||||
expect(substr_count($config, 'header_up X-Forwarded-For {remote_host}'))->toBe(2);
|
||||
});
|
||||
Loading…
Reference in New Issue