Files
deploy-felhom-compose/scripts/docker-setup.sh
T
admin 563c9515d9 v0.14.0: Per-drive backup architecture + storage path overhaul
Major refactor of backup and storage paths:

- Per-drive restic repos at <drive>/backups/primary/restic/
- Per-app DB dumps at <drive>/backups/primary/<app>/db-dumps/
- Remove global BackupDir, DBDumpDir, ResticRepo config fields
- Add SystemDataPath config (fallback for apps without HDD)
- New backup/paths.go with pure path computation helpers
- Add GetStackHDDPath to StackDataProvider interface
- Restic methods now accept repoPath as parameter
- Cross-drive backup uses new secondary path structure
- Rename storage/ to appdata/ in scripts and compose templates
- Update protected HDD paths (storage → appdata + backups)
- Simplify backup UI (remove global path displays)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:47:39 +01:00

1605 lines
55 KiB
Bash

#!/bin/bash
#===============================================================================
# Docker + Felhom Infrastructure Setup v4.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)
# - Cloudflare Tunnel connector (optional, for external access)
# - FileBrowser Quantum (web-based file manager for HDD data)
# - Felhom Controller (deployed separately)
#
# 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 (default: homeserver.local)
# --email EMAIL Email for Let's Encrypt certificates
# --cf-token TOKEN Cloudflare API token for DNS-01 challenge
# --cf-tunnel-token TK Cloudflare Tunnel token for external access
# --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 --cf-tunnel-token eyJhIjo...
#
#===============================================================================
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="3.6.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=""
CF_TUNNEL_TOKEN=""
TRAEFIK_PASSWORD=""
SKIP_FILEBROWSER=false
HDD_PATH=""
DRY_RUN=false
SELF_SIGNED_CERT=false
DEBUG_MODE=false
CUSTOMER_ID=""
# 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}¦ Docker + Felhom Infrastructure Setup v${SCRIPT_VERSION} ¦${NC}"
echo -e "${BOLD}${BLUE}L==================================================================-${NC}"
echo ""
}
print_help() {
cat << 'EOF'
Docker + Felhom Infrastructure Setup v4.0
This script prepares a Debian 13 server with Docker infrastructure for Felhom homeserver.
Application stacks are managed via the felhom-controller dashboard.
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)
--customer ID Customer identifier (e.g., demo-felhom)
--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)
--domain DOMAIN Base domain for services (default: homeserver.local)
--email EMAIL Email for Let's Encrypt certificates
--cf-token TOKEN Cloudflare API token for DNS-01 challenge (recommended)
--cf-tunnel-token TK Cloudflare Tunnel token for external access
--hdd-path PATH HDD mount path (e.g., /mnt/hdd_1) for FileBrowser
--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>
Uses DNS-01 challenge. Works with Cloudflare Tunnel, no port 80 needed.
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
Generates a CA + wildcard cert. Requires manual CA import on all devices.
WHAT THIS SCRIPT INSTALLS:
1. Base packages (curl, git, htop, etc.)
2. Docker Engine + Docker Compose
3. Traefik reverse proxy (with dashboard)
4. Cloudflare Tunnel connector (if --cf-tunnel-token provided)
5. TLS certificates (Let's Encrypt or self-signed)
6. FileBrowser Quantum (web file manager at files.<domain>)
7. Helper tools (ctop, lazydocker, shell aliases)
DEPLOYING APPLICATIONS:
After infrastructure setup, deploy the felhom-controller to manage apps
via the web dashboard at felhom.<domain>.
EXAMPLES:
# Felhom customer deployment (recommended — full stack with tunnel)
sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
--email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx \
--cf-tunnel-token eyJhIjoixxxxxxxx...
# Without tunnel (local access only, or custom ingress)
sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
--email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx
# Self-signed cert (offline/testing)
sudo ./docker-setup.sh --domain example.com --self-signed-cert
# Full setup with static IP
sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
--ip 192.168.0.50 --email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx \
--cf-tunnel-token eyJhIjoixxxxxxxx...
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 ;;
--cf-tunnel-token)
require_arg "$1" "${2:-}"
CF_TUNNEL_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 ;;
--self-signed-cert) SELF_SIGNED_CERT=true; shift ;;
--skip-filebrowser) SKIP_FILEBROWSER=true; shift ;;
--hdd-path)
require_arg "$1" "${2:-}"
HDD_PATH="$2"; shift 2 ;;
--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
# Warn if tunnel token provided without Cloudflare DNS (unusual but valid)
if [[ -n "$CF_TUNNEL_TOKEN" && -z "$CF_DNS_API_TOKEN" ]]; then
log_warn "Cloudflare Tunnel without DNS-01 challenge"
log_warn "External HTTPS will work, but certs must be managed separately"
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=5 # base: packages, network, docker, traefik, tools
[[ -n "$CF_TUNNEL_TOKEN" ]] && ((total++))
[[ "$SELF_SIGNED_CERT" == true ]] && ((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 (no --cf-tunnel-token)"
return
fi
log_step "5/$(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
# Calculate step number dynamically
local step_num=5
[[ -n "$CF_TUNNEL_TOKEN" ]] && ((step_num++))
log_step "${step_num}/$(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 ""
}
#-------------------------------------------------------------------------------
# Step 6: Install FileBrowser Quantum (infrastructure file manager)
#-------------------------------------------------------------------------------
install_filebrowser() {
if [[ "$SKIP_FILEBROWSER" == true ]]; then
log_skip "FileBrowser installation skipped"
return
fi
if [[ -z "$HDD_PATH" ]]; then
log_warn "FileBrowser skipped — no HDD path configured (use --hdd-path)."
log_warn "Deploy manually after HDD setup."
return
fi
if [[ ! -d "$HDD_PATH" ]]; then
log_warn "FileBrowser skipped — HDD path $HDD_PATH does not exist."
log_warn "Run hdd-setup.sh first, then re-run with --hdd-path."
return
fi
# Calculate step number dynamically
local step_num=5
[[ -n "$CF_TUNNEL_TOKEN" ]] && ((step_num++))
[[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++))
log_step "${step_num}/$(get_total_steps) - Installing FileBrowser Quantum..."
if [[ "$DRY_RUN" == true ]]; then
echo -e "${CYAN}[DRY-RUN]${NC} Would install FileBrowser Quantum at files.${BASE_DOMAIN}"
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 — do NOT remove
#
# Mount permissions:
# /srv/appdata/ → HDD appdata/ (READ-ONLY — app data)
# /srv/media/ → HDD media/ (read-write — user media)
# /srv/Dokumentumok/ → HDD Dokumentumok/ (read-write — user documents)
services:
filebrowser:
image: gtstef/filebrowser:latest
container_name: filebrowser
restart: unless-stopped
environment:
- TZ=Europe/Budapest
volumes:
- filebrowser_data:/home/filebrowser/data
- \${HDD_PATH}/appdata:/srv/appdata:ro
- \${HDD_PATH}/media:/srv/media
- \${HDD_PATH}/Dokumentumok:/srv/Dokumentumok
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.\${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
# Create .env file
cat > "${FILEBROWSER_DIR}/.env" << ENVEOF
# FileBrowser environment — generated by docker-setup.sh
DOMAIN=${BASE_DOMAIN}
HDD_PATH=${HDD_PATH}
ENVEOF
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"
}
#-------------------------------------------------------------------------------
# Print summary
#-------------------------------------------------------------------------------
print_summary() {
local server_ip
server_ip=$(get_server_ip)
echo ""
echo -e "${BOLD}${GREEN}-==================================================================¬${NC}"
echo -e "${BOLD}${GREEN}¦ Infrastructure Setup Complete! ¦${NC}"
echo -e "${BOLD}${GREEN}L==================================================================-${NC}"
echo ""
echo -e "${BOLD}Server IP:${NC} ${server_ip}"
echo -e "${BOLD}Domain:${NC} *.${BASE_DOMAIN}"
echo ""
echo -e "${BOLD}Services:${NC}"
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 ]] && [[ -n "$HDD_PATH" ]]; 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}${CYAN}===================================================================${NC}"
echo -e "${BOLD}${CYAN} DEPLOYING APPLICATION STACKS ${NC}"
echo -e "${BOLD}${CYAN}===================================================================${NC}"
echo ""
echo " Deploy applications via the Felhom Controller dashboard:"
echo ""
echo " 1. Deploy felhom-controller (see controller/README.md)"
echo " 2. Open https://felhom.${BASE_DOMAIN}"
echo " 3. Browse available apps on the Alkalmazások page"
echo " 4. Click Telepítés to deploy"
if [[ -n "$CUSTOMER_ID" ]]; then
echo ""
echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}"
echo ""
echo -e "${YELLOW}Note: No --customer specified. Templates use the generic catalog.${NC}"
echo -e "${YELLOW}Env vars (passwords, secrets) must be filled in manually.${NC}"
fi
echo ""
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
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 [[ -n "$CF_TUNNEL_TOKEN" ]]; then
echo -e " 5. Install Cloudflare Tunnel connector: ${GREEN}yes${NC}"
else
echo -e " - Cloudflare Tunnel: ${CYAN}skip (no --cf-tunnel-token)${NC}"
fi
if [[ "$SELF_SIGNED_CERT" == true ]]; then
echo " - Generate self-signed certificate: yes"
fi
echo " - Install FileBrowser: $([[ "$SKIP_FILEBROWSER" == true ]] && echo "skip" || echo "$([[ -n "$HDD_PATH" ]] && echo "yes (HDD: $HDD_PATH)" || echo "skip (no --hdd-path)")")"
echo " - Install helper tools (ctop, lazydocker, aliases)"
echo ""
echo " Domain: *.${BASE_DOMAIN}"
echo " Customer: ${CUSTOMER_ID:-<none — generic catalog>}"
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
if [[ -n "$CF_TUNNEL_TOKEN" ]]; then
echo " CF tunnel token: ${CF_TUNNEL_TOKEN:0:12}... (masked)"
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
install_cloudflare_tunnel
generate_self_signed_cert
install_filebrowser
install_tools_and_configure
print_summary
}
main "$@"