#!/usr/bin/env bash
# Idempotent bootstrap for a fresh MNW node (tier A/B/C deploy target).
#
# Run on the new node as root. After this finishes, sandod on the Sando host
# can rsync + deploy to <ssh_target>:/opt/mnw/.
#
# Required env:
#   SANDO_PUBKEY  — sando user's public key on the Sando host. Get it via:
#                   `ssh fw13 'sudo cat /srv/sando/.ssh/id_ed25519.pub'`
#
# Optional env:
#   DEPLOY_ROOT      — defaults to /opt/mnw
#   BIN_NAME         — primary binary name (matches sando-daemon.toml's
#                      bin_names[0]). Defaults to "makenotwork".
#   SERVICE_NAME     — systemd unit name. Defaults to "makenotwork.service".
#   SERVICE_USER     — runtime user for the binary. Defaults to "deploy".
#   GIT_REPOS_PATH   — where the server keeps bare git repositories. Defaults
#                      to $STATE_DIR/git, which is what prod and testnot both
#                      run. It must match GIT_REPOS_PATH in the env file: the
#                      unit's sandbox makes everything else read-only, and the
#                      binary's own default is /opt/git, so a node that omits
#                      it from the env file writes somewhere the sandbox
#                      forbids.
#   ENABLE_FIREWALL  — "1" to set up UFW (22/80/443). Defaults to "1".
#   INSTALL_CADDY    — "1" to apt-install caddy (config is operator's job).
#                      Defaults to "1".
#   INSTALL_POSTGRES — "1" to apt-install postgresql. Defaults to "1".
#   INSTALL_TAILSCALE — "1" to apt-install tailscale (NOT authenticated;
#                       operator runs `tailscale up`). Defaults to "1".
#
# What this does NOT do (operator's job):
#   - tailscale up (auth)
#   - DNS records
#   - Caddyfile content + Cloudflare origin certs + private keys
#   - postgres role + db + .env / DATABASE_URL
#   - any secrets

set -euo pipefail

if [[ $EUID -ne 0 ]]; then
    echo "must run as root" >&2
    exit 1
fi
if [[ -z "${SANDO_PUBKEY:-}" ]]; then
    echo "SANDO_PUBKEY env var is required" >&2
    exit 1
fi

DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/mnw}"
# FHS-style sidecar paths the systemd unit references. Bootstrap creates the
# dirs but does not populate `ENV_FILE` — operator drops secrets in after the
# bootstrap finishes, before starting the service.
ETC_DIR="${ETC_DIR:-/etc/mnw}"
ENV_FILE="${ENV_FILE:-$ETC_DIR/makenotwork.env}"
STATE_DIR="${STATE_DIR:-/var/lib/mnw}"
GIT_REPOS_PATH="${GIT_REPOS_PATH:-$STATE_DIR/git}"
# The git user's home, where `mnw-admin rebuild-keys` writes
# `.ssh/authorized_keys`. The server reads it from `GIT_HOME` independently of
# `GIT_REPOS_PATH`, so it gets its own ReadWritePaths treatment below: a node
# where the two differ and one falls outside STATE_DIR would otherwise get an
# unwritable authorized_keys with no warning.
GIT_HOME="${GIT_HOME:-$GIT_REPOS_PATH}"
BIN_NAME="${BIN_NAME:-makenotwork}"
SERVICE_NAME="${SERVICE_NAME:-makenotwork.service}"
SERVICE_USER="${SERVICE_USER:-deploy}"
ENABLE_FIREWALL="${ENABLE_FIREWALL:-1}"
INSTALL_CADDY="${INSTALL_CADDY:-1}"
INSTALL_POSTGRES="${INSTALL_POSTGRES:-1}"
INSTALL_TAILSCALE="${INSTALL_TAILSCALE:-1}"

export DEBIAN_FRONTEND=noninteractive

log() { echo "[bootstrap] $*"; }

log "1/8 base packages"
apt-get update -qq
apt-get install -y -qq curl gnupg ca-certificates rsync ufw fail2ban > /dev/null

if [[ "$INSTALL_POSTGRES" == "1" ]]; then
    log "2/8 postgresql"
    apt-get install -y -qq postgresql > /dev/null
else
    log "2/8 skipping postgresql"
fi

if [[ "$INSTALL_TAILSCALE" == "1" ]]; then
    log "3/8 tailscale (not authenticating)"
    if ! command -v tailscale >/dev/null; then
        # Ubuntu codename. tailscale's repo is published per-codename;
        # noble (24.04) keys work on 24.04+ derivatives.
        codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
        curl -fsSL "https://pkgs.tailscale.com/stable/ubuntu/${codename}.noarmor.gpg" \
            > /usr/share/keyrings/tailscale-archive-keyring.gpg
        curl -fsSL "https://pkgs.tailscale.com/stable/ubuntu/${codename}.tailscale-keyring.list" \
            > /etc/apt/sources.list.d/tailscale.list
        apt-get update -qq
        apt-get install -y -qq tailscale > /dev/null
        systemctl enable --now tailscaled
    fi
else
    log "3/8 skipping tailscale"
fi

if [[ "$INSTALL_CADDY" == "1" ]]; then
    log "4/8 caddy (no Caddyfile — operator's job)"
    if ! command -v caddy >/dev/null; then
        curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
            | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
            > /etc/apt/sources.list.d/caddy-stable.list
        apt-get update -qq
        apt-get install -y -qq caddy > /dev/null
    fi
else
    log "4/8 skipping caddy"
fi

log "5/8 deploy user + dirs"
if ! id "$SERVICE_USER" &>/dev/null; then
    useradd -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
fi
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0700 "/home/$SERVICE_USER/.ssh"
if ! grep -qF "$SANDO_PUBKEY" "/home/$SERVICE_USER/.ssh/authorized_keys" 2>/dev/null; then
    echo "$SANDO_PUBKEY" >> "/home/$SERVICE_USER/.ssh/authorized_keys"
fi
chown "$SERVICE_USER:$SERVICE_USER" "/home/$SERVICE_USER/.ssh/authorized_keys"
chmod 0600 "/home/$SERVICE_USER/.ssh/authorized_keys"
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$DEPLOY_ROOT" "$DEPLOY_ROOT/releases"
# FHS sidecars: /etc/mnw owned root:service (so the service can read the env
# file but not edit it); /var/lib/mnw owned service:service for runtime
# state (backups, scan-spool, anything else the binary writes).
install -d -o root -g "$SERVICE_USER" -m 0750 "$ETC_DIR"
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$STATE_DIR"

# If the git user exists (i.e. this host runs git SSH), grant it read access
# to the env file via ACL so mnw-admin git-auth can load DATABASE_URL. The git
# user is neither owner nor in the SERVICE_USER group, so without this the
# /etc/mnw/makenotwork.env is unreadable and every `git push` panics with
# "DATABASE_URL must be set". Conditional + idempotent.
if getent passwd git >/dev/null; then
    setfacl -m u:git:x "$ETC_DIR"
    if [ -f "$ENV_FILE" ]; then
        setfacl -m u:git:r "$ENV_FILE"
    fi
fi

# Bare git repositories. On a host that runs git SSH, two accounts write here:
# the web app creates the owner directory and the bare repo, and `git push`
# writes objects as the git user. Group-writable + setgid so whichever of them
# creates a directory, the other can still write inside it. Measured
# 2026-08-25: prod has this directory 0755 git:git, so the service cannot
# create a new owner directory at all and a creator's first repository fails
# there today — unrelated to the sandbox, which is why it is fixed here.
if getent passwd git >/dev/null; then
    install -d -o "$SERVICE_USER" -g git -m 2775 "$GIT_REPOS_PATH"
else
    install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$GIT_REPOS_PATH"
fi

log "6/8 sudoers (systemctl on $SERVICE_NAME for $SERVICE_USER)"
cat > "/etc/sudoers.d/${SERVICE_USER}-mnw" <<EOF
$SERVICE_USER ALL=(ALL) NOPASSWD: /bin/systemctl reload-or-restart $SERVICE_NAME, /bin/systemctl restart $SERVICE_NAME, /bin/systemctl status $SERVICE_NAME
EOF
chmod 0440 "/etc/sudoers.d/${SERVICE_USER}-mnw"
visudo -c -f "/etc/sudoers.d/${SERVICE_USER}-mnw" >/dev/null

log "7/8 systemd unit ($SERVICE_NAME) — points at $DEPLOY_ROOT/current/$BIN_NAME"
cat > "/etc/systemd/system/$SERVICE_NAME" <<EOF
[Unit]
Description=Makenotwork
Documentation=https://makenot.work/docs
After=network.target

[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$DEPLOY_ROOT/current
ExecStart=$DEPLOY_ROOT/current/$BIN_NAME
# Secrets live outside the release dir so they survive deploys + rollbacks.
# Bootstrap creates ETC_DIR but not ENV_FILE — operator populates that.
EnvironmentFile=$ENV_FILE
# Runtime state (backups, spool, etc.) on FHS path; never inside the release
# dir or the deploy will erase it.
ReadWritePaths=$STATE_DIR$(
    # Bare repositories are normally under STATE_DIR and covered by the line
    # above. Emit a second path only when the operator has put them elsewhere,
    # so a sandbox that lists only STATE_DIR cannot silently break repository
    # creation.
    emitted=""
    # GIT_HOME defaults to GIT_REPOS_PATH, so dedupe rather than emit twice.
    for extra in "$GIT_REPOS_PATH" "$GIT_HOME"; do
        case "$extra/" in
            "$STATE_DIR"/*) continue ;;
        esac
        case " $emitted " in
            *" $extra "*) continue ;;
        esac
        emitted="$emitted $extra"
        printf '\nReadWritePaths=%s' "$extra"
    done
)
Restart=on-failure
RestartSec=30
# Exit 2 = migration failure (MNW server convention). Don't restart;
# operator must intervene before the next deploy.
RestartPreventExitStatus=2
# Scan-spool tempfiles for streaming large uploads through the malware
# pipeline. systemd creates the directory, chowns it to the service user and
# adds it to ReadWritePaths. The path is mirrored in
# \`makenotwork::constants::SCAN_SPOOL_DIR\`, so it is /var/lib/makenotwork
# rather than \$STATE_DIR and the two names are not interchangeable.
# The second entry is the drop-box for authorized_keys rebuild requests; see the
# .path unit below. Named explicitly rather than relying on the parent of the
# spool being writable. Both paths are mirrored in
# \`makenotwork::constants\` (\`SCAN_SPOOL_DIR\`, \`KEYS_REBUILD_MARKER\`).
StateDirectory=makenotwork/scan-spool makenotwork/keys
StateDirectoryMode=0700
# The ceilings server/docs/troubleshooting.md documents. Both are measured
# rather than chosen: prod's own cgroup reported 392M anonymous (unreclaimable)
# with a 403M peak on 2026-08-25, so the 512M this template used to write would
# have left ~110M of headroom and been an OOM kill on the first content export,
# which holds one file of up to 500M in memory. MemoryHigh throttles and
# reclaims before MemoryMax kills. The soft NOFILE the service actually gets
# without this line is 1024, not the 524288 that \`systemctl show\` reports
# (that is the hard limit); the server had 18 descriptors open when measured.
LimitNOFILE=65535
MemoryHigh=1G
MemoryMax=2G
# The sandbox. Verified 2026-08-25 by running a probe under exactly these
# options as the service user, on prod and on testnot: bare-repo creation, the
# scan spool, an export's private /tmp, clamd's unix socket, postgres over both
# TCP and its unix socket, and the yara rules all still work, while /opt, /etc
# and the rest of /var are read-only. ProtectSystem=strict is what makes the
# ReadWritePaths above load-bearing rather than decorative.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$BIN_NAME

[Install]
WantedBy=multi-user.target
EOF

# The authorized_keys rebuild, out of the service's process tree.
#
# sshd's StrictModes refuses an authorized_keys whose file or .ssh directory is
# group-writable, so the service user cannot be given write access to it, and
# the sandbox above implies NoNewPrivileges (PrivateDevices, ProtectClock,
# MemoryDenyWriteExecute, RestrictNamespaces, RestrictSUIDSGID, LockPersonality
# and SystemCallFilter each imply it, and NoNewPrivileges=no does not undo the
# implication), so sudo cannot raise privilege from inside the unit either.
# Measured 2026-09-01: sudo under any one of those options exits 1 with "the
# no new privileges flag is set". The service therefore writes a marker into
# its StateDirectory and this pair does the write as root.
#
# The marker path is mirrored in `makenotwork::constants::KEYS_REBUILD_MARKER`
# and is fixed at /var/lib/makenotwork, not $STATE_DIR, because systemd derives
# it from StateDirectory.
KEYS_MARKER="/var/lib/makenotwork/keys/rebuild"
cat > /etc/systemd/system/makenotwork-rebuild-keys.path <<EOF
[Unit]
Description=Watch for makenotwork authorized_keys rebuild requests

[Path]
PathExists=$KEYS_MARKER
PathModified=$KEYS_MARKER
Unit=makenotwork-rebuild-keys.service

[Install]
WantedBy=multi-user.target
EOF

# Deletes the marker before rebuilding, so a rebuild that outlives its request
# cannot re-trigger itself. A failed rebuild loses its marker and shows up as a
# failed unit rather than a retry loop; the server's own warn line covers the
# case where the request never lands.
cat > /etc/systemd/system/makenotwork-rebuild-keys.service <<EOF
[Unit]
Description=Rebuild the git user's authorized_keys from the database

[Service]
Type=oneshot
EnvironmentFile=$ENV_FILE
ExecStartPre=-/bin/rm -f $KEYS_MARKER
ExecStart=$DEPLOY_ROOT/current/mnw-admin rebuild-keys
StandardOutput=journal
StandardError=journal
SyslogIdentifier=makenotwork-rebuild-keys
EOF

systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
systemctl enable --now makenotwork-rebuild-keys.path >/dev/null 2>&1 || true

if [[ "$ENABLE_FIREWALL" == "1" ]]; then
    log "8/8 firewall (UFW: 22/80/443 in, all else deny)"
    ufw --force reset > /dev/null
    ufw default deny incoming > /dev/null
    ufw default allow outgoing > /dev/null
    ufw allow 22/tcp > /dev/null
    ufw allow 80/tcp > /dev/null
    ufw allow 443/tcp > /dev/null
    ufw --force enable > /dev/null
else
    log "8/8 skipping firewall"
fi

echo
log "Done. Next steps for the operator:"
echo "   - tailscale up    (auth this node to the tailnet)"
echo "   - DNS A/AAAA records for the domain you'll serve"
echo "   - Install /etc/caddy/Caddyfile + Cloudflare Origin CA cert + key"
echo "   - postgres: create role+db, drop secrets into $ENV_FILE (chmod 0640, chown root:$SERVICE_USER)"
echo "   - Run a sando deploy from the Sando host: POST /promote/<tier>"
