CluPilotCloud/deploy/install-agent.sh

150 lines
6.6 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"
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
cat > /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 <<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
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)."