#!/usr/bin/env bash # ----------------------------------------------------------------------------- # RadControl single-node installer. # # Copy-paste onto a fresh modern Linux box (Debian/Ubuntu or RHEL-family): # # curl -fsSL https://radcontrol.org/install.sh | sudo bash # # It installs from a SIGNED, PRE-BUILT image bundle - no git, no source, no # per-service builds. What it does (idempotent; re-run to upgrade - your # .env.prod secrets and data are never touched): # 1. installs Docker (get.docker.com) if missing # 2. downloads the latest release (version from /dl/latest.txt, or RC_VERSION # to pin), VERIFIES its Ed25519 signature + SHA-256 checksums, then # `docker load`s the images and unpacks the small runtime bundle # 3. sizes Postgres + ClickHouse to the host (cores/RAM -> PG_* vars) # 4. generates .env.prod secrets on first run (never overwrites) # 5. brings up the stack from the pre-built images (never --build) # 6. seeds the first root operator and prints the credentials # # By installing you accept the licence in EULA.txt (unpacked into RC_DIR). # # Overridables: RC_DIR=/opt/rcnext PUBLIC_HTTP_PORT=80 # RC_DL_BASE=https://radcontrol.org/dl (release mirror) # RC_VERSION=4.0.2 (pin a version) # ----------------------------------------------------------------------------- set -euo pipefail RC_DIR=${RC_DIR:-/opt/rcnext} RC_DL_BASE=${RC_DL_BASE:-https://radcontrol.org/dl} RC_VERSION=${RC_VERSION:-} # empty -> follow the latest.txt pointer PUBLIC_HTTP_PORT=${PUBLIC_HTTP_PORT:-80} say() { printf '\n\033[1;36m> %s\033[0m\n' "$*"; } die() { printf '\033[1;31mx %s\033[0m\n' "$*" >&2; exit 1; } # openssl (a prerequisite) gives a finite source - `head`/`cut` on /dev/urandom # would SIGPIPE `tr` and abort under `set -o pipefail`. rnd() { openssl rand -hex 24; } dc() { docker compose --env-file "$RC_DIR/.env.prod" "$@"; } [ "$(id -u)" = 0 ] || die "run as root: sudo bash install.sh" # Vendor Ed25519 public key (SPKI PEM) - verifies every release signature. RC_PUBKEY_PEM='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAgpo697eEVFUyU546XlEa2JnHbMo5C7S90I7WOid7llA= -----END PUBLIC KEY-----' # -- 1. base packages + docker ----------------------------------------------- say "Installing prerequisites" if command -v apt-get >/dev/null 2>&1; then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates tar gzip openssl >/dev/null elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl ca-certificates tar gzip openssl else die "unsupported distro: need apt-get or dnf" fi if ! command -v docker >/dev/null 2>&1; then say "Installing Docker" curl -fsSL https://get.docker.com | sh fi systemctl enable --now docker >/dev/null 2>&1 || true docker compose version >/dev/null 2>&1 || die "docker compose plugin missing" # -- 2. fetch + verify + load the release ------------------------------------ ARCH=$(uname -m) if [ -n "$RC_VERSION" ]; then VER=$RC_VERSION else say "Resolving the latest release from $RC_DL_BASE" VER=$(curl -fsSL "$RC_DL_BASE/latest.txt" | tr -d '[:space:]') [ -n "$VER" ] || die "could not read $RC_DL_BASE/latest.txt" fi IMAGES_TAR="rc-next-images-${VER}-${ARCH}.tar.gz" RUNTIME_TAR="rc-next-runtime-${VER}.tar.gz" work=$(mktemp -d) trap 'rm -rf "$work"' EXIT say "Downloading RadControl ${VER} (${ARCH})" for f in "$IMAGES_TAR" "$RUNTIME_TAR" SHA256SUMS SHA256SUMS.sig; do curl -fSL "$RC_DL_BASE/$f" -o "$work/$f" || die "download failed: $RC_DL_BASE/$f" done say "Verifying signature + checksums" printf '%s\n' "$RC_PUBKEY_PEM" > "$work/pub.pem" openssl pkeyutl -verify -pubin -inkey "$work/pub.pem" \ -rawin -in "$work/SHA256SUMS" -sigfile "$work/SHA256SUMS.sig" >/dev/null 2>&1 \ || die "signature verification FAILED - the release is not authentic; refusing to install" ( cd "$work" && sha256sum -c --ignore-missing SHA256SUMS >/dev/null 2>&1 ) \ || die "checksum mismatch - download corrupted; retry" say "Release authenticated (Ed25519 + SHA-256)." say "Unpacking runtime + loading images into Docker" mkdir -p "$RC_DIR" tar xzf "$work/$RUNTIME_TAR" -C "$RC_DIR" --strip-components=1 gunzip -c "$work/$IMAGES_TAR" | docker load cd "$RC_DIR" # -- 3. resource sizing ------------------------------------------------------ # Postgres carries the RADIUS hot path; give it the classic 25%/50% split, # clamped for tiny WISP boxes and for hosts far bigger than the DB needs. CORES=$(nproc) MEM_MB=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo) clamp() { local v=$1 lo=$2 hi=$3; [ "$v" -lt "$lo" ] && v=$lo; [ "$v" -gt "$hi" ] && v=$hi; echo "$v"; } PG_SHARED_BUFFERS="$(clamp $((MEM_MB / 4)) 128 8192)MB" PG_EFFECTIVE_CACHE_SIZE="$(clamp $((MEM_MB / 2)) 256 24576)MB" PG_WORK_MEM="$(clamp $((MEM_MB / 256)) 4 64)MB" PG_MAINTENANCE_WORK_MEM="$(clamp $((MEM_MB / 16)) 64 1024)MB" PG_MAX_CONNECTIONS=$(clamp $((50 + CORES * 25)) 100 400) PG_MAX_WORKER_PROCESSES=$(clamp "$CORES" 4 32) PG_MAX_PARALLEL_WORKERS=$(clamp $((CORES / 2)) 2 16) PG_PARALLEL_PER_GATHER=$(clamp $((CORES / 4)) 1 8) TSDB_BG_WORKERS=$(clamp $((CORES / 2)) 4 16) CH_MEM_LIMIT="$(clamp $((MEM_MB / 5)) 1024 4096)m" say "Sized for ${CORES} cores / ${MEM_MB} MB RAM: shared_buffers=${PG_SHARED_BUFFERS}, clickhouse cap=${CH_MEM_LIMIT}" # -- 4. secrets (first run only) + version/tuning (every run) ---------------- if [ ! -f .env.prod ]; then say "Generating .env.prod secrets" ADMIN_PASS=$(openssl rand -base64 48 | tr -dc 'A-Za-z2-9' | cut -c1-14) cat > .env.prod <> .env.prod < migrate -> app up --------------------------------------- # Bring up ONLY the datastores first, migrate the schema, then start the app - # so no service ever talks to a half-migrated database. say "Starting the database" dc up -d db clickhouse # Wait until Postgres is truly ready. The TimescaleDB image restarts Postgres # once after initdb (to load its preload library); require the ready state to # hold for a few checks so we don't race that restart. say "Waiting for the database" streak=0 for _ in $(seq 1 120); do if dc exec -T db pg_isready -U radius -d radius >/dev/null 2>&1; then streak=$((streak + 1)); [ "$streak" -ge 3 ] && break else streak=0; fi sleep 1 done [ "$streak" -ge 3 ] || die "database did not become ready" # Fresh vs upgrade: does the business schema already exist? IS_UPGRADE=0 if dc exec -T db psql -tAqc "SELECT to_regclass('public.operators') IS NOT NULL" 2>/dev/null | grep -q t; then IS_UPGRADE=1 fi DUMP="" if [ "$IS_UPGRADE" = 1 ]; then say "Existing installation detected - upgrading to ${VER}" # Safety snapshot BEFORE any schema change. Full schema + Tier-1 data # (TimescaleDB chunk data excluded so the dump stays small); enough to # roll the business state back if a migration goes wrong. mkdir -p "$RC_DIR/upgrade-backups" DUMP="$RC_DIR/upgrade-backups/pre-${VER}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" say "Backing up the database first -> $DUMP" if dc exec -T db pg_dump -U radius -d radius \ --exclude-table-data='_timescaledb_internal.*' | gzip > "$DUMP" \ && [ -s "$DUMP" ]; then say "Pre-upgrade backup saved ($(du -h "$DUMP" | cut -f1))." else rm -f "$DUMP" die "pre-upgrade backup failed - aborting before touching the schema" fi # Stop the app so nothing hits a half-migrated schema (brief RADIUS outage). say "Pausing app services for the migration" dc stop api web freeradius coa health scoring flow-ingest >/dev/null 2>&1 || true fi say "Applying database migrations" if ! ENV_FILE="$RC_DIR/.env.prod" MIG_DIR="$RC_DIR/db/migrations" bash "$RC_DIR/deploy/migrate.sh"; then printf '\n\033[1;31mx database migration failed - app NOT started on the new version.\033[0m\n' >&2 if [ "$IS_UPGRADE" = 1 ]; then cat >&2 < Backup & recovery -> Restore, if you have encrypted backups). Then re-run this installer pinned to your previous version: RC_VERSION= ... EOF fi exit 1 fi say "Starting RadControl ${VER}" dc up -d # -- 6. first root operator ------------------------------------------------- RC_ADMIN_LOGIN=$(grep -oP '^RC_ADMIN_LOGIN=\K.*' .env.prod || echo admin) RC_ADMIN_PASS=$(grep -oP '^RC_ADMIN_PASS=\K.*' .env.prod || true) if [ -n "$RC_ADMIN_PASS" ]; then dc exec -T db psql -q -U radius -d radius </dev/null | awk '{print $1}') cat <}:${PUBLIC_HTTP_PORT}/ Login ${RC_ADMIN_LOGIN:-admin} / ${RC_ADMIN_PASS:-} RADIUS ${IP:-}:1812/1813 (udp - open on your firewall) NEXT STEP - protect this install: - New site: System -> Backup & recovery -> generate the recovery passphrase, WRITE IT DOWN, point it at your storage. - Replacing a System -> Backup & recovery -> "Restore from backup..." dead server: and your subscribers, balances and settings return. Secrets live in ${RC_DIR}/.env.prod (also included in every encrypted backup snapshot). Re-run this script any time to upgrade + retune. -------------------------------------------------------------------- EOF