changemaker.lite/config.sh
bunker-admin 6e01d580b2 install: persist generated admin password + Pangolin credential smoke test
config.sh: two more pieces of first-time-user UX polish surfaced
during fresh-install testing.

- When configure_admin() auto-generates the admin password (no
  --admin-password given), also write a locked-down
  data/admin-credentials.txt (mode 0600) containing email + password
  + timestamp. Users who pipe config.sh output or miss the single
  stdout print no longer lose the password forever. The file is only
  written on the auto-generate path — explicit --admin-password
  leaves it alone.

- configure_pangolin() now smoke-tests --pangolin-api-key +
  --pangolin-org-id against /org/:id/resources before writing them to
  .env. Catches typos, revoked keys, or wrong org IDs while recovery
  is cheap (rather than later, when Newt fails to connect and
  symptoms look like a tunnel outage). New flag:
  --skip-pangolin-check for offline bootstrap scenarios.

Bunker Admin
2026-04-16 12:59:10 -06:00

2343 lines
80 KiB
Bash
Executable File

#!/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</dev/tty
else
echo "[ERR] This script requires an interactive terminal." >&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.<domain>."
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 <noreply@$domain>"
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 "$@"