#!/bin/bash # ═══════════════════════════════════════════════════════════════════════════════ # AelynaCP Installer — Fully Automatic # Copyright (c) 2026 AelynaCP. All rights reserved. # https://aelynacp.com # ═══════════════════════════════════════════════════════════════════════════════ set -uo pipefail # ── Lo script non deve essere lo stdin ──────────────────────────────────────── # ⚠️ Sotto `curl -sL … | bash` bash non apre un file: legge le istruzioni dal # tubo, cioè dal proprio stdin. Ogni comando lanciato dallo script eredita quel # tubo, e se ne legge anche solo un pezzo **si mangia le righe che bash non ha # ancora letto**: bash riprende dopo il buco, salta interi blocchi e finisce per # eseguire come comandi il testo che era dato (il 19 ago: 215 righe di # configurazione saltate — PostgreSQL mai inizializzato — e una riga di SQL # eseguita come comando, con bash che dichiarava un numero di riga sfasato di 148). # ⚠️ Il difetto è **a tempo**: quanto testo è già arrivato dalla rete quando parte # un certo comando cambia a ogni installazione, quindi l'installer riesce o # fallisce a caso. Il 18 ago era andata bene, il 19 no, stesso codice. # La difesa non è mettere `< /dev/null` su ogni comando — basta dimenticarlo una # volta e il difetto torna, muto. Qui lo script si riscrive su disco e riparte da # lì: da quel momento lo stdin non è più il sorgente, e non c'è più niente da # mangiare, chiunque fosse il colpevole. if [ -z "${AELYNACP_RIESEGUITO:-}" ] && [ ! -f "${BASH_SOURCE[0]:-$0}" ]; then _origine="${AELYNACP_INSTALL_URL:-https://get.aelynacp.com/install.sh}" _copia="$(mktemp /tmp/aelynacp-install.XXXXXX.sh)" || exit 1 if ! curl -fsSL --max-time 120 -o "$_copia" "$_origine" 2>/dev/null \ && ! wget -q -O "$_copia" "$_origine" 2>/dev/null; then echo " ERRORE: non riesco a riscaricare l'installer da ${_origine}" >&2 rm -f "$_copia"; exit 1 fi # ⚠️ Si controlla che sia arrivato INTERO: un troncone passa `bash -n` solo # per caso, ma rieseguirlo sarebbe peggio del difetto che si sta chiudendo — # un'installazione che si ferma a metà senza dire perché. Il sentinella in # fondo al file è l'unica prova che non manca niente. if ! grep -q '^# FINE-INSTALLER$' "$_copia" || ! bash -n "$_copia" 2>/dev/null; then echo " ERRORE: l'installer riscaricato è incompleto o illeggibile (${_origine})" >&2 rm -f "$_copia"; exit 1 fi export AELYNACP_RIESEGUITO=1 # stdin da /dev/null: lo script non lo usa (le domande passano da /dev/tty, # che resta disponibile perché il terminale di controllo non cambia). exec bash "$_copia" "$@" < /dev/null fi # NB: NON riaprire stdin con `exec < /dev/tty`. Dopo il blocco qui sopra lo # script gira da un file, ma la regola resta: le poche domande (ask_hostname) # leggono da `/dev/tty` **una alla volta** (`read … < /dev/tty`), che redirige # solo quel comando. Un `exec` sostituirebbe lo stdin dell'intera shell, e nel # caso in cui questo blocco non sia scattato — installer già su file, lanciato a # mano — bash proverebbe a leggere il resto del sorgente dal terminale e # resterebbe appeso in silenzio. # ── Constants ───────────────────────────────────────────────────────────────── AELYNACP_VERSION="0.5.8" INSTALL_DIR="/opt/aelynacp" CONFIG_DIR="/etc/aelynacp" DATA_DIR="/var/lib/aelynacp" LOG_DIR="/var/log/aelynacp" RUN_DIR="/var/run/aelynacp" WEB_DIR="${INSTALL_DIR}/web" BIN_DIR="${INSTALL_DIR}/bin" LOG_FILE="/var/log/aelynacp/install.log" DB_NAME="aelynacp" DB_USER="aelynacp" DB_PASS="" ADMIN_PASS="" # ── PHP ──────────────────────────────────────────────────────────────────────── # Versione installata di base. Su RHEL arriva dal modulo Remi (php:remi-8.5), # su Debian/Ubuntu dai pacchetti php-* della distro. # ATTENZIONE al ramo Debian: non aggiungiamo repo sury, quindi vale solo ciò che # la distro offre (Ubuntu 26.04 ha 8.5; Debian 12/13 si fermano a 8.2/8.4). # Su quelle distro va abbassata esplicitamente: # PHP_VERSION=8.3 bash install.sh PHP_VERSION="${PHP_VERSION:-8.5}" # ── MariaDB (repo ufficiale) ─────────────────────────────────────────────────── # DEFAULT: MariaDB 12.3, che dal 2026 è la serie LTS (mariadb_repo_setup dichiara # mariadb_lts_version=12.3; il default upstream è 12.rolling). Sostituisce il # vecchio pin su 11.4: la nota per cui "la 12.x è short-term" non vale più, quindi # ultima e LTS oggi coincidono. Versione parametrizzabile via env: # MARIADB_VERSION="mariadb-11.4" bash install.sh # per restare sulla LTS precedente MARIADB_VERSION="${MARIADB_VERSION:-mariadb-12.3}" MARIADB_REPO_SETUP_URL="${MARIADB_REPO_SETUP_URL:-https://downloads.mariadb.com/MariaDB/mariadb_repo_setup}" # Impostato a 1 da setup_mariadb_repo() quando il repo ufficiale è attivo. MARIADB_REPO_OK=0 # Log d'installazione root-only (può contenere output di comandi) — NON in /tmp world-readable mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true : > "$LOG_FILE" 2>/dev/null || true chmod 600 "$LOG_FILE" 2>/dev/null || true # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m' MAGENTA='\033[0;35m'; WHITE='\033[1;37m'; GRAY='\033[0;90m'; BOLD='\033[1m'; NC='\033[0m' # ── Detect ──────────────────────────────────────────────────────────────────── OS_TYPE="" ; PKG_MGR="" HOSTNAME_INPUT=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "server") PRIMARY_IP=$(curl -4 -s --max-time 5 https://ifconfig.me 2>/dev/null || curl -4 -s --max-time 5 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "0.0.0.0") # ── Progress tracking ──────────────────────────────────────────────────────── TOTAL_TASKS=0 DONE_TASKS=0 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; } info() { echo -e " ${CYAN}▸${NC} $*"; log "INFO: $*"; } success() { DONE_TASKS=$((DONE_TASKS + 1)) echo -e " ${GREEN}✓${NC} $*" log "OK: $*" draw_progress } warn() { echo -e " ${YELLOW}⚠${NC} $*"; log "WARN: $*"; } error() { echo -e " ${RED}✗${NC} $*"; log "ERROR: $*"; } fatal() { echo -e "\n ${RED}${BOLD}✗ ERRORE FATALE:${NC} $*" echo -e " ${GRAY}Log: ${LOG_FILE}${NC}\n" exit 1 } draw_progress() { if [ "$TOTAL_TASKS" -le 0 ]; then return; fi local pct=$((DONE_TASKS * 100 / TOTAL_TASKS)) if [ "$pct" -gt 100 ]; then pct=100; fi local width=50 local filled=$((pct * width / 100)) local empty=$((width - filled)) local bar="" for ((i=0; i/dev/null; do echo -ne "\r ${CYAN}${chars:$i:1}${NC} ${msg}..." i=$(( (i + 1) % ${#chars} )) sleep 0.1 done wait "$pid" 2>/dev/null local rc=$? echo -ne "\r \r" return $rc } run_task() { local msg="$1"; shift "$@" >> "$LOG_FILE" 2>&1 & spinner $! "$msg" if [ $? -eq 0 ]; then success "$msg" else error "$msg" fi } pkg_install() { if [ "$PKG_MGR" = "dnf" ]; then dnf install -y "$@" >> "$LOG_FILE" 2>&1 else DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" >> "$LOG_FILE" 2>&1 fi } # ── PHP AelynaCP: il nostro repository ───────────────────────────────────────── # Configura la sorgente dei **nostri** pacchetti PHP e li installa al posto di # quelli della distribuzione. # # ⚠️ Perché non si usa più il PHP della distribuzione: e' bloccato alla versione # che la distribuzione ha scelto (su AlmaLinux 10 e' la 8.3), non e' affiancabile # — una sola versione per macchina — e i suoi pool stanno in /etc/php-fpm.d, che # non e' dove il pannello li scrive. Con i nostri, ogni ramo e' versionato e # affiancabile, e la cartella dei pool e' quella che l'agent gia' usa. # # ⚠️ Questa e' la definizione del repository scritta **in fase di installazione**, # quando l'agent non c'e' ancora. Da li' in poi la mantiene lui # (ConfiguraRepoAelynacp, crates/aelynacp-agent/src/handlers/repo.rs, D7 e D20) e # la riscrive con lo scarto per macchina su metadata_expire. Le due devono # restare d'accordo su nome del file, URL e gpgcheck: se un giorno divergono, # quella dell'agent e' la buona. # # ⚠️ **Nessun ripiego: se i nostri non si installano, l'installazione si ferma.** # Il contrario — ricadere sul PHP della distribuzione — sembra gentile e non lo # è: il server nascerebbe *funzionante* ma con il PHP sbagliato, e nessuno se ne # accorgerebbe finché non serve. È successo davvero, su vm200: i siti giravano # sul PHP di AlmaLinux mentre tutti — documenti compresi — credevano fossero su # Remi, e il pannello non lo vedeva nemmeno. Ci sono volute ore per capirlo. # Meglio un'installazione che si ferma dicendo perché. setup_aelynacp_php() { local cifre distro id ver maggiore famiglia cifre="${PHP_VERSION//./}" # La famiglia si legge da /etc/os-release e non si indovina: il percorso # pubblico e' pkg/stabile//php, e sbagliare quella parola da' # un 404 alla prima lettura dell'indice — cioe' un errore che sembra «il # repository e' giu'» mentre sta benissimo. id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")" ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID:-}")" maggiore="${ver%%.*}" case "$id" in almalinux) famiglia="alma" ;; rocky) famiglia="rocky" ;; rhel|centos) famiglia="rhel" ;; *) famiglia="$id" ;; esac distro="${famiglia}${maggiore}" if [ -z "$id" ] || [ -z "$maggiore" ]; then fatal "Distribuzione non riconosciuta da /etc/os-release: impossibile scegliere il ramo dei pacchetti PHP AelynaCP" fi # La chiave prima della sorgente: al contrario, il primo dnf fallisce sulla # firma e sembra un problema del repository. rpm --import "https://get.aelynacp.com/pkg/RPM-GPG-KEY-aelynacp" >> "$LOG_FILE" 2>&1 || \ fatal "Chiave di firma AelynaCP non importata: senza, nessun nostro pacchetto può essere verificato. Controlla la rete verso get.aelynacp.com e rilancia." cat > "/etc/yum.repos.d/aelynacp-php${cifre}.repo" <> "$LOG_FILE" 2>&1 || true # fpm e cli non sono opzionali: un ramo senza PHP-FPM non serve a un sito. # Le estensioni si aggiungono in un secondo comando perche' una sola di esse # mancante farebbe fallire tutto, e restare senza PHP per colpa di `intl` non # e' un buon affare. Dal pannello si aggiungono quando si vuole. if ! pkg_install "aelynacp-php${cifre}-fpm" "aelynacp-php${cifre}-cli"; then fatal "PHP ${PHP_VERSION} AelynaCP non installabile per ${distro}. Verifica che il ramo esista su get.aelynacp.com/pkg/stabile/${distro}/php${PHP_VERSION}, oppure rilancia con PHP_VERSION=." fi # ⚠️ Si chiede **prima** se il pacchetto esiste, invece di provare e # raccogliere l'errore. Non è cosmesi: su certi rami un'estensione è # compilata dentro il binario e il pacchetto non esiste affatto — è il caso # di `opcache` sulla 8.5, dove la 8.5 fa 45 moduli invece di 46. Provarlo e # basta lascia nel log un «Unable to find a match» che è **indistinguibile** # da un repository rotto, e quel giorno lì la riga che conta si perde in # mezzo alle altre. # # ⚠️ Se `repoquery` stesso non funziona (dnf troppo vecchio, plugin assente) # si prova a installare lo stesso: in dubbio si tenta, perché saltare tutte # le estensioni in silenzio sarebbe un guasto molto peggiore del rumore che # si voleva togliere. local ext pacchetto disponibili for ext in mysqlnd pgsql gd xml mbstring zip intl curl opcache; do pacchetto="aelynacp-php${cifre}-${ext}" if disponibili=$(dnf -q repoquery --qf '%{name}' "$pacchetto" 2>/dev/null); then if [ -z "$disponibili" ]; then log "Estensione ${ext}: nessun pacchetto ${pacchetto} nel ramo ${PHP_VERSION} — su questo ramo è dentro il binario, oppure non è prevista" continue fi fi pkg_install "$pacchetto" >> "$LOG_FILE" 2>&1 || \ log "Estensione ${ext} non installata (il pacchetto ${pacchetto} esiste ma dnf non l'ha messo)" done blocca_php_distribuzione AELYNACP_PHP_UNIT="aelynacp-php${cifre}-fpm" return 0 } # Impedisce a dnf di installare il PHP della distribuzione, comunque ci si provi. # # ⚠️ **Non basta non installarlo: bisogna che non possa entrare.** Senza questo, # il PHP di AlmaLinux torna dentro da tre strade che nessuno sta guardando — # `dnf install php-fpm` dato a mano, un pacchetto che lo tira come dipendenza # (`composer` richiede `php(language)`), o un `dnf install` distratto. E torna # **in silenzio**: il server si ritrova due PHP, i pool nella cartella sbagliata, # e il pannello che ne vede uno solo. È esattamente com'era ridotta vm200. # # ⚠️ Il modello è `php*`, cioè **tutto** quello che i repository della # distribuzione chiamano PHP: `php-fpm` e `php-cli` di AlmaLinux, `php84-php-gd` # e `php84-runtime` in stile Remi, le librerie `php-*`, e **phpMyAdmin**. # Quest'ultimo è voluto: lo sostituisce AelynaDB, che è servito dal pannello e # non usa PHP, e lasciarlo installabile vorrebbe dire che un giorno rientra da # un `dnf install` e si porta dietro un PHP che qui non deve esserci. # # ⚠️ **I nostri non sono toccati**: si chiamano `aelynacp-php84-*` e non # cominciano per «php». Verificato sulla macchina — `php-fpm` e `php84-php-gd` # spariscono, i 54 pacchetti nostri restano e si installano. # # ⚠️ Conseguenza da conoscere: le librerie PHP della distribuzione # (`php-fedora-autoloader`, `php-maxmind-db-reader`) e `composer` dal suo repo # non si installeranno più. È coerente — sono compilate per un PHP che qui non # c'è — ma se un giorno servisse composer, va preso per altra via. blocca_php_distribuzione() { local file="/etc/dnf/dnf.conf" marcatore="# AelynaCP: il PHP lo fornisce solo il nostro repository" if grep -qF "$marcatore" "$file" 2>/dev/null; then log "Esclusione PHP distribuzione già presente" return 0 fi # ⚠️ Se un `exclude=` c'è già, non lo si sovrascrive: apparterrebbe a # qualcun altro e portarglielo via sarebbe un guasto silenzioso. Si avvisa e # si lascia stare — meglio un avviso che una riga cancellata di nascosto. if grep -q '^exclude=' "$file" 2>/dev/null; then warn "In dnf.conf c'è già un 'exclude=': aggiungi a mano 'php*'" return 0 fi printf '\n%s\nexclude=php*\n' "$marcatore" >> "$file" log "PHP della distribuzione escluso da dnf" } # Toglie di mezzo il broadcast di sssd_kcm durante l'installazione. # # ⚠️ **Non è un problema nostro, ma lo vede il cliente e scrive a noi.** Su # AlmaLinux 10 il servizio `sssd-kcm` (il gestore delle credenziali Kerberos) # parte da solo, non trova `/var/log/sssd/` — che il pacchetto non crea — e lo # segnala con una priorità così alta che systemd-journald lo **trasmette su tutti # i terminali aperti**: # # Broadcast message from systemd-journald@… # sssd_kcm[…]: Could not open file [/var/log/sssd/sssd_kcm.log]. Error: [2] # # Compare in mezzo all'installazione, in rosso, e sembra che sia esplosa. Non lo # è: il pannello non usa Kerberos e non se ne accorge nemmeno. # # ⚠️ Si crea la cartella invece di spegnere `sssd-kcm`, ed è una scelta. Spegnere # un componente di autenticazione di sistema per far tacere un messaggio vuol # dire decidere al posto del cliente che non userà mai un dominio Kerberos o AD: # non è una decisione dell'installer di un pannello. Creare la cartella che manca # risolve la causa e non toglie niente a nessuno. silenzia_sssd_kcm() { command -v sssd_kcm > /dev/null 2>&1 || [ -x /usr/libexec/sssd/sssd_kcm ] || return 0 [ -d /var/log/sssd ] && return 0 mkdir -p /var/log/sssd 2>/dev/null || return 0 # Il proprietario dipende dalla distribuzione: su RHEL 9+ sssd_kcm può girare # come utente `sssd` invece che come root. Si assegna a lui se esiste, senza # dare per scontato nulla. if getent passwd sssd > /dev/null 2>&1; then chown sssd:sssd /var/log/sssd 2>/dev/null || true fi chmod 750 /var/log/sssd 2>/dev/null || true log "Creata /var/log/sssd (mancante): evita il broadcast di sssd_kcm durante l'installazione" } # ── MariaDB: repo ufficiale + install difensiva ──────────────────────────────── # Configura il repo ufficiale MariaDB (downloads.mariadb.com) così da poter # installare MariaDB ${MARIADB_VERSION} invece del default distro (~10.x/11.x). # Difensiva: qualunque fallimento NON rompe il sistema, si ricade sui pacchetti # distro (MARIADB_REPO_OK resta 0). Idempotente: rilevazione repo già presente. setup_mariadb_repo() { # Già configurato in un run precedente? Non riscaricare nulla. if [ "$PKG_MGR" = "dnf" ]; then if grep -rqiE "(downloads|dlm)\.mariadb\.com" /etc/yum.repos.d/ 2>/dev/null; then MARIADB_REPO_OK=1 log "MariaDB: repo ufficiale già presente (yum/dnf)" return 0 fi else if grep -rqiE "(downloads|dlm)\.mariadb\.com" /etc/apt/sources.list.d/ 2>/dev/null; then MARIADB_REPO_OK=1 log "MariaDB: repo ufficiale già presente (apt)" return 0 fi fi info "Configurazione repo ufficiale MariaDB (${MARIADB_VERSION})..." # Prerequisiti per scaricare/validare il repo (curl/ca-certificates/gnupg). if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then if [ "$PKG_MGR" = "dnf" ]; then dnf install -y curl ca-certificates >> "$LOG_FILE" 2>&1 || true else apt-get install -y curl ca-certificates apt-transport-https gnupg >> "$LOG_FILE" 2>&1 || true fi fi local setup_script dl_ok=0 setup_script="$(mktemp 2>/dev/null || echo "/tmp/mariadb_repo_setup.$$")" # Scarica lo script ufficiale di setup repo (curl, poi wget come fallback). if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 30 "$MARIADB_REPO_SETUP_URL" -o "$setup_script" >> "$LOG_FILE" 2>&1 && dl_ok=1 fi if [ "$dl_ok" -ne 1 ] && command -v wget >/dev/null 2>&1; then wget -q -T 30 -O "$setup_script" "$MARIADB_REPO_SETUP_URL" >> "$LOG_FILE" 2>&1 && dl_ok=1 fi if [ "$dl_ok" -ne 1 ] || [ ! -s "$setup_script" ]; then warn "Download mariadb_repo_setup fallito: uso MariaDB del repo distro" log "ERROR: download mariadb_repo_setup fallito da $MARIADB_REPO_SETUP_URL" rm -f "$setup_script" 2>/dev/null || true MARIADB_REPO_OK=0 return 1 fi # Esegue lo script ufficiale (gestisce internamente apt vs yum/dnf e la chiave GPG). # --skip-maxscale: MaxScale non ci serve e il suo repo non ha un Release file # per tutti i nomi in codice (su Ubuntu 26.04 "resolute" manca), il che fa # fallire ogni successivo `apt-get update` dell'intero sistema. if bash "$setup_script" --mariadb-server-version="$MARIADB_VERSION" --skip-maxscale >> "$LOG_FILE" 2>&1; then MARIADB_REPO_OK=1 log "MariaDB: repo ufficiale configurato per $MARIADB_VERSION" else warn "mariadb_repo_setup fallito: uso MariaDB del repo distro" log "ERROR: mariadb_repo_setup ha restituito errore per $MARIADB_VERSION" MARIADB_REPO_OK=0 rm -f "$setup_script" 2>/dev/null || true return 1 fi rm -f "$setup_script" 2>/dev/null || true # Aggiorna gli indici del nuovo repo. if [ "$PKG_MGR" = "dnf" ]; then dnf clean metadata >> "$LOG_FILE" 2>&1 || true dnf makecache >> "$LOG_FILE" 2>&1 || true else apt-get update -y >> "$LOG_FILE" 2>&1 || true fi return 0 } # Installa MariaDB: dal repo ufficiale (pacchetti MariaDB-server/MariaDB-client) # se configurato, altrimenti dai pacchetti distro (mariadb-server/...). # Fallback automatico al repo distro se l'install ufficiale fallisce. install_mariadb_pkgs() { if [ "$MARIADB_REPO_OK" = "1" ]; then # Repo ufficiale: i nomi pacchetto DIFFERISCONO per package manager. # - RPM/dnf: capitalizzati (MariaDB-server / MariaDB-client) # - Debian/apt: minuscoli (mariadb-server / mariadb-client), come il repo distro if [ "$PKG_MGR" = "dnf" ]; then if pkg_install MariaDB-server MariaDB-client; then return 0 fi else if pkg_install mariadb-server mariadb-client; then return 0 fi fi warn "Install dal repo ufficiale MariaDB fallita: fallback su repo distro" log "ERROR: install MariaDB dal repo ufficiale fallita" MARIADB_REPO_OK=0 fi # Default / fallback: pacchetti distro (retrocompatibili 10.x/11.x). if [ "$PKG_MGR" = "dnf" ]; then pkg_install mariadb-server mariadb else pkg_install mariadb-server mariadb-client fi } # Sistemi supportati: SOLO AlmaLinux 10 e Ubuntu 26.04. # # Prima si accettavano rocky, rhel, centos, fedora, oracle e qualunque # debian/ubuntu, a qualsiasi versione. Era una promessa piu' larga di quello che # possiamo mantenere: proviamo su due sole macchine, AlmaLinux 10 e Ubuntu 26.04. # Un pannello di hosting che si installa e poi si comporta in modo imprevisto fa # piu' danni di uno che si rifiuta di partire. # # Sono entrambe LTS con supporto lungo (AlmaLinux 10 fino al 2035, Ubuntu 26.04 # fino al 2031): il perimetro resta fermo per anni, non e' una restrizione da # rivedere ogni sei mesi. Aggiungere un sistema in futuro sara' una decisione # deliberata, con il collaudo che le corrisponde. detect_os() { if [ ! -f /etc/os-release ]; then fatal "Impossibile rilevare il sistema operativo" fi . /etc/os-release # La versione va confrontata sul ramo maggiore: AlmaLinux 10 si presenta come # 10.0, 10.1, 10.2... e sono tutte la stessa distribuzione. os_major="${VERSION_ID%%.*}" case "$ID" in almalinux) [ "$os_major" = "10" ] || fatal "Serve AlmaLinux 10 — trovato ${PRETTY_NAME:-$ID $VERSION_ID}" OS_TYPE="rhel"; PKG_MGR="dnf" ;; ubuntu) [ "$VERSION_ID" = "26.04" ] || fatal "Serve Ubuntu 26.04 LTS — trovato ${PRETTY_NAME:-$ID $VERSION_ID}" OS_TYPE="debian"; PKG_MGR="apt" ;; *) error "AelynaCP si installa solo su AlmaLinux 10 o Ubuntu 26.04 LTS." error "Questo sistema e': ${PRETTY_NAME:-$ID $VERSION_ID}" fatal "Installazione annullata." ;; esac } generate_password() { tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24; } generate_admin_password() { tr -dc 'A-Za-z0-9!@#%' < /dev/urandom | head -c 16; } generate_hex() { tr -dc 'a-f0-9' < /dev/urandom | head -c "${1:-64}"; } # ═══════════════════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════════════════ clear echo -e "${MAGENTA}" echo ' ___ __ __________ ' echo ' / | ___ / /_ ______ ____ _/ ____/ __ \' echo ' / /| | / _ \/ / / / / __ \/ __ `/ / / /_/ /' echo ' / ___ |/ __/ / /_/ / / / / /_/ / /___/ ____/ ' echo '/_/ |_|\___/_/\__, /_/ /_/\__,_/\____/_/ ' echo ' /____/ ' echo -e "${NC}" echo -e "${WHITE}${BOLD} AELYNA${CYAN}™${WHITE}CP${NC} ${GRAY}v${AELYNACP_VERSION}${NC}" echo -e "${GRAY} Hosting Control Panel${NC}" echo "" # ── Preflight ───────────────────────────────────────────────────────────────── [ "$(id -u)" -ne 0 ] && fatal "Esegui come root" # ── Una macchina, una installazione ─────────────────────────────────────────── # ⚠️ Rilanciare install.sh su un server già installato non è «reinstallare», è # rompere. La password di admin viene rigenerata, le configurazioni riscritte, i # certificati rifatti e le migrazioni ripassate — mentre siti, caselle, DNS e # database restano quelli di prima. Il risultato è un pannello che non entra più # in casa propria, e non lo si scopre subito: lo si scopre quando serve. # cPanel si ferma per la stessa ragione, e fa bene. # ⚠️ Sta PRIMA di tutto il resto, hostname compreso: una guardia che scatta dopo # aver già cambiato il nome della macchina ha comunque fatto un danno. # Chi vuole davvero rifare tutto lo dice a voce alta: AELYNACP_REINSTALL=1. verifica_non_gia_installato() { local trovato="" [ -f /opt/aelynacp/VERSION ] && \ trovato="${trovato}\n - /opt/aelynacp/VERSION (versione $(cat /opt/aelynacp/VERSION 2>/dev/null | tr -d '\n'))" [ -f /etc/aelynacp/aelynacp.toml ] && \ trovato="${trovato}\n - la configurazione /etc/aelynacp/aelynacp.toml" systemctl cat aelynacp-web.service > /dev/null 2>&1 && \ trovato="${trovato}\n - il servizio aelynacp-web" if command -v psql > /dev/null 2>&1 && \ sudo -u postgres psql -tAXc "SELECT 1 FROM pg_database WHERE datname='aelynacp'" 2>/dev/null | grep -q 1; then trovato="${trovato}\n - il database 'aelynacp', con i dati che contiene" fi [ -z "$trovato" ] && return 0 if [ -n "${AELYNACP_REINSTALL:-}" ]; then # Il log non c'è ancora (viene azzerato più avanti): l'avviso deve stare # sullo schermo, dove c'è chi ha scritto quella variabile. echo -e "\n ${YELLOW}${BOLD}⚠ AELYNACP_REINSTALL=1${NC} — proseguo su una macchina che risulta" echo -e " ${YELLOW}già installata. Quello che c'è verrà sovrascritto.${NC}" echo -e " ${GRAY}Trovato:${NC}${trovato}\n" return 0 fi echo -e "\n ${RED}${BOLD}✗ AelynaCP risulta già installato su questa macchina.${NC}" echo -e " ${GRAY}Trovato:${NC}${trovato}" echo "" echo -e " ${WHITE}Cosa vuoi fare davvero:${NC}" echo -e " ${GRAY}·${NC} aggiornare il pannello → ${CYAN}dnf update aelynacp${NC}, o dal pannello stesso" echo -e " ${GRAY}·${NC} ripartire da zero → ${CYAN}riformatta la macchina${NC}: è l'unica strada pulita" echo -e " ${GRAY}·${NC} sai cosa stai facendo → ${CYAN}AELYNACP_REINSTALL=1 bash install.sh${NC}" echo "" echo -e " ${GRAY}Non è una formalità: rilanciando, la password di admin verrebbe rigenerata${NC}" echo -e " ${GRAY}e le configurazioni riscritte, mentre siti, caselle e DNS resterebbero${NC}" echo -e " ${GRAY}quelli di prima — un pannello che non entra più in casa sua.${NC}" echo "" exit 1 } verifica_non_gia_installato detect_os # ── Hostname del pannello ────────────────────────────────────────────────────── # L'hostname finisce nella configurazione, in Postfix e nel certificato: chiederlo # qui evita di doverlo correggere a mano dopo. # # NB: si legge con `read < /dev/tty`, che redirige solo QUESTO comando. Non usare # mai `exec < /dev/tty`: sostituirebbe lo stdin della shell, che sotto # `curl … | bash` e' il sorgente dello script, e l'installer resterebbe appeso. # Con `-t` la domanda scade da sola, cosi' le installazioni non presidiate # proseguono col valore rilevato invece di bloccarsi. # Mette l'hostname e — la parte che mancava — fa in modo che ci resti. # Sono quattro gesti, e nessuno e' facoltativo: # 1. `hostnamectl`, che scrive /etc/hostname; # 2. /etc/hosts, che hostnamectl **non tocca**: senza, `hostname -f` non # risolve e Postfix parte con un nome che nessuno conferma; # 3. cloud-init, che sulle immagini cloud rimette il **suo** nome a ogni # avvio (`preserve_hostname: false` piu' `update_hostname` fra i moduli di # boot) e riscrive /etc/hosts dal proprio modello. Senza il file qui sotto, # il nome scelto durante l'installazione dura fino al primo riavvio: il # pannello, i certificati e la posta restano sul nome vecchio e non si # capisce perche'. E' il motivo per cui chiedere l'hostname sembrava inutile. # 4. la verifica. Prima si scriveva "Hostname impostato" senza guardare: # stesso difetto di ProFTPD nella 0.5.3 — il comando *era* riuscito, # semplicemente non faceva quello che serviva. applica_hostname() { local nuovo="$1" origine="$2" corto ora HOSTNAME_INPUT="$nuovo" corto="${nuovo%%.*}" if command -v hostnamectl > /dev/null 2>&1; then hostnamectl set-hostname "$nuovo" >> "$LOG_FILE" 2>&1 || true else hostname "$nuovo" >> "$LOG_FILE" 2>&1 || true echo "$nuovo" > /etc/hostname fi # Riga marcata, cosi' rilanciare l'installer la sostituisce invece di # accumularne una nuova ogni volta. 127.0.0.1 come faceva cloud-init: # serve a risolvere il nome, non a pubblicarlo. if [ -f /etc/hosts ]; then sed -i '/# aelynacp-hostname$/d' /etc/hosts 2>/dev/null || true printf '127.0.0.1 %s %s # aelynacp-hostname\n' "$nuovo" "$corto" >> /etc/hosts fi if [ -d /etc/cloud/cloud.cfg.d ]; then cat > /etc/cloud/cloud.cfg.d/99-aelynacp-hostname.cfg <<'CLOUDINITEOF' # Scritto da AelynaCP install.sh. # Senza queste due righe cloud-init rimette l'hostname dei metadati a ogni # avvio e riscrive /etc/hosts dal suo modello, cancellando il nome scelto # durante l'installazione. Da qui in poi il nome del server lo decide il # pannello, non il fornitore della macchina. preserve_hostname: true manage_etc_hosts: false CLOUDINITEOF log "cloud-init: preserve_hostname attivato (99-aelynacp-hostname.cfg)" fi ora="$(hostname 2>/dev/null)" if [ "$ora" = "$nuovo" ] || [ "$ora" = "$corto" ]; then log "Hostname impostato (${origine}): ${nuovo}" else warn "Hostname richiesto '${nuovo}' ma il sistema dice '${ora}'" log "ERROR: hostname non applicato: richiesto=${nuovo} effettivo=${ora}" fi } ask_hostname() { local detected="$HOSTNAME_INPUT" answer="" # Ambiente/automazione: ha la precedenza e non chiede nulla. # ⚠️ Prima questo ramo assegnava la variabile e usciva, **senza toccare # l'hostname di sistema**: chi installava da uno script si ritrovava i # file di configurazione col nome nuovo e la macchina col nome vecchio. if [ -n "${AELYNACP_HOSTNAME:-}" ]; then applica_hostname "$AELYNACP_HOSTNAME" "AELYNACP_HOSTNAME" return 0 fi # ⚠️ Non `[ -r /dev/tty ]`: senza terminale di controllo il nodo esiste lo # stesso e il test risponde di si' — e' **aprirlo** che fallisce, con ENXIO. # Cosi' la guardia era morta proprio nel caso per cui era stata scritta, e # sotto `nohup` stampava due «No such device or address» in mezzo a # un'installazione perfettamente sana. Si prova ad aprirlo, in un # sottoguscio perche' un `exec` fallito qui affosserebbe l'installer. if ! ( exec 3>/dev/tty ) 2>/dev/null; then log "Nessun terminale disponibile: uso l'hostname rilevato (${detected})" return 0 fi printf '\n %bHostname del pannello%b %b[%s]%b: ' \ "$WHITE" "$NC" "$GRAY" "$detected" "$NC" > /dev/tty read -r -t 120 answer < /dev/tty || answer="" printf '\n' > /dev/tty answer="$(echo "$answer" | tr -d '[:space:]')" if [ -z "$answer" ]; then log "Hostname confermato: ${detected}" return 0 fi # Accetta solo nomi plausibili: lettere, cifre, punti e trattini. if ! echo "$answer" | grep -qE '^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$'; then warn "Hostname '${answer}' non valido: proseguo con ${detected}" return 0 fi case "$answer" in *.*) : ;; *) warn "'${answer}' non e' un nome completo (manca il dominio): la posta e i certificati potrebbero non funzionare" ;; esac applica_hostname "$answer" "scelto dall'utente" } echo "=== AelynaCP Install ===" > "$LOG_FILE" echo "Date: $(date), OS: ${ID} ${VERSION_ID}, IP: ${PRIMARY_IP}" >> "$LOG_FILE" # Dopo l'inizializzazione del log, altrimenti la riga sopra lo azzera e le note # di ask_hostname andrebbero perse. ask_hostname echo -e " ${WHITE}Server:${NC} ${CYAN}${HOSTNAME_INPUT}${NC} (${PRIMARY_IP})" echo -e " ${WHITE}OS:${NC} ${CYAN}${ID} ${VERSION_ID}${NC} (${OS_TYPE})" echo "" # Count total tasks for progress bar # # ⚠️ Va tenuto d'accordo con il numero di `success` che il ramo esegue **davvero**, # ed è un numero che si conta, non si stima. Contando gli `OK:` di # un'installazione riuscita del 14 ago sul ramo RHEL ne uscivano **39** contro un # TOTAL_TASKS di 40: la barra si fermava al **97%** e non arrivava mai a 100. # L'installazione era perfetta e sembrava interrotta a un passo dalla fine, che è # il modo peggiore di finire bene. Tolto Remi, i passi sono 38. # # Il ramo Debian non è ancora stato misurato allo stesso modo: resta a 39 finché # non c'è un'installazione vera da contare, invece di cambiarlo a intuito. if [ "$OS_TYPE" = "rhel" ]; then TOTAL_TASKS=40 # 38 + "Aggiornamento sistema" e "Controllo finale" (18 ago) else TOTAL_TASKS=41 # 39 + "Aggiornamento sistema" e "Controllo finale" (18 ago) fi echo -e " ${WHITE}Installazione in corso...${NC}" echo "" # Prima di installare qualsiasi cosa: un messaggio d'allarme a metà installazione # fa sembrare rotto ciò che sta funzionando. silenzia_sssd_kcm # ── Install Packages ────────────────────────────────────────────────────────── if [ "$OS_TYPE" = "rhel" ]; then # 'file' non serve piu' all'updater (ora valida gli archivi con `tar tzf`), ma # i pannelli gia' installati lo usano ancora: senza, il loro aggiornamento # imbocca un ramo che sovrascrive i binari col pacchetto scaricato. run_task "Tools base" pkg_install tar gzip bzip2 rsync openssl unzip wget curl file python3 python3-pip sudo run_task "EPEL" dnf install -y epel-release /usr/bin/crb enable >> "$LOG_FILE" 2>&1 || true # ⚠️ **Remi non si installa più**, e con lui se n'è andato # `dnf module enable php:remi-${PHP_VERSION}`. Il PHP lo diamo noi, dal nostro # repository (setup_aelynacp_php): una sorgente in più è una strada in più da # cui può entrare un secondo PHP che nessuno ha chiesto. # # Fino alla 0.5.0 `remi-release` veniva installato lo stesso, per abitudine: # 33 kB di pacchetto e 836 kB di metadati scaricati a ogni installazione, per # tre repository i cui pacchetti vengono esclusi da `dnf` due minuti dopo # (blocca_php_distribuzione). Non faceva danno diretto — faceva peggio: # lasciava sulla macchina una sorgente di PHP pronta all'uso, e bastava un # `exclude=` tolto per ritrovarsi il doppio PHP di vm200. # ⚠️ Il `dnf module reset php` serve su EL8/EL9, dove il modulo `php` della # distribuzione esiste: trovarlo già abilitato da un'immagine preparata da # altri farebbe entrare il PHP di sistema come dipendenza. # ⚠️ **Su EL10 la modularità non c'è più**, e il comando falliva con # `Unable to resolve argument php` / `missing groups or modules: php`, # scrivendo una riga `Error:` nel log di **ogni** installazione riuscita — # visto il 18 ago 2026 su AlmaLinux 10.2. Il `|| true` lo rendeva innocuo ma # non silenzioso, e un errore finto in un log è peggio di nessun errore: # insegna a non leggere i log. Quindi si chiede prima se quel modulo esiste. if dnf module list php > /dev/null 2>&1; then dnf module reset php -y >> "$LOG_FILE" 2>&1 || true else log "Nessun modulo 'php' nella distribuzione (atteso su EL10): reset non necessario" fi # Remove broken PowerDNS repo for EL10 and refresh cache rm -f /etc/yum.repos.d/powerdns-auth.repo 2>/dev/null dnf clean metadata >> "$LOG_FILE" 2>&1 || true run_task "Nginx" pkg_install nginx run_task "PostgreSQL" pkg_install postgresql-server postgresql postgresql-contrib setup_mariadb_repo || true run_task "MariaDB" install_mariadb_pkgs # Non c'è un ramo «oppure»: o si installano i nostri, o setup_aelynacp_php # ferma l'installazione dicendo perché. setup_aelynacp_php success "PHP ${PHP_VERSION} (pacchetti AelynaCP)" run_task "Postfix" pkg_install postfix run_task "Dovecot" pkg_install dovecot run_task "PowerDNS" pkg_install pdns pdns-backend-postgresql run_task "ProFTPD" pkg_install proftpd pkg_install valkey >> "$LOG_FILE" 2>&1 || pkg_install redis >> "$LOG_FILE" 2>&1 || true; success "Valkey/Redis" run_task "Certbot" pkg_install certbot python3-certbot-nginx run_task "Fail2Ban" pkg_install fail2ban run_task "Firewalld" pkg_install firewalld pkg_install smartmontools sysstat mdadm >> "$LOG_FILE" 2>&1 || true; success "Monitoring" pkg_install rclone >> "$LOG_FILE" 2>&1 || true; success "Rclone (backup remoto S3/cloud)" # argon2 serve per l'hash della password admin. Il pacchetto della distro è la # via affidabile: pip può mancare del tutto (non è installato di default) o # essere rifiutato dalle distro PEP 668. pkg_install python3-argon2-cffi >> "$LOG_FILE" 2>&1 || true; success "argon2" else # Debian/Ubuntu export DEBIAN_FRONTEND=noninteractive run_task "Aggiornamento" apt-get update -y # Il ramo Debian non installava alcuno strumento di base: su una minimale # mancano rsync, unzip e altri, e 'file' non c'e' nemmeno su Ubuntu 26.04 # (vedi nota sul ramo RHEL: senza, l'aggiornamento dei pannelli gia' # installati sovrascrive i binari col pacchetto scaricato). run_task "Tools base" pkg_install tar gzip bzip2 rsync openssl unzip wget curl file sudo run_task "Nginx" pkg_install nginx run_task "PostgreSQL" pkg_install postgresql postgresql-client postgresql-contrib setup_mariadb_repo || true run_task "MariaDB" install_mariadb_pkgs # fpm e cli sono obbligatori; le estensioni si aggiungono solo se la distro le # impacchetta davvero. apt fallisce l'INTERA transazione se un solo nome non # esiste: con PHP 8.5 su Ubuntu 26.04 php8.5-opcache non c'è (opcache è dentro # cli/common) e questo faceva fallire tutto il PHP. # ⚠️ Qui il PHP è ancora quello della distribuzione, ed è l'unico posto in cui # lo resta: i nostri pacchetti esistono solo in formato rpm. I .deb arrivano # con la fase 6 (D20), e quel giorno questo blocco diventa la gemella di # setup_aelynacp_php — sorgente nostra e nessun ripiego, come sul ramo dnf. php_pkgs=("php${PHP_VERSION}-fpm" "php${PHP_VERSION}-cli") php_skipped="" for ext in mysql pgsql gd xml mbstring zip intl opcache curl; do if apt-cache show "php${PHP_VERSION}-${ext}" > /dev/null 2>&1; then php_pkgs+=("php${PHP_VERSION}-${ext}") else php_skipped="${php_skipped} ${ext}" fi done [ -n "$php_skipped" ] && echo "PHP: estensioni non disponibili nella distro, saltate:${php_skipped}" >> "$LOG_FILE" run_task "PHP ${PHP_VERSION}" pkg_install "${php_pkgs[@]}" run_task "Postfix" pkg_install postfix run_task "Dovecot" pkg_install dovecot-imapd dovecot-pop3d pkg_install pdns-server pdns-backend-pgsql >> "$LOG_FILE" 2>&1 || true; success "PowerDNS" pkg_install proftpd-basic >> "$LOG_FILE" 2>&1 || true; success "ProFTPD" pkg_install redis-server >> "$LOG_FILE" 2>&1 || true; success "Redis" run_task "Certbot" pkg_install certbot python3-certbot-nginx run_task "Fail2Ban" pkg_install fail2ban pkg_install firewalld >> "$LOG_FILE" 2>&1 || true; success "Firewall" pkg_install smartmontools sysstat >> "$LOG_FILE" 2>&1 || true; success "Monitoring" pkg_install rclone >> "$LOG_FILE" 2>&1 || true; success "Rclone (backup remoto S3/cloud)" # Vedi nota sul ramo RHEL: su Debian/Ubuntu il pacchetto si chiama python3-argon2 # (python3-argon2-cffi non esiste) e pip di sistema è rifiutato da PEP 668, # ammesso che pip sia installato — su Ubuntu 26.04 non lo è. pkg_install python3-argon2 >> "$LOG_FILE" 2>&1 || true; success "argon2" fi # ── Configure Services ──────────────────────────────────────────────────────── # PostgreSQL if [ "$OS_TYPE" = "rhel" ]; then postgresql-setup --initdb >> "$LOG_FILE" 2>&1 || postgresql-setup initdb >> "$LOG_FILE" 2>&1 || true fi systemctl enable --now postgresql >> "$LOG_FILE" 2>&1 || true pg_hba=$(find /var/lib/pgsql /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) if [ -n "$pg_hba" ] && [ -f "$pg_hba" ]; then cp "$pg_hba" "${pg_hba}.bak" 2>/dev/null || true sed -i 's/^\(local.*all.*all.*\)peer/\1md5/' "$pg_hba" 2>/dev/null || true sed -i 's/^\(local.*all.*all.*\)ident/\1md5/' "$pg_hba" 2>/dev/null || true sed -i 's/^\(host.*all.*all.*127.0.0.1\/32.*\)ident/\1md5/' "$pg_hba" 2>/dev/null || true sed -i 's/^\(host.*all.*all.*::1\/128.*\)ident/\1md5/' "$pg_hba" 2>/dev/null || true grep -q "postgres.*peer" "$pg_hba" || sed -i '1i local all postgres peer' "$pg_hba" 2>/dev/null || true echo "host pdns ${DB_USER} 127.0.0.1/32 md5" >> "$pg_hba" fi systemctl restart postgresql >> "$LOG_FILE" 2>&1 || true DB_PASS=$(generate_password) # La password NON deve finire nel log: redirige questi comandi a /dev/null. # Su una riesecuzione l'utente esiste gia' e CREATE fallisce: senza l'ALTER # resterebbe la password del run precedente, mentre il config ne riceve una # nuova — il pannello poi non riesce piu' a connettersi al database. sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" >/dev/null 2>&1 \ || sudo -u postgres psql -c "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" >/dev/null 2>&1 \ || true sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -d "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO ${DB_USER};" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS \"pgcrypto\";" >> "$LOG_FILE" 2>&1 || true success "PostgreSQL" # MariaDB # ── Pin charset/collation per portabilità tra versioni ──────────────────────── # MariaDB 12 introduce come default la collation UCA 14.0.0 (utf8mb4_uca1400_ai_ci), # che NON esiste su 10.x/11.x: un dump preso da 12 non si importerebbe su server più # vecchi e alcune app si aspettano una collation classica. Fissiamo quindi una # collation portabile (utf8mb4_unicode_ci) come default del server, così i DB creati # restano importabili ovunque indipendentemente dalla versione installata. if [ "$PKG_MGR" = "dnf" ]; then MARIADB_CONF_DIR="/etc/my.cnf.d" else MARIADB_CONF_DIR="/etc/mysql/mariadb.conf.d" fi mkdir -p "$MARIADB_CONF_DIR" cat > "$MARIADB_CONF_DIR/aelynacp-charset.cnf" << 'MDBCHARSET' # Gestito da AelynaCP — collation portabile tra MariaDB 10/11/12. [mariadbd] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci MDBCHARSET systemctl enable --now mariadb >> "$LOG_FILE" 2>&1 || systemctl enable --now mysql >> "$LOG_FILE" 2>&1 || true # ── Compat binari client MariaDB/MySQL ──────────────────────────────────────── # Su MariaDB recenti (>=11/12) i comandi nativi sono `mariadb`/`mariadb-dump`; # i vecchi `mysql`/`mysqldump` sono symlink deprecati che possono mancare. # Su MariaDB 10.x/11.x esistono ancora `mysql`/`mysqldump` ma può mancare # `mariadb`/`mariadb-dump`. Qui garantiamo che ENTRAMBE le coppie siano # presenti a livello di sistema (symlink di compatibilità bidirezionali), # così sia l'installer (che usa `mysql`) sia il codice agent restano portabili # su ogni versione SENZA rinominare le invocazioni. ensure_db_bin_symlink() { # $1 = comando mancante da creare, $2 = comando esistente a cui puntare local want="$1" have="$2" src dst command -v "$want" >/dev/null 2>&1 && return 0 # già presente src=$(command -v "$have" 2>/dev/null) || return 0 # sorgente assente: niente da fare dst="$(dirname "$src")/$want" if [ ! -e "$dst" ]; then ln -s "$src" "$dst" >> "$LOG_FILE" 2>&1 && log "compat symlink: $dst -> $src" || true fi } # mariadb/mariadb-dump -> garantiti se esistono solo mysql/mysqldump (MariaDB 10.x/11.x) ensure_db_bin_symlink mariadb mysql ensure_db_bin_symlink mariadb-dump mysqldump # mysql/mysqldump -> compat per il codice esistente se restano solo i nativi (MariaDB 12) ensure_db_bin_symlink mysql mariadb ensure_db_bin_symlink mysqldump mariadb-dump mysql -e "DELETE FROM mysql.user WHERE User='';" >> "$LOG_FILE" 2>&1 || true mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true cat > /root/.my.cnf << 'MYCNF' [client] user=root MYCNF chmod 600 /root/.my.cnf success "MariaDB" # Nginx mkdir -p /etc/nginx/conf.d/vhosts mkdir -p /var/www/default cat > /var/www/default/index.html << 'HTML' Server

Server in configurazione

Gestito da AelynaCP

HTML if ! grep -q "conf.d/vhosts" /etc/nginx/nginx.conf 2>/dev/null; then sed -i '/include.*conf\.d.*\.conf/a\ include /etc/nginx/conf.d/vhosts/*.conf;' /etc/nginx/nginx.conf 2>/dev/null || true fi systemctl enable --now nginx >> "$LOG_FILE" 2>&1 || true success "Nginx" # PHP-FPM # ⚠️ La cartella dei socket serve **subito** e anche dopo ogni riavvio: /run è # una tmpfs, e un mkdir da solo sparisce al primo boot. Il nostro pacchetto porta # un file tmpfiles.d che la ricrea; qui la si crea per il primo avvio, prima che # quel file venga letto. mkdir -p /var/run/php-fpm if [ -n "${AELYNACP_PHP_UNIT:-}" ]; then # Sui nostri non si tollera il silenzio: un PHP-FPM installato ma spento è un # server che risponde a tutto tranne che ai siti. systemctl enable --now "$AELYNACP_PHP_UNIT" >> "$LOG_FILE" 2>&1 \ || fatal "PHP-FPM (${AELYNACP_PHP_UNIT}) non si avvia. Vedi: journalctl -u ${AELYNACP_PHP_UNIT}" else # Ramo apt: i nostri .deb non esistono ancora (fase 6), qui c'è il PHP della # distribuzione e si resta tolleranti come prima. systemctl enable --now php-fpm >> "$LOG_FILE" 2>&1 \ || systemctl enable --now "php${PHP_VERSION}-fpm" >> "$LOG_FILE" 2>&1 \ || true fi success "PHP-FPM" # Postfix postconf -e "myhostname = ${HOSTNAME_INPUT}" >> "$LOG_FILE" 2>&1 || true postconf -e "inet_interfaces = all" >> "$LOG_FILE" 2>&1 || true systemctl enable --now postfix >> "$LOG_FILE" 2>&1 || true success "Postfix" # Dovecot groupadd -g 5000 vmail >> "$LOG_FILE" 2>&1 || true useradd -u 5000 -g vmail -d /var/mail/vhosts -s /sbin/nologin vmail >> "$LOG_FILE" 2>&1 || true mkdir -p /var/mail/vhosts && chown -R vmail:vmail /var/mail/vhosts touch /etc/dovecot/users 2>/dev/null || true systemctl enable --now dovecot >> "$LOG_FILE" 2>&1 || true success "Dovecot" # PowerDNS pdns_api_key=$(generate_hex 32) sudo -u postgres psql -c "CREATE DATABASE pdns OWNER ${DB_USER};" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -d pdns << 'PDNSQL' >> "$LOG_FILE" 2>&1 || true -- Schema allineato a PowerDNS >= 4.7 (incluse le colonne delle catalog zone). -- pdns 5.x interroga domains.catalog gia' all'avvio, per riempire la zone cache: -- senza quella colonna il server esce con codice 1 e il DNS non parte affatto. CREATE TABLE IF NOT EXISTS domains (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, master VARCHAR(128), last_check INT, type TEXT NOT NULL DEFAULT 'NATIVE', notified_serial BIGINT, account VARCHAR(40), options TEXT, catalog TEXT); -- Ripara i database creati da versioni precedenti dell'installer (idempotente). ALTER TABLE domains ADD COLUMN IF NOT EXISTS options TEXT; ALTER TABLE domains ADD COLUMN IF NOT EXISTS catalog TEXT; -- 'type' era VARCHAR(8): troppo stretto per i tipi delle catalog zone. ALTER TABLE domains ALTER COLUMN type TYPE TEXT; CREATE TABLE IF NOT EXISTS records (id BIGSERIAL PRIMARY KEY, domain_id INT REFERENCES domains(id) ON DELETE CASCADE, name VARCHAR(255), type VARCHAR(10), content VARCHAR(65535), ttl INT, prio INT, disabled BOOL DEFAULT false, ordername VARCHAR(255), auth BOOL DEFAULT true); CREATE TABLE IF NOT EXISTS supermasters (ip VARCHAR(64) NOT NULL, nameserver VARCHAR(255) NOT NULL, account VARCHAR(40) NOT NULL); CREATE TABLE IF NOT EXISTS comments (id SERIAL PRIMARY KEY, domain_id INT NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(10) NOT NULL, modified_at INT NOT NULL, account VARCHAR(40) NOT NULL, comment TEXT NOT NULL); CREATE TABLE IF NOT EXISTS domainmetadata (id SERIAL PRIMARY KEY, domain_id INT REFERENCES domains(id) ON DELETE CASCADE, kind VARCHAR(32), content TEXT); CREATE TABLE IF NOT EXISTS cryptokeys (id SERIAL PRIMARY KEY, domain_id INT REFERENCES domains(id) ON DELETE CASCADE, flags INT NOT NULL, active BOOL, published BOOL DEFAULT true, content TEXT); CREATE TABLE IF NOT EXISTS tsigkeys (id SERIAL PRIMARY KEY, name VARCHAR(255), algorithm VARCHAR(50), secret VARCHAR(255)); CREATE INDEX IF NOT EXISTS rec_name_index ON records(name); CREATE INDEX IF NOT EXISTS rec_domain_id ON records(domain_id); CREATE INDEX IF NOT EXISTS dom_name_index ON domains(name); PDNSQL sudo -u postgres psql -d pdns -c "GRANT ALL ON ALL TABLES IN SCHEMA public TO ${DB_USER};" >> "$LOG_FILE" 2>&1 || true sudo -u postgres psql -d pdns -c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};" >> "$LOG_FILE" 2>&1 || true mkdir -p "$CONFIG_DIR" echo "$pdns_api_key" > "${CONFIG_DIR}/pdns_api_key" chmod 600 "${CONFIG_DIR}/pdns_api_key" pdns_conf=$(find /etc/pdns /etc/powerdns -name "pdns.conf" 2>/dev/null | head -1) [ -z "$pdns_conf" ] && mkdir -p /etc/pdns && pdns_conf="/etc/pdns/pdns.conf" cat > "$pdns_conf" << PDNSCFG launch=gpgsql gpgsql-host=/var/run/postgresql gpgsql-dbname=pdns gpgsql-user=${DB_USER} gpgsql-password=${DB_PASS} local-address=0.0.0.0 local-port=53 api=yes api-key=${pdns_api_key} webserver=yes webserver-address=127.0.0.1 webserver-port=8081 webserver-allow-from=127.0.0.1 default-soa-content=ns1.aelynacp.com. admin.${HOSTNAME_INPUT}. 0 10800 3600 604800 3600 PDNSCFG # Restringe i permessi: il file contiene gpgsql-password e api-key if getent group pdns >/dev/null 2>&1; then chown root:pdns "$pdns_conf" 2>/dev/null || true chmod 640 "$pdns_conf" else chmod 600 "$pdns_conf" fi # Free port 53 if systemd-resolved is using it if ss -tlnp 2>/dev/null | grep -q ":53 "; then mkdir -p /etc/systemd/resolved.conf.d echo -e "[Resolve]\nDNSStubListener=no" > /etc/systemd/resolved.conf.d/no-stub.conf systemctl restart systemd-resolved >> "$LOG_FILE" 2>&1 || true sleep 1 fi systemctl enable --now pdns >> "$LOG_FILE" 2>&1 || true # Non dare per riuscito ciò che non lo è: `enable --now` può fallire e lasciare il # servizio giù, e dichiarare OK qui rende invisibile un DNS non funzionante. if systemctl is-active --quiet pdns 2>/dev/null; then success "PowerDNS" else error "PowerDNS non attivo — controlla: journalctl -xeu pdns.service" log "ERROR: pdns non attivo dopo enable --now" fi # ProFTPD mkdir -p /etc/proftpd && touch /etc/proftpd/ftpd.passwd && chmod 600 /etc/proftpd/ftpd.passwd # ⚠️ **`--now`, come tutti gli altri.** Qui c'era `systemctl enable proftpd` # secco: l'unico dei venti servizi dell'installer senza `--now`. Il risultato era # un ProFTPD **abilitato all'avvio ma mai avviato**, quindi FTP spento fino al # primo riavvio della macchina — e il riepilogo di fine installazione lo mostrava # «Stopped» sotto la scritta «installato con successo». # `success` lo dichiarava a posto senza guardare, che è il modo in cui la cosa è # rimasta lì: il passo *era* riuscito, semplicemente non faceva ciò che serviva. systemctl enable --now proftpd >> "$LOG_FILE" 2>&1 || true if systemctl is-active --quiet proftpd 2>/dev/null; then success "ProFTPD" else error "ProFTPD non attivo — controlla: journalctl -xeu proftpd.service" log "ERROR: proftpd non attivo dopo enable --now" fi # Valkey/Redis — il nome dell'unit cambia per distro: valkey su RHEL, # redis-server su Debian/Ubuntu (dove `redis` è solo un alias collegato e # systemctl si rifiuta di operarci sopra). kv_unit="" for u in valkey redis-server redis; do if systemctl list-unit-files "${u}.service" 2>/dev/null | grep -q "^${u}.service"; then kv_unit="$u" break fi done if [ -n "$kv_unit" ]; then systemctl enable --now "$kv_unit" >> "$LOG_FILE" 2>&1 || true fi if systemctl is-active --quiet valkey 2>/dev/null || systemctl is-active --quiet redis-server 2>/dev/null || systemctl is-active --quiet redis 2>/dev/null; then success "Valkey/Redis${kv_unit:+ ($kv_unit)}" else error "Valkey/Redis non attivo — la cache e il rate limiting non funzioneranno" log "ERROR: nessuna unit valkey/redis-server/redis attiva" fi # Fail2Ban mkdir -p /etc/fail2ban/jail.d cat > /etc/fail2ban/jail.d/aelynacp.conf << 'F2B' [sshd] enabled = true maxretry = 5 bantime = 3600 F2B systemctl enable --now fail2ban >> "$LOG_FILE" 2>&1 || true success "Fail2Ban" # Firewall systemctl enable --now firewalld >> "$LOG_FILE" 2>&1 || true for port in 80/tcp 443/tcp 21/tcp 25/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 53/tcp 53/udp 9443/tcp 9447/tcp 9448/tcp; do firewall-cmd --permanent --add-port="$port" >> "$LOG_FILE" 2>&1 || true done firewall-cmd --reload >> "$LOG_FILE" 2>&1 || true success "Firewall" # ── Deploy AelynaCP ────────────────────────────────────────────────────────── mkdir -p "$INSTALL_DIR" "$BIN_DIR" "$WEB_DIR" "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR" "$RUN_DIR" "${DATA_DIR}/backups" # Download info "Download AelynaCP v${AELYNACP_VERSION}..." pkg_url="https://get.aelynacp.com/aelynacp-${AELYNACP_VERSION}-linux-x86_64.tar.gz" pkg_file="/tmp/aelynacp-${AELYNACP_VERSION}.tar.gz" rm -f "$pkg_file" curl -L --progress-bar -o "$pkg_file" "$pkg_url" 2>&1 | tail -1 pkg_size=$(stat -c%s "$pkg_file" 2>/dev/null || echo "0") if [ "$pkg_size" -lt 100000 ]; then fatal "Download fallito (${pkg_size} bytes). Verifica che v${AELYNACP_VERSION} esista." fi success "Download ($(( pkg_size / 1048576 ))MB)" # Verifica integrità (SHA256) — fail closed: senza checksum valido NON si installa. info "Verifica integrità (SHA256)..." rm -f "${pkg_file}.sha256" if ! curl -fsSL -o "${pkg_file}.sha256" "${pkg_url}.sha256"; then fatal "Checksum non disponibile (${pkg_url}.sha256). Installazione annullata per sicurezza." fi expected_sha=$(awk '{print $1}' "${pkg_file}.sha256" | head -1) actual_sha=$(sha256sum "$pkg_file" | awk '{print $1}') if [ -z "$expected_sha" ] || [ "$expected_sha" != "$actual_sha" ]; then error "Checksum SHA256 NON corrispondente — pacchetto corrotto o manomesso." error " Atteso: ${expected_sha}" error " Calcolato: ${actual_sha}" fatal "Installazione annullata." fi success "Integrità verificata (SHA256)" tar xzf "$pkg_file" -C "$INSTALL_DIR" >> "$LOG_FILE" 2>&1 # Tutti i binari, non solo web e agent: worker, monitor, updater, smtp-policy e # antispam hanno una unit systemd propria, e `aelynacp` e' la CLI. chmod +x "${BIN_DIR}"/* 2>/dev/null || true # La CLI va raggiungibile senza percorso assoluto: senza questo collegamento # `aelynacp` risponde "command not found" e i comandi documentati (update check, # update install, ...) risultano inesistenti pur essendo installati. ln -sf "${BIN_DIR}/aelynacp" /usr/local/bin/aelynacp 2>/dev/null || true success "Estrazione pacchetto" # Generate config JWT_SECRET=$(generate_hex 64) ADMIN_PASS=$(generate_admin_password) cat > "${CONFIG_DIR}/aelynacp.toml" << TOML [server] hostname = "${HOSTNAME_INPUT}" primary_ip = "${PRIMARY_IP}" admin_port = 9443 user_port = 9447 api_port = 8450 nameservers = ["ns1.aelynacp.com", "ns2.aelynacp.com"] webserver = "nginx" max_upload_mb = 200 [database] url = "postgres://${DB_USER}:${DB_PASS}@localhost/${DB_NAME}" max_connections = 20 [redis] url = "redis://127.0.0.1:6379" [license] key = "" server_url = "https://panel.aelynacp.com" check_interval = 43200 [security] jwt_secret = "${JWT_SECRET}" jwt_expiration = 3600 session_expiration = 86400 max_login_attempts = 5 lockout_duration = 900 [paths] config_dir = "${CONFIG_DIR}" data_dir = "${DATA_DIR}" log_dir = "${LOG_DIR}" bin_dir = "${BIN_DIR}" web_dir = "${WEB_DIR}" home_dir = "/home" backup_dir = "${DATA_DIR}/backups" agent_socket = "${RUN_DIR}/agent.sock" [update] channel = "stable" auto_update = true [notifications] enabled = true admin_email = "" [smtp] use_local = true from_address = "noreply@${HOSTNAME_INPUT}" from_name = "AelynaCP" [defaults] php_version = "${PHP_VERSION}" shell = "/sbin/nologin" auto_dns_zone = true TOML chmod 600 "${CONFIG_DIR}/aelynacp.toml" echo "$PRIMARY_IP" > "${CONFIG_DIR}/primary_ip" # Verify config was written if [ -f "${CONFIG_DIR}/aelynacp.toml" ]; then log "Config file OK: $(wc -c < ${CONFIG_DIR}/aelynacp.toml) bytes at ${CONFIG_DIR}/aelynacp.toml" success "Configurazione generata" else error "ERRORE: config file non creato!" fi # Run migrations # # ⚠️ **Gli errori si contano, non si buttano.** Il `|| true` da solo copriva # tutto: su un database vuoto una migrazione che fallisce davvero e una che non # ha niente da fare finivano nello stesso silenzio, e «Database schema» diceva OK # in entrambi i casi. È così che il duplicato `006_backup.sql` — copia byte per # byte della 005 — è rimasto nel pacchetto per mesi, producendo otto # «relation already exists» a ogni installazione senza che nessuno li vedesse. # # ⚠️ Non si usa `ON_ERROR_STOP=1` e non si rende fatale: le migrazioni non sono # idempotenti (`CREATE TABLE` senza `IF NOT EXISTS`), quindi su una macchina dove # lo schema c'è già fermarsi al primo errore vorrebbe dire non installare più # niente. Si applica tutto come prima, ma alla fine si dice quante hanno # protestato e quali: su un'installazione nuova devono essere **zero**. migrazioni_con_errori=0 migrazioni_fallite="" for migration in "${INSTALL_DIR}/migrations"/*.sql; do [ -f "$migration" ] || continue esito=$(PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -d "$DB_NAME" -f "$migration" 2>&1) echo "$esito" >> "$LOG_FILE" if echo "$esito" | grep -q "^psql:.*ERROR:"; then migrazioni_con_errori=$((migrazioni_con_errori + 1)) migrazioni_fallite="${migrazioni_fallite} $(basename "$migration")" fi done if [ "$migrazioni_con_errori" -eq 0 ]; then success "Database schema" else warn "Database schema: ${migrazioni_con_errori} migrazioni con errori —${migrazioni_fallite}" warn " Su un'installazione nuova non dovrebbero essercene: vedi ${LOG_FILE}" log "ERROR: migrazioni con errori:${migrazioni_fallite}" # Il conteggio della barra va avanti comunque: il passo è stato eseguito. DONE_TASKS=$((DONE_TASKS + 1)) draw_progress fi # Create admin account gen_admin_hash() { python3 -c "import argon2; print(argon2.PasswordHasher().hash('${ADMIN_PASS}'))" 2>/dev/null; } # Il pacchetto argon2 ha nomi diversi fra le famiglie di distro. if [ "$OS_TYPE" = "rhel" ]; then ARGON2_PKG="python3-argon2-cffi" else ARGON2_PKG="python3-argon2" fi admin_hash=$(gen_admin_hash) if [ -z "$admin_hash" ]; then # Ultimo tentativo col pacchetto della distro. pip non è affidabile: su molte # distro recenti non è installato di default e comunque rifiuta di scrivere # nel Python di sistema (PEP 668). pkg_install "$ARGON2_PKG" >> "$LOG_FILE" 2>&1 || true admin_hash=$(gen_admin_hash) fi if [ -z "$admin_hash" ]; then if command -v pip3 > /dev/null 2>&1; then pip3 install --break-system-packages argon2-cffi >> "$LOG_FILE" 2>&1 \ || pip3 install argon2-cffi >> "$LOG_FILE" 2>&1 || true admin_hash=$(gen_admin_hash) fi fi if [ -z "$admin_hash" ]; then fatal "Impossibile generare l'hash della password admin (argon2 non disponibile). Installa '${ARGON2_PKG}' e riesegui l'installer." fi PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -d "$DB_NAME" -c "DELETE FROM admins WHERE username = 'admin';" >> "$LOG_FILE" 2>&1 || true if ! PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO admins (username, password_hash, role, email) VALUES ('admin', '${admin_hash}', 'admin', '');" >> "$LOG_FILE" 2>&1; then fatal "Inserimento dell'account admin nel database fallito (vedi ${LOG_FILE})." fi PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO server_settings (key, value) VALUES ('setup_completed', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false';" >> "$LOG_FILE" 2>&1 || true echo "$AELYNACP_VERSION" > "${CONFIG_DIR}/.current_version" success "Account admin" # Certificato TLS self-signed per i pannelli (HTTPS). Rimpiazzabile con Let's Encrypt dal pannello. PANEL_SSL_DIR="${CONFIG_DIR}/ssl" mkdir -p "$PANEL_SSL_DIR" if [ ! -f "${PANEL_SSL_DIR}/panel.crt" ] || [ ! -f "${PANEL_SSL_DIR}/panel.key" ]; then openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ -keyout "${PANEL_SSL_DIR}/panel.key" -out "${PANEL_SSL_DIR}/panel.crt" \ -subj "/CN=${HOSTNAME_INPUT}" >> "$LOG_FILE" 2>&1 || true fi chmod 600 "${PANEL_SSL_DIR}/panel.key" 2>/dev/null || true chmod 644 "${PANEL_SSL_DIR}/panel.crt" 2>/dev/null || true # Nginx panels (HTTPS/TLS) cat > /etc/nginx/conf.d/aelynacp-panels.conf << NGINX server { listen 9443 ssl; server_name _; ssl_certificate ${PANEL_SSL_DIR}/panel.crt; ssl_certificate_key ${PANEL_SSL_DIR}/panel.key; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000" always; root ${WEB_DIR}/admin; index index.html; add_header Cache-Control "no-cache" always; location / { try_files \$uri \$uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8450; proxy_http_version 1.1; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header X-Aelyna-Panel admin; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } server { listen 9447 ssl; server_name _; client_max_body_size 256m; ssl_certificate ${PANEL_SSL_DIR}/panel.crt; ssl_certificate_key ${PANEL_SSL_DIR}/panel.key; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000" always; root ${WEB_DIR}/user; index index.html; location / { try_files \$uri \$uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8450; proxy_http_version 1.1; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header X-Aelyna-Panel user; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } } server { listen 9448 ssl; server_name _; ssl_certificate ${PANEL_SSL_DIR}/panel.crt; ssl_certificate_key ${PANEL_SSL_DIR}/panel.key; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000" always; root ${WEB_DIR}/webmail; index index.html; location / { try_files \$uri \$uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8450; proxy_http_version 1.1; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } } NGINX nginx -t >> "$LOG_FILE" 2>&1 && systemctl reload nginx >> "$LOG_FILE" 2>&1 || true success "Nginx pannelli (HTTPS)" # ── Pagina predefinita del server ──────────────────────────────────────────── # Chi apre l'IP del server, o un dominio puntato qui ma non ancora configurato, # deve vedere la pagina di AelynaCP — non il benvenuto di nginx, che rivela la # distribuzione e fa sembrare il server non configurato. # # Le pagine (/var/www/default/index.html e noindex.html) arrivano dal pacchetto; # fino alla 0.4.20 venivano installate ma non servite da nessun vhost, quindi # restavano invisibili. if [ -d "${INSTALL_DIR}/www-default" ]; then cp -f "${INSTALL_DIR}/www-default/"*.html /var/www/default/ 2>/dev/null || true fi # Il sito predefinito della distribuzione ha la precedenza e va tolto di mezzo: # su Debian/Ubuntu è un collegamento in sites-enabled, su RHEL è un blocco # `default_server` dentro nginx.conf. Senza questo, il nostro vhost o non viene # mai raggiunto oppure fa fallire `nginx -t` con "duplicate default server". rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true cp -a /etc/nginx/nginx.conf "/etc/nginx/nginx.conf.bak-default-$(date +%s)" 2>/dev/null || true sed -i 's/listen\([[:space:]]\+\)80 default_server;/listen\1 80;/; s/listen\([[:space:]]\+\)\[::\]:80 default_server;/listen\1 [::]:80;/' /etc/nginx/nginx.conf 2>/dev/null || true cat > /etc/nginx/conf.d/vhosts/000-default.conf << 'NGINXDEF' # Vhost predefinito di AelynaCP: risponde per ogni nome non associato a un sito # (compreso l'accesso diretto per indirizzo IP). Il nome del file inizia per 000 # perché nginx considera predefinito il primo blocco caricato per quella porta. server { listen 80 default_server; listen [::]:80 default_server; server_name _; root /var/www/default; index index.html; # La validazione dei certificati deve funzionare anche per un nome che non # ha ancora un vhost proprio, altrimenti il primo certificato di un dominio # appena puntato qui fallirebbe. location ^~ /.well-known/acme-challenge/ { root /var/www/aelynacp-acme; default_type "text/plain"; try_files $uri =404; } location / { try_files $uri $uri/ /index.html; } } NGINXDEF if nginx -t >> "$LOG_FILE" 2>&1; then systemctl reload nginx >> "$LOG_FILE" 2>&1 || true success "Pagina predefinita del server" else # Mai lasciare nginx in uno stato che non riparte: una pagina di cortesia non # vale il server web di tutti i siti. rm -f /etc/nginx/conf.d/vhosts/000-default.conf nginx -t >> "$LOG_FILE" 2>&1 && systemctl reload nginx >> "$LOG_FILE" 2>&1 || true warn "Pagina predefinita non installata (configurazione nginx già in uso da un altro vhost predefinito)" fi # Systemd services cat > /etc/systemd/system/aelynacp-agent.service << SVC [Unit] Description=AelynaCP Agent After=network.target postgresql.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStart=${BIN_DIR}/aelynacp-agent Restart=on-failure RestartSec=5 StandardOutput=append:${LOG_DIR}/agent.log StandardError=append:${LOG_DIR}/agent.log Environment=RUST_LOG=aelynacp_agent=info [Install] WantedBy=multi-user.target SVC cat > /etc/systemd/system/aelynacp-web.service << SVC [Unit] Description=AelynaCP Web After=network.target postgresql.service aelynacp-agent.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStartPre=/usr/bin/test -f /etc/aelynacp/aelynacp.toml ExecStart=${BIN_DIR}/aelynacp-web Restart=on-failure RestartSec=10 # Hardening parziale: il refactor per far girare il web non-root e tracciato come Q1 (FIX-PLAN) # Direttive scelte per NON rompere le operazioni privilegiate che oggi il web esegue # direttamente (scrittura su /etc, gestione processi/servizi, self-update via /tmp). # /tmp privato: isola i file temporanei del web (es. chiavi rsync, archivi self-update); # writer e reader del self-update sono nello stesso albero processi, quindi il flusso regge. PrivateTmp=yes # RestrictSUIDSGID=yes RIMOSSA (2026-07-28): rompeva l'aggiornamento dal pannello. # systemd la implementa con seccomp e, non potendo ispezionare i permessi passati # ⚠️ I backtick qui sotto sono sfuggiti con \` di proposito: questo heredoc NON è # quotato (servono ${INSTALL_DIR} e compagni), quindi bash vi farebbe la # sostituzione di comando anche dentro un commento. Non è teoria: senza l'escape # eseguiva davvero \`systemd-run\`, e i tre nomi sparivano dal file installato. # a \`openat2\` (stanno in una struttura in memoria), la nega in blocco restituendo # ENOSYS, contando sul ripiego della libreria su \`openat\`. Con tar 1.35 su kernel # recenti quel ripiego non scatta: l'estrazione fallisce su OGNI file con # "Cannot open: Function not implemented" e l'aggiornamento si annulla. # Verificato su Ubuntu 26.04 / kernel 7.0.0-28: la stessa estrazione riesce dalla # shell e fallisce sotto \`systemd-run --property=RestrictSUIDSGID=yes\`. # Il valore difensivo era comunque marginale: il servizio gira come root e scrive # in /etc, quindi non gli serve il bit setuid per fare danni. PrivateTmp e # ProtectControlGroups restano. # Monta in sola lettura la gerarchia cgroup: il web non vi scrive direttamente. ProtectControlGroups=yes StandardOutput=append:${LOG_DIR}/web.log StandardError=append:${LOG_DIR}/web.log Environment=RUST_LOG=aelynacp_web=info,tower_http=info [Install] WantedBy=multi-user.target SVC cat > /etc/systemd/system/aelynacp-worker.service << SVC [Unit] Description=AelynaCP Worker (coda job / backup) After=network.target postgresql.service aelynacp-agent.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStart=${BIN_DIR}/aelynacp-worker Restart=on-failure RestartSec=10 TimeoutStopSec=300 StandardOutput=append:${LOG_DIR}/worker.log StandardError=append:${LOG_DIR}/worker.log Environment=RUST_LOG=aelynacp_worker=info [Install] WantedBy=multi-user.target SVC cat > /etc/systemd/system/aelynacp-monitor.service << SVC [Unit] Description=AelynaCP Monitor (health-check, auto-restart, alert) After=network.target postgresql.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStart=${BIN_DIR}/aelynacp-monitor Restart=always RestartSec=10 StandardOutput=append:${LOG_DIR}/monitor.log StandardError=append:${LOG_DIR}/monitor.log Environment=RUST_LOG=aelynacp_monitor=info [Install] WantedBy=multi-user.target SVC cat > /etc/systemd/system/aelynacp-smtp-policy.service << SVC [Unit] Description=AelynaCP SMTP Policy (limiti invio per casella/dominio) After=network.target postgresql.service aelynacp-agent.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStart=${BIN_DIR}/aelynacp-smtp-policy Restart=on-failure RestartSec=10 StandardOutput=append:${LOG_DIR}/smtp-policy.log StandardError=append:${LOG_DIR}/smtp-policy.log Environment=RUST_LOG=aelynacp_smtp_policy=info [Install] WantedBy=multi-user.target SVC cat > /etc/systemd/system/aelynacp-updater.service << SVC [Unit] Description=AelynaCP Updater (scheduler auto-update) After=network.target postgresql.service aelynacp-web.service [Service] Type=simple User=root WorkingDirectory=${INSTALL_DIR} ExecStart=${BIN_DIR}/aelynacp-updater Restart=on-failure RestartSec=30 StandardOutput=append:${LOG_DIR}/updater.log StandardError=append:${LOG_DIR}/updater.log Environment=RUST_LOG=aelynacp_updater=info [Install] WantedBy=multi-user.target SVC systemctl daemon-reload systemctl enable --now aelynacp-agent >> "$LOG_FILE" 2>&1; sleep 2 # ── Gettone di installazione ────────────────────────────────────────────────── # ⚠️ Lo crea l'installer, e questa è la novità. Prima nasceva pigro alla prima # apertura del setup (`ensure_install_token` in routes/setup.rs), quindi quando # l'installazione finiva **non esisteva ancora**: all'utente toccava collegarsi # in SSH e fare `cat /etc/aelynacp/.install_token`, un passo a mano in mezzo a # una procedura che per il resto non ne chiede nessuno — e proprio nel momento in # cui sta guardando il riepilogo con la password accanto. # Scriverlo qui non cambia il meccanismo: `ensure_install_token` legge il file # esistente **prima** di generarne uno, quindi il pannello riusa questo. Cambia # solo chi lo crea per primo, e con esso la possibilità di stamparlo. # ⚠️ Resta 0600 e di root: il suo mestiere è essere un segreto che si ottiene # solo stando sulla macchina. Stamparlo qui non lo indebolisce — chi legge questo # riepilogo la macchina ce l'ha già in mano — ma finisce nel log se l'output # viene rediretto, esattamente come la password di admin. mkdir -p /etc/aelynacp if [ -s /etc/aelynacp/.install_token ]; then INSTALL_TOKEN="$(tr -d ' \n\r' < /etc/aelynacp/.install_token)" log "Gettone di installazione già presente: riusato" else # 32 byte casuali in esadecimale: la stessa forma che genera il pannello. INSTALL_TOKEN="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" printf '%s\n' "$INSTALL_TOKEN" > /etc/aelynacp/.install_token chmod 600 /etc/aelynacp/.install_token chown root:root /etc/aelynacp/.install_token 2>/dev/null || true log "Gettone di installazione creato in /etc/aelynacp/.install_token" fi systemctl enable --now aelynacp-web >> "$LOG_FILE" 2>&1; sleep 3 systemctl enable --now aelynacp-worker >> "$LOG_FILE" 2>&1 || true systemctl enable --now aelynacp-monitor >> "$LOG_FILE" 2>&1 || true systemctl enable --now aelynacp-smtp-policy >> "$LOG_FILE" 2>&1 || true systemctl enable --now aelynacp-updater >> "$LOG_FILE" 2>&1 || true systemctl reload nginx >> "$LOG_FILE" 2>&1 || true success "Servizi AelynaCP" # ── Saluto SSH e rotazione dei log ──────────────────────────────────────────── # NON si installano qui: se ne occupa l'agent al proprio avvio # (handlers::saluto::assicura_saluto), che se li porta dentro il binario. # Scritti qui dentro, i server AGGIORNATI non li riceverebbero mai — install.sh # gira soltanto a installazione nuova, ed è esattamente il difetto emerso con # la 0.4.28: i file erano nel pacchetto e non li installava nessuno. echo "$AELYNACP_VERSION" > /etc/aelynacp/version 2>/dev/null || true # ── Aggiornamento del sistema ───────────────────────────────────────────────── # Chiesto dall'utente il 18 ago 2026: l'ultimo gesto dell'installazione è portare # la macchina agli aggiornamenti correnti, senza domande. # ⚠️ Il `-y` non salta una conferma: la conferma è stata data lanciando # l'installer. Una domanda in mezzo a una procedura non presidiata — e sotto # `curl | bash` non c'è quasi mai nessuno a guardare — non protegge nessuno: # blocca l'installazione per due ore finché qualcuno non passa di lì. # ⚠️ L'aggiornamento non può far rientrare il PHP della distribuzione: a questo # punto `exclude=php*` è già in dnf.conf (blocca_php_distribuzione). Se un giorno # quell'ordine cambiasse, questo comando diventerebbe la finestra da cui rientra. run_task "Aggiornamento sistema" dnf -y update # Un aggiornamento può portare un kernel o una libreria che entrano in servizio # solo al riavvio. Lo si DICE nel riepilogo invece di riavviare: riavviare da sé # una macchina appena installata, mentre l'utente sta leggendo la password, # sarebbe un modo eccellente di farla sembrare rotta. RIAVVIO_CONSIGLIATO="" if command -v needs-restarting > /dev/null 2>&1; then needs-restarting -r > /dev/null 2>&1 || RIAVVIO_CONSIGLIATO="1" fi # Health check sleep 2 if curl -s --max-time 5 http://localhost:8450/api/v1/health | grep -q '"ok"' 2>/dev/null; then success "Health check OK" else warn "Health check fallito — controlla i log in ${LOG_DIR}/" fi # ── Autocollaudo: la macchina risponde, invece dell'installer che si loda ───── # ⚠️ Ogni `success` di questo script dice «il comando è andato a buon fine», che # non è «la cosa funziona». La differenza ha morso quattro volte: # · ProFTPD dichiarato installato mentre era spento (0.5.3); # · «opcache attiva» che guardava se il modulo era caricato, non se era acceso; # · `repo_gpgcheck` scritto nel file e mai esercitato; # · la barra al 97% su un'installazione riuscita. # Nessuno dei quattro si vedeva guardando se l'installer aveva finito. Qui si # chiede alla macchina, alla fine, con domande a cui non può rispondere di sì # per educazione. Il collaudo completo è in scripts/verifica-installazione.sh: # questo è il suo nocciolo, quello che vale la pena avere sempre. AUTOCOLLAUDO_FALLITI=0 ac_ko() { AUTOCOLLAUDO_FALLITI=$((AUTOCOLLAUDO_FALLITI+1)); error "$1"; } echo "" echo -e " ${WHITE}Controllo finale...${NC}" # 1. I servizi rispondono ADESSO e ripartono al riavvio. Sono due domande: un # servizio attivo ma non abilitato muore al primo riavvio, e nessuno lo sa. # ⚠️ Le due domande pesano UGUALE, e fino alla 0.5.5 non era così: «non abilitato» # era solo un avviso, quindi il contatore restava a zero e il titolo restava # «installato con successo» sopra una macchina che al primo riavvio si presenta # rotta. È lo stesso difetto di ProFTPD spostato di un giorno in avanti — e il # riavvio, che è il momento in cui morde, di solito succede quando non c'è # nessuno a guardare. Un titolo verde lì è peggio che inutile: è la ragione per # cui nessuno andrà a leggere l'avviso. # ⚠️ Si guardano gli stati che fanno DANNO — `disabled` e `masked` — non «diverso # da enabled»: `static` (unità senza sezione [Install], che non si può # abilitare) e `indirect` sono normali, e trattarli da guasto insegnerebbe a # non fidarsi del controllo. for _s in postgresql mariadb nginx aelynacp-web aelynacp-worker proftpd postfix dovecot firewalld; do systemctl cat "${_s}.service" > /dev/null 2>&1 || continue if ! systemctl is-active --quiet "${_s}" 2>/dev/null; then ac_ko "${_s} non è attivo" continue fi case "$(systemctl is-enabled "${_s}" 2>/dev/null)" in disabled|masked|masked-runtime) ac_ko "${_s} è attivo ma non abilitato: al primo riavvio non riparte" ;; enabled|enabled-runtime|static|indirect|generated|alias|"") ;; *) warn "${_s}: stato di abilitazione inatteso ($(systemctl is-enabled "${_s}" 2>/dev/null))" ;; esac done # 2. Il pannello risponde con un ESITO, non con una pagina. ⚠️ Non si interroga # /health sulla 9443: il catch-all dell'interfaccia risponde 200 a qualunque # percorso, quindi un 200 lì non dimostra niente. if ! curl -s --max-time 10 http://localhost:8450/api/v1/health | grep -q '"ok"'; then ac_ko "il pannello non risponde a /api/v1/health" fi # 3. Il PHP è il nostro, e nessun PHP della distribuzione è entrato. if [ "$(rpm -qa 'php*' 2>/dev/null | grep -cv aelynacp)" -ne 0 ] 2>/dev/null; then ac_ko "sono presenti pacchetti PHP della distribuzione: il PHP servito potrebbe non essere il nostro" fi if ! pgrep -af php-fpm 2>/dev/null | grep -q '/opt/aelynacp/'; then ac_ko "il master PHP-FPM non è il nostro" fi # 4. Il database ha le tabelle: una migrazione fallita in silenzio lascia il # pannello in piedi e vuoto, che è il modo peggiore di essere rotto. _tab="$(sudo -u postgres psql -tAX -d aelynacp -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'" 2>/dev/null)" if [ -z "${_tab}" ] || [ "${_tab}" -lt 20 ] 2>/dev/null; then ac_ko "il database ha ${_tab:-0} tabelle: le migrazioni non sono passate" fi # 5. Il gettone di installazione esiste e non è leggibile da altri. if [ ! -s /etc/aelynacp/.install_token ]; then ac_ko "manca il gettone di installazione /etc/aelynacp/.install_token" elif [ "$(stat -c '%a' /etc/aelynacp/.install_token 2>/dev/null)" != "600" ]; then ac_ko "il gettone di installazione non è a 600: lo può leggere chiunque" fi if [ "$AUTOCOLLAUDO_FALLITI" -eq 0 ]; then success "Controllo finale" else log "AUTOCOLLAUDO: ${AUTOCOLLAUDO_FALLITI} controlli falliti" fi # ── Done ────────────────────────────────────────────────────────────────────── echo "" echo -e " ${MAGENTA}${BOLD}═══════════════════════════════════════════════════${NC}" # ⚠️ Il titolo dipende dall'autocollaudo, non dall'essere arrivati in fondo. # «Installato con successo» stampato sopra un servizio spento è la bugia che ha # tenuto in piedi il difetto di ProFTPD per due versioni: la scritta rassicurava, # e il riepilogo poco sotto diceva «Stopped» senza che nessuno le collegasse. if [ "${AUTOCOLLAUDO_FALLITI:-0}" -eq 0 ]; then echo -e " ${GREEN}${BOLD} AelynaCP installato con successo!${NC}" else echo -e " ${YELLOW}${BOLD} AelynaCP installato, ma con ${AUTOCOLLAUDO_FALLITI} controlli falliti${NC}" fi echo -e " ${MAGENTA}${BOLD}═══════════════════════════════════════════════════${NC}" echo "" echo -e " ${WHITE}Pannello Admin:${NC} ${CYAN}https://${PRIMARY_IP}:9443${NC}" echo -e " ${WHITE}Pannello Utente:${NC} ${CYAN}https://${PRIMARY_IP}:9447${NC}" echo -e " ${WHITE}Webmail:${NC} ${CYAN}https://${PRIMARY_IP}:9448${NC}" echo -e " ${GRAY}(certificato self-signed: il browser avviserà al primo accesso — normale)${NC}" echo "" echo -e " ${WHITE}Username:${NC} ${GREEN}admin${NC}" echo -e " ${WHITE}Password:${NC} ${GREEN}${ADMIN_PASS}${NC}" # Il gettone serve al primo accesso al setup. Prima bisognava andarselo a leggere # con `cat /etc/aelynacp/.install_token`: resta possibile, ma non è più necessario. echo -e " ${WHITE}Gettone:${NC} ${GREEN}${INSTALL_TOKEN}${NC}" echo -e " ${GRAY}(se lo perdi: cat /etc/aelynacp/.install_token, da root sulla macchina)${NC}" echo "" if [ "${AUTOCOLLAUDO_FALLITI:-0}" -ne 0 ]; then echo -e " ${RED}${BOLD}Prima di usarlo, guarda i ${AUTOCOLLAUDO_FALLITI} controlli falliti qui sopra.${NC}" echo -e " ${GRAY}Il pannello può rispondere lo stesso ed essere rotto in un pezzo che si vede dopo.${NC}" echo "" fi if [ -n "${RIAVVIO_CONSIGLIATO:-}" ]; then echo -e " ${YELLOW}L'aggiornamento ha portato un kernel o librerie nuove: ${WHITE}riavvia${YELLOW} quando ti è comodo.${NC}" echo -e " ${GRAY}Il pannello funziona già; il riavvio serve a metterle in servizio.${NC}" echo "" fi echo -e " ${YELLOW}Completa il setup iniziale accedendo al pannello.${NC}" echo -e " ${GRAY}Log: ${LOG_FILE}${NC}" echo "" echo -e " ${MAGENTA}${BOLD}═══════════════════════════════════════════════════${NC}" echo "" # FINE-INSTALLER