#!/usr/bin/env bash
# seedhost.sh: set up an Academy Network seed node on a Debian or
# Ubuntu server, from source, in one run.
#
# A seed is an ordinary full node with NO special authority (SPEC
# section 18): it cannot admit content, sway a review, or sign
# anything on the network's behalf. Its only power is availability.
# Running one donates a little disk and bandwidth so newcomers can
# always reach their first peer.
#
# What this script does, in order (read it before running it; never
# pipe a script you have not read into a root shell):
#   1. installs build tools, git, and Rust (rustup) if missing
#   2. clones the node source from the project's public forge and
#      builds it (a release build: 5 to 30 minutes depending on the
#      machine; a temporary swap file is added on low-memory hosts)
#   3. creates the no-login system user "academy-seed", a hardened
#      systemd service, and a config bootstrapped to the project seed
#   4. starts the node and prints your seed's onion address
#
# Re-running the script later UPDATES the node: it pulls the latest
# source, rebuilds, and restarts the service. Your config, data, and
# onion address are kept.
#
# Requirements: Debian or Ubuntu with systemd, root, about 3 GB of
# free disk for the build, and a network from which Tor is reachable.
# Before running it, check your hosting provider's terms (some
# restrict Tor services) and https://academynetwork.net/legal
#
# Usage:
#   curl -fsSO https://academynetwork.net/seedhost.sh
#   less seedhost.sh
#   sudo bash seedhost.sh

set -euo pipefail

FORGE_REPO="https://git.academynetwork.net/academy/node.git"
SRC_DIR="/opt/academy-seed/src"
BIN="/usr/local/bin/academy-seed"
DATA_DIR="/var/lib/academy-seed"
CONFIG="/etc/academy-seed.toml"
UNIT="/etc/systemd/system/academy-seed.service"
SERVICE_USER="academy-seed"
# The project's first seed: the network's initial bootstrap point.
BOOTSTRAP_ONION="2hf4kjgdrvrgf3zkamunrgfx7ewelzkkuy2wd5ypjcnaa4tnz3ibolqd.onion"
# The pinned genesis MANIFEST id (SPEC 7), the editorial trust anchor
# every client ships. Empty until the network's genesis ceremony has
# happened; until then the node relays manifests but follows no chain.
GENESIS_PIN="b3:c06e2fdabad7798df8a34368f7dcc1dbb2b3fc9b9bf251fa9e1b4395af9f78a0"

# ---- Signed auto-update (SPEC 18.4) ----
# The node keeps itself current by rebuilding when a new release is
# published that is signed by a THRESHOLD of the release roster (M of
# N), not by any single key (SPEC 14: no one key is load-bearing). This
# is the trust anchor: the GENESIS roster, baked in here. Later rosters
# form a succession chain signed by the previous set, so the signers
# can rotate to the community without the founder; every seed verifies
# that chain back to this genesis. A seed rebuilds a commit ONLY when
# the release meets the current roster's threshold AND its version is
# strictly higher than installed. Compromising the git server is not
# enough to push code; a threshold of the roster's keys is required.
RELEASE_GENESIS_ROSTER='{"version":1,"threshold":2,"signers":["b9a706d335a53ecd540164e49ddfcaf49c486539d012ccd19b1f0501d94760a6","dddab4d5494229b8131dcb8bc416bc339ab22bf5f774dccde9aa42f66b32cc85","5e77b5577284d1bf892b12c532b3e3f53a9b7eb51360be55ab816c7ba8375ef5"]}'
# Served from the website, a different system than the code repo, so
# neither one alone can push code.
RELEASE_URL="https://academynetwork.net/release.json"
RELEASE_SIGS_URL="https://academynetwork.net/release.sigs"
ROSTER_URL="https://academynetwork.net/release-roster.json"
GENESIS_ROSTER_FILE="$DATA_DIR/release-genesis-roster.json"
UPDATER="/usr/local/bin/academy-seed-update"
UPDATE_UNIT="/etc/systemd/system/academy-seed-update.service"
UPDATE_TIMER="/etc/systemd/system/academy-seed-update.timer"
VERSION_FILE="$DATA_DIR/installed-version"
# Canary window (SPEC 18.4). An auto-updating seed adopts a new signed
# release only after it has been the LATEST for CANARY_DAYS without
# being superseded, so there is time to catch and withdraw a bad
# release before the fleet moves (withdrawal = publish a good release
# over it, which resets the window). A small, coordination-free canary
# cohort (CANARY_PERCENT of the fleet, chosen by a hash of the node id
# and the version, no telemetry) adopts a release early so the code
# gets real exposure while the rest wait. Override at install:
#   CANARY_DAYS=7 CANARY_PERCENT=5 bash seedhost.sh
CANARY_DAYS="${CANARY_DAYS:-3}"
CANARY_PERCENT="${CANARY_PERCENT:-10}"
PENDING_FILE="$DATA_DIR/update-pending"
# Auto-update runs by default; disable with:
#   systemctl disable --now academy-seed-update.timer
AUTO_UPDATE="${AUTO_UPDATE:-on}"

say()  { echo "[seedhost] $*"; }
fail() { echo "[seedhost] ERROR: $*" >&2; exit 1; }

[ "$(id -u)" -eq 0 ] || fail "run as root: sudo bash seedhost.sh"
command -v apt-get >/dev/null 2>&1 || fail "this script supports Debian and Ubuntu (apt-get not found)"
command -v systemctl >/dev/null 2>&1 || fail "systemd is required"

# 1. Build dependencies, then Rust via rustup if there is no cargo.
say "installing build dependencies (git, compiler, pkg-config)..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git build-essential pkg-config libssl-dev curl ca-certificates >/dev/null

CARGO="$HOME/.cargo/bin/cargo"
if command -v cargo >/dev/null 2>&1; then CARGO="$(command -v cargo)"; fi
if [ ! -x "$CARGO" ]; then
  say "installing Rust (rustup, stable, minimal profile)..."
  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
    | sh -s -- -y --profile minimal --default-toolchain stable >/dev/null
  CARGO="$HOME/.cargo/bin/cargo"
fi
say "using $("$CARGO" --version)"

# The release build wants roughly 2 GB of memory and small VPSes have
# less: add a temporary swap file so the compiler cannot die to the
# OOM killer. Not added to fstab; it vanishes on reboot, and the
# running seed does not need it.
total_kb=$(awk '/MemTotal|SwapTotal/ {sum += $2} END {print sum}' /proc/meminfo)
if [ "$total_kb" -lt 2000000 ] && [ ! -f /swap-academy-build ]; then
  say "under 2 GB of memory: adding a 2 GB swap file for the build..."
  fallocate -l 2G /swap-academy-build 2>/dev/null \
    || dd if=/dev/zero of=/swap-academy-build bs=1M count=2048 status=none
  chmod 600 /swap-academy-build
  mkswap /swap-academy-build >/dev/null
  swapon /swap-academy-build
fi

# 2. Source: clone on the first run, fast-forward on re-runs.
if [ -d "$SRC_DIR/.git" ]; then
  say "updating the node source..."
  git -C "$SRC_DIR" pull --ff-only
else
  say "cloning the node source from the public forge..."
  mkdir -p "$(dirname "$SRC_DIR")"
  git clone -q "$FORGE_REPO" "$SRC_DIR"
fi

say "building academy-seed (release build; this is the slow part)..."
(cd "$SRC_DIR" && "$CARGO" build --release -p academy-seed)
install -m 755 "$SRC_DIR/target/release/academy-seed" "$BIN"

# Record the installed version (the node repo's commit count), so the
# auto-updater below only ever moves FORWARD to a higher-numbered
# signed release, never sideways or backward.
mkdir -p "$DATA_DIR"
git -C "$SRC_DIR" rev-list --count HEAD >"$VERSION_FILE"

# Bake the GENESIS release roster as the local trust anchor. Every
# published roster chain must verify back to this exact object; it
# arrived with this script over HTTPS (the same trust as the install
# itself), never from the code repo.
printf '%s' "$RELEASE_GENESIS_ROSTER" >"$GENESIS_ROSTER_FILE"

# 3. Service user, data dir, config, and the systemd unit.
id -u "$SERVICE_USER" >/dev/null 2>&1 \
  || useradd --system --home-dir "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
mkdir -p "$DATA_DIR"
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
chmod 700 "$DATA_DIR"

if [ -f "$CONFIG" ]; then
  say "keeping the existing $CONFIG"
else
  say "writing $CONFIG..."
  cat > "$CONFIG" <<EOF
# academy-seed configuration (SPEC section 18).
# The data dir holds your onion key: losing it changes your address.
nickname = "academy-seed"
data_dir = "$DATA_DIR"

# First peers to dial. The project seed is prefilled; the node learns
# more peers on its own once connected.
bootstrap = ["$BOOTSTRAP_ONION"]

# Local health endpoint (loopback only; never expose it).
status_addr = "127.0.0.1:9777"

# Connection ceiling, and the blob disk budget in GB. Raise the
# budget if you can spare the disk; over it, the node evicts its
# least-recently-used cached media and keeps the rare pieces it is
# responsible for.
max_peers = 128
blob_budget_gb = 40

# Self-preservation: keep this much of the disk free no matter what
# the budget says, and treat sustained memory growth past the soft
# limit as a leak (0 = auto, 80% of the service's MemoryMax).
disk_reserve_gb = 2
rss_soft_limit_mb = 0

# The pinned genesis manifest id (SPEC 7): the editorial trust anchor.
${GENESIS_PIN:+genesis = "$GENESIS_PIN"}
EOF
fi

cat > "$UNIT" <<EOF
[Unit]
Description=Academy Network seed node
Documentation=https://git.academynetwork.net/academy/node
After=network-online.target
Wants=network-online.target
# Self-healing: never stop retrying, however often it fails. The
# binary backs off on its own, and RestartSec spaces the outer loop.
StartLimitIntervalSec=0

[Service]
User=$SERVICE_USER
Group=$SERVICE_USER
ExecStart=$BIN $CONFIG
# Type=notify: the seed reports READY once its onion service is
# published, and feeds the watchdog only while its node loop is
# provably beating, so systemd recycles a WEDGED process, not just a
# crashed one. The generous start timeout covers a first-run Tor
# directory download.
Type=notify
WatchdogSec=180
TimeoutStartSec=600
TimeoutStopSec=30
Restart=always
RestartSec=15
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$DATA_DIR
# Resource fences: throttle at MemoryHigh, hard-kill at MemoryMax
# (the seed watches its own use and restarts cleanly well before
# either), and keep descriptors and tasks bounded.
MemoryHigh=2560M
MemoryMax=3G
LimitNOFILE=8192
TasksMax=512

[Install]
WantedBy=multi-user.target
EOF

# 3b. Signed auto-updater (SPEC 18.4). A root-side script the node
# itself cannot run (it is a sandboxed, no-privilege service), driven
# by a systemd timer. It resolves the current release roster from the
# succession chain (verified back to the baked genesis roster with the
# INSTALLED binary), requires a THRESHOLD of that roster's signatures
# over the release, refuses anything not strictly newer (downgrade
# guard), holds the release through a CANARY WINDOW (adopt only after
# it has been the latest for N days, so a bad release can be withdrawn
# by publishing a good one over it before the fleet moves; a small
# coordination-free cohort adopts early for exposure), then rebuilds
# the exact signed commit, smoke-tests it, swaps it in with a kept-back
# rollback copy, and restarts the self-healing service, rolling back if
# the node does not come back healthy.
cat > "$UPDATER" <<UPDATER_EOF
#!/usr/bin/env bash
# academy-seed-update: apply a signed node release, or do nothing.
# Installed by seedhost.sh. Runs as root from a systemd timer.
set -euo pipefail

FORGE_REPO="$FORGE_REPO"
SRC_DIR="$SRC_DIR"
BIN="$BIN"
DATA_DIR="$DATA_DIR"
VERSION_FILE="$VERSION_FILE"
GENESIS_ROSTER_FILE="$GENESIS_ROSTER_FILE"
RELEASE_URL="$RELEASE_URL"
RELEASE_SIGS_URL="$RELEASE_SIGS_URL"
ROSTER_URL="$ROSTER_URL"
CANARY_DAYS="$CANARY_DAYS"
CANARY_PERCENT="$CANARY_PERCENT"
PENDING_FILE="$PENDING_FILE"

log() { echo "[seed-update] \$*"; }

# One updater at a time.
exec 9>/run/academy-seed-update.lock
flock -n 9 || { log "another update is running; skipping"; exit 0; }

work="\$(mktemp -d)"
trap 'rm -rf "\$work"' EXIT

# 1. Fetch the roster chain and the release + its signatures.
curl -fsS --max-time 60 "\$ROSTER_URL" -o "\$work/roster-chain.json" || {
  log "could not fetch the roster chain; will retry next timer"; exit 0; }
curl -fsS --max-time 60 "\$RELEASE_URL" -o "\$work/release.json" || {
  log "could not fetch \$RELEASE_URL; will retry next timer"; exit 0; }
curl -fsS --max-time 60 "\$RELEASE_SIGS_URL" -o "\$work/release.sigs" || {
  log "could not fetch the release signatures; skipping"; exit 0; }

# 2. Resolve the CURRENT roster: verify the succession chain back to the
#    baked genesis, then require a THRESHOLD of the current roster's
#    signers over the release. Both steps fail CLOSED (the installed
#    binary exits nonzero on any break), so no unverified code is built.
if ! "\$BIN" roster-current "\$GENESIS_ROSTER_FILE" "\$work/roster-chain.json" \\
      >"\$work/current-roster.json"; then
  log "roster chain did NOT verify against the baked genesis; refusing to update"
  exit 1
fi
if ! "\$BIN" verify-threshold "\$work/current-roster.json" \\
      "\$work/release.json" "\$work/release.sigs"; then
  log "release did NOT meet the roster threshold; refusing to update"
  exit 1
fi

# 3. Parse version + commit (plain grep; the manifest is our own tiny JSON).
new_version="\$(grep -oE '"version":[0-9]+' "\$work/release.json" | grep -oE '[0-9]+')"
commit="\$(grep -oE '"commit":"[0-9a-f]+"' "\$work/release.json" | cut -d'"' -f4)"
[ -n "\$new_version" ] && [ -n "\$commit" ] || { log "malformed manifest; skipping"; exit 1; }

installed="\$(cat "\$VERSION_FILE" 2>/dev/null || echo 0)"
if [ "\$new_version" -le "\$installed" ]; then
  log "already at version \$installed (release is \$new_version); nothing to do"
  rm -f "\$PENDING_FILE"
  exit 0
fi
log "signed release \$new_version > installed \$installed"

# 3b. Canary window (SPEC 18.4). Adopt only after this release has been
#     the latest for a hold period, measured from THIS node's own first
#     sighting (no fleet telemetry). A bad release is withdrawn by
#     publishing a good one over it, which changes the latest version
#     and resets the window, so it is never adopted. A coordination-free
#     canary cohort adopts early for real exposure while the rest wait.
now="\$(date +%s)"
pend_v="\$(sed -n 1p "\$PENDING_FILE" 2>/dev/null || true)"
pend_since="\$(sed -n 2p "\$PENDING_FILE" 2>/dev/null || true)"
if [ "\$pend_v" != "\$new_version" ]; then
  printf '%s\n%s\n' "\$new_version" "\$now" >"\$PENDING_FILE"
  log "candidate v\$new_version first seen; canary window opens"
  exit 0
fi
age=\$(( now - \${pend_since:-\$now} ))
# Cohort: hash(version || node identity) mod 100 < CANARY_PERCENT. No
# identity yet (fresh node) => not a canary (the safe, full-wait side).
cohort=100
if [ -f "\$DATA_DIR/identity.key" ]; then
  h="\$(printf '%s' "\$new_version" | cat "\$DATA_DIR/identity.key" - | sha256sum | cut -c1-8)"
  cohort=\$(( 0x\$h % 100 ))
fi
if [ "\$cohort" -lt "\$CANARY_PERCENT" ]; then
  required=0; role="canary"                 # adopt next tick: real exposure
else
  required=\$(( CANARY_DAYS * 86400 )); role="fleet"
fi
if [ "\$age" -lt "\$required" ]; then
  log "v\$new_version in the canary window (\$role: \${age}s of \${required}s); waiting"
  exit 0
fi
log "v\$new_version cleared the canary window (\$role); updating to \${commit:0:12}"

# 4. Fetch and move to the exact signed commit. Git content-addresses
#    it, so the server cannot substitute different code for this id. We
#    reset the main branch to it (not a detached checkout), so a later
#    manual seedhost.sh re-run's 'git pull --ff-only' still works.
git -C "\$SRC_DIR" fetch -q origin || { log "git fetch failed; skipping"; exit 0; }
git -C "\$SRC_DIR" cat-file -e "\${commit}^{commit}" 2>/dev/null || {
  log "signed commit \$commit not on the forge yet; will retry"; exit 0; }
git -C "\$SRC_DIR" checkout -q -B main "\$commit"

# 5. Build. A tiny VPS can OOM linking; add temporary swap if low on RAM.
mem_kb="\$(awk '/MemTotal|SwapTotal/ {s+=\$2} END {print s}' /proc/meminfo)"
swap=""
if [ "\$mem_kb" -lt 2000000 ]; then
  swap="/swap-academy-update"
  fallocate -l 2G "\$swap" 2>/dev/null || dd if=/dev/zero of="\$swap" bs=1M count=2048 status=none
  chmod 600 "\$swap"; mkswap "\$swap" >/dev/null; swapon "\$swap"
fi
# Must always return 0: this runs inside the EXIT trap under set -e, and
# a bare [ -n ] && {...} returns 1 when no swap was added, which would
# turn a fully successful update into a "failed" unit.
cleanup_swap() { [ -n "\$swap" ] || return 0; swapoff "\$swap" 2>/dev/null || true; rm -f "\$swap"; }
trap 'cleanup_swap; rm -rf "\$work"' EXIT

# systemd runs this with no HOME set (and set -u is on), so never expand
# a bare \$HOME here: root's cargo lives under /root when HOME is absent.
CARGO="\${HOME:-/root}/.cargo/bin/cargo"
command -v cargo >/dev/null 2>&1 && CARGO="\$(command -v cargo)"
log "building (this can take a few minutes)..."
if ! (cd "\$SRC_DIR" && "\$CARGO" build --release -p academy-seed); then
  log "build failed; keeping the current binary"; exit 1
fi
new_bin="\$SRC_DIR/target/release/academy-seed"

# 6. Smoke test: the fresh binary must at least run.
"\$new_bin" --version >/dev/null || { log "new binary will not run; aborting"; exit 1; }

# 7. Swap in, keeping the old one for rollback, then restart.
cp -f "\$BIN" "\$BIN.prev" 2>/dev/null || true
install -m 755 "\$new_bin" "\$BIN"
log "restarting the seed into version \$new_version..."
systemctl restart academy-seed

# 8. Health gate: the node must come back healthy, or roll back.
healthy=0
for _ in \$(seq 1 24); do
  sleep 5
  if systemctl is-active --quiet academy-seed \\
     && curl -fsS --max-time 5 http://127.0.0.1:9777/healthz >/dev/null 2>&1; then
    healthy=1; break
  fi
done

if [ "\$healthy" -eq 1 ]; then
  echo "\$new_version" >"\$VERSION_FILE"
  rm -f "\$PENDING_FILE"
  log "updated to version \$new_version and healthy."
else
  log "new version did not come back healthy; ROLLING BACK."
  if [ -f "\$BIN.prev" ]; then
    # The running BINARY is what matters; restore it. VERSION_FILE is
    # left untouched (still \$installed), so this release is not retried
    # until a higher-numbered one is signed. The source tree stays at
    # the new commit, harmless: the next good update checks out afresh.
    install -m 755 "\$BIN.prev" "\$BIN"
    systemctl restart academy-seed
    log "rolled back to version \$installed."
  fi
  exit 1
fi
UPDATER_EOF
chmod 755 "$UPDATER"

cat > "$UPDATE_UNIT" <<EOF
[Unit]
Description=Academy Network seed: apply signed updates
Documentation=https://git.academynetwork.net/academy/node
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=$UPDATER
EOF

cat > "$UPDATE_TIMER" <<EOF
[Unit]
Description=Academy Network seed: check for signed updates

[Timer]
# A few hours after boot, then every 6 hours, with jitter so the whole
# fleet does not stampede the forge at the same instant.
OnBootSec=15min
OnUnitActiveSec=6h
RandomizedDelaySec=30min
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload

# 4. Start (or restart into the new build) and report.
start_ts=$(date '+%Y-%m-%d %H:%M:%S')
systemctl enable academy-seed >/dev/null 2>&1
systemctl restart academy-seed

# Signed auto-update: on by default (AUTO_UPDATE=off on the command
# line opts out at install; disable later with
#   systemctl disable --now academy-seed-update.timer).
if [ "$AUTO_UPDATE" != "off" ]; then
  systemctl enable --now academy-seed-update.timer >/dev/null 2>&1
  say "signed auto-update is ON (6h timer, ${CANARY_DAYS}-day canary window)."
  say "Disable with: systemctl disable --now academy-seed-update.timer"
else
  systemctl disable --now academy-seed-update.timer >/dev/null 2>&1 || true
  say "signed auto-update is OFF (AUTO_UPDATE=off). Enable with:"
  say "    systemctl enable --now academy-seed-update.timer"
fi

say "seed started; waiting for it to publish its onion address"
say "(the first run bootstraps a Tor directory; allow a few minutes)"

addr=""
for _ in $(seq 1 60); do
  addr=$(journalctl -u academy-seed -o cat --no-pager --since "$start_ts" 2>/dev/null \
    | grep -oE '[a-z2-7]{56}\.onion' | tail -1) || true
  [ -n "$addr" ] && break
  systemctl is-active --quiet academy-seed || break
  sleep 5
done

echo
if [ -n "$addr" ]; then
  say "SUCCESS. This machine is now an Academy Network seed node:"
  say "    $addr"
  say "Nothing to register and nobody to ask: peers will find and use"
  say "your node on their own."
else
  say "the seed has not published its address yet. Watch it with:"
  say "    journalctl -u academy-seed -f"
fi
say "status endpoint: curl http://127.0.0.1:9777/  (this machine only)"
say "logs:            journalctl -u academy-seed -f"
say "auto-update:     signed releases, on by default (6h timer)"
say "                 journalctl -u academy-seed-update -f"
say "update now:      systemctl start academy-seed-update"
say "or manually:     re-run this script"
say "BACK UP $DATA_DIR: it holds your onion key, and losing it"
say "changes your node's address."
