#!/usr/bin/env bash
# security_probe_external.sh — Read-only external attack-surface probes (no credentials).
# Usage: NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
REPORT_DIR="${NEXUS_PROBE_REPORT_DIR:-/tmp/nexus-external-probe}"
PROBE_VENV="${NEXUS_PROBE_VENV_DIR:-/tmp/nexus-probe-venv}"
mkdir -p "$REPORT_DIR"
OUT="$REPORT_DIR/probe-$(date -u +%Y%m%dT%H%M%SZ).txt"

log() { echo "$@" | tee -a "$OUT"; }
plog() { echo "$@" | tee -a "$OUT" >&2; }
pass() { log "  [PASS] $*"; }
fail() { log "  [FAIL] $*"; }
info() { log "  [INFO] $*"; }
warn() { log "  [WARN] $*"; }

# Resolve a Python with `websockets` for BL-07 probes (project venv → system → bootstrap venv).
resolve_ws_python() {
  local py cand
  if [[ -n "${NEXUS_PROBE_PYTHON:-}" && -x "${NEXUS_PROBE_PYTHON}" ]]; then
    if "${NEXUS_PROBE_PYTHON}" -c 'import websockets' 2>/dev/null; then
      echo "${NEXUS_PROBE_PYTHON}"
      return 0
    fi
  fi
  for cand in "${ROOT}/venv/bin/python" "${ROOT}/.venv/bin/python"; do
    [[ -x "$cand" ]] || continue
    if "$cand" -c 'import websockets' 2>/dev/null; then
      echo "$cand"
      return 0
    fi
  done
  if command -v python3 >/dev/null 2>&1; then
    if python3 -c 'import websockets' 2>/dev/null; then
      echo python3
      return 0
    fi
  fi
  if ! command -v python3 >/dev/null 2>&1; then
    return 1
  fi
  if [[ ! -x "${PROBE_VENV}/bin/python" ]]; then
    python3 -m venv "${PROBE_VENV}" 2>>"$OUT" || return 1
  fi
  if ! "${PROBE_VENV}/bin/python" -c 'import websockets' 2>/dev/null; then
    "${PROBE_VENV}/bin/pip" install -q "websockets==14.1" 2>>"$OUT" || return 1
  fi
  echo "${PROBE_VENV}/bin/python"
}

# probe METHOD PATH [JSON_BODY] [extra curl args...]
# Prints HTTP status code to stdout only.
probe() {
  local method="$1" path="$2" body="${3:-}"
  # shift 3 is wrong for GET (only 2 args): bash leaves $@ untouched when n > $#.
  shift 2
  if [[ "$method" != "GET" ]]; then
    shift 1
  fi
  local url="${BASE}${path}"
  local tmp code_file
  tmp="$(mktemp)"
  code_file="$(mktemp)"
  local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20 -X "$method")
  if [[ "$method" != "GET" ]]; then
    curl_args+=(-H "Content-Type: application/json")
    [[ -n "$body" ]] && curl_args+=(-d "$body")
  fi
  local code
  if ! curl "${curl_args[@]}" "$@" "$url" >"$code_file" 2>>"$OUT"; then
    code="000"
  else
    code="$(tr -d '\r\n' <"$code_file")"
  fi
  rm -f "$code_file"
  local snippet
  snippet="$(head -c 400 "$tmp" | tr '\n' ' ')"
  plog ""
  plog "── $method $path → HTTP $code"
  plog "   body: ${snippet:-<empty>}"
  rm -f "$tmp"
  echo "$code"
}

log "═══ Nexus external attack-surface probe ═══"
log "BASE=$BASE"
log "OUT=$OUT"
log "time=$(date -u -Iseconds)"

# ── Health ──
c=$(probe GET /health)
[[ "$c" == "200" ]] && pass "GET /health → 200" || fail "GET /health code=$c"

c=$(probe GET /health/detail)
[[ "$c" == "401" ]] && pass "GET /health/detail requires JWT" || fail "GET /health/detail code=$c (expected 401) — component leak if 200"

# ── OpenAPI / docs ──
c=$(probe GET /openapi.json)
[[ "$c" == "200" ]] && warn "GET /openapi.json accessible (schema disclosure)" || info "GET /openapi.json code=$c"

c=$(probe GET /docs)
[[ "$c" == "200" ]] && warn "GET /docs accessible" || info "GET /docs code=$c"

c=$(probe GET /redoc)
[[ "$c" == "200" ]] && warn "GET /redoc accessible" || info "GET /redoc code=$c"

# ── Install API ──
c=$(probe GET /api/install/status)
if [[ "$c" == "404" ]]; then
  pass "GET /api/install/status hidden when locked (404)"
elif [[ "$c" == "200" ]]; then
  warn "GET /api/install/status returns 200 (install state leak)"
else
  info "GET /api/install/status code=$c"
fi

for ep in env-check connection-check state; do
  c=$(probe GET "/api/install/$ep")
  [[ "$c" == "404" ]] && pass "GET /api/install/$ep hidden when locked (404)" || fail "GET /api/install/$ep code=$c (expected 404)"
done

c=$(probe POST /api/install/init-db '{"db_host":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/init-db hidden when locked (404)" || fail "POST /api/install/init-db code=$c (expected 404)"

c=$(probe POST /api/install/create-admin '{"username":"x","password":"x","install_token":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/create-admin hidden when locked (404)" || fail "POST /api/install/create-admin code=$c (expected 404)"

c=$(probe POST /api/install/test-credentials '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/test-credentials hidden when locked (404)" || fail "POST /api/install/test-credentials code=$c (expected 404)"

c=$(probe POST /api/install/lock '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/lock hidden when locked (404)" || fail "POST /api/install/lock code=$c (expected 404)"

# ── Public settings ──
c=$(probe GET /api/settings/bing-wallpapers)
[[ "$c" == "200" ]] && pass "GET /api/settings/bing-wallpapers public read-only" || fail "code=$c"

c=$(probe POST /api/settings/bing-wallpapers/sync '{}')
[[ "$c" == "401" ]] && pass "POST bing-wallpapers/sync requires JWT" || fail "POST bing-wallpapers/sync code=$c (expected 401)"

# ── Protected admin API ──
c=$(probe GET /api/servers/)
[[ "$c" == "401" ]] && pass "GET /api/servers/ requires JWT" || fail "code=$c"

c=$(probe POST /api/servers/ '{"name":"x","domain":"1.2.3.4"}')
[[ "$c" == "401" ]] && pass "POST /api/servers/ requires JWT" || fail "code=$c"

c=$(probe GET /api/audit/)
[[ "$c" == "401" ]] && pass "GET /api/audit/ requires JWT" || fail "code=$c"

# ── Agent ──
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}')
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat no key rejected ($c)" || fail "code=$c"

c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat bad key rejected ($c)" || fail "code=$c"

c=$(probe POST /api/agent/script-callback \
  '{"job_id":"probe0001","server_id":1,"secret":"invalid-probe-secret","exit_code":0}' \
  -H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "404" || "$c" == "429" ]] && pass "POST script-callback bad key ($c)" || fail "code=$c"

# ── Auth login (single probe) ──
login_tmp="$(mktemp)"
curl -sS -o "$login_tmp" --max-time 15 -X POST \
  -H "Content-Type: application/json" \
  -d '{"username":"nonexistent_probe_user","password":"wrong"}' \
  "${BASE}/api/auth/login" 2>>"$OUT" || true
login_body="$(cat "$login_tmp")"
rm -f "$login_tmp"
info "login wrong user: $login_body"
[[ "$login_body" == *"用户名或密码错误"* ]] && pass "login message unified (no user enumeration)" || warn "login message may leak enumeration"

# ── Static agent ──
c=$(probe GET /agent/install.sh)
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"

# ── WebSocket ──
WS_PY="$(resolve_ws_python || true)"
if [[ -n "${WS_PY}" ]]; then
  log ""
  log "── WebSocket probes (python=${WS_PY})"
  "${WS_PY}" - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
import asyncio, sys
import websockets

base = sys.argv[1].rstrip("/")
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")

async def probe_ws(path, token=None):
    url = f"{ws_base}{path}"
    if token is not None:
        url += f"?token={token}"
    try:
        async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
            print(f"  [FAIL] {path} connected without expected close")
    except Exception as e:
        msg = str(e)
        if "4001" in msg or "Missing JWT" in msg:
            print(f"  [PASS] {path} no token → rejected ({msg[:80]})")
        elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
            print(f"  [PASS] {path} bad token → rejected ({msg[:80]})")
        elif "403" in msg:
            # Starlette: close() before accept() → HTTP 403 (same direct and via proxy)
            print(f"  [PASS] {path} unauthenticated → rejected (HTTP 403)")
        else:
            print(f"  [INFO] {path} → {msg[:120]}")

async def main():
    await probe_ws("/ws/alerts")
    await probe_ws("/ws/alerts", "invalid.jwt.token")
    await probe_ws("/ws/sync")
    await probe_ws("/ws/terminal/1", "invalid.jwt.token")

asyncio.run(main())
PY
else
  warn "WebSocket probes skipped: no python with websockets (set NEXUS_PROBE_PYTHON or install venv)"
fi

log ""
log "═══ Probe complete ═══"
log "Full log: $OUT"
