680 lines
28 KiB
Python
680 lines
28 KiB
Python
#!/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()
|