680 lines
34 KiB
Bash
Executable File
680 lines
34 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Installs the update agent's systemd timer. Root, once per machine, idempotent.
|
|
#
|
|
# Separate from install.sh because the installed base never runs install.sh
|
|
# again — existing servers upgrade through deploy/update.sh, which runs as the
|
|
# service account and cannot write to /etc/systemd. Without this as its own
|
|
# entry point, every server that already exists would show the update button
|
|
# permanently disabled.
|
|
#
|
|
# sudo bash deploy/install-agent.sh
|
|
#
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
APP_USER="${APP_USER:-clupilot}"
|
|
|
|
[[ $EUID -eq 0 ]] || { echo "Please run as root: sudo bash deploy/install-agent.sh" >&2; exit 1; }
|
|
id -u "$APP_USER" >/dev/null 2>&1 || { echo "No such account: $APP_USER" >&2; exit 1; }
|
|
|
|
install -o "$APP_USER" -g "$APP_USER" -d "$ROOT/storage/app/deploy"
|
|
chmod +x "$ROOT/deploy/update-agent.sh"
|
|
|
|
# ── The few things an update needs root for ──────────────────────────────────
|
|
# An update runs as the service account. Everything root-owned on the host is
|
|
# therefore out of its reach, and each such thing turns into "log into every
|
|
# server once and paste this" — which is exactly the work this project exists to
|
|
# remove.
|
|
#
|
|
# The obvious fix is to let the service account run THIS script under sudo. That
|
|
# would be handing it root outright: it owns the checkout, so it can rewrite the
|
|
# very script it is allowed to run privileged. A grant is only worth anything if
|
|
# the holder cannot change what it grants.
|
|
#
|
|
# So the privileged part is written OUT of the checkout, to a root-owned path,
|
|
# from a here-document rather than copied — the service account has no way to
|
|
# influence a single byte of it. What it may then do as root is one fixed
|
|
# command line, enumerated in sudoers, and nothing else.
|
|
#
|
|
# CONTRACT is the version of that fixed set. deploy/update.sh compares it and
|
|
# says so when this script has to be run again — a new step added here is
|
|
# useless on a server still carrying the old helper, and silently useless is the
|
|
# failure mode worth spending a line on.
|
|
HOST_STEP=/usr/local/sbin/clupilot-host-step
|
|
|
|
cat > "$HOST_STEP" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
#
|
|
# The root-owned half of a CluPilot update. Installed by deploy/install-agent.sh
|
|
# and never read from the application checkout — see the note there.
|
|
#
|
|
# One argument, from the list below. Nothing takes free-form input.
|
|
set -euo pipefail
|
|
|
|
CONTRACT=3
|
|
|
|
case "${1:-}" in
|
|
contract)
|
|
echo "$CONTRACT"
|
|
;;
|
|
|
|
ensure-rsync)
|
|
# rsync has to be on the HOST: a backup that collects the invoice
|
|
# archive connects by ssh, and sshd starts `rsync --server` out here,
|
|
# not in a container. rrsync comes with it, and that is what locks the
|
|
# collecting key to one directory.
|
|
command -v rsync >/dev/null 2>&1 && exit 0
|
|
command -v apt-get >/dev/null 2>&1 || { echo "no apt-get on this host" >&2; exit 3; }
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update -qq >/dev/null 2>&1 || true
|
|
apt-get install -y -qq rsync >/dev/null
|
|
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; }
|
|
|
|
# Steht der Name schon von Hand im Caddyfile, wird er hier
|
|
# ausgelassen. Zwei Blöcke für denselben Hostnamen lehnt Caddy ab —
|
|
# und zwar die GANZE Konfiguration, nicht nur den doppelten Block.
|
|
# Ein Betreiber, der einen bereits eingetragenen Namen zusätzlich in
|
|
# der Konsole pflegt, nähme sich damit die öffentliche Website vom
|
|
# Netz, ohne einen Zusammenhang zu sehen.
|
|
if grep -qE "^[[:space:]]*(https?://)?${NAME//./\\.}([[:space:],{]|\$)" "$CADDYFILE"; then
|
|
echo " skipping $NAME — already written by hand in $CADDYFILE" >&2
|
|
continue
|
|
fi
|
|
|
|
{
|
|
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
|
|
;;
|
|
|
|
install-server-terminal)
|
|
# Die Tür, durch die das Terminal auf DIESEN Server kommt.
|
|
#
|
|
# Nicht in sudoers, anders als die beiden Schritte darüber, und das ist
|
|
# Absicht: dieser Schritt schreibt einen Schlüssel nach
|
|
# /root/.ssh/authorized_keys. Stünde er dort, könnte das Dienstkonto
|
|
# sich jederzeit selbst root geben, indem es in der Anwendung eine neue
|
|
# Kennung erzeugt und dann den Schritt anstößt. So braucht es dafür
|
|
# einen Menschen mit sudo — genau einmal, und wieder bei jedem Wechsel
|
|
# der Kennung. Die Konsole schreibt die Zeile hin, wenn es so weit ist.
|
|
CHECKOUT=/opt/clupilot
|
|
[[ -d "$CHECKOUT" ]] || { echo "no checkout at $CHECKOUT" >&2; exit 3; }
|
|
command -v openssl >/dev/null 2>&1 || { echo "openssl is not installed" >&2; exit 3; }
|
|
|
|
# ── 1. Die öffentliche Hälfte, aus der Anwendung geholt ──────────────
|
|
# Geholt, nicht gereicht: derselbe Gedanke wie bei apply-proxy-hosts.
|
|
KEY="$(cd "$CHECKOUT" && runuser -u clupilot -- docker compose exec -T -u www-data app \
|
|
php artisan clupilot:server-terminal-key 2>/dev/null)" \
|
|
|| { echo "could not read the server terminal key from the application" >&2; exit 4; }
|
|
|
|
# ── 2. Genau eine Zeile, und zwar eine, die wie ein Schlüssel aussieht ─
|
|
# Das ist die Stelle, an der aus „die Anwendung nennt ihren Schlüssel"
|
|
# nicht „die Anwendung schreibt in authorized_keys" werden darf: eine
|
|
# zweite Zeile wäre ein zweiter Schlüssel, und ein Zeilenumbruch im Wert
|
|
# ist der ganze Trick.
|
|
KEY="$(printf '%s\n' "$KEY" | tr -d '\r' | grep . || true)"
|
|
[[ "$(printf '%s\n' "$KEY" | grep -c .)" -eq 1 ]] \
|
|
|| { echo "refusing: expected exactly one key line" >&2; exit 5; }
|
|
[[ "$KEY" =~ ^ssh-ed25519\ [A-Za-z0-9+/]+=*(\ [^[:space:]]+)?$ ]] \
|
|
|| { echo "refusing the key line: not a single ssh-ed25519 entry" >&2; exit 5; }
|
|
|
|
# ── 3. Woher der Container kommt, und wie er hierher schaut ──────────
|
|
# Beides gemessen statt geraten: das Netz heißt nach dem Projekt, und
|
|
# die Adresse, unter der der Wirt aus dem Container sichtbar ist, ist
|
|
# das Gateway dieses Netzes. Auf der nächsten Installation sind beide
|
|
# anders.
|
|
CID="$(cd "$CHECKOUT" && runuser -u clupilot -- docker compose ps -q vpn-hub)"
|
|
[[ -n "$CID" ]] || { echo "vpn-hub is not running — start the stack first" >&2; exit 3; }
|
|
NET_NAME="$(docker inspect "$CID" -f '{{range $n, $c := .NetworkSettings.Networks}}{{$n}}{{end}}')"
|
|
GATEWAY="$(docker inspect "$CID" -f '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}')"
|
|
SUBNET="$(docker network inspect "$NET_NAME" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
|
[[ -n "$GATEWAY" && -n "$SUBNET" ]] || { echo "could not read the container network" >&2; exit 3; }
|
|
|
|
# ── 4. Eintragen, mit fester Optionsliste ────────────────────────────
|
|
# Die Optionen stehen HIER und kommen nicht aus der Anwendung: `from`
|
|
# bindet den Schlüssel an das Container-Netz, `restrict` nimmt ihm alles
|
|
# ausser einer Sitzung — kein Port-Forwarding, kein Agent, kein X11 —,
|
|
# und `pty` gibt genau das eine zurück, ohne das ein Terminal keines ist.
|
|
#
|
|
# Ersetzen statt anhängen: hinter der Markierung steht immer genau eine
|
|
# Zeile, und ein zweiter Lauf hinterlässt keinen zweiten Schlüssel.
|
|
MARK="# clupilot-server-terminal"
|
|
AK=/root/.ssh/authorized_keys
|
|
install -d -m 0700 -o root -g root /root/.ssh
|
|
[[ -f "$AK" ]] || { : > "$AK"; chmod 0600 "$AK"; }
|
|
|
|
TMP="$(mktemp)"
|
|
awk -v mark="$MARK" 'skip { skip=0; next } $0 == mark { skip=1; next } { print }' "$AK" > "$TMP"
|
|
printf '%s\nfrom="%s",restrict,pty %s\n' "$MARK" "$SUBNET" "$KEY" >> "$TMP"
|
|
install -o root -g root -m 0600 "$TMP" "$AK"
|
|
rm -f "$TMP"
|
|
|
|
# ── 5. Der Fingerabdruck, IN CLUPILOTS FORM ─────────────────────────
|
|
# `SHA256:` base64 über der Zeichenkette "<algorithmus> <base64-blob>",
|
|
# NICHT der OpenSSH-Fingerabdruck (der hasht den Blob selbst). Wer das
|
|
# hier verwechselt, bekommt bei jedem Verbindungsversuch eine Abweichung
|
|
# — und die Versuchung, die Prüfung für kaputt zu halten. Die
|
|
# Gegenstelle steht in docker/terminal/bridge.py, fingerprints().
|
|
#
|
|
# Ed25519, weil paramiko in genau dieser Reihenfolge aushandelt und
|
|
# ssh-ed25519 an erster Stelle steht. Fehlt er, wird nichts geraten.
|
|
HOSTKEY=/etc/ssh/ssh_host_ed25519_key.pub
|
|
[[ -f "$HOSTKEY" ]] || { echo "no ed25519 host key at $HOSTKEY" >&2; exit 3; }
|
|
FINGERPRINT="SHA256:$(printf '%s %s' "$(awk '{print $1}' "$HOSTKEY")" "$(awk '{print $2}' "$HOSTKEY")" \
|
|
| openssl dgst -sha256 -binary | openssl base64)"
|
|
|
|
# ── 6. Was die Anwendung davon wissen muss ──────────────────────────
|
|
# Kein Geheimnis darin: eine Adresse und ein Hash. Deshalb lesbar für
|
|
# das Dienstkonto, das den Ticket-Inhalt daraus baut.
|
|
OUT="$CHECKOUT/storage/app/deploy/server-terminal.json"
|
|
TMP="$(mktemp)"
|
|
printf '{"address":"%s","fingerprint":"%s","installed_at":"%s"}\n' \
|
|
"$GATEWAY" "$FINGERPRINT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$TMP"
|
|
install -o clupilot -g clupilot -m 0644 "$TMP" "$OUT"
|
|
rm -f "$TMP"
|
|
;;
|
|
|
|
*)
|
|
# Refused by name. sudoers already permits only the exact command lines
|
|
# above, so this is the second of two locks, not the only one.
|
|
echo "unknown step: ${1:-<none>}" >&2
|
|
exit 64
|
|
;;
|
|
esac
|
|
EOF
|
|
|
|
chown root:root "$HOST_STEP"
|
|
chmod 0755 "$HOST_STEP"
|
|
|
|
# The command line in full, not just the path: sudoers matches arguments too, so
|
|
# written this way the grant is for `clupilot-host-step ensure-rsync` and for
|
|
# 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 || {
|
|
echo " ! sudoers fragment rejected — removing it. Updates will ask for rsync by hand."
|
|
rm -f /etc/sudoers.d/clupilot-host-step
|
|
}
|
|
|
|
# And straight away, through the same helper rather than beside it — one
|
|
# implementation, exercised on every install instead of only when an update
|
|
# happens to find rsync missing.
|
|
if ! command -v rsync >/dev/null 2>&1; then
|
|
echo " Installing rsync (the invoice archive is collected over ssh)"
|
|
"$HOST_STEP" ensure-rsync \
|
|
|| echo " ! Could not install rsync — the invoice archive cannot be collected until it is there."
|
|
fi
|
|
|
|
# Und die Tür für das Terminal auf diesen Server — hier und nicht in sudoers,
|
|
# siehe die Begründung am Schritt selbst. Nicht fatal: auf einer Installation,
|
|
# in der noch keine Kennung erzeugt wurde, ist "geht nicht" die richtige
|
|
# Antwort, und die Konsole sagt dann, was zuerst zu tun ist.
|
|
echo " Installing the server terminal key (Einstellungen → Terminal zum Server)"
|
|
"$HOST_STEP" install-server-terminal \
|
|
|| echo " ! Not set up — generate the identity in the console first, then run this script again."
|
|
|
|
cat > /etc/systemd/system/clupilot-update-agent.service <<EOF
|
|
[Unit]
|
|
Description=CluPilot update agent
|
|
After=docker.service
|
|
Requires=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=$APP_USER
|
|
WorkingDirectory=$ROOT
|
|
ExecStart=$ROOT/deploy/update-agent.sh
|
|
EOF
|
|
|
|
# Two things start this service now — the timer below and the path unit after
|
|
# it. A burst of starts would otherwise trip systemd's default rate limit and
|
|
# leave the unit in a failed state, at which point NEITHER trigger runs and the
|
|
# console can no longer update the installation at all. A busy agent is a far
|
|
# smaller problem than a wedged one.
|
|
mkdir -p /etc/systemd/system/clupilot-update-agent.service.d
|
|
cat > /etc/systemd/system/clupilot-update-agent.service.d/rate-limit.conf <<'EOF'
|
|
[Unit]
|
|
StartLimitIntervalSec=0
|
|
EOF
|
|
|
|
cat > /etc/systemd/system/clupilot-update-agent.timer <<'EOF'
|
|
[Unit]
|
|
Description=Check for CluPilot updates and run requested ones
|
|
|
|
[Timer]
|
|
# Every minute: a `git fetch` against one remote is not a nuisance at this rate,
|
|
# and this is now only the FALLBACK — the path unit below wakes the agent the
|
|
# moment somebody asks for something. What the timer still decides is how fresh
|
|
# the "is there a new version" reading is on a console nobody is looking at.
|
|
OnBootSec=3min
|
|
OnUnitActiveSec=1min
|
|
AccuracySec=5s
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
# ── Answering a question at the moment it is asked ───────────────────────────
|
|
# The timer above decides how often the agent looks on its own. It must not also
|
|
# decide how long someone waits after pressing a button.
|
|
#
|
|
# "Nach Aktualisierungen suchen" is a QUESTION. Routed through the timer it was
|
|
# answered on the next tick — up to five minutes later on a server whose timer
|
|
# still had the old interval — and the console, having nothing else to show,
|
|
# counted down to it. An operator who asked what was new was told an update
|
|
# would start in 3:44. Nothing was starting; nothing had been asked to start.
|
|
#
|
|
# So the request file gets its own trigger. The panel writes it inside the
|
|
# container, the checkout is bind-mounted, and the host sees the write on the
|
|
# same inode — the agent is woken within a second, for a check and for a real
|
|
# run alike.
|
|
#
|
|
# PathChanged, deliberately, not PathExists: PathExists re-arms as soon as the
|
|
# service goes inactive, so a request the agent could not consume — it holds a
|
|
# lock while an update runs — would restart it in a tight loop for the length of
|
|
# that update. PathChanged fires once, when the file is written and closed. A
|
|
# trigger missed because the agent was busy is picked up by the timer.
|
|
cat > /etc/systemd/system/clupilot-update-agent.path <<EOF
|
|
[Unit]
|
|
Description=Wake the CluPilot update agent when the console asks for something
|
|
|
|
[Path]
|
|
PathChanged=$ROOT/storage/app/deploy/update-request.json
|
|
Unit=clupilot-update-agent.service
|
|
|
|
[Install]
|
|
WantedBy=paths.target
|
|
EOF
|
|
|
|
# ── Der Waechter ─────────────────────────────────────────────────────────────
|
|
#
|
|
# Jede Minute nachsehen, ob steht, was stehen soll — und richten, was nicht
|
|
# steht. Eigener Dienst, nicht Teil des Update-Agenten: der Agent macht etwas,
|
|
# wenn er gefragt wird, der Waechter, wenn niemand hinsieht.
|
|
#
|
|
# Auf dem WIRT, nicht in einem Container. Ein Waechter im Container braeuchte den
|
|
# Docker-Socket hineingereicht — das ist Root auf dem Wirt fuer jeden, der je in
|
|
# diesen Container kommt — und waere genau dann tot, wenn man ihn braucht,
|
|
# naemlich wenn der Stapel unten liegt.
|
|
#
|
|
# Er nimmt dieselbe Sperre wie der Agent und geht weg, wenn er sie nicht
|
|
# bekommt; waehrend eines Updates fasst er also nichts an.
|
|
cat > /etc/systemd/system/clupilot-watchdog.service <<EOF
|
|
[Unit]
|
|
Description=CluPilot watchdog
|
|
After=docker.service
|
|
Requires=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=$APP_USER
|
|
WorkingDirectory=$ROOT
|
|
ExecStart=$ROOT/deploy/watchdog.sh
|
|
EOF
|
|
|
|
mkdir -p /etc/systemd/system/clupilot-watchdog.service.d
|
|
cat > /etc/systemd/system/clupilot-watchdog.service.d/rate-limit.conf <<'EOF'
|
|
[Unit]
|
|
StartLimitIntervalSec=0
|
|
EOF
|
|
|
|
cat > /etc/systemd/system/clupilot-watchdog.timer <<'EOF'
|
|
[Unit]
|
|
Description=Nachsehen, ob der CluPilot-Stapel steht — und richten, was nicht steht
|
|
|
|
[Timer]
|
|
# Zwei Minuten nach dem Booten, dann jede Minute. Der Bootabstand ist wichtig:
|
|
# direkt nach dem Start sind Container noch am Hochfahren, und ein Waechter, der
|
|
# das fuer einen Ausfall haelt, greift in einen normalen Start ein.
|
|
OnBootSec=2min
|
|
OnUnitActiveSec=1min
|
|
AccuracySec=10s
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
# ── The proxy's console allowlist ────────────────────────────────────────────
|
|
# The proxy has its own allowlist and it runs BEFORE the application, so
|
|
# everything the owner adds in the console has no effect until the proxy is
|
|
# told. The agent regenerates this fragment from the console's own list.
|
|
#
|
|
# Entirely optional: an installation may sit behind nginx, Zoraxy, or nothing at
|
|
# all, and none of that is a reason for the update agent itself to fail to
|
|
# install.
|
|
CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}"
|
|
ALLOWFILE="/etc/caddy/clupilot-console-allow.conf"
|
|
|
|
if command -v caddy >/dev/null 2>&1 && [[ -f "$CADDYFILE" ]]; then
|
|
echo " Wiring the console allowlist into Caddy"
|
|
|
|
# Seed it before anything imports it: an import of a missing file makes the
|
|
# whole proxy config invalid, and that takes the public site down too.
|
|
ALLOWLIST_READY=0
|
|
|
|
if [[ -s "$ALLOWFILE" ]] && grep -q '@allowed remote_ip .' "$ALLOWFILE"; then
|
|
ALLOWLIST_READY=1
|
|
else
|
|
# Compose discovers its own file from the working directory — naming it
|
|
# here guessed wrong once.
|
|
# -u www-data, like every other artisan call: exec defaults to root, and
|
|
# a command that logs would leave storage/logs owned by root — after
|
|
# which the application cannot append to its own log at all, and every
|
|
# page that logs answers 500. That is not hypothetical; it happened.
|
|
if ( cd "$ROOT" && runuser -u "$APP_USER" -- docker compose exec -T -u www-data app \
|
|
php artisan clupilot:console-access caddy ) > "$ALLOWFILE.new" 2>/dev/null \
|
|
&& grep -q '@allowed remote_ip .' "$ALLOWFILE.new"; then
|
|
mv -f "$ALLOWFILE.new" "$ALLOWFILE"
|
|
ALLOWLIST_READY=1
|
|
else
|
|
# NO fallback matcher here. A loopback-only list looks like a valid
|
|
# allowlist, passes validation, and locks out every remote operator
|
|
# the moment Caddy reloads — because the application happened to be
|
|
# unavailable for the ten seconds this ran.
|
|
rm -f "$ALLOWFILE.new"
|
|
echo " ! Could not read the console allowlist from the application."
|
|
echo " Leaving the proxy configuration untouched."
|
|
fi
|
|
fi
|
|
|
|
[[ -f "$ALLOWFILE" ]] && { chown "$APP_USER":root "$ALLOWFILE"; chmod 0644 "$ALLOWFILE"; }
|
|
|
|
# Replace a hard-coded matcher with the import, once. Backed up and
|
|
# validated first — a broken Caddyfile is an outage of everything, not just
|
|
# of the console.
|
|
if [[ "$ALLOWLIST_READY" == "1" ]] && ! grep -q "import $ALLOWFILE" "$CADDYFILE"; then
|
|
# Exactly one, or none of them. An unqualified substitution would
|
|
# rewrite a matcher belonging to some other site — and the result would
|
|
# still validate, so the first sign of it would be that site's access
|
|
# control quietly changing.
|
|
matches="$(grep -cE '^[[:space:]]*@allowed remote_ip ' "$CADDYFILE" || true)"
|
|
|
|
if [[ "$matches" == "1" ]]; then
|
|
cp -a "$CADDYFILE" "$CADDYFILE.clupilot.bak"
|
|
sed -i "s|^\([[:space:]]*\)@allowed remote_ip .*|\1import $ALLOWFILE|" "$CADDYFILE"
|
|
|
|
if caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1; then
|
|
if systemctl reload caddy >/dev/null 2>&1; then
|
|
echo " Console allowlist is now managed from the console."
|
|
else
|
|
# Handed to the agent. Without this marker the file already
|
|
# matches what the agent would generate, so nothing would
|
|
# ever trigger another reload and the proxy would keep the
|
|
# old hard-coded list indefinitely.
|
|
: > "$ROOT/storage/app/deploy/.caddy-reload-pending"
|
|
chown "$APP_USER":"$APP_USER" "$ROOT/storage/app/deploy/.caddy-reload-pending"
|
|
echo " ! Caddy did not reload — the agent retries within a minute."
|
|
fi
|
|
else
|
|
echo " ! Caddy rejected the change — restoring the previous config."
|
|
mv -f "$CADDYFILE.clupilot.bak" "$CADDYFILE"
|
|
fi
|
|
else
|
|
# Said out loud rather than assumed. Nothing to replace, or more
|
|
# than one candidate and no way to tell which belongs to the
|
|
# console — either way, carrying on silently would leave the
|
|
# console's allowlist a decoration while the proxy kept its own.
|
|
echo " ! Found $matches single-line '@allowed remote_ip' entries in $CADDYFILE (expected exactly 1)."
|
|
echo " Put this inside the console's site block yourself, then reload Caddy:"
|
|
echo " import $ALLOWFILE"
|
|
fi
|
|
fi
|
|
|
|
# Narrow on purpose: one command, no arguments, nothing else.
|
|
cat > /etc/sudoers.d/clupilot-caddy-reload <<EOF
|
|
$APP_USER ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy
|
|
EOF
|
|
chmod 0440 /etc/sudoers.d/clupilot-caddy-reload
|
|
visudo -cf /etc/sudoers.d/clupilot-caddy-reload >/dev/null || rm -f /etc/sudoers.d/clupilot-caddy-reload
|
|
fi
|
|
|
|
# ── The console inside the tunnel ────────────────────────────────────────────
|
|
# The gateway serves the console on the WireGuard hub address using the
|
|
# certificate the public Caddy already obtains for that same hostname — a
|
|
# certificate is bound to the NAME, not to the address serving it. Finding it
|
|
# has to happen here, as root: the storage is Caddy's and the paths contain the
|
|
# ACME directory, which is not something to ask an operator to type.
|
|
VPN_HOST="$(sed -n 's/^VPN_INTERNAL_HOST=//p' "$ROOT/.env" 2>/dev/null | tail -1)"
|
|
|
|
if [[ -z "$VPN_HOST" ]]; then
|
|
# Cleared. Reconciled here too, because the updater exits early when the
|
|
# checkout has not moved — so without this there may be no path at all that
|
|
# stops the services, and clients would keep being handed a resolver that
|
|
# is no longer meant to exist.
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
if grep -qE '^COMPOSE_PROFILES=.*vpn' .env 2>/dev/null || grep -qE '^VPN_READY=' .env 2>/dev/null; then
|
|
docker compose --profile vpn stop vpn-dns vpn-gateway >/dev/null 2>&1 || true
|
|
docker compose --profile vpn rm -f vpn-dns vpn-gateway >/dev/null 2>&1 || true
|
|
sed -i '/^VPN_CERT_PATH=/d;/^VPN_KEY_PATH=/d;/^VPN_READY=/d' .env
|
|
profiles=\"\$(sed -n 's/^COMPOSE_PROFILES=//p' .env | tail -1)\"
|
|
profiles=\"\$(printf '%s' \"\$profiles\" | tr ',' '\n' | grep -vx 'vpn' | grep -v '^\$' | paste -sd, - || true)\"
|
|
sed -i '/^COMPOSE_PROFILES=/d' .env
|
|
[ -n \"\$profiles\" ] && printf 'COMPOSE_PROFILES=%s\n' \"\$profiles\" >> .env
|
|
docker compose exec -T -u www-data app php artisan config:clear >/dev/null 2>&1 || true
|
|
echo ' Tunnel gateway disabled — VPN_INTERNAL_HOST is empty.'
|
|
fi
|
|
" || true
|
|
fi
|
|
|
|
if [[ -n "$VPN_HOST" ]]; then
|
|
# From the project's .env, because that is the file Compose reads for the
|
|
# mount. Looking only at the default would report "no certificate" on an
|
|
# installation whose storage is somewhere else.
|
|
CADDY_DATA="$(sed -n 's/^CADDY_DATA_DIR=//p' "$ROOT/.env" 2>/dev/null | tail -1)"
|
|
CADDY_DATA="${CADDY_DATA:-${CADDY_DATA_DIR:-/var/lib/caddy/.local/share/caddy}}"
|
|
# -print -quit, not | head -1: under `set -o pipefail` a find that is still
|
|
# producing output when head exits returns SIGPIPE, and the whole agent
|
|
# installation aborts — on the perfectly ordinary case of one certificate
|
|
# existing under two issuer directories.
|
|
cert="$(find "$CADDY_DATA/certificates" -name "${VPN_HOST}.crt" -print -quit 2>/dev/null)"
|
|
key="${cert%.crt}.key"
|
|
|
|
if [[ -n "$cert" && -f "$key" ]]; then
|
|
# Paths as the gateway container sees them: the storage is mounted at /certs.
|
|
rel="${cert#$CADDY_DATA/}"
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
sed -i '/^VPN_CERT_PATH=/d;/^VPN_KEY_PATH=/d' .env
|
|
printf 'VPN_CERT_PATH=/certs/%s\n' '$rel' >> .env
|
|
printf 'VPN_KEY_PATH=/certs/%s\n' '${rel%.crt}.key' >> .env
|
|
"
|
|
# No chmod. The gateway container runs as root and reads these through
|
|
# the read-only mount already; making the private key world-readable
|
|
# would let every local account copy the console's TLS identity.
|
|
# Enabled AND started here. The updater exits early when the checkout
|
|
# has not moved, so leaving this to "the next deploy" leaves the
|
|
# gateway stopped and the success message a lie.
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
grep -qE '^COMPOSE_PROFILES=.*vpn' .env \
|
|
|| { grep -qE '^COMPOSE_PROFILES=' .env \
|
|
&& sed -i 's/^COMPOSE_PROFILES=\\(.*\\)\$/COMPOSE_PROFILES=\\1,vpn/' .env \
|
|
|| printf 'COMPOSE_PROFILES=vpn\n' >> .env; }
|
|
docker compose --profile vpn up -d vpn-dns vpn-gateway
|
|
" >/dev/null 2>&1 || true
|
|
|
|
# `up -d` succeeds once the containers are CREATED. One that starts and
|
|
# exits — Caddy unable to read the certificate, or to bind the hub
|
|
# address — still counts as success there, so the state is checked.
|
|
sleep 3
|
|
vpn_started=0
|
|
if runuser -u "$APP_USER" -- sh -c "cd '$ROOT' && docker compose --profile vpn ps --status running --services" 2>/dev/null \
|
|
| grep -q '^vpn-gateway$' \
|
|
&& runuser -u "$APP_USER" -- sh -c "cd '$ROOT' && docker compose --profile vpn ps --status running --services" 2>/dev/null \
|
|
| grep -q '^vpn-dns$'; then
|
|
vpn_started=1
|
|
fi
|
|
|
|
# The marker, and only on success. Certificate paths alone would report
|
|
# the tunnel ready after a failed start, and the application would hand
|
|
# out configs naming a resolver that never came up.
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
sed -i '/^VPN_READY=/d' .env
|
|
printf 'VPN_READY=%s\n' '$([[ $vpn_started -eq 1 ]] && echo true || echo false)' >> .env
|
|
"
|
|
|
|
if [[ $vpn_started -ne 1 ]]; then
|
|
echo " ! Could not start the tunnel gateway — check: docker compose --profile vpn up -d"
|
|
fi
|
|
|
|
# The application caches its configuration. Without rebuilding it here,
|
|
# vpn_ready stays false and client configs keep omitting the tunnel
|
|
# resolver — the gateway running but nothing pointed at it.
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
docker compose exec -T -u www-data app php artisan config:clear >/dev/null 2>&1
|
|
docker compose exec -T -u www-data app php artisan config:cache >/dev/null 2>&1
|
|
" || true
|
|
|
|
# Only when it is true. Announcing success while the gateway failed to
|
|
# start is worse than silence: it sends the operator looking somewhere
|
|
# else entirely.
|
|
if [[ $vpn_started -eq 1 ]]; then
|
|
echo " Console reachable inside the tunnel at https://$VPN_HOST"
|
|
fi
|
|
else
|
|
# Nothing usable for the CURRENT hostname. Leaving the old paths, the
|
|
# profile and the readiness marker in place would have the application
|
|
# advertise the renamed endpoint as ready while the gateway still
|
|
# served the previous name.
|
|
runuser -u "$APP_USER" -- sh -c "
|
|
cd '$ROOT'
|
|
sed -i '/^VPN_CERT_PATH=/d;/^VPN_KEY_PATH=/d;/^VPN_READY=/d' .env
|
|
docker compose --profile vpn stop vpn-dns vpn-gateway >/dev/null 2>&1 || true
|
|
# And the profile with it, or the next ordinary `up -d` starts a
|
|
# Caddy with empty tls paths that can only crash.
|
|
profiles=\"\$(sed -n 's/^COMPOSE_PROFILES=//p' .env | tail -1)\"
|
|
profiles=\"\$(printf '%s' \"\$profiles\" | tr ',' '\n' | grep -vx 'vpn' | grep -v '^\$' | paste -sd, - || true)\"
|
|
sed -i '/^COMPOSE_PROFILES=/d' .env
|
|
[ -n \"\$profiles\" ] && printf 'COMPOSE_PROFILES=%s\n' \"\$profiles\" >> .env
|
|
docker compose exec -T -u www-data app php artisan config:clear >/dev/null 2>&1 || true
|
|
" || true
|
|
|
|
echo " ! No certificate for $VPN_HOST in $CADDY_DATA yet."
|
|
echo " Reach https://$VPN_HOST publicly once so Caddy obtains it, then run this again."
|
|
fi
|
|
fi
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now clupilot-update-agent.timer >/dev/null
|
|
systemctl enable --now clupilot-watchdog.timer >/dev/null
|
|
systemctl enable --now clupilot-update-agent.path >/dev/null
|
|
|
|
# Run it once now, so the panel has a status to show instead of "never reported
|
|
# in" for the first minute.
|
|
systemctl start clupilot-update-agent.service || true
|
|
|
|
echo "Update agent installed and running (clupilot-update-agent.timer)."
|
|
echo "Watchdog installed and running (clupilot-watchdog.timer) — checks every minute."
|
|
|
|
# ── Die von der Konsole verwalteten Hostnamen ────────────────────────────────
|
|
# Die Datei MUSS existieren, bevor irgendetwas sie importiert: ein Import ins
|
|
# Leere macht die GANZE Proxy-Konfiguration ungültig, und das nimmt die
|
|
# öffentliche Website mit. Genau diese Reihenfolge ist der Grund, warum das hier
|
|
# steht und nicht in einer Anleitung — eine Anleitung wird in der falschen
|
|
# Reihenfolge befolgt, ein Skript nicht.
|
|
PROXY_HOSTS_FILE="/etc/caddy/clupilot-proxy-hosts.conf"
|
|
|
|
if [[ -f "$CADDYFILE" ]] && command -v caddy >/dev/null 2>&1; then
|
|
if [[ ! -f "$PROXY_HOSTS_FILE" ]]; then
|
|
printf '# Von CluPilot erzeugt. Noch leer — die Konsole füllt sie.\n' > "$PROXY_HOSTS_FILE"
|
|
chown root:root "$PROXY_HOSTS_FILE"
|
|
chmod 0644 "$PROXY_HOSTS_FILE"
|
|
echo " Created $PROXY_HOSTS_FILE"
|
|
fi
|
|
|
|
# Die Import-Zeile wird gesetzt, nicht angesagt. Sie von Hand nachtragen zu
|
|
# lassen war der Fehler: die Anleitung dazu stand in einem Kommentar, die
|
|
# Datei wurde nie angelegt, und wer der Anleitung folgte, hätte den ganzen
|
|
# Proxy ungültig gemacht.
|
|
if ! grep -q "clupilot-proxy-hosts.conf" "$CADDYFILE"; then
|
|
cp -p "$CADDYFILE" "$CADDYFILE.clupilot-backup"
|
|
printf '\n# Von CluPilot verwaltete Hostnamen (Konsole → Betrieb → Hostnamen).\nimport %s\n' \
|
|
"$PROXY_HOSTS_FILE" >> "$CADDYFILE"
|
|
|
|
if caddy validate --config "$CADDYFILE" >/dev/null 2>&1; then
|
|
rm -f "$CADDYFILE.clupilot-backup"
|
|
systemctl reload caddy >/dev/null 2>&1 || true
|
|
echo " Added the import line to $CADDYFILE"
|
|
else
|
|
# Zurück, sofort. Eine ungültige Konfiguration liegen zu lassen
|
|
# heißt, den nächsten Caddy-Start scheitern zu lassen — und der
|
|
# kommt beim nächsten Neustart der Maschine, nicht jetzt.
|
|
mv -f "$CADDYFILE.clupilot-backup" "$CADDYFILE"
|
|
echo " ! caddy rejected the import line — it was taken back out again."
|
|
fi
|
|
fi
|
|
fi
|