#!/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" cat > /etc/systemd/system/clupilot-update-agent.service < /etc/systemd/system/clupilot-update-agent.timer <<'EOF' [Unit] Description=Check for CluPilot updates and run requested ones [Timer] # Every five minutes: fast enough that the button feels like it did something, # slow enough that fetching from the repository is not a nuisance. OnBootSec=3min OnUnitActiveSec=5min AccuracySec=30s [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. if ( cd "$ROOT" && runuser -u "$APP_USER" -- docker compose exec -T 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 few minutes." 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 </dev/null || rm -f /etc/sudoers.d/clupilot-caddy-reload fi systemctl daemon-reload systemctl enable --now clupilot-update-agent.timer >/dev/null # Run it once now, so the panel has a status to show instead of "never reported # in" for the first five minutes. systemctl start clupilot-update-agent.service || true echo "Update agent installed and running (clupilot-update-agent.timer)."