iride-setup 0.2.0 - bootstrap, first-setup, web installer 8888 con licenza, dev-setup runner

This commit is contained in:
tecnotel 2026-09-12 12:54:45 +02:00
parent 5dd160472d
commit eaac6c22c2
11 changed files with 1684 additions and 432 deletions

135
README.md
View File

@ -1,44 +1,121 @@
# iride-setup # IRIDE — Setup & Installer
Bootstrap e primo install di un'istanza **IRIDE** (Tecnotel Servizi SRL), sullo Tecnotel Servizi SRL — [www.tecnotelsrl.com](https://www.tecnotelsrl.com)
schema di `argos-setup`. Il repository applicativo è `iride`; gli aggiornamenti
successivi li fa `iride/scripts/update.sh`.
## 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 ```text
curl -fsSL -H "Authorization: token <TOKEN>" \ curl bootstrap.sh | sudo bash
https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh \ └─ first-setup.sh sistema, Node 22, utente iride, sudoers ristretti,
| sudo bash -s -- --token <TOKEN> /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
``` ```
`<TOKEN>` è un token Gitea di **sola lettura dedicato all'istanza** (uno per ## Prerequisiti
cliente, revocabile), mai un token personale. Resta in
`/opt/iride/config/git-credentials` (0600, utente `iride`) e serve a `update.sh`.
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://<IP>: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 ```text
... | sudo bash -s -- --token <TOKEN> --client "Nome Cliente" --domain iride.cliente.it --admin-password '<min 12 caratteri>' /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 | ## Hardening applicato (stesso di argos-setup)
|---|---|---|
| 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 |
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 Lo stesso server può ospitare l'istanza demo, il checkout di sviluppo e il runner CI:
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 ```bash
all'install arrivano dopo il pilota (B-055 li collauda su VM di test). sudo bash /opt/iride-setup-pkg/dev-setup.sh --token <REGISTRATION_TOKEN>
- Riesecuzione sicura: `bootstrap.sh` aggiorna il setup e rilancia `install.sh`, ```
che non tocca configurazione, utenti e certificati già presenti.
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`.

View File

@ -1 +1 @@
0.1.0 0.2.0

View File

@ -1,73 +1,81 @@
#!/bin/bash #!/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 <TOKEN>" \ # Oppure manuale (raccomandato per verifica):
# https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh \ # curl -fsSLo /tmp/iride-bootstrap.sh https://repo.argosdefense.io/tecnotel/iride-setup/raw/branch/main/bootstrap.sh
# | sudo bash -s -- --token <TOKEN> # less /tmp/iride-bootstrap.sh
# sudo bash /tmp/iride-bootstrap.sh
# #
# <TOKEN> = token Gitea di SOLA LETTURA dedicato all'istanza (mai personale). # Stesso flusso di argos-setup: scarica questo repository (pubblico, nessun
# Opzioni: --gitea-url URL (default https://repo.argosdefense.io) # segreto), esegue first-setup.sh (sistema, utente, firewall, nginx temporaneo)
# --org ORG (default tecnotel) --branch BRANCH (default main) # e avvia il Web Installer sulla porta 8888, che chiede la licenza IRIDE e
# --setup-branch BRANCH (default main) # clona il repository privato tecnotel/iride con il token della licenza.
# --client NOME --domain FQDN --admin-password PWD (non interattivo)
# Riesecuzione sicura: aggiorna iride-setup e rilancia install.sh.
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
set -euo pipefail set -euo pipefail
GITEA_URL="https://repo.argosdefense.io" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
ORG="tecnotel" BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
BRANCH="main" info() { echo -e "${CYAN}[INFO]${NC} $1"; }
SETUP_BRANCH="main" success() { echo -e "${GREEN}[OK]${NC} $1"; }
TOKEN="" warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
PASSTHROUGH=() error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; }
while [[ $# -gt 0 ]]; do # ── Check privilegi e OS ──────────────────────────────────────────────────────
case "$1" in [[ $EUID -ne 0 ]] && error "Eseguire come root (sudo)."
--token) TOKEN="$2"; shift 2 ;; [[ -f /etc/os-release ]] || error "OS non riconosciuto: /etc/os-release mancante."
--gitea-url) GITEA_URL="$2"; shift 2 ;; . /etc/os-release
--org) ORG="$2"; shift 2 ;; SUPPORTED_VERSIONS=("24.04" "26.04")
--branch) BRANCH="$2"; shift 2 ;; VERSION_OK=0
--setup-branch) SETUP_BRANCH="$2"; shift 2 ;; for v in "${SUPPORTED_VERSIONS[@]}"; do
--client|--domain|--admin-password|--admin-user) PASSTHROUGH+=("$1" "$2"); shift 2 ;; [[ "$VERSION_ID" == "$v" ]] && VERSION_OK=1
*) echo "Opzione sconosciuta: $1"; exit 1 ;; done
esac 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 done
[[ $EUID -eq 0 ]] || { echo "Eseguire con sudo"; exit 1; } info "Avvio installer ambiente..."
[[ -n "$TOKEN" ]] || { echo "Serve --token <token Gitea di sola lettura>"; exit 1; } echo ""
cd "$SETUP_DIR"
GITEA_HOST="${GITEA_URL#https://}"; GITEA_HOST="${GITEA_HOST#http://}"; GITEA_HOST="${GITEA_HOST%%/*}" exec bash ./first-setup.sh "$@"
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[@]}"

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# IRIDE — verifica post-install / post-update (Tecnotel Servizi SRL) # 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 set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok=0; ko=0 ok=0; ko=0

132
dev-setup.sh Executable file
View File

@ -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 <REGISTRATION_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 <registration 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."

196
first-setup.sh Executable file
View File

@ -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 <token>"
echo ""

View File

@ -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()

View File

@ -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"

View File

@ -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
}

499
setup.html Normal file
View File

@ -0,0 +1,499 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IRIDE — Installazione</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Barlow:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080a0e;--bg2:#0e1117;--bg3:#161b24;--bg4:#1c2333;
--border:#1e2840;--border2:#2a3a58;
--text:#e2e8f4;--text2:#8a9ab8;--text3:#445068;
--brand:#8A7CFF;--brand2:#6F5FE6;--brand-dim:#8A7CFF18;
--ok:#10b981;--ok-dim:#10b98118;--err:#ef4444;--err-dim:#ef444418;--warn:#f59e0b;
--mono:'JetBrains Mono',monospace;--sans:'Barlow',sans-serif;--r:10px;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.6}
.shell{min-height:100vh;display:grid;grid-template-rows:auto 1fr auto}
.hdr{background:var(--bg2);border-bottom:1px solid var(--border);padding:16px 32px;display:flex;align-items:center;gap:16px}
.hdr-logo{font-size:22px;font-weight:800;color:var(--brand);letter-spacing:3px}
.hdr-sep{width:1px;height:24px;background:var(--border2)}
.hdr-sub{font-size:12px;color:var(--text3);font-family:var(--mono);text-transform:uppercase;letter-spacing:1.5px}
.hdr-badge{margin-left:auto;font-size:10px;font-family:var(--mono);background:var(--brand-dim);color:var(--brand);padding:4px 10px;border-radius:20px;border:1px solid #8A7CFF33}
.main{display:flex;max-width:1000px;margin:0 auto;width:100%;padding:32px 24px;gap:28px}
.sidebar{width:200px;flex-shrink:0}
.sidebar-title{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;padding:0 12px 12px;font-family:var(--mono)}
.tab-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--r);cursor:pointer;transition:all .15s;color:var(--text2);font-size:13px;font-weight:500;margin-bottom:2px;border:1px solid transparent}
.tab-item:hover{background:var(--bg3);color:var(--text)}
.tab-item.active{background:var(--bg3);color:var(--brand);border-color:var(--border2)}
.tab-item.done .tab-num{background:var(--ok);color:#000}
.tab-item.active .tab-num{background:var(--brand);color:#000}
.tab-num{width:22px;height:22px;border-radius:50%;background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;font-family:var(--mono);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s}
.content{flex:1;min-width:0}
.tab-panel{display:none}
.tab-panel.active{display:block}
.panel-title{font-size:22px;font-weight:700;margin-bottom:4px}
.panel-sub{font-size:13px;color:var(--text2);margin-bottom:24px}
.progress{display:flex;gap:6px;margin-bottom:28px}
.prog-step{flex:1;height:3px;border-radius:2px;background:var(--border2);transition:background .3s}
.prog-step.done{background:var(--ok)}
.prog-step.active{background:var(--brand)}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.form-full{grid-column:1/-1}
.form-label{display:block;font-size:11px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
.form-label span{color:var(--err);margin-left:2px}
.form-input,.form-select{width:100%;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);padding:10px 14px;color:var(--text);font-family:var(--sans);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}
.form-input:focus,.form-select:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-dim)}
.form-input::placeholder{color:var(--text3)}
.mono-input{font-family:var(--mono);font-size:12px}
.form-hint{font-size:11px;color:var(--text3);margin-top:5px}
.sec-div{grid-column:1/-1;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;font-family:var(--mono);border-top:1px solid var(--border);padding-top:18px;margin-top:4px}
.sec-div:first-child{border-top:none;padding-top:0;margin-top:0}
.ssl-toggle{grid-column:1/-1;display:flex;gap:8px}
.ssl-btn{flex:1;padding:12px 16px;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);cursor:pointer;text-align:center;font-size:13px;font-weight:600;color:var(--text2);transition:all .15s}
.ssl-btn.active{background:var(--brand-dim);border-color:var(--brand);color:var(--brand)}
.ssl-btn small{display:block;font-weight:400;font-size:11px;opacity:.7;margin-top:2px}
.upload-area{grid-column:1/-1;border:2px dashed var(--border2);border-radius:var(--r);padding:20px;text-align:center;cursor:pointer;transition:all .15s}
.upload-area:hover,.upload-area.drag{border-color:var(--brand);background:var(--brand-dim)}
.upload-preview{display:none;align-items:center;justify-content:center;gap:10px;margin-top:10px}
.upload-preview img{max-height:50px;max-width:180px;border-radius:6px}
.alert{padding:12px 16px;border-radius:var(--r);font-size:13px;margin-bottom:0}
.alert-err{background:var(--err-dim);border:1px solid #ef444433;color:var(--err)}
.alert-ok{background:var(--ok-dim);border:1px solid #10b98133;color:var(--ok)}
.alert-warn{background:#f59e0b15;border:1px solid #f59e0b33;color:var(--warn)}
.nav-row{display:flex;justify-content:space-between;align-items:center;margin-top:28px;padding-top:20px;border-top:1px solid var(--border)}
.btn{padding:10px 22px;border-radius:var(--r);border:none;font-family:var(--sans);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:8px}
.btn-brand{background:var(--brand);color:#000}
.btn-brand:hover{background:var(--brand2)}
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border2)}
.btn-ghost:hover{background:var(--bg3);color:var(--text)}
.btn-ok{background:var(--ok);color:#000;font-size:15px;padding:14px 36px}
.btn-ok:hover{filter:brightness(1.1)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.summary-table{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:16px}
.summary-section{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1px;padding:10px 14px;font-family:var(--mono);background:var(--bg4);border-bottom:1px solid var(--border)}
.summary-row{display:flex;border-bottom:1px solid #1e284044}
.summary-row:last-child{border-bottom:none}
.summary-label{width:160px;padding:8px 14px;font-size:12px;color:var(--text3);flex-shrink:0}
.summary-value{padding:8px 14px;font-size:12px;color:var(--text);font-family:var(--mono);flex:1}
.summary-value.empty{color:var(--text3);font-style:italic;font-family:var(--sans)}
.footer{background:var(--bg2);border-top:1px solid var(--border);padding:12px 32px;text-align:center;font-size:11px;color:var(--text3);font-family:var(--mono)}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.8)}50%{opacity:1;transform:scale(1.2)}}
</style>
</head>
<body>
<div class="shell">
<header class="hdr">
<div class="hdr-logo">IRIDE</div>
<div class="hdr-sep"></div>
<div class="hdr-sub">Setup Wizard — Tecnotel Servizi SRL</div>
<div class="hdr-badge">v0.2.0</div>
</header>
<div class="main">
<aside class="sidebar">
<div class="sidebar-title">Configurazione</div>
<div class="tab-item active" onclick="goTab(0)" id="tab-0"><div class="tab-num">1</div> Licenza</div>
<div class="tab-item" onclick="goTab(1)" id="tab-1"><div class="tab-num">2</div> Cliente</div>
<div class="tab-item" onclick="goTab(2)" id="tab-2"><div class="tab-num">3</div> Rete &amp; SSL</div>
<div class="tab-item" onclick="goTab(3)" id="tab-3"><div class="tab-num">4</div> Intelligenza artificiale</div>
<div class="tab-item" onclick="goTab(4)" id="tab-4"><div class="tab-num">5</div> Utente admin</div>
<div class="tab-item" onclick="goTab(5)" id="tab-5"><div class="tab-num">6</div> Installa</div>
</aside>
<div class="content">
<div class="progress">
<div class="prog-step active" id="ps-0"></div>
<div class="prog-step" id="ps-1"></div>
<div class="prog-step" id="ps-2"></div>
<div class="prog-step" id="ps-3"></div>
<div class="prog-step" id="ps-4"></div>
<div class="prog-step" id="ps-5"></div>
</div>
<!-- 1: Licenza -->
<div class="tab-panel active" id="panel-0">
<div class="panel-title">🔑 Licenza IRIDE</div>
<div class="panel-sub">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.</div>
<div class="form-grid">
<div class="form-full">
<label class="form-label">Machine ID di questo server</label>
<div style="display:flex;gap:8px">
<input class="form-input mono-input" id="machine-id-display" value="Calcolo in corso..." readonly style="flex:1">
<button class="btn btn-ghost" onclick="copyMachineId()" id="btn-copy-mid" style="padding:10px 16px">📋 Copia</button>
</div>
<div class="form-hint">Invia questo identificativo a Tecnotel per ricevere il file <code>license.json</code>.</div>
</div>
<div class="upload-area form-full" id="lic-drop"
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
ondrop="handleLicenseDrop(event)" onclick="document.getElementById('lic-input').click()">
<input type="file" id="lic-input" accept=".json,application/json" style="display:none" onchange="handleLicenseFile(this)">
<div style="font-size:28px;margin-bottom:6px">📄</div>
<div style="font-weight:600">Trascina qui <code>license.json</code> oppure clicca per selezionarlo</div>
<div class="form-hint">Firma Ed25519 verificata sul server prima di procedere</div>
</div>
<div id="lic-error" class="form-full alert alert-err" style="display:none"></div>
<div id="lic-summary" class="form-full" style="display:none;background:var(--ok-dim);border:1px solid var(--ok);border-radius:var(--r);padding:16px 20px">
<div style="font-weight:700;color:var(--ok);margin-bottom:10px">✓ Licenza valida</div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:6px 12px;font-size:12px;font-family:var(--mono)">
<div style="color:var(--text3)">Cliente:</div> <div id="lic-customer"></div>
<div style="color:var(--text3)">Prodotto:</div> <div id="lic-product"></div>
<div style="color:var(--text3)">Tier:</div> <div id="lic-tier"></div>
<div style="color:var(--text3)">Hostname:</div> <div id="lic-hostname"></div>
<div style="color:var(--text3)">Emessa:</div> <div id="lic-issued"></div>
<div style="color:var(--text3)">Scadenza:</div> <div id="lic-expires"></div>
<div style="color:var(--text3)">Token Gitea:</div><div id="lic-gitea"></div>
</div>
</div>
</div>
<div class="nav-row">
<div></div>
<button class="btn btn-brand" id="btn-after-license" onclick="goTab(1)" disabled style="opacity:.4;cursor:not-allowed">Avanti →</button>
</div>
</div>
<!-- 2: Cliente -->
<div class="tab-panel" id="panel-1">
<div class="panel-title">🏢 Informazioni cliente</div>
<div class="panel-sub">Identità dell'istanza: compare nel portale, nei messaggi dell'assistente e nei report.</div>
<div class="form-grid">
<div><label class="form-label">Nome breve <span>*</span></label><input class="form-input" id="cliente_name" placeholder="es. poliambulatorio-rossi"><div class="form-hint">Identificativo tecnico, minuscolo senza spazi</div></div>
<div><label class="form-label">Tipo organizzazione</label><select class="form-select" id="cliente_type"><option value="healthcare">Sanità / Healthcare</option><option value="customer_service">Customer service</option><option value="hospitality">Hospitality</option><option value="pa">Pubblica Amministrazione</option><option value="enterprise">Azienda</option></select></div>
<div class="form-full"><label class="form-label">Nome completo <span>*</span></label><input class="form-input" id="cliente_full" placeholder="es. Poliambulatorio Rossi S.r.l."></div>
<div><label class="form-label">Dominio email</label><input class="form-input" id="cliente_domain" placeholder="es. poliambulatoriorossi.it"><div class="form-hint">Dominio degli utenti del portale (opzionale)</div></div>
<div class="form-full"><label class="form-label">Contesto per l'assistente <span style="font-weight:400">(opzionale)</span></label><textarea class="form-input" id="ai_context" rows="3" placeholder="es. Poliambulatorio con 3 sedi, prenotazioni e ritiro referti, orari 8-20 nei giorni feriali."></textarea><div class="form-hint">Poche frasi sul cliente: entrano nelle istruzioni dell'assistente predefinito, modificabili dal portale.</div></div>
<div class="sec-div">Logo (opzionale)</div>
<div class="upload-area form-full" id="logo-drop" ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')" ondrop="handleLogoDrop(event)" onclick="document.getElementById('logo-input').click()">
<input type="file" id="logo-input" accept="image/*" style="display:none" onchange="handleLogoFile(this)">
<div style="font-size:24px;margin-bottom:4px">🖼️</div>
<div style="font-weight:600">Trascina il logo del cliente oppure clicca</div>
<div class="form-hint">PNG o SVG, max 2 MB — usato nel portale e nel widget</div>
<div class="upload-preview" id="logo-preview"><img id="logo-img" src=""><div style="font-size:11px;color:var(--ok);font-family:var(--mono)" id="logo-fname"></div></div>
</div>
</div>
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(0)">← Indietro</button><button class="btn btn-brand" onclick="goTab(2)">Avanti →</button></div>
</div>
<!-- 3: Rete & SSL -->
<div class="tab-panel" id="panel-2">
<div class="panel-title">🌐 Rete &amp; SSL</div>
<div class="panel-sub">Nome pubblico dell'istanza e certificato. Per WhatsApp e per i webhook dei canali serve HTTPS pubblico con certificato valido.</div>
<div class="form-grid">
<div class="form-full"><label class="form-label">Hostname principale <span>*</span></label><input class="form-input mono-input" id="domain" placeholder="es. iride.cliente.it"></div>
<div class="form-full"><label class="form-label">Nomi alternativi</label><input class="form-input mono-input" id="aliases" placeholder="es. assistente.cliente.it"><div class="form-hint">Separati da spazio — opzionale</div></div>
<div class="sec-div">Certificato SSL</div>
<div class="ssl-toggle">
<div class="ssl-btn active" id="ssl-le" onclick="setSsl('letsencrypt')">🔒 Let's Encrypt<small>Automatico, richiede DNS pubblico</small></div>
<div class="ssl-btn" id="ssl-manual" onclick="setSsl('manual')">📄 Certificato esistente<small>Carica .crt e .key</small></div>
<div class="ssl-btn" id="ssl-selfsigned" onclick="setSsl('selfsigned')">🔐 Autofirmato<small>LAN / demo — 10 anni</small></div>
</div>
<div id="le-block" class="form-full"><label class="form-label">Email amministratore <span>*</span></label><input class="form-input" id="admin_email" placeholder="admin@tecnotelsrl.com"><div class="form-hint">Per le notifiche di scadenza del certificato</div></div>
<div id="manual-block" class="form-full" style="display:none">
<div class="form-grid">
<div><label class="form-label">File .crt <span>*</span></label><input type="file" class="form-input" accept=".crt,.pem,.cer" onchange="uploadSsl(this,'cert')"><div class="form-hint" id="crt-status">Nessun file selezionato</div></div>
<div><label class="form-label">File .key <span>*</span></label><input type="file" class="form-input" accept=".key,.pem" onchange="uploadSsl(this,'key')"><div class="form-hint" id="key-status">Nessun file selezionato</div></div>
</div>
</div>
<div id="selfsigned-block" class="form-full" style="display:none">
<div class="alert alert-warn">Certificato RSA 4096 autofirmato, validità 10 anni, con hostname, alias e IP del server nel SAN. I browser lo segnaleranno come non attendibile: va bene per LAN e demo, non per WhatsApp.</div>
</div>
</div>
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(1)">← Indietro</button><button class="btn btn-brand" onclick="goTab(3)">Avanti →</button></div>
</div>
<!-- 4: AI -->
<div class="tab-panel" id="panel-3">
<div class="panel-title">🧠 Intelligenza artificiale</div>
<div class="panel-sub">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.</div>
<div class="form-grid">
<div><label class="form-label">Provider</label><select class="form-select" id="ai_provider" onchange="onProviderChange()"><option value="fake">Fake (demo, nessuna chiave)</option><option value="openai">OpenAI</option><option value="anthropic">Anthropic (disponibile nella slice 2)</option></select></div>
<div><label class="form-label">Modello</label><input class="form-input mono-input" id="ai_model" placeholder="gpt-4.1-mini" value="gpt-4.1-mini"></div>
<div class="form-full" id="ai-key-block" style="display:none"><label class="form-label">API key</label><input class="form-input mono-input" type="password" id="ai_api_key" placeholder="sk-..."><div class="form-hint">Salvata in /opt/iride/config/iride.json (0600). Prima di dati reali: DPA e regione di elaborazione verificati (B-063).</div></div>
</div>
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(2)">← Indietro</button><button class="btn btn-brand" onclick="goTab(4)">Avanti →</button></div>
</div>
<!-- 5: Utente admin -->
<div class="tab-panel" id="panel-4">
<div class="panel-title">👤 Utente amministratore</div>
<div class="panel-sub">Primo accesso al portale IRIDE. Gli altri utenti e i ruoli si creano dal portale.</div>
<div class="form-grid">
<div><label class="form-label">Username <span>*</span></label><input class="form-input" id="admin_username" placeholder="admin" value="admin"></div>
<div><label class="form-label">Email</label><input class="form-input" id="admin_email_user" placeholder="admin@cliente.it"></div>
<div><label class="form-label">Password <span>*</span></label><input class="form-input" type="password" id="admin_password" placeholder="Min. 12 caratteri"></div>
<div><label class="form-label">Conferma password <span>*</span></label><input class="form-input" type="password" id="admin_password2" placeholder="Ripeti la password"></div>
<div class="form-full" id="pw-error" style="display:none"><div class="alert alert-err">Le password non corrispondono o sono più corte di 12 caratteri</div></div>
</div>
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(3)">← Indietro</button><button class="btn btn-brand" onclick="goToInstall()">Riepilogo →</button></div>
</div>
<!-- 6: Installa -->
<div class="tab-panel" id="panel-5">
<div class="panel-title">🚀 Riepilogo e installazione</div>
<div class="panel-sub">Controlla i dati. L'installazione clona il repository, crea l'ambiente Python, configura SSL, nginx e i servizi systemd, poi chiude la porta 8888.</div>
<div id="summary-box"></div>
<div id="errors-box" style="display:none"></div>
<div id="spinner-box" style="display:none;text-align:center;padding:40px 20px">
<div style="width:48px;height:48px;border:4px solid var(--border2);border-top-color:var(--brand);border-radius:50%;margin:0 auto 20px;animation:spin 1s linear infinite"></div>
<div style="font-size:15px;font-weight:600;margin-bottom:6px">Installazione in corso</div>
<div style="font-size:12px;color:var(--text3);font-family:var(--mono)" id="spinner-status">Avvio installazione...</div>
<pre id="log-box" style="text-align:left;margin-top:20px;max-height:260px;overflow:auto;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:12px;font-family:var(--mono);font-size:11px;color:var(--text2)"></pre>
</div>
<div id="success-box" style="display:none;text-align:center;padding:40px 20px">
<div style="font-size:48px;margin-bottom:12px"></div>
<div style="font-size:18px;font-weight:700;margin-bottom:6px">IRIDE installata</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:24px">La porta 8888 è stata chiusa. Configura i canali in <code>/opt/iride/config/channels.json</code> e <code>credentials.json</code>.</div>
<button class="btn btn-ok" id="btn-open" onclick="openIride()">Apri IRIDE →</button>
</div>
<div class="nav-row" id="install-nav">
<button class="btn btn-ghost" onclick="goTab(4)">← Indietro</button>
<button class="btn btn-ok" id="btn-install" onclick="avviaInstallazione()">🚀 Avvia installazione</button>
</div>
</div>
</div>
</div>
<footer class="footer">IRIDE — Tecnotel Servizi SRL &nbsp;·&nbsp; Web Installer v0.2.0 &nbsp;·&nbsp; La porta 8888 verrà chiusa al termine</footer>
</div>
<script>
let currentTab = 0;
let sslMode = 'letsencrypt';
let certUploaded = false;
let keyUploaded = false;
let installing = false;
let installDomain = '';
let licenseValid = false;
async function loadMachineId() {
const el = document.getElementById('machine-id-display');
try {
const r = await fetch('/api/machine-id');
const d = await r.json();
el.value = (d && d.machine_id) ? d.machine_id : 'Errore caricamento';
} catch (e) { el.value = 'Errore: ' + e.message; }
}
function copyMachineId() {
const el = document.getElementById('machine-id-display');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = document.getElementById('btn-copy-mid');
const orig = btn.textContent;
btn.textContent = '✅ Copiato';
setTimeout(() => btn.textContent = orig, 1500);
}).catch(() => {});
}
function handleLicenseDrop(ev) {
ev.preventDefault();
ev.currentTarget.classList.remove('drag');
if (ev.dataTransfer.files.length) uploadLicense(ev.dataTransfer.files[0]);
}
function handleLicenseFile(inp) { if (inp.files.length) uploadLicense(inp.files[0]); }
async function uploadLicense(file) {
const errEl = document.getElementById('lic-error');
const sumEl = document.getElementById('lic-summary');
const btnNext = document.getElementById('btn-after-license');
errEl.style.display = 'none'; sumEl.style.display = 'none';
try {
const text = await file.text();
const r = await fetch('/api/license/upload', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: text });
const d = await r.json();
if (!d.ok) {
errEl.textContent = 'Licenza non valida: ' + (d.error || 'errore sconosciuto');
errEl.style.display = 'block';
licenseValid = false;
btnNext.disabled = true; btnNext.style.opacity = '.4'; btnNext.style.cursor = 'not-allowed';
return;
}
const s = d.summary || {};
document.getElementById('lic-customer').textContent = s.customer || '—';
document.getElementById('lic-product').textContent = s.product || '—';
document.getElementById('lic-tier').textContent = (s.tier || '—').toUpperCase();
document.getElementById('lic-hostname').textContent = s.issued_to || '—';
document.getElementById('lic-issued').textContent = s.issued_at || '—';
document.getElementById('lic-expires').textContent = s.expires_at || '—';
document.getElementById('lic-gitea').textContent = s.has_gitea ? '✓ Incluso' : '— Non incluso';
sumEl.style.display = 'block';
licenseValid = true;
btnNext.disabled = false; btnNext.style.opacity = '1'; btnNext.style.cursor = 'pointer';
if (s.issued_to && !g('domain')) document.getElementById('domain').value = s.issued_to;
} catch (e) {
errEl.textContent = 'Errore upload: ' + e.message;
errEl.style.display = 'block';
}
}
window.addEventListener('DOMContentLoaded', loadMachineId);
function goTab(n) {
if (n > 0 && !licenseValid) return;
if (n > currentTab) document.getElementById('tab-' + currentTab).classList.add('done');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.prog-step').forEach((s,i) => {
s.classList.remove('active','done');
if (i < n) s.classList.add('done'); else if (i === n) s.classList.add('active');
});
document.getElementById('panel-' + n).classList.add('active');
document.getElementById('tab-' + n).classList.add('active');
currentTab = n;
}
function goToInstall() {
const pw1 = g('admin_password'), pw2 = g('admin_password2');
const err = document.getElementById('pw-error');
if (pw1 !== pw2 || pw1.length < 12) { err.style.display = 'block'; return; }
err.style.display = 'none';
goTab(5);
buildSummary();
}
function setSsl(mode) {
sslMode = mode;
document.getElementById('ssl-le').classList.toggle('active', mode === 'letsencrypt');
document.getElementById('ssl-manual').classList.toggle('active', mode === 'manual');
document.getElementById('ssl-selfsigned').classList.toggle('active', mode === 'selfsigned');
document.getElementById('le-block').style.display = mode === 'letsencrypt' ? 'block' : 'none';
document.getElementById('manual-block').style.display = mode === 'manual' ? 'block' : 'none';
document.getElementById('selfsigned-block').style.display = mode === 'selfsigned' ? 'block' : 'none';
}
function onProviderChange() {
const p = g('ai_provider');
document.getElementById('ai-key-block').style.display = p === 'fake' ? 'none' : 'block';
const m = document.getElementById('ai_model');
if (p === 'openai' && (!m.value || m.value.startsWith('claude'))) m.value = 'gpt-4.1-mini';
if (p === 'anthropic' && (!m.value || m.value.startsWith('gpt'))) m.value = 'claude-sonnet-4-6';
}
async function uploadSsl(input, type) {
const file = input.files[0]; if (!file) return;
const sid = type === 'cert' ? 'crt-status' : 'key-status';
try {
const r = await fetch('/api/upload/' + type, { method: 'POST', body: await file.arrayBuffer() });
const d = await r.json();
if (d.ok) {
document.getElementById(sid).textContent = '✓ ' + file.name;
document.getElementById(sid).style.color = 'var(--ok)';
if (type === 'cert') certUploaded = true; else keyUploaded = true;
}
} catch(e) { document.getElementById(sid).textContent = 'Errore: ' + e.message; }
}
function handleLogoDrop(e) { e.preventDefault(); document.getElementById('logo-drop').classList.remove('drag'); processLogo(e.dataTransfer.files[0]); }
function handleLogoFile(input) { processLogo(input.files[0]); }
async function processLogo(file) {
if (!file || file.size > 2*1024*1024) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('logo-img').src = e.target.result;
document.getElementById('logo-fname').textContent = file.name;
document.getElementById('logo-preview').style.display = 'flex';
};
reader.readAsDataURL(file);
fetch('/api/upload/logo', { method: 'POST', body: await file.arrayBuffer() });
}
function g(id) { return (document.getElementById(id)?.value || '').trim(); }
function collectData() {
return {
cliente_name: g('cliente_name').toLowerCase().replace(/[^a-z0-9-]+/g, '-'), cliente_full: g('cliente_full'),
cliente_domain: g('cliente_domain'), cliente_type: g('cliente_type'), ai_context: g('ai_context'),
domain: g('domain'), aliases: g('aliases'), ssl_mode: sslMode, admin_email: g('admin_email'),
ai_provider: g('ai_provider') || 'fake', ai_model: g('ai_model'), ai_api_key: g('ai_api_key'),
admin_username: g('admin_username') || 'admin', admin_email_user: g('admin_email_user'),
admin_password: g('admin_password'),
};
}
function buildSummary() {
const d = collectData();
const errors = [];
if (!d.cliente_name) errors.push('Nome breve del cliente obbligatorio');
if (!d.cliente_full) errors.push('Nome completo del cliente obbligatorio');
if (!d.domain) errors.push('Hostname obbligatorio');
if (!d.admin_password) errors.push('Password admin obbligatoria');
if (d.ai_provider !== 'fake' && !d.ai_api_key) errors.push('API key del provider AI obbligatoria (oppure scegli Fake)');
if (sslMode === 'letsencrypt' && !d.admin_email) errors.push("Email per Let's Encrypt obbligatoria");
if (sslMode === 'manual' && (!certUploaded || !keyUploaded)) errors.push('Certificato SSL (.crt e .key) non caricato');
const row = (l, v) => '<div class="summary-row"><div class="summary-label">' + l + '</div><div class="summary-value ' + (v ? '' : 'empty') + '">' + (v || 'non configurato') + '</div></div>';
const sslLabel = sslMode === 'letsencrypt' ? "Let's Encrypt (automatico)" : sslMode === 'manual' ? "Certificato esistente caricato" : "Autofirmato (RSA 4096, 10 anni)";
document.getElementById('summary-box').innerHTML = '<div class="summary-table">' +
'<div class="summary-section">Cliente</div>' +
row('Nome breve', d.cliente_name) + row('Nome completo', d.cliente_full) + row('Tipo', d.cliente_type) + row('Dominio email', d.cliente_domain) +
(d.ai_context ? row('Contesto', d.ai_context.substring(0, 80) + (d.ai_context.length > 80 ? '…' : '')) : '') +
'<div class="summary-section">Rete &amp; SSL</div>' + row('Hostname', d.domain) + row('Alias', d.aliases) + row('SSL', sslLabel) +
'<div class="summary-section">Intelligenza artificiale</div>' + row('Provider', d.ai_provider) + row('Modello', d.ai_provider === 'fake' ? '—' : d.ai_model) + row('API key', d.ai_provider === 'fake' ? '—' : (d.ai_api_key ? '••••' + d.ai_api_key.slice(-4) : '')) +
'<div class="summary-section">Utente admin</div>' + row('Username', d.admin_username) + row('Email', d.admin_email_user) +
'</div>';
const errBox = document.getElementById('errors-box');
if (errors.length) {
errBox.style.display = 'block';
errBox.innerHTML = '<div class="alert alert-err" style="margin-bottom:16px">⚠️ <strong>Correggere prima di procedere:</strong><ul style="margin-top:8px;padding-left:16px">' + errors.map(e => '<li style="margin-top:4px">' + e + '</li>').join('') + '</ul></div>';
document.getElementById('btn-install').disabled = true;
} else {
errBox.style.display = 'none';
document.getElementById('btn-install').disabled = false;
}
}
async function avviaInstallazione() {
if (installing) return;
installing = true;
const data = collectData();
installDomain = data.domain;
document.getElementById('summary-box').style.display = 'none';
document.getElementById('errors-box').style.display = 'none';
document.getElementById('install-nav').style.display = 'none';
document.getElementById('spinner-box').style.display = 'block';
try {
const r = await fetch('/api/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
const res = await r.json();
if (!res.ok) throw new Error(res.error || 'Errore sconosciuto');
pollStatus();
} catch(e) {
document.getElementById('spinner-status').textContent = 'ERRORE: ' + e.message;
document.getElementById('spinner-status').style.color = 'var(--err)';
installing = false;
}
}
function pollStatus() {
let errors = 0;
const statusEl = document.getElementById('spinner-status');
const logEl = document.getElementById('log-box');
const iv = setInterval(async () => {
try {
const r = await fetch('/api/status');
const d = await r.json();
errors = 0;
if (d.log && d.log.length) {
logEl.textContent = d.log.join('\n');
logEl.scrollTop = logEl.scrollHeight;
const last = d.log[d.log.length-1];
if (last.startsWith('__ERROR__')) statusEl.textContent = last.replace('__ERROR__: ', 'Errore: ');
else if (last.includes('──')) statusEl.textContent = last.replace(/^\[[^\]]+\]\s*/, '').replace(/─/g, '').trim();
}
if (d.done) { clearInterval(iv); showSuccess(); }
if (d.error) { clearInterval(iv); statusEl.style.color = 'var(--err)'; }
} catch(e) {
errors++;
if (errors >= 6) { clearInterval(iv); showSuccess(); }
}
}, 2000);
}
function showSuccess() {
document.getElementById('spinner-box').style.display = 'none';
document.getElementById('success-box').style.display = 'block';
}
function openIride() { window.location.href = 'https://' + installDomain; }
</script>
</body>
</html>

679
setup_server.py Normal file
View File

@ -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://<IP_SERVER>:{PORT}\n{'='*55}\n")
HTTPServer(("0.0.0.0", PORT), SetupHandler).serve_forever()