Fixes surfaced by three rounds of fresh-install testing on marcelle: - config.sh: add host-port preflight check (ss -tln) to catch cockpit-on-9090 style collisions before compose up; add --skip-port-check escape hatch; add --install-watcher / --no-install-watcher / --install-backup-timer / --no-install-backup-timer flags; -y --enable-all now installs both systemd units by default (previously silently skipped); print resolved admin email in Configuration Complete block. - scripts/validate-env.sh: new section 5b "Host Port Availability" using ss-based detection, with process-name surfacing when run as root. - scripts/pangolin-teardown.sh: new wrapper. Reads credentials from .env or takes --api-url/--api-key/--org-id flags. Dry-run by default; --yes to execute. Deletes resources before sites (avoids orphans). --keep-site-ids for safety. - scripts/build-release.sh: include validate-env.sh and pangolin-teardown.sh in release tarball whitelist. - CCP instances.service.ts: deleteInstance() now calls teardownTunnel() before composeDown when pangolinSiteId is set. Previously an admin clicking "Delete Instance" orphaned the Pangolin site + all its resources. Best-effort with try/catch matching the existing Docker-cleanup tolerance pattern. - CLAUDE.md: sync drift — 44 → 50 migrations, 186 → 192 models, 40 → 44 modules. Bunker Admin
347 lines
8.9 KiB
Bash
Executable File
347 lines
8.9 KiB
Bash
Executable File
#!/bin/bash
|
|
# =============================================================================
|
|
# validate-env.sh — Validate .env for Changemaker Lite
|
|
#
|
|
# Checks required variables, secret strength, placeholder detection,
|
|
# production-mode requirements, and port conflicts.
|
|
#
|
|
# Usage: ./scripts/validate-env.sh [--strict]
|
|
# --strict: treat warnings as errors (for CI/pre-deploy)
|
|
#
|
|
# Exit codes:
|
|
# 0 = all checks passed
|
|
# 1 = errors found
|
|
# 2 = warnings found (only with --strict)
|
|
# =============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
ENV_FILE="${PROJECT_DIR}/.env"
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
GREEN='\033[0;32m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m'
|
|
|
|
ERRORS=0
|
|
WARNINGS=0
|
|
STRICT=false
|
|
|
|
[[ "${1:-}" == "--strict" ]] && STRICT=true
|
|
|
|
# --- Helpers ---
|
|
|
|
error() {
|
|
echo -e " ${RED}ERROR${NC} $1"
|
|
ERRORS=$((ERRORS + 1))
|
|
}
|
|
|
|
warn() {
|
|
echo -e " ${YELLOW}WARN${NC} $1"
|
|
WARNINGS=$((WARNINGS + 1))
|
|
}
|
|
|
|
ok() {
|
|
echo -e " ${GREEN}OK${NC} $1"
|
|
}
|
|
|
|
info() {
|
|
echo -e " ${CYAN}INFO${NC} $1"
|
|
}
|
|
|
|
# --- Load .env ---
|
|
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
echo -e "${RED}ERROR: .env file not found at ${ENV_FILE}${NC}"
|
|
echo " Copy .env.example to .env and configure it:"
|
|
echo " cp .env.example .env"
|
|
exit 1
|
|
fi
|
|
|
|
# Source .env safely (export all, ignore errors from complex values)
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
source "$ENV_FILE" 2>/dev/null || true
|
|
set +a
|
|
|
|
echo ""
|
|
echo "========================================="
|
|
echo " Changemaker Lite — Environment Validator"
|
|
echo "========================================="
|
|
echo ""
|
|
|
|
# --- 1. Required Variables ---
|
|
|
|
echo "1. Required Variables"
|
|
echo "---------------------"
|
|
|
|
REQUIRED_VARS=(
|
|
"V2_POSTGRES_PASSWORD"
|
|
"REDIS_PASSWORD"
|
|
"JWT_ACCESS_SECRET"
|
|
"JWT_REFRESH_SECRET"
|
|
"JWT_INVITE_SECRET"
|
|
"DOMAIN"
|
|
"INITIAL_ADMIN_EMAIL"
|
|
"INITIAL_ADMIN_PASSWORD"
|
|
)
|
|
|
|
for var in "${REQUIRED_VARS[@]}"; do
|
|
val="${!var:-}"
|
|
if [[ -z "$val" ]]; then
|
|
error "$var is not set"
|
|
else
|
|
ok "$var is set"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
|
|
# --- 2. Secret Strength ---
|
|
|
|
echo "2. Secret Strength"
|
|
echo "------------------"
|
|
|
|
check_secret_length() {
|
|
local name="$1"
|
|
local min_len="$2"
|
|
local val="${!name:-}"
|
|
if [[ -n "$val" ]] && [[ ${#val} -lt $min_len ]]; then
|
|
error "$name is too short (${#val} chars, need $min_len+)"
|
|
elif [[ -n "$val" ]]; then
|
|
ok "$name length OK (${#val} chars)"
|
|
fi
|
|
}
|
|
|
|
check_secret_length "JWT_ACCESS_SECRET" 32
|
|
check_secret_length "JWT_REFRESH_SECRET" 32
|
|
check_secret_length "JWT_INVITE_SECRET" 32
|
|
check_secret_length "V2_POSTGRES_PASSWORD" 8
|
|
check_secret_length "REDIS_PASSWORD" 8
|
|
|
|
# Password policy check (12+ chars, uppercase, lowercase, digit)
|
|
ADMIN_PW="${INITIAL_ADMIN_PASSWORD:-}"
|
|
if [[ -n "$ADMIN_PW" ]]; then
|
|
PW_OK=true
|
|
if [[ ${#ADMIN_PW} -lt 12 ]]; then
|
|
error "INITIAL_ADMIN_PASSWORD too short (${#ADMIN_PW} chars, need 12+)"
|
|
PW_OK=false
|
|
fi
|
|
if ! [[ "$ADMIN_PW" =~ [A-Z] ]]; then
|
|
error "INITIAL_ADMIN_PASSWORD needs at least one uppercase letter"
|
|
PW_OK=false
|
|
fi
|
|
if ! [[ "$ADMIN_PW" =~ [a-z] ]]; then
|
|
error "INITIAL_ADMIN_PASSWORD needs at least one lowercase letter"
|
|
PW_OK=false
|
|
fi
|
|
if ! [[ "$ADMIN_PW" =~ [0-9] ]]; then
|
|
error "INITIAL_ADMIN_PASSWORD needs at least one digit"
|
|
PW_OK=false
|
|
fi
|
|
if $PW_OK; then
|
|
ok "INITIAL_ADMIN_PASSWORD meets password policy"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# --- 3. Placeholder Detection ---
|
|
|
|
echo "3. Placeholder Detection"
|
|
echo "------------------------"
|
|
|
|
PLACEHOLDER_PATTERNS=("CHANGE_THIS" "REQUIRED" "changeme" "password123" "secret123" "example.com" "your-")
|
|
|
|
for var in JWT_ACCESS_SECRET JWT_REFRESH_SECRET JWT_INVITE_SECRET V2_POSTGRES_PASSWORD REDIS_PASSWORD ENCRYPTION_KEY; do
|
|
val="${!var:-}"
|
|
if [[ -z "$val" ]]; then continue; fi
|
|
for pattern in "${PLACEHOLDER_PATTERNS[@]}"; do
|
|
if echo "$val" | grep -qi "$pattern"; then
|
|
error "$var contains placeholder value '$pattern'"
|
|
fi
|
|
done
|
|
done
|
|
|
|
# Check secrets are not reused
|
|
if [[ -n "${JWT_ACCESS_SECRET:-}" ]] && [[ "${JWT_ACCESS_SECRET:-}" == "${JWT_REFRESH_SECRET:-}" ]]; then
|
|
error "JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different"
|
|
fi
|
|
|
|
if [[ -n "${ENCRYPTION_KEY:-}" ]] && [[ "${ENCRYPTION_KEY:-}" == "${JWT_ACCESS_SECRET:-}" ]]; then
|
|
error "ENCRYPTION_KEY must not reuse JWT_ACCESS_SECRET"
|
|
fi
|
|
|
|
ok "Placeholder check complete"
|
|
echo ""
|
|
|
|
# --- 4. Production Checks ---
|
|
|
|
echo "4. Production Checks"
|
|
echo "--------------------"
|
|
|
|
NODE_ENV="${NODE_ENV:-development}"
|
|
info "NODE_ENV=$NODE_ENV"
|
|
|
|
if [[ "$NODE_ENV" == "production" ]]; then
|
|
if [[ -z "${ENCRYPTION_KEY:-}" ]]; then
|
|
error "ENCRYPTION_KEY is required in production"
|
|
else
|
|
ok "ENCRYPTION_KEY is set"
|
|
fi
|
|
|
|
if [[ "${EMAIL_TEST_MODE:-}" == "true" ]]; then
|
|
warn "EMAIL_TEST_MODE=true in production (emails go to MailHog, not SMTP)"
|
|
fi
|
|
|
|
CORS="${CORS_ORIGINS:-}"
|
|
if echo "$CORS" | grep -q "localhost"; then
|
|
warn "CORS_ORIGINS contains 'localhost' in production"
|
|
fi
|
|
|
|
if [[ -z "${CORS:-}" ]]; then
|
|
warn "CORS_ORIGINS is not set — API will reject cross-origin requests"
|
|
else
|
|
ok "CORS_ORIGINS configured"
|
|
fi
|
|
else
|
|
info "Skipping production-only checks (NODE_ENV=$NODE_ENV)"
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# --- 5. Port Conflict Detection ---
|
|
|
|
echo "5. Port Conflict Detection"
|
|
echo "--------------------------"
|
|
|
|
# Collect all configured ports
|
|
declare -A PORT_MAP
|
|
PORT_VARS=(
|
|
"ADMIN_PORT:3000"
|
|
"API_PORT:4000"
|
|
"MEDIA_API_PORT:4100"
|
|
"V2_POSTGRES_PORT:5433"
|
|
"GRAFANA_PORT:3001"
|
|
"HOMEPAGE_PORT:3010"
|
|
"GITEA_PORT:3030"
|
|
"MKDOCS_DEV_PORT:4003"
|
|
"NOCODB_PORT:8091"
|
|
"MAILHOG_PORT:8025"
|
|
"LISTMONK_PORT:9001"
|
|
"N8N_PORT:5678"
|
|
"CODE_SERVER_PORT:8888"
|
|
"PROMETHEUS_PORT:9090"
|
|
)
|
|
|
|
DUPLICATE_FOUND=false
|
|
for entry in "${PORT_VARS[@]}"; do
|
|
var="${entry%%:*}"
|
|
default="${entry##*:}"
|
|
port="${!var:-$default}"
|
|
|
|
if [[ -n "${PORT_MAP[$port]:-}" ]]; then
|
|
error "Port $port conflict: ${PORT_MAP[$port]} and $var"
|
|
DUPLICATE_FOUND=true
|
|
else
|
|
PORT_MAP[$port]="$var"
|
|
fi
|
|
done
|
|
|
|
if ! $DUPLICATE_FOUND; then
|
|
ok "No port conflicts detected"
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# --- 5b. Host Port Availability ---
|
|
# Detects processes already bound to ports we intend to use (e.g. cockpit on :9090).
|
|
|
|
echo "5b. Host Port Availability"
|
|
echo "--------------------------"
|
|
|
|
if ! command -v ss >/dev/null 2>&1; then
|
|
warn "ss not installed — skipping host port check (install iproute2 to enable)"
|
|
else
|
|
# Collect unique ports from PORT_MAP keys
|
|
HOST_CONFLICTS=()
|
|
for port in "${!PORT_MAP[@]}"; do
|
|
# ss -H (no header) -t (tcp) -l (listen) -n (numeric); match :PORT at end of local addr
|
|
# Also matches *:PORT and [::]:PORT
|
|
if ss -Htln 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {found=1} END{exit !found}'; then
|
|
owner="${PORT_MAP[$port]}"
|
|
# Process identification needs root; ss -tlnp includes users:(("name",pid=X,fd=Y))
|
|
proc=""
|
|
if [[ $EUID -eq 0 ]]; then
|
|
proc=$(ss -Htlnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print; exit}' \
|
|
| grep -oP 'users:\(\("[^"]+"' | head -1 | sed 's/users:(("//')
|
|
fi
|
|
if [[ -n "$proc" ]]; then
|
|
error "Port $port already in use by '$proc' (want: $owner)"
|
|
else
|
|
error "Port $port already in use on host (want: $owner) — run as root to identify the process"
|
|
fi
|
|
HOST_CONFLICTS+=("$port")
|
|
fi
|
|
done
|
|
|
|
if [[ ${#HOST_CONFLICTS[@]} -eq 0 ]]; then
|
|
ok "All configured host ports are available"
|
|
else
|
|
info "Common culprits: cockpit.socket (9090), systemd-resolved (53), apache2/nginx host (80/443)"
|
|
info "Fix: \`sudo systemctl stop <service> && sudo systemctl disable <service>\` or remap the port in .env"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# --- 6. Feature Flag Consistency ---
|
|
|
|
echo "6. Feature Flag Consistency"
|
|
echo "---------------------------"
|
|
|
|
if [[ "${ENABLE_MEDIA_FEATURES:-}" == "true" ]]; then
|
|
ok "Media features enabled"
|
|
fi
|
|
|
|
if [[ "${LISTMONK_SYNC_ENABLED:-}" == "true" ]]; then
|
|
if [[ -z "${LISTMONK_ADMIN_USER:-}" ]] || [[ -z "${LISTMONK_ADMIN_PASSWORD:-}" ]]; then
|
|
warn "LISTMONK_SYNC_ENABLED=true but LISTMONK_ADMIN_USER/PASSWORD not set"
|
|
else
|
|
ok "Listmonk sync credentials configured"
|
|
fi
|
|
fi
|
|
|
|
if [[ "${ENABLE_PAYMENTS:-}" == "true" ]]; then
|
|
if [[ -z "${STRIPE_SECRET_KEY:-}" ]]; then
|
|
warn "ENABLE_PAYMENTS=true but STRIPE_SECRET_KEY not set"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# --- Summary ---
|
|
|
|
echo "========================================="
|
|
if [[ $ERRORS -gt 0 ]]; then
|
|
echo -e " ${RED}FAILED${NC}: $ERRORS error(s), $WARNINGS warning(s)"
|
|
echo "========================================="
|
|
exit 1
|
|
elif [[ $WARNINGS -gt 0 ]] && $STRICT; then
|
|
echo -e " ${YELLOW}WARNINGS${NC}: $WARNINGS warning(s) (strict mode)"
|
|
echo "========================================="
|
|
exit 2
|
|
elif [[ $WARNINGS -gt 0 ]]; then
|
|
echo -e " ${YELLOW}PASSED${NC} with $WARNINGS warning(s)"
|
|
echo "========================================="
|
|
exit 0
|
|
else
|
|
echo -e " ${GREEN}PASSED${NC}: All checks OK"
|
|
echo "========================================="
|
|
exit 0
|
|
fi
|