#!/bin/bash #=============================================================================== # Felhom Infrastructure Setup v6.0 # Prepares a Debian 13 server for Felhom homeserver deployment # # This script sets up the infrastructure: # - Docker Engine + Compose # - Traefik reverse proxy # - TLS certificates (Let's Encrypt via Cloudflare DNS or self-signed) # - Minimal controller.yaml (full configuration via web UI setup wizard) # - Cloudflare Tunnel connector (optional, via --cf-tunnel-token) # - FileBrowser Quantum (web-based file manager) # - Felhom Controller (with web-based setup wizard on :8081) # - Helper tools (ctop, lazydocker, shell aliases) # # Application stacks are managed via the felhom-controller dashboard. # # Usage: # ./docker-setup.sh --bootstrap # Install sudo (fresh Debian only) # sudo ./docker-setup.sh [OPTIONS] # Run full setup # # Options: # --bootstrap Install sudo and configure sudoers # --ip ADDRESS Static IP address (e.g., 192.168.0.50) # --gateway ADDRESS Gateway address (default: 192.168.0.1) # --dns ADDRESS DNS server (default: 1.1.1.1,8.8.8.8) # --interface NAME Network interface (default: auto-detect) # --domain DOMAIN Base domain for services (required) # --email EMAIL ACME email for Let's Encrypt # --cf-token TOKEN Cloudflare API token for DNS-01 TLS # --cf-tunnel-token TK Cloudflare Tunnel token (optional) # --customer ID Customer identifier (optional, set in web wizard) # --traefik-password PW Password for Traefik dashboard (default: auto-generated) # --self-signed-cert Generate self-signed wildcard certificate # --skip-filebrowser Skip FileBrowser installation # --dry-run Show what would be done without making changes # --debug Enable bash debug tracing (set -x) # -h, --help Show this help message # # Example: # sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ # --email certs@felhom.eu --cf-token cf-xxx # #=============================================================================== set -euo pipefail #------------------------------------------------------------------------------- # Bootstrap: Install sudo on fresh Debian #------------------------------------------------------------------------------- bootstrap_sudo() { echo "" echo "===========================================" echo " Sudo Bootstrap for Fresh Debian Install" echo "===========================================" echo "" # Check if current user already has working sudo if command -v sudo &> /dev/null; then local check_user="" if [[ $EUID -ne 0 ]]; then check_user="$(whoami)" else check_user="${SUDO_USER:-}" fi if [[ -n "$check_user" ]] && sudo -u "$check_user" sudo -n true 2>/dev/null; then echo -e "\033[0;32m[INFO]\033[0m sudo is already installed and configured for '$check_user'!" exit 0 fi fi local target_user="" if [[ -n "${SUDO_USER:-}" ]]; then target_user="$SUDO_USER" elif [[ $EUID -ne 0 ]]; then target_user="$(whoami)" else target_user=$(getent passwd 1000 | cut -d: -f1 || echo "") if [[ -z "$target_user" ]]; then echo -e "\033[0;31m[ERROR]\033[0m Cannot determine target user" exit 1 fi fi echo "This will install sudo and configure NOPASSWD for '$target_user'" echo "You will need to enter the ROOT password." read -p "Continue? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 0 fi local bootstrap_script bootstrap_script=$(mktemp) cat > "$bootstrap_script" << ROOTSCRIPT #!/bin/bash set -e export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" apt-get update -qq apt-get install -qq -y curl sudo > /dev/null usermod -aG sudo "$target_user" echo "$target_user ALL=(ALL:ALL) NOPASSWD: ALL" > "/etc/sudoers.d/90-${target_user}-nopasswd" chmod 440 "/etc/sudoers.d/90-${target_user}-nopasswd" visudo -c -f "/etc/sudoers.d/90-${target_user}-nopasswd" echo "[SUCCESS] sudo configured. Log out and back in, then run the script with sudo." ROOTSCRIPT chmod +x "$bootstrap_script" echo "Enter ROOT password:" su -c "bash $bootstrap_script" rm -f "$bootstrap_script" exit 0 } for arg in "$@"; do [[ "$arg" == "--bootstrap" ]] && bootstrap_sudo done #------------------------------------------------------------------------------- # Configuration #------------------------------------------------------------------------------- SCRIPT_VERSION="6.0.0" # Default values STATIC_IP="" GATEWAY="192.168.0.1" DNS_SERVERS_STR="1.1.1.1,8.8.8.8" INTERFACE="" BASE_DOMAIN="homeserver.local" ACME_EMAIL="" CF_DNS_API_TOKEN="" TRAEFIK_PASSWORD="" SKIP_FILEBROWSER=false DRY_RUN=false SELF_SIGNED_CERT=false DEBUG_MODE=false CUSTOMER_ID="" CF_TUNNEL_TOKEN="" # Directories DOCKER_DATA_DIR="/opt/docker" TRAEFIK_DIR="${DOCKER_DATA_DIR}/traefik" FILEBROWSER_DIR="${DOCKER_DATA_DIR}/stacks/filebrowser" CLOUDFLARED_DIR="${DOCKER_DATA_DIR}/cloudflared" CERTS_DIR="${TRAEFIK_DIR}/certs" #------------------------------------------------------------------------------- # Colors and logging #------------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' 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_step() { echo -e "${BLUE}[STEP]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } log_skip() { echo -e "${CYAN}[SKIP]${NC} $1"; } log_debug() { [[ "$DEBUG_MODE" == true ]] && echo -e "${CYAN}[DEBUG]${NC} $1" || true; } on_error() { echo "" log_error "Script failed at line $1. Collecting diagnostics..." echo "--- Network ---" ip a 2>/dev/null || true ip r 2>/dev/null || true echo "--- DNS ---" cat /etc/resolv.conf 2>/dev/null || true echo "--- DNS Test ---" getent hosts download.docker.com 2>/dev/null || echo "DNS resolution failed" echo "--- Docker ---" systemctl --no-pager status docker 2>/dev/null || true docker ps 2>/dev/null || true echo "--- Network Manager Detection ---" detect_network_manager_debug 2>/dev/null || true } trap 'on_error $LINENO' ERR print_banner() { echo "" echo -e "${BOLD}${BLUE}-==================================================================¬${NC}" echo -e "${BOLD}${BLUE}¦ Felhom Infrastructure Setup v${SCRIPT_VERSION} ¦${NC}" echo -e "${BOLD}${BLUE}L==================================================================-${NC}" echo "" } print_help() { cat << 'EOF' Felhom Infrastructure Setup v6.0 Prepares a Debian 13 server for Felhom homeserver deployment. Installs infrastructure, generates a minimal controller.yaml, and deploys felhom-controller. Full configuration is completed via the web-based setup wizard at http://:8081. USAGE: ./docker-setup.sh --bootstrap # First, on fresh Debian sudo ./docker-setup.sh [OPTIONS] # Then, full setup OPTIONS: --bootstrap Install sudo (run first on fresh Debian) --domain DOMAIN Base domain for services (required) --customer ID Customer identifier (optional, set in web wizard) --ip ADDRESS Static IP address --gateway ADDRESS Gateway (default: 192.168.0.1) --dns ADDRESS DNS servers, comma-separated (default: 1.1.1.1,8.8.8.8) --interface NAME Network interface (default: auto-detect) --email EMAIL Email for Let's Encrypt --cf-token TOKEN Cloudflare API token for DNS-01 TLS --cf-tunnel-token TK Cloudflare Tunnel token (optional) --traefik-password PW Password for Traefik dashboard (default: auto-generated) --self-signed-cert Generate self-signed wildcard certificate (fallback) --skip-filebrowser Skip FileBrowser installation --dry-run Show what would be done --debug Enable verbose debug output -h, --help Show this help TLS CERTIFICATE OPTIONS: There are three TLS modes (in order of preference): 1. Let's Encrypt + Cloudflare DNS (recommended for Felhom customers): --email certs@felhom.eu --cf-token 2. Let's Encrypt + HTTP-01 (public servers without Cloudflare Tunnel): --email admin@example.com Requires port 80 open to the internet. 3. Self-signed certificate (offline / no internet): --self-signed-cert WHAT THIS SCRIPT INSTALLS: 1. Base packages (curl, git, htop, etc.) 2. Docker Engine + Docker Compose 3. Traefik reverse proxy (with dashboard) 4. TLS certificates (Let's Encrypt or self-signed) 5. Felhom Controller (with web-based setup wizard on :8081) 6. FileBrowser Quantum (web file manager at files.) 7. Cloudflare Tunnel (if --cf-tunnel-token provided) 8. Helper tools (ctop, lazydocker, shell aliases) EXAMPLES: # Felhom customer deployment (recommended) sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ --email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx # Minimal (setup wizard handles everything) sudo ./docker-setup.sh --domain example.com # Self-signed cert (offline/testing) sudo ./docker-setup.sh --domain example.com --self-signed-cert # Full setup with static IP + Cloudflare Tunnel sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ --ip 192.168.0.50 --email certs@felhom.eu --cf-token cf-xxx \ --cf-tunnel-token eyJhIjoi... EOF } #------------------------------------------------------------------------------- # Argument parsing (with missing-value protection for set -u) #------------------------------------------------------------------------------- require_arg() { if [[ $# -lt 2 || "$2" == --* ]]; then log_error "Option '$1' requires a value" exit 1 fi } parse_args() { while [[ $# -gt 0 ]]; do case $1 in --bootstrap) bootstrap_sudo ;; --ip) require_arg "$1" "${2:-}" STATIC_IP="$2"; shift 2 ;; --gateway) require_arg "$1" "${2:-}" GATEWAY="$2"; shift 2 ;; --dns) require_arg "$1" "${2:-}" DNS_SERVERS_STR="$2"; shift 2 ;; --interface) require_arg "$1" "${2:-}" INTERFACE="$2"; shift 2 ;; --domain) require_arg "$1" "${2:-}" BASE_DOMAIN="$2"; shift 2 ;; --email) require_arg "$1" "${2:-}" ACME_EMAIL="$2"; shift 2 ;; --cf-token) require_arg "$1" "${2:-}" CF_DNS_API_TOKEN="$2"; shift 2 ;; --traefik-password) require_arg "$1" "${2:-}" TRAEFIK_PASSWORD="$2"; shift 2 ;; --customer) require_arg "$1" "${2:-}" CUSTOMER_ID="$2"; shift 2 ;; --cf-tunnel-token) require_arg "$1" "${2:-}" CF_TUNNEL_TOKEN="$2"; shift 2 ;; --self-signed-cert) SELF_SIGNED_CERT=true; shift ;; --skip-filebrowser) SKIP_FILEBROWSER=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --debug) DEBUG_MODE=true; shift ;; -h|--help) print_help; exit 0 ;; *) log_error "Unknown option: $1"; print_help; exit 1 ;; esac done # Generate Traefik password if not provided (use enough random bytes) if [[ -z "$TRAEFIK_PASSWORD" ]]; then TRAEFIK_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 16) # Fallback if openssl somehow produces too few chars if [[ ${#TRAEFIK_PASSWORD} -lt 12 ]]; then TRAEFIK_PASSWORD=$(head -c 32 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16) fi fi # Validate IP format if provided if [[ -n "$STATIC_IP" ]]; then if ! [[ "$STATIC_IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then log_error "Invalid IP address format: $STATIC_IP" exit 1 fi # Validate each octet is 0-255 IFS='.' read -ra OCTETS <<< "$STATIC_IP" for octet in "${OCTETS[@]}"; do if (( octet > 255 )); then log_error "Invalid IP address (octet > 255): $STATIC_IP" exit 1 fi done fi # Validate gateway format if static IP is set if [[ -n "$STATIC_IP" && -n "$GATEWAY" ]]; then if ! [[ "$GATEWAY" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then log_error "Invalid gateway address format: $GATEWAY" exit 1 fi fi # Validate TLS configuration if [[ -n "$CF_DNS_API_TOKEN" && -z "$ACME_EMAIL" ]]; then log_error "--cf-token requires --email for Let's Encrypt registration" exit 1 fi if [[ -n "$ACME_EMAIL" && -z "$CF_DNS_API_TOKEN" ]]; then log_warn "Let's Encrypt with HTTP-01 challenge (no --cf-token)" log_warn "This requires port 80 open to the internet" log_warn "It will NOT work with Cloudflare Tunnel!" log_warn "For Cloudflare Tunnel setups, use: --email EMAIL --cf-token TOKEN" fi if [[ -n "$ACME_EMAIL" && "$SELF_SIGNED_CERT" == true ]]; then log_warn "Both --email and --self-signed-cert specified" log_warn "Let's Encrypt will be used; self-signed cert will serve as fallback" fi # Validate CUSTOMER_ID format if provided if [[ -n "$CUSTOMER_ID" ]]; then if [[ ! "$CUSTOMER_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then log_error "Customer ID must be alphanumeric (hyphens/underscores allowed): $CUSTOMER_ID" exit 1 fi fi } #------------------------------------------------------------------------------- # Helper functions #------------------------------------------------------------------------------- check_root() { if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root (use sudo)" exit 1 fi } check_debian() { if ! grep -qi "debian" /etc/os-release 2>/dev/null; then log_warn "This script is designed for Debian. Proceeding anyway..." fi } get_current_user() { if [[ -n "${SUDO_USER:-}" ]]; then echo "$SUDO_USER" else echo "root" fi } get_server_ip() { if [[ -n "$STATIC_IP" ]]; then echo "$STATIC_IP" else hostname -I | awk '{print $1}' fi } detect_interface() { if [[ -n "$INTERFACE" ]]; then echo "$INTERFACE" else ip route | grep default | awk '{print $5}' | head -1 fi } # Detect which network manager is active on the system # Returns: "ifupdown", "networkmanager", "systemd-networkd", or "unknown" detect_network_manager() { # Check what's actively managing the default route interface if systemctl is-active --quiet NetworkManager 2>/dev/null; then echo "networkmanager" elif systemctl is-active --quiet systemd-networkd 2>/dev/null; then echo "systemd-networkd" elif [[ -f /etc/network/interfaces ]] && dpkg -l ifupdown 2>/dev/null | grep -q '^ii'; then echo "ifupdown" else echo "unknown" fi } # Debug version for error diagnostics detect_network_manager_debug() { echo "NetworkManager: $(systemctl is-active NetworkManager 2>/dev/null || echo 'not found')" echo "systemd-networkd: $(systemctl is-active systemd-networkd 2>/dev/null || echo 'not found')" echo "ifupdown installed: $(dpkg -l ifupdown 2>/dev/null | grep -q '^ii' && echo 'yes' || echo 'no')" echo "/etc/network/interfaces exists: $([[ -f /etc/network/interfaces ]] && echo 'yes' || echo 'no')" echo "Active network manager: $(detect_network_manager)" } # Calculate total number of steps dynamically get_total_steps() { local total=7 # base: packages, network, docker, traefik, cert, controller, tools [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((total++)) [[ "$SKIP_FILEBROWSER" != true ]] && ((total++)) echo "$total" } #------------------------------------------------------------------------------- # Step 1: Install base packages #------------------------------------------------------------------------------- install_base_packages() { log_step "1/$(get_total_steps) - Installing base packages..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would install: curl wget gnupg lsb-release ca-certificates git htop jq apache2-utils openssl" return fi apt-get update -qq apt-get install -qq -y \ curl \ wget \ gnupg \ lsb-release \ ca-certificates \ apt-transport-https \ git \ htop \ jq \ apache2-utils \ openssl \ resolvconf \ > /dev/null log_success "Base packages installed" } #------------------------------------------------------------------------------- # Step 2: Configure static IP (optional, auto-detects network manager) #------------------------------------------------------------------------------- # Apply DNS immediately so it works without reboot # The network manager hooks will take over on next boot apply_dns_immediately() { local iface="$1" shift local dns_servers=("$@") log_info "Applying DNS immediately..." # Method 1: Feed to resolvconf (if installed) if command -v resolvconf &>/dev/null; then local ns_lines="" for dns in "${dns_servers[@]}"; do ns_lines+="nameserver ${dns}"$'\n' done echo "$ns_lines" | resolvconf -a "${iface}.inet" log_debug "DNS fed to resolvconf for ${iface}.inet" fi # Method 2: Direct write as fallback / belt-and-suspenders # Only if resolv.conf is empty or has no nameserver lines if ! grep -q "^nameserver" /etc/resolv.conf 2>/dev/null; then log_warn "/etc/resolv.conf has no nameservers, writing directly" { echo "# Written by docker-setup.sh as fallback" for dns in "${dns_servers[@]}"; do echo "nameserver ${dns}" done } > /etc/resolv.conf fi # Verify DNS works if getent hosts download.docker.com &>/dev/null; then log_success "DNS resolution working" else log_warn "DNS resolution test failed — may need manual intervention" fi } configure_static_ip() { log_step "2/$(get_total_steps) - Configuring network..." if [[ -z "$STATIC_IP" ]]; then log_skip "No static IP specified, keeping DHCP" return fi if [[ "$DRY_RUN" == true ]]; then local nm_type nm_type=$(detect_network_manager) echo -e "${CYAN}[DRY-RUN]${NC} Would configure static IP: $STATIC_IP (via $nm_type)" return fi local iface iface=$(detect_interface) if [[ -z "$iface" ]]; then log_error "Could not detect network interface. Use --interface to specify." exit 1 fi # Parse DNS servers IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS_STR" local nm_type nm_type=$(detect_network_manager) log_info "Detected network manager: $nm_type" case "$nm_type" in networkmanager) configure_static_ip_nm "$iface" ;; systemd-networkd) configure_static_ip_networkd "$iface" ;; ifupdown) configure_static_ip_ifupdown "$iface" ;; *) log_warn "Unknown network manager. Attempting ifupdown configuration..." # Install ifupdown as fallback if ! dpkg -l ifupdown 2>/dev/null | grep -q '^ii'; then log_info "Installing ifupdown..." apt-get install -qq -y ifupdown > /dev/null fi configure_static_ip_ifupdown "$iface" ;; esac # Apply IP and DNS immediately (don't wait for reboot) log_info "Applying network changes immediately..." # Apply static IP directly via ip command local current_ip current_ip=$(ip -4 addr show "$iface" 2>/dev/null | grep -oP 'inet \K[\d.]+' | head -1) if [[ "$current_ip" != "$STATIC_IP" ]]; then ip addr flush dev "$iface" 2>/dev/null || true ip addr add "${STATIC_IP}/24" dev "$iface" 2>/dev/null || true ip route add default via "$GATEWAY" dev "$iface" 2>/dev/null || true ip link set "$iface" up log_info "Static IP $STATIC_IP applied to $iface" else log_debug "IP already set to $STATIC_IP, skipping" fi # Apply DNS immediately apply_dns_immediately "$iface" "${DNS_ARRAY[@]}" log_success "Network configured" } configure_static_ip_nm() { local iface="$1" log_info "Configuring static IP via NetworkManager..." # Find the active connection name for this interface local conn_name conn_name=$(nmcli -t -f NAME,DEVICE con show --active | grep ":${iface}$" | cut -d: -f1 | head -1) if [[ -z "$conn_name" ]]; then log_warn "No active NM connection for $iface, creating one..." conn_name="static-${iface}" nmcli con add type ethernet ifname "$iface" con-name "$conn_name" fi local dns_space="${DNS_ARRAY[*]}" nmcli con mod "$conn_name" \ ipv4.method manual \ ipv4.addresses "${STATIC_IP}/24" \ ipv4.gateway "$GATEWAY" \ ipv4.dns "${dns_space// /,}" log_info "Static IP configured: $STATIC_IP on $iface (NetworkManager)" log_warn "Run 'nmcli con up \"$conn_name\"' or reboot to apply." } configure_static_ip_networkd() { local iface="$1" log_info "Configuring static IP via systemd-networkd..." local config_file="/etc/systemd/network/10-static-${iface}.network" # Backup existing config if [[ -f "$config_file" ]]; then cp "$config_file" "${config_file}.bak.$(date +%s)" fi local dns_lines="" for dns in "${DNS_ARRAY[@]}"; do dns_lines+="DNS=${dns}"$'\n' done cat > "$config_file" << EOF # Static IP configuration for $iface # Generated by docker-setup.sh on $(date) [Match] Name=$iface [Network] Address=${STATIC_IP}/24 Gateway=$GATEWAY ${dns_lines} EOF log_info "Static IP configured: $STATIC_IP on $iface (systemd-networkd)" log_warn "Run 'systemctl restart systemd-networkd' or reboot to apply." } configure_static_ip_ifupdown() { local iface="$1" log_info "Configuring static IP via ifupdown..." local config_file="/etc/network/interfaces.d/${iface}-static" local main_interfaces="/etc/network/interfaces" # Backup existing drop-in config if [[ -f "$config_file" ]]; then cp "$config_file" "${config_file}.bak.$(date +%s)" fi local dns_line="${DNS_ARRAY[*]}" # Handle conflict: if the interface is configured in /etc/network/interfaces, # we must comment it out or the two configs will fight each other. # On fresh Debian 13, enp* is typically defined here with DHCP. if grep -qE "^[[:space:]]*(auto|allow-hotplug|iface)[[:space:]]+${iface}" "$main_interfaces" 2>/dev/null; then log_warn "Interface $iface is configured in $main_interfaces — commenting out to avoid conflict" cp "$main_interfaces" "${main_interfaces}.bak.$(date +%s)" # Comment out the entire stanza block for this interface # awk handles: header lines (auto/allow-hotplug/iface) AND their # continuation lines (address, netmask, gateway, dns-nameservers, etc.) # which are indented lines following an iface declaration awk -v iface="$iface" ' BEGIN { in_stanza = 0 } /^[[:space:]]*(auto|allow-hotplug|iface)[[:space:]]+/ { if ($0 ~ iface) { in_stanza = 1 print "# [docker-setup] " $0 next } else { in_stanza = 0 } } in_stanza && /^[[:space:]]+/ { print "# [docker-setup] " $0 next } in_stanza && /^[^#[:space:]]/ { in_stanza = 0 } { print } ' "$main_interfaces" > "${main_interfaces}.tmp" && mv "${main_interfaces}.tmp" "$main_interfaces" log_info "Original config backed up and conflicting lines commented out" if [[ "$DEBUG_MODE" == true ]]; then log_debug "Modified $main_interfaces:" grep -n "${iface}" "$main_interfaces" || true fi fi cat > "$config_file" << EOF # Static IP configuration for $iface # Generated by docker-setup.sh on $(date) auto $iface iface $iface inet static address $STATIC_IP netmask 255.255.255.0 gateway $GATEWAY dns-nameservers $dns_line EOF log_info "Static IP configured: $STATIC_IP on $iface (ifupdown)" log_info "Config written to: $config_file" } #------------------------------------------------------------------------------- # Step 3: Install Docker #------------------------------------------------------------------------------- install_docker() { log_step "3/$(get_total_steps) - Installing Docker..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would install Docker Engine + Compose" return fi # Check if Docker is already installed if command -v docker &> /dev/null; then log_info "Docker already installed: $(docker --version)" else # Add Docker's official GPG key install -m 0755 -d /etc/apt/keyrings # Only re-import GPG key if not already present or outdated if [[ ! -f /etc/apt/keyrings/docker.gpg ]] || \ [[ $(find /etc/apt/keyrings/docker.gpg -mtime +30 2>/dev/null) ]]; then curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg log_debug "Docker GPG key imported" else log_debug "Docker GPG key already present and recent, skipping import" fi # Determine codename - fall back to bookworm if trixie repo doesn't exist local codename codename=$(. /etc/os-release && echo "$VERSION_CODENAME") log_debug "Detected Debian codename: $codename" # Add the repository (single line, no extra whitespace) echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${codename} stable" \ > /etc/apt/sources.list.d/docker.list # Test if the repo works, fall back to bookworm if not if ! apt-get update -qq 2>/dev/null; then log_warn "Docker repo for '${codename}' failed, falling back to 'bookworm'..." echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq fi apt-get install -qq -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin > /dev/null fi # Add current user to docker group local current_user current_user=$(get_current_user) if [[ "$current_user" != "root" ]]; then usermod -aG docker "$current_user" log_info "Added $current_user to docker group" fi # Configure Docker DNS fallback in daemon.json # This ensures all containers get proper DNS even if host resolv.conf is empty local daemon_json="/etc/docker/daemon.json" if [[ -f "$daemon_json" ]]; then # Merge dns into existing config (if not already present) if ! grep -q '"dns"' "$daemon_json"; then log_info "Adding DNS fallback to existing $daemon_json" local tmp_json tmp_json=$(mktemp) jq '. + {"dns": ["1.1.1.1", "8.8.8.8"]}' "$daemon_json" > "$tmp_json" 2>/dev/null && \ mv "$tmp_json" "$daemon_json" || { log_warn "Failed to merge daemon.json with jq, overwriting" rm -f "$tmp_json" echo '{ "dns": ["1.1.1.1", "8.8.8.8"] }' > "$daemon_json" } else log_debug "daemon.json already has DNS config" fi else log_info "Creating $daemon_json with DNS fallback" echo '{ "dns": ["1.1.1.1", "8.8.8.8"] }' > "$daemon_json" fi # Enable and start Docker systemctl enable --now docker # Wait for Docker to be fully ready log_info "Waiting for Docker daemon..." local docker_ready=false for i in {1..30}; do if docker info &>/dev/null; then docker_ready=true log_debug "Docker ready after ${i}s" break fi sleep 1 done if [[ "$docker_ready" != true ]]; then log_error "Docker daemon did not start within 30 seconds" systemctl --no-pager status docker || true exit 1 fi # Create docker network for Traefik if docker network inspect traefik-public &>/dev/null; then log_debug "traefik-public network already exists" else docker network create traefik-public log_debug "Created traefik-public network" fi log_success "Docker installed: $(docker --version)" log_success "Docker Compose: $(docker compose version)" } #------------------------------------------------------------------------------- # Step 4: Install Traefik (reverse proxy) #------------------------------------------------------------------------------- install_traefik() { log_step "4/$(get_total_steps) - Installing Traefik reverse proxy..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would install Traefik" return fi mkdir -p "${TRAEFIK_DIR}/dynamic" mkdir -p "${CERTS_DIR}" # Create acme.json for Let's Encrypt certificates touch "${TRAEFIK_DIR}/acme.json" chmod 600 "${TRAEFIK_DIR}/acme.json" # Generate basicAuth hash local auth_hash auth_hash=$(htpasswd -nb admin "$TRAEFIK_PASSWORD") # Determine certificate resolver configuration local cert_resolver_config="" local entrypoint_tls_config="" if [[ -n "$ACME_EMAIL" ]]; then if [[ -n "$CF_DNS_API_TOKEN" ]]; then # DNS-01 challenge via Cloudflare (works with Cloudflare Tunnel) cert_resolver_config=" certificatesResolvers: letsencrypt: acme: email: ${ACME_EMAIL} storage: /etc/traefik/acme.json dnsChallenge: provider: cloudflare resolvers: - \"1.1.1.1:53\" - \"8.8.8.8:53\"" log_info "Let's Encrypt: Cloudflare DNS-01 challenge (works with Tunnel)" else # HTTP-01 challenge (requires port 80 accessible from internet) cert_resolver_config=" certificatesResolvers: letsencrypt: acme: email: ${ACME_EMAIL} storage: /etc/traefik/acme.json httpChallenge: entryPoint: web" log_info "Let's Encrypt: HTTP-01 challenge (requires public port 80)" fi # Set default certResolver on websecure entrypoint # This means ALL routers on websecure automatically get Let's Encrypt certs # without needing certresolver labels on each individual service entrypoint_tls_config=" http: tls: certResolver: letsencrypt" fi # Create Traefik static configuration cat > "${TRAEFIK_DIR}/traefik.yml" << EOF # Traefik Static Configuration # Generated by docker-setup.sh v${SCRIPT_VERSION} api: dashboard: true insecure: false entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" ${entrypoint_tls_config} providers: docker: endpoint: "unix:///var/run/docker.sock" exposedByDefault: false network: traefik-public file: directory: /etc/traefik/dynamic watch: true log: level: INFO accessLog: {} ${cert_resolver_config} EOF # Determine dashboard TLS config local dashboard_tls_config="tls: {}" if [[ -n "$ACME_EMAIL" ]]; then dashboard_tls_config="tls: certResolver: letsencrypt" fi # Create dynamic configuration for Traefik dashboard cat > "${TRAEFIK_DIR}/dynamic/dashboard.yml" << EOF # Traefik Dashboard Configuration http: routers: dashboard: rule: "Host(\`traefik.${BASE_DOMAIN}\`)" service: api@internal entryPoints: - websecure ${dashboard_tls_config} middlewares: - dashboard-auth middlewares: dashboard-auth: basicAuth: users: - "${auth_hash}" EOF # Create Cloudflare .env file if DNS-01 challenge is used local cf_env_section="" if [[ -n "$CF_DNS_API_TOKEN" ]]; then cat > "${TRAEFIK_DIR}/.env" << ENVEOF # Cloudflare API token for Let's Encrypt DNS-01 challenge # Token needs Zone:DNS:Edit permission for the domain zone CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} ENVEOF chmod 600 "${TRAEFIK_DIR}/.env" cf_env_section=" env_file: - .env" log_info "Cloudflare API token stored in ${TRAEFIK_DIR}/.env" fi # Create docker-compose.yml for Traefik cat > "${TRAEFIK_DIR}/docker-compose.yml" << EOF # Traefik Reverse Proxy services: traefik: image: traefik:v3.6.7 container_name: traefik restart: unless-stopped dns: - 1.1.1.1 - 8.8.8.8 security_opt: - no-new-privileges:true ports: - "80:80" - "443:443" ${cf_env_section} volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yml:/etc/traefik/traefik.yml:ro - ./dynamic:/etc/traefik/dynamic:ro - ./acme.json:/etc/traefik/acme.json - ./certs:/etc/traefik/certs:ro networks: - traefik-public networks: traefik-public: external: true EOF # Start Traefik cd "${TRAEFIK_DIR}" log_info "Pulling Traefik image..." docker compose pull -q log_info "Starting Traefik..." docker compose up -d --quiet-pull # Verify Traefik started sleep 3 if docker ps --filter "name=traefik" --filter "status=running" -q | grep -q .; then log_success "Traefik installed and running" else log_error "Traefik container is not running!" docker compose logs --tail=20 traefik exit 1 fi } #------------------------------------------------------------------------------- # Step 4b: Install Cloudflare Tunnel connector (optional) #------------------------------------------------------------------------------- install_cloudflare_tunnel() { if [[ -z "${CF_TUNNEL_TOKEN:-}" ]]; then log_skip "Cloudflare Tunnel not configured (use --cf-tunnel-token to enable)" return fi local step_num=5 [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) log_step "${step_num}/$(get_total_steps) - Installing Cloudflare Tunnel connector..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would deploy cloudflared tunnel connector" return fi mkdir -p "$CLOUDFLARED_DIR" cat > "${CLOUDFLARED_DIR}/docker-compose.yml" << EOF # Cloudflare Tunnel — External access connector # Routes are configured in the Cloudflare dashboard: # Zero Trust > Networks > Tunnels > > Public Hostname # # This container connects to Cloudflare's edge network and routes # external traffic to Traefik (which handles TLS + routing internally). # # Deployed by docker-setup.sh v${SCRIPT_VERSION} services: cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN="${CF_TUNNEL_TOKEN}" dns: - 1.1.1.1 - 8.8.8.8 security_opt: - no-new-privileges:true networks: - traefik-public networks: traefik-public: external: true EOF cd "$CLOUDFLARED_DIR" log_info "Pulling cloudflared image..." docker compose pull -q log_info "Starting Cloudflare Tunnel..." docker compose up -d --quiet-pull # cloudflared takes a few seconds to establish the tunnel sleep 5 if docker ps --filter "name=cloudflared" --filter "status=running" -q | grep -q .; then # Check logs for successful connection indicator if docker logs cloudflared 2>&1 | grep -qi "registered tunnel connection"; then log_success "Cloudflare Tunnel connected" else log_success "Cloudflare Tunnel container running (connection establishing...)" log_info "Verify with: docker logs cloudflared" fi else log_warn "cloudflared container may not be running yet" log_warn "Check: docker logs cloudflared" log_warn "This is non-fatal — tunnel will retry connection automatically" fi } #------------------------------------------------------------------------------- # Step 5: Generate Self-Signed Certificate (optional) #------------------------------------------------------------------------------- generate_self_signed_cert() { if [[ "$SELF_SIGNED_CERT" != true ]]; then log_skip "Self-signed certificate not requested" return fi log_step "5/$(get_total_steps) - Generating self-signed certificate..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would generate self-signed cert for *.${BASE_DOMAIN}" return fi cd "${CERTS_DIR}" # 1. Create CA (Certificate Authority) log_info "Creating Certificate Authority..." openssl genrsa -out ca.key 4096 2>/dev/null openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ -out ca.crt \ -subj "/C=HU/ST=Budapest/L=Budapest/O=${BASE_DOMAIN}/CN=${BASE_DOMAIN} CA" 2>/dev/null # 2. Create wildcard certificate config cat > "${BASE_DOMAIN}.cnf" << EOF [req] distinguished_name = req_distinguished_name req_extensions = v3_req prompt = no [req_distinguished_name] C = HU ST = Budapest L = Budapest O = ${BASE_DOMAIN} CN = *.${BASE_DOMAIN} [v3_req] basicConstraints = CA:FALSE keyUsage = critical, digitalSignature, keyEncipherment extendedKeyUsage = serverAuth subjectAltName = @alt_names [alt_names] DNS.1 = *.${BASE_DOMAIN} DNS.2 = ${BASE_DOMAIN} EOF # 3. Generate key and CSR log_info "Generating wildcard certificate for *.${BASE_DOMAIN}..." openssl genrsa -out "${BASE_DOMAIN}.key" 2048 2>/dev/null openssl req -new -key "${BASE_DOMAIN}.key" -out "${BASE_DOMAIN}.csr" -config "${BASE_DOMAIN}.cnf" 2>/dev/null # 4. Sign with CA openssl x509 -req -in "${BASE_DOMAIN}.csr" \ -CA ca.crt -CAkey ca.key -CAcreateserial \ -out "${BASE_DOMAIN}.crt" -days 825 -sha256 \ -extfile "${BASE_DOMAIN}.cnf" -extensions v3_req 2>/dev/null # 5. Set permissions (explicit file names, not globs) chmod 600 ca.key "${BASE_DOMAIN}.key" chmod 644 ca.crt "${BASE_DOMAIN}.crt" # 6. Create Traefik TLS configuration cat > "${TRAEFIK_DIR}/dynamic/certs.yml" << EOF # TLS Certificate Configuration tls: certificates: - certFile: /etc/traefik/certs/${BASE_DOMAIN}.crt keyFile: /etc/traefik/certs/${BASE_DOMAIN}.key stores: default: defaultCertificate: certFile: /etc/traefik/certs/${BASE_DOMAIN}.crt keyFile: /etc/traefik/certs/${BASE_DOMAIN}.key EOF # 7. Restart Traefik to load new certs cd "${TRAEFIK_DIR}" docker compose restart traefik # Verify Traefik is still running after restart sleep 3 if ! docker ps --filter "name=traefik" --filter "status=running" -q | grep -q .; then log_error "Traefik failed to restart after cert update!" docker compose logs --tail=20 traefik exit 1 fi # 8. Copy CA cert to accessible location local current_user current_user=$(get_current_user) local user_home user_home=$(eval echo "~${current_user}") cp "${CERTS_DIR}/ca.crt" "${user_home}/${BASE_DOMAIN}-ca.crt" chown "${current_user}:${current_user}" "${user_home}/${BASE_DOMAIN}-ca.crt" log_success "Self-signed certificate generated" echo "" echo -e "${BOLD}${YELLOW}===================================================================${NC}" echo -e "${BOLD}${YELLOW} IMPORTANT: Import the CA certificate on your devices!${NC}" echo -e "${BOLD}${YELLOW}===================================================================${NC}" echo "" echo -e "CA certificate location: ${BOLD}${user_home}/${BASE_DOMAIN}-ca.crt${NC}" echo "" echo -e "${BOLD}Windows:${NC}" echo " 1. Copy ca.crt to your Windows machine" echo " 2. Double-click > Install Certificate > Local Machine" echo " 3. Place in 'Trusted Root Certification Authorities'" echo " 4. Restart browser" echo "" echo -e "${BOLD}macOS:${NC}" echo " 1. Double-click ca.crt > Add to System keychain" echo " 2. Keychain Access > Find '${BASE_DOMAIN} CA' > Trust > Always Trust" echo "" echo -e "${BOLD}Linux:${NC}" echo " sudo cp ${user_home}/${BASE_DOMAIN}-ca.crt /usr/local/share/ca-certificates/" echo " sudo update-ca-certificates" echo "" } #------------------------------------------------------------------------------- # Install FileBrowser Quantum (infrastructure file manager) #------------------------------------------------------------------------------- install_filebrowser() { if [[ "$SKIP_FILEBROWSER" == true ]]; then log_skip "FileBrowser installation skipped" return fi # Calculate step number dynamically local step_num=5 [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((step_num++)) log_step "${step_num}/$(get_total_steps) - Installing FileBrowser Quantum..." # Discover drive mounts for FileBrowser volumes local volume_lines="" local mount_comment="" local found_mounts=0 # Scan /mnt/ for existing mount points (e.g., /mnt/hdd_1, /mnt/sys_drive) if [[ -d /mnt ]]; then for mp in /mnt/*/; do [[ ! -d "$mp" ]] && continue local name name=$(basename "$mp") # Skip hidden dirs and raw mount dirs [[ "$name" == .* ]] && continue [[ "$name" == .felhom-raw ]] && continue volume_lines+=" - \"${mp%/}:/srv/${name}\""$'\n' mount_comment+=" # ${mp%/} → /srv/${name}"$'\n' ((found_mounts++)) done fi # Note: system_data_path is now configured via the web setup wizard, # FileBrowser mounts will be synced by the controller after setup completes. if [[ $found_mounts -eq 0 ]]; then log_warn "No mount points found in /mnt/ — FileBrowser will have no drive volumes." log_warn "Drives can be attached later via the controller dashboard." fi if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would install FileBrowser Quantum at files.${BASE_DOMAIN}" if [[ $found_mounts -gt 0 ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would mount ${found_mounts} drive(s)" fi return fi mkdir -p "${FILEBROWSER_DIR}" # Build certresolver label if Let's Encrypt is configured local fb_certresolver_label="" if [[ -n "$ACME_EMAIL" ]]; then fb_certresolver_label=' - "traefik.http.routers.filebrowser.tls.certresolver=letsencrypt"' fi # Create docker-compose.yml cat > "${FILEBROWSER_DIR}/docker-compose.yml" << EOF # FileBrowser Quantum — Infrastructure file manager # Domain: files.${BASE_DOMAIN} # Deployed by docker-setup.sh v${SCRIPT_VERSION} services: filebrowser: image: gtstef/filebrowser:latest container_name: filebrowser restart: unless-stopped environment: - TZ=Europe/Budapest volumes: - filebrowser_data:/home/filebrowser/data ${volume_lines} networks: - traefik-public deploy: resources: limits: memory: 256M healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"] interval: 30s timeout: 5s retries: 3 start_period: 15s labels: - "traefik.enable=true" - "traefik.http.routers.filebrowser.rule=Host(\`files.${BASE_DOMAIN}\`)" - "traefik.http.routers.filebrowser.entrypoints=websecure" - "traefik.http.routers.filebrowser.tls=true" ${fb_certresolver_label} - "traefik.http.services.filebrowser.loadbalancer.server.port=80" - "traefik.docker.network=traefik-public" volumes: filebrowser_data: networks: traefik-public: external: true EOF # Create .felhom.yml metadata cat > "${FILEBROWSER_DIR}/.felhom.yml" << 'METAEOF' display_name: Filebrowser slug: filebrowser description: Fájlkezelő a külső merevlemezhez subdomain: files category: storage resources: mem_request: "128M" mem_limit: "256M" pi_compatible: true needs_hdd: true app_info: tagline: Web-alapú fájlkezelő a külső merevlemezhez use_cases: - Fájlok böngészése és letöltése a külső HDD-ről - Médiafájlok megosztása családtagokkal - Dokumentumok feltöltése és kezelése first_steps: - Nyisd meg a files.DOMAIN címet a böngészőben - Jelentkezz be az admin fiókkal - Tallózd a /srv mappákat prerequisites: - Külső HDD csatlakoztatva és felcsatolva METAEOF cd "${FILEBROWSER_DIR}" log_info "Pulling FileBrowser image..." docker compose pull -q log_info "Starting FileBrowser..." docker compose up -d --quiet-pull # Verify FileBrowser started sleep 3 if docker ps --filter "name=filebrowser" --filter "status=running" -q | grep -q .; then log_success "FileBrowser installed and running at files.${BASE_DOMAIN}" else log_error "FileBrowser container is not running!" docker compose logs --tail=20 filebrowser exit 1 fi } #------------------------------------------------------------------------------- # Install helper tools and configure environment #------------------------------------------------------------------------------- install_tools_and_configure() { log_info "Installing helper tools..." if [[ "$DRY_RUN" == true ]]; then return fi # ctop - container metrics if ! command -v ctop &> /dev/null; then log_info "Installing ctop..." curl -fsSL https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64 -o /usr/local/bin/ctop 2>/dev/null || log_warn "Failed to download ctop" chmod +x /usr/local/bin/ctop 2>/dev/null || true fi # lazydocker - TUI for Docker if ! command -v lazydocker &> /dev/null; then log_info "Installing lazydocker..." local LD_VERSION LD_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazydocker/releases/latest 2>/dev/null | jq -r '.tag_name' | tr -d 'v') if [[ -n "$LD_VERSION" && "$LD_VERSION" != "null" ]]; then curl -fsSL "https://github.com/jesseduffield/lazydocker/releases/download/v${LD_VERSION}/lazydocker_${LD_VERSION}_Linux_x86_64.tar.gz" 2>/dev/null \ | tar xz -C /usr/local/bin lazydocker 2>/dev/null || log_warn "Failed to download lazydocker" else log_warn "Could not determine lazydocker version, skipping" fi fi # Configure shell for current user local current_user current_user=$(get_current_user) local user_home user_home=$(eval echo "~${current_user}") local bashrc="${user_home}/.bashrc" # Add Docker aliases if not present if [[ -f "$bashrc" ]] && ! grep -q "# Docker aliases" "$bashrc" 2>/dev/null; then cat >> "$bashrc" << 'EOF' # Docker aliases alias dc='sudo docker compose' alias dcu='sudo docker compose up -d' alias dcd='sudo docker compose down' alias dcl='sudo docker compose logs -f' alias dps='sudo docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"' alias dlogs='sudo docker logs -f' alias dexec='sudo docker exec -it' alias dprune='sudo docker system prune -af' # Quick access alias traefik-logs='sudo docker logs -f traefik' alias filebrowser-logs='sudo docker logs -f filebrowser' EOF elif [[ ! -f "$bashrc" ]]; then log_warn "No .bashrc found at $bashrc, skipping alias setup" fi log_success "Helper tools installed" } #------------------------------------------------------------------------------- # Generate minimal controller.yaml — full configuration via web UI setup wizard #------------------------------------------------------------------------------- CONTROLLER_DIR="/opt/docker/felhom-controller" generate_minimal_config() { local step_num=5 [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) log_step "${step_num}/$(get_total_steps) - Generating minimal controller.yaml..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would generate minimal controller.yaml (full setup via web UI)" return fi mkdir -p "${CONTROLLER_DIR}" # Build optional customer.id line local customer_id_line="" if [[ -n "$CUSTOMER_ID" ]]; then customer_id_line=" id: \"${CUSTOMER_ID}\"" fi cat > "${CONTROLLER_DIR}/controller.yaml" << YAMLEOF # Auto-generated by docker-setup.sh v${SCRIPT_VERSION} on $(date -u +"%Y-%m-%dT%H:%M:%SZ") # Complete setup via web UI at https://felhom.${BASE_DOMAIN} or http://:8081 customer: ${customer_id_line} domain: "${BASE_DOMAIN}" paths: data_dir: "/opt/docker/felhom-controller/data" stacks_dir: "/opt/docker/stacks" web: listen: ":8080" setup_listen: ":8081" YAMLEOF chmod 600 "${CONTROLLER_DIR}/controller.yaml" log_success "Minimal controller.yaml generated (full config via web setup wizard)" } #------------------------------------------------------------------------------- # Deploy felhom-controller #------------------------------------------------------------------------------- install_controller() { local step_num=5 [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((step_num++)) [[ "$SKIP_FILEBROWSER" != true ]] && ((step_num++)) log_step "${step_num}/$(get_total_steps) - Deploying felhom-controller..." if [[ "$DRY_RUN" == true ]]; then echo -e "${CYAN}[DRY-RUN]${NC} Would deploy felhom-controller at felhom.${BASE_DOMAIN}" return fi mkdir -p "${CONTROLLER_DIR}" # Build certresolver label if Let's Encrypt is configured local ctrl_certresolver_label="" if [[ -n "$ACME_EMAIL" ]]; then ctrl_certresolver_label=' - "traefik.http.routers.controller.tls.certresolver=letsencrypt"' fi cat > "${CONTROLLER_DIR}/docker-compose.yml" << EOF # Felhom Controller — Central management dashboard # Domain: felhom.${BASE_DOMAIN} # Deployed by docker-setup.sh v${SCRIPT_VERSION} services: felhom-controller: image: gitea.dooplex.hu/admin/felhom-controller:latest container_name: felhom-controller restart: unless-stopped privileged: true ports: - "8080:8080" - "8081:8081" volumes: - /var/run/docker.sock:/var/run/docker.sock - ${CONTROLLER_DIR}/controller.yaml:/opt/docker/felhom-controller/controller.yaml - controller-data:/opt/docker/felhom-controller/data - /opt/docker/stacks:/opt/docker/stacks - /srv/backups:/srv/backups - type: bind source: /mnt target: /mnt bind: propagation: rshared - /sys:/host/sys:ro - /etc/os-release:/host/etc/os-release:ro - /etc/hostname:/host/etc/hostname:ro - /dev:/host-dev:rw - /etc/fstab:/host-fstab - /run/udev:/run/udev:ro environment: - TZ=Europe/Budapest - HOST_IP=$(get_server_ip) labels: - "traefik.enable=true" - "traefik.http.routers.controller.rule=Host(\`felhom.${BASE_DOMAIN}\`)" - "traefik.http.routers.controller.entrypoints=websecure" - "traefik.http.routers.controller.tls=true" ${ctrl_certresolver_label} - "traefik.http.services.controller.loadbalancer.server.port=8080" - "traefik.docker.network=traefik-public" - "felhom.managed=true" - "felhom.component=controller" networks: - traefik-public healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] interval: 30s timeout: 5s start_period: 10s retries: 3 volumes: controller-data: networks: traefik-public: external: true EOF cd "${CONTROLLER_DIR}" log_info "Pulling felhom-controller image..." docker compose pull -q log_info "Starting felhom-controller..." docker compose up -d --quiet-pull # Wait for health check sleep 5 if docker ps --filter "name=felhom-controller" --filter "status=running" -q | grep -q .; then log_success "felhom-controller deployed at felhom.${BASE_DOMAIN}" else log_error "felhom-controller container is not running!" docker compose logs --tail=20 felhom-controller exit 1 fi } #------------------------------------------------------------------------------- # Print summary #------------------------------------------------------------------------------- print_summary() { local server_ip server_ip=$(get_server_ip) echo "" echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════════${NC}" echo -e "${BOLD}${GREEN} Felhom Controller telepítve!${NC}" echo "" echo -e "${BOLD}${GREEN} A beállítás folytatásához nyissa meg a böngészőben:${NC}" echo "" echo -e "${BOLD}${GREEN} ► https://felhom.${BASE_DOMAIN} (ajánlott)${NC}" echo "" echo -e "${BOLD}${GREEN} Ha a fenti cím nem érhető el (pl. nincs internet):${NC}" echo -e "${BOLD}${GREEN} ► http://${server_ip}:8081 (helyi hálózat, tartalék)${NC}" echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════════${NC}" echo "" echo -e "${BOLD}Server IP:${NC} ${server_ip}" echo -e "${BOLD}Domain:${NC} *.${BASE_DOMAIN}" if [[ -n "$CUSTOMER_ID" ]]; then echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}" fi echo "" echo -e "${BOLD}Services:${NC}" echo " • Felhom Controller: https://felhom.${BASE_DOMAIN}" echo " L| Config: ${CONTROLLER_DIR}/controller.yaml" echo " L| Setup: http://${server_ip}:8081 (direct HTTP)" echo " • Traefik Dashboard: https://traefik.${BASE_DOMAIN}/dashboard/" echo " L| Credentials: admin / ${TRAEFIK_PASSWORD}" if [[ -n "${CF_TUNNEL_TOKEN:-}" ]]; then echo " • Cloudflare Tunnel: Active (routes configured in CF dashboard)" echo " L| Container: cloudflared" echo " L| Config: ${CLOUDFLARED_DIR}/docker-compose.yml" echo " L| Check: docker logs cloudflared" fi if [[ "$SKIP_FILEBROWSER" != true ]]; then echo " • FileBrowser: https://files.${BASE_DOMAIN}" echo " L| Default login: admin / admin (change immediately!)" fi echo "" echo -e "${BOLD}DNS Setup Required:${NC}" echo " Add wildcard record to your DNS (Pi-hole, router, etc.):" echo " *.${BASE_DOMAIN} → ${server_ip}" echo "" if [[ "$SELF_SIGNED_CERT" == true ]]; then local current_user current_user=$(get_current_user) local user_home user_home=$(eval echo "~${current_user}") echo -e "${BOLD}${YELLOW}Self-Signed Certificate:${NC}" echo " Import CA on your devices: ${user_home}/${BASE_DOMAIN}-ca.crt" echo "" fi if [[ -n "$ACME_EMAIL" && -n "$CF_DNS_API_TOKEN" ]]; then echo -e "${BOLD}${GREEN}TLS: Let's Encrypt (Cloudflare DNS-01)${NC}" echo " Certificates are automatically issued and renewed by Traefik." echo " All *.${BASE_DOMAIN} services get browser-trusted HTTPS." echo " ACME email: ${ACME_EMAIL}" echo " CF token stored in: ${TRAEFIK_DIR}/.env" echo "" elif [[ -n "$ACME_EMAIL" ]]; then echo -e "${BOLD}${YELLOW}TLS: Let's Encrypt (HTTP-01)${NC}" echo " Requires port 80 accessible from the internet." echo " Will NOT work with Cloudflare Tunnel!" echo " ACME email: ${ACME_EMAIL}" echo "" fi echo -e "${BOLD}Quick Commands:${NC}" echo " dps → List containers" echo " dlogs → View container logs" echo " lazydocker → TUI for Docker management" echo "" echo -e "${CYAN}Log out and back in for Docker group membership to take effect.${NC}" echo "" } #------------------------------------------------------------------------------- # Main #------------------------------------------------------------------------------- main() { parse_args "$@" # Root check BEFORE setting up logging to /var/log check_root # Now safe to set up logging (we know we're root) LOG_FILE="/var/log/docker-setup.log" mkdir -p /var/log 2>/dev/null || true exec > >(tee -a "$LOG_FILE") 2>&1 # Enable bash tracing if --debug if [[ "$DEBUG_MODE" == true ]]; then set -x fi print_banner check_debian # Show plan echo "" echo -e "${BOLD}Installation Plan:${NC}" echo " 1. Install base packages" echo " 2. Configure network (Static IP: ${STATIC_IP:-DHCP})" echo " 3. Install Docker Engine + Compose" echo " 4. Install Traefik reverse proxy" if [[ "$SELF_SIGNED_CERT" == true ]]; then echo " 5. Generate self-signed certificate" fi echo " - Generate minimal controller.yaml" echo " - Install Cloudflare Tunnel: $([[ -n "$CF_TUNNEL_TOKEN" ]] && echo "yes" || echo "skip")" echo " - Install FileBrowser: $([[ "$SKIP_FILEBROWSER" == true ]] && echo "skip" || echo "yes (auto-discover drives)")" echo " - Deploy felhom-controller" echo " - Install helper tools (ctop, lazydocker, aliases)" echo "" echo " Domain: *.${BASE_DOMAIN}" echo " Customer: ${CUSTOMER_ID:-}" echo " Traefik password: ${TRAEFIK_PASSWORD}" if [[ -n "$ACME_EMAIL" && -n "$CF_DNS_API_TOKEN" ]]; then echo -e " TLS: ${GREEN}Let's Encrypt (Cloudflare DNS-01)${NC}" echo " ACME email: ${ACME_EMAIL}" echo " CF DNS token: ${CF_DNS_API_TOKEN:0:8}...${CF_DNS_API_TOKEN: -4} (masked)" elif [[ -n "$ACME_EMAIL" ]]; then echo -e " TLS: ${YELLOW}Let's Encrypt (HTTP-01 — needs public port 80)${NC}" echo " ACME email: ${ACME_EMAIL}" elif [[ "$SELF_SIGNED_CERT" == true ]]; then echo -e " TLS: ${YELLOW}Self-signed certificate${NC}" else echo -e " TLS: ${RED}None (Traefik default cert)${NC}" fi echo "" if [[ "$DRY_RUN" == true ]]; then echo -e "${YELLOW}DRY RUN - no changes will be made${NC}" echo "" fi read -p "Continue? [y/N] " -n 1 -r echo [[ ! $REPLY =~ ^[Yy]$ ]] && exit 0 install_base_packages configure_static_ip install_docker install_traefik generate_self_signed_cert generate_minimal_config install_cloudflare_tunnel install_filebrowser install_controller install_tools_and_configure print_summary } main "$@"