36afd828a1
The gtstef/filebrowser image bakes FILEBROWSER_CONFIG=/home/filebrowser/data/config.yaml, but controller mounts config at /home/filebrowser/config.yaml. Override the env var in both generateFileBrowserCompose() and docker-setup.sh so FileBrowser reads the controller-managed config with proper sources and database path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1942 lines
67 KiB
Bash
1942 lines
67 KiB
Bash
#!/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)
|
|
# --hub-customer ID Hub mode: pre-seed setup wizard with customer ID
|
|
# --hub-password PW Hub mode: retrieval password for setup 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
|
|
#
|
|
# Hub mode example:
|
|
# sudo ./docker-setup.sh --hub-customer demo-felhom --hub-password <retrieval-pw>
|
|
#
|
|
#===============================================================================
|
|
|
|
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=""
|
|
HUB_CUSTOMER=""
|
|
HUB_PASSWORD=""
|
|
HUB_CONFIG_TMP="" # path to downloaded hub config temp file (set by apply_hub_config)
|
|
DOMAIN_FROM_CLI=false
|
|
EMAIL_FROM_CLI=false
|
|
CF_TOKEN_FROM_CLI=false
|
|
CF_TUNNEL_FROM_CLI=false
|
|
|
|
# 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://<ip>: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)
|
|
--hub-customer ID Hub mode: pre-seed setup wizard with customer ID
|
|
--hub-password PW Hub mode: retrieval password for setup 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 <cloudflare-api-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.<domain>)
|
|
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...
|
|
|
|
# Hub mode — setup wizard with pre-seeded credentials (offers restore/fresh choice)
|
|
sudo ./docker-setup.sh --hub-customer demo-felhom --hub-password <retrieval-password>
|
|
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"; DOMAIN_FROM_CLI=true; shift 2 ;;
|
|
--email)
|
|
require_arg "$1" "${2:-}"
|
|
ACME_EMAIL="$2"; EMAIL_FROM_CLI=true; shift 2 ;;
|
|
--cf-token)
|
|
require_arg "$1" "${2:-}"
|
|
CF_DNS_API_TOKEN="$2"; CF_TOKEN_FROM_CLI=true; 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"; CF_TUNNEL_FROM_CLI=true; shift 2 ;;
|
|
--hub-customer)
|
|
require_arg "$1" "${2:-}"
|
|
HUB_CUSTOMER="$2"; shift 2 ;;
|
|
--hub-password)
|
|
require_arg "$1" "${2:-}"
|
|
HUB_PASSWORD="$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
|
|
|
|
# Validate hub mode: both flags must be used together
|
|
if [[ -n "$HUB_CUSTOMER" && -z "$HUB_PASSWORD" ]]; then
|
|
log_error "--hub-customer requires --hub-password"
|
|
exit 1
|
|
fi
|
|
if [[ -n "$HUB_PASSWORD" && -z "$HUB_CUSTOMER" ]]; then
|
|
log_error "--hub-password requires --hub-customer"
|
|
exit 1
|
|
fi
|
|
if [[ -n "$HUB_CUSTOMER" ]]; then
|
|
if [[ ! "$HUB_CUSTOMER" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
|
log_error "Hub customer ID must be alphanumeric (hyphens/underscores allowed): $HUB_CUSTOMER"
|
|
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 > <tunnel-name> > 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=$(( step_num + 1 ))
|
|
[[ -n "${CF_TUNNEL_TOKEN:-}" ]] && step_num=$(( step_num + 1 ))
|
|
log_step "${step_num}/$(get_total_steps) - Installing FileBrowser Quantum..."
|
|
|
|
# Note: FileBrowser drive volumes are managed entirely by the controller.
|
|
# SyncFileBrowserMounts() runs on controller startup and regenerates
|
|
# docker-compose.yml + config.yaml whenever storage paths are added/removed.
|
|
# docker-setup.sh writes an empty initial config; the controller takes over immediately.
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Would install FileBrowser Quantum at files.${BASE_DOMAIN}"
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Drive volumes managed by controller (empty on initial install)"
|
|
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
|
|
- FILEBROWSER_CONFIG=/home/filebrowser/config.yaml
|
|
volumes:
|
|
- filebrowser_data:/home/filebrowser/data
|
|
- ./config.yaml:/home/filebrowser/config.yaml:ro
|
|
# Storage volumes are auto-managed by felhom-controller (SyncFileBrowserMounts)
|
|
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 initial config.yaml (no storage sources — controller syncs on startup)
|
|
cat > "${FILEBROWSER_DIR}/config.yaml" << 'CONFIGEOF'
|
|
# FileBrowser Quantum — managed by felhom-controller
|
|
# WARNING: This file is auto-generated. Manual edits will be overwritten.
|
|
|
|
server:
|
|
port: 80
|
|
baseURL: "/"
|
|
database: "/home/filebrowser/data/database.db"
|
|
logging:
|
|
- levels: "info|warning|error"
|
|
sources:
|
|
- path: "/srv"
|
|
userDefaults:
|
|
stickySidebar: true
|
|
darkMode: true
|
|
viewMode: "normal"
|
|
showHidden: false
|
|
dateFormat: false
|
|
gallerySize: 3
|
|
themeColor: "var(--blue)"
|
|
preview:
|
|
disableHideSidebar: false
|
|
highQuality: true
|
|
image: true
|
|
video: true
|
|
motionVideoPreview: true
|
|
office: true
|
|
popup: true
|
|
autoplayMedia: true
|
|
folder: true
|
|
permissions:
|
|
api: false
|
|
admin: false
|
|
modify: false
|
|
share: false
|
|
realtime: false
|
|
delete: false
|
|
create: false
|
|
download: true
|
|
CONFIGEOF
|
|
|
|
# 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"
|
|
}
|
|
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# YAML helper: extract a single string value from a section+key
|
|
# Usage: yaml_get <file> <top-level-section> <key>
|
|
# Handles both quoted ("value") and unquoted values.
|
|
#-------------------------------------------------------------------------------
|
|
yaml_get() {
|
|
local file="$1" section="$2" key="$3"
|
|
awk -v s="${section}:" -v k="${key}:" '
|
|
/^[[:alpha:]]/ { in_s = ($0 == s) }
|
|
in_s && /^[[:space:]]/ {
|
|
line = $0; sub(/^[[:space:]]+/, "", line)
|
|
if (index(line, k) == 1) {
|
|
sub(/^[^:]*:[[:space:]]*/, "", line)
|
|
gsub(/^"|"$/, "", line)
|
|
print line; exit
|
|
}
|
|
}
|
|
' "$file"
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Hub mode: download controller.yaml early and extract infra vars
|
|
# Called from main() before Traefik/infra setup so BASE_DOMAIN etc. are ready.
|
|
#-------------------------------------------------------------------------------
|
|
apply_hub_config() {
|
|
[[ -z "$HUB_CUSTOMER" ]] && return
|
|
|
|
log_info "Fetching configuration from Felhom Hub (customer: ${HUB_CUSTOMER})..."
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Would fetch: https://hub.felhom.eu/api/v1/config/${HUB_CUSTOMER}"
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Would apply domain, email, CF tokens from hub config"
|
|
# Set plausible placeholders so the plan display is meaningful
|
|
[[ "$DOMAIN_FROM_CLI" == false ]] && BASE_DOMAIN="<hub-domain>"
|
|
[[ "$EMAIL_FROM_CLI" == false ]] && ACME_EMAIL="<hub-email>"
|
|
[[ "$CF_TOKEN_FROM_CLI" == false ]] && CF_DNS_API_TOKEN="<hub-cf-token>"
|
|
[[ "$CF_TUNNEL_FROM_CLI" == false ]] && CF_TUNNEL_TOKEN="<hub-cf-tunnel-token>"
|
|
return
|
|
fi
|
|
|
|
HUB_CONFIG_TMP=$(mktemp /tmp/felhom-hub-config-XXXXXX.yaml)
|
|
|
|
local hub_url="https://hub.felhom.eu/api/v1/config/${HUB_CUSTOMER}"
|
|
local http_code
|
|
http_code=$(curl -fsSL \
|
|
-H "X-Retrieval-Password: ${HUB_PASSWORD}" \
|
|
-o "${HUB_CONFIG_TMP}" \
|
|
-w "%{http_code}" \
|
|
"${hub_url}" 2>&1) || true
|
|
|
|
if [[ "$http_code" != "200" ]]; then
|
|
rm -f "${HUB_CONFIG_TMP}"
|
|
HUB_CONFIG_TMP=""
|
|
log_error "Failed to fetch config from Felhom Hub (HTTP ${http_code})"
|
|
log_error "URL: ${hub_url}"
|
|
log_error "Check the customer ID and retrieval password, then re-run."
|
|
exit 1
|
|
fi
|
|
|
|
log_success "Hub config fetched successfully"
|
|
|
|
# Extract values from hub YAML
|
|
local hub_domain hub_email hub_cf_token hub_tunnel_token
|
|
hub_domain=$(yaml_get "${HUB_CONFIG_TMP}" "customer" "domain")
|
|
hub_email=$(yaml_get "${HUB_CONFIG_TMP}" "customer" "email")
|
|
hub_cf_token=$(yaml_get "${HUB_CONFIG_TMP}" "infrastructure" "cf_api_token")
|
|
hub_tunnel_token=$(yaml_get "${HUB_CONFIG_TMP}" "infrastructure" "cf_tunnel_token")
|
|
|
|
# Apply to script vars — CLI flags always take precedence
|
|
if [[ "$DOMAIN_FROM_CLI" == false && -n "$hub_domain" ]]; then
|
|
BASE_DOMAIN="$hub_domain"
|
|
log_info " domain: ${BASE_DOMAIN} (from Hub)"
|
|
fi
|
|
if [[ "$EMAIL_FROM_CLI" == false && -n "$hub_email" ]]; then
|
|
ACME_EMAIL="$hub_email"
|
|
log_info " email: ${ACME_EMAIL} (from Hub)"
|
|
fi
|
|
if [[ "$CF_TOKEN_FROM_CLI" == false && -n "$hub_cf_token" ]]; then
|
|
CF_DNS_API_TOKEN="$hub_cf_token"
|
|
log_info " cf_api_token: ${CF_DNS_API_TOKEN:0:6}... (from Hub)"
|
|
fi
|
|
if [[ "$CF_TUNNEL_FROM_CLI" == false && -n "$hub_tunnel_token" ]]; then
|
|
CF_TUNNEL_TOKEN="$hub_tunnel_token"
|
|
log_info " cf_tunnel_token: ${CF_TUNNEL_TOKEN:0:6}... (from Hub)"
|
|
fi
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# 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++))
|
|
|
|
mkdir -p "${CONTROLLER_DIR}"
|
|
|
|
if [[ -n "$HUB_CUSTOMER" ]]; then
|
|
log_step "${step_num}/$(get_total_steps) - Generating minimal controller.yaml (setup wizard will handle full config)..."
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Would generate minimal controller.yaml for setup wizard"
|
|
echo -e "${CYAN}[DRY-RUN]${NC} Hub credentials pre-seeded via env vars (customer: ${HUB_CUSTOMER})"
|
|
return
|
|
fi
|
|
|
|
# Discard full hub config — we only needed it for infra vars (domain, CF tokens)
|
|
if [[ -n "$HUB_CONFIG_TMP" && -f "$HUB_CONFIG_TMP" ]]; then
|
|
rm -f "${HUB_CONFIG_TMP}"
|
|
HUB_CONFIG_TMP=""
|
|
fi
|
|
|
|
# Generate minimal config WITHOUT customer.id — triggers setup wizard
|
|
# The setup wizard will download full config + offer infra backup restore
|
|
cat > "${CONTROLLER_DIR}/controller.yaml" << YAMLEOF
|
|
# Auto-generated by docker-setup.sh v${SCRIPT_VERSION} on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
# Hub mode: full configuration via web setup wizard (credentials pre-seeded)
|
|
# Setup wizard: https://felhom.${BASE_DOMAIN} or http://<ip>:8081
|
|
|
|
customer:
|
|
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 (setup wizard will offer restore/fresh choice)"
|
|
return
|
|
fi
|
|
|
|
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
|
|
|
|
# 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://<ip>: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
|
|
|
|
# Hub mode: pass pre-seeded credentials to setup wizard via env vars
|
|
local hub_setup_env=""
|
|
if [[ -n "$HUB_CUSTOMER" ]]; then
|
|
hub_setup_env=" - FELHOM_SETUP_CUSTOMER_ID=${HUB_CUSTOMER}
|
|
- FELHOM_SETUP_PASSWORD=${HUB_PASSWORD}"
|
|
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)
|
|
${hub_setup_env}
|
|
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 "$HUB_CUSTOMER" ]]; then
|
|
echo -e "${BOLD}Customer:${NC} ${HUB_CUSTOMER} (Hub credentials pre-seeded for setup wizard)"
|
|
elif [[ -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 <n> → 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
|
|
|
|
# Hub mode: download config early so BASE_DOMAIN, ACME_EMAIL, CF tokens are
|
|
# available before Traefik and other infra steps run
|
|
apply_hub_config
|
|
|
|
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"
|
|
if [[ -n "$HUB_CUSTOMER" ]]; then
|
|
echo " (Hub mode: setup wizard pre-seeded for ${HUB_CUSTOMER})"
|
|
fi
|
|
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}"
|
|
if [[ -n "$HUB_CUSTOMER" ]]; then
|
|
echo " Hub customer: ${HUB_CUSTOMER} (setup wizard will offer restore/fresh choice)"
|
|
else
|
|
echo " Customer: ${CUSTOMER_ID:-<none — will be set in web setup wizard>}"
|
|
fi
|
|
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
|
|
|
|
# Allow tee (from exec > >(tee ...)) to flush remaining output to terminal
|
|
sleep 0.5
|
|
}
|
|
|
|
main "$@" |