diff --git a/controller/configs/controller.yaml.example b/controller/configs/controller.yaml.example index 32844ec..2604ee5 100644 --- a/controller/configs/controller.yaml.example +++ b/controller/configs/controller.yaml.example @@ -53,6 +53,7 @@ stacks: - "traefik" - "cloudflared" - "felhom-controller" + - "filebrowser" update_window: "03:00-05:00" compose_command: "" diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 58c084a..40d4c41 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -83,6 +83,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case hasSuffix(path, "/logs") && req.Method == http.MethodGet: r.getStackLogs(w, req, extractName(path, "/logs")) + // GET /api/stacks/{name}/hdd-data + case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet: + r.getStackHDDData(w, req, extractName(path, "/hdd-data")) + + // DELETE /api/stacks/{name} + case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"): + r.deleteStack(w, req, trimSegment(path, "/stacks/")) + // POST /api/sync — trigger immediate catalog sync case path == "/sync" && req.Method == http.MethodPost: r.triggerSync(w, req) @@ -250,6 +258,45 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}}) } +func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) { + resp, err := r.stackMgr.GetStackHDDData(name) + if err != nil { + writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) +} + +func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) { + r.logger.Printf("[API] Delete requested for stack: %s", name) + + var body struct { + RemoveHDDData bool `json:"remove_hdd_data"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + body.RemoveHDDData = false + } + + resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData) + if err != nil { + r.logger.Printf("[API] Delete failed for %s: %v", name, err) + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "protected") { + status = http.StatusForbidden + } + if strings.Contains(err.Error(), "not found") { + status = http.StatusNotFound + } + if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") { + status = http.StatusConflict + } + writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) + return + } + + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"}) +} + func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) { r.logger.Println("[API] Manual catalog sync requested") result := r.syncer.TriggerSync() diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go new file mode 100644 index 0000000..4e3210b --- /dev/null +++ b/controller/internal/stacks/delete.go @@ -0,0 +1,290 @@ +package stacks + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// DeleteResponse holds the result of a stack deletion. +type DeleteResponse struct { + Deleted string `json:"deleted"` + VolumesRemoved []string `json:"volumes_removed"` + HDDPathsRemoved []string `json:"hdd_paths_removed"` + HDDPathsPreserved []string `json:"hdd_paths_preserved"` +} + +// HDDDataResponse holds information about HDD data associated with a stack. +type HDDDataResponse struct { + Stack string `json:"stack"` + HDDPaths []HDDPath `json:"hdd_paths"` + HasHDDData bool `json:"has_hdd_data"` +} + +// HDDPath represents a single HDD bind mount path and its status. +type HDDPath struct { + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + SizeHuman string `json:"size_human"` + Exists bool `json:"exists"` +} + +// protectedHDDPaths returns the set of top-level HDD directories that must never be deleted. +func protectedHDDPaths(hddPath string) map[string]bool { + if hddPath == "" { + return nil + } + return map[string]bool{ + hddPath: true, + filepath.Join(hddPath, "media"): true, + filepath.Join(hddPath, "storage"): true, + filepath.Join(hddPath, "Dokumentumok"): true, + filepath.Join(hddPath, "appdata"): true, + } +} + +// DeleteStack removes an orphaned stack: stops containers, removes volumes, +// optionally removes HDD data, and deletes the stack directory. +func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) { + // Safety: never delete protected stacks + if m.cfg.IsProtectedStack(name) { + return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name) + } + + stack, ok := m.GetStack(name) + if !ok { + return nil, fmt.Errorf("stack %q not found", name) + } + + // Must be orphaned + if !stack.Orphaned { + return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name) + } + + // Must be stopped (not running) + if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting { + return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name) + } + + stackDir := filepath.Dir(stack.ComposePath) + hddPath := m.cfg.Paths.HDDPath + + m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v)", name, removeHDDData) + start := time.Now() + + resp := &DeleteResponse{ + Deleted: name, + } + + // Step 1: Parse compose file for HDD bind mounts + hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath) + + // Step 2: Run docker compose down --rmi local --volumes + env := m.stackEnv(stackDir) + output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes") + if err != nil { + m.logger.Printf("[WARN] docker compose down for %s had errors: %v (output: %s)", name, err, truncateStr(output, 200)) + // Continue anyway — the stack dir will be removed + } + + // Step 3: Identify removed volumes from compose output + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") { + resp.VolumesRemoved = append(resp.VolumesRemoved, line) + } + } + + // Step 4: Handle HDD data + protected := protectedHDDPaths(hddPath) + for _, mount := range hddMounts { + // Safety: never delete protected top-level dirs + cleanPath := filepath.Clean(mount) + if protected != nil && protected[cleanPath] { + m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath) + continue + } + + if _, err := os.Stat(cleanPath); os.IsNotExist(err) { + continue // path doesn't exist, nothing to do + } + + if removeHDDData { + // Get size before removal + sizeHuman := getDirSizeHuman(cleanPath) + if err := os.RemoveAll(cleanPath); err != nil { + m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err) + } else { + m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman) + resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) + } + } else { + sizeHuman := getDirSizeHuman(cleanPath) + resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) + } + } + + // Step 5: Remove stack directory + if err := os.RemoveAll(stackDir); err != nil { + m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err) + return resp, fmt.Errorf("failed to remove stack directory: %w", err) + } + + m.logger.Printf("[INFO] Stack %s deleted successfully (took %.1fs)", name, time.Since(start).Seconds()) + + // Step 6: Remove from in-memory map and rescan + m.mu.Lock() + delete(m.stacks, name) + m.mu.Unlock() + + if err := m.ScanStacks(); err != nil { + m.logger.Printf("[WARN] Rescan after delete failed: %v", err) + } + + return resp, nil +} + +// GetStackHDDData returns information about HDD bind mounts for a stack. +func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { + stack, ok := m.GetStack(name) + if !ok { + return nil, fmt.Errorf("stack %q not found", name) + } + + hddPath := m.cfg.Paths.HDDPath + resp := &HDDDataResponse{ + Stack: name, + } + + if hddPath == "" { + return resp, nil + } + + mounts := parseComposeHDDMounts(stack.ComposePath, hddPath) + protected := protectedHDDPaths(hddPath) + + for _, mount := range mounts { + cleanPath := filepath.Clean(mount) + + // Skip protected top-level dirs + if protected != nil && protected[cleanPath] { + continue + } + + hddItem := HDDPath{ + Path: cleanPath, + } + + info, err := os.Stat(cleanPath) + if err != nil { + hddItem.Exists = false + } else { + hddItem.Exists = true + if info.IsDir() { + hddItem.SizeBytes = getDirSizeBytes(cleanPath) + hddItem.SizeHuman = getDirSizeHuman(cleanPath) + } + } + + resp.HDDPaths = append(resp.HDDPaths, hddItem) + } + + resp.HasHDDData = len(resp.HDDPaths) > 0 + return resp, nil +} + +// parseComposeHDDMounts reads a docker-compose.yml and extracts host paths +// that reference the HDD path from volume bind mounts. +func parseComposeHDDMounts(composePath, hddPath string) []string { + if hddPath == "" { + return nil + } + + data, err := os.ReadFile(composePath) + if err != nil { + return nil + } + + var mounts []string + seen := make(map[string]bool) + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + inVolumes := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Track when we're in a volumes section (service-level, not top-level) + if strings.HasPrefix(line, "volumes:") { + inVolumes = true + continue + } + if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" { + inVolumes = false + } + + if !inVolumes || !strings.HasPrefix(line, "- ") { + continue + } + + // Parse bind mount: "- /host/path:/container/path:options" + mountStr := strings.TrimPrefix(line, "- ") + mountStr = strings.Trim(mountStr, "\"'") + + parts := strings.SplitN(mountStr, ":", 3) + if len(parts) < 2 { + continue + } + + hostPath := parts[0] + + // Resolve ${HDD_PATH} variable reference + hostPath = strings.ReplaceAll(hostPath, "${HDD_PATH}", hddPath) + + // Check if this is an HDD mount + if !strings.HasPrefix(hostPath, hddPath) { + continue + } + + cleanPath := filepath.Clean(hostPath) + if !seen[cleanPath] { + seen[cleanPath] = true + mounts = append(mounts, cleanPath) + } + } + + return mounts +} + +// getDirSizeHuman returns a human-readable size string for a directory using du. +func getDirSizeHuman(path string) string { + cmd := exec.Command("du", "-sh", path) + output, err := cmd.Output() + if err != nil { + return "unknown" + } + fields := strings.Fields(string(output)) + if len(fields) > 0 { + return fields[0] + } + return "unknown" +} + +// getDirSizeBytes returns the total size in bytes for a directory. +func getDirSizeBytes(path string) int64 { + cmd := exec.Command("du", "-sb", path) + output, err := cmd.Output() + if err != nil { + return 0 + } + fields := strings.Fields(string(output)) + if len(fields) > 0 { + var size int64 + fmt.Sscanf(fields[0], "%d", &size) + return size + } + return 0 +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index d1cefe1..5ecd1f6 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -29,6 +29,7 @@ const ( StatePaused ContainerState = "paused" StateUnknown ContainerState = "unknown" StateNotDeployed ContainerState = "not_deployed" + StateOrphaned ContainerState = "orphaned" ) // ContainerInfo holds status info about a single container within a stack. @@ -47,6 +48,7 @@ type Stack struct { State ContainerState `json:"state"` Deployed bool `json:"deployed"` // Has app.yaml with deployed=true Protected bool `json:"protected"` + Orphaned bool `json:"orphaned"` // Deployed but no catalog template Containers []ContainerInfo `json:"containers"` AppConfig *AppConfig `json:"app_config,omitempty"` LastUpdated time.Time `json:"last_updated"` @@ -166,6 +168,25 @@ func (m *Manager) ScanStacks() error { } } + // Detect orphaned stacks (deployed but no longer in catalog) + catalogTemplates := m.getCatalogTemplateSlugs() + if catalogTemplates != nil { + orphanCount := 0 + for _, stack := range m.stacks { + if stack.Protected || !stack.Deployed { + stack.Orphaned = false + continue + } + stack.Orphaned = !catalogTemplates[stack.Name] + if stack.Orphaned { + orphanCount++ + } + } + if orphanCount > 0 { + m.logger.Printf("[INFO] Detected %d orphaned stack(s)", orphanCount) + } + } + deployedCount := 0 for _, s := range m.stacks { if s.Deployed { @@ -733,4 +754,25 @@ func (m *Manager) CommittedMemory() (requestMB int, limitMB int) { limitMB += ParseMemoryMB(s.Meta.Resources.MemLimit) } return +} + +// getCatalogTemplateSlugs reads the synced catalog cache and returns a set of +// template slugs (directory names) that have a docker-compose.yml. +func (m *Manager) getCatalogTemplateSlugs() map[string]bool { + cacheDir := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates") + entries, err := os.ReadDir(cacheDir) + if err != nil { + m.logger.Printf("[WARN] Cannot read catalog cache for orphan detection: %v", err) + return nil + } + slugs := make(map[string]bool, len(entries)) + for _, e := range entries { + if e.IsDir() { + composePath := filepath.Join(cacheDir, e.Name(), "docker-compose.yml") + if _, err := os.Stat(composePath); err == nil { + slugs[e.Name()] = true + } + } + } + return slugs } \ No newline at end of file diff --git a/controller/internal/web/templates.go b/controller/internal/web/templates.go index 9eaf384..d1f4ce7 100644 --- a/controller/internal/web/templates.go +++ b/controller/internal/web/templates.go @@ -117,6 +117,84 @@ const layoutTmpl = ` btn.classList.remove('loading'); } } + async function deleteOrphanStack(name) { + var modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.id = 'delete-modal'; + modal.innerHTML = ''; + modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); }); + document.body.appendChild(modal); + try { + var resp = await fetch('/api/stacks/' + name + '/hdd-data'); + var data = await resp.json(); + var hddInfo = ''; + var checkboxHTML = ''; + if (data.ok && data.data && data.data.has_hdd_data) { + hddInfo = ''; + checkboxHTML = ''; + } + modal.querySelector('.modal-card').innerHTML = + '

Alkalmazás törlése: ' + name + '

' + + '

Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.

' + + '
Ez a művelet nem visszavonható!
' + + hddInfo + checkboxHTML + + ''; + } catch (err) { + modal.querySelector('.modal-card').innerHTML = + '

Hiba

Nem sikerült lekérni az adatokat: ' + err.message + '

' + + ''; + } + } + function closeDeleteModal() { + var modal = document.getElementById('delete-modal'); + if (modal) modal.remove(); + } + async function confirmDelete(name) { + var btn = document.getElementById('confirm-delete-btn'); + var checkbox = document.getElementById('delete-hdd-check'); + var removeHDD = checkbox ? checkbox.checked : false; + btn.disabled = true; + btn.textContent = 'Törlés folyamatban...'; + try { + var resp = await fetch('/api/stacks/' + name, { + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({remove_hdd_data: removeHDD}) + }); + var data = await resp.json(); + if (data.ok) { + var modal = document.getElementById('delete-modal'); + var removedInfo = ''; + if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) { + removedInfo = '

Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '

'; + } + var preservedInfo = ''; + if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) { + preservedInfo = '

Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '

'; + } + modal.querySelector('.modal-card').innerHTML = + '

Sikeresen törölve!

' + + '

Az alkalmazás (' + name + ') törölve lett.

' + + removedInfo + preservedInfo + + ''; + } else { + alert('Hiba: ' + (data.error || 'Ismeretlen hiba')); + btn.disabled = false; + btn.textContent = 'Törlés'; + } + } catch (err) { + alert('Hálózati hiba: ' + err.message); + btn.disabled = false; + btn.textContent = 'Törlés'; + } + } @@ -198,6 +276,7 @@ const dashboardTmpl = `
{{stateLabel .State}} + {{if .Orphaned}}Elavult{{end}} {{if .Protected}} Védett @@ -211,6 +290,7 @@ const dashboardTmpl = ` {{end}} Napló + {{if .Orphaned}}{{end}} {{end}}
@@ -253,6 +333,7 @@ const stacksTmpl = ` {{stateLabel .State}} + {{if .Orphaned}}Elavult{{end}} {{if .Meta.Description}} @@ -284,14 +365,15 @@ const stacksTmpl = ` Részletek {{else}} {{if isOperational .State}} - + {{if not .Orphaned}}{{end}} {{else}} {{end}} Naplók - Részletek + {{if not .Orphaned}}Részletek{{end}} + {{if .Orphaned}}{{end}} {{end}} @@ -753,9 +835,14 @@ const appInfoTmpl = `
{{if .Stack.Deployed}} {{stateLabel .Stack.State}} + {{if .Stack.Orphaned}}Elavult{{end}} Megnyitás ↗ Napló - Beállítások + {{if .Stack.Orphaned}} + + {{else}} + Beállítások + {{end}} {{else}} Telepítés {{end}} @@ -1980,6 +2067,74 @@ select.form-control option { background: var(--bg-secondary); color: var(--text- color: var(--green) !important; } +/* Orphan badge */ +.badge-orphaned { + background: var(--orange-bg); + color: var(--orange); +} + +/* Delete modal */ +.modal-overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +.modal-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.5rem; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.modal-card h3 { + margin-bottom: .75rem; +} +.modal-hdd-info { + background: var(--bg-secondary); + border-radius: 8px; + padding: .75rem 1rem; + margin: .75rem 0; + font-size: .85rem; + color: var(--text-secondary); +} +.modal-hdd-path { + font-family: 'JetBrains Mono', monospace; + font-size: .8rem; + color: var(--text-muted); + padding: .2rem 0; +} +.modal-checkbox { + display: flex; + align-items: center; + gap: .5rem; + margin: .75rem 0; + padding: .75rem 1rem; + background: var(--red-bg); + border: 1px solid rgba(218, 54, 51, 0.3); + border-radius: 8px; + font-size: .85rem; + color: var(--red); + cursor: pointer; +} +.modal-checkbox input[type="checkbox"] { + accent-color: var(--red); +} +.modal-actions { + display: flex; + gap: .75rem; + margin-top: 1rem; + justify-content: flex-end; +} + /* Responsive */ @media(max-width: 768px) { .sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } diff --git a/scripts/docker-setup.sh b/scripts/docker-setup.sh new file mode 100644 index 0000000..22d196d --- /dev/null +++ b/scripts/docker-setup.sh @@ -0,0 +1,1577 @@ +#!/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 + 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.) + 7. Helper tools (ctop, lazydocker, shell aliases) + +DEPLOYING APPLICATIONS: + After infrastructure setup, deploy the felhom-controller to manage apps + via the web dashboard at dashboard.. + +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) 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 > > 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/storage/ → HDD storage/ (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}/storage:/srv/storage: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 .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://dashboard.${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 → 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:-}" + 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 "$@" \ No newline at end of file diff --git a/scripts/hdd-setup.sh b/scripts/hdd-setup.sh new file mode 100644 index 0000000..3a09853 --- /dev/null +++ b/scripts/hdd-setup.sh @@ -0,0 +1,1252 @@ +#!/bin/bash + +#=============================================================================== +# HDD Detection & Setup Script v1.0 +# Detects unmounted HDDs, checks state, formats, mounts, creates folder structure +# +# This script safely prepares external/additional drives for Felhom homeserver: +# - Detects unmounted block devices (HDDs and SSDs) +# - Shows SMART health, partition tables, existing filesystems +# - Formats with ext4 (only after explicit confirmation + safety checks) +# - Mounts by UUID in /etc/fstab with proper options +# - Creates standard Felhom media + storage folder structure +# +# Usage: +# sudo ./hdd-setup.sh [OPTIONS] +# +# Options: +# --scan Scan and report only (no changes) +# --mount-point PATH Pre-set mount point (skip prompt) +# --skip-smart Skip SMART health check +# --skip-folders Skip folder structure creation +# --dry-run Show what would be done without making changes +# --debug Enable bash debug tracing (set -x) +# -h, --help Show this help message +# +# Safety: +# - Never formats a drive with existing filesystems without triple confirmation +# - Shows all existing data/partitions before any destructive action +# - Writes fstab backup before modifications +# - Uses nofail mount option to prevent boot failures +# - All destructive operations require typing "YES" (not just y/n) +# +#=============================================================================== + +set -euo pipefail + +#------------------------------------------------------------------------------- +# Configuration +#------------------------------------------------------------------------------- +SCRIPT_VERSION="1.0.1" + +# Default values +SCAN_ONLY=false +PRESET_MOUNT_POINT="" +SKIP_SMART=false +SKIP_FOLDERS=false +DRY_RUN=false +DEBUG_MODE=false + +# Folder structure owner (UID:GID for Docker containers, typically 1000:1000) +FOLDER_UID=1000 +FOLDER_GID=1000 + +# Standard Felhom folder structure +declare -a MEDIA_DIRS=( + "media/downloads/complete" + "media/downloads/incomplete" + "media/movies" + "media/series" + "media/music" + "media/books" +) + +declare -a STORAGE_DIRS=( + "storage/immich" + "storage/nextcloud" + "storage/backups/local" + "storage/backups/appdata" +) + +declare -a USER_DIRS=( + "Dokumentumok" +) + +declare -a APPDATA_DIRS=( + "appdata" +) + +#------------------------------------------------------------------------------- +# Colors and logging (matching docker-setup.sh conventions) +#------------------------------------------------------------------------------- +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() { if [[ "$DEBUG_MODE" == true ]]; then echo -e "${CYAN}[DEBUG]${NC} $1"; fi; } +log_danger() { echo -e "${RED}${BOLD}[DANGER]${NC} $1"; } + +#------------------------------------------------------------------------------- +# Error handling +#------------------------------------------------------------------------------- +on_error() { + echo "" + log_error "Script failed at line $1. Collecting diagnostics..." + echo "--- Block Devices ---" + lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT 2>/dev/null || true + echo "--- /etc/fstab ---" + cat /etc/fstab 2>/dev/null || true + echo "--- Mount status ---" + mount 2>/dev/null | grep -E "^/dev/" || true +} +trap 'on_error $LINENO' ERR + +# Cleanup trap for temp files +TEMP_FILES=() +LOCK_FILE="/tmp/.hdd_setup.lock" +cleanup() { + rm -f /tmp/.hdd_setup_part_dev 2>/dev/null || true + rm -f "$LOCK_FILE" 2>/dev/null || true + for f in "${TEMP_FILES[@]}"; do + rm -f "$f" 2>/dev/null || true + done +} +trap cleanup EXIT + +#------------------------------------------------------------------------------- +# Helpers +#------------------------------------------------------------------------------- +print_banner() { + echo "" + echo -e "${BOLD}${BLUE}-==================================================================¬${NC}" + echo -e "${BOLD}${BLUE}¦ HDD Detection & Setup Script v${SCRIPT_VERSION} ¦${NC}" + echo -e "${BOLD}${BLUE}L==================================================================-${NC}" + echo "" +} + +print_help() { + cat << 'EOF' +HDD Detection & Setup Script v1.0 + +Safely detects, formats, mounts, and prepares external drives for Felhom homeserver. + +USAGE: + sudo ./hdd-setup.sh [OPTIONS] + +OPTIONS: + --scan Scan and report only (no changes made) + --mount-point PATH Pre-set mount point (e.g., /mnt/hdd_1) + --skip-smart Skip SMART health check + --skip-folders Skip folder structure creation + --dry-run Show what would be done without making changes + --debug Enable verbose debug output + -h, --help Show this help + +WHAT THIS SCRIPT DOES: + 1. Detects unmounted block devices + 2. Shows disk info (size, partitions, SMART health) + 3. Lets you select a disk to set up + 4. Formats with ext4 (with safety confirmations) + 5. Mounts via UUID in /etc/fstab (with nofail) + 6. Creates Felhom folder structure + +FOLDER STRUCTURE CREATED: + / + ├── Dokumentumok/ + ├── media/ + │ ├── downloads/complete/ + │ ├── downloads/incomplete/ + │ ├── movies/ + │ ├── series/ + │ ├── music/ + │ └── books/ + ├── storage/ + │ ├── immich/ + │ ├── nextcloud/ + │ └── backups/ + │ ├── local/ + │ └── appdata/ + └── appdata/ + +SAFETY: + - Drives with existing data require typing "YES" to format + - fstab is backed up before any modification + - Uses 'nofail' mount option (system boots even if drive missing) + - --scan mode for non-destructive inspection + +EOF + exit 0 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --scan) SCAN_ONLY=true; shift ;; + --mount-point) PRESET_MOUNT_POINT="$2"; shift 2 ;; + --skip-smart) SKIP_SMART=true; shift ;; + --skip-folders) SKIP_FOLDERS=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --debug) DEBUG_MODE=true; shift ;; + -h|--help) print_help ;; + *) log_error "Unknown option: $1"; print_help ;; + esac + done +} + +check_root() { + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +install_dependencies() { + local missing=() + + # smartmontools for SMART data + if ! command -v smartctl &> /dev/null && [[ "$SKIP_SMART" == false ]]; then + missing+=("smartmontools") + fi + + # parted for partition inspection + if ! command -v parted &> /dev/null; then + missing+=("parted") + fi + + # util-linux should be present, but check for blkid/lsblk + if ! command -v blkid &> /dev/null; then + missing+=("util-linux") + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + log_info "Installing missing dependencies: ${missing[*]}" + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would install: ${missing[*]}" + return + fi + apt-get update -qq + apt-get install -qq -y "${missing[@]}" > /dev/null 2>&1 + log_success "Dependencies installed" + fi +} + +#------------------------------------------------------------------------------- +# Disk detection +#------------------------------------------------------------------------------- + +# Get the device name of the root filesystem (e.g., "sda") +get_root_disk() { + local root_dev + root_dev=$(findmnt -n -o SOURCE / 2>/dev/null | head -1) + + # Handle LVM: /dev/mapper/vg-root -> find underlying PV + if [[ "$root_dev" == /dev/mapper/* ]]; then + if command -v pvs &> /dev/null; then + root_dev=$(pvs --noheadings -o pv_name 2>/dev/null | head -1 | tr -d ' ') + fi + fi + + # Handle partitions: /dev/sda2 -> sda, /dev/nvme0n1p2 -> nvme0n1 + local disk_name + disk_name=$(lsblk -no PKNAME "$root_dev" 2>/dev/null | head -1) + + if [[ -z "$disk_name" ]]; then + # Fallback: strip partition number + disk_name=$(echo "$root_dev" | sed 's|/dev/||' | sed 's/[0-9]*$//' | sed 's/p$//') + fi + + echo "$disk_name" +} + +# Get list of disks that are NOT the system disk and NOT mounted +get_candidate_disks() { + local root_disk + root_disk=$(get_root_disk) + log_debug "Root disk detected as: $root_disk" + + local candidates=() + + # Iterate over whole-disk block devices (not partitions) + while IFS= read -r line; do + local name size type + name=$(echo "$line" | awk '{print $1}') + size=$(echo "$line" | awk '{print $2}') + type=$(echo "$line" | awk '{print $3}') + + # Skip if this IS the root disk + if [[ "$name" == "$root_disk" ]]; then + log_debug "Skipping $name (root disk)" + continue + fi + + # Skip if type is not "disk" + if [[ "$type" != "disk" ]]; then + log_debug "Skipping $name (type: $type)" + continue + fi + + # Skip very small devices (<1GB) - likely USB sticks with boot media, etc. + local size_bytes + size_bytes=$(lsblk -bno SIZE "/dev/$name" 2>/dev/null | head -1) + if [[ -n "$size_bytes" ]] && [[ "$size_bytes" -lt 1073741824 ]]; then + log_debug "Skipping $name (too small: $size)" + continue + fi + + # Skip loop, ram, zram devices + if [[ "$name" =~ ^(loop|ram|zram) ]]; then + log_debug "Skipping $name (virtual device)" + continue + fi + + # Check if ANY partition of this disk is mounted + local any_mounted=false + while IFS= read -r part_line; do + local part_name part_mount + part_name=$(echo "$part_line" | awk '{print $1}') + part_mount=$(echo "$part_line" | awk '{print $7}') + if [[ -n "$part_mount" ]]; then + any_mounted=true + log_debug "Skipping $name (partition $part_name mounted at $part_mount)" + break + fi + done < <(lsblk -lno NAME,MAJ:MIN,RM,SIZE,RO,TYPE,MOUNTPOINT "/dev/$name" 2>/dev/null | tail -n +2) + + # Also check if the disk itself is mounted (whole-disk filesystem) + local disk_mount + disk_mount=$(lsblk -lno MOUNTPOINT "/dev/$name" 2>/dev/null | head -1) + if [[ -n "$disk_mount" ]]; then + any_mounted=true + log_debug "Skipping $name (disk mounted at $disk_mount)" + fi + + if [[ "$any_mounted" == true ]]; then + continue + fi + + candidates+=("$name") + + done < <(lsblk -lno NAME,SIZE,TYPE 2>/dev/null | grep ' disk$') + + echo "${candidates[@]}" +} + +#------------------------------------------------------------------------------- +# Disk inspection +#------------------------------------------------------------------------------- + +print_disk_info() { + local disk="$1" + local dev="/dev/$disk" + + echo "" + echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD} Disk: /dev/$disk${NC}" + echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + # Basic info + local size model serial rotational transport + size=$(lsblk -dno SIZE "$dev" 2>/dev/null | tr -d ' ') + model=$(lsblk -dno MODEL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + serial=$(lsblk -dno SERIAL "$dev" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + rotational=$(cat /sys/block/"$disk"/queue/rotational 2>/dev/null || echo "?") + transport=$(lsblk -dno TRAN "$dev" 2>/dev/null | tr -d ' ') + + local disk_type="Unknown" + if [[ "$rotational" == "1" ]]; then + disk_type="HDD (rotational)" + elif [[ "$rotational" == "0" ]]; then + disk_type="SSD (non-rotational)" + fi + + echo -e " ${BOLD}Size:${NC} $size" + echo -e " ${BOLD}Model:${NC} ${model:-Unknown}" + echo -e " ${BOLD}Serial:${NC} ${serial:-Unknown}" + echo -e " ${BOLD}Type:${NC} $disk_type" + echo -e " ${BOLD}Transport:${NC} ${transport:-Unknown}" + + # Partition table + echo "" + echo -e " ${BOLD}Partition layout:${NC}" + local part_output + part_output=$(lsblk -o NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINT "$dev" 2>/dev/null) + echo "$part_output" | sed 's/^/ /' + + # Check for existing filesystems on whole disk and partitions + echo "" + echo -e " ${BOLD}Filesystem detection:${NC}" + local has_data=false + + # Check whole disk + local whole_fs + whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "") + if [[ -n "$whole_fs" ]]; then + local whole_label + whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "") + echo -e " ${YELLOW}$dev: filesystem=$whole_fs label=${whole_label:-}${NC}" + has_data=true + fi + + # Check each partition + while IFS= read -r part; do + [[ -z "$part" ]] && continue + local part_dev="/dev/$part" + local part_fs part_label part_uuid + part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "") + part_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "") + part_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "") + if [[ -n "$part_fs" ]]; then + echo -e " ${YELLOW}$part_dev: filesystem=$part_fs label=${part_label:-} uuid=${part_uuid:-}${NC}" + has_data=true + else + echo -e " $part_dev: ${GREEN}no filesystem detected${NC}" + fi + done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) + + if [[ "$has_data" == false ]]; then + echo -e " ${GREEN}No filesystems detected (clean disk)${NC}" + fi + + # SMART health + if [[ "$SKIP_SMART" == false ]] && command -v smartctl &> /dev/null; then + echo "" + echo -e " ${BOLD}SMART Health:${NC}" + local smart_output + smart_output=$(smartctl -H "$dev" 2>/dev/null || echo "SMART not available") + + if echo "$smart_output" | grep -q "PASSED"; then + echo -e " ${GREEN}SMART overall: PASSED${NC}" + elif echo "$smart_output" | grep -q "FAILED"; then + echo -e " ${RED}${BOLD}SMART overall: FAILED - DO NOT USE THIS DISK${NC}" + else + echo -e " ${YELLOW}SMART: Not available (USB enclosures often don't support SMART)${NC}" + fi + + # Show key SMART attributes if available + local reallocated power_on temp + reallocated=$(smartctl -A "$dev" 2>/dev/null | grep -i "Reallocated_Sector" | awk '{print $NF}' || echo "") + power_on=$(smartctl -A "$dev" 2>/dev/null | grep -i "Power_On_Hours" | awk '{print $NF}' || echo "") + temp=$(smartctl -A "$dev" 2>/dev/null | grep -i "Temperature_Celsius" | awk '{print $NF}' || echo "") + + if [[ -n "$reallocated" ]]; then + if [[ "$reallocated" -gt 0 ]] 2>/dev/null; then + echo -e " ${YELLOW}Reallocated sectors: $reallocated (some wear detected)${NC}" + else + echo -e " Reallocated sectors: $reallocated" + fi + fi + [[ -n "$power_on" ]] && echo " Power-on hours: $power_on" + [[ -n "$temp" ]] && echo " Temperature: ${temp}°C" + fi + + # Return whether disk has data (for caller to use) + if [[ "$has_data" == true ]]; then + return 1 + else + return 0 + fi +} + +#------------------------------------------------------------------------------- +# Disk formatting +#------------------------------------------------------------------------------- + +# Check if a disk has any filesystem signatures anywhere +disk_has_data() { + local disk="$1" + local dev="/dev/$disk" + + # Check whole disk + if blkid "$dev" &>/dev/null; then + return 0 # has data + fi + + # Check all partitions + while IFS= read -r part; do + [[ -z "$part" ]] && continue + if blkid "/dev/$part" &>/dev/null; then + return 0 # has data + fi + done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) + + return 1 # no data +} + +# Check if disk is referenced anywhere in fstab +disk_in_fstab() { + local disk="$1" + local dev="/dev/$disk" + + # Check by device path + if grep -q "$dev" /etc/fstab 2>/dev/null; then + return 0 + fi + + # Check by UUID of any partition + while IFS= read -r part; do + [[ -z "$part" ]] && continue + local part_uuid + part_uuid=$(blkid -o value -s UUID "/dev/$part" 2>/dev/null || echo "") + if [[ -n "$part_uuid" ]] && grep -q "$part_uuid" /etc/fstab 2>/dev/null; then + return 0 + fi + done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) + + # Check whole disk UUID + local disk_uuid + disk_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "") + if [[ -n "$disk_uuid" ]] && grep -q "$disk_uuid" /etc/fstab 2>/dev/null; then + return 0 + fi + + return 1 +} + +format_disk() { + local disk="$1" + local dev="/dev/$disk" + + echo "" + log_step "Preparing to format /dev/$disk" + + # Safety check: Is the disk part of a RAID array? + if command -v mdadm &>/dev/null; then + if mdadm --examine "$dev" &>/dev/null || mdadm --examine "${dev}"* &>/dev/null 2>&1; then + log_error "Disk /dev/$disk appears to be part of a RAID array!" + log_error "Remove it from the array first with: mdadm --zero-superblock $dev" + return 1 + fi + fi + # Check for RAID signatures via blkid + if blkid "$dev" 2>/dev/null | grep -qi "linux_raid_member"; then + log_error "Disk /dev/$disk has a RAID superblock! Cannot format." + return 1 + fi + + # Safety check: Is the disk used by LVM? + if command -v pvs &>/dev/null; then + if pvs "$dev" &>/dev/null 2>&1 || pvs "${dev}"* &>/dev/null 2>&1; then + log_error "Disk /dev/$disk is an LVM physical volume!" + log_error "Remove it first with: pvremove $dev" + return 1 + fi + fi + + # Safety check: Is the disk or any partition currently in use? + if lsof "$dev"* 2>/dev/null | grep -q .; then + log_error "Disk /dev/$disk has open file handles! Cannot proceed." + log_error "Another process is using this disk." + return 1 + fi + + # Safety check: Is it in fstab? + if disk_in_fstab "$disk"; then + log_warn "This disk is referenced in /etc/fstab!" + grep -E "(${dev}|$(blkid -o value -s UUID "$dev" 2>/dev/null || echo 'NOUUID'))" /etc/fstab 2>/dev/null | sed 's/^/ /' + echo "" + log_warn "Proceeding will remove the old fstab entry." + fi + + local has_existing_data=false + if disk_has_data "$disk"; then + has_existing_data=true + fi + + if [[ "$has_existing_data" == true ]]; then + echo "" + log_danger "═══════════════════════════════════════════════════════════════" + log_danger " THIS DISK CONTAINS DATA! Formatting will DESTROY everything." + log_danger "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " Disk: ${BOLD}/dev/$disk${NC}" + echo -e " Existing filesystems:" + blkid "$dev"* 2>/dev/null | sed 's/^/ /' || true + echo "" + + # First confirmation + echo -e "${RED}${BOLD}Type the disk name (e.g., sdb) to confirm you want to DESTROY ALL DATA:${NC}" + read -r confirm_name + if [[ "$confirm_name" != "$disk" ]]; then + log_info "Disk name did not match. Aborting format." + return 1 + fi + + # Second confirmation + echo -e "${RED}${BOLD}Type YES (uppercase) to confirm formatting /dev/$disk:${NC}" + read -r confirm_yes + if [[ "$confirm_yes" != "YES" ]]; then + log_info "Confirmation not received. Aborting format." + return 1 + fi + else + echo "" + echo "This disk appears empty (no filesystems detected)." + read -p "Format /dev/$disk with ext4? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Skipping format." + return 1 + fi + fi + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would format /dev/$disk with ext4" + return 0 + fi + + log_info "Wiping existing partition table on /dev/$disk..." + wipefs -a "$dev" 2>/dev/null || true + + log_info "Creating GPT partition table..." + parted -s "$dev" mklabel gpt + + log_info "Creating single ext4 partition spanning entire disk..." + parted -s "$dev" mkpart primary ext4 0% 100% + + # Wait for kernel to pick up partition + sleep 2 + partprobe "$dev" 2>/dev/null || true + sleep 1 + + # Determine partition device name (sdb1 or nvme0n1p1) + local part_dev="" + # Try common naming patterns + if [[ -b "${dev}1" ]]; then + part_dev="${dev}1" + elif [[ -b "${dev}p1" ]]; then + part_dev="${dev}p1" + else + # Wait a bit more and retry + sleep 3 + partprobe "$dev" 2>/dev/null || true + if [[ -b "${dev}1" ]]; then + part_dev="${dev}1" + elif [[ -b "${dev}p1" ]]; then + part_dev="${dev}p1" + else + log_error "Could not find partition device after partitioning!" + log_error "Expected ${dev}1 or ${dev}p1" + lsblk "$dev" + return 1 + fi + fi + + log_info "Formatting ${part_dev} as ext4..." + mkfs.ext4 -F -L "felhom_data" "$part_dev" + + log_success "Disk formatted successfully" + echo " Partition: $part_dev" + echo " Filesystem: ext4" + echo " Label: felhom_data" + + # Return the partition device for mounting + echo "$part_dev" > /tmp/.hdd_setup_part_dev + return 0 +} + +#------------------------------------------------------------------------------- +# Mount point selection and mounting +#------------------------------------------------------------------------------- + +suggest_mount_point() { + # Find next available /mnt/hdd_N + local n=1 + while [[ -d "/mnt/hdd_${n}" ]] && mountpoint -q "/mnt/hdd_${n}" 2>/dev/null; do + ((n++)) + done + + # If dir exists but not mounted, it might be a leftover — still suggest it + if [[ -d "/mnt/hdd_${n}" ]] && [[ -z "$(ls -A /mnt/hdd_${n} 2>/dev/null)" ]]; then + echo "/mnt/hdd_${n}" + elif [[ ! -d "/mnt/hdd_${n}" ]]; then + echo "/mnt/hdd_${n}" + else + # Directory exists and has content, try next + ((n++)) + echo "/mnt/hdd_${n}" + fi +} + +select_mount_point() { + local mount_point="" + + if [[ -n "$PRESET_MOUNT_POINT" ]]; then + mount_point="$PRESET_MOUNT_POINT" + log_info "Using pre-set mount point: $mount_point" >&2 + else + local suggested + suggested=$(suggest_mount_point) + + echo "" >&2 + log_step "Select mount point" >&2 + echo "" >&2 + echo " Suggested: $suggested" >&2 + echo "" >&2 + read -rp " Enter mount point [${suggested}]: " user_mount + mount_point="${user_mount:-$suggested}" + fi + + # Validate mount point + if [[ ! "$mount_point" =~ ^/ ]]; then + log_error "Mount point must be an absolute path (start with /)" >&2 + return 1 + fi + + # Check if something is already mounted there + if mountpoint -q "$mount_point" 2>/dev/null; then + log_error "$mount_point is already a mount point!" >&2 + mount | grep "$mount_point" >&2 + return 1 + fi + + # Check if directory exists and has content + if [[ -d "$mount_point" ]] && [[ -n "$(ls -A "$mount_point" 2>/dev/null)" ]]; then + log_warn "$mount_point exists and is not empty!" >&2 + ls -la "$mount_point" >&2 + echo "" >&2 + log_warn "Mounting here will HIDE existing content (it won't be deleted, but inaccessible while mounted)." >&2 + read -p "Continue with this mount point? [y/N] " -n 1 -r + echo >&2 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + return 1 + fi + fi + + echo "$mount_point" +} + +mount_disk() { + local part_dev="$1" + local mount_point="$2" + + echo "" + log_step "Mounting $part_dev at $mount_point" + + # Get UUID + local uuid + uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null) + if [[ -z "$uuid" ]]; then + log_error "Could not determine UUID for $part_dev" + return 1 + fi + + log_info "Partition UUID: $uuid" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would create $mount_point" + log_info "[DRY RUN] Would add to /etc/fstab: UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2" + log_info "[DRY RUN] Would mount $part_dev at $mount_point" + return 0 + fi + + # Create mount point + mkdir -p "$mount_point" + + # Backup fstab + local fstab_backup + fstab_backup="/etc/fstab.backup.$(date +%Y%m%d_%H%M%S)" + cp /etc/fstab "$fstab_backup" + log_info "fstab backed up to: $fstab_backup" + + # Remove any existing entry for this UUID (in case of re-setup) + if grep -q "$uuid" /etc/fstab 2>/dev/null; then + log_warn "Removing existing fstab entry for UUID=$uuid" + sed -i "\|$uuid|d" /etc/fstab + fi + + # Add fstab entry + # nofail: system boots even if drive is missing + # x-systemd.device-timeout=10: don't wait forever during boot + # noatime: skip updating access timestamps (better performance) + local fstab_entry="UUID=$uuid $mount_point ext4 defaults,noatime,nofail,x-systemd.device-timeout=10 0 2" + + { + echo "" + echo "# Felhom data drive - added by hdd-setup.sh on $(date '+%Y-%m-%d %H:%M:%S')" + echo "$fstab_entry" + } >> /etc/fstab + + log_info "Added to /etc/fstab:" + echo " $fstab_entry" + + # Verify fstab syntax (multiple methods - findmnt segfaults on some Debian 13 builds) + local fstab_ok=false + + # Method 1: findmnt --verify (preferred, but may segfault on trixie) + local verify_exit=0 + findmnt --verify --tab-file /etc/fstab &>/dev/null || verify_exit=$? + if [[ $verify_exit -eq 0 ]]; then + fstab_ok=true + log_debug "fstab verified via findmnt" + elif [[ $verify_exit -eq 139 ]] || [[ $verify_exit -gt 128 ]]; then + log_debug "findmnt segfaulted (exit $verify_exit), falling back to mount --fake" + fi + + # Method 2: mount --fake -a (dry-run mount test) + if [[ "$fstab_ok" == false ]]; then + if mount --fake -a &>/dev/null; then + fstab_ok=true + log_debug "fstab verified via mount --fake" + fi + fi + + # Method 3: Basic sanity check on the entry we just wrote + if [[ "$fstab_ok" == false ]]; then + # Verify our entry has all required fields (UUID, mountpoint, fstype, options, dump, pass) + local field_count + field_count=$(echo "$fstab_entry" | awk '{print NF}') + if [[ "$field_count" -eq 6 ]] && [[ "$fstab_entry" == UUID=* ]]; then + fstab_ok=true + log_debug "fstab verified via field count sanity check" + fi + fi + + if [[ "$fstab_ok" == false ]]; then + log_error "fstab verification failed! Restoring backup..." + cp "$fstab_backup" /etc/fstab + log_info "fstab restored from backup" + return 1 + fi + log_success "fstab syntax verified" + + # Mount + mount "$mount_point" + + if mountpoint -q "$mount_point"; then + log_success "Disk mounted at $mount_point" + + # Show mounted disk info + df -h "$mount_point" | sed 's/^/ /' + else + log_error "Mount failed! Check dmesg for errors." + dmesg | tail -5 + return 1 + fi +} + +#------------------------------------------------------------------------------- +# Folder structure creation +#------------------------------------------------------------------------------- + +create_folder_structure() { + local mount_point="$1" + + echo "" + log_step "Creating Felhom folder structure at $mount_point" + echo "" + echo -e " ${BOLD}Planned structure:${NC}" + echo " $mount_point/" + + for dir in "${USER_DIRS[@]}"; do + echo " ├── $dir/" + done + for dir in "${MEDIA_DIRS[@]}"; do + echo " ├── $dir/" + done + for dir in "${STORAGE_DIRS[@]}"; do + echo " ├── $dir/" + done + for dir in "${APPDATA_DIRS[@]}"; do + echo " └── $dir/" + done + + echo "" + echo " Owner: ${FOLDER_UID}:${FOLDER_GID} (standard Docker user)" + echo "" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would create the above folder structure" + return 0 + fi + + read -p "Create this folder structure? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Nn]$ ]]; then + log_skip "Folder structure creation skipped" + return 0 + fi + + for dir in "${USER_DIRS[@]}" "${MEDIA_DIRS[@]}" "${STORAGE_DIRS[@]}" "${APPDATA_DIRS[@]}"; do + mkdir -p "${mount_point}/${dir}" + log_debug "Created: ${mount_point}/${dir}" + done + + # Set ownership recursively + chown -R "${FOLDER_UID}:${FOLDER_GID}" "$mount_point" + + # Set permissions: owner rwx, group rwx, others r-x + # This allows Docker containers running as 1000:1000 to read/write + chmod -R 775 "$mount_point" + + # Verify + local created_count + created_count=$(find "$mount_point" -type d | wc -l) + log_success "Folder structure created ($created_count directories)" + + echo "" + echo -e " ${BOLD}Result:${NC}" + # Use find to show the tree structure (works without 'tree' command) + (cd "$mount_point" && find . -type d -maxdepth 3 | sort | sed 's|^\.||' | sed 's|/| |g' | sed 's/^/ /') +} + +#------------------------------------------------------------------------------- +# Existing partition handling +#------------------------------------------------------------------------------- + +# Handle a disk that already has a usable ext4 partition +handle_existing_partition() { + local disk="$1" + local dev="/dev/$disk" + + echo "" + log_step "Checking for usable existing partitions on /dev/$disk" + + local usable_parts=() + local part_info=() + + # Check whole disk for ext4 + local whole_fs + whole_fs=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "") + if [[ "$whole_fs" == "ext4" ]]; then + usable_parts+=("$dev") + local whole_label whole_uuid + whole_label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "") + whole_uuid=$(blkid -o value -s UUID "$dev" 2>/dev/null || echo "") + part_info+=("$dev fs=ext4 label=$whole_label uuid=$whole_uuid") + fi + + # Check partitions + while IFS= read -r part; do + [[ -z "$part" ]] && continue + local part_dev="/dev/$part" + local part_fs + part_fs=$(blkid -o value -s TYPE "$part_dev" 2>/dev/null || echo "") + if [[ "$part_fs" == "ext4" ]]; then + usable_parts+=("$part_dev") + local p_label p_uuid + p_label=$(blkid -o value -s LABEL "$part_dev" 2>/dev/null || echo "") + p_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "") + part_info+=("$part_dev fs=ext4 label=$p_label uuid=$p_uuid") + fi + done < <(lsblk -lno NAME "$dev" 2>/dev/null | tail -n +2) + + if [[ ${#usable_parts[@]} -eq 0 ]]; then + return 1 # No usable ext4 partitions + fi + + echo "" + echo -e " ${GREEN}Found usable ext4 partition(s):${NC}" + for info in "${part_info[@]}"; do + echo " $info" + done + echo "" + + echo "Options:" + echo " 1) Use existing ext4 partition (no format, no data loss)" + echo " 2) Wipe and reformat the entire disk" + echo " 3) Cancel" + echo "" + read -rp "Choose [1/2/3]: " choice + + case "$choice" in + 1) + # Use the first (or only) usable ext4 partition + local selected_part="${usable_parts[0]}" + if [[ ${#usable_parts[@]} -gt 1 ]]; then + echo "" + echo "Multiple ext4 partitions found. Select one:" + for i in "${!usable_parts[@]}"; do + echo " $((i+1))) ${part_info[$i]}" + done + read -rp "Choose [1-${#usable_parts[@]}]: " part_choice + local idx=$((part_choice - 1)) + if [[ $idx -ge 0 && $idx -lt ${#usable_parts[@]} ]]; then + selected_part="${usable_parts[$idx]}" + else + log_error "Invalid selection" + return 1 + fi + fi + + log_info "Using existing partition: $selected_part" + + # Run fsck before mounting + log_info "Running filesystem check on $selected_part..." + if ! fsck.ext4 -n "$selected_part" 2>/dev/null; then + log_warn "Filesystem has issues. Run 'fsck.ext4 -y $selected_part' manually to fix." + read -p "Try auto-fix now? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + fsck.ext4 -y "$selected_part" || true + fi + else + log_success "Filesystem check passed" + fi + + echo "$selected_part" > /tmp/.hdd_setup_part_dev + return 0 + ;; + 2) + # Will proceed to format + return 1 + ;; + 3) + log_info "Cancelled." + exit 0 + ;; + *) + log_error "Invalid choice" + return 1 + ;; + esac +} + +#------------------------------------------------------------------------------- +# Main flow +#------------------------------------------------------------------------------- + +main() { + parse_args "$@" + check_root + + # Logging + LOG_FILE="/var/log/hdd-setup.log" + mkdir -p /var/log 2>/dev/null || true + exec > >(tee -a "$LOG_FILE") 2>&1 + + if [[ "$DEBUG_MODE" == true ]]; then + set -x + fi + + print_banner + + # Prevent concurrent runs + if [[ -f "$LOCK_FILE" ]]; then + local lock_pid + lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "") + if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then + log_error "Another instance is already running (PID: $lock_pid)" + log_error "If this is incorrect, remove $LOCK_FILE" + exit 1 + fi + fi + echo $$ > "$LOCK_FILE" + + if [[ "$DRY_RUN" == true ]]; then + echo -e "${YELLOW}DRY RUN MODE - no changes will be made${NC}" + echo "" + fi + + install_dependencies + + #--------------------------------------------------------------------------- + # Phase 1: Detect candidate disks + #--------------------------------------------------------------------------- + log_step "Phase 1: Scanning for unmounted disks..." + + local root_disk + root_disk=$(get_root_disk) + log_info "System disk: /dev/$root_disk (will be excluded)" + + local candidates_str + candidates_str=$(get_candidate_disks) + + if [[ -z "$candidates_str" ]]; then + echo "" + log_warn "No unmounted disks found!" + echo "" + echo " All detected disks:" + lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | sed 's/^/ /' + echo "" + echo " Possible reasons:" + echo " - All disks are already mounted" + echo " - External drive not connected/detected" + echo " - Drive may need a different driver" + echo "" + echo " Troubleshooting:" + echo " dmesg | tail -20 # Check kernel messages" + echo " lsusb # Check USB devices" + echo " lsblk # Full block device list" + exit 0 + fi + + # Convert to array + read -ra candidates <<< "$candidates_str" + + echo "" + log_info "Found ${#candidates[@]} unmounted disk(s)" + + #--------------------------------------------------------------------------- + # Phase 2: Show disk information + #--------------------------------------------------------------------------- + log_step "Phase 2: Inspecting disks..." + + local disk_has_data_map=() + for disk in "${candidates[@]}"; do + local has_data=false + print_disk_info "$disk" || has_data=true + disk_has_data_map+=("$has_data") + done + + if [[ "$SCAN_ONLY" == true ]]; then + echo "" + echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD} Scan complete. Use without --scan to set up a disk.${NC}" + echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + exit 0 + fi + + #--------------------------------------------------------------------------- + # Phase 3: Select disk + #--------------------------------------------------------------------------- + echo "" + log_step "Phase 3: Select a disk to set up" + echo "" + + if [[ ${#candidates[@]} -eq 1 ]]; then + echo " Only one unmounted disk found: /dev/${candidates[0]}" + read -p " Set up this disk? [Y/n] " -n 1 -r + echo + if [[ $REPLY =~ ^[Nn]$ ]]; then + log_info "Cancelled." + exit 0 + fi + selected_disk="${candidates[0]}" + else + echo " Available disks:" + for i in "${!candidates[@]}"; do + local d="${candidates[$i]}" + local s + s=$(lsblk -dno SIZE "/dev/$d" 2>/dev/null | tr -d ' ') + local m + m=$(lsblk -dno MODEL "/dev/$d" 2>/dev/null | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + echo " $((i+1))) /dev/$d ($s, ${m:-Unknown})" + done + echo "" + read -rp " Select disk [1-${#candidates[@]}]: " disk_choice + local idx=$((disk_choice - 1)) + if [[ $idx -lt 0 || $idx -ge ${#candidates[@]} ]]; then + log_error "Invalid selection" + exit 1 + fi + selected_disk="${candidates[$idx]}" + fi + + log_info "Selected: /dev/$selected_disk" + + #--------------------------------------------------------------------------- + # Phase 4: Format or use existing + #--------------------------------------------------------------------------- + log_step "Phase 4: Prepare filesystem" + + local part_dev="" + + # Check if disk already has a usable ext4 partition + if handle_existing_partition "$selected_disk"; then + # User chose to use existing partition + part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null) + rm -f /tmp/.hdd_setup_part_dev + else + # Need to format + if format_disk "$selected_disk"; then + part_dev=$(cat /tmp/.hdd_setup_part_dev 2>/dev/null) + rm -f /tmp/.hdd_setup_part_dev + else + log_error "Format failed or was cancelled." + exit 1 + fi + fi + + if [[ -z "$part_dev" ]] && [[ "$DRY_RUN" == false ]]; then + log_error "No partition device determined. Cannot proceed." + exit 1 + fi + + # For dry run, simulate a partition device + if [[ "$DRY_RUN" == true ]] && [[ -z "$part_dev" ]]; then + part_dev="/dev/${selected_disk}1" + fi + + #--------------------------------------------------------------------------- + # Phase 5: Mount + #--------------------------------------------------------------------------- + log_step "Phase 5: Mount disk" + + local mount_point + mount_point=$(select_mount_point) + if [[ -z "$mount_point" ]]; then + log_error "No mount point selected. Aborting." + exit 1 + fi + + mount_disk "$part_dev" "$mount_point" + + #--------------------------------------------------------------------------- + # Phase 6: Create folder structure + #--------------------------------------------------------------------------- + if [[ "$SKIP_FOLDERS" == false ]]; then + log_step "Phase 6: Create folder structure" + create_folder_structure "$mount_point" + fi + + #--------------------------------------------------------------------------- + # Summary + #--------------------------------------------------------------------------- + echo "" + echo -e "${BOLD}${GREEN}-==================================================================¬${NC}" + echo -e "${BOLD}${GREEN}¦ HDD Setup Complete! ¦${NC}" + echo -e "${BOLD}${GREEN}L==================================================================-${NC}" + echo "" + echo -e " ${BOLD}Disk:${NC} /dev/$selected_disk" + echo -e " ${BOLD}Partition:${NC} $part_dev" + if [[ "$DRY_RUN" == false ]]; then + local final_uuid + final_uuid=$(blkid -o value -s UUID "$part_dev" 2>/dev/null || echo "N/A") + echo -e " ${BOLD}UUID:${NC} $final_uuid" + fi + echo -e " ${BOLD}Mount point:${NC} $mount_point" + echo -e " ${BOLD}Filesystem:${NC} ext4" + echo -e " ${BOLD}fstab:${NC} Configured (nofail)" + echo "" + + if [[ "$DRY_RUN" == false ]]; then + echo -e " ${BOLD}Disk usage:${NC}" + df -h "$mount_point" | sed 's/^/ /' + echo "" + fi + + echo -e " ${BOLD}Docker volume mount examples:${NC}" + echo " # Media stack (Sonarr/Radarr/Plex)" + echo " volumes:" + echo " - ${mount_point}/media:/data/media" + echo "" + echo " # Immich photo storage" + echo " volumes:" + echo " - ${mount_point}/storage/immich:/usr/src/app/upload" + echo "" + echo " # Nextcloud data" + echo " volumes:" + echo " - ${mount_point}/storage/nextcloud:/var/www/html/data" + echo "" + echo -e " ${BOLD}Verify mount survives reboot:${NC}" + echo " sudo mount -a # Test fstab now" + echo " sudo reboot # Full test (when convenient)" + echo "" + echo -e " ${BOLD}Quick check commands:${NC}" + echo " lsblk # See all block devices" + echo " df -h ${mount_point} # Disk usage" + echo " ls -la ${mount_point} # Folder structure" + echo "" +} + +main "$@" \ No newline at end of file