diff --git a/docs/runbook/secrets-rotation.md b/docs/runbook/secrets-rotation.md new file mode 100644 index 00000000..6a18721b --- /dev/null +++ b/docs/runbook/secrets-rotation.md @@ -0,0 +1,100 @@ +# Runbook: Secrets Rotation (Sofiia Stack) + +Ціль: безпечно ротувати секрети для `sofiia-console`/router/gateway/memory без простою або з мінімальним вікном деградації. + +## 1) Інвентар секретів (класи) + +- Telegram bot tokens (`TELEGRAM_BOT_TOKEN`, агентні токени). +- LLM/model provider keys (`OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`, інші provider keys). +- Redis auth (`SOFIIA_REDIS_URL` з паролем, якщо увімкнено auth). +- DB encryption / signing keys (якщо використовується шифрування або signing). +- JWT/session signing keys (якщо застосовуються в BFF/API). +- Internal service tokens (`SUPERVISOR_API_KEY`, `ROUTER_API_KEY`, внутрішні service-to-service ключі). + +## 2) Де вони живуть + +- `.env` / docker compose env blocks. +- systemd/launchd env (для процесів поза Docker). +- Цільовий стан: централізований secrets manager (Vault/1Password/GitHub Encrypted Secrets) + мінімум секретів у файлах. + +## 3) Процедура ротації (порядок і команди) + +### 3.1 Підготувати нові секрети + +```bash +# Приклад генерації (локально, не комітити) +openssl rand -hex 32 +``` + +Зафіксувати у change-log: які ключі, коли, хто, який rollout order. + +### 3.2 Внести нові значення у runtime env (без друку значень) + +```bash +cd /path/to/microdao-daarion +# оновити .env/.env.node* або secrets manager +``` + +### 3.3 Dual-accept / dual-write (де можливо) + +- Якщо сервіс підтримує два ключі: додати `NEW`, залишити `OLD` на перехідний період. +- Якщо не підтримує: ротувати покроково з перевірками після кожного restart. + +### 3.4 Rollout порядок по нодах + +Рекомендований порядок для цього стеку: + +1. **NODA2 (control-plane) — precheck** + - Переконатися, що нові env підхоплюються локально. +2. **NODA1 (production runtime)** + - Оновити env, перезапустити критичні сервіси по черзі. +3. **NODA2 (control-plane) — finalize** + - Перезапустити `sofiia-console`, підтвердити керування нодами. + +### 3.5 Команди перезапуску (приклади) + +```bash +# NODA1 (remote) +ssh root@144.76.224.179 "cd /opt/microdao-daarion && docker compose -f docker-compose.node1.yml up -d router gateway memory-service" + +# NODA2 (local control plane) +cd /Users/apple/github-projects/microdao-daarion +docker compose -f docker-compose.node2-sofiia.yml up -d sofiia-console router +``` + +## 4) Перевірки після ротації + +```bash +# 1) статус API +curl -fsS http://127.0.0.1:8002/api/status/full > /dev/null + +# 2) дашборд нод +curl -fsS http://127.0.0.1:8002/api/nodes/dashboard > /dev/null + +# 3) метрики живі +curl -fsS http://127.0.0.1:8002/metrics | rg "sofiia_" + +# 4) distributed idempotency smoke (A/B) +bash ops/redis_idempotency_smoke.sh +``` + +Паралельно виконати preflight: + +```bash +bash ops/preflight_sofiia_console.sh +STRICT=1 bash ops/preflight_sofiia_console.sh +``` + +## 5) Rollback + +1. Повернути попередні значення секретів (із безпечного backup/manager). +2. Перезапустити сервіси в зворотному порядку: + - NODA2 control-plane -> NODA1 runtime (або за вашим SOP). +3. Повторити перевірки з розділу 4. +4. Зафіксувати інцидент/rollback у операційному журналі. + +## 6) Операційні правила + +- Ніколи не комітити реальні секрети в git. +- Логувати лише факт наявності/відсутності секрету, не значення. +- Після ротації закривати перехідний dual-accept вікно (видалити старий ключ). diff --git a/ops/preflight_sofiia_console.sh b/ops/preflight_sofiia_console.sh new file mode 100755 index 00000000..056cab8d --- /dev/null +++ b/ops/preflight_sofiia_console.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Preflight checks before sofiia-console deploy. +# +# Exit codes: +# 0 = OK +# 1 = FAIL (or WARN in STRICT=1) +# 2 = WARN-only (STRICT=0) + +set -euo pipefail + +SOFIIA_URL="${SOFIIA_URL:-http://127.0.0.1:8002}" +STRICT="${STRICT:-0}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +PASS=0 +WARN=0 +FAIL=0 + +_pass() { echo -e "${GREEN}PASS${NC}: $1"; PASS=$((PASS+1)); } +_warn() { echo -e "${YELLOW}WARN${NC}: $1"; WARN=$((WARN+1)); } +_fail() { echo -e "${RED}FAIL${NC}: $1"; FAIL=$((FAIL+1)); } +_section() { echo -e "\n-- $1 --"; } + +for cmd in bash curl rg python3; do + command -v "$cmd" >/dev/null 2>&1 || { echo "Missing prerequisite: $cmd"; exit 1; } +done + +echo "Sofiia Console Preflight" +echo " SOFIIA_URL = ${SOFIIA_URL}" +echo " STRICT = ${STRICT}" +echo " $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + +_section "1) Secrets exposure scan (tracked files, best-effort)" +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + mapfile -t scan_files < <(git ls-files | rg '(^|/)(docker-compose.*\.ya?ml|\.env(\..*)?$|config/.*\.ya?ml)$' || true) + if [ "${#scan_files[@]}" -eq 0 ]; then + _warn "No tracked compose/env/config files found for secrets scan" + else + # Patterns focus on obvious accidental leaks in committed files. + secret_pattern='(OPENAI_API_KEY\s*=\s*[^\s$]|DEEPSEEK_API_KEY\s*=\s*[^\s$]|ANTHROPIC_API_KEY\s*=\s*[^\s$]|TELEGRAM_BOT_TOKEN\s*=\s*[^\s$]|SUPERVISOR_API_KEY\s*=\s*[^\s$]|ROUTER_API_KEY\s*=\s*[^\s$]|Authorization:\s*Bearer\s+[A-Za-z0-9._-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,})' + if mapfile -t exposed < <(rg --files-with-matches -e "$secret_pattern" -- "${scan_files[@]}" 2>/dev/null); then + if [ "${#exposed[@]}" -gt 0 ]; then + _fail "Potential secrets detected in tracked files:" + for f in "${exposed[@]}"; do + echo " - $f" + done + else + _pass "No obvious plaintext secrets found in tracked compose/env/config files" + fi + else + _warn "Secrets scan command returned non-zero (review manually)" + fi + fi +else + _warn "Not inside git repo; skipped tracked-file secrets scan" +fi + +_section "2) Required environment variables (presence only)" +required_env=("ROUTER_URL" "SOFIIA_DATA_DIR") +if [ "${SOFIIA_IDEMPOTENCY_BACKEND:-inmemory}" = "redis" ] || [ "${SOFIIA_RATE_LIMIT_BACKEND:-inmemory}" = "redis" ]; then + required_env+=("SOFIIA_REDIS_URL") +fi + +for var_name in "${required_env[@]}"; do + if [ -n "${!var_name:-}" ]; then + _pass "env ${var_name} is set" + else + _warn "env ${var_name} is not set" + fi +done + +_section "3) Reachability (health + metrics)" +if curl -fsS --max-time 5 "${SOFIIA_URL}/api/health" >/dev/null 2>&1; then + _pass "GET ${SOFIIA_URL}/api/health reachable" +else + _warn "GET ${SOFIIA_URL}/api/health unreachable (service may be down pre-deploy)" +fi + +if curl -fsS --max-time 5 "${SOFIIA_URL}/metrics" >/dev/null 2>&1; then + _pass "GET ${SOFIIA_URL}/metrics reachable" +else + _warn "GET ${SOFIIA_URL}/metrics unreachable (service may be down pre-deploy)" +fi + +_section "4) Filesystem / data dir writable" +DATA_DIR="${SOFIIA_DATA_DIR:-/app/data}" +if mkdir -p "${DATA_DIR}" 2>/dev/null; then + probe="${DATA_DIR}/.preflight_write_probe_$$" + if ( : > "${probe}" ) 2>/dev/null; then + rm -f "${probe}" || true + _pass "SOFIIA_DATA_DIR is writable (${DATA_DIR})" + else + _fail "SOFIIA_DATA_DIR is not writable (${DATA_DIR})" + fi +else + _fail "Cannot create SOFIIA_DATA_DIR (${DATA_DIR})" +fi + +_section "5) Redis connectivity (if redis backend enabled)" +if [ "${SOFIIA_IDEMPOTENCY_BACKEND:-inmemory}" = "redis" ] || [ "${SOFIIA_RATE_LIMIT_BACKEND:-inmemory}" = "redis" ]; then + REDIS_URL="${SOFIIA_REDIS_URL:-}" + if [ -z "${REDIS_URL}" ]; then + _fail "Redis backend enabled but SOFIIA_REDIS_URL is empty" + else + if python3 - <<'PYEOF' >/dev/null 2>&1 +import os +url = os.getenv("SOFIIA_REDIS_URL", "") +if not url: + raise SystemExit(2) +try: + import redis + c = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2, decode_responses=True) + if c.ping(): + raise SystemExit(0) + raise SystemExit(1) +except Exception: + raise SystemExit(1) +PYEOF + then + _pass "Redis ping OK (redis-python)" + elif command -v redis-cli >/dev/null 2>&1 && redis-cli -u "${REDIS_URL}" --no-auth-warning ping >/dev/null 2>&1; then + _pass "Redis ping OK (redis-cli)" + else + _fail "Redis ping failed (backend=redis)" + fi + fi +else + _pass "Redis connectivity check skipped (backend != redis)" +fi + +echo "" +echo "========================================" +echo "Preflight summary: PASS=${PASS} WARN=${WARN} FAIL=${FAIL}" +echo "========================================" + +if [ "${FAIL}" -gt 0 ]; then + exit 1 +fi + +if [ "${WARN}" -gt 0 ]; then + if [ "${STRICT}" = "1" ]; then + exit 1 + fi + exit 2 +fi + +exit 0