#!/bin/bash set +e INSTALL_DIR="/opt/soulfire" COMPOSE_FILE="$INSTALL_DIR/docker-compose.yml" ENV_FILE="$INSTALL_DIR/.env" SF_IMAGE="ghcr.io/soulfiremc-com/soulfire" SF_PORT=38765 TUI_CMD="" DISTRO_ID="" DISTRO_ID_LIKE="" PKG_MGR="" SSL_MODE="" TUNNEL_TOKEN="" DOMAIN="" EMAIL="" BUILTIN_ROOT_DEFAULT_EMAIL="root@soulfiremc.com" SF_ROOT_DEFAULT_EMAIL="${SF_ROOT_DEFAULT_EMAIL:-}" PUBLIC_IP="" RECOMMENDED_ACCESS_MODE="" # --- Output helpers --- msg_info() { echo -e "\e[34m[INFO]\e[0m $1"; } msg_ok() { echo -e "\e[32m[OK]\e[0m $1"; } msg_error() { echo -e "\e[31m[ERROR]\e[0m $1"; } msg_warn() { echo -e "\e[33m[WARN]\e[0m $1"; } # --- Precondition checks --- check_root() { if [[ $EUID -ne 0 ]]; then msg_error "This script must be run as root. Use: sudo bash $0" exit 1 fi } # --- Distro detection --- detect_distro() { if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_ID_LIKE="${ID_LIKE:-}" else msg_error "Cannot detect distribution (/etc/os-release not found)" exit 1 fi } detect_pkg_manager() { case "$DISTRO_ID" in ubuntu|debian|raspbian|linuxmint|pop) PKG_MGR="apt" ;; fedora) PKG_MGR="dnf" ;; centos|rhel|rocky|alma|ol) if command -v dnf &>/dev/null; then PKG_MGR="dnf" else PKG_MGR="yum" fi ;; arch|manjaro|endeavouros) PKG_MGR="pacman" ;; opensuse*|sles) PKG_MGR="zypper" ;; *) if [[ "$DISTRO_ID_LIKE" == *debian* ]] || [[ "$DISTRO_ID_LIKE" == *ubuntu* ]]; then PKG_MGR="apt" elif [[ "$DISTRO_ID_LIKE" == *fedora* ]] || [[ "$DISTRO_ID_LIKE" == *rhel* ]]; then PKG_MGR="dnf" elif [[ "$DISTRO_ID_LIKE" == *arch* ]]; then PKG_MGR="pacman" elif [[ "$DISTRO_ID_LIKE" == *suse* ]]; then PKG_MGR="zypper" else msg_error "Unsupported distribution: $DISTRO_ID" msg_error "Please install Docker manually and re-run this script." exit 1 fi ;; esac } # --- TUI detection and wrappers --- install_whiptail() { msg_info "Installing whiptail..." case "$PKG_MGR" in apt) apt-get install -y whiptail &>/dev/null ;; dnf) dnf install -y newt &>/dev/null ;; yum) yum install -y newt &>/dev/null ;; pacman) pacman -S --noconfirm libnewt &>/dev/null ;; zypper) zypper install -y newt &>/dev/null ;; esac return 0 } check_tui() { if command -v whiptail &>/dev/null; then TUI_CMD="whiptail" elif command -v dialog &>/dev/null; then TUI_CMD="dialog" else install_whiptail || true if command -v whiptail &>/dev/null; then TUI_CMD="whiptail" else TUI_CMD="none" msg_warn "No TUI available (whiptail/dialog), using basic prompts" fi fi } tui_msgbox() { local title="$1" text="$2" if [[ "$TUI_CMD" != "none" ]]; then $TUI_CMD --title "$title" --msgbox "$text" 14 72 else echo "" echo "=== $title ===" echo "$text" echo "" read -rp "Press Enter to continue..." fi } tui_yesno() { local title="$1" text="$2" if [[ "$TUI_CMD" != "none" ]]; then if $TUI_CMD --title "$title" --yesno "$text" 14 72; then return 0 else return 1 fi else echo "" echo "=== $title ===" echo "$text" read -rp "[y/N]: " answer [[ "$answer" =~ ^[Yy] ]] fi } tui_inputbox() { local title="$1" text="$2" default="${3:-}" if [[ "$TUI_CMD" != "none" ]]; then local result result=$($TUI_CMD --title "$title" --inputbox "$text" 10 72 "$default" 3>&1 1>&2 2>&3) || return 1 echo "$result" else echo "" >&2 echo "=== $title ===" >&2 echo "$text" >&2 read -rp "[$default]: " answer echo "${answer:-$default}" fi } tui_menu() { local title="$1" text="$2" shift 2 if [[ "$TUI_CMD" != "none" ]]; then local result result=$($TUI_CMD --title "$title" --menu "$text" 18 72 10 "$@" 3>&1 1>&2 2>&3) || return 1 echo "$result" else echo "" >&2 echo "=== $title ===" >&2 echo "$text" >&2 local i=1 local -a tags=() while [[ $# -gt 0 ]]; do tags+=("$1") echo " $i) $1 - $2" >&2 shift 2 ((i++)) done read -rp "Choice [1-${#tags[@]}]: " choice if [[ "$choice" -ge 1 && "$choice" -le ${#tags[@]} ]] 2>/dev/null; then echo "${tags[$((choice-1))]}" else return 1 fi fi } # --- System setup --- update_system() { msg_info "Updating system packages..." case "$PKG_MGR" in apt) apt-get update -y && apt-get upgrade -y ;; dnf) dnf upgrade -y ;; yum) yum update -y ;; pacman) pacman -Syu --noconfirm ;; zypper) zypper refresh && zypper update -y ;; esac msg_ok "System packages updated" } docker_distro_id() { case "$DISTRO_ID" in linuxmint|pop) echo "ubuntu" ;; raspbian) echo "debian" ;; *) echo "$DISTRO_ID" ;; esac } docker_codename() { # shellcheck source=/dev/null . /etc/os-release case "$DISTRO_ID" in linuxmint) if [[ -n "${UBUNTU_CODENAME:-}" ]]; then echo "$UBUNTU_CODENAME" else echo "${VERSION_CODENAME:-}" fi ;; *) echo "${VERSION_CODENAME:-}" ;; esac } install_docker() { if command -v docker &>/dev/null && docker compose version &>/dev/null; then msg_ok "Docker and Docker Compose already installed" return 0 fi msg_info "Installing Docker..." local docker_distro codename case "$PKG_MGR" in apt) apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings docker_distro=$(docker_distro_id) curl -fsSL "https://download.docker.com/linux/${docker_distro}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc codename=$(docker_codename) echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${docker_distro} ${codename} stable" > /etc/apt/sources.list.d/docker.list apt-get update -y apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; dnf) dnf install -y dnf-plugins-core dnf config-manager --add-repo "https://download.docker.com/linux/fedora/docker-ce.repo" || true dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; yum) yum install -y yum-utils yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ;; pacman) pacman -S --noconfirm docker docker-compose ;; zypper) zypper install -y docker docker-compose ;; esac systemctl enable docker systemctl start docker msg_ok "Docker installed and started" } # --- Network helpers --- detect_public_ip() { local ip="" for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com" "https://ipinfo.io/ip"; do ip=$(curl -fsSL --connect-timeout 5 "$svc" 2>/dev/null | tr -d '[:space:]') || continue if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "$ip" return 0 fi done return 1 } check_port() { local port="$1" if ss -tlnp 2>/dev/null | grep -q ":${port} "; then return 1 fi return 0 } is_valid_domain() { local domain="$1" [[ "$domain" =~ ^([a-zA-Z0-9]([-a-zA-Z0-9]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]] } is_valid_email() { local email="$1" [[ "$email" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] } is_valid_ipv4() { local ip="$1" local IFS=. local -a octets read -r -a octets <<< "$ip" if [[ ${#octets[@]} -ne 4 ]]; then return 1 fi for octet in "${octets[@]}"; do if ! [[ "$octet" =~ ^[0-9]+$ ]] || ((octet < 0 || octet > 255)); then return 1 fi done return 0 } is_known_access_mode() { case "$1" in cloudflared|traefik|traefik-ip|http) return 0 ;; *) return 1 ;; esac } app_container_id() { docker compose -f "$COMPOSE_FILE" ps -q app 2>/dev/null } app_container_state() { local container_id container_id=$(app_container_id) if [[ -z "$container_id" ]]; then echo "not found" return 0 fi docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo "unknown" } app_container_health() { local container_id container_id=$(app_container_id) if [[ -z "$container_id" ]]; then echo "not found" return 0 fi docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || echo "unknown" } access_mode_name() { case "$1" in cloudflared) echo "Cloudflared Tunnel" ;; traefik) echo "Traefik + Domain HTTPS" ;; traefik-ip) echo "Traefik + IP HTTPS" ;; http) echo "HTTP Only" ;; *) echo "Unknown" ;; esac } access_mode_summary() { case "$1" in cloudflared) echo "Public HTTPS through Cloudflare Tunnel without opening ports 80/443 on your server." ;; traefik) echo "Public HTTPS on your own domain using Traefik and a Let's Encrypt certificate." ;; traefik-ip) echo "Public HTTPS directly on your server's public IPv4 using Traefik and Let's Encrypt." ;; http) echo "Plain HTTP only. Suitable for local testing or when another reverse proxy already handles HTTPS." ;; *) echo "Unknown access method." ;; esac } access_mode_requirements() { case "$1" in cloudflared) echo "Requirements: Cloudflare account, browser access to the Cloudflare dashboard, and an existing tunnel token." ;; traefik) echo "Requirements: a domain pointing to this server, ports 80/443 reachable from the internet, and an email for Let's Encrypt." ;; traefik-ip) echo "Requirements: public IPv4 address, ports 80/443 reachable from the internet, and an email for Let's Encrypt." ;; http) echo "Requirements: port ${SF_PORT} reachable to your clients, or another reverse proxy in front of SoulFire." ;; *) echo "Requirements unavailable." ;; esac } access_mode_caveats() { case "$1" in cloudflared) echo "Caveats: this script cannot create the tunnel for you. You must complete the Cloudflare-side setup in a browser first." ;; traefik) echo "Caveats: DNS must already point at this server and certificate issuance can fail if ports 80/443 are blocked." ;; traefik-ip) echo "Caveats: IP-based certificates are more niche and less predictable than domain-based HTTPS." ;; http) echo "Caveats: some clients may reject or degrade plain HTTP connections, especially browsers or HTTPS-enforcing clients." ;; *) echo "Caveats unavailable." ;; esac } selected_access_url() { case "$1" in cloudflared) echo "the URL configured in your Cloudflare tunnel" ;; traefik) echo "https://${DOMAIN}" ;; traefik-ip) echo "https://${PUBLIC_IP}" ;; http) echo "http://:${SF_PORT}" ;; *) echo "unknown" ;; esac } load_existing_config() { SSL_MODE="" DOMAIN="" EMAIL="" SF_ROOT_DEFAULT_EMAIL="" PUBLIC_IP="" TUNNEL_TOKEN="" if [[ -f "$ENV_FILE" ]]; then # shellcheck source=/dev/null . "$ENV_FILE" fi if [[ -n "$TUNNEL_TOKEN" ]]; then SSL_MODE="cloudflared" elif [[ -n "$DOMAIN" ]]; then SSL_MODE="traefik" elif [[ -n "$PUBLIC_IP" ]]; then SSL_MODE="traefik-ip" else SSL_MODE="http" fi } # --- Compose template generators --- generate_cloudflared_compose() { cat <<'COMPOSE' services: app: image: ghcr.io/soulfiremc-com/soulfire restart: always stdin_open: true tty: true environment: SF_JVM_FLAGS: ${SF_JVM_FLAGS:-} SF_ROOT_DEFAULT_EMAIL: ${SF_ROOT_DEFAULT_EMAIL:-} volumes: - app_data:/soulfire/data cloudflared: image: cloudflare/cloudflared restart: always command: tunnel run environment: TUNNEL_TOKEN: ${TUNNEL_TOKEN} volumes: app_data: driver: local COMPOSE } generate_traefik_domain_compose() { cat <<'COMPOSE' services: app: image: ghcr.io/soulfiremc-com/soulfire restart: always stdin_open: true tty: true environment: SF_JVM_FLAGS: ${SF_JVM_FLAGS:-} SF_ROOT_DEFAULT_EMAIL: ${SF_ROOT_DEFAULT_EMAIL:-} labels: - "traefik.enable=true" - "traefik.http.routers.soulfire.rule=Host(`${DOMAIN}`)" - "traefik.http.services.soulfire.loadbalancer.server.port=38765" - "traefik.http.routers.soulfire.entrypoints=websecure" - "traefik.http.routers.soulfire.tls.certresolver=myresolver" volumes: - app_data:/soulfire/data traefik: image: traefik restart: always command: - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--certificatesresolvers.myresolver.acme.email=${EMAIL}" - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "letsencrypt:/letsencrypt" volumes: app_data: driver: local letsencrypt: driver: local COMPOSE } generate_traefik_ip_compose() { cat <<'COMPOSE' services: app: image: ghcr.io/soulfiremc-com/soulfire restart: always stdin_open: true tty: true environment: SF_JVM_FLAGS: ${SF_JVM_FLAGS:-} SF_ROOT_DEFAULT_EMAIL: ${SF_ROOT_DEFAULT_EMAIL:-} labels: - "traefik.enable=true" - "traefik.http.routers.soulfire.rule=Host(`${PUBLIC_IP}`)" - "traefik.http.services.soulfire.loadbalancer.server.port=38765" - "traefik.http.routers.soulfire.entrypoints=websecure" - "traefik.http.routers.soulfire.tls.certresolver=myresolver" - "traefik.tls.stores.default.defaultgeneratedcert.resolver=myresolver" - "traefik.tls.stores.default.defaultgeneratedcert.domain.main=${PUBLIC_IP}" volumes: - app_data:/soulfire/data traefik: image: traefik:v3 restart: always command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.myresolver.acme.httpchallenge=true" - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" - "--certificatesresolvers.myresolver.acme.httpchallenge.delay=5" - "--certificatesresolvers.myresolver.acme.email=${EMAIL}" - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.myresolver.acme.certificatesduration=160" - "--certificatesresolvers.myresolver.acme.profile=shortlived" - "--certificatesresolvers.myresolver.acme.disablecommonname=true" ports: - "80:80" - "443:443" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "letsencrypt:/letsencrypt" volumes: app_data: driver: local letsencrypt: driver: local COMPOSE } generate_http_compose() { cat <<'COMPOSE' services: app: image: ghcr.io/soulfiremc-com/soulfire restart: always stdin_open: true tty: true environment: SF_JVM_FLAGS: ${SF_JVM_FLAGS:-} SF_ROOT_DEFAULT_EMAIL: ${SF_ROOT_DEFAULT_EMAIL:-} ports: - "38765:38765" volumes: - app_data:/soulfire/data volumes: app_data: driver: local COMPOSE } # --- Compose file writing --- write_compose_file() { if ! is_known_access_mode "$SSL_MODE"; then msg_error "Cannot write compose file: invalid access method '${SSL_MODE:-}'" return 1 fi mkdir -p "$INSTALL_DIR" case "$SSL_MODE" in cloudflared) generate_cloudflared_compose > "$COMPOSE_FILE" ;; traefik) generate_traefik_domain_compose > "$COMPOSE_FILE" ;; traefik-ip) generate_traefik_ip_compose > "$COMPOSE_FILE" ;; http) generate_http_compose > "$COMPOSE_FILE" ;; esac msg_ok "Generated $COMPOSE_FILE" } write_env_file() { local env_content="SF_ROOT_DEFAULT_EMAIL=${SF_ROOT_DEFAULT_EMAIL}" if ! is_known_access_mode "$SSL_MODE"; then msg_error "Cannot write environment file: invalid access method '${SSL_MODE:-}'" return 1 fi case "$SSL_MODE" in cloudflared) env_content="${env_content} TUNNEL_TOKEN=${TUNNEL_TOKEN}" ;; traefik) env_content="${env_content} DOMAIN=${DOMAIN} EMAIL=${EMAIL}" ;; traefik-ip) env_content="${env_content} PUBLIC_IP=${PUBLIC_IP} EMAIL=${EMAIL}" ;; http) env_content="${env_content}" ;; esac echo "$env_content" > "$ENV_FILE" chmod 600 "$ENV_FILE" msg_ok "Generated $ENV_FILE" } # --- SSL menu and prompts --- show_before_you_start() { tui_msgbox "Before You Start" \ "This installer can set up Docker, generate a Docker Compose stack, and start SoulFire.\n\nSome access methods still require work outside this script:\n - Cloudflared needs a Cloudflare account, browser access, and an existing tunnel/token\n - Domain HTTPS needs DNS pointed to this server\n - HTTPS certificate issuance requires public reachability for validation" tui_msgbox "HTTPS and Let's Encrypt" \ "HTTPS encrypts the connection between SoulFire and its clients.\n\nLet's Encrypt is the certificate authority used by the Traefik options. It issues the certificate that lets clients trust your server instead of showing security warnings.\n\nIf validation cannot reach your server on ports 80/443, certificate issuance will fail." } show_access_method_overview() { tui_msgbox "Access Methods" \ "Choose how clients should reach SoulFire:\n\n - Cloudflared Tunnel: easiest public HTTPS if you already use Cloudflare\n - Traefik + Domain HTTPS: best general-purpose production setup\n - Traefik + IP HTTPS: HTTPS without a domain, but more niche\n - HTTP Only: for local testing, LAN use, or when another proxy already handles HTTPS" } show_cloudflared_info() { tui_msgbox "Cloudflared Tunnel" \ "Use this when you already use Cloudflare and want public HTTPS without opening ports 80/443.\n\nRequires:\n - Cloudflare account\n - Browser access to the Cloudflare dashboard\n - A tunnel created in Cloudflare Zero Trust\n - A valid tunnel token\n\nThis script only runs the connector. It does not create the tunnel for you." } show_traefik_domain_info() { tui_msgbox "Traefik + Domain HTTPS" \ "This is the most standard public deployment option.\n\nTraefik listens on ports 80 and 443 and requests a Let's Encrypt certificate for your domain.\n\nRequires:\n - A domain you control\n - DNS already pointing to this server\n - Ports 80 and 443 reachable from the internet\n - Browser or DNS-panel access to manage your domain" } show_traefik_ip_info() { tui_msgbox "Traefik + IP HTTPS" \ "This option requests a certificate for the server's public IPv4 address instead of a domain.\n\nUse it when you need HTTPS but do not have a domain.\n\nCaveats:\n - More niche than domain-based HTTPS\n - Depends on public IPv4 reachability\n - Ports 80 and 443 must be reachable from the internet\n - Some environments are less thoroughly tested than standard domain HTTPS" } show_http_info() { tui_msgbox "HTTP Only" \ "HTTP mode does not use encryption.\n\nUse it only when:\n - you are testing locally\n - clients connect over a trusted LAN\n - another reverse proxy already provides HTTPS in front of SoulFire\n\nCaveats:\n - credentials and traffic are sent in cleartext\n - some clients may refuse or warn on plain HTTP\n - browser-based flows and HTTPS-enforcing environments can break" } show_access_method_details() { show_cloudflared_info show_traefik_domain_info show_traefik_ip_info show_http_info } recommend_access_method() { RECOMMENDED_ACCESS_MODE="" if tui_yesno "Recommendation Wizard" \ "Is this mainly for local testing, LAN use, or behind an existing HTTPS reverse proxy?"; then RECOMMENDED_ACCESS_MODE="http" return 0 fi if tui_yesno "Recommendation Wizard" \ "Do you already use Cloudflare and want to expose SoulFire through Cloudflare Tunnel?"; then RECOMMENDED_ACCESS_MODE="cloudflared" return 0 fi if tui_yesno "Recommendation Wizard" \ "Do you have a domain name that you can point to this server?"; then RECOMMENDED_ACCESS_MODE="traefik" return 0 fi if tui_yesno "Recommendation Wizard" \ "Can this server accept inbound traffic on ports 80 and 443 from the public internet?"; then RECOMMENDED_ACCESS_MODE="traefik-ip" return 0 fi RECOMMENDED_ACCESS_MODE="http" return 0 } confirm_access_selection() { local access_mode="$1" if tui_yesno "Use This Access Method?" \ "Selected: $(access_mode_name "$access_mode")\n\n$(access_mode_summary "$access_mode")\n\n$(access_mode_requirements "$access_mode")\n\n$(access_mode_caveats "$access_mode")\n\nUse this method?"; then return 0 fi return 1 } confirm_configuration_summary() { local access_mode="$1" local details="" local initial_root_email="$BUILTIN_ROOT_DEFAULT_EMAIL" if [[ -n "$SF_ROOT_DEFAULT_EMAIL" ]]; then initial_root_email="$SF_ROOT_DEFAULT_EMAIL" fi if ! is_known_access_mode "$access_mode"; then tui_msgbox "Configuration Error" \ "The installer selected an invalid access method: ${access_mode:-}\n\nPlease go back and choose the access method again." return 1 fi case "$access_mode" in cloudflared) details="Tunnel token: configured\nInitial root email: ${initial_root_email}" ;; traefik) details="Domain: ${DOMAIN}\nLet's Encrypt email: ${EMAIL}\nInitial root email: ${initial_root_email}\nPorts required: 80 and 443" ;; traefik-ip) details="Public IP: ${PUBLIC_IP}\nLet's Encrypt email: ${EMAIL}\nInitial root email: ${initial_root_email}\nPorts required: 80 and 443" ;; http) details="Published port: ${SF_PORT}\nInitial root email: ${initial_root_email}\nTLS: disabled" ;; esac tui_yesno "Confirm Configuration" \ "Please review the configuration before continuing.\n\nAccess method: $(access_mode_name "$access_mode")\nAccess URL: $(selected_access_url "$access_mode")\n\n${details}\n\n$(access_mode_caveats "$access_mode")\n\nProceed with this configuration?" } show_ssl_menu() { local choice recommended SSL_MODE="" while true; do show_access_method_overview if tui_yesno "Guided Recommendation" \ "Would you like the installer to recommend an access method based on your setup?"; then recommend_access_method recommended="$RECOMMENDED_ACCESS_MODE" if ! is_known_access_mode "$recommended"; then tui_msgbox "Recommendation Error" \ "The installer produced an invalid access method: ${recommended:-}\n\nPlease choose the access method manually." elif confirm_access_selection "$recommended"; then SSL_MODE="$recommended" fi fi while [[ -z "$SSL_MODE" ]]; do choice=$(tui_menu "Access Method" "How should SoulFire be exposed?" \ "cloudflared" "Cloudflared Tunnel" \ "traefik" "Traefik + Domain HTTPS" \ "traefik-ip" "Traefik + IP HTTPS" \ "http" "HTTP Only" \ "details" "Explain the options and caveats" \ "cancel" "Cancel setup") || { msg_info "Setup cancelled" exit 0 } case "$choice" in cloudflared|traefik|traefik-ip|http) if confirm_access_selection "$choice"; then SSL_MODE="$choice" fi ;; details) show_access_method_details ;; cancel) msg_info "Setup cancelled" exit 0 ;; esac done case "$SSL_MODE" in cloudflared) prompt_cloudflared ;; traefik) prompt_traefik_domain ;; traefik-ip) prompt_traefik_ip ;; http) if ! confirm_http_warning; then SSL_MODE="" continue fi ;; esac if confirm_configuration_summary "$SSL_MODE"; then return 0 fi SSL_MODE="" done } prompt_cloudflared() { show_cloudflared_info TUNNEL_TOKEN=$(tui_inputbox "Cloudflared Tunnel" \ "Enter your Cloudflare Tunnel token.\n\nGet it from the Cloudflare Zero Trust dashboard after creating a tunnel in your browser:\nhttps://one.dash.cloudflare.com" \ "") || { msg_info "Setup cancelled" exit 0 } if [[ -z "$TUNNEL_TOKEN" ]]; then msg_error "Tunnel token cannot be empty" prompt_cloudflared fi } prompt_traefik_domain() { show_traefik_domain_info DOMAIN=$(tui_inputbox "Domain Configuration" \ "Enter the domain pointing to this server.\n\nMake sure DNS is configured before proceeding." \ "") || { msg_info "Setup cancelled" exit 0 } if [[ -z "$DOMAIN" ]]; then msg_error "Domain cannot be empty" prompt_traefik_domain return fi if ! is_valid_domain "$DOMAIN"; then msg_error "Domain format looks invalid: $DOMAIN" prompt_traefik_domain return fi EMAIL=$(tui_inputbox "Let's Encrypt Email" \ "Enter your email for Let's Encrypt certificate notifications.\n\nLet's Encrypt uses this address for expiry and validation notices." \ "") || { msg_info "Setup cancelled" exit 0 } if [[ -z "$EMAIL" ]]; then msg_error "Email cannot be empty" prompt_traefik_domain return fi if ! is_valid_email "$EMAIL"; then msg_error "Email format looks invalid: $EMAIL" prompt_traefik_domain return fi if ! check_port 80 || ! check_port 443; then tui_msgbox "Port Conflict" "Ports 80 and/or 443 are already in use.\nTraefik needs these ports to be available.\n\nPlease stop the conflicting service and try again." exit 1 fi } prompt_traefik_ip() { tui_msgbox "IP SSL Information" \ "Traefik + IP HTTPS uses Let's Encrypt to issue certificates for your server's public IPv4 address instead of a domain.\n\nRequirements:\n- Traefik v3.6.7+ (handled automatically)\n- Ports 80 and 443 open to the internet\n- Certificates are short-lived (~6 days, auto-renewed)\n\nCaveats:\n- More niche than domain-based HTTPS\n- Depends on public IPv4 reachability\n- Some environments are less thoroughly tested than standard domain HTTPS" msg_info "Detecting public IP address..." local detected_ip detected_ip=$(detect_public_ip) || detected_ip="" if [[ -n "$detected_ip" ]]; then PUBLIC_IP=$(tui_inputbox "Public IP Address" \ "Detected public IP: $detected_ip\n\nPress Enter to use this IP or type a different one." \ "$detected_ip") || { msg_info "Setup cancelled" exit 0 } else PUBLIC_IP=$(tui_inputbox "Public IP Address" \ "Could not auto-detect your public IP.\n\nPlease enter your server's public IPv4 address." \ "") || { msg_info "Setup cancelled" exit 0 } fi if [[ -z "$PUBLIC_IP" ]]; then msg_error "IP address cannot be empty" prompt_traefik_ip return fi if ! is_valid_ipv4 "$PUBLIC_IP"; then msg_error "Public IPv4 format looks invalid: $PUBLIC_IP" prompt_traefik_ip return fi EMAIL=$(tui_inputbox "Let's Encrypt Email" \ "Enter your email for Let's Encrypt certificate notifications.\n\nLet's Encrypt uses this address for expiry and validation notices." \ "") || { msg_info "Setup cancelled" exit 0 } if [[ -z "$EMAIL" ]]; then msg_error "Email cannot be empty" prompt_traefik_ip return fi if ! is_valid_email "$EMAIL"; then msg_error "Email format looks invalid: $EMAIL" prompt_traefik_ip return fi if ! check_port 80 || ! check_port 443; then tui_msgbox "Port Conflict" "Ports 80 and/or 443 are already in use.\nTraefik needs these ports to be available.\n\nPlease stop the conflicting service and try again." exit 1 fi } confirm_http_warning() { show_http_info if ! tui_yesno "Security Warning" \ "HTTP mode serves SoulFire WITHOUT encryption.\n\nAll traffic including credentials will be sent in cleartext and can be intercepted.\n\nSome clients may not work correctly over plain HTTP, especially browser-based flows or environments that expect HTTPS by default.\n\nThis is not recommended for public production use.\n\nContinue with HTTP only?"; then return 1 fi if ! check_port "$SF_PORT"; then tui_msgbox "Port Conflict" "Port $SF_PORT is already in use.\n\nPlease stop the conflicting service and try again." exit 1 fi return 0 } prompt_root_default_email_override() { SF_ROOT_DEFAULT_EMAIL=$(tui_inputbox "Initial Root Email" \ "Enter the email SoulFire should assign to the root user on first startup.\n\nLeave this blank to keep the built-in default (${BUILTIN_ROOT_DEFAULT_EMAIL}).\n\nThis only affects the initial root-user creation before first boot." \ "$SF_ROOT_DEFAULT_EMAIL") || { msg_info "Setup cancelled" exit 0 } if [[ -z "$SF_ROOT_DEFAULT_EMAIL" ]]; then return 0 fi if ! is_valid_email "$SF_ROOT_DEFAULT_EMAIL"; then msg_error "Email format looks invalid: $SF_ROOT_DEFAULT_EMAIL" prompt_root_default_email_override return fi } # --- Container helpers --- is_installed() { [[ -f "$COMPOSE_FILE" ]] } expected_tls_identifier() { case "$SSL_MODE" in traefik) echo "$DOMAIN" ;; traefik-ip) echo "$PUBLIC_IP" ;; *) return 1 ;; esac } traefik_cert_issued() { local identifier identifier=$(expected_tls_identifier) || return 1 docker compose -f "$COMPOSE_FILE" exec -T traefik sh -lc \ "test -s /letsencrypt/acme.json && grep -Fq \"$identifier\" /letsencrypt/acme.json" \ >/dev/null 2>&1 } traefik_default_cert_served() { local identifier cert_details identifier=$(expected_tls_identifier) || return 1 if ! command -v openssl >/dev/null 2>&1; then return 1 fi cert_details=$( printf '' | openssl s_client -connect 127.0.0.1:443 -servername "$identifier" 2>/dev/null \ | openssl x509 -noout -subject -issuer 2>/dev/null ) || return 1 [[ "$cert_details" == *"TRAEFIK DEFAULT CERT"* ]] } wait_for_tls_certificate() { local identifier attempts=0 max_attempts=120 identifier=$(expected_tls_identifier) || return 0 while true; do if traefik_cert_issued && ! traefik_default_cert_served; then echo "" msg_ok "Traefik obtained a trusted certificate for ${identifier}" return 0 fi if [ "$attempts" -ge "$max_attempts" ]; then echo "" msg_warn "Traefik did not obtain a usable certificate for ${identifier} within ${max_attempts}s" return 1 fi attempts=$((attempts + 1)) echo -ne "\r\e[34m[INFO]\e[0m Waiting for Traefik certificate... (${attempts}s/${max_attempts}s) [identifier: ${identifier}]" sleep 1 done } verify_tls_access_ready() { case "$SSL_MODE" in traefik|traefik-ip) msg_info "Verifying Traefik certificate issuance..." if ! wait_for_tls_certificate; then tui_msgbox "Certificate Not Ready" \ "Traefik did not obtain a usable certificate for $(expected_tls_identifier).\n\nThe installer will stop here because browsers would otherwise see Traefik's default self-signed certificate.\n\nUseful commands:\n docker compose -f $COMPOSE_FILE logs traefik\n docker compose -f $COMPOSE_FILE exec -T traefik cat /letsencrypt/acme.json" return 1 fi ;; esac return 0 } wait_for_healthy() { local attempts=0 local max_attempts=180 local state="" local health="" while true; do if [ "$attempts" -ge "$max_attempts" ]; then echo "" msg_warn "Container did not become ready within ${max_attempts}s" return 1 fi state="$(app_container_state)" health="$(app_container_health)" if [[ "$state" = "running" && ( "$health" = "healthy" || "$health" = "none" ) ]]; then echo "" msg_ok "SoulFire is ready" return 0 fi attempts=$((attempts + 1)) echo -ne "\r\e[34m[INFO]\e[0m Waiting for SoulFire to become ready... (${attempts}s/${max_attempts}s) [state: ${state}, health: ${health}]" sleep 1 done } # --- Fresh install flow --- show_welcome() { tui_msgbox "SoulFire Dedicated Server Setup" \ "Welcome to the SoulFire Dedicated Server installer!\n\nThis script will:\n 1. Update your system packages\n 2. Install Docker (if needed)\n 3. Help you choose an access method\n 4. Deploy SoulFire via Docker Compose\n\nInstall directory: /opt/soulfire/\n\nAfter installation, the management menu will open automatically." } show_startup_troubleshooting() { local details="" case "$SSL_MODE" in cloudflared) details="Likely causes:\n - SoulFire is still starting\n - the Cloudflare tunnel token is invalid\n - Cloudflared is connected but the app is not healthy yet" ;; traefik) details="Likely causes:\n - SoulFire is still starting\n - DNS does not point to this server yet\n - ports 80/443 are blocked by a firewall or router" ;; traefik-ip) details="Likely causes:\n - SoulFire is still starting\n - the public IP is wrong or changed\n - ports 80/443 are blocked by a firewall or router" ;; http) details="Likely causes:\n - SoulFire is still starting\n - port ${SF_PORT} is blocked or already in use\n - another service is interfering with the container" ;; esac tui_msgbox "Startup Taking Longer Than Expected" \ "SoulFire is taking longer than expected to become ready.\n\n${details}\n\nUseful commands:\n docker compose -f $COMPOSE_FILE ps\n docker compose -f $COMPOSE_FILE logs app\n docker compose -f $COMPOSE_FILE logs --tail 100" } show_post_install_guidance() { local access_url="$1" tui_msgbox "Next Steps" \ "SoulFire is now running.\n\nAccess: ${access_url}\n\nRecommended next steps:\n 1. Open the management menu next\n 2. Attach to the SoulFire console\n 3. Run: generate-token api\n 4. Change the root email later if needed\n 5. Use logs/status if something is not reachable yet" } do_fresh_install() { show_welcome show_before_you_start if tui_yesno "System Update" "Update system packages before installing?\n\n(Recommended for fresh servers)"; then update_system fi install_docker prompt_root_default_email_override show_ssl_menu if ! is_known_access_mode "$SSL_MODE"; then msg_error "Installer aborted: invalid access method '${SSL_MODE:-}'" exit 1 fi write_compose_file || exit 1 write_env_file || exit 1 msg_info "Starting SoulFire..." docker compose -f "$COMPOSE_FILE" pull docker compose -f "$COMPOSE_FILE" up -d msg_info "Waiting for SoulFire to start..." if ! wait_for_healthy; then show_startup_troubleshooting fi verify_tls_access_ready || exit 1 local access_url access_url=$(selected_access_url "$SSL_MODE") show_post_install_guidance "$access_url" msg_ok "SoulFire installed successfully" show_manage_menu } # --- Management menu --- do_attach() { local state state=$(docker compose -f "$COMPOSE_FILE" ps --format '{{.State}}' app 2>/dev/null) || true if [[ "$state" != "running" ]]; then msg_warn "Container is not running yet (state: ${state:-not found})" msg_info "Waiting for SoulFire to start..." if ! wait_for_healthy; then true # timed out, check state below fi state=$(docker compose -f "$COMPOSE_FILE" ps --format '{{.State}}' app 2>/dev/null) || true if [[ "$state" != "running" ]]; then tui_msgbox "Container Not Ready" \ "The SoulFire container failed to start.\n\nCurrent state: ${state:-not found}\n\nCheck logs for details:\n docker compose -f $COMPOSE_FILE logs app" return fi fi msg_info "Attaching to SoulFire console (detach with Ctrl+P, Ctrl+Q)..." docker compose -f "$COMPOSE_FILE" attach app || true } do_logs() { msg_info "Showing logs (Ctrl+C to stop)..." docker compose -f "$COMPOSE_FILE" logs -f --tail 100 || true } do_update() { msg_info "Pulling latest images..." docker compose -f "$COMPOSE_FILE" pull msg_info "Recreating containers..." docker compose -f "$COMPOSE_FILE" up -d msg_info "Waiting for SoulFire to become ready..." if ! wait_for_healthy; then show_startup_troubleshooting fi verify_tls_access_ready || return 1 msg_ok "SoulFire updated successfully" } do_reconfigure() { show_ssl_menu msg_info "Stopping current containers..." docker compose -f "$COMPOSE_FILE" down write_compose_file write_env_file msg_info "Starting with new configuration..." docker compose -f "$COMPOSE_FILE" up -d msg_info "Waiting for SoulFire to become ready..." if ! wait_for_healthy; then show_startup_troubleshooting fi verify_tls_access_ready || return 1 msg_ok "SoulFire reconfigured successfully" } do_uninstall() { if ! tui_yesno "Confirm Uninstall" \ "This will stop all containers and remove all SoulFire data.\n\nAre you sure?"; then return fi if ! tui_yesno "Final Confirmation" \ "ALL SOULFIRE DATA WILL BE PERMANENTLY DELETED.\n\nThis cannot be undone. Proceed?"; then return fi msg_info "Stopping containers..." docker compose -f "$COMPOSE_FILE" down -v msg_info "Removing $INSTALL_DIR..." rm -rf "$INSTALL_DIR" msg_ok "SoulFire has been completely removed" } do_status() { load_existing_config echo "Access method: $(access_mode_name "$SSL_MODE")" echo "Expected access URL: $(selected_access_url "$SSL_MODE")" if [[ -n "$SF_ROOT_DEFAULT_EMAIL" ]]; then echo "Initial root email: $SF_ROOT_DEFAULT_EMAIL" else echo "Initial root email: $BUILTIN_ROOT_DEFAULT_EMAIL" fi echo "Container state: $(app_container_state)" echo "Container health: $(app_container_health)" echo "" docker compose -f "$COMPOSE_FILE" ps echo "" read -rp "Press Enter to continue..." } show_manage_menu() { while true; do load_existing_config local choice choice=$(tui_menu "SoulFire Management" "SoulFire is installed at $INSTALL_DIR\n\nAccess method: $(access_mode_name "$SSL_MODE")\nAccess URL: $(selected_access_url "$SSL_MODE")\nContainer: $(app_container_state) / $(app_container_health)" \ "attach" "Attach to SoulFire console" \ "logs" "View container logs" \ "status" "Show container status" \ "update" "Update SoulFire (pull latest)" \ "reconfigure" "Change SSL configuration" \ "uninstall" "Remove SoulFire completely" \ "exit" "Exit") || break case "$choice" in attach) do_attach ;; logs) do_logs ;; status) do_status ;; update) do_update ;; reconfigure) do_reconfigure ;; uninstall) do_uninstall; break ;; exit|"") break ;; esac done } # --- Main --- main() { check_root detect_distro detect_pkg_manager check_tui if is_installed; then show_manage_menu else do_fresh_install fi } main "$@"