#!/usr/bin/env bash set -euo pipefail CAMELEER_INSTALLER_VERSION="1.0.0" CAMELEER_DEFAULT_VERSION="latest" REGISTRY="gitea.siegeln.net/cameleer" # --- Colors --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' # --- Defaults --- DEFAULT_INSTALL_DIR="./cameleer" DEFAULT_PUBLIC_PROTOCOL="https" DEFAULT_ADMIN_USER="admin" DEFAULT_TLS_MODE="self-signed" DEFAULT_HTTP_PORT="80" DEFAULT_HTTPS_PORT="443" DEFAULT_LOGTO_CONSOLE_PORT="3002" DEFAULT_LOGTO_CONSOLE_EXPOSED="true" DEFAULT_VENDOR_ENABLED="false" DEFAULT_VENDOR_USER="vendor" DEFAULT_COMPOSE_PROJECT="cameleer-saas" DEFAULT_DOCKER_SOCKET="/var/run/docker.sock" # --- Config values (set by args/env/config/prompts) --- # Save environment values before initialization (CLI args override these) _ENV_PUBLIC_HOST="${PUBLIC_HOST:-}" _ENV_PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}" _ENV_POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" _ENV_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" _ENV_TLS_MODE="${TLS_MODE:-}" _ENV_CERT_FILE="${CERT_FILE:-}" _ENV_KEY_FILE="${KEY_FILE:-}" _ENV_CA_FILE="${CA_FILE:-}" _ENV_HTTP_PORT="${HTTP_PORT:-}" _ENV_HTTPS_PORT="${HTTPS_PORT:-}" _ENV_LOGTO_CONSOLE_PORT="${LOGTO_CONSOLE_PORT:-}" _ENV_LOGTO_CONSOLE_EXPOSED="${LOGTO_CONSOLE_EXPOSED:-}" _ENV_VENDOR_ENABLED="${VENDOR_ENABLED:-}" _ENV_VENDOR_USER="${VENDOR_USER:-}" _ENV_VENDOR_PASS="${VENDOR_PASS:-}" _ENV_MONITORING_NETWORK="${MONITORING_NETWORK:-}" _ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}" _ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}" _ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}" INSTALL_DIR="" PUBLIC_HOST="" PUBLIC_PROTOCOL="" ADMIN_USER="" ADMIN_PASS="" TLS_MODE="" CERT_FILE="" KEY_FILE="" CA_FILE="" POSTGRES_PASSWORD="" CLICKHOUSE_PASSWORD="" HTTP_PORT="" HTTPS_PORT="" LOGTO_CONSOLE_PORT="" LOGTO_CONSOLE_EXPOSED="" VENDOR_ENABLED="" VENDOR_USER="" VENDOR_PASS="" MONITORING_NETWORK="" VERSION="" COMPOSE_PROJECT="" DOCKER_SOCKET="" NODE_TLS_REJECT="" # --- State --- MODE="" # simple, expert, silent IS_RERUN=false RERUN_ACTION="" # upgrade, reconfigure, reinstall CONFIRM_DESTROY=false CONFIG_FILE_PATH="" # --- Utility functions --- log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } print_banner() { echo -e "${BOLD}" echo " ____ _ " echo " / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ " echo "| | / _\` | '_ \` _ \\ / _ \\| |/ _ \\/ _ \\ '__|" echo "| |__| (_| | | | | | | __/| | __/ __/ | " echo " \\____\\__,_|_| |_| |_|\\___||_|\\___|\\___||_| " echo "" echo " SaaS Platform Installer v${CAMELEER_INSTALLER_VERSION}" echo -e "${NC}" } prompt() { local var_name="$1" prompt_text="$2" default="${3:-}" local input if [ -n "$default" ]; then read -rp " $prompt_text [$default]: " input eval "$var_name=\"\${input:-$default}\"" else read -rp " $prompt_text: " input eval "$var_name=\"\$input\"" fi } prompt_password() { local var_name="$1" prompt_text="$2" default="${3:-}" local input if [ -n "$default" ]; then read -rsp " $prompt_text [${default:+********}]: " input echo eval "$var_name=\"\${input:-$default}\"" else read -rsp " $prompt_text: " input echo eval "$var_name=\"\$input\"" fi } prompt_yesno() { local prompt_text="$1" default="${2:-n}" local input if [ "$default" = "y" ]; then read -rp " $prompt_text [Y/n]: " input case "${input:-y}" in [nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac else read -rp " $prompt_text [y/N]: " input case "${input:-n}" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac fi } generate_password() { openssl rand -base64 24 | tr -d '/+=' | head -c 32 } # --- Argument parsing --- parse_args() { while [ $# -gt 0 ]; do case "$1" in --silent) MODE="silent" ;; --expert) MODE="expert" ;; --config) CONFIG_FILE_PATH="$2"; shift ;; --install-dir) INSTALL_DIR="$2"; shift ;; --public-host) PUBLIC_HOST="$2"; shift ;; --public-protocol) PUBLIC_PROTOCOL="$2"; shift ;; --admin-user) ADMIN_USER="$2"; shift ;; --admin-password) ADMIN_PASS="$2"; shift ;; --tls-mode) TLS_MODE="$2"; shift ;; --cert-file) CERT_FILE="$2"; shift ;; --key-file) KEY_FILE="$2"; shift ;; --ca-file) CA_FILE="$2"; shift ;; --postgres-password) POSTGRES_PASSWORD="$2"; shift ;; --clickhouse-password) CLICKHOUSE_PASSWORD="$2"; shift ;; --http-port) HTTP_PORT="$2"; shift ;; --https-port) HTTPS_PORT="$2"; shift ;; --logto-console-port) LOGTO_CONSOLE_PORT="$2"; shift ;; --logto-console-exposed) LOGTO_CONSOLE_EXPOSED="$2"; shift ;; --vendor-enabled) VENDOR_ENABLED="$2"; shift ;; --vendor-user) VENDOR_USER="$2"; shift ;; --vendor-password) VENDOR_PASS="$2"; shift ;; --monitoring-network) MONITORING_NETWORK="$2"; shift ;; --version) VERSION="$2"; shift ;; --compose-project) COMPOSE_PROJECT="$2"; shift ;; --docker-socket) DOCKER_SOCKET="$2"; shift ;; --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;; --reconfigure) RERUN_ACTION="reconfigure" ;; --reinstall) RERUN_ACTION="reinstall" ;; --confirm-destroy) CONFIRM_DESTROY=true ;; --help|-h) show_help; exit 0 ;; *) log_error "Unknown option: $1" echo " Run with --help for usage." exit 1 ;; esac shift done } show_help() { echo "Usage: install.sh [OPTIONS]" echo "" echo "Modes:" echo " (default) Interactive simple mode (6 questions)" echo " --expert Interactive expert mode (all options)" echo " --silent Non-interactive, use defaults + overrides" echo "" echo "Options:" echo " --install-dir DIR Install directory (default: ./cameleer)" echo " --public-host HOST Public hostname (default: auto-detect)" echo " --admin-user USER Admin username (default: admin)" echo " --admin-password PASS Admin password (default: generated)" echo " --tls-mode MODE self-signed or custom (default: self-signed)" echo " --cert-file PATH TLS certificate file" echo " --key-file PATH TLS key file" echo " --ca-file PATH CA bundle file" echo " --monitoring-network NAME Docker network for Prometheus scraping" echo " --version TAG Image version tag (default: latest)" echo " --config FILE Load config from file" echo " --help Show this help" echo "" echo "Expert options:" echo " --postgres-password, --clickhouse-password, --http-port," echo " --https-port, --logto-console-port, --logto-console-exposed," echo " --vendor-enabled, --vendor-user, --vendor-password," echo " --compose-project, --docker-socket, --node-tls-reject" echo "" echo "Re-run options:" echo " --reconfigure Re-run interactive setup (preserve data)" echo " --reinstall --confirm-destroy Fresh install (destroys data)" echo "" echo "Config precedence: CLI flags > env vars > config file > defaults" } # --- Config file handling --- load_config_file() { local file="$1" [ ! -f "$file" ] && return while IFS='=' read -r key value; do case "$key" in \#*|"") continue ;; esac key=$(echo "$key" | tr -d ' ') value=$(echo "$value" | sed 's/^[ ]*//;s/[ ]*$//') case "$key" in install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;; public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;; public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;; admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;; admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;; tls_mode) [ -z "$TLS_MODE" ] && TLS_MODE="$value" ;; cert_file) [ -z "$CERT_FILE" ] && CERT_FILE="$value" ;; key_file) [ -z "$KEY_FILE" ] && KEY_FILE="$value" ;; ca_file) [ -z "$CA_FILE" ] && CA_FILE="$value" ;; postgres_password) [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$value" ;; clickhouse_password) [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$value" ;; http_port) [ -z "$HTTP_PORT" ] && HTTP_PORT="$value" ;; https_port) [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$value" ;; logto_console_port) [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$value" ;; logto_console_exposed) [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$value" ;; vendor_enabled) [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$value" ;; vendor_user) [ -z "$VENDOR_USER" ] && VENDOR_USER="$value" ;; vendor_password) [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$value" ;; monitoring_network) [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$value" ;; version) [ -z "$VERSION" ] && VERSION="$value" ;; compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;; docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;; node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;; esac done < "$file" } load_env_overrides() { [ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}" [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$_ENV_PUBLIC_HOST" [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$_ENV_PUBLIC_PROTOCOL" [ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}" [ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}" [ -z "$TLS_MODE" ] && TLS_MODE="$_ENV_TLS_MODE" [ -z "$CERT_FILE" ] && CERT_FILE="$_ENV_CERT_FILE" [ -z "$KEY_FILE" ] && KEY_FILE="$_ENV_KEY_FILE" [ -z "$CA_FILE" ] && CA_FILE="$_ENV_CA_FILE" [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$_ENV_POSTGRES_PASSWORD" [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$_ENV_CLICKHOUSE_PASSWORD" [ -z "$HTTP_PORT" ] && HTTP_PORT="$_ENV_HTTP_PORT" [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$_ENV_HTTPS_PORT" [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$_ENV_LOGTO_CONSOLE_PORT" [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$_ENV_LOGTO_CONSOLE_EXPOSED" [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$_ENV_VENDOR_ENABLED" [ -z "$VENDOR_USER" ] && VENDOR_USER="$_ENV_VENDOR_USER" [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$_ENV_VENDOR_PASS" [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$_ENV_MONITORING_NETWORK" [ -z "$VERSION" ] && VERSION="${CAMELEER_VERSION:-}" [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$_ENV_COMPOSE_PROJECT" [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET" [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT" } # --- Prerequisites --- check_prerequisites() { log_info "Checking prerequisites..." local errors=0 if ! command -v docker >/dev/null 2>&1; then log_error "Docker is not installed." echo " Install Docker Engine: https://docs.docker.com/engine/install/" errors=$((errors + 1)) else local docker_version docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "unknown") log_info "Docker version: $docker_version" fi if ! docker compose version >/dev/null 2>&1; then log_error "Docker Compose v2 is not available." echo " 'docker compose' subcommand required (not standalone docker-compose)." errors=$((errors + 1)) else local compose_version compose_version=$(docker compose version --short 2>/dev/null || echo "unknown") log_info "Docker Compose version: $compose_version" fi if ! command -v openssl >/dev/null 2>&1; then log_error "OpenSSL is not installed (needed for password generation)." errors=$((errors + 1)) fi local socket="${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}" if [ ! -S "$socket" ]; then log_warn "Docker socket not found at $socket" fi check_port_available "${HTTP_PORT:-$DEFAULT_HTTP_PORT}" "HTTP" check_port_available "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}" "HTTPS" check_port_available "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}" "Logto Console" if [ $errors -gt 0 ]; then log_error "$errors prerequisite(s) not met. Please install missing dependencies and retry." exit 1 fi log_success "All prerequisites met." } check_port_available() { local port="$1" name="$2" if command -v ss >/dev/null 2>&1; then if ss -tlnp 2>/dev/null | grep -q ":${port} "; then log_warn "Port $port ($name) is already in use." fi elif command -v netstat >/dev/null 2>&1; then if netstat -tlnp 2>/dev/null | grep -q ":${port} "; then log_warn "Port $port ($name) is already in use." fi fi } # --- Auto-detection --- auto_detect() { if [ -z "$PUBLIC_HOST" ]; then PUBLIC_HOST=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost") local primary_ip primary_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || true) if [ -n "$primary_ip" ]; then local rdns rdns=$(dig +short -x "$primary_ip" 2>/dev/null | sed 's/\.$//' || true) [ -n "$rdns" ] && PUBLIC_HOST="$rdns" fi fi if [ -z "$DOCKER_SOCKET" ]; then DOCKER_SOCKET="$DEFAULT_DOCKER_SOCKET" fi } detect_existing_install() { local dir="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" if [ -f "$dir/cameleer.conf" ]; then IS_RERUN=true INSTALL_DIR="$dir" load_config_file "$dir/cameleer.conf" fi } # --- Interactive prompts --- select_mode() { if [ -n "$MODE" ]; then return; fi echo "" echo " Installation mode:" echo " [1] Simple — 6 questions, sensible defaults (recommended)" echo " [2] Expert — configure everything" echo "" local choice read -rp " Select mode [1]: " choice case "${choice:-1}" in 2) MODE="expert" ;; *) MODE="simple" ;; esac } run_simple_prompts() { echo "" echo -e "${BOLD}--- Simple Installation ---${NC}" echo "" prompt INSTALL_DIR "Install directory" "${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" prompt PUBLIC_HOST "Public hostname" "${PUBLIC_HOST:-localhost}" prompt ADMIN_USER "Admin username" "${ADMIN_USER:-$DEFAULT_ADMIN_USER}" if prompt_yesno "Auto-generate admin password?" "y"; then ADMIN_PASS="" else prompt_password ADMIN_PASS "Admin password" "" fi echo "" if prompt_yesno "Use custom TLS certificates? (no = self-signed)"; then TLS_MODE="custom" prompt CERT_FILE "Path to certificate file (PEM)" "" prompt KEY_FILE "Path to private key file (PEM)" "" if prompt_yesno "Include CA bundle?"; then prompt CA_FILE "Path to CA bundle (PEM)" "" fi else TLS_MODE="self-signed" fi echo "" prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" "" } run_expert_prompts() { echo "" echo -e "${BOLD}--- Expert Installation ---${NC}" run_simple_prompts echo "" echo -e "${BOLD} Credentials:${NC}" if prompt_yesno "Auto-generate database passwords?" "y"; then POSTGRES_PASSWORD="" CLICKHOUSE_PASSWORD="" else prompt_password POSTGRES_PASSWORD "PostgreSQL password" "" prompt_password CLICKHOUSE_PASSWORD "ClickHouse password" "" fi echo "" if prompt_yesno "Enable vendor account?"; then VENDOR_ENABLED="true" prompt VENDOR_USER "Vendor username" "${VENDOR_USER:-$DEFAULT_VENDOR_USER}" if prompt_yesno "Auto-generate vendor password?" "y"; then VENDOR_PASS="" else prompt_password VENDOR_PASS "Vendor password" "" fi else VENDOR_ENABLED="false" fi echo "" echo -e "${BOLD} Networking:${NC}" prompt HTTP_PORT "HTTP port" "${HTTP_PORT:-$DEFAULT_HTTP_PORT}" prompt HTTPS_PORT "HTTPS port" "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}" prompt LOGTO_CONSOLE_PORT "Logto admin console port" "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}" echo "" echo -e "${BOLD} Docker:${NC}" prompt VERSION "Image version/tag" "${VERSION:-$CAMELEER_DEFAULT_VERSION}" prompt COMPOSE_PROJECT "Compose project name" "${COMPOSE_PROJECT:-$DEFAULT_COMPOSE_PROJECT}" prompt DOCKER_SOCKET "Docker socket path" "${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}" echo "" echo -e "${BOLD} Logto:${NC}" if prompt_yesno "Expose Logto admin console externally?" "y"; then LOGTO_CONSOLE_EXPOSED="true" else LOGTO_CONSOLE_EXPOSED="false" fi } # --- Config merge and validation --- merge_config() { : "${INSTALL_DIR:=$DEFAULT_INSTALL_DIR}" : "${PUBLIC_HOST:=localhost}" : "${PUBLIC_PROTOCOL:=$DEFAULT_PUBLIC_PROTOCOL}" : "${ADMIN_USER:=$DEFAULT_ADMIN_USER}" : "${TLS_MODE:=$DEFAULT_TLS_MODE}" : "${HTTP_PORT:=$DEFAULT_HTTP_PORT}" : "${HTTPS_PORT:=$DEFAULT_HTTPS_PORT}" : "${LOGTO_CONSOLE_PORT:=$DEFAULT_LOGTO_CONSOLE_PORT}" : "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}" : "${VENDOR_ENABLED:=$DEFAULT_VENDOR_ENABLED}" : "${VENDOR_USER:=$DEFAULT_VENDOR_USER}" : "${VERSION:=$CAMELEER_DEFAULT_VERSION}" : "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}" : "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}" if [ -z "$NODE_TLS_REJECT" ]; then if [ "$TLS_MODE" = "custom" ]; then NODE_TLS_REJECT="1" else NODE_TLS_REJECT="0" fi fi } validate_config() { local errors=0 if [ "$TLS_MODE" = "custom" ]; then if [ ! -f "$CERT_FILE" ]; then log_error "Certificate file not found: $CERT_FILE" errors=$((errors + 1)) fi if [ ! -f "$KEY_FILE" ]; then log_error "Key file not found: $KEY_FILE" errors=$((errors + 1)) fi if [ -n "$CA_FILE" ] && [ ! -f "$CA_FILE" ]; then log_error "CA bundle not found: $CA_FILE" errors=$((errors + 1)) fi fi for port_var in HTTP_PORT HTTPS_PORT LOGTO_CONSOLE_PORT; do local port_val eval "port_val=\$$port_var" if ! echo "$port_val" | grep -qE '^[0-9]+$' || [ "$port_val" -lt 1 ] || [ "$port_val" -gt 65535 ]; then log_error "Invalid port for $port_var: $port_val" errors=$((errors + 1)) fi done if [ $errors -gt 0 ]; then log_error "Configuration validation failed." exit 1 fi log_success "Configuration validated." } generate_passwords() { if [ -z "$ADMIN_PASS" ]; then ADMIN_PASS=$(generate_password) log_info "Generated admin password." fi if [ -z "$POSTGRES_PASSWORD" ]; then POSTGRES_PASSWORD=$(generate_password) log_info "Generated PostgreSQL password." fi if [ -z "$CLICKHOUSE_PASSWORD" ]; then CLICKHOUSE_PASSWORD=$(generate_password) log_info "Generated ClickHouse password." fi if [ "$VENDOR_ENABLED" = "true" ] && [ -z "$VENDOR_PASS" ]; then VENDOR_PASS=$(generate_password) log_info "Generated vendor password." fi }