changemaker.lite/config.sh
bunker-admin 38ccaa8a5b Add remote instance management with mTLS agent and phone-home registration
Enables the CCP to manage CML instances on remote servers via a lightweight
HTTP agent. Key components:

- ExecutionDriver abstraction (local-driver.ts / remote-driver.ts) routes
  operations to local Docker or remote agent transparently
- Remote agent package (agent/) with mTLS authentication, Docker Compose
  operations, file management, backup/upgrade delegation
- Certificate service using openssl CLI for CA management and cert issuance
- Phone-home registration: remote agents register via invite code, CCP admin
  approves, agent receives mTLS cert bundle automatically
- config.sh integration with configure_control_panel() section
- ccp-agent Docker Compose service (profile-gated)
- Frontend: AgentRegistrationsPage, InviteCodesPage, Remote Agents sidebar menu
- Security hardened: cert bundle wiped after delivery, shell injection prevention
  via execFile, command allowlist with metachar rejection, rate-limited public
  endpoints, auto-populated fingerprint pinning

Also wires ENABLE_SOCIAL/PEOPLE/ANALYTICS through env.ts, seed.ts, and
docker-compose env passthrough (from previous session).

Bunker Admin
2026-04-07 15:24:33 -06:00

1868 lines
61 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
# --- 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 ;;
--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"
echo " --help, -h Show this help"
echo ""
echo "Example:"
echo " bash config.sh --non-interactive --domain example.org --admin-password MyStr0ngPass123"
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
$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
if [[ -z "$admin_password" ]]; then
admin_password="CmLite$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12)1"
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"
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"
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
# Non-interactive: use MailHog defaults (production SMTP can be configured later)
update_env_var "VAULTWARDEN_SMTP_SECURITY" "off"
info "Using MailHog for email (configure SMTP later via .env)"
SMTP_MODE="mailhog"
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
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 Analytics & GeoIP (visitor tracking, geo dashboard)?"; then
update_env_var "ENABLE_ANALYTICS" "true"
success "Analytics enabled"
else
update_env_var "ENABLE_ANALYTICS" "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 auto-setup will create the API token, repos, and OAuth app automatically."
info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
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_PASSWORD" "$gitea_admin_pw"
update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin"
success "Gitea admin password saved — auto-setup will run on next start"
else
info "No password provided. Run Gitea Setup from the admin GUI after first start."
fi
fi
else
DOCS_COMMENTS_ENABLED="no"
fi
if prompt_yes_no "Enable Monitoring stack (Prometheus, Grafana, Alertmanager, cAdvisor)?" "y"; then
update_env_var "COMPOSE_PROFILES" "monitoring"
success "Monitoring enabled (COMPOSE_PROFILES=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" == "false" ]]; then
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
}
configure_pangolin() {
header "Tunnel Configuration (Pangolin)"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
info "Skipping Pangolin setup (configure later via admin GUI or .env)"
PANGOLIN_CONFIGURED="no"
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
update_env_var "PANGOLIN_API_URL" "$pang_url"
update_env_var "PANGOLIN_API_KEY" "$pang_key"
update_env_var "PANGOLIN_ORG_ID" "$pang_org"
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/" 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" ;;
2) pangolin_connect_site "$pang_url" "$pang_key" "$pang_org" ;;
*)
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_site() {
local api_url=$1 api_key=$2 org_id=$3
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
local site_name
read -rp " Site name [default: changemaker-$domain]: " site_name
site_name=${site_name:-changemaker-$domain}
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 client_address
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)
client_address=$(echo "$defaults_resp" | jq -r '.data.clientAddress // .clientAddress // .data.address // .address // 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
create_payload=$(jq -n \
--arg name "$site_name" \
--arg newtId "$newt_id" \
--arg secret "$newt_secret" \
--arg address "$client_address" \
'{name: $name, type: "newt", newtId: $newtId, secret: $secret, address: $address}')
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)"
# Derive endpoint from API URL (strip /v1 path)
local endpoint
endpoint=$(echo "$api_url" | sed 's|/v1/*$||')
# 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"
update_env_var "PANGOLIN_ENDPOINT" "$endpoint"
success "Tunnel credentials written to .env"
info "Resources will be created automatically via the admin GUI or sync endpoint."
}
pangolin_connect_site() {
local api_url=$1 api_key=$2 org_id=$3
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')
# Derive endpoint from API URL
local endpoint
endpoint=$(echo "$api_url" | sed 's|/v1/*$||')
# Write site ID to .env
update_env_var "PANGOLIN_SITE_ID" "$sel_id"
update_env_var "PANGOLIN_ENDPOINT" "$endpoint"
success "Connected to site: $sel_name (ID: $sel_id)"
# Check if we also need Newt credentials
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 "Newt credentials not yet set."
info "If you have the Newt ID and Secret for this site, enter them now."
info "Otherwise, set them later via the admin GUI."
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
else
info "Existing Newt credentials found in .env (newtId: $existing_newt_id)"
fi
}
configure_control_panel() {
header "Control Panel Registration"
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"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
mkdir -p "$SCRIPT_DIR/data/upgrade"
info "Skipping systemd watcher install (run manually later)"
UPGRADE_WATCHER="skipped"
return
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 ""
# Ensure upgrade IPC directory exists
mkdir -p "$SCRIPT_DIR/data/upgrade"
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 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"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
info "Skipping backup timer install (run manually later)"
BACKUP_TIMER="skipped"
return
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 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} (see .env: INITIAL_ADMIN_EMAIL)"
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 " Pre-built images will be pulled from the registry (~2 min first time)."
echo -e " Database migrations and seeding run automatically on startup."
echo ""
echo -e " ${BOLD}2.${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}3.${NC} Check status:"
echo -e " ${CYAN}docker compose ps${NC}"
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
if [[ "${MONITORING_ENABLED:-no}" == "yes" ]]; then
update_env_var "COMPOSE_PROFILES" "monitoring"
fi
success "Set IMAGE_TAG=latest, NODE_ENV=production (pre-built images)"
fi
print_summary
print_next_steps
}
main "$@"