From eaac6c22c2f26d0f254b324dbb80da26a3f196ee Mon Sep 17 00:00:00 2001 From: tecnotel Date: Sat, 12 Sep 2026 12:54:45 +0200 Subject: [PATCH] iride-setup 0.2.0 - bootstrap, first-setup, web installer 8888 con licenza, dev-setup runner --- README.md | 135 ++++++++-- VERSION | 2 +- bootstrap.sh | 132 ++++----- checks/health.sh | 2 +- dev-setup.sh | 132 +++++++++ first-setup.sh | 196 ++++++++++++++ first_setup.py | 128 --------- install.sh | 130 --------- lib/common.sh | 81 ------ setup.html | 499 ++++++++++++++++++++++++++++++++++ setup_server.py | 679 +++++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 1684 insertions(+), 432 deletions(-) create mode 100755 dev-setup.sh create mode 100755 first-setup.sh delete mode 100755 first_setup.py delete mode 100755 install.sh delete mode 100644 lib/common.sh create mode 100644 setup.html create mode 100644 setup_server.py diff --git a/README.md b/README.md index 18e46c3..274b76b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,121 @@ -# iride-setup +# IRIDE — Setup & Installer -Bootstrap e primo install di un'istanza **IRIDE** (Tecnotel Servizi SRL), sullo -schema di `argos-setup`. Il repository applicativo è `iride`; gli aggiornamenti -successivi li fa `iride/scripts/update.sh`. +Tecnotel Servizi SRL — [www.tecnotelsrl.com](https://www.tecnotelsrl.com) -## Install su un server Ubuntu LTS pulito +Repository **pubblico** con l'installer di prima fase e il Web Installer di IRIDE, +sullo schema di `argos-setup`. Non contiene segreti: solo la chiave pubblica con cui +si verifica la licenza. Il software è nel repository privato `tecnotel/iride`, +raggiungibile con il token di sola lettura incluso nella licenza. + +## Flusso ```text -curl -fsSL -H "Authorization: token " \ - https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh \ - | sudo bash -s -- --token +curl bootstrap.sh | sudo bash + └─ first-setup.sh sistema, Node 22, utente iride, sudoers ristretti, + /opt/iride/*, UFW, fail2ban, nginx temporaneo + └─ Web Installer http://IP:8888 — wizard in 6 step + └─ install() licenza → clone iride → venv → iride.json/users.json → + migrazioni → (frontend) → SSL → nginx → systemd → + licenza in /opt/iride/data → chiude la 8888 e si rimuove ``` -`` è un token Gitea di **sola lettura dedicato all'istanza** (uno per -cliente, revocabile), mai un token personale. Resta in -`/opt/iride/config/git-credentials` (0600, utente `iride`) e serve a `update.sh`. +## Prerequisiti -Non interattivo (VM di test, CI): +- VM **Ubuntu 24.04 o 26.04 LTS** pulita (Python 3.12/3.14), accesso `sudo`. +- Sizing minimo: 2 vCPU / 4 GB RAM / 40 GB disco (il DB è SQLite, niente broker). +- Uscita verso `repo.argosdefense.io` (codice e aggiornamenti) e `license.argosdefense.io` + (licenze, heartbeat); per i canali: Meta, Telegram, provider SMS, OpenAI. +- Ingresso: `443` dal pubblico se i canali usano webhook (WhatsApp, Telegram in + webhook), `8888` **solo** dal tuo IP durante il wizard, `22` solo dagli IP admin. +- Una **licenza IRIDE** emessa da Tecnotel per il `machine_id` del server (lo mostra + il wizard al primo step). + +## Installazione + +```bash +curl -fsSL https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh | sudo bash +``` + +Manuale equivalente: + +```bash +sudo apt update && sudo apt install -y git +git clone https://repo.argosdefense.io/tecnotel/iride-setup.git /opt/iride-setup-pkg +cd /opt/iride-setup-pkg && sudo bash first-setup.sh +``` + +Poi apri `http://:8888`: + +1. **Licenza** — copia il `machine_id`, invialo a Tecnotel, carica `license.json`. + Il wizard verifica firma Ed25519, prodotto (`iride`), `machine_id`, scadenza e + presenza del token Gitea. +2. **Cliente** — nome breve, nome completo, tipo, contesto per l'assistente, logo. +3. **Rete & SSL** — hostname pubblico, alias, certificato: + - *Let's Encrypt* (automatico, richiede DNS pubblico già puntato alla VM), + - *certificato esistente* (upload `.crt` + `.key`, es. wildcard aziendale), + - *autofirmato* (RSA 4096, 10 anni, SAN con hostname, alias e IP: LAN e demo; + **non** per WhatsApp, che richiede un certificato valido). +4. **Intelligenza artificiale** — provider del modello testuale: `fake` (demo, nessuna + chiave), OpenAI o Anthropic con API key. Modificabile dal portale. +5. **Utente admin** — primo accesso al portale (password ≥ 12 caratteri, hash bcrypt). +6. **Installa** — riepilogo e avvio; il log dell'installazione scorre nella pagina. + +A fine wizard la porta 8888 viene chiusa e `/opt/iride-setup-pkg` rimosso. +Aggiornamenti successivi: `sudo bash /opt/iride/app/scripts/update.sh` (usa il token +della licenza, mai credenziali personali). Verifica rapida: `iride-health`. + +## Layout risultante ```text -... | sudo bash -s -- --token --client "Nome Cliente" --domain iride.cliente.it --admin-password '' +/opt/iride/app repository tecnotel/iride (utente iride) +/opt/iride/config iride.json, users.json, channels.json, credentials.json (0600) +/opt/iride/data iride.db (WAL), license.json, heartbeat, stato update +/opt/iride/logs demoni e nginx (logrotate) +/opt/iride/certs certificati TLS +/opt/iride/backups snapshot pre-update e giornalieri ``` -## Cosa fa +Servizi: `iride-api` (8080), `iride-worker`, `iride-scheduler`; `iride-voice` (8081) +solo dopo il POC-01. nginx su 80/443. -| Passo | Script | Esito | -|---|---|---| -| pacchetti, utente `iride`, `/opt/iride/*`, credenziali git | `bootstrap.sh` | clona questo repo in `/opt/iride/setup` | -| clone di `iride` in `/opt/iride/app`, venv, dipendenze | `install.sh` | | -| configurazione da `config/*.example` + wizard | `first_setup.py` | `iride.json`, `users.json` (0600) | -| migrazioni, build frontend (se presente), unit systemd, nginx + certificato | `install.sh` | servizi abilitati e avviati | -| verifica | `checks/health.sh` | exit 1 se qualcosa non va | +## Hardening applicato (stesso di argos-setup) -Layout risultante: `/opt/iride/{app,config,data,logs,backups,certs,setup}`. +Aggiornamento completo del sistema, UFW con default deny (22, 80, 443; 8888 chiusa a +fine setup), fail2ban, utente di servizio senza privilegi con sudoers limitati al +restart dei demoni IRIDE e a `update.sh`, configurazione e licenza in file 0600, +token Gitea mai in `.git/config` (URL autenticato solo per clone e fetch), +Strict-Transport-Security e rate limit sulle API in nginx. -## Note +## Server di sviluppo (solo Tecnotel) -- Il certificato generato è self-signed: **WhatsApp richiede un certificato - valido** (Let's Encrypt o del cliente) in `/opt/iride/certs/{fullchain,privkey}.pem`. -- Il wizard web (porta 8888, stile ARGOS) e la validazione della licenza - all'install arrivano dopo il pilota (B-055 li collauda su VM di test). -- Riesecuzione sicura: `bootstrap.sh` aggiorna il setup e rilancia `install.sh`, - che non tocca configurazione, utenti e certificati già presenti. +Lo stesso server può ospitare l'istanza demo, il checkout di sviluppo e il runner CI: + +```bash +sudo bash /opt/iride-setup-pkg/dev-setup.sh --token +``` + +Installa l'ultimo **Gitea Runner** (ex act_runner) in modalità host con label +`ubuntu-latest:host`, come utente `gitea-runner`, servizio `gitea-runner.service`. +Il token si crea su Gitea in *Impostazioni → Actions → Runners*. Va lanciato +**prima** del wizard (che a fine install rimuove `/opt/iride-setup-pkg`). +Il checkout di sviluppo è un normale `git clone` nella home dell'utente. + +## Contenuto del repository + +| File | Scopo | +|---|---| +| `bootstrap.sh` | One-liner: scarica il repo e avvia `first-setup.sh` | +| `first-setup.sh` | Installer ambiente base: sistema, utente, firewall, nginx temporaneo, avvia il wizard | +| `setup_server.py` | Backend del Web Installer (porta 8888, self-contained) | +| `setup.html` | Frontend del Web Installer, wizard in 6 step | +| `dev-setup.sh` | Gitea Runner in modalità host per il server di sviluppo | +| `checks/health.sh` | Verifica dell'istanza (copiato in `/usr/local/bin/iride-health`) | + +## Repository correlati + +- [`tecnotel/iride`](https://repo.argosdefense.io/tecnotel/iride) — codice di IRIDE + (privato, accessibile con licenza). + +## Versioning + +SemVer, tag `vX.Y.Z`, allineato al repository `iride`. diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/bootstrap.sh b/bootstrap.sh index 6ee70a6..02f5c18 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,73 +1,81 @@ #!/bin/bash # ══════════════════════════════════════════════════════════════════════════════ -# IRIDE — Bootstrap installer (Tecnotel Servizi SRL) +# IRIDE — Bootstrap Installer (one-liner) +# Tecnotel Servizi SRL — Ubuntu 24.04 / 26.04 LTS # -# Uso su un server Ubuntu LTS pulito: +# Uso tramite one-liner: +# curl -fsSL https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh | sudo bash # -# curl -fsSL -H "Authorization: token " \ -# https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh \ -# | sudo bash -s -- --token +# Oppure manuale (raccomandato per verifica): +# curl -fsSLo /tmp/iride-bootstrap.sh https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh +# less /tmp/iride-bootstrap.sh +# sudo bash /tmp/iride-bootstrap.sh # -# = token Gitea di SOLA LETTURA dedicato all'istanza (mai personale). -# Opzioni: --gitea-url URL (default https://repo.argosdefense.io) -# --org ORG (default tecnotel) --branch BRANCH (default main) -# --setup-branch BRANCH (default main) -# --client NOME --domain FQDN --admin-password PWD (non interattivo) -# Riesecuzione sicura: aggiorna iride-setup e rilancia install.sh. +# Stesso flusso di argos-setup: scarica questo repository (pubblico, nessun +# segreto), esegue first-setup.sh (sistema, utente, firewall, nginx temporaneo) +# e avvia il Web Installer sulla porta 8888, che chiede la licenza IRIDE e +# clona il repository privato tecnotel/iride con il token della licenza. # ══════════════════════════════════════════════════════════════════════════════ set -euo pipefail -GITEA_URL="https://repo.argosdefense.io" -ORG="tecnotel" -BRANCH="main" -SETUP_BRANCH="main" -TOKEN="" -PASSTHROUGH=() +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } -while [[ $# -gt 0 ]]; do - case "$1" in - --token) TOKEN="$2"; shift 2 ;; - --gitea-url) GITEA_URL="$2"; shift 2 ;; - --org) ORG="$2"; shift 2 ;; - --branch) BRANCH="$2"; shift 2 ;; - --setup-branch) SETUP_BRANCH="$2"; shift 2 ;; - --client|--domain|--admin-password|--admin-user) PASSTHROUGH+=("$1" "$2"); shift 2 ;; - *) echo "Opzione sconosciuta: $1"; exit 1 ;; - esac +# ── Check privilegi e OS ────────────────────────────────────────────────────── +[[ $EUID -ne 0 ]] && error "Eseguire come root (sudo)." +[[ -f /etc/os-release ]] || error "OS non riconosciuto: /etc/os-release mancante." +. /etc/os-release +SUPPORTED_VERSIONS=("24.04" "26.04") +VERSION_OK=0 +for v in "${SUPPORTED_VERSIONS[@]}"; do + [[ "$VERSION_ID" == "$v" ]] && VERSION_OK=1 +done +if [[ "$ID" != "ubuntu" || $VERSION_OK -eq 0 ]]; then + error "Richiesto Ubuntu 24.04 o 26.04 LTS (trovato: $ID $VERSION_ID)." +fi +info "Sistema rilevato: Ubuntu $VERSION_ID LTS" + +echo "" +echo -e "${BLUE}╔══════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ IRIDE — Bootstrap Installer ║${NC}" +echo -e "${BLUE}║ Tecnotel Servizi SRL ║${NC}" +echo -e "${BLUE}╚══════════════════════════════════════════════════╝${NC}" +echo "" + +SETUP_REPO_URL="https://repo.argosdefense.io/tecnotel/iride-setup.git" +SETUP_DIR="/opt/iride-setup-pkg" + +# ── Install git se mancante ────────────────────────────────────────────────── +if ! command -v git >/dev/null 2>&1; then + info "Git non installato — installo..." + apt-get update -qq + apt-get install -y -qq git + success "Git installato" +else + info "Git già presente ($(git --version | awk '{print $3}'))" +fi + +# ── Scarica o aggiorna iride-setup ─────────────────────────────────────────── +if [[ -d "$SETUP_DIR/.git" ]]; then + info "iride-setup già presente in $SETUP_DIR — aggiorno..." + git -C "$SETUP_DIR" pull --ff-only origin main + success "iride-setup aggiornato" +else + info "Scarico iride-setup da $SETUP_REPO_URL..." + rm -rf "$SETUP_DIR" + git clone --depth=1 "$SETUP_REPO_URL" "$SETUP_DIR" + success "iride-setup scaricato in $SETUP_DIR" +fi + +for f in first-setup.sh setup_server.py setup.html; do + [[ -f "$SETUP_DIR/$f" ]] || error "File $f mancante in $SETUP_DIR — repo iride-setup incompleto?" done -[[ $EUID -eq 0 ]] || { echo "Eseguire con sudo"; exit 1; } -[[ -n "$TOKEN" ]] || { echo "Serve --token "; exit 1; } - -GITEA_HOST="${GITEA_URL#https://}"; GITEA_HOST="${GITEA_HOST#http://}"; GITEA_HOST="${GITEA_HOST%%/*}" -SETUP_URL="$GITEA_URL/$ORG/iride-setup.git" -APP_URL="$GITEA_URL/$ORG/iride.git" - -echo "IRIDE bootstrap — $GITEA_URL/$ORG (app: $BRANCH, setup: $SETUP_BRANCH)" -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y -qq git curl ca-certificates >/dev/null - -if ! id iride >/dev/null 2>&1; then - useradd --system --home-dir /opt/iride --shell /usr/sbin/nologin iride -fi -mkdir -p /opt/iride/config /opt/iride/setup -chown iride:iride /opt/iride /opt/iride/config /opt/iride/setup -chmod 700 /opt/iride/config - -CRED="/opt/iride/config/git-credentials" -printf 'https://oauth2:%s@%s\n' "$TOKEN" "$GITEA_HOST" > "$CRED" -chown iride:iride "$CRED"; chmod 600 "$CRED" -sudo -u iride -H git config --global credential.helper "store --file=$CRED" -sudo -u iride -H git config --global safe.directory /opt/iride/setup -sudo -u iride -H git config --global safe.directory /opt/iride/app - -if [[ -d /opt/iride/setup/.git ]]; then - sudo -u iride -H git -C /opt/iride/setup fetch --quiet origin "$SETUP_BRANCH" - sudo -u iride -H git -C /opt/iride/setup checkout --quiet "$SETUP_BRANCH" - sudo -u iride -H git -C /opt/iride/setup merge --ff-only --quiet "origin/$SETUP_BRANCH" -else - sudo -u iride -H git clone --quiet --branch "$SETUP_BRANCH" "$SETUP_URL" /opt/iride/setup -fi - -exec bash /opt/iride/setup/install.sh --app-url "$APP_URL" --branch "$BRANCH" --gitea-host "$GITEA_HOST" "${PASSTHROUGH[@]}" +info "Avvio installer ambiente..." +echo "" +cd "$SETUP_DIR" +exec bash ./first-setup.sh "$@" diff --git a/checks/health.sh b/checks/health.sh index 4c40995..7b63509 100755 --- a/checks/health.sh +++ b/checks/health.sh @@ -1,6 +1,6 @@ #!/bin/bash # IRIDE — verifica post-install / post-update (Tecnotel Servizi SRL) -# Uso: bash /opt/iride/setup/checks/health.sh (exit 1 se qualcosa non va) +# Uso: iride-health (copiato in /usr/local/bin dal web installer; exit 1 se qualcosa non va) set -uo pipefail RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' ok=0; ko=0 diff --git a/dev-setup.sh b/dev-setup.sh new file mode 100755 index 0000000..119a677 --- /dev/null +++ b/dev-setup.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# IRIDE — dev-setup.sh: Gitea Runner (CI) sul server di sviluppo +# Tecnotel Servizi SRL +# +# SOLO sul server di sviluppo Tecnotel (mai su un server cliente). Installa il +# Gitea Runner ufficiale (ex act_runner) in modalità HOST: i job girano sul +# server con il Python e il Node già presenti, senza Docker. +# +# Uso: sudo bash /opt/iride-setup-pkg/dev-setup.sh --token +# [--instance https://repo.argosdefense.io] [--name iride-dev] +# [--labels "ubuntu-latest:host,ubuntu-26.04:host"] +# +# Il token di registrazione si crea su Gitea: Impostazioni utente (tecnotel) → +# Actions → Runners → "Crea nuovo runner". Vale una volta sola. +# ══════════════════════════════════════════════════════════════════════════════ +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } + +INSTANCE="https://repo.argosdefense.io" +TOKEN="" +NAME="$(hostname -s)" +LABELS="ubuntu-latest:host,ubuntu-26.04:host,ubuntu-24.04:host" +RUNNER_USER="gitea-runner" +RUNNER_HOME="/opt/gitea-runner" +BIN="/usr/local/bin/gitea-runner" + +while [[ $# -gt 0 ]]; do + case "$1" in + --token) TOKEN="$2"; shift 2 ;; + --instance) INSTANCE="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --labels) LABELS="$2"; shift 2 ;; + *) error "Opzione sconosciuta: $1" ;; + esac +done +[[ $EUID -eq 0 ]] || error "Eseguire con sudo" +[[ -n "$TOKEN" ]] || error "Serve --token (Gitea → Impostazioni → Actions → Runners)" +command -v node >/dev/null || error "Node.js assente: eseguire prima first-setup.sh (serve alle azioni JavaScript in modalità host)" +command -v git >/dev/null || error "git assente" + +# ── Utente dedicato ────────────────────────────────────────────────────────── +if ! id "$RUNNER_USER" &>/dev/null; then + useradd -r -s /bin/bash -m -d "$RUNNER_HOME" "$RUNNER_USER" + success "Utente $RUNNER_USER creato" +fi +mkdir -p "$RUNNER_HOME" +chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" + +# ── Download dell'ultima release (gitea/runner, ex act_runner) ─────────────── +info "Cerco l'ultima release di gitea/runner..." +ARCH="$(dpkg --print-architecture)" # amd64 | arm64 +ASSET_URL="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/runner/releases/latest | python3 -c " +import json, sys +rel = json.load(sys.stdin) +arch = sys.argv[1] +for a in rel.get('assets', []): + n = a.get('name', '') + if f'linux-{arch}' in n and not n.endswith(('.sha256', '.asc', '.sig', '.txt')): + print(a['browser_download_url']); break +print('', end='') +" "$ARCH")" || ASSET_URL="" +if [[ -z "$ASSET_URL" ]]; then + error "Nessun asset linux-$ARCH nell'ultima release di https://gitea.com/gitea/runner/releases: scaricare il binario a mano in $BIN e rilanciare" +fi +info "Asset: $ASSET_URL" +TMP="$(mktemp -d)" +curl -fsSL -o "$TMP/runner.bin" "$ASSET_URL" +if file "$TMP/runner.bin" | grep -qi "xz compressed"; then + xz -d -c "$TMP/runner.bin" > "$TMP/runner" +else + mv "$TMP/runner.bin" "$TMP/runner" +fi +install -m 0755 "$TMP/runner" "$BIN" +rm -rf "$TMP" +success "Runner installato: $($BIN --version 2>/dev/null | head -1 || echo "$BIN")" + +# ── Registrazione (il token vale una volta) ────────────────────────────────── +if [[ -f "$RUNNER_HOME/.runner" ]]; then + warn "Runner già registrato ($RUNNER_HOME/.runner presente): salto la registrazione" +else + info "Registro il runner '$NAME' su $INSTANCE con label $LABELS" + sudo -u "$RUNNER_USER" -H bash -c "cd '$RUNNER_HOME' && '$BIN' register --no-interactive --instance '$INSTANCE' --token '$TOKEN' --name '$NAME' --labels '$LABELS'" \ + || error "Registrazione fallita: verificare token, URL dell'istanza e che Actions sia abilitato su Gitea (Site Administration → Actions)" + success "Runner registrato" +fi +if [[ ! -f "$RUNNER_HOME/config.yaml" ]]; then + # gitea-runner >= 1.0: "config init" scrive una config minima (act_runner usava generate-config) + sudo -u "$RUNNER_USER" -H bash -c "cd '$RUNNER_HOME' && ('$BIN' config init >/dev/null 2>&1 || '$BIN' generate-config > config.yaml)" \ + && success "config.yaml scritto in $RUNNER_HOME" || warn "config non generata: il runner userà i default" +fi + +# ── Servizio systemd ───────────────────────────────────────────────────────── +CONFIG_ARG="" +[[ -f "$RUNNER_HOME/config.yaml" ]] && CONFIG_ARG=" --config $RUNNER_HOME/config.yaml" +cat > /etc/systemd/system/gitea-runner.service << UNITEOF +[Unit] +Description=Gitea Runner (IRIDE CI, modalità host) +After=network.target + +[Service] +Type=simple +User=$RUNNER_USER +Group=$RUNNER_USER +WorkingDirectory=$RUNNER_HOME +ExecStart=$BIN daemon$CONFIG_ARG +Restart=always +RestartSec=5 +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +StandardOutput=journal +StandardError=journal +SyslogIdentifier=gitea-runner + +[Install] +WantedBy=multi-user.target +UNITEOF +systemctl daemon-reload +systemctl enable --now gitea-runner +sleep 2 +if systemctl is-active --quiet gitea-runner; then + success "gitea-runner attivo: journalctl -u gitea-runner -f per i job" +else + error "gitea-runner non parte: journalctl -u gitea-runner -n 50" +fi +echo "" +echo " Verifica su Gitea: Impostazioni → Actions → Runners deve mostrare '$NAME' online." +echo " Il workflow .gitea/workflows/ci.yml del repo iride usa runs-on: ubuntu-latest → label ubuntu-latest:host." diff --git a/first-setup.sh b/first-setup.sh new file mode 100755 index 0000000..499c78c --- /dev/null +++ b/first-setup.sh @@ -0,0 +1,196 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# IRIDE — Installer ambiente base +# Tecnotel Servizi SRL — Ubuntu 24.04 / 26.04 LTS +# Uso: sudo bash first-setup.sh (lanciato da bootstrap.sh) +# +# Stesso hardening di argos-setup: aggiornamento del sistema, UFW con default +# deny, fail2ban, utente di servizio senza privilegi con sudoers a scope +# ristretto, nginx temporaneo, Web Installer su 8888 chiuso a fine wizard. +# ══════════════════════════════════════════════════════════════════════════════ +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } +section() { echo -e "\n${BLUE}══════════════════════════════════════${NC}"; echo -e "${BLUE} $1${NC}"; echo -e "${BLUE}══════════════════════════════════════${NC}"; } + +[[ $EUID -ne 0 ]] && error "Eseguire come root: sudo bash first-setup.sh" +. /etc/os-release +SUPPORTED_VERSIONS=("24.04" "26.04") +VERSION_OK=0 +for v in "${SUPPORTED_VERSIONS[@]}"; do + [[ "$VERSION_ID" == "$v" ]] && VERSION_OK=1 +done +[[ "$ID" != "ubuntu" || $VERSION_OK -eq 0 ]] && \ + error "Richiesto Ubuntu 24.04 o 26.04 LTS (rilevato: $ID $VERSION_ID)" +info "Sistema rilevato: Ubuntu $VERSION_ID LTS" + +SETUP_PKG="/opt/iride-setup-pkg" +APP_USER="iride" + +echo "" +echo -e "${BLUE}╔══════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ IRIDE — Setup ambiente base ║${NC}" +echo -e "${BLUE}║ Tecnotel Servizi SRL ║${NC}" +echo -e "${BLUE}╚══════════════════════════════════════════╝${NC}" +echo "" + +# ══════════════════════════════════════════════════════════════════════════════ +section "1. Sistema base" +# ══════════════════════════════════════════════════════════════════════════════ +timedatectl set-timezone Europe/Rome +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get upgrade -y -qq +apt-get install -y -qq \ + curl wget git vim htop unzip jq \ + python3 python3-pip python3-venv python3-cryptography python3-dev \ + nginx certbot python3-certbot-nginx \ + ufw fail2ban \ + build-essential libssl-dev libffi-dev \ + sqlite3 net-tools dnsutils lsof \ + ca-certificates gnupg apt-transport-https openssl +success "Pacchetti sistema installati" + +PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' \ + || error "Python >= 3.12 richiesto (trovato $PY_VER)" +success "Python $PY_VER" + +# ══════════════════════════════════════════════════════════════════════════════ +section "2. Node.js 22 LTS" +# ══════════════════════════════════════════════════════════════════════════════ +# Serve alla build del frontend (solo sul server) e alle azioni JavaScript del +# Gitea Runner in modalità host (dev-setup.sh). +if ! node --version 2>/dev/null | grep -q "v2[2-9]"; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1 + apt-get install -y -qq nodejs +fi +success "Node.js $(node --version) installato" + +# ══════════════════════════════════════════════════════════════════════════════ +section "3. Utente applicazione" +# ══════════════════════════════════════════════════════════════════════════════ +if ! id "$APP_USER" &>/dev/null; then + useradd -r -s /bin/bash -m -d /home/$APP_USER $APP_USER + success "Utente $APP_USER creato" +else + warn "Utente $APP_USER già esistente" +fi +usermod -aG systemd-journal $APP_USER +success "Utente $APP_USER nel gruppo systemd-journal (lettura log dal portale)" + +# Restart dei soli demoni IRIDE dal portale (nessun altro comando) +cat > /etc/sudoers.d/iride-systemctl <<'SUDOEOF' +iride ALL=(ALL) NOPASSWD: /bin/systemctl restart iride-api +iride ALL=(ALL) NOPASSWD: /bin/systemctl restart iride-worker +iride ALL=(ALL) NOPASSWD: /bin/systemctl restart iride-scheduler +iride ALL=(ALL) NOPASSWD: /bin/systemctl restart iride-voice +SUDOEOF +chmod 440 /etc/sudoers.d/iride-systemctl +visudo -cf /etc/sudoers.d/iride-systemctl > /dev/null +success "Sudoers per restart servizi configurato" + +# update.sh dal portale: SOLO quello script, path esatto (symlink /usr/bin/bash e /bin/bash) +cat > /etc/sudoers.d/iride-update <<'SUDOEOF' +iride ALL=(root) NOPASSWD: /usr/bin/bash /opt/iride/app/scripts/update.sh +iride ALL=(root) NOPASSWD: /bin/bash /opt/iride/app/scripts/update.sh +SUDOEOF +chmod 440 /etc/sudoers.d/iride-update +visudo -cf /etc/sudoers.d/iride-update > /dev/null +success "Sudoers per update.sh dal portale configurato" + +# ══════════════════════════════════════════════════════════════════════════════ +section "4. Struttura cartelle" +# ══════════════════════════════════════════════════════════════════════════════ +mkdir -p /opt/iride/{app,config,data,logs,certs,backups} +mkdir -p /opt/iride/config/assets +chown -R $APP_USER:$APP_USER /opt/iride +chown -R $APP_USER:$APP_USER "$SETUP_PKG" +chmod -R 750 /opt/iride +chmod 700 /opt/iride/config +success "Struttura /opt/iride/ creata" + +# ══════════════════════════════════════════════════════════════════════════════ +section "5. Firewall UFW" +# ══════════════════════════════════════════════════════════════════════════════ +ufw --force reset >/dev/null +ufw default deny incoming >/dev/null +ufw default allow outgoing >/dev/null +ufw allow 22/tcp comment 'SSH' >/dev/null +ufw allow 80/tcp comment 'HTTP' >/dev/null +ufw allow 443/tcp comment 'HTTPS' >/dev/null +ufw allow 8888/tcp comment 'IRIDE Web Installer (temporaneo)' >/dev/null +ufw --force enable >/dev/null +success "Firewall UFW configurato (22, 80, 443, 8888 temporanea)" + +# ══════════════════════════════════════════════════════════════════════════════ +section "6. Fail2ban" +# ══════════════════════════════════════════════════════════════════════════════ +systemctl enable --now fail2ban >/dev/null 2>&1 +success "Fail2ban attivo" + +# ══════════════════════════════════════════════════════════════════════════════ +section "7. Nginx temporaneo" +# ══════════════════════════════════════════════════════════════════════════════ +rm -f /etc/nginx/sites-enabled/default +cat > /etc/nginx/sites-available/iride-setup << 'NGINX' +server { + listen 80 default_server; + server_name _; + return 200 'IRIDE Setup in corso — vai a http://IP:8888'; + add_header Content-Type text/plain; +} +NGINX +ln -sf /etc/nginx/sites-available/iride-setup /etc/nginx/sites-enabled/ +nginx -t && systemctl restart nginx +success "Nginx temporaneo configurato" + +# ══════════════════════════════════════════════════════════════════════════════ +section "8. Web Installer" +# ══════════════════════════════════════════════════════════════════════════════ +# Gira da /opt/iride-setup-pkg (clone del bootstrap); a fine install la +# cartella viene rimossa e la porta 8888 chiusa. +cat > /etc/systemd/system/iride-setup.service << 'UNITEOF' +[Unit] +Description=IRIDE Web Installer +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/iride-setup-pkg +ExecStart=/usr/bin/python3 /opt/iride-setup-pkg/setup_server.py +Restart=on-failure +RestartSec=3 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=iride-setup + +[Install] +WantedBy=multi-user.target + +UNITEOF + +systemctl daemon-reload +systemctl enable --now iride-setup +success "Web installer avviato" + +SERVER_IP=$(hostname -I | awk '{print $1}') + +echo "" +echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ Ambiente pronto! ║${NC}" +echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e " ${CYAN}Completa la configurazione aprendo nel browser:${NC}" +echo -e " ${YELLOW}→ http://${SERVER_IP}:8888${NC}" +echo "" +echo -e " ${YELLOW}NOTA:${NC} La porta 8888 verrà chiusa automaticamente" +echo -e " al termine dell'installazione." +echo -e " Server di sviluppo con runner CI: sudo bash ${SETUP_PKG}/dev-setup.sh --token " +echo "" diff --git a/first_setup.py b/first_setup.py deleted file mode 100755 index 6a62e8c..0000000 --- a/first_setup.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -""" -IRIDE — first_setup.py: wizard di prima configurazione (CLI) -Tecnotel Servizi SRL - -Eseguito da install.sh come utente iride con il venv dell'app e -PYTHONPATH=/opt/iride/app/backend. Scrive: - - $IRIDE_CONFIG_DIR/iride.json (cliente, dominio, chiavi di sistema generate) - - $IRIDE_CONFIG_DIR/users.json (utente admin con hash bcrypt) - -Non interattivo con IRIDE_CLIENT_NAME, IRIDE_DOMAIN, IRIDE_ADMIN_USER, -IRIDE_ADMIN_PASSWORD nell'ambiente. Rieseguibile: non sovrascrive chiavi e -utenti già reali senza conferma. Il wizard web (porta 8888, stile ARGOS) -arriva dopo il pilota. -""" -from __future__ import annotations - -import getpass -import json -import os -import re -import secrets -import sys -from pathlib import Path - -CONFIG_DIR = Path(os.environ.get("IRIDE_CONFIG_DIR", "/opt/iride/config")) -PLACEHOLDER = "GENERATO-DA-first_setup" -INTERACTIVE = sys.stdin.isatty() - - -def ask(label: str, default: str = "", env: str = "", secret: bool = False, required: bool = False) -> str: - value = os.environ.get(env, "") if env else "" - if value: - return value - if not INTERACTIVE: - if required and not default: - sys.exit(f"Manca {env or label} (esecuzione non interattiva)") - return default - prompt = f"{label}" + (f" [{default}]" if default and not secret else "") + ": " - while True: - value = getpass.getpass(prompt) if secret else input(prompt) - value = value.strip() or default - if value or not required: - return value - print(" valore obbligatorio") - - -def slugify(s: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "cliente" - - -def load(path: Path) -> dict: - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - return {} - - -def save(path: Path, data: dict) -> None: - tmp = path.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - os.chmod(tmp, 0o600) - tmp.replace(path) - - -def main() -> None: - print("\n── IRIDE: prima configurazione ──") - iride = load(CONFIG_DIR / "iride.json") - users = load(CONFIG_DIR / "users.json") - cliente = iride.setdefault("cliente", {}) - system = iride.setdefault("system", {}) - - already = system.get("secret_key") and system.get("secret_key") != PLACEHOLDER - if already and INTERACTIVE: - keep = ask("Configurazione già presente: mantenerla? (s/n)", "s") - if keep.lower().startswith("s"): - print("Configurazione mantenuta.") - return - - full_name = ask("Nome del cliente", cliente.get("full_name") or "", "IRIDE_CLIENT_NAME", required=True) - cliente["full_name"] = full_name - cliente["name"] = slugify(cliente.get("name") if cliente.get("name") not in ("", "cliente") else full_name) - cliente["domain"] = ask("Dominio pubblico dell'istanza (FQDN)", cliente.get("domain") or "", "IRIDE_DOMAIN") - cliente.setdefault("type", "customer_service") - cliente.setdefault("ai_context", "") - system.setdefault("timezone", "Europe/Rome") - if not already: - system["secret_key"] = secrets.token_urlsafe(48) - system["internal_api_key"] = secrets.token_urlsafe(32) - ai = iride.setdefault("ai", {}) - text = ai.setdefault("text", {"provider": "fake", "options": {}}) - provider = ask("Provider AI per il testo (fake/openai)", text.get("provider", "fake"), "IRIDE_AI_PROVIDER") - text["provider"] = provider if provider in ("fake", "openai", "anthropic") else "fake" - if text["provider"] != "fake": - providers = ai.setdefault("providers", {}) - key = ask(f"API key {text['provider']} (invio per lasciarla vuota)", "", "IRIDE_AI_API_KEY", secret=True) - if key: - providers.setdefault(text["provider"], {})["api_key"] = key - save(CONFIG_DIR / "iride.json", iride) - print(f" iride.json scritto (cliente: {cliente['name']}, dominio: {cliente['domain'] or '-'})") - - from core.auth import hash_password # dal backend dell'app (PYTHONPATH) - admin_user = ask("Utente amministratore", "admin", "IRIDE_ADMIN_USER") - existing = (users.get("users") or {}).get(admin_user, {}) - if existing.get("password_hash", "").startswith("$2") and PLACEHOLDER not in existing.get("password_hash", ""): - print(f" utente {admin_user} già presente: password invariata") - else: - while True: - pwd = ask(f"Password di {admin_user} (min 12 caratteri)", "", "IRIDE_ADMIN_PASSWORD", secret=True, required=True) - if len(pwd) < 12: - print(" troppo corta") - if not INTERACTIVE: - sys.exit("password troppo corta") - continue - if INTERACTIVE and not os.environ.get("IRIDE_ADMIN_PASSWORD"): - if getpass.getpass("Conferma password: ") != pwd: - print(" non coincidono") - continue - break - users.setdefault("users", {})[admin_user] = { - "password_hash": hash_password(pwd), "roles": ["admin"], "totp_secret": "", "enabled": True} - save(CONFIG_DIR / "users.json", users) - print(f" users.json scritto (admin: {admin_user})") - print("── configurazione completata ──\n") - - -if __name__ == "__main__": - main() diff --git a/install.sh b/install.sh deleted file mode 100755 index 711ae44..0000000 --- a/install.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/bash -# ══════════════════════════════════════════════════════════════════════════════ -# IRIDE — install.sh: primo install di un'istanza (Tecnotel Servizi SRL) -# Lanciato da bootstrap.sh; rieseguibile (idempotente sui passi già fatti). -# -# sudo bash /opt/iride/setup/install.sh --app-url URL --branch main --gitea-host host -# [--client NOME] [--domain FQDN] [--admin-user admin] [--admin-password PWD] -# -# Passi: repo app → venv → config da esempi → wizard first_setup.py → migrazioni -# → build frontend (se presente) → systemd → nginx + certificato → permessi -# → avvio → health check. Gli aggiornamenti successivi: scripts/update.sh. -# ══════════════════════════════════════════════════════════════════════════════ -set -euo pipefail -SETUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -source "$SETUP_DIR/lib/common.sh" - -APP_URL=""; BRANCH="main"; GITEA_HOST="" -CLIENT=""; DOMAIN=""; ADMIN_USER="admin"; ADMIN_PASSWORD="" -while [[ $# -gt 0 ]]; do - case "$1" in - --app-url) APP_URL="$2"; shift 2 ;; - --branch) BRANCH="$2"; shift 2 ;; - --gitea-host) GITEA_HOST="$2"; shift 2 ;; - --client) CLIENT="$2"; shift 2 ;; - --domain) DOMAIN="$2"; shift 2 ;; - --admin-user) ADMIN_USER="$2"; shift 2 ;; - --admin-password) ADMIN_PASSWORD="$2"; shift 2 ;; - *) error "Opzione sconosciuta: $1" ;; - esac -done -require_root -[[ -n "$APP_URL" ]] || APP_URL="https://${GITEA_HOST:-repo.argosdefense.io}/tecnotel/iride.git" - -section "1. Sistema" -detect_os -ensure_packages -ensure_user_and_dirs - -section "2. Repository applicativo ($BRANCH)" -clone_or_update "$APP_URL" "$IRIDE_APP" "$BRANCH" -APP_VERSION="$(cat "$IRIDE_APP/VERSION" 2>/dev/null || echo '?')" -info "IRIDE $APP_VERSION" - -section "3. Virtualenv e dipendenze" -if [[ ! -x "$IRIDE_VENV/bin/python" ]]; then - as_iride python3 -m venv "$IRIDE_VENV" -fi -as_iride "$IRIDE_VENV/bin/pip" install --quiet --upgrade pip -as_iride "$IRIDE_VENV/bin/pip" install --quiet -r "$IRIDE_APP/backend/requirements.txt" -success "Dipendenze installate in $IRIDE_VENV" - -section "4. Configurazione" -for example in "$IRIDE_APP"/config/*.example; do - target="$IRIDE_CONFIG/$(basename "${example%.example}")" - if [[ ! -f "$target" ]]; then - cp "$example" "$target" - info "Creato $target da esempio" - fi -done -chown "$IRIDE_USER:$IRIDE_USER" "$IRIDE_CONFIG"/*.json -chmod 600 "$IRIDE_CONFIG"/*.json -export IRIDE_CONFIG_DIR="$IRIDE_CONFIG" IRIDE_DATA_DIR="$IRIDE_DATA" IRIDE_LOGS_DIR="$IRIDE_LOGS" IRIDE_DB="$IRIDE_DATA/iride.db" -export IRIDE_CLIENT_NAME="$CLIENT" IRIDE_DOMAIN="$DOMAIN" IRIDE_ADMIN_USER="$ADMIN_USER" IRIDE_ADMIN_PASSWORD="$ADMIN_PASSWORD" -sudo -u "$IRIDE_USER" -H --preserve-env=IRIDE_CONFIG_DIR,IRIDE_DATA_DIR,IRIDE_LOGS_DIR,IRIDE_DB,IRIDE_CLIENT_NAME,IRIDE_DOMAIN,IRIDE_ADMIN_USER,IRIDE_ADMIN_PASSWORD \ - env PYTHONPATH="$IRIDE_APP/backend" "$IRIDE_VENV/bin/python" "$SETUP_DIR/first_setup.py" -SERVER_NAME="$("$IRIDE_VENV/bin/python" -c "import json; print(json.load(open('$IRIDE_CONFIG/iride.json'))['cliente'].get('domain') or '')")" -[[ -n "$SERVER_NAME" ]] || SERVER_NAME="$(hostname -f 2>/dev/null || hostname)" - -section "5. Migrazioni DB" -cd "$IRIDE_APP/backend" -sudo -u "$IRIDE_USER" -H --preserve-env=IRIDE_CONFIG_DIR,IRIDE_DATA_DIR,IRIDE_LOGS_DIR,IRIDE_DB "$IRIDE_VENV/bin/python" db.py -success "Schema allineato" - -section "6. Frontend" -if [[ -f "$IRIDE_APP/frontend/package.json" ]]; then - command -v npm >/dev/null || error "npm assente: installare Node LTS (NodeSource) e rilanciare" - cd "$IRIDE_APP/frontend" - as_iride npm ci --silent - as_iride npm run build - success "Frontend compilato" -else - warn "Nessun frontend/package.json: nginx servirà solo l'API (B-067)" -fi - -section "7. systemd" -for svc in "${IRIDE_SERVICES[@]}"; do - cp "$IRIDE_APP/deploy/systemd/$svc.service" "/etc/systemd/system/$svc.service" -done -systemctl daemon-reload -for svc in "${IRIDE_SERVICES[@]}"; do systemctl enable --quiet "$svc"; done -cp "$IRIDE_APP/deploy/logrotate/iride" /etc/logrotate.d/iride -cp "$IRIDE_APP/deploy/sudoers/iride-systemctl" /etc/sudoers.d/iride-systemctl -chmod 440 /etc/sudoers.d/iride-systemctl -visudo -cf /etc/sudoers.d/iride-systemctl >/dev/null || error "sudoers non valido" -success "Unit installate e abilitate: ${IRIDE_SERVICES[*]}" - -section "8. nginx e certificato ($SERVER_NAME)" -if [[ ! -f "$IRIDE_CERTS/fullchain.pem" ]]; then - openssl req -x509 -nodes -newkey rsa:2048 -days 825 -subj "/CN=$SERVER_NAME" \ - -keyout "$IRIDE_CERTS/privkey.pem" -out "$IRIDE_CERTS/fullchain.pem" >/dev/null 2>&1 - chown "$IRIDE_USER:$IRIDE_USER" "$IRIDE_CERTS"/*.pem; chmod 600 "$IRIDE_CERTS/privkey.pem" - warn "Certificato self-signed generato: per WhatsApp serve un certificato valido in $IRIDE_CERTS" -fi -sed "s/IRIDE_SERVER_NAME/$SERVER_NAME/g" "$IRIDE_APP/deploy/nginx/iride.conf" > /etc/nginx/sites-available/iride -ln -sf /etc/nginx/sites-available/iride /etc/nginx/sites-enabled/iride -rm -f /etc/nginx/sites-enabled/default -nginx -t >/dev/null 2>&1 || error "Configurazione nginx non valida: nginx -t" -systemctl enable --quiet nginx -systemctl reload nginx || systemctl restart nginx -success "nginx configurato" - -section "9. Permessi" -chown -R "$IRIDE_USER:$IRIDE_USER" "$IRIDE_ROOT" -chmod 700 "$IRIDE_CONFIG" -chmod 600 "$IRIDE_CONFIG"/*.json "$IRIDE_CONFIG/git-credentials" 2>/dev/null || true -success "Proprietario $IRIDE_USER, config 0600" - -section "10. Avvio e verifica" -for svc in "${IRIDE_SERVICES[@]}"; do systemctl restart "$svc"; done -sleep 2 -bash "$SETUP_DIR/checks/health.sh" || error "Health check fallito: vedere $IRIDE_LOGS" - -echo -success "IRIDE $APP_VERSION installato" -echo " Portale: https://$SERVER_NAME/ (API: /api/health, /api/docs)" -echo " Utente admin: $ADMIN_USER" -echo " Prossimi passi: canali in $IRIDE_CONFIG/channels.json e credenziali in credentials.json," -echo " poi: sudo systemctl restart iride-api" -echo " Aggiornamenti: sudo bash $IRIDE_APP/scripts/update.sh" diff --git a/lib/common.sh b/lib/common.sh deleted file mode 100644 index 34f36e6..0000000 --- a/lib/common.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# IRIDE setup — funzioni comuni (sourced da bootstrap.sh e install.sh) -# Tecnotel Servizi SRL - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' -info() { echo -e "${CYAN}[INFO]${NC} $1"; } -success() { echo -e "${GREEN}[OK]${NC} $1"; } -warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } -section() { echo -e "\n${BLUE}══════════════════════════════════════${NC}"; echo -e "${BLUE} $1${NC}"; echo -e "${BLUE}══════════════════════════════════════${NC}"; } - -IRIDE_ROOT="/opt/iride" -IRIDE_APP="$IRIDE_ROOT/app" -IRIDE_CONFIG="$IRIDE_ROOT/config" -IRIDE_DATA="$IRIDE_ROOT/data" -IRIDE_LOGS="$IRIDE_ROOT/logs" -IRIDE_BACKUPS="$IRIDE_ROOT/backups" -IRIDE_CERTS="$IRIDE_ROOT/certs" -IRIDE_SETUP="$IRIDE_ROOT/setup" -IRIDE_USER="iride" -IRIDE_VENV="$IRIDE_APP/backend/venv" -IRIDE_SERVICES=(iride-api iride-worker iride-scheduler) - -require_root() { [[ $EUID -eq 0 ]] || error "Eseguire con sudo"; } - -detect_os() { - if [[ -f /etc/os-release ]]; then - . /etc/os-release - case "${ID:-}:${VERSION_ID:-}" in - ubuntu:22.04|ubuntu:24.04) success "Sistema: $PRETTY_NAME" ;; - ubuntu:*) warn "Ubuntu ${VERSION_ID} non collaudato (riferimento: 22.04 / 24.04)" ;; - *) warn "Distribuzione ${ID:-?} non collaudata: il riferimento è Ubuntu Server LTS" ;; - esac - fi -} - -as_iride() { sudo -u "$IRIDE_USER" -H "$@"; } - -ensure_packages() { - export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq git curl ca-certificates openssl python3 python3-venv python3-pip nginx sqlite3 >/dev/null - success "Pacchetti di base installati" -} - -ensure_user_and_dirs() { - if ! id "$IRIDE_USER" >/dev/null 2>&1; then - useradd --system --home-dir "$IRIDE_ROOT" --shell /usr/sbin/nologin "$IRIDE_USER" - success "Utente di servizio $IRIDE_USER creato" - fi - mkdir -p "$IRIDE_APP" "$IRIDE_CONFIG" "$IRIDE_DATA" "$IRIDE_LOGS" "$IRIDE_BACKUPS" "$IRIDE_CERTS" "$IRIDE_SETUP" - chown "$IRIDE_USER:$IRIDE_USER" "$IRIDE_ROOT" "$IRIDE_APP" "$IRIDE_CONFIG" "$IRIDE_DATA" "$IRIDE_LOGS" "$IRIDE_BACKUPS" "$IRIDE_CERTS" "$IRIDE_SETUP" - chmod 750 "$IRIDE_ROOT" - chmod 700 "$IRIDE_CONFIG" -} - -# Credenziali git di sola lettura per le istanze: file 0600 dell'utente iride, -# usato dal credential helper "store". Mai token personali. -configure_git_credentials() { - local gitea_host="$1" token="$2" - local cred="$IRIDE_CONFIG/git-credentials" - printf 'https://oauth2:%s@%s\n' "$token" "$gitea_host" > "$cred" - chown "$IRIDE_USER:$IRIDE_USER" "$cred"; chmod 600 "$cred" - as_iride git config --global credential.helper "store --file=$cred" - as_iride git config --global safe.directory "$IRIDE_APP" - as_iride git config --global safe.directory "$IRIDE_SETUP" - success "Credenziali git salvate in $cred (0600)" -} - -clone_or_update() { - local url="$1" dest="$2" branch="$3" - if [[ -d "$dest/.git" ]]; then - as_iride git -C "$dest" fetch --quiet origin "$branch" - as_iride git -C "$dest" checkout --quiet "$branch" - as_iride git -C "$dest" merge --ff-only --quiet "origin/$branch" - success "Aggiornato $dest ($branch)" - else - as_iride git clone --quiet --branch "$branch" "$url" "$dest" - success "Clonato $url in $dest" - fi -} diff --git a/setup.html b/setup.html new file mode 100644 index 0000000..3f84c37 --- /dev/null +++ b/setup.html @@ -0,0 +1,499 @@ + + + + + +IRIDE — Installazione + + + + +
+
+ +
+
Setup Wizard — Tecnotel Servizi SRL
+
v0.2.0
+
+
+ +
+
+
+
+
+
+
+
+
+ + +
+
🔑 Licenza IRIDE
+
IRIDE richiede una licenza valida emessa da Tecnotel Servizi SRL, vincolata + all'identificativo hardware di questo server. La licenza contiene anche le credenziali di sola + lettura per scaricare il software e i suoi aggiornamenti.
+
+
+ +
+ + +
+
Invia questo identificativo a Tecnotel per ricevere il file license.json.
+
+
+ +
📄
+
Trascina qui license.json oppure clicca per selezionarlo
+
Firma Ed25519 verificata sul server prima di procedere
+
+ + +
+ +
+ + +
+
🏢 Informazioni cliente
+
Identità dell'istanza: compare nel portale, nei messaggi dell'assistente e nei report.
+
+
Identificativo tecnico, minuscolo senza spazi
+
+
+
Dominio degli utenti del portale (opzionale)
+
Poche frasi sul cliente: entrano nelle istruzioni dell'assistente predefinito, modificabili dal portale.
+
Logo (opzionale)
+
+ +
🖼️
+
Trascina il logo del cliente oppure clicca
+
PNG o SVG, max 2 MB — usato nel portale e nel widget
+
+
+
+ +
+ + +
+
🌐 Rete & SSL
+
Nome pubblico dell'istanza e certificato. Per WhatsApp e per i webhook dei canali serve HTTPS pubblico con certificato valido.
+
+
+
Separati da spazio — opzionale
+
Certificato SSL
+
+
🔒 Let's EncryptAutomatico, richiede DNS pubblico
+
📄 Certificato esistenteCarica .crt e .key
+
🔐 AutofirmatoLAN / demo — 10 anni
+
+
Per le notifiche di scadenza del certificato
+ + +
+ +
+ + +
+
🧠 Intelligenza artificiale
+
Provider del modello testuale. "Fake" è deterministico e senza costi: va bene per demo e collaudo; la chiave si può inserire o cambiare dopo dal portale.
+
+
+
+ +
+ +
+ + +
+
👤 Utente amministratore
+
Primo accesso al portale IRIDE. Gli altri utenti e i ruoli si creano dal portale.
+
+
+
+
+
+ +
+ +
+ + +
+
🚀 Riepilogo e installazione
+
Controlla i dati. L'installazione clona il repository, crea l'ambiente Python, configura SSL, nginx e i servizi systemd, poi chiude la porta 8888.
+
+ + + + +
+
+
+
IRIDE — Tecnotel Servizi SRL  ·  Web Installer v0.2.0  ·  La porta 8888 verrà chiusa al termine
+
+ + + + diff --git a/setup_server.py b/setup_server.py new file mode 100644 index 0000000..0adfa68 --- /dev/null +++ b/setup_server.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +""" +IRIDE — Web Installer Server (porta 8888) +Tecnotel Servizi SRL + +Porting di argos-setup/setup_server.py. Self-contained: solo stdlib + +python3-cryptography (pacchetto di sistema) per la verifica della licenza. + +Flusso: licenza (Ed25519 + machine_id + product) → clone di tecnotel/iride +con il token della licenza → virtualenv → iride.json / users.json → config +da template → migrazioni → build frontend (se presente) → SSL (Let's Encrypt, +certificato caricato o autofirmato) → nginx → unit systemd → licenza in +/opt/iride/data → chiusura della 8888 e rimozione di /opt/iride-setup-pkg. +""" +import hashlib +import json +import os +import secrets +import shutil +import signal +import subprocess +import threading +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import urlparse + +APP_DIR = Path("/opt/iride/app") +CONFIG_DIR = Path("/opt/iride/config") +DATA_DIR = Path("/opt/iride/data") +LOGS_DIR = Path("/opt/iride/logs") +CERTS_DIR = Path("/opt/iride/certs") +BACKUP_DIR = Path("/opt/iride/backups") +SETUP_DIR = Path("/opt/iride/setup") +APP_USER = "iride" +PORT = 8888 +PRODUCT = "iride" + +# ── Licenza — chiave pubblica Ed25519 del vendor (raw 32 byte, base64) ──────── +# Stessa costante di iride/backend/core/license.py e del portale Tecnotel. +_LICENSE_PUBLIC_KEY_B64 = "GMRsZMoxOlCBiJU66EsQcj0ZO0gVd0GHB5LelEo/hns=" + +# ── Clone: username Basic Auth del bot Gitea al quale appartiene il token ───── +GITEA_BOT_USER = "argos-portal-bot" +GITEA_REPO_PATH = "/tecnotel/iride.git" + +# ── Heartbeat verso il portale vendor (stessa chiave di registrazione ARGOS) ── +VENDOR_HEARTBEAT_URL = "https://license.argosdefense.io" +VENDOR_INSTALL_KEY = "5b1ab5c872383f686d3a25a5e123adca" + +SERVICES = ["iride-api", "iride-worker", "iride-scheduler"] + +install_log = [] +install_done = False +install_error = False + + +def get_machine_id() -> str: + """Fingerprint univoco del server: SHA256 hex di + /etc/machine-id | hostname | MAC prima interfaccia fisica. + IDENTICO a iride/backend/core/license.py e a ARGOS core.get_machine_id().""" + import socket as _sock + parts = [] + try: + with open("/etc/machine-id") as f: + parts.append(f.read().strip()) + except Exception: + parts.append("") + try: + parts.append(_sock.gethostname()) + except Exception: + parts.append("") + try: + r = subprocess.run(["cat", "/sys/class/net/eth0/address"], capture_output=True, text=True, timeout=2) + mac = r.stdout.strip() + if not mac or mac == "00:00:00:00:00:00": + r = subprocess.run(["ip", "-o", "link", "show"], capture_output=True, text=True, timeout=2) + for line in r.stdout.splitlines(): + if "link/ether" in line and "00:00:00:00:00:00" not in line: + if "docker" in line or "br-" in line or "veth" in line: + continue + mac = line.split("link/ether")[1].split()[0].strip() + break + parts.append(mac or "") + except Exception: + parts.append("") + return hashlib.sha256("|".join(parts).encode()).hexdigest() + + +def verify_license(raw_bytes): + """Firma Ed25519 + product + machine_id + scadenza + credenziali Gitea. + Ritorna (ok, license_dict, errore).""" + try: + raw = json.loads(raw_bytes) + except Exception as e: + return (False, None, f"File non è JSON valido: {e}") + if not isinstance(raw, dict): + return (False, None, "Formato licenza non riconosciuto") + try: + import base64 + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + sig = raw.pop("signature", "") + if not sig: + return (False, None, "Licenza senza firma (campo 'signature' mancante)") + payload = json.dumps(raw, sort_keys=True, separators=(",", ":")) + raw["signature"] = sig + pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_LICENSE_PUBLIC_KEY_B64)) + try: + pub.verify(base64.b64decode(sig), payload.encode()) + except InvalidSignature: + return (False, None, "Firma non valida: licenza manomessa o emessa da un altro vendor.") + except ImportError: + return (False, None, "Libreria 'cryptography' non disponibile: apt install python3-cryptography") + except Exception as e: + return (False, None, f"Errore verifica firma: {e}") + + product = raw.get("product") + if product is not None and product != PRODUCT: + return (False, None, f"Licenza per il prodotto '{product}', non per IRIDE.") + lic_machine = raw.get("machine_id", "") + if not lic_machine: + return (False, None, "Licenza senza machine_id: formato non supportato") + cur_machine = get_machine_id() + if lic_machine != cur_machine: + return (False, None, f"Machine ID non corrisponde: licenza per {lic_machine[:12]}..., " + f"questo server è {cur_machine[:12]}... La licenza non vale per questa macchina.") + expires = raw.get("expires_at", "") + if expires and expires < datetime.now().strftime("%Y-%m-%d"): + return (False, None, f"Licenza scaduta il {expires}") + if not raw.get("gitea_url") or not raw.get("gitea_token"): + return (False, None, "Licenza priva di credenziali Gitea: contattare Tecnotel per riemetterla.") + return (True, raw, "") + + +def log(msg): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + install_log.append(line) + print(line, flush=True) + + +def run(cmd, check=True): + log(f"$ {cmd}") + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + if result.stdout.strip(): log(result.stdout.strip()[-2000:]) + if result.stderr.strip(): log(result.stderr.strip()[-2000:]) + if check and result.returncode != 0: + raise RuntimeError(f"Comando fallito (exit {result.returncode}): {cmd}") + return result + + +def chown(path): + run(f"chown -R {APP_USER}:{APP_USER} {path}", check=False) + + +def venv_python() -> str: + return str(APP_DIR / "backend/venv/bin/python") + + +# ── Generazione configurazione ──────────────────────────────────────────────── + +def generate_iride_json(data): + hostname = data.get("domain", "").strip().lower() + aliases = [a.strip().lower() for a in data.get("aliases", "").split() if a.strip()] + provider = data.get("ai_provider", "fake") or "fake" + if provider not in ("fake", "openai", "anthropic"): + provider = "fake" + api_key = data.get("ai_api_key", "").strip() + cfg = { + "_version": "1.0", + "_installed": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "cliente": { + "name": data.get("cliente_name", "").strip(), + "full_name": data.get("cliente_full", "").strip() or data.get("cliente_name", "").strip(), + "domain": hostname, + "email_domain": data.get("cliente_domain", "").strip().lower(), + "type": data.get("cliente_type", "customer_service"), + "ai_context": data.get("ai_context", "").strip(), + }, + "network": {"hostname": hostname, "aliases": aliases}, + "system": { + "secret_key": secrets.token_hex(32), + "internal_api_key": secrets.token_hex(24), + "timezone": "Europe/Rome", + "vendor_heartbeat": { + "enabled": True, + "url": VENDOR_HEARTBEAT_URL, + "install_key": VENDOR_INSTALL_KEY, + "interval_h": 6, + }, + }, + "ai": { + "text": {"provider": provider, + "options": {"model": data.get("ai_model", "").strip() or "gpt-4.1-mini", "temperature": 0.3}}, + "realtime": {"provider": "openai", "options": {"model": ""}}, + "providers": { + "openai": {"api_key": api_key if provider == "openai" else "", "base_url": "https://api.openai.com/v1"}, + "anthropic": {"api_key": api_key if provider == "anthropic" else ""}, + }, + "limits": {"max_ai_turns": 20, "daily_budget_micros": 5000000}, + }, + "ports": {"api": 8080, "voice": 8081}, + "paths": {"config_dir": str(CONFIG_DIR), "data_dir": str(DATA_DIR), "logs_dir": str(LOGS_DIR), + "backups": str(BACKUP_DIR)}, + "backup": {"keep_snapshots": 14}, + "license": {"file": str(DATA_DIR / "license.json")}, + } + return cfg + + +def create_admin_user(data): + """users.json con hash bcrypt calcolato dal backend IRIDE (stesso modulo + che lo verifica al login). La password passa via stdin, mai in argv.""" + username = (data.get("admin_username") or "admin").strip().lower() + password = data.get("admin_password", "") + if not username or not password: + log("WARN: credenziali admin mancanti — skip creazione utente") + return + proc = subprocess.run( + [venv_python(), "-c", "import sys; from core.auth import hash_password; print(hash_password(sys.stdin.read()))"], + input=password, capture_output=True, text=True, cwd=str(APP_DIR / "backend"), + env={**os.environ, "PYTHONPATH": str(APP_DIR / "backend"), "IRIDE_CONFIG_DIR": str(CONFIG_DIR), + "IRIDE_DATA_DIR": str(DATA_DIR)}) + if proc.returncode != 0: + raise RuntimeError(f"hash della password fallito: {proc.stderr.strip()[-300:]}") + pw_hash = proc.stdout.strip() + users_file = CONFIG_DIR / "users.json" + try: + users = json.loads(users_file.read_text()) + except Exception: + users = {"users": {}} + users.setdefault("users", {})[username] = { + "password_hash": pw_hash, "roles": ["admin"], "totp_secret": "", + "email": (data.get("admin_email_user") or "").strip(), "enabled": True, + } + users_file.write_text(json.dumps(users, indent=2, ensure_ascii=False) + "\n") + os.chmod(users_file, 0o600) + chown(users_file) + log(f"Utente admin '{username}' creato in users.json") + + +# ── Installazione ───────────────────────────────────────────────────────────── + +def install(data): + global install_done, install_error + try: + log("=== AVVIO INSTALLAZIONE IRIDE ===") + + # 0. Licenza (già validata da /api/license/upload) + log("── Verifica licenza IRIDE ──") + lic_path = SETUP_DIR / "license.json" + if not lic_path.exists(): + raise RuntimeError("license.json non trovata in /opt/iride/setup/: caricare una licenza valida.") + ok, lic, err = verify_license(lic_path.read_bytes()) + if not ok: + raise RuntimeError(f"Licenza non valida: {err}") + gitea_url = lic.get("gitea_url", "").rstrip("/") + gitea_token = lic.get("gitea_token", "") + gitea_host = gitea_url[:-len("/api/v1")] if gitea_url.endswith("/api/v1") else gitea_url + log(f"Licenza OK: {lic.get('customer')} / {lic.get('tier')} / exp {lic.get('expires_at')}") + + # 1. Clone (URL autenticato temporaneo: il token NON resta in .git/config) + log("── Clone repository IRIDE ──") + if (APP_DIR / ".git").exists(): + log("Repository già presente — skip clone") + else: + auth_url = f"https://{GITEA_BOT_USER}:{gitea_token}@{gitea_host[len('https://'):]}{GITEA_REPO_PATH}" + APP_DIR.parent.mkdir(parents=True, exist_ok=True) + run(f"git config --global --add safe.directory {APP_DIR}") + run(f"git clone {auth_url} {APP_DIR}") + run(f"git -C {APP_DIR} remote set-url origin {gitea_host}{GITEA_REPO_PATH}") + chown(APP_DIR) + log(f"Repository IRIDE pronto ({(APP_DIR / 'VERSION').read_text().strip() if (APP_DIR / 'VERSION').exists() else '?'})") + + # 2. Virtualenv + log("── Virtualenv Python ──") + venv_dir = APP_DIR / "backend/venv" + if not venv_dir.exists(): + run(f"python3 -m venv {venv_dir}") + run(f"{venv_dir}/bin/pip install --upgrade pip -q") + run(f"{venv_dir}/bin/pip install -r {APP_DIR}/backend/requirements.txt -q") + chown(venv_dir) + log(f"Virtualenv pronto ({run(f'{venv_dir}/bin/python --version', check=False).stdout.strip()})") + + # 3. iride.json + log("── Generazione iride.json ──") + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + cfg_path = CONFIG_DIR / "iride.json" + cfg_path.write_text(json.dumps(generate_iride_json(data), indent=2, ensure_ascii=False) + "\n") + os.chmod(cfg_path, 0o600) + chown(CONFIG_DIR) + log("iride.json creato") + + # 4. Config da template .example (glob, idempotente: mai clobber) + _GENERATED = {"iride.json", "users.json"} + for src in sorted((APP_DIR / "config").glob("*.json.example")): + name = src.name[:-len(".example")] + if name in _GENERATED: + continue + dst = CONFIG_DIR / name + if dst.exists(): + continue + shutil.copy(src, dst) + os.chmod(dst, 0o600) + chown(dst) + log(f"{name} copiato da template") + + # 5. Logo cliente + logo_src = SETUP_DIR / "logo_cliente.png" + if logo_src.exists(): + (CONFIG_DIR / "assets").mkdir(parents=True, exist_ok=True) + shutil.copy(logo_src, CONFIG_DIR / "assets" / "logo_cliente.png") + chown(CONFIG_DIR / "assets") + log("Logo cliente copiato") + + # 6. Utente admin (bcrypt dal backend) + log("── Creazione utente admin ──") + create_admin_user(data) + + # 7. Migrazioni DB + log("── Migrazioni database ──") + DATA_DIR.mkdir(parents=True, exist_ok=True) + chown(DATA_DIR) + run(f"cd {APP_DIR}/backend && sudo -u {APP_USER} env IRIDE_CONFIG_DIR={CONFIG_DIR} IRIDE_DATA_DIR={DATA_DIR} " + f"IRIDE_LOGS_DIR={LOGS_DIR} IRIDE_DB={DATA_DIR}/iride.db {venv_dir}/bin/python db.py") + log("Schema allineato") + + # 8. Frontend (solo se presente: B-067) + if (APP_DIR / "frontend/package.json").exists(): + log("── Build frontend ──") + run(f"cd {APP_DIR}/frontend && npm ci --silent") + run(f"cd {APP_DIR}/frontend && npm run build") + chown(APP_DIR / "frontend") + run(f"chmod 755 /opt/iride /opt/iride/app /opt/iride/app/frontend") + run(f"chmod -R 755 {APP_DIR}/frontend/dist/", check=False) + log("Frontend compilato") + else: + log("Nessun frontend/package.json: nginx serve la sola API (B-067)") + + # 9. SSL — stesse tre modalità di ARGOS + log("── Configurazione SSL ──") + domain = data.get("domain", "").strip() + aliases = data.get("aliases", "").strip() + ssl_mode = data.get("ssl_mode", "letsencrypt") + all_names = (domain + " " + aliases).strip() + CERTS_DIR.mkdir(parents=True, exist_ok=True) + if ssl_mode == "manual": + crt_src, key_src = SETUP_DIR / "uploaded.crt", SETUP_DIR / "uploaded.key" + if not crt_src.exists() or not key_src.exists(): + raise RuntimeError("File SSL .crt o .key non trovati in /opt/iride/setup/") + shutil.copy(crt_src, CERTS_DIR / "fullchain.pem") + shutil.copy(key_src, CERTS_DIR / "privkey.pem") + os.chmod(CERTS_DIR / "privkey.pem", 0o600) + ssl_crt, ssl_key = str(CERTS_DIR / "fullchain.pem"), str(CERTS_DIR / "privkey.pem") + log("Certificato SSL caricato dal wizard") + elif ssl_mode == "selfsigned": + log("Generazione certificato autofirmato (RSA 4096, validità 10 anni)") + crt_path, key_path, cnf_path = CERTS_DIR / "fullchain.pem", CERTS_DIR / "privkey.pem", CERTS_DIR / "openssl-selfsigned.cnf" + san_dns = [n for n in all_names.split() if n] + try: + server_ip = subprocess.check_output(["hostname", "-I"], text=True).strip().split()[0] + except Exception: + server_ip = "" + san_lines = "\n".join(f"DNS.{i+1} = {n}" for i, n in enumerate(san_dns)) or "DNS.1 = iride.local" + if server_ip: + san_lines += f"\nIP.1 = {server_ip}" + client_full = data.get("cliente_full") or data.get("cliente_name") or "IRIDE" + cn = domain or "iride.local" + cnf_path.write_text(f"""[req] +default_bits = 4096 +prompt = no +default_md = sha256 +distinguished_name = dn +req_extensions = req_ext +x509_extensions = v3_ext + +[dn] +C = IT +O = {client_full} +OU = IRIDE +CN = {cn} + +[req_ext] +subjectAltName = @alt_names + +[v3_ext] +subjectAltName = @alt_names +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth + +[alt_names] +{san_lines} +""") + run(f"openssl req -x509 -nodes -days 3650 -newkey rsa:4096 -keyout {key_path} -out {crt_path} -config {cnf_path}") + os.chmod(key_path, 0o600) + os.chmod(crt_path, 0o644) + ssl_crt, ssl_key = str(crt_path), str(key_path) + log(f"Certificato autofirmato generato (CN={cn}, SAN: {len(san_dns)} DNS{' + 1 IP' if server_ip else ''})") + log("ATTENZIONE: i browser lo segnaleranno come non attendibile; per WhatsApp serve un certificato valido.") + else: + _write_nginx_http(all_names) + run("nginx -t && systemctl restart nginx") + certbot_d = " ".join(f"-d {n}" for n in all_names.split()) + email = data.get("admin_email", "admin@tecnotelsrl.com") + run(f"certbot --nginx {certbot_d} --non-interactive --agree-tos -m {email}") + ssl_crt = f"/etc/letsencrypt/live/{domain}/fullchain.pem" + ssl_key = f"/etc/letsencrypt/live/{domain}/privkey.pem" + log("Certificato Let's Encrypt ottenuto") + chown(CERTS_DIR) + + # 10. Nginx finale + log("── Nginx configurazione finale ──") + _write_nginx_final(all_names or "_", ssl_crt, ssl_key) + run("nginx -t && systemctl restart nginx") + log("Nginx configurato") + + # 11. Servizi systemd (unit dal repo: update.sh le tiene allineate) + log("── Creazione e avvio servizi ──") + for svc in SERVICES: + shutil.copy(APP_DIR / "deploy/systemd" / f"{svc}.service", f"/etc/systemd/system/{svc}.service") + if (APP_DIR / "deploy/logrotate/iride").exists(): + shutil.copy(APP_DIR / "deploy/logrotate/iride", "/etc/logrotate.d/iride") + run("systemctl daemon-reload") + LOGS_DIR.mkdir(parents=True, exist_ok=True) + chown(LOGS_DIR) + for svc in SERVICES: + run(f"systemctl enable --now {svc}") + log(f"{svc} avviato") + + # 12. Licenza in posizione finale + log("── Copia licenza in posizione finale ──") + final_lic = DATA_DIR / "license.json" + shutil.copy(lic_path, final_lic) + os.chmod(final_lic, 0o600) + chown(final_lic) + log(f"Licenza copiata in {final_lic}") + + # 13. Health check e strumento di verifica permanente + health = Path(__file__).parent / "checks" / "health.sh" + if health.exists(): + shutil.copy(health, "/usr/local/bin/iride-health") + os.chmod("/usr/local/bin/iride-health", 0o755) + res = run("bash /usr/local/bin/iride-health", check=False) + if res.returncode != 0: + log("ATTENZIONE: health check con errori (vedi sopra): verificare i log in /opt/iride/logs") + + # 14. Chiusura web installer + log("── Chiusura web installer ──") + run("systemctl disable --now iride-setup", check=False) + run("ufw delete allow 8888/tcp", check=False) + log("Porta 8888 chiusa — web installer disabilitato") + + log("=== INSTALLAZIONE COMPLETATA ===") + _schedule_cleanup() + install_done = True + + def shutdown(): + import time + time.sleep(15) + os.kill(os.getpid(), signal.SIGTERM) + threading.Thread(target=shutdown, daemon=True).start() + + except Exception as e: + log(f"ERRORE: {e}") + install_log.append(f"__ERROR__: {e}") + install_error = True + + +# ── Nginx ───────────────────────────────────────────────────────────────────── + +def _write_nginx_http(all_names): + _write_nginx_conf(f"""server {{ + listen 80; + server_name {all_names}; + location /.well-known/acme-challenge/ {{ root /var/www/html; }} + location / {{ return 301 https://$host$request_uri; }} +}} +""") + + +def _write_nginx_final(all_names, ssl_crt, ssl_key): + has_frontend = (APP_DIR / "frontend/dist/index.html").exists() + root_block = (f""" location / {{ + root {APP_DIR}/frontend/dist; + try_files $uri $uri/ /index.html; + expires 1h; + }}""" if has_frontend else + """ location = / { + return 200 'IRIDE installata. Portale in arrivo (B-067). API: /api/health'; + add_header Content-Type text/plain; + }""") + conf = f"""limit_req_zone $binary_remote_addr zone=iride:10m rate=20r/s; + +server {{ + listen 80; + server_name {all_names}; + location / {{ return 301 https://$host$request_uri; }} + location /.well-known/acme-challenge/ {{ root /var/www/html; }} +}} + +server {{ + listen 443 ssl http2; + server_name {all_names}; + + ssl_certificate {ssl_crt}; + ssl_certificate_key {ssl_key}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header X-Frame-Options SAMEORIGIN; + add_header X-Content-Type-Options nosniff; + add_header Strict-Transport-Security "max-age=31536000" always; + + client_max_body_size 25m; + + # Webhook dei canali: nessun rate limit per IP (Meta e Telegram arrivano da pochi IP) + location /api/v1/webhooks/ {{ + proxy_pass http://127.0.0.1:8080; + 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 https; + proxy_read_timeout 30s; + }} + location /api/ {{ + limit_req zone=iride burst=40 nodelay; + proxy_pass http://127.0.0.1:8080; + 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 https; + proxy_connect_timeout 30s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + }} + # WebSocket dell'inbox operatori (B-016) + location /ws/ {{ + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + }} + # Relay vocale (POC-01): attivare solo con iride-voice installato + # location /v1/voice/ {{ + # proxy_pass http://127.0.0.1:8081; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # proxy_read_timeout 3600s; + # proxy_send_timeout 3600s; + # }} + location /widget/ {{ + alias {APP_DIR}/frontend/packages/widget/dist/; + add_header Cache-Control "public, max-age=3600"; + }} +{root_block} + + access_log {LOGS_DIR}/nginx-access.log; + error_log {LOGS_DIR}/nginx-error.log; +}} +""" + _write_nginx_conf(conf) + + +def _write_nginx_conf(conf): + Path("/etc/nginx/sites-available/iride").write_text(conf) + p = Path("/etc/nginx/sites-enabled/iride") + if not p.exists(): + p.symlink_to("/etc/nginx/sites-available/iride") + for f in ["/etc/nginx/sites-enabled/default", "/etc/nginx/sites-enabled/iride-setup"]: + if Path(f).exists(): + Path(f).unlink() + + +# ── HTTP ────────────────────────────────────────────────────────────────────── + +class SetupHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + path = urlparse(self.path).path + if path in ("/", "/setup"): + html_path = Path(__file__).parent / "setup.html" + if not html_path.exists(): + self.send_response(404); self.end_headers(); return + html = html_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", len(html)) + self.end_headers() + self.wfile.write(html) + elif path == "/api/status": + self._json({"done": install_done, "error": install_error, "log": install_log[-60:]}) + elif path == "/api/machine-id": + self._json({"machine_id": get_machine_id()}) + else: + self.send_response(404); self.end_headers() + + def do_POST(self): + path = urlparse(self.path).path + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + if path == "/api/install": + try: + data = json.loads(body) + threading.Thread(target=install, args=(data,), daemon=True).start() + self._json({"ok": True}) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 400) + elif path in ("/api/upload/cert", "/api/upload/key", "/api/upload/logo"): + SETUP_DIR.mkdir(parents=True, exist_ok=True) + name = {"cert": "uploaded.crt", "key": "uploaded.key", "logo": "logo_cliente.png"}[path.rsplit("/", 1)[1]] + (SETUP_DIR / name).write_bytes(body) + os.chmod(SETUP_DIR / name, 0o600) + self._json({"ok": True}) + elif path == "/api/license/upload": + ok, lic, err = verify_license(body) + if not ok: + self._json({"ok": False, "error": err}, 400) + return + SETUP_DIR.mkdir(parents=True, exist_ok=True) + lic_path = SETUP_DIR / "license.json" + lic_path.write_bytes(body) + os.chmod(lic_path, 0o600) + self._json({"ok": True, "summary": { + "customer": lic.get("customer", ""), + "tier": lic.get("tier", ""), + "issued_to": lic.get("issued_to", ""), + "issued_at": lic.get("issued_at", ""), + "expires_at": lic.get("expires_at", ""), + "product": lic.get("product", "") or "(non indicato)", + "has_gitea": bool(lic.get("gitea_token")), + }}) + else: + self.send_response(404); self.end_headers() + + def _json(self, data, code=200): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", len(body)) + self.end_headers() + self.wfile.write(body) + + +def _schedule_cleanup(): + """Disabilita iride-setup e rimuove /opt/iride-setup-pkg via transient + unit systemd-run, indipendente dal processo padre (che sta per morire).""" + script = r"""#!/bin/bash +sleep 5 +systemctl stop iride-setup.service 2>/dev/null || true +systemctl disable iride-setup.service 2>/dev/null || true +rm -f /etc/systemd/system/iride-setup.service +systemctl daemon-reload +ufw delete allow 8888/tcp 2>/dev/null || true +rm -rf /opt/iride-setup-pkg +echo "iride-setup cleanup completato $(date -Iseconds)" >> /var/log/iride-setup-cleanup.log +""" + script_path = "/tmp/iride-setup-cleanup.sh" + try: + import time + Path(script_path).write_text(script) + os.chmod(script_path, 0o755) + unit_name = f"iride-setup-cleanup-{int(time.time())}.service" + subprocess.Popen(["systemd-run", "--no-block", "--unit", unit_name, "/bin/bash", script_path], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + log(f"Cleanup schedulato via systemd-run come {unit_name} (delay 5s)") + except Exception as e: + log(f"Errore schedulazione cleanup: {e}") + + +if __name__ == "__main__": + print(f"\n{'='*55}\n IRIDE — Web Installer\n Tecnotel Servizi SRL\n Apri: http://:{PORT}\n{'='*55}\n") + HTTPServer(("0.0.0.0", PORT), SetupHandler).serve_forever()