#!/usr/bin/env bash # ────────────────────────────────────────────────────────────────────────────── # Argos CISO server — one-command installer # # Downloads the signed server bundle, verifies its SHA-256, extracts to # /opt/argos-server, generates a `.env` with auto-random secrets, and # prints next steps for finishing the configuration. # # Usage # Interactive (recommended for first install) : # curl -fsSL https://argosdev.vercel.app/install-server.sh | sudo bash # # Reinstall over an existing install (data + .env preserved) : # curl -fsSL https://argosdev.vercel.app/install-server.sh | sudo bash -s -- --force # # Flags # --force, -f reinstall in place if already installed (skips the y/N # prompt; required when piped via curl|bash, which has no TTY) # --yes, -y assume yes for all prompts and auto-start docker compose # # Unattended (CI / cloud-init / Terraform) : # sudo ARGOS_CP_INTERNAL_TOKEN= \ # ARGOS_DOMAIN=argos..com \ # bash -c 'curl -fsSL https://argosdev.vercel.app/install-server.sh | bash -s -- --yes' # # Env overrides # ARGOS_BASE_URL distribution host (default https://argosdev.vercel.app) # ARGOS_SERVER_DIR install target (default /opt/argos-server) # ARGOS_DOMAIN your CISO-server hostname (e.g. argos.acme.com) # ARGOS_CP_INTERNAL_TOKEN shared secret given to you by the Argos team # ARGOS_ORG_NAME org this server represents (shown on the dashboard setup page) # ARGOS_SKIP_SHA skip checksum verification (TESTING ONLY) # ARGOS_NO_DOCKER_START don't run `docker compose up -d` at the end # # This script does NOT start Docker for you. If Docker isn't installed # it tells you to install it and exits. Auto-installing system # packages from a curl|bash script is a footgun this installer refuses. # # OWASP controls match install_client.sh — see that script for the full # enumeration. Specific to this script: # A02 .env mode 0600; auto-generated secrets via openssl rand # A05 Caddyfile placeholder forces operator to set a real domain # A06 SHA-256 verification before any extraction # ────────────────────────────────────────────────────────────────────────────── set -Eeuo pipefail IFS=$'\n\t' umask 077 BASE_URL="${ARGOS_BASE_URL:-https://argos.tail3ad60e.ts.net}" TARGET_DIR="${ARGOS_SERVER_DIR:-/opt/argos-server}" ZIP_NAME="argos-server.zip" SHA_NAME="SHA256SUMS" if [[ -t 1 ]]; then GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; RED=$'\033[0;31m' CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; NC=$'\033[0m' else GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC='' fi log() { printf ' %s✓%s %s\n' "$GREEN" "$NC" "$1"; } warn() { printf ' %s⚠%s %s\n' "$YELLOW" "$NC" "$1"; } info() { printf ' %s→%s %s\n' "$CYAN" "$NC" "$1"; } step() { printf '\n%s── %s%s\n' "$BOLD" "$1" "$NC"; } fail() { printf ' %s✗%s %s\n' "$RED" "$NC" "$1" >&2; exit 1; } ASSUME_YES=0 FORCE=0 while [[ $# -gt 0 ]]; do case "$1" in --yes|-y) ASSUME_YES=1; shift ;; --force|-f) FORCE=1; shift ;; -h|--help) sed -n '3,34p' "$0"; exit 0 ;; *) fail "unknown flag: $1" ;; esac done TMP_DIR="" _cleanup() { [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR" } trap _cleanup EXIT trap 'echo; fail "interrupted"' INT TERM printf '%s\n' "$BOLD" cat <<'B' ╔════════════════════════════════════════════════╗ ║ Argos CISO Server — one-command installer ║ ╚════════════════════════════════════════════════╝ B printf '%s\n' "$NC" # A01 [[ "$EUID" -eq 0 ]] || fail "must be run as root (try: curl … | sudo bash)" # Prereqs step "Prerequisites" for cmd in curl unzip openssl mktemp uname; do command -v "$cmd" >/dev/null || fail "missing required tool: $cmd" done SHA_CMD="" if command -v sha256sum >/dev/null; then SHA_CMD="sha256sum" elif command -v shasum >/dev/null; then SHA_CMD="shasum -a 256" else fail "neither sha256sum nor shasum is available" fi if ! command -v docker >/dev/null; then fail "Docker is not installed. Install Docker Engine first: Ubuntu/Debian: https://docs.docker.com/engine/install/ubuntu/ Rocky/Alma: https://docs.docker.com/engine/install/rhel/ Other Linux: https://docs.docker.com/engine/install/ Then re-run this installer." fi if ! docker compose version >/dev/null 2>&1; then fail "docker compose v2 not detected. Update Docker to a recent version (v20.10+)." fi log "docker $(docker --version | awk '{print $3}' | sed 's/,//')" log "docker compose v$(docker compose version --short 2>/dev/null || echo '?')" # Existing install → back up and reinstall in place (no manual uninstall). # - Data lives in Docker named volumes (postgres_data, tokens_data); we stop # the stack WITHOUT `-v` so those volumes — and all your data — survive. # - The existing .env is preserved verbatim: its ARGOS_DB_PASSWORD must keep # matching the postgres_data volume, or the database won't open. # - A portable pg_dump snapshot (.sql.gz) is taken as a belt-and-braces backup. PRESERVED_ENV="" PRESERVED_CADDY="" BACKUP_DIR="" if [[ -e "$TARGET_DIR" ]]; then step "Existing install detected" # Reinstall is destructive to the code dir (data volumes + .env are preserved), # so confirm first — unless --force/--yes is passed. When run non-interactively # (curl … | sudo bash, no TTY) we refuse rather than guess. if [[ "$FORCE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then if [[ -t 0 ]]; then printf ' %s⚠%s %s already exists.\n' "$YELLOW" "$NC" "$TARGET_DIR" printf ' Reinstalling preserves your data (Docker volumes) and backs up .env first.\n' printf ' Reinstall in place? [y/N] ' read -r _reply case "$_reply" in y|Y|yes|YES) : ;; *) fail "aborted — existing install left untouched" ;; esac else fail "$TARGET_DIR already exists. Re-run with --force to reinstall in place: curl -fsSL $BASE_URL/install-server.sh | sudo bash -s -- --force Your data is preserved and .env is backed up automatically." fi fi BACKUP_DIR="${TARGET_DIR%/}.backup.$(date -u '+%Y%m%dT%H%M%SZ')" mkdir -p "$BACKUP_DIR"; chmod 0700 "$BACKUP_DIR" if [[ -f "$TARGET_DIR/.env" ]]; then cp -p "$TARGET_DIR/.env" "$BACKUP_DIR/.env" PRESERVED_ENV="$BACKUP_DIR/.env" log "secrets preserved (.env backed up)" else warn "no existing .env found — fresh secrets will be generated" fi if [[ -f "$TARGET_DIR/server/Caddyfile" ]]; then cp -p "$TARGET_DIR/server/Caddyfile" "$BACKUP_DIR/Caddyfile" PRESERVED_CADDY="$BACKUP_DIR/Caddyfile" fi if [[ -f "$TARGET_DIR/docker-compose.yml" ]]; then # Best-effort database snapshot (needs the db container briefly up). if [[ -n "$PRESERVED_ENV" ]]; then info "creating database snapshot…" ( set -a; . "$PRESERVED_ENV"; set +a cd "$TARGET_DIR" || exit 0 docker compose up -d postgres >/dev/null 2>&1 || exit 0 for _ in $(seq 1 15); do docker compose exec -T postgres pg_isready -U argos >/dev/null 2>&1 && break sleep 2 done docker compose exec -T postgres pg_dump -U argos argos_central 2>/dev/null \ | gzip > "$BACKUP_DIR/argos_central.sql.gz" || true ) if [[ -s "$BACKUP_DIR/argos_central.sql.gz" ]]; then log "database snapshot · $(($(wc -c < "$BACKUP_DIR/argos_central.sql.gz")/1024)) KB" else rm -f "$BACKUP_DIR/argos_central.sql.gz" warn "snapshot skipped (stack not running / empty) — volume data still preserved" fi fi info "stopping running stack (named volumes are preserved)…" ( cd "$TARGET_DIR" && docker compose down --remove-orphans ) >/dev/null 2>&1 || true fi rm -rf "$TARGET_DIR" log "previous install backed up to $BACKUP_DIR" fi # Working dir TMP_DIR="$(mktemp -d -t argos-server-install.XXXXXX)" info "staging at $TMP_DIR" # Download step "Download" info "fetching $BASE_URL/$ZIP_NAME" curl --fail-with-body --silent --show-error --location \ --max-time 120 \ -o "$TMP_DIR/$ZIP_NAME" \ "$BASE_URL/$ZIP_NAME" \ || fail "download failed (check $BASE_URL is reachable)" log "$(printf '%6s KB · %s' "$(($(wc -c < "$TMP_DIR/$ZIP_NAME")/1024))" "$ZIP_NAME")" # Verify SHA if [[ -z "${ARGOS_SKIP_SHA:-}" ]]; then curl --fail-with-body --silent --show-error --location \ --max-time 30 \ -o "$TMP_DIR/$SHA_NAME" \ "$BASE_URL/$SHA_NAME" \ || fail "could not fetch checksum file (set ARGOS_SKIP_SHA=1 only if you know what you're doing)" EXPECTED="$(awk -v n="$ZIP_NAME" '$2==n || $2=="*"n { print $1 }' "$TMP_DIR/$SHA_NAME" | head -1)" [[ -n "$EXPECTED" ]] || fail "$ZIP_NAME not listed in $SHA_NAME" COMPUTED="$($SHA_CMD "$TMP_DIR/$ZIP_NAME" | awk '{print $1}')" if [[ "$COMPUTED" != "$EXPECTED" ]]; then fail "checksum mismatch — expected $EXPECTED, got $COMPUTED. Don't trust this zip." fi log "checksum verified · $EXPECTED" else warn "ARGOS_SKIP_SHA set — skipping checksum verification" fi # Extract to target step "Install" unzip -q "$TMP_DIR/$ZIP_NAME" -d "$TMP_DIR" [[ -d "$TMP_DIR/argos-server" ]] || fail "bundle layout unexpected" mkdir -p "$(dirname "$TARGET_DIR")" mv "$TMP_DIR/argos-server" "$TARGET_DIR" chmod 0755 "$TARGET_DIR" log "extracted to $TARGET_DIR" # Configuration — preserve existing secrets on reinstall, else auto-generate. step "Configuration" ENV_FILE="$TARGET_DIR/.env" if [[ -n "$PRESERVED_ENV" && -f "$PRESERVED_ENV" ]]; then # Restore the previous .env verbatim: keeping ARGOS_DB_PASSWORD identical is # what lets the existing postgres_data volume keep opening after reinstall. install -m 0600 "$PRESERVED_ENV" "$ENV_FILE" log ".env restored from previous install (DB password + secrets preserved)" else DB_PASS="$(openssl rand -base64 24 | tr -d '+/=' | head -c 32)" SECRET_KEY="$(openssl rand -hex 32)" # Touch .env with mode 0600 BEFORE writing secrets into it. install -m 0600 /dev/null "$ENV_FILE" { echo "# Auto-generated by install-server.sh on $(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "# DO NOT COMMIT THIS FILE." echo "ARGOS_DB_PASSWORD=$DB_PASS" echo "ARGOS_SECRET_KEY=$SECRET_KEY" echo "ARGOS_CONTROL_PLANE_URL=${ARGOS_CONTROL_PLANE_URL:-https://cp.argos.dev}" echo "ARGOS_CP_INTERNAL_TOKEN=${ARGOS_CP_INTERNAL_TOKEN:-}" echo "ARGOS_CUSTOMER_ID=${ARGOS_CUSTOMER_ID:-}" echo "ARGOS_ORG_NAME=${ARGOS_ORG_NAME:-}" } > "$ENV_FILE" log ".env written at $ENV_FILE (mode 0600)" [[ -z "${ARGOS_CP_INTERNAL_TOKEN:-}" ]] && \ warn "ARGOS_CP_INTERNAL_TOKEN is empty — set it before docker compose up (Argos team provides this · per customer)" [[ -z "${ARGOS_CUSTOMER_ID:-}" ]] && \ warn "ARGOS_CUSTOMER_ID is empty — set it before docker compose up (Argos team provides this · numeric id)" [[ -z "${ARGOS_ORG_NAME:-}" ]] && \ warn "ARGOS_ORG_NAME is empty — set it in $ENV_FILE before first launch (used by the dashboard setup page)" fi # Pre-fill Caddyfile if domain provided if [[ -n "${ARGOS_DOMAIN:-}" ]]; then if [[ "$ARGOS_DOMAIN" =~ ^[A-Za-z0-9.-]{1,253}$ ]]; then # Replace the :443 placeholder in Caddyfile sed -i.bak "s|^:443 {|$ARGOS_DOMAIN {|" "$TARGET_DIR/server/Caddyfile" rm -f "$TARGET_DIR/server/Caddyfile.bak" log "Caddyfile configured for $ARGOS_DOMAIN" else warn "ARGOS_DOMAIN '$ARGOS_DOMAIN' looks invalid — Caddyfile left as :443 placeholder" fi elif [[ -n "$PRESERVED_CADDY" && -f "$PRESERVED_CADDY" ]]; then # Reinstall with no new domain given → keep the domain you configured before. cp -p "$PRESERVED_CADDY" "$TARGET_DIR/server/Caddyfile" log "Caddyfile restored from previous install" fi # Maybe start step "Done" echo echo " Install location: $TARGET_DIR" echo " Config file: $ENV_FILE (mode 0600)" echo " Caddyfile: $TARGET_DIR/server/Caddyfile" echo echo " ${BOLD}Before starting:${NC}" if [[ -z "${ARGOS_CP_INTERNAL_TOKEN:-}" ]]; then echo " 1. Edit $ENV_FILE — set ARGOS_CP_INTERNAL_TOKEN" fi if [[ -z "${ARGOS_DOMAIN:-}" ]]; then echo " $([[ -n "${ARGOS_CP_INTERNAL_TOKEN:-}" ]] && echo '1.' || echo '2.') Edit $TARGET_DIR/server/Caddyfile —" echo " replace ':443' with your real hostname (e.g. argos.acme.com)" echo " $([[ -n "${ARGOS_CP_INTERNAL_TOKEN:-}" ]] && echo '2.' || echo '3.') Point that hostname's DNS A record at this VM's public IP" fi echo echo " ${BOLD}Then start:${NC}" echo " cd $TARGET_DIR" echo " docker compose up -d" echo " docker compose exec argos-server alembic upgrade head" echo echo " ${BOLD}Verify:${NC}" echo " curl https:///health # → {\"ok\":true}" echo echo " Full guide: $TARGET_DIR/INSTALL.md" echo if [[ "$ASSUME_YES" == "1" && -z "${ARGOS_NO_DOCKER_START:-}" \ && -n "${ARGOS_CP_INTERNAL_TOKEN:-}" && -n "${ARGOS_DOMAIN:-}" ]]; then info "All required env vars set + --yes passed; starting now" cd "$TARGET_DIR" docker compose up -d docker compose exec argos-server alembic upgrade head log "stack is up. Test: curl https://$ARGOS_DOMAIN/health" fi