setup.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
#
# soc-ai guided installer — get from a fresh box to a running triage console.
#
# Interactive (default): ./setup.sh
# Automated from a file: cp setup.conf.example setup.conf # then edit it
# ./setup.sh --auto
# Automated, named file: ./setup.sh --auto myhost.conf
# Pre-seed interactive: ./setup.sh --config myhost.conf
# Prebuilt image (no build): ./setup.sh --prebuilt # pulls ghcr.io/nuk3s/soc-ai
# Test/CI mode: ./setup.sh --auto --env-only # write .env, then stop
#
# It installs Docker if missing, collects connection settings (validating them
# before the build), generates the encryption key + admin password + a TLS cert,
# writes .env, asks for an HTTPS domain (blank = self-signed on 8443), brings
# the stack up, seeds enrichment, and prints the URL + login.
# Re-running is safe.
set -euo pipefail
cd "$(dirname "$0")"
# ── args ──────────────────────────────────────────────────────────────────────
AUTO=0; CONF=""; SHOW_HELP=0; PREBUILT=0; ENVONLY=0
DEFAULT_CONF="setup.conf"
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--auto|-y|--yes) AUTO=1 ;;
-c|--config|--file) CONF="${2:-}"; shift ;;
-p|--prebuilt) PREBUILT=1 ;;
-h|--help) SHOW_HELP=1 ;;
--env-only) ENVONLY=1 ;;
*.conf|*.txt|*.env) CONF="$1" ;; # bare filename → config file
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
esac
shift
done
if [[ $SHOW_HELP -eq 1 ]]; then
sed -n '3,16p' "$0" | sed 's/^#\s\{0,1\}//; s/^#$//'
exit 0
fi
# ── pretty output ─────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; C=$'\e[36m'; N=$'\e[0m'
else B=''; G=''; Y=''; R=''; C=''; N=''; fi
info(){ printf '%s %s\n' "${C}›${N}" "$*"; }
ok(){ printf '%s %s\n' "${G}✓${N}" "$*"; }
warn(){ printf '%s %s\n' "${Y}!${N}" "$*"; }
die(){ printf '%s %s\n' "${R}✗${N}" "$*" >&2; exit 1; }
hr(){ printf '%s\n' "────────────────────────────────────────────────────────────"; }
genfernet(){ head -c32 /dev/urandom | base64 | tr '+/' '-_'; } # 44-char urlsafe = valid Fernet key
genpw(){ openssl rand -base64 15 2>/dev/null | tr -d '/+=' || head -c12 /dev/urandom | base64 | tr -d '/+='; }
b2yn(){ [[ ${1:-} == true ]] && echo y || echo n; }
trim(){ local s=$1; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
# The managed .env block writes every free-text value single-quoted — the one
# form compose's env_file parser and python-dotenv both read literally (left
# bare, `$name` expands and ` #` starts a comment, so a password like `pa$sw #x`
# reached the container as `pa` seconds after the grid check passed with the
# real one). Neither parser has a portable escape for a single quote inside
# single quotes, and compose rejects the whole file on a trailing backslash, so
# a value carrying either is refused up front instead of written in a shape
# that reads back as something else.
envsafe(){ local name v
for name in "$@"; do v=${!name:-}
if [[ $v == *\'* || $v == *\\* || $v == *$'\n'* ]]; then
die "$name contains a single quote, a backslash or a newline. .env cannot carry that value reliably. Choose another value."
fi
done
}
# Last-wins read of one key from .env, quotes stripped. The managed block is
# APPENDED after .env.example's own defaults, so the file carries duplicate
# keys and dotenv takes the last one; a first-match read returns the placeholder.
env_readback(){ local v
v=$(grep "^$1=" .env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r') || true
[[ $v == \'*\' && $v == *\' ]] && v=${v:1:-1}
[[ $v == \"*\" && $v == *\" ]] && v=${v:1:-1}
printf '%s' "$v"
}
# Add one name to the COMPOSE_PROFILES list in .env and keep the others.
# scripts/tls-proxy.sh adds and removes "proxy" in the same list. The line
# goes after the managed block, so a later run keeps it.
add_compose_profile(){ local name=$1 cur out="" p parts=()
cur=$(env_readback COMPOSE_PROFILES)
IFS=',' read -ra parts <<< "$cur" || true
for p in "${parts[@]}"; do
p=${p//[[:space:]]/}
[[ -z $p || $p == "$name" ]] && continue
out+="${out:+,}$p"
done
sed -i '/^[[:space:]]*COMPOSE_PROFILES=/d' .env
printf 'COMPOSE_PROFILES=%s\n' "${out:+$out,}$name" >> .env
}
# uvicorn opens the TLS key inside the container as uid 1000 (Dockerfile: USER
# soc-ai) through a read-only bind mount that carries no ACLs, so the host file
# must be readable by that uid through plain owner/group/other bits. openssl
# writes it 0600 owned by whoever runs setup.sh — fine when that is uid 1000
# (usually the first Linux user), but `sudo ./setup.sh` or a second admin
# account leaves a key the container cannot open: uvicorn dies on
# PermissionError, the container restart-loops, and the only hint is buried in
# `docker compose logs`. Hand the group to gid 1000 and keep the key
# group-readable when we may; otherwise 0644, as docker-compose.yml's mounts
# note asks for.
key_readable_by_container(){ local u g m
read -r u g m < <(stat -c '%u %g %a' certs/key.pem 2>/dev/null) || return 1
[[ -n ${m:-} ]] || return 1
(( 8#$m & 4 )) && return 0
[[ $u == 1000 ]] && (( 8#$m & 256 )) && return 0
[[ $g == 1000 ]] && (( 8#$m & 32 )) && return 0
return 1
}
make_key_readable(){
if { [[ $(id -u) -eq 1000 ]] || chgrp 1000 certs/key.pem 2>/dev/null; } \
&& chmod 640 certs/key.pem 2>/dev/null; then return 0; fi
warn "certs/key.pem must be readable by the container's uid 1000. Relaxing it to 0644. See the mounts note in docker-compose.yml."
chmod 644 certs/key.pem 2>/dev/null \
|| warn " could not change certs/key.pem. Make it readable by uid 1000 before the container starts."
}
# Load a KEY=value config file. Only sets vars that aren't already in the
# environment, so an explicit `FOO=bar ./setup.sh` still wins over the file.
load_conf(){ local f=$1 line k v
[[ -r $f ]] || return 1
while IFS= read -r line || [[ -n $line ]]; do
line=${line%$'\r'}
[[ $line =~ ^[[:space:]]*(#|$) ]] && continue
[[ $line == *=* ]] || continue
k=$(trim "${line%%=*}"); v=${line#*=}
[[ -n $k ]] || continue
# Checked before the ${!k+x} test below: an indirect expansion of a name
# bash can't hold (`export KEY=…`, a hyphen, a space) aborts the whole run
# with bash's own one-line error and neither the file nor the line named.
[[ $k =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "$f: invalid key '$k'. Expected KEY=value."
# An unquoted value ends at the first whitespace-then-#, the way dotenv and
# compose read the .env this feeds — setup.conf.example annotates its lines
# that way, and a comment carried into the value flipped API_AUTH_REQUIRED
# and LITELLM_VERIFY_SSL to "not true" and made the comment text the Fernet
# key. A quoted value is taken whole (quotes stripped), so it can hold ` #`.
# Stripped on the raw text before trim(): a line that is only an annotation
# (`ES_HOSTS= # blank = …`) would otherwise trim to a value starting `#`.
[[ $(trim "$v") == [\"\']* ]] || v=${v%%[[:space:]]#*}
v=$(trim "$v")
[[ $v == \"*\" && $v == *\" ]] && v=${v:1:-1}
[[ $v == \'*\' && $v == *\' ]] && v=${v:1:-1}
[[ -z ${!k+x} ]] && export "$k=$v"
done < "$f"
return 0
}
# Prompt helpers — in --auto mode they take the default with no prompt.
ask(){ local __v=$1 __p=$2 __d=${3:-} __cur=${!1:-} def ans; def=${__cur:-$__d}
if [[ $AUTO -eq 1 ]]; then printf -v "$__v" '%s' "$def"; return; fi
if [[ -n $def ]]; then read -rp " $__p [$def]: " ans || true; else read -rp " $__p: " ans || true; fi
printf -v "$__v" '%s' "${ans:-$def}"; }
asksecret(){ local __v=$1 __p=$2 __cur=${!1:-} ans
if [[ $AUTO -eq 1 || -n $__cur ]]; then printf -v "$__v" '%s' "$__cur"; return; fi
read -rsp " $__p: " ans || true; echo; printf -v "$__v" '%s' "$ans"; }
yesno(){ local __v=$1 __p=$2 __d=${3:-y} ans
# The default can be a raw, unvalidated conf-file value (e.g. AUTO_TRIAGE=yes
# or STARTER_PACK=true), not just the clean y/n every existing caller passes
# (literals, or b2yn()'s always-y/n output) — coerce ONCE, up front, so
# --auto and the interactive Enter-takes-default fallback agree on what
# counts as "yes" instead of each doing its own ad hoc test.
#
# Widened past a bare ^[Yy] so common truthy spellings in a hand-edited
# setup.conf (true/True/TRUE, 1, on/ON) also read as yes — every alternative
# except [Yy] is fully anchored (a leading ^ shared by the whole group, and
# its own trailing $) so it matches the WHOLE default value, not a prefix:
# "TRUE" isn't missed by a lowercase-only "true" test, and "online"/"1000"
# can't false-positive off the "on"/"1" arms the way an unanchored substring
# test would. A default of "" can't reach this test at all — `${3:-y}` above
# already turned it into "y" (bash's `:-` triggers on unset OR empty).
[[ $__d =~ ^([Yy]|[Tt][Rr][Uu][Ee]$|1$|[Oo][Nn]$) ]] && __d=y || __d=n
if [[ $AUTO -eq 1 ]]; then printf -v "$__v" '%s' "$__d"; return; fi
read -rp " $__p ($([[ $__d == y ]] && echo 'Y/n' || echo 'y/N')): " ans || true
ans=${ans:-$__d}; [[ $ans =~ ^[Yy] ]] && printf -v "$__v" '%s' y || printf -v "$__v" '%s' n; }
httpcode(){ curl -k -s -o /dev/null -w '%{http_code}' -m "${2:-8}" "$1" 2>/dev/null || echo 000; }
# Fetch a gateway/provider's /v1/models ids, sorted, one per line. Silent on
# any failure (empty output) — callers tell "reachable but empty" from
# "fetch failed" via `${#MODELS[@]}`. Shared by both LLM_ROUTE branches below.
fetch_gateway_models(){
local base=$1 key=$2 insecure=$3 vflag=""
local hdr=()
[[ $insecure == n ]] && vflag="-k"
[[ -n $key ]] && hdr=(-H "Authorization: Bearer ${key}")
curl -fsS $vflag -m 12 "${hdr[@]}" "${base%/}/v1/models" 2>/dev/null \
| grep -oE '"id"[[:space:]]*:[[:space:]]*"[^"]+"' | sed -E 's/.*"([^"]+)"$/\1/' | sort
}
# Resolve the release version to pin the prebuilt GHCR image to, so --prebuilt
# never rides the mutable `:latest` tag (an unaudited moving target — every pull
# would be a silent upgrade). Prefer the repo VERSION in pyproject.toml; fall
# back to the newest `v*` git tag. Prints nothing if neither is resolvable.
resolve_release_version(){
local v=""
if [[ -r pyproject.toml ]]; then
v=$(grep -m1 -E '^version[[:space:]]*=' pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')
fi
if [[ -z $v ]] && command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
v=$(git tag --list 'v*' --sort=-v:refname 2>/dev/null | head -1 | sed 's/^v//')
fi
printf '%s' "$v"
}
# Detect the events index pattern that actually matches THIS grid, so it's right
# on a single-node grid (local `logs-*` data streams) AND a multi-node one
# (reached cross-cluster as `*:logs-*`). Counts Suricata alerts under each
# prefix and picks the one with hits — querying both and unioning would
# double-count on a grid that registers a self-remote cluster.
detect_events_pattern(){
local pfx cnt
for pfx in '' '*:'; do
cnt=$(curl -k -s -m 8 -u "${SO_USERNAME}:${SO_PASSWORD}" \
"${ES_HOSTS%/}/${pfx}logs-*/_search?ignore_unavailable=true&allow_no_indices=true" \
-H 'Content-Type: application/json' \
-d '{"size":0,"track_total_hits":1,"query":{"term":{"event.dataset":"suricata.alert"}}}' 2>/dev/null \
| grep -oE '"value"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+$')
[[ -n ${cnt:-} && $cnt -gt 0 ]] && { printf '%slogs-*' "$pfx"; return 0; }
done
printf 'logs-*' # fallback: the single-node form
}
echo
printf '%s\n' "${B}soc-ai setup${N} — guided Docker install"
# Resolve the config file: explicit --config, else setup.conf if it exists.
[[ -z $CONF && -r $DEFAULT_CONF ]] && CONF="$DEFAULT_CONF"
if [[ -n $CONF ]]; then
[[ -r $CONF ]] || die "config file not found: $CONF"
load_conf "$CONF" && ok "Loaded settings from ${B}${CONF}${N}"
fi
[[ $AUTO -eq 1 ]] && info "Automated mode (no prompts)." || info "Interactive mode — press Enter to accept [defaults]."
# --auto with nothing to go on would silently fall through to placeholder
# defaults and fail later — stop with a clear instruction instead.
if [[ $AUTO -eq 1 && -z $CONF && ! -f .env && -z ${SO_HOST:-} ]]; then
die "--auto needs settings but found none. Run: cp setup.conf.example setup.conf → edit it → ./setup.sh --auto"
fi
hr
# ── 1. prerequisites ──────────────────────────────────────────────────────────
info "Checking prerequisites…"
for t in curl openssl; do command -v "$t" >/dev/null 2>&1 || die "'$t' is required but not installed (try: sudo dnf install -y $t / sudo apt install -y $t)"; done
if [[ $ENVONLY -eq 0 ]]; then
need_docker=0
command -v docker >/dev/null 2>&1 || need_docker=1
if [[ $need_docker -eq 0 ]] && ! docker compose version >/dev/null 2>&1; then
warn "docker is present but the 'compose' plugin is missing"; need_docker=1; fi
if [[ $need_docker -eq 1 ]]; then
warn "Docker (with the compose plugin) is not installed."
yesno DOIT "Install Docker now, using Docker's official installer? (needs sudo)" y
[[ $DOIT == y ]] || die "Install Docker + the compose plugin, then re-run ./setup.sh"
# Docker's get.docker.com detects the distro (Debian / Ubuntu / Fedora /
# RHEL / Rocky / Alma / CentOS …) and installs docker-ce + the compose and
# buildx plugins from the right repo. One path instead of per-distro logic.
info "Installing Docker (Docker's installer detects your distro)…"
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh \
|| die "couldn't download get.docker.com — check the network, or install Docker yourself"
if ! sudo sh /tmp/get-docker.sh 2>/tmp/get-docker.err; then
# get.docker.com has no packages for some fresh EL10 distros — it points the
# repo at e.g. download.docker.com/linux/rocky/$releasever (rocky/10), which
# doesn't exist yet. Docker's centos/<ver> packages are EL-compatible; write a
# CLEAN repo for them and retry. (Note: the gpgkey is .../linux/centos/gpg —
# a fixed key path, NOT a version dir — so we can't just sed the broken repo.)
ver=$(. /etc/os-release 2>/dev/null; echo "${VERSION_ID%%.*}")
if [[ -n ${ver:-} ]] && command -v dnf >/dev/null 2>&1; then
warn "get.docker.com has no packages for this EL${ver} distro; retrying with Docker's centos/${ver} packages…"
sudo tee /etc/yum.repos.d/docker-ce.repo >/dev/null <<EOF
[docker-ce-stable]
name=Docker CE Stable - centos ${ver}
baseurl=https://download.docker.com/linux/centos/${ver}/\$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/centos/gpg
EOF
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|| die "Docker install failed even via the centos/${ver} repo (see /tmp/get-docker.err) — install Docker yourself, then re-run"
else
die "Docker install failed (see /tmp/get-docker.err) — install Docker yourself, then re-run"
fi
fi
rm -f /tmp/get-docker.sh /tmp/get-docker.err
sudo systemctl enable --now docker 2>/dev/null || true
# Let this user run docker without sudo from next login on.
sudo usermod -aG docker "$USER" 2>/dev/null || true
docker compose version >/dev/null 2>&1 || sudo docker compose version >/dev/null 2>&1 \
|| die "Docker installed but 'docker compose' isn't working — check the install output above"
fi
if docker info >/dev/null 2>&1; then DC="docker compose"
else DC="sudo docker compose"; warn "Using sudo for docker this run — log out/in (or run 'newgrp docker') to use docker without sudo."; fi
ok "Docker ready — $(docker --version 2>/dev/null | cut -d, -f1)"
fi
# ── 2. configuration (.env) ───────────────────────────────────────────────────
hr
RECFG=y
# A fresh install has no .env yet. Only a fresh install may put a new store in
# PostgreSQL: on a re-run the SQLite store can hold data, and a switch would
# leave it behind while the console shows an empty store.
FRESH_ENV=1; [[ -f .env ]] && FRESH_ENV=0
if [[ -f .env ]]; then yesno RECFG ".env already exists — reconfigure it?" n
[[ $RECFG == n ]] && info "Keeping the existing .env."; fi
if [[ $RECFG == y ]]; then
info "Security Onion connection:"
ask SO_HOST " Security Onion URL" "${SO_HOST:-https://your-so-grid}"
yesno SO_TLS " Verify the grid's TLS cert? (No for a self-signed SO)" "$(b2yn "${SO_VERIFY_SSL:-false}")"
ask SO_USERNAME " SO analyst username" "${SO_USERNAME:-}"
asksecret SO_PASSWORD " SO analyst password"
ask ES_HOSTS " Elasticsearch URL" "${ES_HOSTS:-${SO_HOST%/}:9200}"
[[ -n ${SO_HOST:-} && -n ${SO_USERNAME:-} && -n ${SO_PASSWORD:-} ]] \
|| die "SO_HOST, SO_USERNAME and SO_PASSWORD are required."
envsafe SO_HOST SO_USERNAME SO_PASSWORD ES_HOSTS
# Validate SO + ES BEFORE the long build, so a typo'd host/password fails in
# seconds instead of after a 3-minute build and a first hunt.
info "Checking the grid…"
code=$(httpcode "$SO_HOST")
[[ $code == 000 ]] && warn "Can't reach SO at $SO_HOST (no response) — check the URL/network." \
|| ok "Security Onion reachable (HTTP $code)."
ecode=$(curl -k -s -o /dev/null -w '%{http_code}' -m 8 -u "${SO_USERNAME}:${SO_PASSWORD}" "$ES_HOSTS" 2>/dev/null || echo 000)
case "$ecode" in
200|201) ok "Elasticsearch credentials OK." ;;
401|403) warn "Elasticsearch rejected those credentials (HTTP $ecode) — double-check the username/password." ;;
000) warn "Can't reach Elasticsearch at $ES_HOSTS — check the URL/network." ;;
*) ok "Elasticsearch reachable (HTTP $ecode)." ;;
esac
echo
info "AI model — how will soc-ai reach one?"
echo " 1) Local / self-hosted endpoint you run (LiteLLM gateway, vLLM, Ollama) — nothing leaves your network"
echo " 2) Cloud API key (OpenRouter or another OpenAI-compatible provider) — no local infra, redacted egress"
ask LLM_ROUTE " Route" "${LLM_ROUTE:-1}"
case $LLM_ROUTE in
1|2) ;;
*) die "LLM_ROUTE must be 1 (local) or 2 (cloud) — got '${LLM_ROUTE}'" ;;
esac
if [[ $LLM_ROUTE == 2 ]]; then
info "Cloud route — triage prompts leave this box, redacted first (disclosure below)."
ask LITELLM_BASE_URL " Provider base URL" "${LITELLM_BASE_URL:-https://openrouter.ai/api/v1}"
asksecret LITELLM_API_KEY " Provider API key"
yesno LLM_TLS " Verify the provider's TLS cert? (Yes for real cloud providers; No only behind a TLS-inspecting proxy)" "$(b2yn "${LITELLM_VERIFY_SSL:-true}")"
ANALYST_CLOUD_REDACTION=true
else
info "LLM gateway (local / self-hosted; no backend yet? see docs/LESSER_MODELS.md → 'Standing one up'):"
ask LITELLM_BASE_URL " Gateway URL" "${LITELLM_BASE_URL:-http://localhost:4000}"
asksecret LITELLM_API_KEY " Gateway API key (blank if none)"
yesno LLM_TLS " Verify the gateway's TLS cert? (No for a self-signed gateway)" "$(b2yn "${LITELLM_VERIFY_SSL:-true}")"
ANALYST_CLOUD_REDACTION=""
fi
envsafe LITELLM_BASE_URL LITELLM_API_KEY
# HEAVY_MODEL is the old name for ANALYST_MODEL — honor it if a config file
# still uses it, so upgrades don't silently lose the setting.
ANALYST_MODEL="${ANALYST_MODEL:-${HEAVY_MODEL:-}}"
if [[ $LLM_ROUTE == 2 ]]; then
# A cloud provider's /v1/models is a public catalog hundreds of ids long
# (OpenRouter's alone runs 400+) — enumerating it here the way the local
# route does below would bury a first-timer in irrelevant choices. Curated
# shortlist instead, with an escape hatch for any other id the provider
# serves.
#
# Curated cloud defaults — verified against openrouter.ai 2026-08-19
# (curl -s https://openrouter.ai/api/v1/models | python3 -c "import
# json,sys; [print(m['id']) for m in json.load(sys.stdin)['data']]" |
# sort): one id per family, the newest stable (non-preview, non-free-tier)
# release, each confirmed to advertise tools/tool_choice support. Any
# other OpenAI-compatible id works too — qualify it first with:
# soc-ai model-probe --model <id>
CLOUD_MODELS=(
"anthropic/claude-sonnet-5" # Anthropic — Sonnet-class
"openai/gpt-5.4-mini" # OpenAI — flagship-mini, tool-calling
"deepseek/deepseek-v4-flash" # DeepSeek — chat-class, non-reasoning
"qwen/qwen3-next-80b-a3b-instruct" # Qwen — large instruct
)
if [[ $AUTO -eq 1 ]]; then
ANALYST_MODEL="${ANALYST_MODEL:-${CLOUD_MODELS[0]}}"
else
echo " Known-good cloud models (tool-calling verified classes):"
i=1; for m in "${CLOUD_MODELS[@]}"; do printf ' %2d) %s\n' "$i" "$m"; i=$((i+1)); done
read -rp " Number, or any model id from your provider [1]: " sel || true
sel=${sel:-1}
if [[ $sel =~ ^[0-9]+$ ]] && (( sel>=1 && sel<=${#CLOUD_MODELS[@]} )); then
ANALYST_MODEL="${CLOUD_MODELS[$((sel-1))]}"
else
ANALYST_MODEL="$sel"
fi
fi
# Best-effort, silent validation against the provider's real list — same
# fetch the local route uses below, just not rendered as a picker.
mapfile -t MODELS < <(fetch_gateway_models "$LITELLM_BASE_URL" "${LITELLM_API_KEY:-}" "$LLM_TLS")
if [[ ${#MODELS[@]} -gt 0 ]]; then
printf '%s\n' " ${MODELS[*]} " | grep -q " ${ANALYST_MODEL} " \
&& ok "Analyst model: ${B}${ANALYST_MODEL}${N}" \
|| warn "ANALYST_MODEL '${ANALYST_MODEL}' isn't in the gateway list — hunts will fail until it is."
else
warn "Couldn't list gateway models (unreachable / wrong key / TLS mismatch)."
fi
else
# Fetch the gateway's model list so ANALYST_MODEL can't be silently wrong (a
# wrong value answers /v1/models fine but 400s every hunt). No python needed.
mapfile -t MODELS < <(fetch_gateway_models "$LITELLM_BASE_URL" "${LITELLM_API_KEY:-}" "$LLM_TLS")
if [[ ${#MODELS[@]} -gt 0 ]]; then
ok "Gateway serves ${#MODELS[@]} models."
# default: existing value, else a sensible reasoning model if present
hv="${ANALYST_MODEL:-}"
if [[ -z $hv ]]; then for m in "${MODELS[@]}"; do [[ $m == *deepseek* || $m == *70b* || $m == *qwen*reason* ]] && { hv=$m; break; }; done; fi
if [[ $AUTO -eq 1 ]]; then
ANALYST_MODEL="${ANALYST_MODEL:-$hv}"
else
echo " Pick the analyst model (used for every hunt):"
i=1; for m in "${MODELS[@]}"; do printf ' %2d) %s%s\n' "$i" "$m" "$([[ $m == "$hv" ]] && echo ' ← suggested')"; i=$((i+1)); done
read -rp " Number or model name [${hv:-1}]: " sel || true
sel=${sel:-$hv}
if [[ $sel =~ ^[0-9]+$ ]] && (( sel>=1 && sel<=${#MODELS[@]} )); then ANALYST_MODEL="${MODELS[$((sel-1))]}"
else ANALYST_MODEL="$sel"; fi
fi
printf '%s\n' " ${MODELS[*]} " | grep -q " ${ANALYST_MODEL} " \
&& ok "Analyst model: ${B}${ANALYST_MODEL}${N}" \
|| warn "ANALYST_MODEL '${ANALYST_MODEL}' isn't in the gateway list — hunts will fail until it is."
else
warn "Couldn't list gateway models (unreachable / wrong key / TLS mismatch)."
ask ANALYST_MODEL " Analyst model your gateway serves" "${ANALYST_MODEL:-soc-ai-analyst}"
fi
fi
if [[ $LLM_ROUTE == 2 ]]; then
echo
info "Cloud egress disclosure — per investigation/hunt/chat turn, the provider sees:"
echo " SENT: the triage prompts — alert fields, related-event summaries, enrichment"
echo " results, runbook excerpts — with internal IPs, hostnames, usernames,"
echo " MACs, and internal-domain emails replaced by opaque tokens (the"
echo " reversal map never leaves this box)."
echo " NOT SENT: raw pcap files, credentials or .env contents, the audit trail."
echo " Details: docs/SAFETY_MODEL.md (redaction — Oracle + cloud analyst models)."
fi
echo
info "Grid-specific tuning:"
# Unions SO's own tag with the ECS field Elastic Defend endpoint alerts carry
# instead. Neither label alone covers a grid. See .env.example.
ask WEBUI_ALERTS_QUERY " Alerts OQL filter" \
"${WEBUI_ALERTS_QUERY:-tags:alert OR event.kind:alert}"
# Auto-detect the events index pattern unless the operator pinned one. `logs-*`
# for a single-node grid; `*:logs-*` when the data is only reachable
# cross-cluster (multi-node). Either way the alerts console + agent searches
# find the data — the old `*:so-*` default matched the wrong index family and
# left the console empty.
if [[ -z ${EVENTS_INDEX_PATTERN:-} && -n ${SO_USERNAME:-} && -n ${SO_PASSWORD:-} && -n ${ES_HOSTS:-} ]]; then
info " Detecting the events index pattern from the grid…"
EVENTS_INDEX_PATTERN=$(detect_events_pattern)
if [[ ${EVENTS_INDEX_PATTERN} == \*:* ]]; then
ok " Multi-node / cross-cluster grid → ${B}${EVENTS_INDEX_PATTERN}${N}"
else
ok " Single-node grid → ${B}${EVENTS_INDEX_PATTERN}${N}"
fi
fi
ask EVENTS_INDEX_PATTERN " Events index pattern (single-node: logs-* · multi-node: *:logs-*)" "${EVENTS_INDEX_PATTERN:-logs-*}"
# Carry the same cross-cluster prefix to the cases/detections/playbooks indices.
EIDX_PFX=""; [[ ${EVENTS_INDEX_PATTERN} == \*:* ]] && EIDX_PFX="*:"
yesno APIAUTH " Require login/token for the API? (recommended)" "$(b2yn "${API_AUTH_REQUIRED:-true}")"
if [[ $APIAUTH == n ]]; then
warn "API auth OFF — admin endpoints answer ANY caller that can reach the port."
warn " Not just writes (secret edits, user/token creation) but admin READS:"
warn " the user table, config, and which connection secrets are set. Safe only"
warn " on a loopback-bound dev box — choose 'yes' for any host reachable on the LAN."
fi
echo
info "Enrichment feeds (optional):"
# abuse.ch (URLhaus / ThreatFox / Feodo) now requires a free Auth-Key. Without
# it those three feeds are skipped on every `blocklists refresh` (Tor + cloud
# prefixes still work). Blank = skip. Register at https://auth.abuse.ch/ .
ask ABUSE_CH_AUTH_KEY " abuse.ch Auth-Key (blank to skip URLhaus/ThreatFox/Feodo)" "${ABUSE_CH_AUTH_KEY:-}"
# MaxMind GeoLite2 (GeoIP/ASN enrichment). Free key: https://www.maxmind.com/en/geolite2/signup
# Documented in docs/DOCKER.md but previously missing here — GeoIP silently
# no-ops without it.
ask MAXMIND_LICENSE_KEY " MaxMind GeoLite2 license key, optional. Copy the .mmdb files by hand, see docs/BLOCKLISTS.md" "${MAXMIND_LICENSE_KEY:-}"
echo
info "Day-1 automation:"
# Plain yesno, no `case`/die validation like LLM_ROUTE's — deliberate. yesno's
# --auto branch coerces its default through the same ^[Yy] test the interactive
# branch uses (see yesno() above), so a junk conf value (e.g. AUTO_TRIAGE=maybe)
# degrades to "n", never an unsafe state; a hard validation gate would be
# redundant here.
yesno AUTO_TRIAGE " Auto-triage the alert backlog on a schedule? (every 5 min, ≤25 targets/sweep, high-severity+)" "${AUTO_TRIAGE:-y}"
yesno STARTER_PACK " Install the 10-runbook starter pack after start? (grounds verdicts; idempotent)" "${STARTER_PACK:-y}"
echo
info "Store:"
# SQLite is the default and suits one site. PostgreSQL takes the writes of a
# large estate; the compose "postgres" profile runs it next to soc-ai. A store
# already in PostgreSQL stays there. See docs/DOCKER.md, PostgreSQL.
PG_URL=$(env_readback SOC_AI_DATABASE_URL); PG_COMPOSE=n
# Read back before the managed block is rewritten: the server keeps the
# password it started with, so a new one would lock soc-ai out.
SOC_AI_POSTGRES_PASSWORD=${SOC_AI_POSTGRES_PASSWORD:-$(env_readback SOC_AI_POSTGRES_PASSWORD)}
if [[ -n $PG_URL ]]; then
STORE_POSTGRES=y
info " The store is in PostgreSQL (SOC_AI_DATABASE_URL). It stays there."
[[ $PG_URL == *@postgres:5432/* ]] && PG_COMPOSE=y
else
yesno STORE_POSTGRES " Keep the store in PostgreSQL? Yes suits a large estate, no keeps SQLite" "${STORE_POSTGRES:-n}"
if [[ $STORE_POSTGRES == y && $FRESH_ENV -eq 0 ]]; then
warn "This host already has a .env, so its SQLite store can hold data. The store stays in SQLite."
warn " To move it, follow docs/DOCKER.md, PostgreSQL: soc-ai store migrate copies every table."
STORE_POSTGRES=n
fi
if [[ $STORE_POSTGRES == y ]]; then
SOC_AI_POSTGRES_PASSWORD=${SOC_AI_POSTGRES_PASSWORD:-$(genpw)}
[[ $SOC_AI_POSTGRES_PASSWORD =~ ^[A-Za-z0-9._~-]+$ ]] \
|| die "SOC_AI_POSTGRES_PASSWORD may hold letters, digits, '.', '_', '~' and '-' only. The store URL carries it."
PG_URL="postgresql+asyncpg://soc_ai:${SOC_AI_POSTGRES_PASSWORD}@postgres:5432/soc_ai"
PG_COMPOSE=y
fi
fi
CONFIG_SECRET_KEY=${CONFIG_SECRET_KEY:-$(genfernet)}
BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:-$(genpw)}
# Pin the prebuilt image to the release version, so --prebuilt never rides the
# mutable :latest tag. Resolved from pyproject.toml (fallback: newest v* tag).
SOC_AI_IMAGE_TAG=${SOC_AI_IMAGE_TAG:-$(resolve_release_version)}
envsafe ANALYST_MODEL WEBUI_ALERTS_QUERY ABUSE_CH_AUTH_KEY MAXMIND_LICENSE_KEY \
CONFIG_SECRET_KEY BOOTSTRAP_ADMIN_PASSWORD PG_URL SOC_AI_POSTGRES_PASSWORD
[[ -f .env ]] || cp .env.example .env
sed -i '/# >>> soc-ai setup.sh >>>/,/# <<< soc-ai setup.sh <<</d' .env 2>/dev/null || true
{
echo "# >>> soc-ai setup.sh >>> (this block wins — dotenv last value applies)"
# Free-text values are single-quoted: literal for compose and dotenv alike
# (see envsafe above), so `$` and ` #` in a secret survive the parse.
echo "SO_HOST='${SO_HOST%/}'"
echo "SO_VERIFY_SSL=$([[ $SO_TLS == y ]] && echo true || echo false)"
echo "SO_USERNAME='${SO_USERNAME}'"
echo "SO_PASSWORD='${SO_PASSWORD}'"
echo "ES_HOSTS='${ES_HOSTS}'"
echo "ES_USERNAME='${SO_USERNAME}'"
echo "ES_PASSWORD='${SO_PASSWORD}'"
echo "ES_VERIFY_SSL=$([[ $SO_TLS == y ]] && echo true || echo false)"
echo "LITELLM_BASE_URL='${LITELLM_BASE_URL%/}'"
echo "LITELLM_API_KEY='${LITELLM_API_KEY}'"
echo "LITELLM_VERIFY_SSL=$([[ $LLM_TLS == y ]] && echo true || echo false)"
echo "ANALYST_MODEL='${ANALYST_MODEL}'"
[[ -n ${ANALYST_CLOUD_REDACTION:-} ]] && echo "ANALYST_CLOUD_REDACTION=true"
[[ -n ${MAXMIND_LICENSE_KEY:-} ]] && echo "MAXMIND_LICENSE_KEY='${MAXMIND_LICENSE_KEY}'"
[[ ${AUTO_TRIAGE:-n} == y ]] && echo "AUTO_TRIAGE_SCHEDULE_ENABLED=true"
[[ -n ${ABUSE_CH_AUTH_KEY:-} ]] && echo "ABUSE_CH_AUTH_KEY='${ABUSE_CH_AUTH_KEY}'"
# Prebuilt installs pin the image to a specific release rather than :latest
# (an unaudited moving target). Source builds don't pull, so no pin is written.
if [[ $PREBUILT -eq 1 ]]; then
if [[ -n ${SOC_AI_IMAGE_TAG:-} ]]; then
echo "SOC_AI_IMAGE_TAG=${SOC_AI_IMAGE_TAG}"
else
echo "# SOC_AI_IMAGE_TAG= # PIN THIS to a release (e.g. 1.1.0); :latest is an unaudited moving target"
fi
fi
echo "WEBUI_ALERTS_QUERY='${WEBUI_ALERTS_QUERY}'"
echo "EVENTS_INDEX_PATTERN=${EVENTS_INDEX_PATTERN}"
echo "CASES_INDEX_PATTERN=${EIDX_PFX}so-case*"
echo "DETECTIONS_INDEX_PATTERN=${EIDX_PFX}so-detection*"
echo "PLAYBOOKS_INDEX_PATTERN=${EIDX_PFX}so-playbook*"
echo "API_AUTH_REQUIRED=$([[ $APIAUTH == y ]] && echo true || echo false)"
echo "CONFIG_SECRET_KEY='${CONFIG_SECRET_KEY}'"
echo "BOOTSTRAP_ADMIN_PASSWORD='${BOOTSTRAP_ADMIN_PASSWORD}'"
echo "SOC_AI_HOST=0.0.0.0"
echo "SOC_AI_PORT=8443"
echo "SOC_AI_TLS_CERT=/etc/soc-ai/cert.pem"
echo "SOC_AI_TLS_KEY=/etc/soc-ai/key.pem"
echo "SOC_AI_DATA_DIR=/var/lib/soc-ai/data"
if [[ $STORE_POSTGRES == y ]]; then
echo "SOC_AI_DATABASE_URL='${PG_URL}'"
[[ $PG_COMPOSE == y ]] && echo "SOC_AI_POSTGRES_PASSWORD='${SOC_AI_POSTGRES_PASSWORD}'"
fi
echo "# <<< soc-ai setup.sh <<<"
} >> .env
# The compose profile goes outside the managed block: scripts/tls-proxy.sh
# adds and removes "proxy" in the same COMPOSE_PROFILES list.
[[ $PG_COMPOSE == y ]] && add_compose_profile postgres
chmod 600 .env
ok "Wrote .env"
# Offer to save a reusable config (for automating the next host).
if [[ $AUTO -eq 0 && -z $CONF ]]; then
yesno SAVE "Save these answers to ${DEFAULT_CONF} for reuse (./setup.sh --auto)?" n
if [[ $SAVE == y ]]; then
umask 077
{
echo "# soc-ai automated-install settings — consumed by ./setup.sh --auto"
echo "# Contains secrets; keep private (chmod 600, gitignored)."
for k in SO_HOST SO_VERIFY_SSL SO_USERNAME SO_PASSWORD ES_HOSTS ES_VERIFY_SSL \
LLM_ROUTE LITELLM_BASE_URL LITELLM_API_KEY LITELLM_VERIFY_SSL ANALYST_MODEL \
WEBUI_ALERTS_QUERY EVENTS_INDEX_PATTERN API_AUTH_REQUIRED \
MAXMIND_LICENSE_KEY AUTO_TRIAGE STARTER_PACK STORE_POSTGRES \
HTTPS_DOMAIN HTTPS_CA \
CONFIG_SECRET_KEY BOOTSTRAP_ADMIN_PASSWORD; do
case $k in
SO_VERIFY_SSL|ES_VERIFY_SSL) v=$([[ $SO_TLS == y ]] && echo true || echo false) ;;
LITELLM_VERIFY_SSL) v=$([[ $LLM_TLS == y ]] && echo true || echo false) ;;
API_AUTH_REQUIRED) v=$([[ $APIAUTH == y ]] && echo true || echo false) ;;
*) v="${!k:-}" ;;
esac
# Single-quoted so a password with ` #` or `$` in it reads back whole
# on the next host — load_conf() strips the quotes and only treats a
# ` #` as a comment on an unquoted value.
echo "$k='$v'"
done
} > "$DEFAULT_CONF"
ok "Saved ${DEFAULT_CONF} (chmod 600). Reuse it on another host with: ./setup.sh --auto"
fi
fi
fi
if [[ $ENVONLY -eq 1 ]]; then
ok "env-only run: .env written; skipping cert generation, build, and start."
exit 0
fi
# ── 3. TLS certificate ────────────────────────────────────────────────────────
hr
if [[ -f certs/cert.pem && -f certs/key.pem ]]; then ok "Reusing existing certs/."
key_readable_by_container || make_key_readable
else
ipdef=$(hostname -I 2>/dev/null | awk '{print $1}'); ipdef=${ipdef:-127.0.0.1}
ask CERT_HOST "Host IP/DNS for the TLS cert" "${CERT_HOST:-$ipdef}"
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=soc-ai" \
-addext "subjectAltName=IP:${CERT_HOST},DNS:soc-ai.local" \
-keyout certs/key.pem -out certs/cert.pem 2>/dev/null \
|| openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=soc-ai" \
-keyout certs/key.pem -out certs/cert.pem 2>/dev/null
chmod 644 certs/cert.pem # the cert is public; the key stays as tight as uid 1000 allows
make_key_readable
ok "Generated self-signed certs/ (your browser warns once — accept it)."
fi
# ── 3b. HTTPS domain: the proxy path ─────────────────────────────────────────
# One question. A domain puts Caddy in front of soc-ai through
# scripts/tls-proxy.sh, which writes the proxy settings to .env and starts
# Caddy through the compose proxy profile. Blank keeps the direct path: the
# self-signed pair on 8443. The default is the domain a previous run wrote,
# so a re-run keeps the proxy path.
echo
info "HTTPS:"
ask HTTPS_DOMAIN " HTTPS domain served by Caddy (blank = self-signed on 8443)" "${HTTPS_DOMAIN:-$(env_readback SOC_AI_DOMAIN)}"
if [[ -n ${HTTPS_DOMAIN:-} ]]; then
# The default follows .env on a re-run: an empty SOC_AI_CADDY_TLS next to a
# domain means Let's Encrypt. A fresh install defaults to Caddy's own CA.
_ca_default=${HTTPS_CA:-}
if [[ -z $_ca_default && -n $(env_readback SOC_AI_DOMAIN) && -z $(env_readback SOC_AI_CADDY_TLS) ]]; then _ca_default=auto; fi
ask HTTPS_CA " Certificate source: auto (Let's Encrypt) or internal (Caddy CA)" "${_ca_default:-internal}"
case $HTTPS_CA in
auto|internal) ;;
*) die "HTTPS_CA must be auto or internal. Got '${HTTPS_CA}'." ;;
esac
fi
# The direct path starts the stack here. The proxy path builds or pulls
# first, then hands the start to scripts/tls-proxy.sh, which writes the proxy
# settings, starts Caddy and prints the trust steps. A blank domain on a box
# with the proxy path on turns it off, so Caddy never fronts a TLS listener.
start_stack(){
if [[ -n ${HTTPS_DOMAIN:-} ]]; then
[[ ${1:-} == --build ]] && $DC build
info "The proxy path: Caddy serves https://${HTTPS_DOMAIN}/ with a certificate from ${HTTPS_CA}…"
scripts/tls-proxy.sh enable "$HTTPS_DOMAIN" "$HTTPS_CA"
elif [[ $(env_readback COMPOSE_PROFILES) == *proxy* ]]; then
[[ ${1:-} == --build ]] && $DC build
warn "The proxy path is on in .env and no HTTPS domain was given. Turning it off."
scripts/tls-proxy.sh disable
else
$DC up -d ${1:-}
fi
}
# ── 4. build + start ──────────────────────────────────────────────────────────
hr
if [[ $PREBUILT -eq 1 ]]; then
# Pull the published release image, pinned to SOC_AI_IMAGE_TAG from .env (never
# the mutable :latest). With the image in the local store, `up` uses it instead
# of building. If .env carries no pin yet (e.g. the operator kept an existing
# .env), append one now so this pull — and every future `docker compose pull` —
# is a deliberate, versioned upgrade rather than a silent :latest ride.
if [[ -f .env ]] && ! grep -qE '^[[:space:]]*SOC_AI_IMAGE_TAG=' .env; then
_pin=$(resolve_release_version)
if [[ -n $_pin ]]; then
printf 'SOC_AI_IMAGE_TAG=%s\n' "$_pin" >> .env
ok "Pinned SOC_AI_IMAGE_TAG=${_pin} in .env (prebuilt image; :latest is an unaudited moving target)."
else
warn "Couldn't resolve a release version to pin — this pull will use :latest. Set SOC_AI_IMAGE_TAG in .env to pin it."
fi
fi
info "Pulling the prebuilt image (ghcr.io/nuk3s/soc-ai) and starting the stack…"
# If the image isn't published yet (no release tag), the registry answers
# `denied` — catch that and offer to build from source in the same run, so a
# stranger who copy-pasted --prebuilt still gets to a running stack. Everything
# up to here (config, cert) is already done, so there's nothing to redo.
if ! $DC pull soc-ai; then
echo
warn "Couldn't pull the prebuilt image ghcr.io/nuk3s/soc-ai:${SOC_AI_IMAGE_TAG:-<unpinned>}."
warn "No tagged release is published yet, so there's no image to pull — this is expected right now."
yesno BUILD_NOW "Build the image from source instead? (~3 min)" y
if [[ $BUILD_NOW == y ]]; then
info "Building and starting the stack (first build pulls deps — ~3 min)…"
start_stack --build
else
info "Nothing built. When you're ready, run: ${B}./setup.sh${N} (no --prebuilt) to build from source."
die "no image to run yet."
fi
else
start_stack
fi
else
info "Building and starting the stack (first build pulls deps — ~3 min)…"
start_stack --build
fi
info "Waiting for the service to answer…"
# The managed block always writes SOC_AI_PORT=8443, but a keep-existing .env
# (RECFG=n) can carry a different host port — docker-compose.yml publishes
# ${SOC_AI_PORT:-8443}:8443 — so read it back last-wins rather than assume 8443,
# or this poll spins for three minutes against a port nothing listens on and
# the summary prints a URL nobody can open. The starter-pack calls and the
# summary below use the same value.
_port=$(env_readback SOC_AI_PORT); _port=${_port:-8443}
# On the proxy path every call goes through Caddy on the domain, the way a
# browser does. --resolve pins the name to this host, so DNS can lag.
if [[ -n ${HTTPS_DOMAIN:-} ]]; then
_base="https://${HTTPS_DOMAIN}"; _resolve="--resolve ${HTTPS_DOMAIN}:443:127.0.0.1"
else
_base="https://localhost:${_port}"; _resolve=""
fi
healthy=0
for _ in $(seq 1 60); do
out=$(curl -fsk -m5 $_resolve "${_base}/healthz" 2>/dev/null || true)
if [[ -n $out ]]; then ok "Up — ${out}"; healthy=1; break; fi
sleep 3
done
if [[ $healthy -ne 1 ]]; then
warn "Liveness check timed out — the server never answered. Two things to try, in order:"
warn " 1. The doctor runs next — read its FAIL lines, each carries a fix."
warn " 2. Read the container logs:"
printf ' %s\n' "${B}${DC} logs soc-ai${N}"
fi
# Run the doctor either way — healthy or not. /healthz is a liveness probe
# (process up, DB reachable); the doctor is the fitness probe (config, store,
# SO/ES reachability, the audit-write grant, AND check_model_fitness — a real
# structured-output call against the configured analyst model). Both the
# --prebuilt and source-build routes land here, so this is the one place every
# install path ends in the same live check.
hr
info "Preflight — the doctor checks every dependency, the model's fitness, and the audit grant…"
if $DC exec -T soc-ai python -m soc_ai doctor; then
ok "Preflight clean."
elif [[ -z $($DC ps -q soc-ai 2>/dev/null) ]]; then
# `ps -q` (no -a) lists only currently-RUNNING containers for the service —
# empty means the exec above never reached the doctor at all (build/start
# failed, or the container crashed after the health poll), a compose-level
# problem, not a doctor finding. Narrating that as "doctor FAIL lines" would
# send the operator chasing fixes for checks that never ran.
warn "Couldn't run the doctor — the soc-ai container isn't up."
printf ' %s\n' "${B}${DC} ps${N} / ${B}${DC} logs soc-ai${N}"
else
warn "Doctor reported FAIL lines above — each carries its fix."
warn "The audit-grant one is the classic: without it every ack/escalate/comment silently aborts."
printf ' %s\n' "${B}ssh <admin>@<so-manager> 'sudo bash -s' < scripts/setup-audit-index.sh${N}"
fi
# ── seed the runbook starter pack ─────────────────────────────────────────────
if [[ ${STARTER_PACK:-y} == y ]]; then
# BOOTSTRAP_ADMIN_PASSWORD is set in this shell on a fresh configure; on a
# keep-existing-.env run, read it back from .env (last occurrence wins, same
# as the app itself and the test harness's env_values() helper in
# tests/test_setup_script.py — see env_readback above).
if [[ -z ${BOOTSTRAP_ADMIN_PASSWORD:-} && -f .env ]]; then
BOOTSTRAP_ADMIN_PASSWORD=$(env_readback BOOTSTRAP_ADMIN_PASSWORD)
fi
# genpw() (base64, tr -d '/+=') only ever emits alphanumerics, so naive JSON
# interpolation is safe for a freshly generated password — but a
# keep-existing .env can carry a user-set password with a quote or backslash
# in it. Escape both before they go inside the JSON string literal.
_pw_json=${BOOTSTRAP_ADMIN_PASSWORD//\\/\\\\}
_pw_json=${_pw_json//\"/\\\"}
info "Installing the runbook starter pack (idempotent)…"
jar=$(mktemp)
# The starter-pack route sits behind require_csrf_safe (soc_ai/api/security.py):
# ANY cookie-authenticated mutating request with no Origin/Referer matching the
# app's own origin is rejected 403 bad_origin — curl sends neither by default.
# Send an Origin that matches this exact request's scheme+host+port (verified
# live against the hermetic harness: the bare call 403s without this header).
if curl -fsk $_resolve -c "$jar" -m 10 -X POST "${_base}/api/v1/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"${_pw_json}\"}" >/dev/null 2>&1 \
&& out=$(curl -fsk $_resolve -b "$jar" -m 30 -X POST "${_base}/api/v1/runbooks/starter-pack" \
-H "Origin: ${_base}" 2>/dev/null); then
ok "Runbook starter pack: ${out}"
else
warn "Couldn't install the pack automatically (changed admin password?) —"
warn " click 'Load starter pack' on the Runbooks page instead."
fi
rm -f "$jar"
fi
# ── 5. seed enrichment ────────────────────────────────────────────────────────
hr
if [[ -n ${ABUSE_CH_AUTH_KEY:-} ]]; then _seed_q="Seed enrichment data now (Tor + AWS/GCP/Cloudflare + abuse.ch)?"
else _seed_q="Seed enrichment data now (Tor + AWS/GCP/Cloudflare; abuse.ch skipped — no key)?"; fi
yesno SEED "$_seed_q" y
[[ $SEED == y ]] && { info "Seeding…"; $DC run --rm soc-ai python -m soc_ai blocklists refresh \
|| warn "Some optional feeds were skipped (see above) — non-fatal."; }
# ── 6. done ───────────────────────────────────────────────────────────────────
hr; ipshow=$(hostname -I 2>/dev/null | awk '{print $1}'); ipshow=${ipshow:-localhost}
echo
ok "${B}soc-ai is running.${N}"
if [[ -n ${HTTPS_DOMAIN:-} ]]; then
echo " Open: ${C}https://${HTTPS_DOMAIN}/app${N}"
else
echo " Open: ${C}https://${ipshow}:${_port}/app${N} Accept the self-signed cert on the first visit."
fi
echo " Sign in: admin"
if [[ $RECFG == y ]]; then
echo " Password: ${B}${BOOTSTRAP_ADMIN_PASSWORD}${N} ← save this now; change it after first login"
else
echo " Password: unchanged (your existing .env, or the first-boot logs)"
fi
echo
if [[ $PREBUILT -eq 1 ]]; then
echo " Logs: ${DC} logs -f soc-ai Stop: ${DC} down Update: git pull && ${DC} pull soc-ai && ${DC} up -d"
else
echo " Logs: ${DC} logs -f soc-ai Stop: ${DC} down Update: git pull && ${DC} up -d --build"
fi
echo
echo " ${B}Recommended next steps:${N}"
if [[ ${AUTO_TRIAGE:-n} == y ]]; then
echo " • Auto-triage is ON — a sweep runs every 5 min (≤25 alerts, high-severity+)."
echo " Turn it off in Config → Triage automation, or set AUTO_TRIAGE_SCHEDULE_ENABLED=false in .env."
if [[ ${LLM_ROUTE:-} == 2 ]]; then
echo " Each sweep calls your cloud provider — metered spend starts now; cap or"
echo " disable in Config → Triage automation."
fi
fi
if [[ -n ${HTTPS_DOMAIN:-} && ${HTTPS_CA:-} == internal ]]; then
echo " • Trust the Caddy root certificate on each client. The file is ./caddy-root.crt."
echo " Fedora, RHEL: sudo cp caddy-root.crt /etc/pki/ca-trust/source/anchors/soc-ai-caddy.crt && sudo update-ca-trust"
echo " Debian, Ubuntu: sudo install -m644 caddy-root.crt /usr/local/share/ca-certificates/soc-ai-caddy.crt && sudo update-ca-certificates"
echo " macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt"
echo " Windows: certutil -addstore -f Root caddy-root.crt"
echo " Browsers on those hosts trust it after a restart. Firefox needs its own import under Settings, Certificates."
fi
if [[ -n ${HTTPS_DOMAIN:-} ]]; then
echo " • Go back to the direct path at any time: scripts/tls-proxy.sh disable"
fi
echo " • Back up before every upgrade: ${DC} exec soc-ai python -m soc_ai backup --out /var/lib/soc-ai/data/backup.tar.gz"
echo " • Schedule the blocklist refresh (feeds go stale without it):"
echo " cp scripts/cron.d/soc-ai-blocklists.example /etc/cron.d/soc-ai-blocklists # edit the path first"
echo " • Schedule backups with retention — see docs/DOCKER.md → Backup and restore."
hr