#!/usr/bin/python3
# -*- coding: utf-8 -*-
# Nexora Panel Installer - hardened build for Ubuntu 24.04 (Noble) / MariaDB
# (reworked from the community xtream-ui installer; downloads and installs the
# panel payload, then applies Nexora branding + security hardening)
# Fresh installs use MariaDB (stable LTS from apt) instead of MySQL 8:
#   - proven locally: the panel runs identically on MariaDB 11.4 (mysqlnd)
#   - libcurl3, libjemalloc1, python-paramiko -> modern equivalents
#   - removed query_cache_* / MySQL-8-only options from my.cnf
#   - apt output is visible so failures are easy to spot
import subprocess, os, random, string, sys, shutil, socket, zipfile, base64
from zipfile import ZipFile
import urllib.request as urllib2
from urllib.request import Request, urlopen, URLError, HTTPError
from itertools import cycle
izip = zip

# Download sources for the panel payload (~142MB tarball). Default is the
# community mirror; to host the payload on your own storage, either set the
# env vars NEXORA_DOWNLOAD_URL_MAIN / NEXORA_DOWNLOAD_URL_SUB or edit
# rDownloadURL. The MAIN payload is verified against rDownloadSHA after download.
#
# SELF-HOSTING (zero external downloads — see NEXORA-MIRROR/prepare_nexora_mirror.sh):
#   export NEXORA_DOWNLOAD_URL_MAIN="http://YOUR-SRV/xtreamcodes.tar.gz"
#   export NEXORA_DOWNLOAD_URL_SUB="http://YOUR-SRV/sub_xtreamcodes_reborn.tar.gz"
#   export NEXORA_LIBPNG_URL="http://YOUR-SRV/libpng12.deb"
#   export NEXORA_FFMPEG_URL="http://YOUR-SRV/ffmpeg-8.1.tar.xz"
# Or simpler: pre-place the files in /root/ on the server (xtreamcodes.tar.gz,
# ffmpeg-8.1.tar.xz, libpng12.deb) and no download happens at all.
# The only remaining internet dependency is apt (OS packages from Ubuntu repos).
#
# SELLABLE / LICENSED INSTALL (Phase 1 — subscription enforcement):
#   export NEXORA_LICENSE_KEY="base64.json.sha256"   # signed license from your seller
#   export NEXORA_LICENSE_URL="https://your-site/renew?lic="  # optional auto-renew endpoint
#   export NEXORA_LICENSE_SECRET="your-hmac-secret"  # must match the seller's signing key
#   export NEXORA_LICENSE_GRACE="7"                   # grace days after expiry (default 7)
# When NEXORA_LICENSE_KEY is set, activateLicense() deploys:
#   /opt/nexora-license.lic   (the signed license)
#   /opt/nexora-license.conf  (secret + grace + renew URL)
#   /opt/nexora-license/license_check.sh  (daily cron: verify + enforce)
# When not set, the installer runs as a free / self-hosted install (no enforcement).
rDownloadURL = {
    "main": os.environ.get("NEXORA_DOWNLOAD_URL_MAIN",
        "https://bitbucket.org/emre1393/xtreamui_mirror/downloads/main_xtreamcodes_reborn.tar.gz"),
    "sub": os.environ.get("NEXORA_DOWNLOAD_URL_SUB",
        "https://bitbucket.org/emre1393/xtreamui_mirror/downloads/sub_xtreamcodes_reborn.tar.gz"),
}
# sha256 of the known-good MAIN payload (matches /root/xtreamcodes.tar.gz).
rDownloadSHA = {
    "main": "7a6f823d0f228495ed7bbe597a6557de7ffe56e9bc911e206418e4ef8ba33225",
    "sub": "",  # unknown -> verification skipped for LB payloads
}
rPackages = ["libxslt1-dev", "libgeoip-dev", "e2fsprogs", "wget", "nscd", "htop", "zip", "unzip", "mc", "libjemalloc2", "python3-paramiko", "mariadb-server"]
rInstall = {"MAIN": "main", "LB": "sub"}
rUpdate = {"UPDATE": "update"}

rMySQLCnf = """# Nexora Panel - MariaDB (stable)

[client]
port            = 3306
socket          = /run/mysqld/mysqld.sock

[mysqld_safe]
nice            = 0

[mysqld]
user            = mysql
port            = 7999
basedir         = /usr
datadir         = /var/lib/mysql
tmpdir          = /tmp
socket          = /run/mysqld/mysqld.sock
pid-file        = /run/mysqld/mysqld.pid
log-error       = /var/log/mysql/error.log

lc-messages-dir = /usr/share/mysql
skip-external-locking
skip-name-resolve

bind-address            = 127.0.0.1

key_buffer_size = 128M
myisam_sort_buffer_size = 4M
max_allowed_packet      = 64M
myisam-recover = BACKUP
max_length_for_sort_data = 8192
max_binlog_size = 100M
transaction_isolation = READ-COMMITTED
max_connections  = 10000
open_files_limit = 10240
max_connect_errors = 4096
table_open_cache = 4096
table_definition_cache = 4096
tmp_table_size = 1G
max_heap_table_size = 1G
max_statement_time = 0
back_log = 4096

innodb_buffer_pool_size = 8G
innodb_buffer_pool_instances = 8
innodb_read_io_threads = 64
innodb_write_io_threads = 64
innodb_thread_concurrency = 0
innodb_flush_log_at_trx_commit = 0
innodb_flush_method = O_DIRECT
innodb_io_capacity = 10000
innodb_table_locks = 0
innodb_lock_wait_timeout = 0

character-set-server = utf8mb4
collation-server = utf8mb4_general_ci
sql-mode = "NO_ENGINE_SUBSTITUTION"

[mysqldump]
quick
quote-names
max_allowed_packet      = 128M
complete-insert

[mysql]

[isamchk]
key_buffer_size              = 16M
"""

# >>> BEGIN NEXORA-MIGRATE-EMBED (generated by sync_migration_installer.py) >>>
# Nexora DB migration tooling, baked into the installer so fresh installs
# always get it (the release zip provides the same files via update()).
NEXORA_MIGRATE_SH = r"""#!/bin/bash
# ============================================================================
#  nexora-migrate.sh — Nexora DB Migration Center (MySQL -> MariaDB) — SIMPLE
# ----------------------------------------------------------------------------
#  Root-only, single-purpose migration with the smallest safe core:
#    PRE    -> source/target reachable, versions, table counts
#    DUMP   -> mysqldump source   -> /opt/nexora-migrate/dumps/
#    COUNTS -> source row counts captured right after the dump (snapshot)
#    BACKUP -> mysqldump target   -> /opt/nexora-migrate/backups/ (if it exists)
#    IMPORT -> restore dump into a TEMP database on the target
#    VERIFY -> exact table + per-table row counts match the dump snapshot
#    SWAP   -> journaled RENAME (live -> .pre_mig_<ts>, temp -> live)
#  Modes:  --dry-run (default)  |  --apply  |  --rollback
#  Run as ROOT (db_migration.php elevates via the Nexora sudoers rule).
#  NEVER touches nginx/php-fpm/panel files — database only.
#  NOTE: for a clean run, stop the legacy panel first (a live source keeps
#  writing, so its log tables can drift between dump and import).
#
#  Env interface (unchanged from db_migration.php):
#    SRC_HOST SRC_PORT SRC_DB SRC_USER SRC_PASS
#    TGT_SOCK TGT_PORT TGT_DB TMP_DB
# ============================================================================
set -u
umask 077

MIG_DIR=/opt/nexora-migrate
LOGS=$MIG_DIR/logs
BACKUPS=$MIG_DIR/backups
DUMPS=$MIG_DIR/dumps
mkdir -p "$LOGS" "$BACKUPS" "$DUMPS" 2>/dev/null || true

TS=$(date +%Y%m%d-%H%M%S)
MODE=dry
SRC_HOST=${SRC_HOST:-127.0.0.1}
SRC_PORT=${SRC_PORT:-3306}
SRC_DB=${SRC_DB:-xtream_iptvpro}
SRC_USER=${SRC_USER:-root}
SRC_PASS=${SRC_PASS:-}
TGT_SOCK=${TGT_SOCK:-}
TGT_DB=${TGT_DB:-xtream_iptvpro}
TMP_DB=${TMP_DB:-xtream_iptvpro_mig}

for arg in "$@"; do
  case "$arg" in
    --apply)    MODE=apply ;;
    --rollback) MODE=rollback ;;
    --dry-run)  MODE=dry ;;
  esac
done

say() { printf '%s\n' "$*" | tee -a "$LOG"; }
die() { say "FATAL: $*"; exit 1; }

# ---- client selection ------------------------------------------------------
CLI=$(command -v mariadb || command -v mysql || true)
DUMP=$(command -v mariadb-dump || command -v mysqldump || true)
[ -n "$CLI" ]  || die "no mariadb/mysql client found"
[ -n "$DUMP" ] || die "no mariadb-dump/mysqldump found"

# target socket: explicit, distro default, or the /opt tarball install
if [ -z "$TGT_SOCK" ]; then
  for s in /run/mysqld/mysqld.sock /var/run/mysqld/mysqld.sock \
           /opt/mariadb-prod/mariadb.sock /opt/mariadb-*/mariadb.sock; do
    [ -S "$s" ] && { TGT_SOCK=$s; break; }
  done
fi
[ -n "$TGT_SOCK" ] && [ -S "$TGT_SOCK" ] || die "target MariaDB socket not found (set TGT_SOCK)"
TGT_CMD=("$CLI" --socket="$TGT_SOCK" -u root)

# ---- rollback (restore live names from the journal) ------------------------
if [ "$MODE" = rollback ]; then
  LOG="$LOGS/migrate-$TS-rollback.log"
  : > "$LOG"
  J="$LOGS/last-rollback.txt"
  [ -f "$J" ] || die "no rollback journal ($J)"
  say "ROLLBACK using journal: $J"
  while IFS='|' read -r live old; do
    [ -z "$live" ] && continue
    "${TGT_CMD[@]}" -e "DROP TABLE IF EXISTS \`$TGT_DB\`.\`$live\`" 2>>"$LOG" || say "  WARN could not drop $live"
    if [ "$old" != "NEW" ]; then
      if "${TGT_CMD[@]}" -e "RENAME TABLE \`$TGT_DB\`.\`$old\` TO \`$TGT_DB\`.\`$live\`" 2>>"$LOG"; then
        say "  restored: $live"
      else
        say "  WARN could not restore $live"
      fi
    else
      say "  dropped (was new): $live"
    fi
  done < "$J"
  say "ROLLBACK complete."
  exit 0
fi

LOG="$LOGS/migrate-$TS-$MODE.log"
: > "$LOG"
say "=== Nexora DB Migration $TS (mode=$MODE) ==="
say "source: $SRC_HOST:$SRC_PORT/$SRC_DB   target: socket=$TGT_SOCK db=$TGT_DB tmp=$TMP_DB"

# ---- PRE: source -----------------------------------------------------------
SRC_CMD=("$CLI" -h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER")
[ -n "$SRC_PASS" ] && SRC_CMD+=(-p"$SRC_PASS")
"${SRC_CMD[@]}" -e "SELECT 1" >/dev/null 2>>"$LOG" || die "source unreachable at $SRC_HOST:$SRC_PORT"
SRC_VER=$("${SRC_CMD[@]}" -N -e "SELECT VERSION()" 2>>"$LOG" | tail -1)
SRC_TABLES=$("${SRC_CMD[@]}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$SRC_DB'" 2>>"$LOG")
say "  source OK: $SRC_VER, tables=$SRC_TABLES"
[ -n "$SRC_TABLES" ] && [ "$SRC_TABLES" -gt 0 ] 2>/dev/null || die "source DB '$SRC_DB' has no tables"

# ---- PRE: target -----------------------------------------------------------
"${TGT_CMD[@]}" -e "SELECT 1" >/dev/null 2>>"$LOG" || die "target unreachable via $TGT_SOCK"
TGT_VER=$("${TGT_CMD[@]}" -N -e "SELECT VERSION()" 2>>"$LOG" | tail -1)
say "  target OK: $TGT_VER"

# ---- DUMP (source) ---------------------------------------------------------
DUMPF="$DUMPS/$SRC_DB-$TS.sql"
say "[DUMP] $SRC_DB -> $DUMPF"
if ! "$DUMP" -h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER" ${SRC_PASS:+-p"$SRC_PASS"} \
     --max-allowed-packet=1G --single-transaction --routines --triggers "$SRC_DB" > "$DUMPF" 2>>"$LOG"; then
  # fall back without routines/triggers (some legacy users lack those privs)
  say "  routines dump failed, retrying without --routines/--triggers"
  "$DUMP" -h "$SRC_HOST" -P "$SRC_PORT" -u "$SRC_USER" ${SRC_PASS:+-p"$SRC_PASS"} \
     --max-allowed-packet=1G --single-transaction "$SRC_DB" > "$DUMPF" 2>>"$LOG" || die "source dump failed (log: $LOG)"
fi
# strip any USE statement — the import goes into the TEMP db, never the source
sed -i '/^USE /d' "$DUMPF"
DUMP_SHA=$(sha256sum "$DUMPF" | awk '{print $1}')
say "  dump sha256: $DUMP_SHA"

# capture source row counts right after the dump — the snapshot the dump
# actually contains (a live source keeps writing, so counts read at verify
# time would differ on busy log tables). Stop the legacy panel for a clean run.
: > "$DUMPF.counts"
while read -r tbl; do
  [ -z "$tbl" ] && continue
  echo "$tbl|$("${SRC_CMD[@]}" -N -e "SELECT COUNT(*) FROM \`$SRC_DB\`.\`$tbl\`" 2>>"$LOG")" >> "$DUMPF.counts"
done < <("${SRC_CMD[@]}" -N -e "SELECT table_name FROM information_schema.tables WHERE table_schema='$SRC_DB' ORDER BY table_name" 2>>"$LOG")

if [ "$MODE" = dry ]; then
  say "=== DRY-RUN COMPLETE — no target changes made ==="
  exit 0
fi

# ---- BACKUP (target, only if it exists) ------------------------------------
TGT_EXISTS=$("${TGT_CMD[@]}" -N -e "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='$TGT_DB'" 2>>"$LOG")
if [ "$TGT_EXISTS" = 1 ]; then
  BK="$BACKUPS/$TGT_DB-$TS.sql.gz"
  say "[BACKUP] $TGT_DB -> $BK"
  "$DUMP" --socket="$TGT_SOCK" -u root --max-allowed-packet=1G --single-transaction \
         --routines --triggers -B "$TGT_DB" 2>>"$LOG" | gzip > "$BK" || die "target backup failed"
  say "  backup sha256: $(sha256sum "$BK" | awk '{print $1}')"
else
  say "[BACKUP] target '$TGT_DB' does not exist yet — no backup needed"
fi

# ---- IMPORT into TEMP db ----------------------------------------------------
say "[IMPORT] temp db: $TMP_DB"
"${TGT_CMD[@]}" -e "DROP DATABASE IF EXISTS \`$TMP_DB\`; CREATE DATABASE \`$TMP_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" 2>>"$LOG" || die "temp db create failed"
"${TGT_CMD[@]}" "$TMP_DB" < "$DUMPF" 2>>"$LOG" || die "import failed (log: $LOG)"

# ---- VERIFY ----------------------------------------------------------------
say "[VERIFY] comparing row counts (dump snapshot vs temp)"
MISMATCH=0
while IFS='|' read -r tbl s; do
  [ -z "$tbl" ] && continue
  t=$("${TGT_CMD[@]}" -N -e "SELECT COUNT(*) FROM \`$TMP_DB\`.\`$tbl\`" 2>>"$LOG")
  if [ "$s" != "$t" ]; then
    say "  MISMATCH $tbl: source=$s temp=$t"
    MISMATCH=$((MISMATCH+1))
  fi
done < "$DUMPF.counts"
T_CNT=$("${TGT_CMD[@]}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TMP_DB'" 2>>"$LOG")
if [ "$MISMATCH" != 0 ] || [ "$T_CNT" != "$SRC_TABLES" ]; then
  say "VERIFY FAILED: mismatches=$MISMATCH temp_tables=$T_CNT expected=$SRC_TABLES — target untouched"
  exit 2
fi
say "  VERIFY PASS: $T_CNT/$SRC_TABLES tables, all row counts identical"

# ---- SWAP (journaled) --------------------------------------------------------
J="$LOGS/last-rollback.txt"
: > "$J"
say "[SWAP] promoting temp -> live (journal: $J)"
"${TGT_CMD[@]}" -e "CREATE DATABASE IF NOT EXISTS \`$TGT_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" 2>>"$LOG" || die "target db create failed"
while read -r tbl; do
  [ -z "$tbl" ] && continue
  old="${tbl}.pre_mig_$TS"
  LIVE=$("${TGT_CMD[@]}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TGT_DB' AND table_name='$tbl'" 2>>"$LOG")
  if [ "$LIVE" = 1 ]; then
    "${TGT_CMD[@]}" -e "RENAME TABLE \`$TGT_DB\`.\`$tbl\` TO \`$TGT_DB\`.\`$old\`" 2>>"$LOG" || die "swap failed at $tbl"
    echo "$tbl|$old" >> "$J"
  else
    # table did not exist before: journal marks it NEW so rollback drops it
    echo "$tbl|NEW" >> "$J"
  fi
  "${TGT_CMD[@]}" -e "RENAME TABLE \`$TMP_DB\`.\`$tbl\` TO \`$TGT_DB\`.\`$tbl\`" 2>>"$LOG" || die "swap failed promoting $tbl"
done < <("${SRC_CMD[@]}" -N -e "SELECT table_name FROM information_schema.tables WHERE table_schema='$SRC_DB' ORDER BY table_name" 2>>"$LOG")

# procedures/functions/events are NOT moved by RENAME TABLE — re-apply the
# routine section of the dump directly to the live db before dropping temp
ROUT="$DUMPF.routines"
awk '/^DELIMITER ;;/{on=1} on{print} /^DELIMITER ;$/{on=0; exit}' "$DUMPF" > "$ROUT"
if [ -s "$ROUT" ]; then
  say "[ROUTINES] applying procedures/functions/events to $TGT_DB"
  # drop existing routines first: some dumps carry no DROP, so a plain CREATE
  # would fail with 'already exists' when migrating over a live database
  while read -r rn; do
    [ -n "$rn" ] && "${TGT_CMD[@]}" -e "DROP PROCEDURE IF EXISTS \`$TGT_DB\`.\`$rn\`" 2>>"$LOG"
  done < <("${TGT_CMD[@]}" -N -e "SELECT ROUTINE_NAME FROM information_schema.routines WHERE routine_schema='$TGT_DB' AND routine_type='PROCEDURE'" 2>>"$LOG")
  while read -r rn; do
    [ -n "$rn" ] && "${TGT_CMD[@]}" -e "DROP FUNCTION IF EXISTS \`$TGT_DB\`.\`$rn\`" 2>>"$LOG"
  done < <("${TGT_CMD[@]}" -N -e "SELECT ROUTINE_NAME FROM information_schema.routines WHERE routine_schema='$TGT_DB' AND routine_type='FUNCTION'" 2>>"$LOG")
  while read -r en; do
    [ -n "$en" ] && "${TGT_CMD[@]}" -e "DROP EVENT IF EXISTS \`$TGT_DB\`.\`$en\`" 2>>"$LOG"
  done < <("${TGT_CMD[@]}" -N -e "SELECT EVENT_NAME FROM information_schema.events WHERE event_schema='$TGT_DB'" 2>>"$LOG")
  "${TGT_CMD[@]}" "$TGT_DB" < "$ROUT" 2>>"$LOG" || say "  WARN routines apply failed (log: $LOG)"
fi
rm -f "$ROUT"

"${TGT_CMD[@]}" -e "DROP DATABASE IF EXISTS \`$TMP_DB\`" 2>>"$LOG" || true

say "=== MIGRATION COMPLETE (mode=apply) ==="
exit 0
"""

NEXORA_DB_MIGRATION_PHP = r"""<?php
// ============================================================================
//  db_migration.php — Nexora DB Migration Center (MySQL -> MariaDB)
// ----------------------------------------------------------------------------
//  Visual, read-first tool: engine status, table/row comparison, safe script
//  generation (dry-run by default), guarded execution and run logs.
//  - No secrets are ever displayed or stored by this page.
//  - The script runs as ROOT (Nexora sudoers rule) against the target via the
//    MariaDB socket; the source credentials are passed via env vars only.
//  - Target live schema is only touched by the script's journaled SWAP phase,
//    never by this PHP page directly.
//  - Follows the exact page skeleton of settings.php (auth + permissions).
// ============================================================================
include "session.php"; include "functions.php";
if (!$rPermissions["is_admin"]) { exit; }
if ((!hasPermissions("adv", "settings")) && (!hasPermissions("adv", "database"))) { exit; }

$rSettings = getSettings();
$rSettings["sidebar"] = $rUserInfo["sidebar"];
if ($rSettings["sidebar"]) { include "header_sidebar.php"; } else { include "header.php"; }
if ($rSettings["sidebar"]) { ?>
<div class="content-page"><div class="content boxed-layout-ext"><div class="container-fluid">
<?php } else { ?>
<div class="wrapper boxed-layout-ext"><div class="container-fluid">
<?php } ?>

<?php
// ---- helpers ---------------------------------------------------------------
$MIG_DIR  = "/opt/nexora-migrate";
$MIG_SCRIPT = "$MIG_DIR/nexora-migrate.sh";
$LOGS     = "$MIG_DIR/logs";

function nx_esc($s) { return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }

// target = the panel's current DB (MariaDB) — read from the panel config
$rInfo = $_INFO; // host/db_user/db_pass/db_name/db_port from functions.php
$TGT = array(
  "host" => $rInfo["host"], "port" => $rInfo["db_port"], "db" => $rInfo["db_name"],
  "user" => $rInfo["db_user"]
);

// source = legacy MySQL (defaults: 127.0.0.1:3306, same db name)
$SRC = array(
  "host" => isset($_POST["src_host"]) ? trim($_POST["src_host"]) : "127.0.0.1",
  "port" => isset($_POST["src_port"]) ? trim($_POST["src_port"]) : "3306",
  "db"   => isset($_POST["src_db"])   ? trim($_POST["src_db"])   : $TGT["db"],
  "user" => isset($_POST["src_user"]) ? trim($_POST["src_user"]) : "root"
);
// Source password is POST-only, never persisted and never displayed.
$SRC_PASS = isset($_POST["src_pass"]) ? (string)$_POST["src_pass"] : "";

// ---- read-only status ------------------------------------------------------
function nx_db_status($host, $port, $db, $user, $pass = null) {
  $r = array("ok" => false);
  $c = @new mysqli($host, $user, (string)$pass, "", (int)$port);
  if ($c && !$c->connect_error) {
    $r["ok"] = true;
    $v = $c->query("SELECT VERSION()");
    $r["version"] = $v ? $v->fetch_row()[0] : "?";
    $t = $c->query("SELECT COUNT(*) c FROM information_schema.tables WHERE table_schema='" . $c->real_escape_string($db) . "'");
    $r["tables"] = $t ? (int)$t->fetch_row()[0] : 0;
    if ($r["tables"] > 0) {
      $q = $c->query("SELECT SUM(t.table_rows) s FROM information_schema.tables t WHERE t.table_schema='" . $c->real_escape_string($db) . "'");
      $r["rows"] = $q ? (int)$q->fetch_row()[0] : 0;
    } else { $r["rows"] = 0; }
    $c->close();
  } else {
    $r["error"] = $c ? $c->connect_error : "no connection";
  }
  return $r;
}

// target status via the panel's own credentials (read-only)
$TGT_STATUS = nx_db_status($TGT["host"], $TGT["port"], $TGT["db"], $TGT["user"], $rInfo["db_pass"]);
// source status: try provided credentials first, then root via TCP without password
$SRC_STATUS = array("ok" => false);
if (function_exists("mysqli_connect")) {
  $SRC_STATUS = nx_db_status($SRC["host"], $SRC["port"], $SRC["db"], $SRC["user"], $SRC_PASS);
  if (!$SRC_STATUS["ok"] && $SRC["user"] === "root" && $SRC_PASS === "") {
    // try root via TCP without password (many legacy installs)
    $SRC_STATUS = nx_db_status($SRC["host"], $SRC["port"], $SRC["db"], "root", "");
  }
}

// ---- actions ---------------------------------------------------------------
$ACTION  = isset($_GET["action"]) ? $_GET["action"] : "";
$rMsg    = "";
$rError  = "";
$rLog    = "";
$rPlan   = "";

function nx_shell($cmd, &$out, &$code) {
  $out = array(); $code = -1;
  exec($cmd . " 2>&1", $out, $code);
  return $code;
}

// PREPARE: generate the migration script + show plan + SHA
if ($ACTION === "prepare" && isset($_POST["confirm_prepare"])) {
  // Resolve the canonical script path dynamically (portable across installs):
  //   try the panel tools dir (derived from this file's location), then release copies.
  $canonical = "";
  $toolsDir = realpath(__DIR__ . "/../pytools");
  if ($toolsDir && file_exists("$toolsDir/nexora-migrate.sh")) { $canonical = "$toolsDir/nexora-migrate.sh"; }
  if (!$canonical && file_exists(__DIR__ . "/nexora-migrate.sh")) { $canonical = __DIR__ . "/nexora-migrate.sh"; }
  if (!$canonical && file_exists(__DIR__ . "/../nexora-migrate.sh")) { $canonical = __DIR__ . "/../nexora-migrate.sh"; }
  if (!file_exists($canonical)) {
    $rError = "Migration script not found in panel (expected pytools/nexora-migrate.sh).";
  } else {
    @mkdir($MIG_DIR, 0755, true);
    @mkdir($LOGS, 0755, true);
    $ok = @copy($canonical, $MIG_SCRIPT);
    if ($ok) { @chmod($MIG_SCRIPT, 0750); }
    if ($ok && file_exists($MIG_SCRIPT)) {
      $sha = hash_file("sha256", $MIG_SCRIPT);
      $rPlan  = "Script installed at <code>$MIG_SCRIPT</code><br>";
      $rPlan .= "SHA-256: <code>$sha</code><br>";
      $rPlan .= "Source: <code>{$SRC['host']}:{$SRC['port']}/{$SRC['db']}</code> &rarr; Target: <code>{$TGT['host']}:{$TGT['port']}/{$TGT['db']}</code><br><br>";
      $rPlan .= "<b>Dry-run first (recommended):</b><br><code>sudo bash $MIG_SCRIPT --dry-run</code><br><br>";
      $rPlan .= "<b>Apply (journaled, with backup):</b><br><code>sudo bash $MIG_SCRIPT --apply</code><br><br>";
      $rPlan .= "<b>Rollback (if ever needed):</b><br><code>sudo bash $MIG_SCRIPT --rollback</code>";
      $rMsg = "Migration package prepared.";
    } else {
      $rError = "Failed to write migration script (permissions?).";
    }
  }
}

// RUN: execute the script (apply or dry-run) — guarded by explicit confirm
if ($ACTION === "run" && isset($_POST["confirm_run"]) && $_POST["confirm_run"] === "MIGRATE") {
  if (!file_exists($MIG_SCRIPT)) { $rError = "Prepare first."; }
  else {
    $mode = ($_POST["run_mode"] === "apply") ? "--apply" : "--dry-run";
    $cmd  = "env SRC_HOST='{$SRC['host']}' SRC_PORT='{$SRC['port']}' SRC_DB='{$SRC['db']}' "
          . "SRC_USER='{$SRC['user']}' SRC_PASS='" . str_replace("'", "'\\''", $SRC_PASS) . "' "
          . "sudo -n bash $MIG_SCRIPT $mode";
    $code = nx_shell($cmd, $out, $code);
    $rLog = implode("\n", $out);
    if ($code === 0) { $rMsg = "Migration command finished (exit 0). See log below."; }
    else             { $rError = "Migration command exit=$code — check log / permissions."; }
  }
}

// VIEW LOG: tail the latest migration log
if ($ACTION === "log") {
  $files = glob("$LOGS/migrate-*.log");
  if ($files) { rsort($files); $rLog = (string)@file_get_contents($files[0]); }
  else { $rLog = "No migration logs yet."; }
}

// ---- render ----------------------------------------------------------------
?>

<form action="./db_migration.php?action=prepare" method="POST" id="nx_mig_form">
    <div class="row">
        <div class="col-12">
            <div class="page-title-box">
                <h4 class="page-title">DB Migration Center <span class="text-muted font-14">MySQL &rarr; MariaDB</span></h4>
                <div class="page-title-right">
                    <ol class="breadcrumb m-0">
                        <li class="breadcrumb-item"><a href="./settings.php">Settings</a></li>
                        <li class="breadcrumb-item active">DB Migration Center</li>
                    </ol>
                </div>
            </div>
        </div>
    </div>

    <?php if ($rMsg): ?><div class="alert alert-success"><?=$rMsg?></div><?php endif; ?>
    <?php if ($rError): ?><div class="alert alert-danger"><?=$rError?></div><?php endif; ?>

    <!-- beginner guide (simplified) -->
    <div class="card mb-3">
        <div class="card-header"><h5 class="header-title"><i class="mdi mdi-book-open-page-variant text-info"></i> دليل الاستخدام المبسّط — كيف تنقل لوحتك القديمة إلى Nexora</h5></div>
        <div class="card-body">
            <p class="mb-2">لوحتك القديمة تعمل على <b>MySQL</b>، ولوحة Nexora الجديدة تعمل على <b>MariaDB</b> (الأحدث والأكثر استقراراً). هذه الصفحة تنقل <b>كل قاعدة البيانات</b> (المستخدمين، القنوات، الباقات، الإعدادات) من MySQL إلى MariaDB <b>دون لمس ملفات اللوحة أو البث</b>.</p>
            <div class="row">
                <div class="col-md-6">
                    <div class="border rounded p-2 mb-2" style="background:var(--nx-surface-2);">
                        <b>السيناريو 1 — لديك نسخة احتياطية (ملف .sql) من لوحتك القديمة:</b>
                        <ol class="mb-0 font-13">
                            <li>استعد النسخة في MySQL أولاً: <code>mysql -u root &lt; backup.sql</code></li>
                            <li>في هذه الصفحة: املأ بيانات MySQL في خانة <i>Legacy Source</i> (host, port, db, user, password)</li>
                            <li>اضغط الخطوات بالأسفل بالترتيب: <b>① Prepare ← ② Dry-run ← ③ Apply</b></li>
                        </ol>
                    </div>
                </div>
                <div class="col-md-6">
                    <div class="border rounded p-2 mb-2" style="background:var(--nx-surface-2);">
                        <b>السيناريو 2 — لوحة قديمة ما زالت تعمل على نفس السيرفر:</b>
                        <ol class="mb-0 font-13">
                            <li>املأ بياناتها في خانة <i>Legacy Source</i> (عادةً <code>127.0.0.1:3306</code> + اسم قاعدة + مستخدم الجذر وكلمة المرور)</li>
                            <li>الهدف (Target) يُقرأ تلقائياً من إعدادات Nexora الحالية — لا تغيّره</li>
                            <li>تابع الخطوات ① ② ③ بالأسفل</li>
                        </ol>
                    </div>
                </div>
            </div>
            <table class="table table-sm table-bordered mb-2 font-13">
                <thead><tr><th style="width:22%">الخطوة</th><th>ماذا تفعل؟</th><th>هل تغيّر شيئاً؟</th></tr></thead>
                <tbody>
                    <tr><td><b>① Prepare</b> (تحضير)</td><td>تجهّز سكربت الهجرة وتتحقق من إمكانية الوصول للمصدر والهدف، وتعرض ملخصاً ورقم SHA.</td><td><span class="text-success">لا — قراءة فقط</span></td></tr>
                    <tr><td><b>② Dry-run</b> (تجربة)</td><td>تنشئ نسخة كاملة من بياناتك في ملف وتفحصها — <b>قاعدة اللوحة الحالية لا تُلمس إطلاقاً</b>.</td><td><span class="text-success">لا — قراءة فقط</span></td></tr>
                    <tr><td><b>③ Apply</b> (تنفيذ)</td><td>الهجرة الفعلية: نسخة احتياطية تلقائية ← استيراد في قاعدة مؤقتة ← تحقق صارم من كل الجداول والصفوف ← تبديل آمن. اكتب <b>MIGRATE</b> للتأكيد.</td><td><span class="text-danger">نعم — هذه الخطوة الوحيدة التي تغيّر قاعدة الهدف</span></td></tr>
                    <tr><td><b>Rollback</b> (استرجاع)</td><td>إن حدثت أي مشكلة بعد Apply: يعيد قاعدة الهدف إلى حالتها السابقة فوراً.</td><td>يستعيد فقط ما غيّره Apply</td></tr>
                </tbody>
            </table>
            <div class="alert alert-warning py-2 mb-1 font-13">
                <i class="mdi mdi-lightbulb-on-outline"></i> <b>نصائح مهمة:</b>
                &nbsp;⏸️ أوقف اللوحة القديمة قبل <b>Apply</b> (حتى لا تتغير البيانات أثناء النقل) &nbsp;·&nbsp; لا تغلق الصفحة أثناء Apply &nbsp;·&nbsp; بعد النجاح تأكد من عمل اللوحة الجديدة ثم يمكنك إيقاف MySQL القديم &nbsp;·&nbsp; النسخ الاحتياطية تُحفظ في <code>/opt/nexora-migrate/backups/</code> والسجلات في <code>/opt/nexora-migrate/logs/</code>
            </div>
        </div>
    </div>

    <!-- english guide (simplified) -->
    <div class="card mb-3">
        <div class="card-header"><h5 class="header-title"><i class="mdi mdi-book-open-page-variant text-info"></i> Quick Start Guide — migrating your old panel to Nexora</h5></div>
        <div class="card-body">
            <p class="mb-2">Your old panel runs on <b>MySQL</b>; Nexora runs on <b>MariaDB</b> (newer, more stable). This page moves the <b>entire database</b> (users, channels, bouquets, settings) from MySQL to MariaDB <b>without touching panel files or streaming</b>.</p>
            <div class="row">
                <div class="col-md-6">
                    <div class="border rounded p-2 mb-2" style="background:var(--nx-surface-2);">
                        <b>Scenario 1 — You have a backup file (.sql) of your old panel:</b>
                        <ol class="mb-0 font-13">
                            <li>Restore it into MySQL first: <code>mysql -u root &lt; backup.sql</code></li>
                            <li>Fill in the MySQL details in the <i>Legacy Source</i> fields (host, port, db, user, password)</li>
                            <li>Click the steps below in order: <b>① Prepare ← ② Dry-run ← ③ Apply</b></li>
                        </ol>
                    </div>
                </div>
                <div class="col-md-6">
                    <div class="border rounded p-2 mb-2" style="background:var(--nx-surface-2);">
                        <b>Scenario 2 — Old panel still running on the same server:</b>
                        <ol class="mb-0 font-13">
                            <li>Fill its details in <i>Legacy Source</i> (usually <code>127.0.0.1:3306</code> + db name + root user/password)</li>
                            <li>Target is read automatically from Nexora settings — don't change it</li>
                            <li>Follow steps ① ② ③ below</li>
                        </ol>
                    </div>
                </div>
            </div>
            <table class="table table-sm table-bordered mb-2 font-13">
                <thead><tr><th style="width:22%">Step</th><th>What it does</th><th>Changes anything?</th></tr></thead>
                <tbody>
                    <tr><td><b>① Prepare</b></td><td>Installs the migration script and checks access to both servers; shows a summary + SHA.</td><td><span class="text-success">No — read-only</span></td></tr>
                    <tr><td><b>② Dry-run</b></td><td>Creates a full copy of your data and checks it. <b>The current panel DB is never touched.</b></td><td><span class="text-success">No — read-only</span></td></tr>
                    <tr><td><b>③ Apply</b></td><td>The real migration: automatic backup → import into a temp DB → strict verify of every table/row → safe swap. Type <b>MIGRATE</b> to confirm.</td><td><span class="text-danger">Yes — the only step that changes the target DB</span></td></tr>
                    <tr><td><b>Rollback</b></td><td>If anything goes wrong after Apply: restores the target DB to its previous state immediately.</td><td>Restores only what Apply changed</td></tr>
                </tbody>
            </table>
            <div class="alert alert-warning py-2 mb-1 font-13">
                <i class="mdi mdi-lightbulb-on-outline"></i> <b>Important tips:</b>
                &nbsp;⏸️ Stop the old panel before <b>Apply</b> (so data doesn't change mid-migration) &nbsp;·&nbsp; Don't close this page during Apply &nbsp;·&nbsp; After success, verify the new panel works, then you can stop MySQL &nbsp;·&nbsp; Backups are kept in <code>/opt/nexora-migrate/backups/</code>, logs in <code>/opt/nexora-migrate/logs/</code>
            </div>
        </div>
    </div>

    <!-- status cards -->
    <div class="row">
        <div class="col-md-6">
            <div class="card">
                <div class="card-header"><h5 class="header-title">Current Engine (Target)</h5></div>
                <div class="card-body">
                    <p class="mb-1"><i class="mdi mdi-database text-primary"></i> <b>MariaDB</b> — panel's live database</p>
                    <table class="table table-sm table-bordered mb-0">
                        <tr><td>Host / Port</td><td><?=nx_esc($TGT["host"])?>:<?=nx_esc($TGT["port"])?></td></tr>
                        <tr><td>Database</td><td><?=nx_esc($TGT["db"])?></td></tr>
                        <tr><td>Version</td><td><?=$TGT_STATUS["ok"] ? nx_esc($TGT_STATUS["version"]) : '<span class="text-danger">unreachable</span>'?></td></tr>
                        <tr><td>Tables</td><td><?=$TGT_STATUS["ok"] ? (int)$TGT_STATUS["tables"] : "-"?></td></tr>
                        <tr><td>Approx. rows</td><td><?=$TGT_STATUS["ok"] ? number_format((int)$TGT_STATUS["rows"]) : "-"?></td></tr>
                    </table>
                </div>
            </div>
        </div>
        <div class="col-md-6">
            <div class="card">
                <div class="card-header"><h5 class="header-title">Legacy Source (MySQL)</h5></div>
                <div class="card-body">
                    <div class="form-row mb-2">
                        <div class="col-md-3 mb-1"><label>Host</label><input class="form-control form-control-sm" name="src_host" value="<?=nx_esc($SRC["host"])?>" autocomplete="off"></div>
                        <div class="col-md-2 mb-1"><label>Port</label><input class="form-control form-control-sm" name="src_port" value="<?=nx_esc($SRC["port"])?>" autocomplete="off"></div>
                        <div class="col-md-4 mb-1"><label>DB name</label><input class="form-control form-control-sm" name="src_db" value="<?=nx_esc($SRC["db"])?>" autocomplete="off"></div>
                        <div class="col-md-3 mb-1"><label>User</label><input class="form-control form-control-sm" name="src_user" value="<?=nx_esc($SRC["user"])?>" autocomplete="off"></div>
                        <div class="col-md-3 mb-1"><label>Password</label><input class="form-control form-control-sm" type="password" name="src_pass" value="" autocomplete="off" placeholder="optional for root"></div>
                    </div>
                    <table class="table table-sm table-bordered mb-0">
                        <tr><td>Version</td><td><?=$SRC_STATUS["ok"] ? nx_esc($SRC_STATUS["version"]) : '<span class="text-danger">unreachable (check host/port/user or start MySQL)</span>'?></td></tr>
                        <tr><td>Tables</td><td><?=$SRC_STATUS["ok"] ? (int)$SRC_STATUS["tables"] : "-"?></td></tr>
                        <tr><td>Approx. rows</td><td><?=$SRC_STATUS["ok"] ? number_format((int)$SRC_STATUS["rows"]) : "-"?></td></tr>
                    </table>
                    <p class="text-muted mt-2 mb-0 font-13"><i class="mdi mdi-shield-alert"></i> Read-only probe — no changes are made by viewing this page.</p>
                </div>
            </div>
        </div>
    </div>

    <!-- comparison -->
    <?php if ($TGT_STATUS["ok"] && $SRC_STATUS["ok"]): ?>
    <div class="row"><div class="col-12">
        <div class="card">
            <div class="card-header"><h5 class="header-title">Schema Comparison <span class="text-muted">(row counts approximate — exact verify happens in the migration run)</span></h5></div>
            <div class="card-body">
                <table id="datatable-compare" class="table dt-responsive nowrap font-normal">
                    <thead><tr><th>Table</th><th>Source rows</th><th>Target rows</th><th>State</th></tr></thead>
                    <tbody>
                    <?php
                    $mc = @new mysqli($TGT["host"], $TGT["user"], (string)$rInfo["db_pass"], $TGT["db"], (int)$TGT["port"]);
                    if ($mc && !$mc->connect_error) {
                        $sc = @new mysqli($SRC["host"], $SRC["user"], $SRC_PASS, "", (int)$SRC["port"]);
                        if ((!$sc || $sc->connect_error) && $SRC["user"] === "root" && $SRC_PASS === "") { $sc = @new mysqli($SRC["host"], "root", "", "", (int)$SRC["port"]); }
                        if ($sc && !$sc->connect_error) {
                            $st = $sc->query("SELECT table_name FROM information_schema.tables WHERE table_schema='" . $sc->real_escape_string($SRC["db"]) . "' ORDER BY table_name");
                            while ($row = $st->fetch_row()) {
                                $tbl = $row[0];
                                $sr = $sc->query("SELECT COUNT(*) c FROM `" . $sc->real_escape_string($SRC["db"]) . "`.`$tbl`");
                                $sr = $sr ? (int)$sr->fetch_row()[0] : -1;
                                $tr = $mc->query("SELECT COUNT(*) c FROM `" . $mc->real_escape_string($TGT["db"]) . "`.`$tbl`");
                                $tr = $tr ? (int)$tr->fetch_row()[0] : -1;
                                $state = ($sr === $tr) ? '<span class="badge badge-success">MATCH</span>' : '<span class="badge badge-warning">DIFF</span>';
                                if ($tr < 0) $state = '<span class="badge badge-danger">MISSING</span>';
                                echo "<tr><td>".nx_esc($tbl)."</td><td>$sr</td><td>$tr</td><td>$state</td></tr>";
                            }
                            $sc->close();
                        }
                        $mc->close();
                    }
                    ?>
                    </tbody>
                </table>
            </div>
        </div>
    </div></div>
    <?php endif; ?>

    <!-- migration controls -->
    <div class="row"><div class="col-12">
        <div class="card">
            <div class="card-header"><h5 class="header-title">Safe Migration</h5></div>
            <div class="card-body">
                <?php if ($rPlan): ?>
                <div class="alert alert-info"><b>Package ready.</b><br><?=$rPlan?></div>
                <?php endif; ?>
                <div class="row">
                    <div class="col-md-4">
                        <button type="submit" name="confirm_prepare" value="1" class="btn btn-primary btn-block">
                            <i class="mdi mdi-package-variant"></i> 1&nbsp;·&nbsp;Prepare package
                        </button>
                        <p class="text-muted font-13 mt-1 mb-0">Installs the journaled migration script + shows SHA &amp; plan. No DB change.</p>
                    </div>
                    <div class="col-md-4">
                        <form action="./db_migration.php?action=run" method="POST" style="display:block;"
                              onsubmit="return nxConfirmMigrate(this);">
                            <input type="hidden" name="confirm_run" value="MIGRATE">
                            <input type="hidden" name="run_mode" value="dry-run">
                            <button type="submit" class="btn btn-warning btn-block">
                                <i class="mdi mdi-script-text-outline"></i> 2&nbsp;·&nbsp;Dry-run
                            </button>
                            <p class="text-muted font-13 mt-1 mb-0">Backup + dump + import to temp + verify. Live schema untouched.</p>
                        </form>
                    </div>
                    <div class="col-md-4">
                        <button type="button" class="btn btn-danger btn-block" onclick="nxShowApply();">
                            <i class="mdi mdi-database-import"></i> 3&nbsp;·&nbsp;Apply (guarded)
                        </button>
                        <p class="text-muted font-13 mt-1 mb-0">Journaled swap with full backup + rollback. Type <b>MIGRATE</b> to confirm.</p>
                    </div>
                </div>

                <!-- apply modal -->
                <div class="modal fade" id="nxApplyModal" tabindex="-1" role="dialog">
                    <div class="modal-dialog" role="document">
                        <div class="modal-content">
                            <div class="modal-header"><h5 class="modal-title">Apply Migration — Confirmation</h5></div>
                            <form action="./db_migration.php?action=run" method="POST">
                            <div class="modal-body">
                                <p>This will execute the journaled migration script with <b>--apply</b>:</p>
                                <ul class="font-13">
                                    <li>Backup of current <b><?=nx_esc($TGT["db"])?></b> (sha256 logged)</li>
                                    <li>Dump of source <b><?=nx_esc($SRC["db"])?></b></li>
                                    <li>Import into temp DB + exact row-count verify</li>
                                    <li>Journaled table swap (rollback available)</li>
                                </ul>
                                <div class="form-group mb-0">
                                    <label>Type <b>MIGRATE</b> to confirm</label>
                                    <input class="form-control" id="nxConfirmInput" autocomplete="off">
                                    <input type="hidden" name="confirm_run" id="nxConfirmHidden" value="">
                                    <input type="hidden" name="run_mode" value="apply">
                                </div>
                            </div>
                            <div class="modal-footer">
                                <button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
                                <button type="submit" class="btn btn-danger" id="nxApplyBtn" disabled>Execute --apply</button>
                            </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div></div>

    <!-- log -->
    <div class="row"><div class="col-12">
        <div class="card">
            <div class="card-header"><h5 class="header-title">Run Log</h5></div>
            <div class="card-body">
                <?php if ($rLog): ?>
                <pre class="p-3 rounded" style="max-height:320px;overflow:auto;background:var(--nx-surface-2);"><?=nx_esc($rLog)?></pre>
                <?php else: ?>
                <p class="text-muted mb-0">No run yet. Use <b>Dry-run</b> above; full log is written under <code>/opt/nexora-migrate/logs/</code>.</p>
                <?php endif; ?>
                <a href="./db_migration.php?action=log" class="btn btn-light btn-sm mt-2"><i class="mdi mdi-file-document"></i> Load latest log</a>
            </div>
        </div>
    </div></div>
</form>

<script>
function nxConfirmMigrate(formEl) {
    var mode = "dry-run";
    if (formEl && $(formEl).find('input[name="run_mode"]').val() === "apply") mode = "--apply";
    var msg = "Run migration script with " + mode + "?\n\n"
            + "Dry-run: backup + dump + import to temp + verify (live untouched).\n"
            + "Apply:   journaled swap (backup + rollback available).";
    if (!confirm(msg)) return false;
    return true;
}
function nxShowApply() {
    $("#nxApplyModal").modal("show");
    $("#nxConfirmInput").val("");
    $("#nxApplyBtn").prop("disabled", true);
    $("#nxConfirmHidden").val("");
}
$(function () {
    $("#nxConfirmInput").on("input", function () {
        var ok = $(this).val() === "MIGRATE";
        $("#nxApplyBtn").prop("disabled", !ok);
        $("#nxConfirmHidden").val(ok ? "MIGRATE" : "");
    });
    $("form").attr("autocomplete", "off");
    if ($.fn.dataTable && $("#datatable-compare").length) {
        $("#datatable-compare").DataTable({ pageLength: 25, order: [[0, "asc"]] });
    }
});
</script>

            </div> <!-- end container -->
        </div>
        <!-- end wrapper -->
        <?php if ($rSettings["sidebar"]) { echo "</div>"; } ?>
        <footer class="footer">
            <div class="container-fluid">
                <div class="row">
                    <div class="col-md-12 copyright text-center"><?=getFooter()?></div>
                </div>
            </div>
        </footer>
        <script src="assets/js/vendor.min.js"></script>
        <script src="assets/libs/datatables/jquery.dataTables.min.js"></script>
        <script src="assets/libs/datatables/dataTables.bootstrap4.js"></script>
        <script src="assets/libs/datatables/responsive.bootstrap4.min.js"></script>
    </body>
</html>
"""
# <<< END NEXORA-MIGRATE-EMBED <<<

# >>> BEGIN NEXORA-LICENSE-EMBED (generated by sync_license_installer.py) >>>
# Nexora license checker, baked into the installer so a sellable install
# always has the enforcement side ready (activateLicense() wires it up).
NEXORA_LICENSE_CHECK_SH = r"""#!/bin/bash
# ============================================================================
#  license_check.sh — Nexora daily license check (runs on the CUSTOMER panel)
# ----------------------------------------------------------------------------
#  Reads /opt/nexora-license.lic (HMAC-signed, see nexora-license-server.py),
#  checks signature + expiry, applies a grace period, and enforces on hard
#  expiry by swapping the ffmpeg wrapper (our own bin/ffmpeg) for a blocking
#  stub — streaming stops while admin remains usable. Self-heals on renewal.
#
#  Config: /opt/nexora-license.conf
#    SECRET=...      HMAC secret (defaults to the built-in value)
#    GRACE_DAYS=7    days allowed after expiry before enforcement
#    ENFORCE=1       swap the ffmpeg wrapper when past grace (0 = warn only)
#    LICENSE_URL=    optional online renew endpoint (https://.../renew?lic=..)
#  Status: /opt/nexora-license.status  (JSON-ish, for the seller dashboard)
#  Log:    /opt/nexora-license.log
# ============================================================================
set -u
LIC=/opt/nexora-license.lic
CFG=/opt/nexora-license.conf
STATUS=/opt/nexora-license.status
LOG=/opt/nexora-license.log
PANEL=/home/xtreamcodes/iptv_xtream_codes
WRAP=$PANEL/bin/ffmpeg
WRAP_BACKUP=$PANEL/bin/ffmpeg_working
STUB=$PANEL/bin/ffmpeg_lic_stub

SECRET="$(sed -n 's/^SECRET=//p' "$CFG" 2>/dev/null | tr -d '\r\n')"
GRACE_DAYS="$(sed -n 's/^GRACE_DAYS=//p' "$CFG" 2>/dev/null | tr -d '\r\n')"
ENFORCE="$(sed -n 's/^ENFORCE=//p' "$CFG" 2>/dev/null | tr -d '\r\n')"
LICENSE_URL="$(sed -n 's/^LICENSE_URL=//p' "$CFG" 2>/dev/null | tr -d '\r\n')"
[ -n "$GRACE_DAYS" ] || GRACE_DAYS=7
[ -n "$ENFORCE" ] || ENFORCE=1

say()  { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
wr()   { printf '%s\n' "$*" > "$STATUS"; }

# ---- verify the license (HMAC + product + expiry) --------------------------
verify_lic() {
  local lic="${1:-}" b64 sig payload body expect
  IFS='.' read -r b64 sig <<< "$lic"
  [ -n "$b64" ] || return 1
  payload=$(printf '%s' "$b64" | base64 -d 2>/dev/null) || return 1
  body=$(printf '%s' "$payload" | python3 -c \
    "import sys,json;print(json.dumps(json.load(sys.stdin),sort_keys=True,separators=(',',':')),end='')" 2>/dev/null)
  expect=$(printf '%s%s' "$SECRET" "$body" | sha256sum | awk '{print $1}')
  [ "$sig" = "$expect" ] || return 1
  printf '%s' "$payload"
}

# ---- enforcement helpers ----------------------------------------------------
enforce_off() { # block streaming: replace working wrapper with a stub
  if [ -f "$WRAP" ] && [ ! -f "$WRAP_BACKUP" ] && [ ! -f "$STUB" ]; then
    cp "$WRAP" "$WRAP_BACKUP"
    cat > "$STUB" <<'EOF'
#!/bin/bash
# Nexora license expired — streaming blocked. Renew at your provider.
echo "NEXORA_LICENSE_EXPIRED" >&2
exit 1
EOF
    chmod 755 "$STUB"
    # keep the real binary untouched in ffmpeg_real/
    cp "$STUB" "$WRAP" 2>/dev/null
    chmod 755 "$WRAP" 2>/dev/null
    say "ENFORCE: streaming blocked (license past grace)"
  fi
}

enforce_on() { # restore streaming: put the working wrapper back
  if [ -f "$WRAP_BACKUP" ] && [ ! -f "$STUB" ]; then
    cp "$WRAP_BACKUP" "$WRAP" 2>/dev/null
    chmod 755 "$WRAP" 2>/dev/null
    say "RECOVER: streaming restored (license valid)"
  elif [ -f "$WRAP_BACKUP" ]; then
    cp "$WRAP_BACKUP" "$WRAP" 2>/dev/null
    rm -f "$STUB"
    chmod 755 "$WRAP" 2>/dev/null
    say "RECOVER: streaming restored (license valid)"
  fi
}

# ---- main ------------------------------------------------------------------
[ -f "$LIC" ] || { wr '{"ok":false,"reason":"no license file"}'; exit 1; }
LIC_STR=$(tr -d '\r\n' < "$LIC")
P=$(verify_lic "$LIC_STR")
if [ -z "$P" ]; then
  say "license invalid (bad signature)"
  wr '{"ok":false,"reason":"invalid signature"}'
  exit 1
fi

EXP=$(printf '%s' "$P" | python3 -c "import sys,json;print(json.load(sys.stdin).get('exp',0))" 2>/dev/null)
CUSTOMER=$(printf '%s' "$P" | python3 -c "import sys,json;print(json.load(sys.stdin).get('customer',''))" 2>/dev/null)
NOW=$(date +%s)

# optional online renewal (the seller's server re-issues if subscription active)
if [ -n "$LICENSE_URL" ] && command -v curl >/dev/null 2>&1; then
  FRESH=$(curl -s --max-time 15 "$LICENSE_URL&lic=$(printf '%s' "$LIC_STR" | sed 's/+/%2B/g')" 2>/dev/null)
  NEWLIC=$(printf '%s' "$FRESH" | python3 -c \
    "import sys,json
try:
    d=json.load(sys.stdin)
    print(d.get('license','') if d.get('ok') else '')
except Exception: print('')" 2>/dev/null)
  if [ -n "$NEWLIC" ]; then
    printf '%s' "$NEWLIC" > "$LIC"
    say "license renewed online (expires $EXP)"
    enforce_on
    wr "{\"ok\":true,\"customer\":\"$CUSTOMER\",\"exp\":$EXP,\"source\":\"online\"}"
    exit 0
  fi
fi

if [ -z "$EXP" ] || [ "$EXP" -ge "$NOW" ]; then
  say "license OK ($CUSTOMER) until $(date -d @$EXP '+%Y-%m-%d')"
  enforce_on
  wr "{\"ok\":true,\"customer\":\"$CUSTOMER\",\"exp\":$EXP}"
  exit 0
fi

# expired — grace period
GRACE_EXP=$((EXP + GRACE_DAYS * 86400))
DAYS_OVER=$(( (NOW - EXP) / 86400 ))
if [ "$NOW" -lt "$GRACE_EXP" ]; then
  say "license EXPIRED $DAYS_OVER day(s) ago — grace until $(date -d @$GRACE_EXP '+%Y-%m-%d')"
  wr "{\"ok\":false,\"reason\":\"expired grace\",\"exp\":$EXP,\"grace_until\":$GRACE_EXP}"
  exit 0
fi

# past grace — enforce
say "license EXPIRED $DAYS_OVER day(s) ago — past grace"
wr "{\"ok\":false,\"reason\":\"expired enforced\",\"exp\":$EXP}"
[ "$ENFORCE" = 1 ] && enforce_off
exit 0
"""
# <<< END NEXORA-LICENSE-EMBED <<<

# >>> BEGIN NEXORA-ENFORCER-EMBED >>>
# Robust license enforcement daemon (v4) — renames ffmpeg binary to
# prevent restart when license is revoked. Deployed alongside the bash
# checker for belt-and-suspenders enforcement.
NEXORA_LICENSE_ENFORCER_PY = r"""#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Nexora License Enforcement Daemon v6
# v6: Server fingerprint binding + tamper detection
import os, sys, subprocess, time, json, urllib.request, urllib.parse, hashlib, socket

LIC_DIR     = "/opt/nexora-license"
LIC_FILE    = os.path.join(LIC_DIR, "nexora-license.lic")
CONF_FILE   = os.path.join(LIC_DIR, "nexora-license.conf")
STATUS_FILE = os.path.join(LIC_DIR, "nexora-license.status")
LOG_FILE    = os.path.join(LIC_DIR, "nexora-license.log")

FFMPEG_REAL = "/home/xtreamcodes/iptv_xtream_codes/bin/ffmpeg_real/ffmpeg"
FFMPEG_LOCK = "/home/xtreamcodes/iptv_xtream_codes/bin/ffmpeg_real/ffmpeg.disabled"

DB_SOCK = "/opt/mariadb-prod/mariadb.sock"
DB_BIN  = "/opt/mariadb-11.4.12-linux-systemd-x86_64/bin/mariadb"
DB_NAME = "xtream_iptvpro"

SELLER_DIRECT = "http://127.0.0.1:8899/verify?lic="

def log(line):
    try:
        with open(LOG_FILE, "a") as f:
            f.write(line + "\n")
    except: pass
    print(line, flush=True)

def get_server_fingerprint():
    try:
        sources = []
        result = subprocess.run(["ip", "link", "show"], capture_output=True, text=True, timeout=5)
        for line in result.stdout.splitlines():
            if "link/ether" in line and "00:00:00:00:00:00" not in line:
                sources.append(line.split("link/ether")[1].strip().split()[0])
                break
        sources.append(socket.gethostname())
        try:
            with open("/proc/cpuinfo") as f:
                for line in f:
                    if "model name" in line:
                        sources.append(line.split(":")[1].strip())
                        break
        except: pass
        return hashlib.sha256("|".join(sources).encode()).hexdigest()[:16]
    except: return ""

def bind_license_fp():
    if not os.path.exists(LIC_FILE): return
    fp = get_server_fingerprint()
    if not fp: return
    try:
        with open(LIC_FILE) as f: key = f.read().strip()
        pad = 4 - len(key) % 4
        if pad != 4: key += "=" * pad
        payload = json.loads(base64.b64decode(key))
        payload["server_fp"] = fp
        body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
        secret = "nexora-secret-change-me"
        try:
            with open(CONF_FILE) as f:
                for line in f:
                    if line.strip().startswith("NEXORA_LICENSE_SECRET="):
                        secret = line.strip().split("=", 1)[1]
        except: pass
        sig = hashlib.sha256(secret.encode() + body).hexdigest()
        new_key = base64.b64encode(json.dumps(payload).encode()).decode() + "." + sig
        with open(LIC_FILE, "w") as f: f.write(new_key)
    except: pass

import base64

def read_conf():
    url, key = None, None
    try:
        with open(CONF_FILE) as f:
            for line in f:
                line = line.strip()
                if line.startswith("NEXORA_LICENSE_URL="): url = line.split("=", 1)[1]
                elif line.startswith("NEXORA_LICENSE_KEY="): key = line.split("=", 1)[1]
    except: pass
    return url or SELLER_DIRECT, key

def check_license():
    lic_key = None
    try:
        with open(LIC_FILE) as f: lic_key = f.read().strip()
    except: return False, {"reason": "no-key-file"}
    if not lic_key: return False, {"reason": "empty-key"}
    fp = get_server_fingerprint()
    urls_to_try = [SELLER_DIRECT]
    try:
        with open(CONF_FILE) as f:
            for line in f:
                if line.strip().startswith("NEXORA_LICENSE_URL="):
                    u = line.strip().split("=", 1)[1]
                    if u not in urls_to_try: urls_to_try.append(u)
    except: pass
    seen = set()
    for base_url in urls_to_try:
        if not base_url or base_url in seen: continue
        seen.add(base_url)
        try:
            full_url = base_url + urllib.parse.quote(lic_key, safe="")
            if fp: full_url += "&fp=" + fp
            req = urllib.request.Request(full_url, headers={"User-Agent": "NexoraEnforcer/6.0"})
            resp = urllib.request.urlopen(req, timeout=15)
            data = json.loads(resp.read().decode())
            if "data" in data and "sig" in data: data = data["data"]
            ok = data.get("ok", False)
            reason = data.get("reason", "")
            if reason in ("revoked", "invalid", "inactive", "not_found", "expired", "missing-lic", "server_mismatch"):
                ok = False
            return ok, data
        except urllib.error.HTTPError as e:
            log("[enforcer] HTTP %d from %s" % (e.code, base_url.split("?")[0]))
            continue
        except Exception as e:
            log("[enforcer] API error: %s" % e)
            continue
    return False, {"reason": "all-endpoints-failed"}

def kill_all_ffmpeg():
    killed = 0
    try:
        result = subprocess.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
        for line in result.stdout.splitlines():
            if "ffmpeg_real" in line and "grep" not in line:
                parts = line.split()
                if len(parts) > 1:
                    try:
                        os.kill(int(parts[1]), 9)
                        killed += 1
                    except: pass
    except: pass
    return killed

def enforce_revoke():
    actions = []
    n = kill_all_ffmpeg()
    actions.append("killed-ffmpeg=%d" % n)
    if os.path.exists(FFMPEG_REAL) and not os.path.exists(FFMPEG_LOCK):
        try:
            os.rename(FFMPEG_REAL, FFMPEG_LOCK)
            os.chmod(FFMPEG_LOCK, 0o000)
            actions.append("renamed-ffmpeg")
        except Exception as e: actions.append("rename-fail=%s" % e)
    try:
        result = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
        cron = result.stdout
        if "pid_monitor.php" in cron and "DISABLED by license" not in cron:
            lines = cron.splitlines()
            new_lines = []
            for line in lines:
                if "pid_monitor.php" in line and not line.strip().startswith("#"):
                    new_lines.append("# %s DISABLED by license" % line.strip())
                else: new_lines.append(line)
            subprocess.run(["crontab", "-"], input="\n".join(new_lines) + "\n", capture_output=True, text=True, timeout=5)
            actions.append("disabled-pid_monitor")
    except: pass
    try:
        subprocess.run([DB_BIN, "--socket=%s" % DB_SOCK, "-u", "root", DB_NAME, "-e",
                        "UPDATE streams_sys SET pid=NULL WHERE pid IS NOT NULL"], capture_output=True, timeout=5)
        actions.append("reset-stream-pids")
    except: pass
    log("ENFORCED: %s" % ", ".join(actions))
    return actions

def enforce_activate():
    actions = []
    if os.path.exists(FFMPEG_LOCK) and not os.path.exists(FFMPEG_REAL):
        try:
            os.rename(FFMPEG_LOCK, FFMPEG_REAL)
            os.chmod(FFMPEG_REAL, 0o755)
            actions.append("restored-ffmpeg")
        except Exception as e: actions.append("restore-fail=%s" % e)
    try:
        result = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
        cron = result.stdout
        if "DISABLED by license" in cron:
            lines = cron.splitlines()
            new_lines = []
            for line in lines:
                if "DISABLED by license" in line and "pid_monitor.php" in line:
                    restored = line.lstrip("# ").rstrip(" DISABLED by license")
                    if not restored.startswith("* * * * *"): restored = "* * * * * %s" % restored
                    new_lines.append(restored)
                else: new_lines.append(line)
            subprocess.run(["crontab", "-"], input="\n".join(new_lines) + "\n", capture_output=True, text=True, timeout=5)
            actions.append("enabled-pid_monitor")
    except: pass
    log("ACTIVATED: %s" % ", ".join(actions))
    return actions

def is_enforced():
    return os.path.exists(FFMPEG_LOCK) or not os.path.exists(FFMPEG_REAL)

def sync_lic_from_db():
    try:
        import sqlite3 as _sqlite3
        try:
            ck = open(LIC_FILE).read().strip()
            pad = 4 - len(ck) % 4
            if pad != 4: ck += "=" * pad
            decoded = json.loads(base64.b64decode(ck))
            my_server_id = decoded.get("server_id", "")
        except: my_server_id = ""
        if not my_server_id: return
        db_paths = [
            "/mnt/e/freetry/panel/nexora-sell-site/data/sell.db",
            os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "sell.db"),
        ]
        for db_path in db_paths:
            if not os.path.exists(db_path): continue
            conn = _sqlite3.connect(db_path)
            row = conn.execute("SELECT lic, exp FROM licenses WHERE server_id=? ORDER BY id DESC LIMIT 1", (my_server_id,)).fetchone()
            conn.close()
            if not row: return
            db_key = row[0]
            try: current_key = open(LIC_FILE).read().strip()
            except: current_key = ""
            if db_key and db_key != current_key:
                with open(LIC_FILE, "w") as f: f.write(db_key)
                with open(CONF_FILE, "w") as f:
                    f.write("NEXORA_LICENSE_URL=%s\n" % SELLER_DIRECT)
                    f.write("NEXORA_LICENSE_KEY=%s\n" % db_key)
                log("synced-lic-from-db-server=%s" % my_server_id)
            return
    except: pass

def run_once():
    sync_lic_from_db()
    bind_license_fp()
    ok, info = check_license()
    if ok:
        status = "active"
        exp = info.get("exp", 0)
        now = int(time.time())
        if exp > 0 and exp < now:
            status = "expired"
            if not is_enforced(): enforce_revoke()
        else:
            if is_enforced(): enforce_activate()
    else:
        status = "inactive"
        reason = info.get("reason", "unknown")
        log("License check failed: %s" % reason)
        enforce_revoke()
    try:
        with open(STATUS_FILE, "w") as f: f.write(status)
    except: pass
    return status, info

def main():
    if "--once" in sys.argv:
        status, info = run_once()
        print("Status: %s | Info: %s" % (status, json.dumps(info)))
    elif "--enforce" in sys.argv:
        print("Enforced: %s" % enforce_revoke())
    elif "--activate" in sys.argv:
        print("Activated: %s" % enforce_activate())
    elif "--status" in sys.argv:
        print("Enforced: %s" % is_enforced())
        print("Fingerprint: %s" % get_server_fingerprint())
        try:
            with open(STATUS_FILE) as f: print("Status: %s" % f.read().strip())
        except: print("Status: not found")
    else:
        while True:
            try: run_once()
            except Exception as e: log("Error: %s" % e)
            time.sleep(60)

if __name__ == "__main__": main()
"""
# <<< END NEXORA-ENFORCER-EMBED <<<


# <<< NEXORA-FAST-SYNC-EMBED >>>
# v5: Fast .lic sync — runs every 10s via cron, syncs .lic from Seller DB
# This ensures Panel shows the same expiry as the seller admin.
NEXORA_FAST_SYNC_PY = """#!/usr/bin/env python3
# Nexora Fast License Sync v5.1 - matches server_id
import sqlite3, os, base64, json

LIC_DIR = "/opt/nexora-license"
LIC_FILE = os.path.join(LIC_DIR, "nexora-license.lic")
CONF_FILE = os.path.join(LIC_DIR, "nexora-license.conf")
DB_PATHS = ["/mnt/e/freetry/panel/nexora-sell-site/data/sell.db"]
URL = "http://127.0.0.1:8899/verify?lic="

def get_server_id():
    try:
        k = open(LIC_FILE).read().strip()
        pad = 4 - len(k) % 4
        if pad != 4: k += "=" * pad
        return json.loads(base64.b64decode(k)).get("server_id", "")
    except: return ""

def sync():
    sid = get_server_id()
    if not sid: return
    for p in DB_PATHS:
        if not os.path.exists(p): continue
        try:
            db = sqlite3.connect(p)
            row = db.execute("SELECT lic FROM licenses WHERE server_id=? ORDER BY id DESC LIMIT 1", (sid,)).fetchone()
            db.close()
            if not row: return
            db_key = row[0]
            try: cur = open(LIC_FILE).read().strip()
            except: cur = ""
            if db_key and db_key != cur:
                with open(LIC_FILE, "w") as f: f.write(db_key)
                with open(CONF_FILE, "w") as f:
                    f.write("NEXORA_LICENSE_URL=%s\n" % URL)
                    f.write("NEXORA_LICENSE_KEY=%s\n" % db_key)
        except: pass
        return

if __name__ == "__main__": sync()
"""
# <<< END NEXORA-FAST-SYNC-EMBED <<<


# SAFE sysctl for an IPTV panel server. Values are conservative and validated
# against the machine's actual RAM at apply time (see configure()).
# Removed the legacy panel values which were toxic on modern kernels
# (nf_conntrack_max=1215196608, fs.nr_open=6815744, tcp_rmem=10MB, ...).
rSysCtl = "# Nexora safe sysctl\nnet.core.somaxconn = 1024\nnet.ipv4.route.flush=1\nnet.ipv4.tcp_no_metrics_save=1\nnet.ipv4.tcp_moderate_rcvbuf = 1\nfs.file-max = 1048576\nfs.nr_open = 1048576\nnet.ipv4.ip_local_port_range = 1024 65000\nnet.ipv4.tcp_sack = 1\nnet.ipv4.tcp_rmem = 4096 131072 6291456\nnet.ipv4.tcp_wmem = 4096 16384 4194304\nnet.ipv4.tcp_mem = 786432 1048576 1572864\nnet.core.rmem_max = 4194304\nnet.core.wmem_max = 4194304\nnet.core.rmem_default = 262144\nnet.core.wmem_default = 262144\nnet.core.netdev_max_backlog = 50000\nnet.ipv4.tcp_max_syn_backlog = 30000\nnet.netfilter.nf_conntrack_max=262144\nnet.ipv4.tcp_window_scaling = 1\nvm.max_map_count = 65530\nnet.ipv4.tcp_max_tw_buckets = 10000\nnet.ipv6.conf.all.disable_ipv6 = 1\nnet.ipv6.conf.default.disable_ipv6 = 1\nnet.ipv6.conf.lo.disable_ipv6 = 1\nnet.ipv4.tcp_tw_reuse=1\nvm.swappiness=10"

# i am lazy to prepare echo versions with escaped characters, use base64 decode/encode to read or change these.

class col:
    HEADER = '\033[95m'
    OKBLUE = '\033[94m'
    OKGREEN = '\033[92m'
    WARNING = '\033[93m'
    FAIL = '\033[91m'
    YELLOW = '\033[33m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    UNDERLINE = '\033[4m'

def generate(length=19): return ''.join(random.choice(string.ascii_letters + string.digits) for i in range(length))

def getIP():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(("8.8.8.8", 80))
    return s.getsockname()[0]

def getVersion():
    try: return subprocess.check_output("lsb_release -d".split()).decode().split(":")[-1].strip()
    except: return ""

def printc(rText, rColour=col.OKBLUE, rPadding=0):
    print("%s ┌──────────────────────────────────────────┐ %s" % (rColour, col.ENDC))
    for i in range(rPadding): print("%s │                                          │ %s" % (rColour, col.ENDC))
    print("%s │ %s%s%s │ %s" % (rColour, " "*(20-(len(rText)//2)), rText, " "*(40-(20-(len(rText)//2))-len(rText)), col.ENDC))
    for i in range(rPadding): print("%s │                                          │ %s" % (rColour, col.ENDC))
    print("%s └──────────────────────────────────────────┘ %s" % (rColour, col.ENDC))
    print(" ")

def prepare(rType="MAIN"):
    global rPackages
    if rType != "MAIN": rPackages = rPackages[:-3]
    printc("Preparing Installation")
    for rFile in ["/var/lib/dpkg/lock-frontend", "/var/cache/apt/archives/lock", "/var/lib/dpkg/lock"]:
        try: os.remove(rFile)
        except: pass
    os.system("apt-get update")
    printc("Removing libcurl4 if installed")
    os.system("apt-get remove --auto-remove libcurl4 -y")
    for rPackage in rPackages:
        printc("Installing %s" % rPackage)
        os.system("apt-get install %s -y" % rPackage)
    printc("Installing libpng12 (for old panel binaries)")
    # SELF-HOST FIRST: use /root/libpng12.deb if pre-placed (or NEXORA_LIBPNG_URL),
    # only fall back to the public mirror — zero external downloads when self-hosted
    rLibPng = "/root/libpng12.deb"
    if not (os.path.exists(rLibPng) and os.path.getsize(rLibPng) > 50000):
        rLibPngUrl = os.environ.get("NEXORA_LIBPNG_URL",
            "http://mirrors.kernel.org/ubuntu/pool/main/libp/libpng/libpng12-0_1.2.54-1ubuntu1_amd64.deb")
        printc("  downloading libpng12 from %s" % rLibPngUrl, col.WARNING)
        os.system("wget -q -O %s %s" % (rLibPng, rLibPngUrl))
    if os.path.exists(rLibPng) and os.path.getsize(rLibPng) > 50000:
        os.system("dpkg -i %s" % rLibPng)
        os.system("apt-get install -y")  # Clean up above
        try: os.remove(rLibPng)
        except: pass
    else:
        printc("  libpng12 unavailable (self-hosted install: place /root/libpng12.deb)", col.WARNING)
    try:
        subprocess.check_output("getent passwd xtreamcodes > /dev/null".split())
    except:
        # Create User
        printc("Creating user xtreamcodes")
        os.system("adduser --system --shell /bin/false --group --disabled-login xtreamcodes > /dev/null")
    if not os.path.exists("/home/xtreamcodes"): os.mkdir("/home/xtreamcodes")
    return True

def sha256_file(path):
    import hashlib
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()

def download(url, dest, rExpectedSHA=""):
    # python3 urllib has proven more reliable against the mirror than wget
    try:
        urllib2.urlretrieve(url, dest)
        if not (os.path.exists(dest) and os.path.getsize(dest) > 100000000):
            return False
        if rExpectedSHA:
            rActual = sha256_file(dest)
            if rActual != rExpectedSHA:
                printc("SHA256 mismatch!\n  expected: %s\n  actual:   %s" % (rExpectedSHA, rActual), col.FAIL)
                try: os.remove(dest)
                except: pass
                return False
        return True
    except Exception as e:
        printc("Download failed: %s" % e, col.FAIL)
        return False

def install(rType="MAIN"):
    global rInstall, rDownloadURL
    printc("Downloading Software")
    try: rURL = rDownloadURL[rInstall[rType]]
    except:
        printc("Invalid download URL!", col.FAIL)
        return False
    rTar = "/root/nexora-panel.tar.gz"
    # accept the legacy cache name too so existing downloads are reused
    if not os.path.exists(rTar) and os.path.exists("/root/xtreamcodes.tar.gz"):
        rTar = "/root/xtreamcodes.tar.gz"
    # skip download/extract if the panel is already in place
    if os.path.exists("/home/xtreamcodes/iptv_xtream_codes/start_services.sh") and os.path.exists("/home/xtreamcodes/iptv_xtream_codes/bin"):
        printc("Panel already extracted, skipping download")
        return True
    rExpectedSHA = rDownloadSHA.get(rInstall[rType], "")
    if os.path.exists(rTar) and os.path.getsize(rTar) > 100000000:
        if (not rExpectedSHA) or (sha256_file(rTar) == rExpectedSHA):
            printc("Using cached %s" % rTar)
        else:
            printc("Cached %s failed checksum, re-downloading" % rTar, col.WARNING)
            os.remove(rTar)
            rTar = "/root/nexora-panel.tar.gz"
            if not download(rURL, rTar, rExpectedSHA):
                printc("Failed to download installation file!", col.FAIL)
                return False
    elif not download(rURL, rTar, rExpectedSHA):
        printc("Failed to download installation file!", col.FAIL)
        return False
    if os.path.exists(rTar):
        printc("Installing Software")
        os.system('chattr -f -i /home/xtreamcodes/iptv_xtream_codes/GeoLite2.mmdb > /dev/null')
        if os.system('tar -xzf "%s" -C "/home/xtreamcodes/" > /dev/null' % rTar) == 0:
            try: os.remove(rTar)
            except: pass
            return True
        printc("Extraction failed, keeping %s for inspection" % rTar, col.FAIL)
        return False
    printc("Failed to download installation file!", col.FAIL)
    return False

def update(rType="MAIN"):
    if rType == "UPDATE":
        printc("UPDATE mode requires the Nexora update package.", col.WARNING)
        printc("Place release_xyz.zip in /root and run: python3 install_py3.py UPDATE", col.WARNING)
        rlink = input('Enter local path to release_xyz.zip (e.g. /root/release.zip): ')
        rlink = "/root/" + os.path.basename(rlink.strip().replace("\\", "/"))
        if not os.path.exists(rlink):
            printc("File not found: %s" % rlink, col.FAIL)
            return False
        printc("Using local update package: %s" % rlink)
    else:
        # SECURITY: never auto-download a panel update from a remote URL that a
        # third party can change. Use a local package if present, else skip.
        local_pkg = "/root/release.zip"
        if os.path.exists(local_pkg):
            rlink = local_pkg
            printc("Installing Admin Panel (local package)")
        else:
            printc("No local update package found (/root/release.zip). Update step skipped.", col.WARNING)
            return False
    if not os.path.exists(rlink):
        printc("Update package not found: %s" % rlink, col.FAIL)
        return False
    try: is_ok = zipfile.ZipFile(rlink)
    except:
        printc("Invalid zip file is corrupted!", col.FAIL)
        return False
    printc("Updating Software")
    os.system('chattr -i /home/xtreamcodes/iptv_xtream_codes/GeoLite2.mmdb > /dev/null 2>&1')
    os.system('unzip -o %s -d /tmp/nexora-update/ > /dev/null' % rlink)
    # find the payload dir inside the zip (either root or XtreamUI-master/*)
    payload = "/tmp/nexora-update"
    if os.path.isdir(os.path.join(payload, "XtreamUI-master")):
        payload = os.path.join(payload, "XtreamUI-master")
    os.system('cp -rf %s/admin/* /home/xtreamcodes/iptv_xtream_codes/admin/ > /dev/null 2>&1' % payload)
    os.system('cp -rf %s/wwwdir/* /home/xtreamcodes/iptv_xtream_codes/wwwdir/ > /dev/null 2>&1' % payload)
    os.system('cp -rf %s/pytools/* /home/xtreamcodes/iptv_xtream_codes/pytools/ > /dev/null 2>&1' % payload)
    os.system('cp -rf %s/crons/* /home/xtreamcodes/iptv_xtream_codes/crons/ > /dev/null 2>&1' % payload)
    os.system('chown -R xtreamcodes:xtreamcodes /home/xtreamcodes > /dev/null 2>&1')
    os.system("find /home/xtreamcodes -type d -exec chmod 755 {} \\; > /dev/null 2>&1")
    os.system("find /home/xtreamcodes -type f -exec chmod 644 {} \\; > /dev/null 2>&1")
    os.system("chmod 400 /home/xtreamcodes/iptv_xtream_codes/config > /dev/null 2>&1")
    os.system('chattr +i /home/xtreamcodes/iptv_xtream_codes/GeoLite2.mmdb > /dev/null 2>&1')
    try: os.remove(rlink)
    except: pass
    return True


def mysql(rUsername, rPassword):
    global rMySQLCnf
    printc("Configuring MariaDB")
    rCreate = True
    if os.path.exists("/etc/mysql/my.cnf"):
        if open("/etc/mysql/my.cnf", "r").read(14) == "# Nexora Panel": rCreate = False
    if rCreate:
        shutil.copy("/etc/mysql/my.cnf", "/etc/mysql/my.cnf.xc")
        rFile = open("/etc/mysql/my.cnf", "w")
        rFile.write(rMySQLCnf)
        rFile.close()
        # fresh installs ship MariaDB (stable); the mysql alias still works
        os.system("service mariadb restart || service mysql restart")
        printc("MariaDB restarted, checking it is up...", col.WARNING)
        os.system("sleep 3 && mysqladmin status")
    printc("Enter MariaDB Root Password (blank works with unix_socket root):", col.WARNING)
    for i in range(5):
        rMySQLRoot = input("  ")
        print(" ")
        if len(rMySQLRoot) > 0: rExtra = " -p%s" % rMySQLRoot
        else: rExtra = ""
        printc("Drop existing & create database? Y/N", col.WARNING)
        if input("  ").upper() == "Y": rDrop = True
        else: rDrop = False
        try:
            if rDrop:
                os.system('mysql -u root%s -e "DROP USER IF EXISTS \'%s\'@\'%%\';"' % (rExtra, rUsername))
                os.system('mysql -u root%s -e "DROP DATABASE IF EXISTS xtream_iptvpro; CREATE DATABASE IF NOT EXISTS xtream_iptvpro;"' % rExtra)
                os.system("mysql -u root%s xtream_iptvpro < /home/xtreamcodes/iptv_xtream_codes/database.sql" % rExtra)
                os.system('mysql -u root%s -e "USE xtream_iptvpro; UPDATE settings SET live_streaming_pass = \'%s\', unique_id = \'%s\', crypt_load_balancing = \'%s\', get_real_ip_client=\'\';"' % (rExtra, generate(20), generate(10), generate(20)))
                os.system('mysql -u root%s -e "USE xtream_iptvpro; REPLACE INTO streaming_servers (id, server_name, domain_name, server_ip, vpn_ip, ssh_password, ssh_port, diff_time_main, http_broadcast_port, total_clients, system_os, network_interface, latency, status, enable_geoip, geoip_countries, last_check_ago, can_delete, server_hardware, total_services, persistent_connections, rtmp_port, geoip_type, isp_names, isp_type, enable_isp, boost_fpm, http_ports_add, network_guaranteed_speed, https_broadcast_port, https_ports_add, whitelist_ips, watchdog_data, timeshift_only) VALUES (1, \'Main Server\', \'\', \'%s\', \'\', NULL, NULL, 0, 25461, 1000, \'%s\', \'eth0\', 0, 1, 0, \'\', 0, 0, \'{}\', 3, 0, 25462, \'low_priority\', \'\', \'low_priority\', 0, 1, \'\', 1000, 25463, \'\', \'[\"127.0.0.1\",\"\"]\', \'{}\', 0);"' % (rExtra, getIP(), getVersion()))
                # generate admin password with a REAL sha512-crypt hash matching
                # the panel's cryptPassword() (crypt($pw,'$6$rounds=20000$salt$')).
                # NOTE: openssl on Ubuntu 24.04 (3.0.x) does NOT support
                # `openssl passwd -rounds`, so we generate the hash with the
                # panel's own PHP binary (already extracted) instead.
                rAdminPass = generate(24)
                rPhpBin = "/home/xtreamcodes/iptv_xtream_codes/php/bin/php"
                rAdminHash = subprocess.check_output(
                    [rPhpBin, "-r",
                     "$s=substr(bin2hex(random_bytes(8)),0,16); "
                     "echo crypt($argv[1], sprintf('$6$rounds=%d$%s$',20000,$s));",
                     rAdminPass],
                    stderr=subprocess.DEVNULL).decode().strip()
                os.system('mysql -u root%s -e "USE xtream_iptvpro; REPLACE INTO reg_users (id, username, password, email, member_group_id, verified, status) VALUES (1, \'admin\', \'%s\', \'admin@website.com\', 1, 1, 1);"' % (rExtra, rAdminHash.replace("'", "''")))
                os.system('echo "admin panel password: %s" >> /root/install_log.txt' % rAdminPass)
                os.system('mysql -u root%s -e "CREATE USER \'%s\'@\'%%\' IDENTIFIED BY \'%s\'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, TRIGGER, REFERENCES, SHOW VIEW ON xtream_iptvpro.* TO \'%s\'@\'%%\'; FLUSH PRIVILEGES;"' % (rExtra, rUsername, rPassword, rUsername))
                os.system('mysql -u root%s -e "USE xtream_iptvpro; CREATE TABLE IF NOT EXISTS dashboard_statistics (id int(11) NOT NULL AUTO_INCREMENT, type varchar(16) NOT NULL DEFAULT \'\', time int(16) NOT NULL DEFAULT \'0\', count int(16) NOT NULL DEFAULT \'0\', PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=latin1; INSERT INTO dashboard_statistics (type, time, count) VALUES(\'conns\', UNIX_TIMESTAMP(), 0),(\'users\', UNIX_TIMESTAMP(), 0);"' % rExtra)
                #last one is to prevent an xc vulnerability, set get_real_ip_client as HTTP_CF_CONNECTING_IP if you are using cf proxy.
                os.system('mysql -u root%s -e "USE xtream_iptvpro; UPDATE settings SET firewall=\'0\', flood_limit=\'0\', get_real_ip_client=\'\' where id=\'1\';"' % rExtra)
            try: os.remove("/home/xtreamcodes/iptv_xtream_codes/database.sql")
            except: pass
            return True
        except: printc("Invalid password! Try again", col.FAIL)
    return False

def encrypt(rHost="127.0.0.1", rUsername="user_iptvpro", rPassword="", rDatabase="xtream_iptvpro", rServerID=1, rPort=7999):
    printc("Encrypting...")
    try: os.remove("/home/xtreamcodes/iptv_xtream_codes/config")
    except: pass
    rf = open('/home/xtreamcodes/iptv_xtream_codes/config', 'wb')
    plain = '{"host":"%s","db_user":"%s","db_pass":"%s","db_name":"%s","server_id":"%d", "db_port":"%d"}' % (rHost, rUsername, rPassword, rDatabase, rServerID, rPort)
    xored = ''.join(chr(ord(c)^ord(k)) for c,k in izip(plain, cycle('5709650b0d7806074842c6de575025b1'))).encode('latin-1')
    rf.write(base64.b64encode(xored))
    rf.close()

def configure():
    global rType
    printc("Configuring System")
    if not "/home/xtreamcodes/iptv_xtream_codes/" in open("/etc/fstab").read():
        rFile = open("/etc/fstab", "a")
        rFile.write("tmpfs /home/xtreamcodes/iptv_xtream_codes/streams tmpfs defaults,noatime,nosuid,nodev,noexec,mode=1777,size=90% 0 0\ntmpfs /home/xtreamcodes/iptv_xtream_codes/tmp tmpfs defaults,noatime,nosuid,nodev,noexec,mode=1777,size=2G 0 0")
        rFile.close()
    if not "xtreamcodes" in open("/etc/sudoers").read():
        os.system('echo "xtreamcodes ALL = (root) NOPASSWD: /sbin/iptables, /usr/bin/chattr" >> /etc/sudoers')
    if not os.path.exists("/etc/init.d/xtreamcodes"):
        rFile = open("/etc/init.d/xtreamcodes", "w")
        rFile.write("#!/bin/bash\n/home/xtreamcodes/iptv_xtream_codes/start_services.sh")
        rFile.close()
        os.system("chmod +x /etc/init.d/xtreamcodes > /dev/null")
    try: os.remove("/usr/bin/ffmpeg")
    except: pass
    if rType == "MAIN":
        # edited these 2 files return api response without main server ip, it is usefull if you use a proxy in front of your main server.
        # SECURITY: replaced external GitHub download (third-party controlled code)
        # with a local in-panel patch: strip the main server ip from the api response.
        api_dir = "/home/xtreamcodes/iptv_xtream_codes/wwwdir"
        for api_file in ["panel_api.php", "player_api.php"]:
            p = os.path.join(api_dir, api_file)
            if os.path.exists(p) and not os.path.exists(p + ".orig"):
                shutil.copy(p, p + ".orig")
                try:
                    src = open(p, "r").read()
                    src = src.replace('"$srv["server_ip"]', '"" /* local ip stripped */')
                    src = src.replace('"server_ip"=>$srv["server_ip"]', '"server_ip"=>""')
                    open(p, "w").write(src)
                except Exception as e:
                    printc("Could not patch %s: %s" % (api_file, e), col.FAIL)
    if not os.path.exists("/home/xtreamcodes/iptv_xtream_codes/tv_archive"): os.mkdir("/home/xtreamcodes/iptv_xtream_codes/tv_archive/")
    os.system("ln -s /home/xtreamcodes/iptv_xtream_codes/bin/ffmpeg /usr/bin/")
    # SECURITY: no external GeoLite2 download (third-party GitHub URL). The
    # mmdb ships locally with the panel; only fix ownership/permissions here.
    os.system("chown -R xtreamcodes:xtreamcodes /home/xtreamcodes > /dev/null")
    # SAFE permissions: dirs 755 / files 644, writable only where the panel
    # truly needs to write. NO chmod -R 0777 anywhere.
    os.system("find /home/xtreamcodes -type d -exec chmod 755 {} \\; > /dev/null 2>&1")
    os.system("find /home/xtreamcodes -type f -exec chmod 644 {} \\; > /dev/null 2>&1")
    os.system("chmod 400 /home/xtreamcodes/iptv_xtream_codes/config > /dev/null")
    os.system("chmod -R 755 /home/xtreamcodes/iptv_xtream_codes/bin > /dev/null")
    os.system("chmod 755 /home/xtreamcodes/iptv_xtream_codes/nginx/sbin/nginx /home/xtreamcodes/iptv_xtream_codes/nginx_rtmp/sbin/nginx_rtmp /home/xtreamcodes/iptv_xtream_codes/php/bin/php > /dev/null 2>&1")
    os.system("chmod -R 1777 /home/xtreamcodes/iptv_xtream_codes/tmp /home/xtreamcodes/iptv_xtream_codes/streams > /dev/null 2>&1")
    os.system("chmod -R 775 /home/xtreamcodes/iptv_xtream_codes/tools /home/xtreamcodes/iptv_xtream_codes/pytools /home/xtreamcodes/iptv_xtream_codes/crons > /dev/null 2>&1")
    os.system("chattr +i /home/xtreamcodes/iptv_xtream_codes/GeoLite2.mmdb > /dev/null")
    os.system("sed -i 's|chown -R xtreamcodes:xtreamcodes /home/xtreamcodes|chown -R xtreamcodes:xtreamcodes /home/xtreamcodes 2>/dev/null|g' /home/xtreamcodes/iptv_xtream_codes/start_services.sh")
    os.system("chmod +x /home/xtreamcodes/iptv_xtream_codes/start_services.sh > /dev/null")
    os.system("mount -a")
    os.system("chmod 0700 /home/xtreamcodes/iptv_xtream_codes/config > /dev/null")
    os.system("sed -i 's|echo \"Xtream Codes Reborn\";|echo \"Nexora Panel\";|g' /home/xtreamcodes/iptv_xtream_codes/wwwdir/index.php")
    #new sysctl.conf
    os.system("/bin/cp /etc/sysctl.conf /etc/sysctl.conf.bak")
    os.system('echo "%s" > /etc/sysctl.conf' % rSysCtl)
    os.system("/sbin/sysctl -p > /dev/null")
    #new alias, shortcuts, restartpanel and reloadnginx
    os.system('echo "alias restartpanel=\'sudo /home/xtreamcodes/iptv_xtream_codes/start_services.sh && echo done\'\nalias reloadnginx=\'sudo /home/xtreamcodes/iptv_xtream_codes/nginx/sbin/nginx -s reload && echo done\'" > /root/.bash_aliases')
    os.system("source /root/.bashrc > /dev/null")
    # NOTE (privacy): these hosts entries keep the panel from phoning home to
    # api.xtream-codes.com. They also block legitimate upstream updates — on a
    # resold panel this is intentional and desired.
    if not "api.xtream-codes.com" in open("/etc/hosts").read(): os.system('echo "127.0.0.1    api.xtream-codes.com" >> /etc/hosts')
    if not "downloads.xtream-codes.com" in open("/etc/hosts").read(): os.system('echo "127.0.0.1    downloads.xtream-codes.com" >> /etc/hosts')
    if not "xtream-codes.com" in open("/etc/hosts").read(): os.system('echo "127.0.0.1    xtream-codes.com" >> /etc/hosts')
    if not "@reboot root /home/xtreamcodes/iptv_xtream_codes/start_services.sh" in open("/etc/crontab").read(): os.system('echo "@reboot root /home/xtreamcodes/iptv_xtream_codes/start_services.sh" >> /etc/crontab')

def start(first=True):
    if first: printc("Starting Nexora")
    else: printc("Restarting Nexora")
    os.system("/home/xtreamcodes/iptv_xtream_codes/start_services.sh > /dev/null")

def modifyNginx():
    printc("Modifying Nginx")
    rPath = "/home/xtreamcodes/iptv_xtream_codes/nginx/conf/nginx.conf"
    rPrevData = open(rPath, "r").read()
    if not "listen 25500;" in rPrevData:
        shutil.copy(rPath, "%s.xc" % rPath)
        # SECURITY HARDENED admin server block:
        #  - full Nexora security header set (matches security-pack)
        #  - optional TLS (25501) using the generated self-signed cert
        rData = "}".join(rPrevData.split("}")[:-1]) + (
            "    server {\n"
            "        listen 25500;\n"
            "        server_name _;\n"
            "        index index.php index.html index.htm;\n"
            "        root /home/xtreamcodes/iptv_xtream_codes/admin/;\n"
            "        server_tokens off;\n"
            "        add_header X-Frame-Options \"SAMEORIGIN\" always;\n"
            "        add_header X-Content-Type-Options \"nosniff\" always;\n"
            "        add_header X-XSS-Protection \"1; mode=block\" always;\n"
            "        add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n"
            "        add_header Permissions-Policy \"geolocation=(), camera=(), microphone=(), fullscreen=(self)\" always;\n"
            "        add_header X-Permitted-Cross-Domain-Policies \"none\" always;\n"
            "        add_header Cache-Control \"no-store, no-cache, must-revalidate, max-age=0\" always;\n"
            "\n"
            "        location ~ \\.php$ {\n"
            "            limit_req zone=one burst=8;\n"
            "            try_files $uri =404;\n"
            "            fastcgi_index index.php;\n"
            "            fastcgi_pass php;\n"
            "            include fastcgi_params;\n"
            "            fastcgi_buffering on;\n"
            "            fastcgi_buffers 96 32k;\n"
            "            fastcgi_buffer_size 32k;\n"
            "            fastcgi_max_temp_file_size 0;\n"
            "            fastcgi_keep_conn on;\n"
            "            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n"
            "            fastcgi_param SCRIPT_NAME $fastcgi_script_name;\n"
            "        }\n"
            "    }\n"
            "}"
        )
        rFile = open(rPath, "w")
        rFile.write(rData)
        rFile.close()
        # SECURITY: upgrade the streaming server TLS protocols (drop SSLv3/TLSv1.0/1.1)
        rCur = open(rPath, "r").read()
        if "SSLv3 TLSv1.1" in rCur:
            rCur = rCur.replace("ssl_protocols SSLv3 TLSv1.1 TLSv1.2;",
                                "ssl_protocols TLSv1.2 TLSv1.3;")
            rFile = open(rPath, "w")
            rFile.write(rCur)
            rFile.close()
        # generate a self-signed cert for the admin panel if none exists
        cert_dir = "/home/xtreamcodes/iptv_xtream_codes/nginx/conf"
        if not os.path.exists(os.path.join(cert_dir, "server.crt")):
            os.system("openssl req -x509 -newkey rsa:2048 -nodes -days 3650 "
                      "-keyout %s/server.key -out %s/server.crt "
                      "-subj '/CN=iptv-panel' > /dev/null 2>&1" % (cert_dir, cert_dir))
        printc("Admin panel hardened (security headers + TLS upgrade + self-signed cert ready).", col.OKGREEN)

def applyOnDemandFixes():
    """Baked-in on-demand streaming fixes so fresh installs never suffer the
    cold-start / no-sleep problems hit in the field (see PREVENT-FUTURE-ISSUES.md):
      1) ffmpeg wrapper that injects larger -probesize/-analyzeduration
         (the panel's ionCube stream_monitor hardcodes 256000 which kills
         slow mpeg2 sources instantly). Real binary moves to ffmpeg_real/ so
         /proc/<pid>/exe basename stays "ffmpeg" and ps_running() passes.
      2) clients_live.php wait loop 20s -> 120s (ts: *10->*60, m3u8: 20->240).
      3) pid_monitor + kill_leaks cron entries, exactly once each.
    Idempotent: safe to run again after an update()."""
    rPanel = "/home/xtreamcodes/iptv_xtream_codes"
    printc("Applying On-Demand Fixes")
    # --- 1) ffmpeg wrapper -------------------------------------------
    rBin = os.path.join(rPanel, "bin")
    rFF = os.path.join(rBin, "ffmpeg")
    rRealDir = os.path.join(rBin, "ffmpeg_real")
    rReal = os.path.join(rRealDir, "ffmpeg")
    def _writeWrapper():
        # Version-aware wrapper. Works with the shipped 2018 binary AND modern
        # ffmpeg (>= 5.0, e.g. the 8.1 prebuilt installed by upgradeCoreBinaries):
        #   1) strips any explicit -probesize/-analyzeduration, prepends larger
        #   2) always swaps the panel UA (some sources 403 on it, anti-restream)
        #   3) only on modern ffmpeg: -user-agent -> -user_agent rename and
        #      removal of the dropped "+delete" segment_list_flags value
        wrapper = (
            "#!/bin/bash\n"
            "# Nexora wrapper (version-aware): inject larger probe values, swap\n"
            "# the panel UA (some sources 403 it), and translate options that\n"
            "# modern ffmpeg (>= 5.0) renamed or dropped. Real binary in\n"
            "# ./ffmpeg_real/. /proc/<pid>/exe basename stays \"ffmpeg\" so\n"
            "# ps_running() passes.\n"
            "REAL=\"$(dirname \"$0\")/ffmpeg_real/ffmpeg\"\n"
            "VER=\"$($REAL -version 2>/dev/null | head -1 | grep -oE '[0-9]+\\.[0-9]+' | head -1)\"\n"
            "MAJOR=\"${VER%%.*}\"\n"
            "if [ -n \"$MAJOR\" ] && [ \"$MAJOR\" -ge 5 ] 2>/dev/null; then MODERN=1; else MODERN=0; fi\n"
            "nargs=(\"$@\")\n"
            "args=()\n"
            "i=0\n"
            "while [ \"$i\" -lt \"${#nargs[@]}\" ]; do\n"
            "  a=\"${nargs[$i]}\"\n"
            "  case \"$a\" in\n"
            "    -probesize|-analyzeduration)\n"
            "      i=$((i+2))\n"
            "      continue\n"
            "      ;;\n"
            "    -user-agent|-user_agent)\n"
            "      v=\"${nargs[$((i+1))]}\"\n"
            "      if [ \"$v\" = \"Xtream-Codes IPTV Panel Pro\" ]; then\n"
            "        v=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36\"\n"
            "      fi\n"
            "      if [ \"$MODERN\" = \"1\" ]; then\n"
            "        args+=(\"-user_agent\" \"$v\")\n"
            "      else\n"
            "        args+=(\"-user-agent\" \"$v\")\n"
            "      fi\n"
            "      i=$((i+2))\n"
            "      continue\n"
            "      ;;\n"
            "    -segment_list_flags)\n"
            "      args+=(\"$a\")\n"
            "      v=\"${nargs[$((i+1))]}\"\n"
            "      if [ \"$MODERN\" = \"1\" ]; then\n"
            "        v=\"${v//+delete/}\"\n"
            "      fi\n"
            "      args+=(\"$v\")\n"
            "      i=$((i+2))\n"
            "      continue\n"
            "      ;;\n"
            "  esac\n"
            "  args+=(\"$a\")\n"
            "  i=$((i+1))\n"
            "done\n"
            'exec "$REAL" -probesize 5000000 -analyzeduration 30000000 "${args[@]}"\n'
        )
        with open(rFF, "w") as f:
            f.write(wrapper)
        os.chmod(rFF, 0o755)
        printc("ffmpeg wrapper installed (version-aware, probesize 5000000)", col.OKGREEN)
    try:
        if os.path.exists(rFF) and not os.path.exists(rReal):
            # fresh install: move the shipped real binary aside, write wrapper
            os.makedirs(rRealDir, exist_ok=True)
            shutil.move(rFF, rReal)
            os.chmod(rReal, 0o755)
            printc("ffmpeg moved to ffmpeg_real/", col.OKGREEN)
            _writeWrapper()
        elif os.path.exists(rReal) and not os.path.exists(rFF):
            # ffmpeg_real present but wrapper missing: recreate it
            _writeWrapper()
        elif os.path.exists(rFF) and os.path.exists(rReal):
            if os.path.getsize(rFF) < 1000000 and os.path.getsize(rReal) > 1000000:
                printc("ffmpeg wrapper already in place", col.OKGREEN)
            else:
                # update() re-deployed the real binary at bin/ffmpeg while
                # ffmpeg_real/ still exists from a previous install: restore
                # the wrapper (idempotent, safe on repeat runs)
                os.remove(rFF)
                _writeWrapper()
    except Exception as e:
        printc("ffmpeg wrapper step failed: %s" % e, col.FAIL)
    # --- 2) clients_live.php wait loop 20s -> 120s --------------------
    rCL = os.path.join(rPanel, "wwwdir", "streaming", "clients_live.php")
    if os.path.exists(rCL):
        try:
            data = open(rCL, "r", encoding="latin-1").read()
            new = data.replace("* 10))", "* 60))").replace("<= 20))", "<= 240))").replace("== 20))", "== 240))")
            if new != data:
                if not os.path.exists(rCL + ".bak_odwait"):
                    shutil.copy(rCL, rCL + ".bak_odwait")
                with open(rCL, "w", encoding="latin-1") as f:
                    f.write(new)
                printc("clients_live.php wait loop extended to 120s", col.OKGREEN)
            else:
                printc("clients_live.php wait loop already patched", col.OKGREEN)
        except Exception as e:
            printc("clients_live.php patch failed: %s" % e, col.FAIL)
    # --- 3) sleep crons: pid_monitor + kill_leaks, once each -----------
    rPhp = os.path.join(rPanel, "php", "bin", "php")
    rPM = os.path.join(rPanel, "crons", "pid_monitor.php")
    rKL = os.path.join(rPanel, "crons", "kill_leaks.php")
    if os.path.exists(rPhp) and os.path.exists(rPM) and os.path.exists(rKL):
        try:
            cur = subprocess.check_output("crontab -l 2>/dev/null || true", shell=True).decode("utf-8", "replace")
            add = ""
            if "pid_monitor.php" not in cur:
                add += "\n* * * * * %s %s >/dev/null 2>&1" % (rPhp, rPM)
            if "kill_leaks.php" not in cur:
                add += "\n* * * * * %s %s >/dev/null 2>&1" % (rPhp, rKL)
            if add:
                os.system('(crontab -l 2>/dev/null; echo -e "%s") | crontab - > /dev/null 2>&1' % add.replace("\n", "\\n"))
                printc("Sleep crons added (pid_monitor + kill_leaks)", col.OKGREEN)
            else:
                printc("Sleep crons already present", col.OKGREEN)
        except Exception as e:
            printc("crontab update failed: %s" % e, col.FAIL)
    printc("On-Demand fixes applied", col.OKGREEN)


def upgradeCoreBinaries():
    """Best-effort core binary upgrades so fresh installs get modern nginx
    and ffmpeg (see LOCAL-SECURITY-REPORT.md). Every step is optional and
    falls back to the shipped binaries -- never fatal:
      1) nginx: swap the bundled (2018) nginx for the distro nginx when the
         distro one is >= 1.20 and `nginx -t` passes with the panel conf.
         start_services.sh is then patched to launch it with -p/-c so the
         relative paths (certs, mime.types, logs) resolve correctly.
      2) ffmpeg: replace the shipped 2018 binary with a modern static build
         (>= 8.0). Uses a pre-placed /root/ffmpeg-8.1.tar.xz when present,
         otherwise downloads the BtbN n8.1 build and verifies its SHA256
         before installing -- a bad checksum is never installed. The old
         binary is parked at bin/ffmpeg.old_2018 and the version-aware
         wrapper is recreated by applyOnDemandFixes().
    Idempotent: skips when the binaries are already modern."""
    import re
    rPanel = "/home/xtreamcodes/iptv_xtream_codes"
    def _ver(cmd):
        try:
            out = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", "replace")
            m = re.search(r"(\d+)\.(\d+)", out)
            if m:
                return (int(m.group(1)), int(m.group(2)))
        except Exception:
            pass
        return (0, 0)
    # --- 1) nginx -----------------------------------------------------
    rNgBin = os.path.join(rPanel, "nginx", "sbin", "nginx")
    sysNg = "/usr/sbin/nginx"
    if os.path.exists(rNgBin):
        if os.path.exists(sysNg):
            cur = _ver([rNgBin, "-v"])
            sys = _ver([sysNg, "-v"])
            if cur >= (1, 20):
                printc("nginx already modern (%d.%d)" % cur, col.OKGREEN)
            elif sys < (1, 20):
                printc("distro nginx too old (%d.%d), keeping bundled" % sys, col.WARNING)
            else:
                rNgConf = os.path.join(rPanel, "nginx", "conf", "nginx.conf")
                if os.path.exists(rNgConf):
                    rc = subprocess.call([sysNg, "-t", "-p", os.path.join(rPanel, "nginx") + "/",
                                          "-c", rNgConf],
                                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    if rc == 0:
                        shutil.copy(rNgBin, rNgBin + ".bak_old")
                        shutil.copy(sysNg, rNgBin)
                        os.chmod(rNgBin, 0o755)
                        printc("nginx upgraded %d.%d -> distro %d.%d" % (cur[0], cur[1], sys[0], sys[1]), col.OKGREEN)
                        rSS = os.path.join(rPanel, "start_services.sh")
                        if os.path.exists(rSS):
                            data = open(rSS, "r").read()
                            if "-p " not in data:
                                new = re.sub(r"(nginx/sbin/nginx)(\s*)$",
                                             "\\1 -p %s/nginx/ -c %s/nginx/conf/nginx.conf\\2" % (rPanel, rPanel),
                                             data, flags=re.M)
                                if new != data:
                                    open(rSS, "w").write(new)
                                    printc("start_services.sh patched to launch nginx with -p/-c", col.OKGREEN)
                    else:
                        printc("nginx -t failed with distro nginx, keeping bundled binary", col.WARNING)
                else:
                    printc("nginx.conf not found, keeping bundled nginx", col.WARNING)
        else:
            printc("distro nginx not found (/usr/sbin/nginx) - apt install nginx to enable the 1.24 upgrade", col.WARNING)
    # --- 2) ffmpeg ----------------------------------------------------
    rBin = os.path.join(rPanel, "bin")
    rReal = os.path.join(rBin, "ffmpeg_real", "ffmpeg")
    cur = _ver([rReal, "-version"])
    if cur < (8, 0):
        cur = _ver([os.path.join(rBin, "ffmpeg"), "-version"])
    if cur >= (8, 0):
        printc("ffmpeg already modern (%d.%d)" % cur, col.OKGREEN)
        return
    tar = "/root/ffmpeg-8.1.tar.xz"
    rSHA = "aec049708a05ec2b5ee8e709ce683300bb40a7e35a090b1c5a74d42ae9bf1fd5"
    if not (os.path.exists(tar) and os.path.getsize(tar) > 50000000):
        printc("Downloading ffmpeg 8.1 static build (BtbN)...", col.WARNING)
        if not download(os.environ.get("NEXORA_FFMPEG_URL",
                          "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz"), tar):
            printc("ffmpeg download failed, keeping shipped binary", col.WARNING)
            return
    if sha256_file(tar) != rSHA:
        printc("ffmpeg SHA256 mismatch -- not installing unverified build", col.FAIL)
        return
    ex = "/tmp/nexora-ffmpeg81"
    if os.path.exists(ex):
        shutil.rmtree(ex, ignore_errors=True)
    os.makedirs(ex, exist_ok=True)
    if os.system("tar -xJf %s -C %s > /dev/null 2>&1" % (tar, ex)) != 0:
        printc("ffmpeg extract failed, keeping shipped binary", col.WARNING)
        return
    found = None
    for root, dirs, files in os.walk(ex):
        if "ffmpeg" in files:
            p = os.path.join(root, "ffmpeg")
            if os.path.getsize(p) > 1000000 and _ver([p, "-version"]) >= (8, 0):
                found = p
                break
    if not found:
        printc("ffmpeg 8.1 binary not found in archive, keeping shipped binary", col.WARNING)
        return
    os.makedirs(os.path.dirname(rReal), exist_ok=True)
    if os.path.exists(rReal) and _ver([rReal, "-version"]) < (8, 0):
        shutil.copy(rReal, rReal + ".bak_old")
        printc("previous ffmpeg preserved at ffmpeg_real/ffmpeg.bak_old", col.OKGREEN)
    oldReal = os.path.join(rBin, "ffmpeg")
    if os.path.exists(oldReal) and os.path.getsize(oldReal) > 1000000:
        # the shipped 2018 real binary: park it so the wrapper branch in
        # applyOnDemandFixes() (ffmpeg_real present, bin/ffmpeg missing)
        # recreates the wrapper around the new binary.
        shutil.move(oldReal, os.path.join(rBin, "ffmpeg.old_2018"))
        printc("2018 ffmpeg preserved at bin/ffmpeg.old_2018", col.OKGREEN)
    shutil.copy(found, rReal)
    os.chmod(rReal, 0o755)
    printc("ffmpeg upgraded to 8.1 (BtbN static build)", col.OKGREEN)


def deployMigrationTool():
    """Bake the Nexora DB migration tooling (MySQL -> MariaDB) into the panel so
    it exists on EVERY install — not only when /root/release.zip is applied:
      - /opt/nexora-migrate/nexora-migrate.sh   (canonical engine; sudoers rule)
      - pytools/nexora-migrate.sh               (panel copy db_migration.php uses)
      - admin/db_migration.php                  (the UI page, written if missing)
    Idempotent: the script is always rewritten from the embedded constant; the
    page is written only when absent so any admin customisation survives."""
    rPanel = "/home/xtreamcodes/iptv_xtream_codes"
    try:
        os.makedirs("/opt/nexora-migrate", exist_ok=True)
        os.makedirs(os.path.join(rPanel, "pytools"), exist_ok=True)
        os.makedirs(os.path.join(rPanel, "admin"), exist_ok=True)
        with open("/opt/nexora-migrate/nexora-migrate.sh", "w") as f:
            f.write(NEXORA_MIGRATE_SH)
        os.chmod("/opt/nexora-migrate/nexora-migrate.sh", 0o750)
        rPytools = os.path.join(rPanel, "pytools", "nexora-migrate.sh")
        with open(rPytools, "w") as f:
            f.write(NEXORA_MIGRATE_SH)
        os.chmod(rPytools, 0o750)
        rDbMig = os.path.join(rPanel, "admin", "db_migration.php")
        if not os.path.exists(rDbMig):
            with open(rDbMig, "w") as f:
                f.write(NEXORA_DB_MIGRATION_PHP)
        os.system("chown -R xtreamcodes:xtreamcodes /opt/nexora-migrate %s %s > /dev/null 2>&1"
                  % (rPytools, rDbMig))
        printc("DB migration tool deployed (nexora-migrate.sh + db_migration.php)", col.OKGREEN)
    except Exception as e:
        printc("deployMigrationTool failed: %s" % e, col.FAIL)


def activateLicense():
    """Sellable flow: if NEXORA_LICENSE_KEY is provided, deploy the license
    file, the checker script, and the daily cron so the panel enforces
    subscription expiry. Skips cleanly for self-hosted / free installs.

    Env vars (all optional, set by the customer before running the installer):
      NEXORA_LICENSE_KEY   - signed license string from the seller
      NEXORA_LICENSE_URL   - seller's renew endpoint (https://your-site/renew?lic=..)
      NEXORA_LICENSE_SECRET - HMAC secret (must match the seller's signing key)
      NEXORA_LICENSE_GRACE - grace days after expiry (default 7)
    Idempotent: re-runs are safe (overwrites lic/conf, cron is deduped)."""
    rKey = os.environ.get("NEXORA_LICENSE_KEY", "").strip()
    if not rKey:
        printc("No NEXORA_LICENSE_KEY — license activation skipped (free install)", col.OKGREEN)
        return True
    rUrl = os.environ.get("NEXORA_LICENSE_URL", "").strip()
    rSecret = os.environ.get("NEXORA_LICENSE_SECRET", "").strip()
    rGrace = os.environ.get("NEXORA_LICENSE_GRACE", "7").strip()
    try:
        os.makedirs("/opt/nexora-license", exist_ok=True)
        # 1) license file — v5: always in /opt/nexora-license/
        with open("/opt/nexora-license/nexora-license.lic", "w") as f:
            f.write(rKey + "\n")
        # 2) config — v5: use NEXORA_LICENSE_URL format, default to seller direct
        seller_url = rUrl or "http://127.0.0.1:8899/verify?lic="
        conf = "NEXORA_LICENSE_URL=%s\nNEXORA_LICENSE_KEY=%s\n" % (seller_url, rKey)
        with open("/opt/nexora-license/nexora-license.conf", "w") as f:
            f.write(conf)
        # 3) checker script (baked-in or from release package)
        rChecker = globals().get("NEXORA_LICENSE_CHECK_SH", "")
        if rChecker:
            with open("/opt/nexora-license/license_check.sh", "w") as f:
                f.write(rChecker)
            try:
                os.chmod("/opt/nexora-license/license_check.sh", 0o750)
            except Exception:
                pass  # best-effort (never abort activation on chmod failure)
        # 3b) enforcement daemon (robust: renames binary + kills processes)
        rEnforcer = globals().get("NEXORA_LICENSE_ENFORCER_PY", "")
        if rEnforcer:
            with open("/opt/nexora-license/license_enforcer.py", "w") as f:
                f.write(rEnforcer)
            try:
                os.chmod("/opt/nexora-license/license_enforcer.py", 0o755)
            except Exception:
                pass
        # 3c) v5: fast sync daemon (syncs .lic from seller DB every 10s)
        rFastSync = globals().get("NEXORA_FAST_SYNC_PY", "")
        if rFastSync:
            with open("/opt/nexora-license/fast_sync.py", "w") as f:
                f.write(rFastSync)
            try:
                os.chmod("/opt/nexora-license/fast_sync.py", 0o755)
            except Exception:
                pass
        # 4) cron: enforcer every minute + fast_sync every 10s + checker daily
        try:
            cur = subprocess.check_output("crontab -l 2>/dev/null || true",
                                          shell=True).decode("utf-8", "replace")
        except Exception:
            cur = ""
        if "license_enforcer.py" not in cur:
            cron_line = "* * * * * python3 /opt/nexora-license/license_enforcer.py --once >> /opt/nexora-license/nexora-license.log 2>&1"
            os.system(
                '(crontab -l 2>/dev/null; echo "%s") | crontab - > /dev/null 2>&1'
                % cron_line)
        # v5: add fast_sync every 10 seconds (6x per minute)
        if "fast_sync.py" not in cur:
            cron_line = "*/1 * * * * for i in $(seq 1 6); do python3 /opt/nexora-license/fast_sync.py >/dev/null 2>&1; sleep 10; done"
            os.system(
                '(crontab -l 2>/dev/null; echo "%s") | crontab - > /dev/null 2>&1'
                % cron_line)
        if "license_check.sh" not in cur and "license_enforcer.py" not in cur:
            cron_line = "0 5 * * * /opt/nexora-license/license_check.sh >/dev/null 2>&1"
            os.system(
                '(crontab -l 2>/dev/null; echo "%s") | crontab - > /dev/null 2>&1'
                % cron_line)
        printc("License deployed (lic + conf + checker + enforcer + fast_sync + cron)", col.OKGREEN)
        printc("  License:    /opt/nexora-license/nexora-license.lic", col.OKGREEN)
        printc("  Config:     /opt/nexora-license/nexora-license.conf", col.OKGREEN)
        printc("  Enforcer:   /opt/nexora-license/license_enforcer.py (every minute)", col.OKGREEN)
        printc("  Fast sync:  /opt/nexora-license/fast_sync.py (every 10s)", col.OKGREEN)
        printc("  Seller URL: %s" % seller_url, col.OKGREEN)
    except Exception as e:
        printc("activateLicense failed: %s" % e, col.FAIL)


def hardenSystem():
    """Phase 1 hardening baked into the installer: access_log, fail2ban,
    php.ini session hardening, sudoers rule for the migration script."""
    rPanel = "/home/xtreamcodes/iptv_xtream_codes"
    # 1) enable nginx access_log (feeds fail2ban)
    rConf = os.path.join(rPanel, "nginx/conf/nginx.conf")
    if os.path.exists(rConf):
        rC = open(rConf, "r").read()
        if "access_log off" in rC:
            rC = rC.replace("access_log off;",
                            "access_log %s/nginx/logs/access.log;" % rPanel)
            open(rConf, "w").write(rC)
        os.makedirs(os.path.join(rPanel, "nginx/logs"), exist_ok=True)
    # 2) fail2ban jail + filter for the streaming API
    os.system("DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban > /dev/null 2>&1 || true")
    if os.path.isdir("/etc/fail2ban"):
        os.makedirs("/etc/fail2ban/jail.d", exist_ok=True)
        os.makedirs("/etc/fail2ban/filter.d", exist_ok=True)
        rJail = os.path.join(rPanel, "nginx/logs/access.log")
        with open("/etc/fail2ban/filter.d/xtream-stream.conf", "w") as f:
            f.write("# Nexora filter - streaming API 401s\n"
                    "[Definition]\n"
                    "failregex = ^\\s*<HOST> - - \\[.*\\] \"(GET|POST) [^\"]*\" 401\n"
                    "ignoreregex =\n")
        with open("/etc/fail2ban/jail.d/nexora.conf", "w") as f:
            f.write("[DEFAULT]\nbantime = 1h\nfindtime = 10m\nmaxretry = 8\n"
                    "ignoreip = 127.0.0.1/8 ::1\n\n"
                    "[xtream-stream]\nenabled = true\nport = http,https,25461,25463\n"
                    "filter = xtream-stream\nlogpath = %s\nbackend = polling\n"
                    "maxretry = 8\nfindtime = 10m\nbantime = 30m\n" % rJail)
        os.system("systemctl restart fail2ban > /dev/null 2>&1 || "
                  "service fail2ban restart > /dev/null 2>&1 || true")
    # 3) php.ini session hardening
    rPhpIni = os.path.join(rPanel, "php/lib/php.ini")
    if os.path.exists(rPhpIni):
        rP = open(rPhpIni, "r").read()
        if "session.use_strict_mode = 1" not in rP:
            with open(rPhpIni, "a") as f:
                f.write("\n; --- Nexora hardening ---\n"
                        "session.cookie_httponly = 1\n"
                        "session.use_strict_mode = 1\n"
                        "session.use_only_cookies = 1\n"
                        "session.cookie_secure = 1\n"
                        "expose_php = Off\n"
                        "display_errors = Off\n")
    # 4) sudoers rule so the DB Migration Center can run the mig script
    rSudoers = "/etc/sudoers.d/nexora-migrate"
    if not os.path.exists(rSudoers):
        with open(rSudoers, "w") as f:
            f.write("# Nexora: allow the panel web user to run ONLY the migration script as root\n"
                    "xtreamcodes ALL = (root) NOPASSWD: /bin/bash "
                    "/opt/nexora-migrate/nexora-migrate.sh *\n")
        os.system("chmod 440 %s" % rSudoers)
        os.system("visudo -c > /dev/null 2>&1")
    printc("System hardening applied (access_log, fail2ban, php.ini, sudoers).", col.OKGREEN)

if __name__ == "__main__":
    global rType
    printc("Nexora Panel Installer (hardened build for Ubuntu 24.04)", col.OKGREEN, 2)
    print(" ")
    rType = input("  Installation Type [MAIN, LB, UPDATE]: ")
    print(" ")
    if rType.upper() in ["MAIN", "LB"]:
        if rType.upper() == "LB":
            rHost = input("  Main Server IP Address: ")
            rPassword = input("  MySQL Password: ")
            try: rServerID = int(input("  Load Balancer Server ID: "))
            except: rServerID = -1
            print(" ")
        else:
            rHost = "127.0.0.1"
            rPassword = generate()
            rServerID = 1
        rUsername = "user_iptvpro"
        rDatabase = "xtream_iptvpro"
        rPort = 7999
        if len(rHost) > 0 and len(rPassword) > 0 and rServerID > -1:
            printc("Start installation? Y/N", col.WARNING)
            if input("  ").upper() == "Y":
                print(" ")
                rRet = prepare(rType.upper())
                if not install(rType.upper()): sys.exit(1)
                if rType.upper() == "MAIN":
                    if not mysql(rUsername, rPassword): sys.exit(1)
                encrypt(rHost, rUsername, rPassword, rDatabase, rServerID, rPort)
                configure()
                if rType.upper() == "MAIN":
                    modifyNginx()
                    update(rType.upper())
                upgradeCoreBinaries()
                applyOnDemandFixes()
                deployMigrationTool()
                activateLicense()
                start()
                hardenSystem()
                printc("Installation completed!", col.OKGREEN, 2)
                if rType.upper() == "MAIN":
                    printc("Please store your MySQL password!")
                    printc(rPassword)
                    printc("Admin UI: http://%s:25500" % getIP())
                    printc("Admin UI username is 'admin' — a strong password was generated and set automatically.")
                    printc("It was also written to /root/install_log.txt")
            else: printc("Installation cancelled", col.FAIL)
        else: printc("Invalid entries", col.FAIL)
    elif rType.upper() == "UPDATE":
        if os.path.exists("/home/xtreamcodes/iptv_xtream_codes/wwwdir/api.php"):
            printc("Update Admin Panel? Y/N?", col.WARNING)
            if input("  ").upper() == "Y":
                if not update(rType.upper()): sys.exit(1)
                upgradeCoreBinaries()
                applyOnDemandFixes()
                deployMigrationTool()
                activateLicense()
                printc("Installation completed!", col.OKGREEN, 2)
                start()
            else: printc("Install Nexora Main first!", col.FAIL)
    else: printc("Invalid installation type", col.FAIL)
