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
2343 lines
80 KiB
Bash
Executable File
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 "$@"
|