#!/usr/bin/env bash set -euo pipefail # ============================================================================= # Changemaker Lite V2 — Configuration Wizard # Produces a working .env file and stages the full application stack. # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env" ENV_EXAMPLE="$SCRIPT_DIR/.env.example" MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml" SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml" # --- Non-interactive mode flags --- NON_INTERACTIVE=false NI_DOMAIN="" NI_ADMIN_EMAIL="" NI_ADMIN_PASSWORD="" NI_PRODUCTION=true NI_ENABLE_ALL=false SKIP_PORT_CHECK=false SKIP_PANGOLIN_CHECK=false # Systemd unit install opt-in ("", "yes", "no") # Empty means "use default for this mode": skipped in NI unless --enable-all. NI_INSTALL_WATCHER="" NI_INSTALL_BACKUP="" # SMTP flags NI_SMTP_HOST="" NI_SMTP_PORT="" NI_SMTP_USER="" NI_SMTP_PASS="" # Pangolin flags NI_PANGOLIN_API_URL="" NI_PANGOLIN_API_KEY="" NI_PANGOLIN_ORG_ID="" NI_PANGOLIN_ENDPOINT="" NI_PANGOLIN_SITE="" # "new", "existing", or "" (skip) # Service credential flags NI_MAPBOX_KEY="" NI_MAXMIND_ACCOUNT_ID="" NI_MAXMIND_LICENSE_KEY="" # CCP (Changemaker Control Panel) registration flags NI_CCP_URL="" NI_CCP_INVITE_CODE="" NI_CCP_AGENT_URL="" # --- Arg parser --- while [[ $# -gt 0 ]]; do case "$1" in --non-interactive|-y) NON_INTERACTIVE=true; shift ;; --domain) NI_DOMAIN="$2"; shift 2 ;; --admin-email) NI_ADMIN_EMAIL="$2"; shift 2 ;; --admin-password) NI_ADMIN_PASSWORD="$2"; shift 2 ;; --development) NI_PRODUCTION=false; shift ;; --enable-all) NI_ENABLE_ALL=true; shift ;; --skip-port-check) SKIP_PORT_CHECK=true; shift ;; --skip-pangolin-check) SKIP_PANGOLIN_CHECK=true; shift ;; --install-watcher) NI_INSTALL_WATCHER="yes"; shift ;; --no-install-watcher) NI_INSTALL_WATCHER="no"; shift ;; --install-backup-timer) NI_INSTALL_BACKUP="yes"; shift ;; --no-install-backup-timer) NI_INSTALL_BACKUP="no"; shift ;; # SMTP --smtp-host) NI_SMTP_HOST="$2"; shift 2 ;; --smtp-port) NI_SMTP_PORT="$2"; shift 2 ;; --smtp-user) NI_SMTP_USER="$2"; shift 2 ;; --smtp-pass) NI_SMTP_PASS="$2"; shift 2 ;; # Pangolin --pangolin-api-url) NI_PANGOLIN_API_URL="$2"; shift 2 ;; --pangolin-api-key) NI_PANGOLIN_API_KEY="$2"; shift 2 ;; --pangolin-org-id) NI_PANGOLIN_ORG_ID="$2"; shift 2 ;; --pangolin-endpoint) NI_PANGOLIN_ENDPOINT="$2"; shift 2 ;; --pangolin-site) NI_PANGOLIN_SITE="$2"; shift 2 ;; # Services --mapbox-key) NI_MAPBOX_KEY="$2"; shift 2 ;; --maxmind-account-id) NI_MAXMIND_ACCOUNT_ID="$2"; shift 2 ;; --maxmind-license-key) NI_MAXMIND_LICENSE_KEY="$2"; shift 2 ;; # CCP (Changemaker Control Panel) --ccp-url) NI_CCP_URL="$2"; shift 2 ;; --ccp-invite-code) NI_CCP_INVITE_CODE="$2"; shift 2 ;; --ccp-agent-url) NI_CCP_AGENT_URL="$2"; shift 2 ;; --help|-h) echo "Usage: bash config.sh [OPTIONS]" echo "" echo "Options:" echo " --non-interactive, -y Skip all prompts (generate secrets, use defaults)" echo " --domain DOMAIN Set domain (default: cmlite.org)" echo " --admin-email EMAIL Set admin email (default: admin@DOMAIN)" echo " --admin-password PASS Set admin password (must meet policy: 12+ chars, upper+lower+digit)" echo " --development Set NODE_ENV=development (default: production)" echo " --enable-all Enable all optional features + install systemd units" echo " --skip-port-check Skip host port availability check (not recommended)" echo " --skip-pangolin-check Skip Pangolin credential smoke test (for offline bootstrap)" echo "" echo "Systemd Units (default in -y mode: skipped, unless --enable-all):" echo " --install-watcher Install upgrade watcher systemd unit" echo " --no-install-watcher Skip upgrade watcher even with --enable-all" echo " --install-backup-timer Install daily backup timer systemd unit" echo " --no-install-backup-timer Skip backup timer even with --enable-all" echo "" echo "SMTP:" echo " --smtp-host HOST SMTP server hostname" echo " --smtp-port PORT SMTP port (default: 587)" echo " --smtp-user USER SMTP username" echo " --smtp-pass PASS SMTP password" echo "" echo "Pangolin Tunnel:" echo " --pangolin-api-url URL Pangolin REST API URL" echo " --pangolin-api-key KEY Pangolin API key" echo " --pangolin-org-id ID Pangolin organization ID" echo " --pangolin-endpoint URL Pangolin dashboard/Newt WebSocket URL" echo " --pangolin-site MODE Site setup: 'new' (create) or 'existing' (connect first)" echo "" echo "Services:" echo " --mapbox-key KEY Mapbox API key for map features" echo " --maxmind-account-id ID MaxMind GeoIP account ID" echo " --maxmind-license-key K MaxMind GeoIP license key" echo "" echo "CCP (Changemaker Control Panel) — all 3 flags required to register:" echo " --ccp-url URL CCP server URL (e.g., https://ccp.example.com)" echo " --ccp-invite-code CODE One-time invite code from CCP" echo " --ccp-agent-url URL Agent URL the CCP reaches (e.g., https://this-host:7443)" echo "" echo "Example:" echo " bash config.sh --non-interactive --domain example.org --admin-password MyStr0ngPass123" echo " bash config.sh -y --domain example.org --admin-password MyStr0ngPass123 \\" echo " --smtp-host smtp.example.com --smtp-port 587 --smtp-user me@example.com --smtp-pass secret \\" echo " --pangolin-api-url https://api.pangolin.example/v1 --pangolin-api-key KEY \\" echo " --pangolin-org-id myorg --pangolin-endpoint https://pangolin.example --pangolin-site new \\" echo " --enable-all --mapbox-key pk.xxx --maxmind-account-id 12345 --maxmind-license-key abc" exit 0 ;; *) shift ;; esac done # --- Detect install mode --- # Release mode: installed from tarball (has VERSION file, no .git directory) # Source mode: cloned from git repository if [[ -f "$SCRIPT_DIR/VERSION" ]] && [[ ! -d "$SCRIPT_DIR/.git" ]]; then INSTALL_MODE="release" RELEASE_VERSION=$(head -1 "$SCRIPT_DIR/VERSION") else INSTALL_MODE="source" RELEASE_VERSION="" fi # --- Colors (respects NO_COLOR convention) --- if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' NC='' fi # ============================================================================= # Ensure stdin is connected to the terminal (handles curl | bash case) # ============================================================================= if [[ "$NON_INTERACTIVE" == "false" ]]; then if [[ ! -t 0 ]]; then if [[ -e /dev/tty ]]; then exec 0&2 echo " Use --non-interactive for headless mode, or run manually: bash config.sh" >&2 exit 1 fi fi fi # ============================================================================= # Utility Functions # ============================================================================= info() { echo -e "${CYAN}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERR]${NC} $*" >&2; } header() { echo "" echo -e "${BOLD}${BLUE}── $* ──${NC}" echo "" } generate_secret() { openssl rand -hex 32 } generate_password() { local length=${1:-24} openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" } # Line-by-line .env replacement — robust with special characters update_env_var() { local key=$1 local value=$2 if grep -q "^${key}=" "$ENV_FILE"; then local tmpfile tmpfile=$(mktemp) while IFS= read -r line; do if [[ "$line" =~ ^${key}= ]]; then echo "${key}=${value}" >> "$tmpfile" else echo "$line" >> "$tmpfile" fi done < "$ENV_FILE" mv "$tmpfile" "$ENV_FILE" else echo "${key}=${value}" >> "$ENV_FILE" fi } backup_env_file() { if [[ -f "$ENV_FILE" ]]; then local ts ts=$(date +"%Y%m%d_%H%M%S") local backup="$ENV_FILE.backup_$ts" cp "$ENV_FILE" "$backup" success "Backed up .env to ${backup##*/}" fi } validate_password() { local pw=$1 [[ ${#pw} -ge 12 ]] || return 1 [[ "$pw" =~ [A-Z] ]] || return 1 [[ "$pw" =~ [a-z] ]] || return 1 [[ "$pw" =~ [0-9] ]] || return 1 return 0 } prompt_yes_no() { local prompt=$1 default=${2:-n} # In non-interactive mode, use default (or yes-for-all if --enable-all) if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ "$NI_ENABLE_ALL" == "true" ]]; then return 0 # yes to everything fi [[ "$default" == "y" ]] && return 0 || return 1 fi local yn if [[ "$default" == "y" ]]; then read -rp "$prompt [Y/n]: " yn [[ "$yn" =~ ^[Nn]$ ]] && return 1 || return 0 else read -rp "$prompt [y/N]: " yn [[ "$yn" =~ ^[Yy]$ ]] && return 0 || return 1 fi } # ============================================================================= # Prerequisites # ============================================================================= check_prerequisites() { header "Checking Prerequisites" local ok=true if command -v docker &>/dev/null; then success "Docker found: $(docker --version | head -1)" else error "Docker is not installed. See https://docs.docker.com/get-docker/" ok=false fi if docker compose version &>/dev/null; then success "Docker Compose found: $(docker compose version --short)" else error "Docker Compose v2 plugin not found. See https://docs.docker.com/compose/install/" ok=false fi if command -v openssl &>/dev/null; then success "OpenSSL found" else error "OpenSSL is not installed (needed for secret generation)" ok=false fi # Host port availability check — catches cockpit on :9090, other stray listeners. # Not fatal on its own; we warn here and let validate-env.sh surface details later. if command -v ss &>/dev/null; then local host_conflicts="" for port in 3000 4000 4100 5433 3001 3030 9090 8091 8025 9001 5678 8888; do if ss -Htln 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {found=1} END{exit !found}'; then host_conflicts+="$port " fi done if [[ -n "$host_conflicts" ]]; then warn "Host ports already in use: $host_conflicts" warn "This will break 'docker compose up -d' on affected services." warn "Common: cockpit.socket owns :9090 — 'sudo systemctl disable --now cockpit.socket'" warn "Run './scripts/validate-env.sh' after setup for a full report." if [[ "$NON_INTERACTIVE" == "true" ]]; then error "Refusing to continue in non-interactive mode with host port conflicts." error "Free the ports or pass --skip-port-check to override." [[ "$SKIP_PORT_CHECK" != "true" ]] && ok=false fi else success "Host ports available" fi else info "ss not installed — skipping host port check" fi $ok || { echo ""; error "Missing prerequisites. Install them and re-run."; exit 1; } } # ============================================================================= # Banner # ============================================================================= print_banner() { cat << 'EOF' ██████╗██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗ ██╔════╝██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝ ██║ ███████║███████║██╔██╗ ██║██║ ███╗█████╗ ██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝ ╚██████╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝ ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ V2 Configuration Wizard EOF echo "" info "This wizard will create your .env file, generate secure secrets," info "and prepare your system to run the full Changemaker Lite stack." echo "" } # ============================================================================= # .env Initialization # ============================================================================= initialize_env() { header "Environment File Setup" if [[ ! -f "$ENV_EXAMPLE" ]]; then error ".env.example not found at $ENV_EXAMPLE" error "Make sure you're running this from the project root." exit 1 fi RECONFIGURE_MODE=false if [[ -f "$ENV_FILE" ]]; then if [[ "$NON_INTERACTIVE" == "true" ]]; then # Non-interactive: back up and create fresh backup_env_file cp "$ENV_EXAMPLE" "$ENV_FILE" success "Created fresh .env from .env.example (backed up existing)" else warn "Existing .env file found at $ENV_FILE" if prompt_yes_no "Back up existing .env and create a fresh one?"; then backup_env_file cp "$ENV_EXAMPLE" "$ENV_FILE" success "Created fresh .env from .env.example" else info "Keeping existing .env. Existing secrets will be preserved." RECONFIGURE_MODE=true fi fi else cp "$ENV_EXAMPLE" "$ENV_FILE" success "Created .env from .env.example" fi } # ============================================================================= # Configuration Sections # ============================================================================= configure_domain() { header "Domain Configuration" if [[ "$NON_INTERACTIVE" == "true" ]]; then domain="${NI_DOMAIN:-cmlite.org}" else info "Root domain serves MkDocs documentation." info "All application routes are at app.." echo "" read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain domain=${domain:-cmlite.org} fi update_env_var "DOMAIN" "$domain" update_env_var "BASE_DOMAIN" "https://$domain" update_env_var "GITEA_ROOT_URL" "https://git.$domain" update_env_var "GITEA_DOMAIN" "git.$domain" update_env_var "N8N_HOST" "n8n.$domain" update_env_var "SMTP_FROM" "noreply@$domain" update_env_var "SMTP_FROM_NAME" "Changemaker Lite" update_env_var "INITIAL_ADMIN_EMAIL" "admin@$domain" update_env_var "NC_ADMIN_EMAIL" "admin@$domain" update_env_var "TEST_EMAIL_RECIPIENT" "admin@$domain" update_env_var "EXCALIDRAW_WS_URL" "wss://draw.$domain" update_env_var "LISTMONK_SMTP_FROM" "Changemaker Lite " update_env_var "HOMEPAGE_VAR_BASE_URL" "https://$domain" update_env_var "VAULTWARDEN_DOMAIN" "https://vault.$domain" update_env_var "GANCIO_BASE_URL" "https://events.$domain" # Update mkdocs.yml if [[ -f "$MKDOCS_YML" ]]; then sed -i "s|^site_url:.*|site_url: https://$domain|" "$MKDOCS_YML" sed -i "s|^repo_url:.*|repo_url: https://git.$domain/admin/changemaker.lite|" "$MKDOCS_YML" success "Updated mkdocs.yml (site_url, repo_url)" else warn "mkdocs.yml not found — skipping" fi success "Domain set to: $domain" if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ "$NI_PRODUCTION" == "true" ]]; then update_env_var "NODE_ENV" "production" IS_PRODUCTION="yes" else IS_PRODUCTION="no" fi else echo "" if prompt_yes_no "Is this a production deployment?"; then update_env_var "NODE_ENV" "production" success "NODE_ENV set to production" IS_PRODUCTION="yes" else info "NODE_ENV stays as development" IS_PRODUCTION="no" fi fi # Store for later use CONFIGURED_DOMAIN="$domain" } configure_admin() { header "Admin Credentials" if [[ "$NON_INTERACTIVE" == "true" ]]; then local admin_email="${NI_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}" local admin_password="${NI_ADMIN_PASSWORD:-}" # Generate a password if not provided local password_was_generated=false if [[ -z "$admin_password" ]]; then admin_password="CmLite$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12)1" password_was_generated=true info "Generated admin password: $admin_password" info "SAVE THIS — it will not be shown again." fi if ! validate_password "$admin_password"; then error "Admin password does not meet policy (12+ chars, uppercase + lowercase + digit)" exit 1 fi update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" update_env_var "N8N_USER_EMAIL" "$admin_email" CONFIGURED_ADMIN_EMAIL="$admin_email" # Persist auto-generated password to a locked-down file so users piping # config.sh output (or missing the scroll) don't lose it forever. # Only written when we generated it — if the user supplied --admin-password, # they already have it. if [[ "$password_was_generated" == "true" ]]; then mkdir -p "$SCRIPT_DIR/data" local creds_file="$SCRIPT_DIR/data/admin-credentials.txt" umask 077 cat > "$creds_file" << CREDS_EOF # Changemaker Lite — auto-generated admin credentials # Written by config.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ) # DELETE THIS FILE after you have saved the password elsewhere. email=$admin_email password=$admin_password CREDS_EOF chmod 600 "$creds_file" info "Credentials saved to: $creds_file (mode 0600)" fi success "Admin credentials configured ($admin_email)" else local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}" read -rp "Admin email [default: $default_email]: " admin_email admin_email=${admin_email:-$default_email} local admin_password="" while true; do echo "" read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password echo "" if validate_password "$admin_password"; then read -rsp "Confirm password: " confirm_password echo "" if [[ "$admin_password" == "$confirm_password" ]]; then break else warn "Passwords do not match. Try again." fi else warn "Password must be 12+ characters with uppercase, lowercase, and a digit." fi done update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email" update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password" update_env_var "N8N_USER_EMAIL" "$admin_email" CONFIGURED_ADMIN_EMAIL="$admin_email" success "Admin credentials configured" fi } # Check if a key already has a real value in .env (non-empty, not a placeholder) env_var_is_set() { local key=$1 local val val=$(grep "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-) [[ -n "$val" && "$val" != "changeme" && "$val" != *"example"* && "$val" != *"CHANGEME"* ]] } # Update an env var only if not already set (for reconfigure mode) update_env_var_if_empty() { local key=$1 local value=$2 if [[ "$RECONFIGURE_MODE" == "true" ]] && env_var_is_set "$key"; then return 1 # signal: kept existing fi update_env_var "$key" "$value" return 0 } generate_all_secrets() { header "Generating Secrets" if [[ "$RECONFIGURE_MODE" == "true" ]]; then info "Reconfigure mode: existing secrets will be preserved." else info "Auto-generating 22 unique secrets and passwords..." fi echo "" local generated=0 kept=0 # JWT & Encryption (64-char hex) local jwt_access jwt_refresh jwt_invite enc_key jwt_access=$(generate_secret) jwt_refresh=$(generate_secret) jwt_invite=$(generate_secret) enc_key=$(generate_secret) local jwt_changed=false update_env_var_if_empty "JWT_ACCESS_SECRET" "$jwt_access" && jwt_changed=true update_env_var_if_empty "JWT_REFRESH_SECRET" "$jwt_refresh" && jwt_changed=true update_env_var_if_empty "JWT_INVITE_SECRET" "$jwt_invite" && jwt_changed=true update_env_var_if_empty "ENCRYPTION_KEY" "$enc_key" && jwt_changed=true if [[ "$jwt_changed" == "true" ]]; then success "JWT secrets + encryption key" ((generated+=4)) else info "JWT secrets + encryption key (kept existing)" ((kept+=4)) fi # Gitea SSO + service password salt (isolated from JWT secrets) local sso_secret svc_salt sso_secret=$(generate_secret) svc_salt=$(generate_secret) local sso_changed=false update_env_var_if_empty "GITEA_SSO_SECRET" "$sso_secret" && sso_changed=true update_env_var_if_empty "SERVICE_PASSWORD_SALT" "$svc_salt" && sso_changed=true if [[ "$sso_changed" == "true" ]]; then success "Gitea SSO secret + service password salt" ((generated+=2)) else info "Gitea SSO secret + service password salt (kept existing)" ((kept+=2)) fi # Database passwords (24-char alphanum) local pg_pass redis_pass pg_pass=$(generate_password 24) redis_pass=$(generate_password 24) local db_changed=false if update_env_var_if_empty "V2_POSTGRES_PASSWORD" "$pg_pass"; then update_env_var "DATABASE_URL" "postgresql://changemaker:${pg_pass}@localhost:5433/changemaker_v2" db_changed=true fi update_env_var_if_empty "REDIS_PASSWORD" "$redis_pass" && db_changed=true if [[ "$db_changed" == "true" ]]; then # Rebuild REDIS_URL if password changed local current_redis_pass current_redis_pass=$(grep "^REDIS_PASSWORD=" "$ENV_FILE" | cut -d= -f2-) update_env_var "REDIS_URL" "redis://:${current_redis_pass}@redis-changemaker:6379" success "PostgreSQL + Redis passwords" ((generated+=2)) else info "PostgreSQL + Redis passwords (kept existing)" ((kept+=2)) fi # Listmonk local lm_db_pass lm_web_pass lm_api_token lm_db_pass=$(generate_password 24) lm_web_pass=$(generate_password 20) lm_api_token=$(openssl rand -hex 16) local lm_changed=false update_env_var_if_empty "LISTMONK_DB_PASSWORD" "$lm_db_pass" && lm_changed=true update_env_var_if_empty "LISTMONK_WEB_ADMIN_PASSWORD" "$lm_web_pass" && lm_changed=true if update_env_var_if_empty "LISTMONK_API_TOKEN" "$lm_api_token"; then update_env_var "LISTMONK_ADMIN_PASSWORD" "$lm_api_token" lm_changed=true fi if [[ "$lm_changed" == "true" ]]; then success "Listmonk passwords + API token" ((generated+=3)) else info "Listmonk passwords + API token (kept existing)" ((kept+=3)) fi # NocoDB local nc_pass nc_pass=$(generate_password 20) if update_env_var_if_empty "NC_ADMIN_PASSWORD" "$nc_pass"; then success "NocoDB admin password" ((generated++)) else info "NocoDB admin password (kept existing)" ((kept++)) fi # Gitea local gitea_db gitea_root gitea_db=$(generate_password 20) gitea_root=$(generate_password 20) local gitea_changed=false update_env_var_if_empty "GITEA_DB_PASSWD" "$gitea_db" && gitea_changed=true update_env_var_if_empty "GITEA_DB_ROOT_PASSWORD" "$gitea_root" && gitea_changed=true if [[ "$gitea_changed" == "true" ]]; then success "Gitea database passwords" ((generated+=2)) else info "Gitea database passwords (kept existing)" ((kept+=2)) fi # n8n local n8n_enc n8n_pass n8n_enc=$(generate_password 32) n8n_pass=$(generate_password 20) local n8n_changed=false update_env_var_if_empty "N8N_ENCRYPTION_KEY" "$n8n_enc" && n8n_changed=true update_env_var_if_empty "N8N_USER_PASSWORD" "$n8n_pass" && n8n_changed=true if [[ "$n8n_changed" == "true" ]]; then success "n8n encryption key + admin password" ((generated+=2)) else info "n8n encryption key + admin password (kept existing)" ((kept+=2)) fi # Monitoring local grafana_pass gotify_pass grafana_pass=$(generate_password 20) gotify_pass=$(generate_password 20) local mon_changed=false update_env_var_if_empty "GRAFANA_ADMIN_PASSWORD" "$grafana_pass" && mon_changed=true update_env_var_if_empty "GOTIFY_ADMIN_PASSWORD" "$gotify_pass" && mon_changed=true if [[ "$mon_changed" == "true" ]]; then success "Grafana + Gotify admin passwords" ((generated+=2)) else info "Grafana + Gotify admin passwords (kept existing)" ((kept+=2)) fi # Vaultwarden local vw_admin_token vw_admin_token=$(generate_secret) if update_env_var_if_empty "VAULTWARDEN_ADMIN_TOKEN" "$vw_admin_token"; then success "Vaultwarden admin token" ((generated++)) else info "Vaultwarden admin token (kept existing)" ((kept++)) fi # Rocket.Chat local rc_pass rc_pass=$(generate_password 20) if update_env_var_if_empty "ROCKETCHAT_ADMIN_PASSWORD" "$rc_pass"; then success "Rocket.Chat admin password" ((generated++)) else info "Rocket.Chat admin password (kept existing)" ((kept++)) fi # MongoDB (required for Rocket.Chat — runs with --auth) local mongo_pass mongo_pass=$(generate_password 24) if update_env_var_if_empty "MONGO_ROOT_PASSWORD" "$mongo_pass"; then success "MongoDB root password" ((generated++)) else info "MongoDB root password (kept existing)" ((kept++)) fi # Gancio local gancio_pass gancio_pass=$(generate_password 20) if update_env_var_if_empty "GANCIO_ADMIN_PASSWORD" "$gancio_pass"; then success "Gancio admin password" ((generated++)) else info "Gancio admin password (kept existing)" ((kept++)) fi # Jitsi Meet local jitsi_secret jitsi_jicofo jitsi_jvb jitsi_secret=$(generate_secret) jitsi_jicofo=$(openssl rand -hex 16) jitsi_jvb=$(openssl rand -hex 16) local jitsi_changed=false update_env_var_if_empty "JITSI_APP_SECRET" "$jitsi_secret" && jitsi_changed=true update_env_var_if_empty "JITSI_JICOFO_AUTH_PASSWORD" "$jitsi_jicofo" && jitsi_changed=true update_env_var_if_empty "JITSI_JVB_AUTH_PASSWORD" "$jitsi_jvb" && jitsi_changed=true if [[ "$jitsi_changed" == "true" ]]; then success "Jitsi Meet secrets (JWT + XMPP)" ((generated+=3)) else info "Jitsi Meet secrets (kept existing)" ((kept+=3)) fi echo "" if [[ $kept -gt 0 ]]; then success "Secrets: ${generated} generated, ${kept} preserved from existing .env" else success "All 22 secrets generated. No placeholder passwords remain." fi } configure_smtp() { header "Email Configuration" if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ -n "$NI_SMTP_HOST" ]]; then update_env_var "SMTP_HOST" "$NI_SMTP_HOST" update_env_var "SMTP_PORT" "${NI_SMTP_PORT:-587}" update_env_var "SMTP_USER" "$NI_SMTP_USER" update_env_var "SMTP_PASS" "$NI_SMTP_PASS" update_env_var "EMAIL_TEST_MODE" "false" update_env_var "VAULTWARDEN_SMTP_SECURITY" "starttls" # Also configure Listmonk SMTP update_env_var "LISTMONK_SMTP_HOST" "$NI_SMTP_HOST" update_env_var "LISTMONK_SMTP_PORT" "${NI_SMTP_PORT:-587}" update_env_var "LISTMONK_SMTP_USER" "$NI_SMTP_USER" update_env_var "LISTMONK_SMTP_PASSWORD" "$NI_SMTP_PASS" update_env_var "LISTMONK_SMTP_TLS_TYPE" "STARTTLS" success "Production SMTP configured ($NI_SMTP_HOST)" SMTP_MODE="production" else update_env_var "VAULTWARDEN_SMTP_SECURITY" "off" info "Using MailHog for email (configure SMTP later via .env)" SMTP_MODE="mailhog" fi return fi info "By default, emails are captured by MailHog (test mode)." info "You can configure a production SMTP server now or later." echo "" if prompt_yes_no "Configure production SMTP now?"; then read -rp " SMTP host (e.g., smtp.protonmail.ch): " smtp_host read -rp " SMTP port (e.g., 587): " smtp_port read -rp " SMTP user: " smtp_user read -rsp " SMTP password: " smtp_pass echo "" update_env_var "SMTP_HOST" "$smtp_host" update_env_var "SMTP_PORT" "$smtp_port" update_env_var "SMTP_USER" "$smtp_user" update_env_var "SMTP_PASS" "$smtp_pass" update_env_var "EMAIL_TEST_MODE" "false" if prompt_yes_no " Also use this SMTP for Listmonk newsletters?"; then update_env_var "LISTMONK_SMTP_HOST" "$smtp_host" update_env_var "LISTMONK_SMTP_PORT" "$smtp_port" update_env_var "LISTMONK_SMTP_USER" "$smtp_user" update_env_var "LISTMONK_SMTP_PASSWORD" "$smtp_pass" update_env_var "LISTMONK_SMTP_TLS_TYPE" "STARTTLS" success "Listmonk SMTP configured" fi # Vaultwarden needs matching SMTP security update_env_var "VAULTWARDEN_SMTP_SECURITY" "starttls" success "Production SMTP configured" SMTP_MODE="production" else info "Using MailHog for email testing (port 8025)" update_env_var "VAULTWARDEN_SMTP_SECURITY" "off" SMTP_MODE="mailhog" fi } configure_features() { header "Feature Flags" if prompt_yes_no "Enable Media Manager (video library)?"; then update_env_var "ENABLE_MEDIA_FEATURES" "true" success "Media Manager enabled" MEDIA_ENABLED="yes" else MEDIA_ENABLED="no" fi if prompt_yes_no "Enable Listmonk newsletter sync?"; then update_env_var "LISTMONK_SYNC_ENABLED" "true" success "Listmonk sync enabled" LISTMONK_SYNC="yes" else LISTMONK_SYNC="no" fi if prompt_yes_no "Enable Payments (Stripe)?"; then update_env_var "ENABLE_PAYMENTS" "true" success "Payments enabled" PAYMENTS_ENABLED="yes" else PAYMENTS_ENABLED="no" fi if prompt_yes_no "Enable Rocket.Chat (team chat)?"; then update_env_var "ENABLE_CHAT" "true" success "Rocket.Chat enabled" CHAT_ENABLED="yes" else CHAT_ENABLED="no" fi if prompt_yes_no "Enable Gancio event sync (shift → event)?"; then update_env_var "GANCIO_SYNC_ENABLED" "true" success "Gancio sync enabled" GANCIO_SYNC="yes" else GANCIO_SYNC="no" fi if prompt_yes_no "Enable Jitsi Meet (video conferencing)?"; then update_env_var "ENABLE_MEET" "true" success "Jitsi Meet enabled" MEET_ENABLED="yes" if [[ "$NON_INTERACTIVE" == "false" ]]; then echo "" info "Jitsi requires your server's public IP for media traffic (NAT traversal)." info "Firewall must allow UDP port 10000 for video/audio." read -rp " Server public IP [leave blank to set later]: " jvb_ip if [[ -n "$jvb_ip" ]]; then update_env_var "JVB_ADVERTISE_IP" "$jvb_ip" success "JVB advertise IP set to $jvb_ip" else warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers." fi else # Non-interactive: auto-detect public IP for NAT traversal local detected_ip detected_ip=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || true) if [[ -n "$detected_ip" ]]; then update_env_var "JVB_ADVERTISE_IP" "$detected_ip" success "JVB advertise IP auto-detected: $detected_ip" else warn "Could not auto-detect public IP. Set JVB_ADVERTISE_IP in .env before starting Jitsi." fi fi else MEET_ENABLED="no" fi if prompt_yes_no "Enable SMS Campaigns (Termux Android bridge)?"; then update_env_var "ENABLE_SMS" "true" success "SMS Campaigns enabled" SMS_ENABLED="yes" if [[ "$NON_INTERACTIVE" == "false" ]]; then echo "" info "SMS uses a Termux-based Android phone as the sending device." read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url termux_url=${termux_url:-http://10.0.0.193:5001} update_env_var "TERMUX_API_URL" "$termux_url" read -rp " Termux API Key [leave blank to set later]: " termux_key if [[ -n "$termux_key" ]]; then update_env_var "TERMUX_API_KEY" "$termux_key" fi fi else SMS_ENABLED="no" fi if prompt_yes_no "Enable Social Connections (friendships, challenges, spotlights)?"; then update_env_var "ENABLE_SOCIAL" "true" success "Social Connections enabled" else update_env_var "ENABLE_SOCIAL" "false" fi if prompt_yes_no "Enable People CRM (unified contact management)?"; then update_env_var "ENABLE_PEOPLE" "true" success "People CRM enabled" else update_env_var "ENABLE_PEOPLE" "false" fi if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then update_env_var "GITEA_COMMENTS_ENABLED" "true" success "Docs Comments & Version History enabled" DOCS_COMMENTS_ENABLED="yes" if [[ "$NON_INTERACTIVE" == "false" ]]; then echo "" info "Gitea will be auto-initialized on first boot (no manual install wizard)." info "The admin user and docs comment system will be configured automatically." info "Provide a password for the Gitea admin account:" echo "" read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw echo "" if [[ -n "$gitea_admin_pw" ]]; then update_env_var "GITEA_ADMIN_USER" "admin" update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw" update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin" success "Gitea admin user + docs comment auto-setup configured" else info "No password provided. Run Gitea Setup from the admin GUI after first start." fi else # Non-interactive: reuse admin password for Gitea local gitea_pw="${NI_ADMIN_PASSWORD:-}" if [[ -n "$gitea_pw" ]]; then update_env_var "GITEA_ADMIN_USER" "admin" update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_pw" update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin" fi fi else DOCS_COMMENTS_ENABLED="no" fi if prompt_yes_no "Enable Monitoring stack (Prometheus, Grafana, Alertmanager, cAdvisor)?" "y"; then local existing_profiles existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "") if [[ -z "$existing_profiles" ]]; then update_env_var "COMPOSE_PROFILES" "monitoring" elif [[ "$existing_profiles" != *"monitoring"* ]]; then update_env_var "COMPOSE_PROFILES" "${existing_profiles},monitoring" fi success "Monitoring enabled (COMPOSE_PROFILES includes monitoring)" MONITORING_ENABLED="yes" else MONITORING_ENABLED="no" fi if prompt_yes_no "Enable Bunker Ops (fleet metrics push to central server)?"; then update_env_var "BUNKER_OPS_ENABLED" "true" success "Bunker Ops enabled" BUNKER_OPS_ENABLED="yes" if [[ "$NON_INTERACTIVE" == "false" ]]; then echo "" read -rp " Instance label [default: domain name]: " instance_label if [[ -n "$instance_label" ]]; then update_env_var "INSTANCE_LABEL" "$instance_label" fi read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url if [[ -n "$remote_write_url" ]]; then update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url" fi fi else BUNKER_OPS_ENABLED="no" fi echo "" if prompt_yes_no "Enable Analytics & GeoIP tracking (visitor geography)?"; then update_env_var "ENABLE_ANALYTICS" "true" success "Analytics enabled" if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ -n "$NI_MAXMIND_ACCOUNT_ID" ]]; then update_env_var "MAXMIND_ACCOUNT_ID" "$NI_MAXMIND_ACCOUNT_ID" update_env_var "MAXMIND_LICENSE_KEY" "$NI_MAXMIND_LICENSE_KEY" success "MaxMind GeoIP credentials configured" fi else echo "" info "GeoIP tracking requires a free MaxMind account." info "Sign up at: https://www.maxmind.com/en/geolite2/signup" read -rp " MaxMind Account ID [leave blank to set later]: " maxmind_id if [[ -n "$maxmind_id" ]]; then update_env_var "MAXMIND_ACCOUNT_ID" "$maxmind_id" read -rp " MaxMind License Key: " maxmind_key if [[ -n "$maxmind_key" ]]; then update_env_var "MAXMIND_LICENSE_KEY" "$maxmind_key" success "MaxMind GeoIP credentials configured" fi else info "Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env to enable geo tracking." fi fi else info "Analytics disabled (can enable later in admin Settings)" fi # Mapbox API key (used for map features) if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ -n "$NI_MAPBOX_KEY" ]]; then update_env_var "MAPBOX_API_KEY" "$NI_MAPBOX_KEY" success "Mapbox API key configured" fi else echo "" read -rp " Mapbox API key [leave blank to set later]: " mapbox_key if [[ -n "$mapbox_key" ]]; then update_env_var "MAPBOX_API_KEY" "$mapbox_key" success "Mapbox API key configured" fi fi } configure_pangolin() { header "Tunnel Configuration (Pangolin)" if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ -n "$NI_PANGOLIN_API_KEY" ]]; then local pang_url="${NI_PANGOLIN_API_URL:-https://api.bnkserve.org/v1}" local pang_key="$NI_PANGOLIN_API_KEY" local pang_org="$NI_PANGOLIN_ORG_ID" local pang_endpoint="${NI_PANGOLIN_ENDPOINT:-https://pangolin.bnkserve.org}" # Smoke-test credentials before committing to .env. Catches typos, # revoked keys, or wrong org IDs while recovery is still cheap. if [[ "$SKIP_PANGOLIN_CHECK" != "true" ]] && command -v curl &>/dev/null; then info "Verifying Pangolin credentials..." local smoke_status smoke_status=$(curl -s -o /dev/null -w "%{http_code}" -m 10 \ -H "Authorization: Bearer $pang_key" \ "$pang_url/org/$pang_org/resources" 2>/dev/null) || smoke_status="000" if [[ "$smoke_status" != "200" ]]; then error "Pangolin credentials rejected (HTTP $smoke_status)." error " Check --pangolin-api-url, --pangolin-api-key, --pangolin-org-id." error " URL tested: $pang_url/org/$pang_org/resources" error " Pass --skip-pangolin-check to bypass (not recommended)." exit 1 fi success "Pangolin credentials verified" fi update_env_var "PANGOLIN_API_URL" "$pang_url" update_env_var "PANGOLIN_API_KEY" "$pang_key" update_env_var "PANGOLIN_ORG_ID" "$pang_org" update_env_var "PANGOLIN_ENDPOINT" "$pang_endpoint" success "Pangolin API credentials saved" if command -v curl &>/dev/null && command -v jq &>/dev/null; then case "${NI_PANGOLIN_SITE:-}" in new) pangolin_create_site "$pang_url" "$pang_key" "$pang_org" "$pang_endpoint" ;; existing) # Connect to the first available site pangolin_connect_first_site "$pang_url" "$pang_key" "$pang_org" "$pang_endpoint" ;; esac fi PANGOLIN_CONFIGURED="yes" else info "Skipping Pangolin setup (no --pangolin-api-key provided)" PANGOLIN_CONFIGURED="no" fi return fi info "Pangolin provides secure public access to your services." info "Skip this if you'll configure tunneling later." echo "" if ! prompt_yes_no "Configure Pangolin tunnel now?"; then PANGOLIN_CONFIGURED="no" return fi read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url pang_url=${pang_url:-https://api.bnkserve.org/v1} read -rp " Pangolin API key: " pang_key read -rp " Pangolin Organization ID: " pang_org # The Pangolin endpoint (dashboard/Newt WebSocket URL) may differ from the API URL. # For example: API at api.example.org vs dashboard at pangolin.example.org read -rp " Pangolin Endpoint (dashboard/Newt URL) [default: https://pangolin.bnkserve.org]: " pang_endpoint pang_endpoint=${pang_endpoint:-https://pangolin.bnkserve.org} update_env_var "PANGOLIN_API_URL" "$pang_url" update_env_var "PANGOLIN_API_KEY" "$pang_key" update_env_var "PANGOLIN_ORG_ID" "$pang_org" update_env_var "PANGOLIN_ENDPOINT" "$pang_endpoint" success "Pangolin API credentials saved" # Check if curl and jq are available for site setup if ! command -v curl &>/dev/null || ! command -v jq &>/dev/null; then warn "curl or jq not found — skipping site setup." info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." PANGOLIN_CONFIGURED="yes" return fi # Verify API connectivity info "Verifying Pangolin API connectivity..." local health_status health_status=$(curl -s -o /dev/null -w "%{http_code}" -m 10 \ -H "Authorization: Bearer $pang_key" \ "$pang_url/org/$pang_org/sites" 2>/dev/null) || true if [[ "$health_status" != "200" ]]; then warn "Could not reach Pangolin API (HTTP $health_status)." info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." PANGOLIN_CONFIGURED="yes" return fi success "Pangolin API reachable" echo "" # Offer site setup options info "Set up a tunnel site now?" echo -e " ${BOLD}1)${NC} Create a new site" echo -e " ${BOLD}2)${NC} Connect to an existing site" echo -e " ${BOLD}3)${NC} Skip (configure later in admin GUI)" echo "" local site_choice read -rp " Choose [1-3, default: 3]: " site_choice site_choice=${site_choice:-3} case "$site_choice" in 1) pangolin_create_site "$pang_url" "$pang_key" "$pang_org" "$pang_endpoint" ;; 2) pangolin_connect_site "$pang_url" "$pang_key" "$pang_org" "$pang_endpoint" ;; *) info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." ;; esac PANGOLIN_CONFIGURED="yes" } # Helper: Call Pangolin API and extract data from response envelope pangolin_api() { local method=$1 url=$2 api_key=$3 shift 3 local body_args=() if [[ $# -gt 0 ]]; then body_args=(-d "$1") fi curl -s -m 15 -X "$method" "$url" \ -H "Authorization: Bearer $api_key" \ -H "Content-Type: application/json" \ "${body_args[@]}" 2>/dev/null } pangolin_create_resources() { local api_url=$1 api_key=$2 org_id=$3 site_id=$4 domain=$5 info "Creating Pangolin resources and targets for $domain..." # Look up the domain ID from registered domains local domains_resp domain_id domains_resp=$(pangolin_api GET "$api_url/org/$org_id/domains" "$api_key") domain_id=$(echo "$domains_resp" | jq -r --arg d "$domain" \ '.data.domains[]? | select(.baseDomain == $d) | .domainId' 2>/dev/null) if [[ -z "$domain_id" ]]; then warn "Domain '$domain' not found in Pangolin. Register it first in the dashboard." info "After registering the domain, run the sync from the admin GUI at /app/pangolin." return fi success "Found domain: $domain (ID: $domain_id)" # Resource definitions: subdomain|name # Empty subdomain = root domain local -a resource_defs=( "app|Admin GUI" "api|API Server" "|Public Site" "db|NocoDB" "docs|Documentation" "code|Code Server" "n8n|Workflows" "git|Gitea" "home|Homepage" "listmonk|Newsletter" "qr|Mini QR" "draw|Excalidraw" "vault|Vaultwarden" "mail|MailHog" "chat|Rocket.Chat" "events|Gancio Events" "meet|Jitsi Meet" "grafana|Grafana" ) local created=0 skipped=0 failed=0 for def in "${resource_defs[@]}"; do local subdomain="${def%%|*}" local name="${def#*|}" local full_domain if [[ -n "$subdomain" ]]; then full_domain="$subdomain.$domain" else full_domain="$domain" fi # Build create payload — omit subdomain entirely for root domain (Pangolin rejects empty string) local create_payload if [[ -n "$subdomain" ]]; then create_payload=$(jq -n \ --arg name "$name" \ --arg domainId "$domain_id" \ --arg subdomain "$subdomain" \ '{name: $name, domainId: $domainId, subdomain: $subdomain, http: true, protocol: "tcp"}') else create_payload=$(jq -n \ --arg name "$name" \ --arg domainId "$domain_id" \ '{name: $name, domainId: $domainId, http: true, protocol: "tcp"}') fi # Create the resource local res_resp res_resp=$(pangolin_api PUT "$api_url/org/$org_id/resource" "$api_key" "$create_payload") local resource_id resource_id=$(echo "$res_resp" | jq -r '.data.resourceId // empty' 2>/dev/null) if [[ -z "$resource_id" ]]; then local err_msg err_msg=$(echo "$res_resp" | jq -r '.message // "unknown error"' 2>/dev/null) if echo "$err_msg" | grep -qi "already exists\|duplicate\|conflict"; then skipped=$((skipped + 1)) else warn " Failed to create $full_domain: $err_msg" failed=$((failed + 1)) fi continue fi # Create target pointing to nginx:80 local target_payload target_payload=$(jq -n \ --argjson siteId "$site_id" \ '{siteId: $siteId, ip: "nginx", port: 80, method: "http", enabled: true}') pangolin_api PUT "$api_url/resource/$resource_id/target" "$api_key" "$target_payload" >/dev/null # Set resource as public (no SSO, no access block) pangolin_api POST "$api_url/resource/$resource_id" "$api_key" \ '{"sso":false,"blockAccess":false}' >/dev/null created=$((created + 1)) done if [[ $created -gt 0 ]]; then success "Created $created resources with targets → nginx:80" fi if [[ $skipped -gt 0 ]]; then info "$skipped resources already existed (skipped)" fi if [[ $failed -gt 0 ]]; then warn "$failed resources failed to create" fi } pangolin_create_site() { local api_url=$1 api_key=$2 org_id=$3 endpoint=$4 local domain="${CONFIGURED_DOMAIN:-cmlite.org}" local site_name if [[ "$NON_INTERACTIVE" == "true" ]]; then site_name="changemaker-$domain" else read -rp " Site name [default: changemaker-$domain]: " site_name site_name=${site_name:-changemaker-$domain} fi info "Fetching Newt credentials..." local defaults_resp defaults_resp=$(pangolin_api GET "$api_url/org/$org_id/pick-site-defaults" "$api_key") local newt_id newt_secret newt_id=$(echo "$defaults_resp" | jq -r '.data.newtId // .newtId // empty' 2>/dev/null) newt_secret=$(echo "$defaults_resp" | jq -r '.data.newtSecret // .newtSecret // .data.secret // .secret // empty' 2>/dev/null) if [[ -z "$newt_id" || -z "$newt_secret" ]]; then warn "Could not fetch Newt credentials from pickSiteDefaults." info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." return fi success "Got Newt credentials (newtId: $newt_id)" info "Creating site \"$site_name\"..." local create_payload # Note: omit 'address' — Pangolin auto-assigns it; clientAddress from # pickSiteDefaults lacks CIDR notation and gets rejected. create_payload=$(jq -n \ --arg name "$site_name" \ --arg newtId "$newt_id" \ --arg secret "$newt_secret" \ '{name: $name, type: "newt", newtId: $newtId, secret: $secret}') local site_resp site_resp=$(pangolin_api PUT "$api_url/org/$org_id/site" "$api_key" "$create_payload") local site_id site_id=$(echo "$site_resp" | jq -r '.data.siteId // .siteId // empty' 2>/dev/null) if [[ -z "$site_id" ]]; then local err_msg err_msg=$(echo "$site_resp" | jq -r '.message // .error // "Unknown error"' 2>/dev/null) warn "Site creation failed: $err_msg" info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services." return fi success "Site created: $site_id ($site_name)" # Write credentials to .env update_env_var "PANGOLIN_SITE_ID" "$site_id" update_env_var "PANGOLIN_NEWT_ID" "$newt_id" update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret" success "Tunnel credentials written to .env" # Create resources and targets pangolin_create_resources "$api_url" "$api_key" "$org_id" "$site_id" "$domain" } pangolin_connect_site() { local api_url=$1 api_key=$2 org_id=$3 endpoint=$4 info "Fetching sites from organization..." local sites_resp sites_resp=$(pangolin_api GET "$api_url/org/$org_id/sites" "$api_key") # Extract sites array (handle Pangolin response envelope) local sites_json sites_json=$(echo "$sites_resp" | jq '.data.sites // .data // .sites // []' 2>/dev/null) local site_count site_count=$(echo "$sites_json" | jq 'length' 2>/dev/null) if [[ -z "$site_count" || "$site_count" -eq 0 ]]; then warn "No sites found in this organization." info "Use option 1 to create a new site, or set up via the admin GUI." return fi echo "" echo -e " ${BOLD}Available sites:${NC}" echo "" local i=1 while IFS= read -r line; do local name site_id online last_seen name=$(echo "$line" | jq -r '.name') site_id=$(echo "$line" | jq -r '.siteId') online=$(echo "$line" | jq -r '.online // false') last_seen=$(echo "$line" | jq -r '.lastSeen // "never"') local status_tag if [[ "$online" == "true" ]]; then status_tag="${GREEN}online${NC}" else status_tag="${DIM}offline${NC}" fi echo -e " ${BOLD}$i)${NC} $name ${DIM}(ID: $site_id)${NC} — $status_tag" i=$((i + 1)) done < <(echo "$sites_json" | jq -c '.[]') echo "" local choice read -rp " Select site [1-$site_count]: " choice if [[ -z "$choice" || "$choice" -lt 1 || "$choice" -gt "$site_count" ]] 2>/dev/null; then warn "Invalid selection." return fi local selected selected=$(echo "$sites_json" | jq -c ".[$((choice - 1))]") local sel_id sel_name sel_id=$(echo "$selected" | jq -r '.siteId') sel_name=$(echo "$selected" | jq -r '.name') # Write site ID to .env update_env_var "PANGOLIN_SITE_ID" "$sel_id" success "Connected to site: $sel_name (ID: $sel_id)" # Fetch Newt credentials for this site from the API local existing_newt_id existing_newt_id=$(grep "^PANGOLIN_NEWT_ID=" "$ENV_FILE" 2>/dev/null | cut -d= -f2-) if [[ -z "$existing_newt_id" ]]; then info "Fetching Newt credentials for this site..." # Try pickSiteDefaults for fresh Newt creds local defaults_resp defaults_resp=$(pangolin_api GET "$api_url/org/$org_id/pick-site-defaults" "$api_key") local newt_id newt_secret newt_id=$(echo "$defaults_resp" | jq -r '.data.newtId // .newtId // empty' 2>/dev/null) newt_secret=$(echo "$defaults_resp" | jq -r '.data.newtSecret // .newtSecret // .data.secret // .secret // empty' 2>/dev/null) if [[ -n "$newt_id" && -n "$newt_secret" ]]; then update_env_var "PANGOLIN_NEWT_ID" "$newt_id" update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret" success "Newt credentials fetched and saved (newtId: $newt_id)" else info "Could not auto-fetch Newt credentials." info "Enter them manually (from your Pangolin dashboard)." echo "" read -rp " Newt ID (leave blank to skip): " newt_id_input if [[ -n "$newt_id_input" ]]; then read -rp " Newt Secret: " newt_secret_input if [[ -n "$newt_secret_input" ]]; then update_env_var "PANGOLIN_NEWT_ID" "$newt_id_input" update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret_input" success "Newt credentials written to .env" fi fi fi else info "Existing Newt credentials found in .env (newtId: $existing_newt_id)" fi # Create resources and targets local domain="${CONFIGURED_DOMAIN:-cmlite.org}" pangolin_create_resources "$api_url" "$api_key" "$org_id" "$sel_id" "$domain" } # Non-interactive helper: connect to the first available site automatically pangolin_connect_first_site() { local api_url=$1 api_key=$2 org_id=$3 endpoint=$4 local sites_resp sites_resp=$(pangolin_api GET "$api_url/org/$org_id/sites" "$api_key") local first_site first_site=$(echo "$sites_resp" | jq -c '.data.sites[0] // empty' 2>/dev/null) if [[ -z "$first_site" || "$first_site" == "null" ]]; then warn "No existing sites found — cannot auto-connect." return fi local sel_id sel_name sel_id=$(echo "$first_site" | jq -r '.siteId') sel_name=$(echo "$first_site" | jq -r '.name') update_env_var "PANGOLIN_SITE_ID" "$sel_id" success "Connected to site: $sel_name (ID: $sel_id)" # Fetch Newt credentials local defaults_resp defaults_resp=$(pangolin_api GET "$api_url/org/$org_id/pick-site-defaults" "$api_key") local newt_id newt_secret newt_id=$(echo "$defaults_resp" | jq -r '.data.newtId // empty' 2>/dev/null) newt_secret=$(echo "$defaults_resp" | jq -r '.data.newtSecret // empty' 2>/dev/null) if [[ -n "$newt_id" && -n "$newt_secret" ]]; then update_env_var "PANGOLIN_NEWT_ID" "$newt_id" update_env_var "PANGOLIN_NEWT_SECRET" "$newt_secret" success "Newt credentials saved (newtId: $newt_id)" fi # Create resources local domain="${CONFIGURED_DOMAIN:-cmlite.org}" pangolin_create_resources "$api_url" "$api_key" "$org_id" "$sel_id" "$domain" } configure_control_panel() { header "Control Panel Registration" # Non-interactive: use --ccp-* flags if all three provided, otherwise skip if [[ "$NON_INTERACTIVE" == "true" ]]; then if [[ -n "$NI_CCP_URL" && -n "$NI_CCP_INVITE_CODE" && -n "$NI_CCP_AGENT_URL" ]]; then update_env_var "ENABLE_CCP_AGENT" "true" update_env_var "CCP_URL" "$NI_CCP_URL" update_env_var "CCP_INVITE_CODE" "$NI_CCP_INVITE_CODE" update_env_var "CCP_AGENT_URL" "$NI_CCP_AGENT_URL" # Append ccp-agent to existing profiles (don't clobber monitoring) local existing_profiles existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "") if [[ -z "$existing_profiles" ]]; then update_env_var "COMPOSE_PROFILES" "ccp-agent" elif [[ "$existing_profiles" != *"ccp-agent"* ]]; then update_env_var "COMPOSE_PROFILES" "${existing_profiles},ccp-agent" fi success "CCP registration configured ($NI_CCP_URL)" else update_env_var "ENABLE_CCP_AGENT" "false" if [[ -n "$NI_CCP_URL" || -n "$NI_CCP_INVITE_CODE" || -n "$NI_CCP_AGENT_URL" ]]; then warn "CCP registration needs all 3 flags: --ccp-url, --ccp-invite-code, --ccp-agent-url" else info "Skipping CCP registration (no --ccp-url provided)" fi fi return fi if prompt_yes_no "Register this instance with a Changemaker Control Panel?"; then echo "" read -rp " Enter Control Panel URL (e.g., https://ccp.example.com): " ccp_url read -rp " Enter invite code: " invite_code read -rp " Agent URL (how the CCP reaches this host, e.g., https://this-host:7443): " agent_url update_env_var "ENABLE_CCP_AGENT" "true" update_env_var "CCP_URL" "$ccp_url" update_env_var "CCP_INVITE_CODE" "$invite_code" update_env_var "CCP_AGENT_URL" "$agent_url" # Add ccp-agent to compose profiles local existing_profiles existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "") if [[ -n "$existing_profiles" ]]; then update_env_var "COMPOSE_PROFILES" "${existing_profiles},ccp-agent" else update_env_var "COMPOSE_PROFILES" "ccp-agent" fi success "Control Panel registration configured — agent will phone home on startup" else update_env_var "ENABLE_CCP_AGENT" "false" fi } configure_cors() { local domain="${CONFIGURED_DOMAIN:-cmlite.org}" # Include app subdomain + root domain (for MkDocs payment widgets) + localhost fallbacks local origins="http://app.$domain,https://app.$domain,http://$domain,https://$domain,http://localhost:3000,http://localhost,http://localhost:4003" update_env_var "CORS_ORIGINS" "$origins" success "CORS origins set for $domain" } generate_nginx_configs() { header "Nginx Configuration" local domain="${CONFIGURED_DOMAIN:-cmlite.org}" local template_dir="$SCRIPT_DIR/nginx/conf.d" local templates_found=0 for template in "$template_dir"/*.conf.template; do [[ -f "$template" ]] || continue templates_found=$((templates_found + 1)) local conf_file="${template%.template}" local conf_name conf_name=$(basename "$conf_file") # Substitute ${DOMAIN} while preserving nginx variables ($host, $scheme, etc.) sed "s/\${DOMAIN}/$domain/g" "$template" > "$conf_file" success "Generated $conf_name" done if [[ $templates_found -eq 0 ]]; then warn "No nginx .conf.template files found — skipping" else success "Generated $templates_found nginx configs for domain: $domain" info "Restart nginx to apply: docker compose restart nginx" fi } # ============================================================================= # Homepage services.yaml # ============================================================================= # Read a variable from .env with fallback default read_env_var() { local key=$1 default=$2 local val val=$(grep "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-) echo "${val:-$default}" } generate_services_yaml() { local domain="${CONFIGURED_DOMAIN:-cmlite.org}" # Read embed proxy ports from .env (supports multi-instance port customization) local rc_port; rc_port=$(read_env_var ROCKETCHAT_EMBED_PORT 8891) local jitsi_port; jitsi_port=$(read_env_var JITSI_EMBED_PORT 8893) mkdir -p "$(dirname "$SERVICES_YAML")" cat > "$SERVICES_YAML" << YAML --- # Homepage Services Configuration — Generated by config.sh # Production tab: public URLs via $domain # Local tab: localhost URLs with ports - Production - Core: - Admin GUI: icon: mdi-view-dashboard href: "https://app.$domain" description: Application dashboard and public pages container: changemaker-v2-admin - API: icon: mdi-api href: "https://api.$domain" description: V2 REST API container: changemaker-v2-api - Media API: icon: mdi-video href: "https://media.$domain" description: Video library and streaming container: changemaker-media-api - Main Site: icon: mdi-web href: "https://$domain" description: Documentation and marketing site container: mkdocs-site-server-changemaker - Production - Tools: - Code Server: icon: mdi-code-braces href: "https://code.$domain" description: VS Code in the browser container: code-server-changemaker - NocoDB: icon: mdi-database href: "https://db.$domain" description: Database browser (read-only) container: changemaker-v2-nocodb - MkDocs (Live): icon: mdi-book-open-page-variant href: "https://docs.$domain" description: Live documentation with hot reload container: mkdocs-changemaker - Mini QR: icon: mdi-qrcode href: "https://qr.$domain" description: QR code generator container: mini-qr - Excalidraw: icon: mdi-draw href: "https://draw.$domain" description: Collaborative whiteboard container: excalidraw-changemaker - Vaultwarden: icon: mdi-lock href: "https://vault.$domain" description: Password manager (Bitwarden-compatible) container: vaultwarden-changemaker - Gancio: icon: mdi-calendar-multiple href: "https://events.$domain" description: Event management platform container: gancio-changemaker - Rocket.Chat: icon: mdi-chat href: "https://chat.$domain" description: Team communication platform container: rocketchat-changemaker - Jitsi Meet: icon: mdi-video href: "https://meet.$domain" description: Video conferencing container: jitsi-web-changemaker - Production - Integrations: - Listmonk: icon: mdi-email-newsletter href: "https://listmonk.$domain" description: Newsletter and mailing list manager container: listmonk-app - MailHog: icon: mdi-email-check href: "https://mail.$domain" description: Email capture for testing container: mailhog-changemaker - n8n: icon: mdi-robot-industrial href: "https://n8n.$domain" description: Workflow automation platform container: n8n-changemaker - Gitea: icon: mdi-git href: "https://git.$domain" description: Git repository hosting container: gitea-changemaker - Production - Infrastructure: - Nginx: icon: mdi-web-box href: "#" description: Reverse proxy (subdomain routing) container: changemaker-v2-nginx - PostgreSQL: icon: mdi-database-outline href: "#" description: Primary database (V2) container: changemaker-v2-postgres - Redis: icon: mdi-database-sync href: "#" description: Cache, rate limiting, job queues container: redis-changemaker - Production - Monitoring: - Grafana: icon: mdi-chart-box href: "https://grafana.$domain" description: Monitoring dashboards container: grafana-changemaker - Prometheus: icon: mdi-chart-line href: "https://prometheus.$domain" description: Metrics collection container: prometheus-changemaker - Alertmanager: icon: mdi-bell-alert href: "https://alertmanager.$domain" description: Alert routing container: alertmanager-changemaker - Gotify: icon: mdi-cellphone-message href: "https://gotify.$domain" description: Push notifications container: gotify-changemaker - cAdvisor: icon: mdi-docker href: "https://cadvisor.$domain" description: Container metrics container: cadvisor-changemaker - Node Exporter: icon: mdi-server href: "#" description: Host system metrics container: node-exporter-changemaker - Redis Exporter: icon: mdi-database-export href: "#" description: Redis metrics exporter container: redis-exporter-changemaker # ───────────────────────────────────────────────── # LOCAL DEVELOPMENT # ───────────────────────────────────────────────── - Local - Core: - Admin GUI: icon: mdi-view-dashboard href: "http://localhost:3000" description: Application dashboard (port 3000) container: changemaker-v2-admin - API: icon: mdi-api href: "http://localhost:4000" description: V2 REST API (port 4000) container: changemaker-v2-api - Media API: icon: mdi-video href: "http://localhost:4100" description: Video library API (port 4100) container: changemaker-media-api - Main Site: icon: mdi-web href: "http://localhost:4004" description: Documentation site (port 4004) container: mkdocs-site-server-changemaker - Homepage: icon: mdi-home href: "http://localhost:3010" description: This dashboard (port 3010) container: homepage-changemaker - Local - Tools: - Code Server: icon: mdi-code-braces href: "http://localhost:8888" description: VS Code in the browser (port 8888) container: code-server-changemaker - NocoDB: icon: mdi-database href: "http://localhost:8091" description: Database browser (port 8091) container: changemaker-v2-nocodb - MkDocs (Live): icon: mdi-book-open-page-variant href: "http://localhost:4003" description: Live documentation (port 4003) container: mkdocs-changemaker - Mini QR: icon: mdi-qrcode href: "http://localhost:8089" description: QR code generator (port 8089) container: mini-qr - Excalidraw: icon: mdi-draw href: "http://localhost:8090" description: Collaborative whiteboard (port 8090) container: excalidraw-changemaker - Vaultwarden: icon: mdi-lock href: "http://localhost:8445" description: Password manager (port 8445) container: vaultwarden-changemaker - Gancio: icon: mdi-calendar-multiple href: "http://localhost:8092" description: Event management (port 8092) container: gancio-changemaker - Rocket.Chat: icon: mdi-chat href: "http://localhost:$rc_port" description: Team chat (port $rc_port) container: rocketchat-changemaker - Jitsi Meet: icon: mdi-video href: "http://localhost:$jitsi_port" description: Video conferencing (port $jitsi_port) container: jitsi-web-changemaker - Local - Integrations: - Listmonk: icon: mdi-email-newsletter href: "http://localhost:9001" description: Newsletter manager (port 9001) container: listmonk-app - MailHog: icon: mdi-email-check href: "http://localhost:8025" description: Email capture UI (port 8025) container: mailhog-changemaker - n8n: icon: mdi-robot-industrial href: "http://localhost:5678" description: Workflow automation (port 5678) container: n8n-changemaker - Gitea: icon: mdi-git href: "http://localhost:3030" description: Git repository hosting (port 3030) container: gitea-changemaker - Local - Infrastructure: - Nginx: icon: mdi-web-box href: "#" description: Reverse proxy (port 80) container: changemaker-v2-nginx - PostgreSQL: icon: mdi-database-outline href: "#" description: Primary database (port 5433) container: changemaker-v2-postgres - Redis: icon: mdi-database-sync href: "#" description: Cache and job queues (port 6379) container: redis-changemaker - Local - Monitoring: - Grafana: icon: mdi-chart-box href: "http://localhost:3005" description: Monitoring dashboards (port 3005) container: grafana-changemaker - Prometheus: icon: mdi-chart-line href: "http://localhost:9090" description: Metrics collection (port 9090) container: prometheus-changemaker - Alertmanager: icon: mdi-bell-alert href: "http://localhost:9093" description: Alert routing (port 9093) container: alertmanager-changemaker - Gotify: icon: mdi-cellphone-message href: "http://localhost:8889" description: Push notifications (port 8889) container: gotify-changemaker - cAdvisor: icon: mdi-docker href: "http://localhost:8086" description: Container metrics (port 8086) container: cadvisor-changemaker - Node Exporter: icon: mdi-server href: "http://localhost:9100/metrics" description: Host system metrics (port 9100) container: node-exporter-changemaker - Redis Exporter: icon: mdi-database-export href: "http://localhost:9121/metrics" description: Redis metrics (port 9121) container: redis-exporter-changemaker YAML success "Generated services.yaml for Homepage dashboard" } # ============================================================================= # Documentation Site Reset # ============================================================================= configure_docs_reset() { header "Documentation Site" if [[ "$NON_INTERACTIVE" == "true" ]]; then info "Keeping existing documentation content" return fi if prompt_yes_no "Reset documentation site to baseline? (keeps header & tracking)"; then touch "$SCRIPT_DIR/mkdocs/.reset-docs-on-startup" success "Docs reset scheduled for first startup" info "Custom code (header, analytics, hooks, assets) will be preserved" else info "Keeping existing documentation content" fi } # ============================================================================= # Directory Permissions # ============================================================================= fix_container_permissions() { header "Container Directory Permissions" local -a dirs=( "configs/code-server/.config:Code Server config" "configs/code-server/.local:Code Server local data" "mkdocs:MkDocs root" "mkdocs/docs:MkDocs source docs" "mkdocs/overrides:MkDocs template overrides" "mkdocs/.cache:MkDocs cache" "mkdocs/site:MkDocs built site" "assets/uploads:Listmonk uploads" "assets/images:Shared images" "assets/icons:Homepage icons" "media/local/inbox:Media upload inbox" "media/local/photos:Media photos" "media/local/thumbnails:Video thumbnails" "media/public:Public media files" "local-files:n8n local files" "data:NAR import data" "data/upgrade:Upgrade trigger directory" ) local errors=0 for entry in "${dirs[@]}"; do local dir_path="$SCRIPT_DIR/${entry%%:*}" local dir_desc="${entry#*:}" mkdir -p "$dir_path" if chmod 775 "$dir_path" 2>/dev/null; then success "$dir_desc" else warn "$dir_desc — could not set permissions (may need sudo)" ((errors++)) fi done echo "" if [[ $errors -eq 0 ]]; then success "All directories ready" else warn "Some directories may need: sudo chown -R $(id -u):$(id -g) $SCRIPT_DIR" fi } # ============================================================================= # Upgrade Watcher (systemd) # ============================================================================= install_upgrade_watcher() { header "System Upgrade Watcher" # Ensure upgrade IPC directory exists regardless of install decision mkdir -p "$SCRIPT_DIR/data/upgrade" # Resolve whether to install in non-interactive mode: # --install-watcher => yes # --no-install-watcher => no # --enable-all (no override) => yes (new default) # otherwise => no (preserve legacy behaviour) local should_install="ask" if [[ "$NON_INTERACTIVE" == "true" ]]; then case "$NI_INSTALL_WATCHER" in yes) should_install="yes" ;; no) should_install="no" ;; "") if [[ "$NI_ENABLE_ALL" == "true" ]]; then should_install="yes"; else should_install="no"; fi ;; esac if [[ "$should_install" == "no" ]]; then info "Skipping systemd watcher install (pass --install-watcher or --enable-all to install)" UPGRADE_WATCHER="skipped" return fi fi info "The upgrade watcher lets you trigger upgrades from the admin Settings page." info "It installs a systemd path watcher that monitors for trigger files." echo "" local unit_src="$SCRIPT_DIR/scripts/systemd" if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then warn "Systemd unit templates not found in scripts/systemd/ — skipping" UPGRADE_WATCHER="skipped" return fi if ! command -v systemctl &>/dev/null; then warn "systemctl not found — skipping (not a systemd host?)" UPGRADE_WATCHER="skipped" return fi if [[ "$should_install" == "yes" ]] || prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then # Generate units with correct paths substituted local tmp_path tmp_service tmp_path=$(mktemp) tmp_service=$(mktemp) sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-upgrade.path" > "$tmp_path" sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-upgrade.service" > "$tmp_service" if sudo cp "$tmp_path" /etc/systemd/system/changemaker-upgrade.path \ && sudo cp "$tmp_service" /etc/systemd/system/changemaker-upgrade.service \ && sudo systemctl daemon-reload \ && sudo systemctl enable --now changemaker-upgrade.path; then success "Upgrade watcher installed and enabled" UPGRADE_WATCHER="yes" else warn "Failed to install systemd units (sudo may have failed)" warn "Install manually later:" echo -e " ${CYAN}cd ~/changemaker.lite && sudo ./scripts/systemd/install.sh${NC}" UPGRADE_WATCHER="manual" fi rm -f "$tmp_path" "$tmp_service" else info "Skipped. You can install it later from Settings > System." UPGRADE_WATCHER="skipped" fi } # ============================================================================= # Automated Backups (systemd) # ============================================================================= install_backup_timer() { header "Automated Backups" local should_install="ask" if [[ "$NON_INTERACTIVE" == "true" ]]; then case "$NI_INSTALL_BACKUP" in yes) should_install="yes" ;; no) should_install="no" ;; "") if [[ "$NI_ENABLE_ALL" == "true" ]]; then should_install="yes"; else should_install="no"; fi ;; esac if [[ "$should_install" == "no" ]]; then info "Skipping backup timer install (pass --install-backup-timer or --enable-all to install)" BACKUP_TIMER="skipped" return fi fi info "Daily automated backups protect against data loss." info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention." echo "" local unit_src="$SCRIPT_DIR/scripts/systemd" if [[ ! -f "$unit_src/changemaker-backup.timer" ]] || [[ ! -f "$unit_src/changemaker-backup.service" ]]; then warn "Systemd backup unit templates not found in scripts/systemd/ — skipping" BACKUP_TIMER="skipped" return fi if ! command -v systemctl &>/dev/null; then warn "systemctl not found — skipping (not a systemd host?)" BACKUP_TIMER="skipped" return fi if [[ "$should_install" == "yes" ]] || prompt_yes_no "Install daily automated backups (requires sudo)?" "y"; then local tmp_timer tmp_service tmp_timer=$(mktemp) tmp_service=$(mktemp) sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-backup.timer" > "$tmp_timer" sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-backup.service" > "$tmp_service" if sudo cp "$tmp_timer" /etc/systemd/system/changemaker-backup.timer \ && sudo cp "$tmp_service" /etc/systemd/system/changemaker-backup.service \ && sudo systemctl daemon-reload \ && sudo systemctl enable --now changemaker-backup.timer; then success "Daily backup timer installed and enabled (runs at 02:00)" BACKUP_TIMER="yes" else warn "Failed to install systemd units (sudo may have failed)" warn "Install manually later:" echo -e " ${CYAN}sudo cp scripts/systemd/changemaker-backup.* /etc/systemd/system/${NC}" echo -e " ${CYAN}sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-backup.timer${NC}" BACKUP_TIMER="manual" fi rm -f "$tmp_timer" "$tmp_service" else info "Skipped. Run backups manually: ./scripts/backup.sh" BACKUP_TIMER="skipped" fi } # ============================================================================= # Summary & Next Steps # ============================================================================= print_summary() { header "Configuration Complete" echo -e " ${BOLD}Domain:${NC} ${CONFIGURED_DOMAIN:-cmlite.org}" echo -e " ${BOLD}Admin email:${NC} ${CONFIGURED_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}" echo -e " ${BOLD}Admin password:${NC} [set]" echo -e " ${BOLD}SMTP:${NC} ${SMTP_MODE:-mailhog}" echo -e " ${BOLD}Media Manager:${NC} ${MEDIA_ENABLED:-no}" echo -e " ${BOLD}Payments:${NC} ${PAYMENTS_ENABLED:-no}" echo -e " ${BOLD}Listmonk sync:${NC} ${LISTMONK_SYNC:-no}" echo -e " ${BOLD}Rocket.Chat:${NC} ${CHAT_ENABLED:-no}" echo -e " ${BOLD}Gancio sync:${NC} ${GANCIO_SYNC:-no}" echo -e " ${BOLD}Jitsi Meet:${NC} ${MEET_ENABLED:-no}" echo -e " ${BOLD}SMS Campaigns:${NC} ${SMS_ENABLED:-no}" echo -e " ${BOLD}Monitoring:${NC} ${MONITORING_ENABLED:-no}" echo -e " ${BOLD}Docs Comments:${NC} ${DOCS_COMMENTS_ENABLED:-no}" echo -e " ${BOLD}Bunker Ops:${NC} ${BUNKER_OPS_ENABLED:-no}" echo -e " ${BOLD}Pangolin:${NC} ${PANGOLIN_CONFIGURED:-no}" echo -e " ${BOLD}Upgrade watcher:${NC} ${UPGRADE_WATCHER:-skipped}" echo -e " ${BOLD}Backup timer:${NC} ${BACKUP_TIMER:-skipped}" echo -e " ${BOLD}Secrets:${NC} 22 auto-generated" echo "" echo -e " ${DIM}Config file: $ENV_FILE${NC}" } print_next_steps() { echo "" echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}" echo -e "${BOLD}${BLUE} Next Steps${NC}" echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}" echo "" if [[ "$INSTALL_MODE" == "release" ]]; then # Release mode: simpler instructions (production images, auto-migration) echo -e " ${BOLD}1.${NC} Start all services:" echo -e " ${CYAN}docker compose up -d${NC}" echo "" echo -e " First run pulls ~40 images (~3 min) and stabilizes health in ~90s." echo -e " Brief unhealthy statuses during this window are expected." echo -e " Database migrations and seeding run automatically on startup." echo "" echo -e " ${BOLD}2.${NC} Verify the install:" echo -e " ${CYAN}bash scripts/test-deployment.sh --wait 60${NC}" echo "" echo -e " Checks all containers healthy, API responding, (if domain set) tunnel reachable." echo "" echo -e " ${BOLD}3.${NC} Access the application:" echo -e " Admin GUI: ${CYAN}http://localhost:3000${NC}" echo -e " API: ${CYAN}http://localhost:4000${NC}" echo "" echo -e " ${BOLD}4.${NC} Useful tools:" echo -e " ${CYAN}bash scripts/validate-env.sh${NC} # re-check .env + host ports" echo -e " ${CYAN}bash scripts/pangolin-teardown.sh${NC} # wipe tunnel org before reinstall (dry-run by default)" echo -e " ${CYAN}docker compose ps${NC} # live status" echo -e " ${CYAN}docker compose logs -f api --tail 20${NC}" echo "" else # Source mode: existing instructions echo -e " ${BOLD}1.${NC} Start core services:" echo -e " ${CYAN}docker compose up -d v2-postgres redis api admin${NC}" echo "" echo -e " ${BOLD}2.${NC} Run database setup:" echo -e " ${CYAN}docker compose exec api npx prisma migrate deploy${NC}" echo -e " ${CYAN}docker compose exec api npx prisma db seed${NC}" echo "" echo -e " ${BOLD}3.${NC} Access the application:" echo -e " Admin GUI: ${CYAN}http://localhost:3000${NC}" echo -e " API: ${CYAN}http://localhost:4000${NC}" echo "" echo -e " ${BOLD}4.${NC} Optional — start additional services:" echo -e " ${CYAN}docker compose up -d nginx${NC} # Reverse proxy" echo -e " ${CYAN}docker compose up -d media-api${NC} # Video library" echo -e " ${CYAN}docker compose up -d listmonk-app${NC} # Newsletters" echo -e " ${CYAN}docker compose up -d rocketchat${NC} # Team chat" echo -e " ${CYAN}docker compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb${NC} # Video calls" echo -e " ${CYAN}docker compose up -d homepage${NC} # Service dashboard" echo "" echo -e " ${BOLD}5.${NC} Or start everything at once (monitoring included if enabled above):" echo -e " ${CYAN}docker compose up -d${NC}" echo "" fi echo -e " ${YELLOW}IMPORTANT: Change your admin password after first login!${NC}" echo -e " ${YELLOW}JITSI: Ensure UDP port 10000 is open in your firewall for video/audio.${NC}" echo "" } # ============================================================================= # Main # ============================================================================= main() { print_banner check_prerequisites initialize_env configure_domain configure_admin generate_all_secrets configure_smtp configure_features configure_pangolin configure_control_panel configure_cors generate_nginx_configs generate_services_yaml configure_docs_reset fix_container_permissions install_upgrade_watcher install_backup_timer # Release mode: auto-set production defaults if [[ "$INSTALL_MODE" == "release" ]]; then header "Release Mode Settings" update_env_var "IMAGE_TAG" "latest" update_env_var "NODE_ENV" "production" # Ensure monitoring is included if user opted in (preserve existing profiles) if [[ "${MONITORING_ENABLED:-no}" == "yes" ]]; then local existing_profiles existing_profiles=$(grep -oP 'COMPOSE_PROFILES=\K.*' "$ENV_FILE" 2>/dev/null || echo "") if [[ -z "$existing_profiles" ]]; then update_env_var "COMPOSE_PROFILES" "monitoring" elif [[ "$existing_profiles" != *"monitoring"* ]]; then update_env_var "COMPOSE_PROFILES" "${existing_profiles},monitoring" fi fi success "Set IMAGE_TAG=latest, NODE_ENV=production (pre-built images)" fi print_summary print_next_steps } main "$@"