#!/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/ 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 </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 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 <</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:-}." 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 @ '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