#!/bin/bash # ══════════════════════════════════════════════════════════════════════════════ # enconf Webpanel — One-Liner Installer # # curl -fsSL https://get.enconf.com | sudo bash # # Supported: Debian 13 (Trixie) — amd64 + arm64 # # On a minimal Debian install neither curl nor sudo are pre-installed: # apt update && apt install -y curl sudo # ══════════════════════════════════════════════════════════════════════════════ set -euo pipefail # Force a C locale so command output we parse (apt-cache policy "Candidate:", # apt messages, …) is always English. Without this, a German/other-locale # Debian prints "Installationskandidat:" and `grep Candidate` fails — which, # under `pipefail`+`set -e`, aborted the whole installer silently right after # the checklist. C.UTF-8 keeps UTF-8 so the spinner/✓/✗ glyphs still render. export LC_ALL=C.UTF-8 LANG=C.UTF-8 # ─── Colors + helpers ───────────────────────────────────────────────────────── GRN="\033[0;32m"; RED="\033[0;31m"; YLW="\033[0;33m"; CYN="\033[0;36m" BLD="\033[1m"; DIM="\033[2m"; NC="\033[0m" CHECK="${GRN}✓${NC}" CROSS="${RED}✗${NC}" SPIN="${CYN}⟳${NC}" step() { local label="$1"; shift local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' local pid logf logf="$(mktemp /tmp/enconf-step.XXXXXX 2>/dev/null || echo /tmp/enconf-step.log)" # Start command in background, capturing output so a failure can be shown. "$@" >"$logf" 2>&1 & pid=$! # Animate spinner with elapsed time local i=0 local start=$SECONDS while kill -0 "$pid" 2>/dev/null; do local c="${spin_chars:i%${#spin_chars}:1}" local elapsed=$(( SECONDS - start )) printf "\r ${CYN}${c}${NC} ${label}... ${DIM}${elapsed}s${NC} " sleep 0.1 i=$((i + 1)) done local total=$(( SECONDS - start )) if wait "$pid"; then printf "\r ${CHECK} ${label} ${DIM}(${total}s)${NC} \n" rm -f "$logf" return 0 fi # Failure — surface the captured output. A silent abort here (output sent to # /dev/null) was the #1 install-support issue: the user saw only ✗ and had # no idea why. Now the last lines of the real error are printed. printf "\r ${CROSS} ${label} ${DIM}(${total}s)${NC} \n" echo -e " ${RED}Failed at: ${label}${NC}" echo -e " ${DIM}────────────────── Output ──────────────────${NC}" tail -n 25 "$logf" | sed 's/^/ /' echo -e " ${DIM}──────────────────────────────────────────────${NC}" rm -f "$logf" return 1 } step_log() { # Print step result directly (no command) printf " ${CHECK} $1\n" } fail() { echo -e "\n ${CROSS} ${RED}$*${NC}\n"; exit 1; } # On a freshly booted server, unattended-upgrades/apt-daily holds the dpkg/apt # lock — apt-get then aborts with "Could not get lock". Wait until it's free # (max 5 min) instead of failing immediately on first boot. wait_for_apt() { local max=300 waited=0 while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/lib/dpkg/lock >/dev/null 2>&1; do [ "$waited" -ge "$max" ] && fail "apt/dpkg lock still held after ${max}s. Stop running apt processes and retry." sleep 3 waited=$((waited + 3)) done } apt_update() { wait_for_apt # PDiffs=false avoids a corrupted incremental-index state that can leave # Debian's own packages unfindable ("Unable to locate package ..."). apt-get update -qq -o Acquire::PDiffs=false 2>/dev/null || true # Some cloud images arrive with a broken apt list state (empty main index # from failed PDiffs) — Debian packages that aren't pre-installed then can't # be located. unbound is a good canary: always in Debian main, and the # installer needs it a few steps later. If it's unresolvable, nuke the lists # and do a clean full rebuild. if ! apt-cache policy unbound 2>/dev/null | grep -Eq 'Candidate: [0-9]'; then rm -rf /var/lib/apt/lists/* 2>/dev/null apt-get clean >/dev/null 2>&1 || true apt-get update -qq -o Acquire::PDiffs=false fi } install_prereqs() { wait_for_apt; DEBIAN_FRONTEND=noninteractive apt-get install -y -qq -o Dpkg::Options::=--force-confold curl gnupg ca-certificates apt-transport-https; } # ─── Banner ─────────────────────────────────────────────────────────────────── clear 2>/dev/null || true echo "" echo -e " ${CYN}${BLD}enconf Webpanel${NC} — Installer" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" # ─── Root check ─────────────────────────────────────────────────────────────── [ "$(id -u)" -ne 0 ] && fail "Please run as root: curl -fsSL https://get.enconf.com | sudo bash" # ─── OS check ───────────────────────────────────────────────────────────────── if [ -f /etc/os-release ]; then . /etc/os-release OS_ID="$ID" OS_VERSION="$VERSION_ID" OS_CODENAME="${VERSION_CODENAME:-}" else fail "Operating system not detected." fi case "$OS_ID" in debian) [ "$OS_VERSION" = "13" ] || fail "Debian $OS_VERSION not supported (only 13)." ;; *) fail "$OS_ID not supported. enconf Webpanel only runs on Debian 13 (Trixie)." ;; esac ARCH=$(dpkg --print-architecture 2>/dev/null || uname -m) case "$ARCH" in amd64|x86_64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; *) fail "Architecture $ARCH not supported (only amd64/arm64)." ;; esac step_log "${OS_ID^} ${OS_VERSION} (${OS_CODENAME}) · ${ARCH}" # ─── Safety: refuse to re-provision an already-provisioned / demo box ────────── # A destructive fresh install on a box that already holds enconf state can wipe a # live panel or the demo. Removing the .deb does NOT clear /var/lib/enconf or # /etc/enconf, and the demo seed (/var/lib/enconf/demo-seed*) survives a package # removal — so these are reliable "this box was already provisioned" markers even # when the packages are gone. This installer is for FRESH boxes; updates go via # apt. Override for a deliberate wipe-and-rebuild with ENCONF_FORCE_REINSTALL=1. if [ "${ENCONF_FORCE_REINSTALL:-}" != "1" ]; then if ls /var/lib/enconf/demo-seed* >/dev/null 2>&1; then fail "This box carries the DEMO seed (/var/lib/enconf/demo-seed*) — it is the demo server, not a fresh box. Refusing to reinstall. Set ENCONF_FORCE_REINSTALL=1 ONLY if you truly mean to wipe it." fi if [ -e /etc/enconf/api.env ] || [ -e /etc/enconf/agent.env ] || dpkg-query -W -f='${Status}' enconf-webpanel enconf-agent 2>/dev/null | grep -q "install ok installed"; then fail "Existing enconf install detected (/etc/enconf or installed packages) — this box is already provisioned. Refusing to reinstall over it (updates go via 'apt upgrade'). Set ENCONF_FORCE_REINSTALL=1 to override." fi fi # ─── 1. Update system ──────────────────────────────────────────────────────── rm -f /etc/apt/sources.list.d/netcell.list /etc/apt/keyrings/netcell-gitea.asc 2>/dev/null || true step "Update system" apt_update step "Install prerequisites" install_prereqs # ─── 2. Set up repository ──────────────────────────────────────────────────── setup_repo() { wait_for_apt rm -f /etc/apt/sources.list.d/netcell.list /etc/apt/keyrings/netcell-gitea.asc 2>/dev/null || true mkdir -p /etc/apt/keyrings curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \ -o /etc/apt/keyrings/enconf-gitea.asc # Kanal-Modell: Suite = OS-Codename, Komponente = Kanal (Default stable). local suite="$OS_CODENAME" case "$suite" in trixie) ;; *) suite="trixie" ;; esac local channel="${ENCONF_CHANNEL:-stable}" echo "deb [signed-by=/etc/apt/keyrings/enconf-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $suite $channel" \ > /etc/apt/sources.list.d/enconf.list apt-get update -qq -o Acquire::PDiffs=false } step "Set up enconf apt repository" setup_repo # ─── Sury PHP repository ───────────────────────────────────────────────────── # Sury (packages.sury.org) is the canonical upstream for PHP on Debian. # We install it BEFORE the panel so apt pulls PHP from Sury instead of Debian's # own trixie-security php8.4-fpm, whose postinst tries to start the FPM service # before /etc/php/8.4/fpm/php-fpm.conf is deployed — that race leaves PHP-FPM # failed on first install and cascades the panel's configure step into # half-configured state. Sury deploys the conffile before triggering the # service, which avoids the race entirely. # # APT pin priority 600 > default 500 so Sury wins over Debian for every php* # package, including on machines that already had Debian's PHP installed. setup_sury() { wait_for_apt # Drop any alternative Sury source (e.g. the agent installer's php.list with a # different signed-by keyring) so apt doesn't fail with "Conflicting values set # for option Signed-By … packages.sury.org". rm -f /etc/apt/sources.list.d/php.list 2>/dev/null || true curl -fsSL https://packages.sury.org/php/apt.gpg \ -o /etc/apt/keyrings/sury-php.gpg chmod 644 /etc/apt/keyrings/sury-php.gpg echo "deb [signed-by=/etc/apt/keyrings/sury-php.gpg] https://packages.sury.org/php ${OS_CODENAME} main" \ > /etc/apt/sources.list.d/sury-php.list cat > /etc/apt/preferences.d/sury-php </dev/null | awk '/Candidate:/{print $2}') [ -z "$AVAILABLE" ] && AVAILABLE="?" step_log "Version ${BLD}${AVAILABLE}${NC} available" # ─── 3. Unbound DNS resolver ───────────────────────────────────────────────── setup_unbound() { wait_for_apt # dns-root-data is named explicitly: unbound only *Recommends* it, but # Debian's shipped root-auto-trust-anchor-file.conf makes it mandatory in # practice. /var/lib/unbound/root.key is created by unbound-helper copying # /usr/share/dns/root.key, which that package provides — no package, no copy # ("[ -r "$DNS_ROOT_KEY_FILE" ] || return"), and unbound then refuses to # start with "unable to open /var/lib/unbound/root.key". On any image that # disables recommends the install died right here. if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unbound dns-root-data 2>/dev/null; then rm -rf /var/lib/apt/lists/* 2>/dev/null apt-get clean >/dev/null 2>&1 || true apt-get update -qq -o Acquire::PDiffs=false DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unbound dns-root-data fi # Always use port 5353 — PowerDNS will likely be installed later during # role setup and needs port 53. Unbound on 5353 works for rspamd/RBL # and avoids any conflict regardless of which roles are assigned. UB_PORT=5353 mkdir -p /etc/unbound/unbound.conf.d cat > /etc/unbound/unbound.conf.d/netcell.conf </dev/null; then sed -i '/^[[:space:]]*RESOLVCONF[[:space:]]*=/d' /etc/default/unbound 2>/dev/null || true { echo "# Managed by enconf: unbound listens on ${UB_PORT}, publishing it as the system" echo "# resolver would point resolv.conf at 127.0.0.1:53 where nothing answers." echo "RESOLVCONF=false" } >> /etc/default/unbound fi systemctl stop unbound-resolvconf.service 2>/dev/null || true systemctl mask unbound-resolvconf.service 2>/dev/null || true # Disable systemd-resolved stub on port 53 so PowerDNS can bind there later. # This is the one step here that can take the whole server's DNS down: if # /etc/resolv.conf names the stub address (127.0.0.53), switching the # listener off leaves nothing answering. Normally systemd-resolved writes the # real upstreams into its resolv.conf files and everything keeps working — # but on an image with a static resolv.conf pointing at the stub, resolution # dies here. That is not theoretical: a customer whose install aborted at # this step was left with a box whose resolver answered nothing, which then # showed up as PowerDNS logging "RCODE was Server Failure" every 30 minutes. mkdir -p /etc/systemd/resolved.conf.d printf "[Resolve]\nDNSStubListener=no\n" > /etc/systemd/resolved.conf.d/no-stub.conf systemctl restart systemd-resolved 2>/dev/null || true # Check resolv.conf itself, NOT `getent`: Debian's nsswitch hands getent to # systemd-resolved over varlink, so it keeps resolving even when # /etc/resolv.conf names the stub we just switched off — while everything # using the classic resolver (PowerDNS's security poll among them) fails. # The file is broken when it lists no reachable nameserver: nothing outside # loopback, and nothing actually listening on the loopback address given. resolv_conf_is_dead() { local all ext ns all=$(awk '/^[[:space:]]*nameserver[[:space:]]/{print $2}' /etc/resolv.conf 2>/dev/null) [ -z "$all" ] && return 0 ext=$(printf '%s\n' "$all" | grep -vE '^(127\.|::1$)' || true) [ -n "$ext" ] && return 1 for ns in $all; do ss -lun 2>/dev/null | grep -q "${ns}:53" && return 1 done return 0 } if resolv_conf_is_dead; then if [ -e /run/systemd/resolve/resolv.conf ]; then # resolved's uplink list — the real upstreams, no stub involved. rm -f /etc/resolv.conf ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf fi if resolv_conf_is_dead; then rm -f /etc/resolv.conf printf 'nameserver 1.1.1.1\nnameserver 9.9.9.9\n' > /etc/resolv.conf fi resolv_conf_is_dead && : > /tmp/enconf-dns-broken fi # Last-resort anchor: the package path above should have produced root.key, # but unbound.service swallows its own ExecStartPre failure (the "-" prefix), # so a missing anchor only surfaces as a dead service. if [ ! -s /var/lib/unbound/root.key ]; then mkdir -p /var/lib/unbound if [ -r /usr/share/dns/root.key ]; then cp /usr/share/dns/root.key /var/lib/unbound/root.key elif command -v unbound-anchor >/dev/null 2>&1; then unbound-anchor -a /var/lib/unbound/root.key 2>/dev/null || true fi chown unbound:unbound /var/lib/unbound/root.key 2>/dev/null || true fi # Non-fatal: unbound is the local resolver for rspamd/RBL on 5353, not a # prerequisite for the panel. Aborting the whole installation over it left # customers with a half-installed server and no panel at all. if ! systemctl enable --now unbound 2>/dev/null; then : > /tmp/enconf-unbound-failed fi } rm -f /tmp/enconf-unbound-failed /tmp/enconf-dns-broken step "Install Unbound DNS resolver" setup_unbound if [ -e /tmp/enconf-dns-broken ]; then # Nothing further can work without name resolution — apt is the very next # step. Stop here with an actionable message rather than failing obscurely. rm -f /tmp/enconf-dns-broken fail "Keine DNS-Auflösung mehr nach dem Abschalten des systemd-resolved-Stubs. Bitte einen erreichbaren Nameserver in /etc/resolv.conf eintragen, z. B.: echo 'nameserver 1.1.1.1' | sudo tee /etc/resolv.conf Danach den Installer erneut ausführen." fi if [ -e /tmp/enconf-unbound-failed ]; then # Visible but non-fatal: the panel installs and runs without unbound; only # rspamd's RBL lookups fall back to the system resolver. The agent retries # the anchor repair on every start (ensureUnboundTrustAnchor). printf " ${YLW}!${NC} Unbound startet nicht — Installation läuft weiter.\n" printf " ${DIM}Diagnose: systemctl status unbound${NC}\n" rm -f /tmp/enconf-unbound-failed fi # ─── 4. Install enconf Webpanel ────────────────────────────────────────────── install_panel() { wait_for_apt local apt_opts=(-y -qq -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef) if ! DEBIAN_FRONTEND=noninteractive apt-get install "${apt_opts[@]}" enconf-webpanel proftpd-mod-crypto; then # Safety net for any upstream package whose postinst still misbehaves. # DEBIAN_FRONTEND=noninteractive is critical on dpkg --configure too — # without it, roundcube-core.config opens a debconf prompt and the # whole installer stalls waiting on stdin that will never arrive. DEBIAN_FRONTEND=noninteractive dpkg --configure -a >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get install "${apt_opts[@]}" --fix-broken >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get install "${apt_opts[@]}" enconf-webpanel proftpd-mod-crypto fi } step "Install enconf Webpanel" install_panel # ─── 4b. Filesystem disk quota on / (best-effort, never fails the install) ── # Per-package DiskQuotaMB/MaxInodes limits are stored regardless of this, but # only ENFORCED once the OS has filesystem quotas turned on for /. Safe to # attempt here, unlike on an already-running production box (see # docs.enconf.com -> Admin -> Festplatten-Quota for why that stays a manual, # admin-timed step): nothing is provisioned yet on a fresh install, so there is # no live customer data a `mount -o remount` could disrupt — and a remount is # itself atomic, it either applies cleanly or fails leaving the current mount # untouched. Deliberately quiet/best-effort: skips on unsupported filesystems # or containers without a real block device, and never aborts the panel # install either way. The Security Advisor reports the real state honestly # regardless of whether this step succeeded. enable_disk_quota() { local fstype opt fstype=$(findmnt -no FSTYPE / 2>/dev/null || echo "") case "$fstype" in ext2|ext3|ext4) opt="usrquota" ;; xfs) opt="uquota" ;; *) printf " ${DIM}·${NC} Disk quota: skipped (unsupported filesystem: ${fstype:-unknown})\n" return 0 ;; esac # A single source of truth for "is the user quota actually running". # `quotaon -p` prints one line per quota type; matching a bare "is on" would # also be satisfied by the group or project quota, neither of which enforces # a package limit. quota_user_is_on() { command -v quotaon >/dev/null 2>&1 && quotaon -p / 2>/dev/null | grep -qiE "^(user|usr) quota .* is on" } # After an activation attempt the state needs a moment to settle: the remount # triggers systemd's quota generator, so quotaon can report "Device or # resource busy" while the quota is being switched on behind it. Asking # immediately reported a failure on a fresh install whose quota was active # seconds later — the outcome was right, only the timing of the question. quota_user_becomes_on() { local i for i in 1 2 3 4 5 6 7 8; do quota_user_is_on && return 0 sleep 1 done return 1 } if findmnt -no OPTIONS / 2>/dev/null | grep -qE "(^|,)(usrquota|uquota|quota)(,|$)"; then # The mount option is not proof that anything is enforced. It survives in # /etc/fstab across a reinstall, and the kernel keeps it across a remount # even after the entry is gone, so both "tooling missing" and "tooling # present but quota off" hide behind it. Reporting "already active" on # the strength of the option alone is how this shipped a server whose # quotaon-root.service failed at every boot with no limit enforced. if quota_user_is_on; then printf " ${CHECK} Disk quota: already active\n" return 0 fi if ! command -v quotaon >/dev/null 2>&1; then if ! DEBIAN_FRONTEND=noninteractive apt-get install -y -qq quota >/dev/null 2>&1; then printf " ${DIM}·${NC} Disk quota: mount option set but quota package unavailable\n" return 0 fi fi # `quotaon /` is what actually enables it. The packaged # quotaon-root.service runs `quotaon -ug /`, and the -g half has nothing # to enable because only usrquota is in fstab — the unit then reports # success to systemd while the user quota stays off. quotacheck -cum / >/dev/null 2>&1 || true quotaon / >/dev/null 2>&1 || true systemctl reset-failed quotaon-root.service >/dev/null 2>&1 || true if quota_user_becomes_on; then printf " ${CHECK} Disk quota: active (per-package limits are now enforced)\n" else printf " ${DIM}·${NC} Disk quota: mount option set, could not turn on — see docs.enconf.com -> Admin -> Festplatten-Quota\n" fi return 0 fi if ! grep -qE '^\S+\s+/\s' /etc/fstab 2>/dev/null; then printf " ${DIM}·${NC} Disk quota: skipped (no root entry in /etc/fstab)\n" return 0 fi DEBIAN_FRONTEND=noninteractive apt-get install -y -qq quota >/dev/null 2>&1 || { printf " ${DIM}·${NC} Disk quota: skipped (quota package unavailable)\n" return 0 } cp -a /etc/fstab "/etc/fstab.bak-$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true if ! sed -i -E "s|^(\S+\s+/\s+${fstype}\s+)(\S+)(\s+.*)|\1\2,${opt}\3|" /etc/fstab; then printf " ${DIM}·${NC} Disk quota: skipped (could not edit /etc/fstab)\n" return 0 fi if [ "$fstype" = "xfs" ]; then printf " ${CHECK} Disk quota: fstab updated (uquota) — XFS activates on next reboot\n" return 0 fi if ! mount -o remount / 2>/dev/null; then printf " ${DIM}·${NC} Disk quota: fstab updated, remount failed — will activate on next reboot\n" return 0 fi # The remount does not always pick the option up on a live root filesystem. # Without it in the mount, quotacheck/quotaon cannot enable anything, and # reporting that as "could not turn on" was misleading: the fstab entry is # correct and the quota comes up on the next boot. Say which of the two # happened instead of calling both a failure. if ! findmnt -no OPTIONS / 2>/dev/null | grep -qE "(^|,)(usrquota|uquota|quota)(,|$)"; then printf " ${CHECK} Disk quota: fstab updated — activates on next reboot\n" return 0 fi quotacheck -cum / >/dev/null 2>&1 || true quotaon / >/dev/null 2>&1 || true # Judge by the quota's actual state, not by quotaon's exit status: it returns # 0 while printing "Device or resource busy" on an ext4 with external quota # files, so a zero exit says nothing about whether a limit is enforced. if quota_user_becomes_on; then printf " ${CHECK} Disk quota: active (per-package limits are now enforced)\n" else # Not a failure, and saying so was wrong: on a freshly remounted root the # kernel reports the user quota as off for a while after quotaon, and # every run measured here had it active shortly afterwards. The panel # reports the live state under Admin -> Security Advisor, so point there # rather than asserting an outcome this moment cannot establish. printf " ${CHECK} Disk quota: fstab updated and enabled — status under Admin -> Security Advisor\n" fi } enable_disk_quota || true # ─── 5. Dienste prüfen ─────────────────────────────────────────────────────── # Install Chromium in background (for site thumbnails) — user can already # start the setup wizard while this runs silently. nohup bash -c 'DEBIAN_FRONTEND=noninteractive apt-get install -y -qq chromium >/dev/null 2>&1' >/dev/null 2>&1 & for svc in enconf-api enconf-agent nginx; do if systemctl is-active --quiet "$svc" 2>/dev/null; then step_log "${svc} running" else printf " ${CROSS} ${svc} not active\n" fi done # ─── Result ────────────────────────────────────────────────────────────────── SERVER_IP=$(hostname -I | awk '{print $1}') echo "" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" echo -e " ${GRN}${BLD}Installation complete!${NC}" echo "" echo -e " ${BLD}▸ Setup wizard:${NC} ${CYN}https://${SERVER_IP}:3443/setup${NC}" echo "" echo -e " ${DIM}Open the URL in your browser to configure the panel.${NC}" echo -e " ${DIM}(SSL certificate is self-signed — confirm the browser warning)${NC}" echo "" # Anonymous install-success beacon — only fires after the full script completed. # Server uses GET /installed to count "real" installs (vs. mere preview-curls). # host-hash is SHA-truncated machine-id so reinstalls on the same host don't double-count. { HOST_HASH=$(printf '%s' "$(cat /etc/machine-id 2>/dev/null || hostname)" | sha256sum 2>/dev/null | cut -c1-16) OS_ID=$(. /etc/os-release 2>/dev/null && echo "${ID:-?}-${VERSION_ID:-?}") curl -fsS --max-time 5 "https://get.enconf.com/installed?h=${HOST_HASH}&os=${OS_ID}" >/dev/null 2>&1 || true } & disown 2>/dev/null || true