#!/usr/bin/env python3
"""
SYLink HoneyBot — License watcher.

Polle /api/honeypot/license/verify toutes les 5 min. Si licence revoked/suspended
côté UniSOC → stop tous les services leurres (la VM devient inerte).
Quand la licence redevient valide → redémarre les services.

Mode "résilient" : si UniSOC injoignable, on garde la dernière décision connue
(cache /opt/sylink-honeypot/etc/license_status.json TTL 30 min) — pas de flap.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone, timedelta
from pathlib import Path

CONFIG_DIR = Path("/opt/sylink-honeypot/etc")
LOG_DIR = Path("/opt/sylink-honeypot/log")
SUSPENDED_FLAG = Path("/var/lib/unisoc-honeypot.suspended")
LICENSE_FILE = CONFIG_DIR / "license.json"
STATUS_CACHE = CONFIG_DIR / "license_status.json"
PT_API_BASE = os.environ.get("HP_API_BASE", "https://api.unisoc.fr")
CACHE_TTL_S = 30 * 60  # 30 min de tolérance UniSOC down

# Services à toggle on/off selon licence
LEURRE_SERVICES = [
    "cowrie", "opencanary", "veeam-fake", "proftpd-fake",
    "ssh-tarpit", "file-watcher", "rdp-recorder", "http-honeytrap",
    "smbd", "nmbd", "xrdp", "xrdp-sesman",
]


def log(msg: str):
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    line = f"[{datetime.now(timezone.utc).isoformat()}] {msg}"
    print(line, flush=True)
    with (LOG_DIR / "license_check.log").open("a") as f:
        f.write(line + "\n")


def read_license_token() -> str | None:
    if not LICENSE_FILE.exists():
        return None
    try:
        return json.loads(LICENSE_FILE.read_text()).get("license_token")
    except Exception:
        return None


def verify_remote(license_token: str) -> dict | None:
    url = f"{PT_API_BASE}/api/honeypot/license/verify"
    req = urllib.request.Request(
        url, method="POST",
        headers={"Authorization": f"Bearer {license_token}", "Content-Type": "application/json"},
        data=b"{}",
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except Exception as e:
        log(f"verify HTTP erreur : {e}")
        return None


def read_cache() -> dict | None:
    if not STATUS_CACHE.exists():
        return None
    try:
        c = json.loads(STATUS_CACHE.read_text())
        ts = datetime.fromisoformat(c.get("cached_at", "").replace("Z", "+00:00"))
        if (datetime.now(timezone.utc) - ts).total_seconds() > CACHE_TTL_S:
            return None
        return c
    except Exception:
        return None


def write_cache(result: dict):
    result["cached_at"] = datetime.now(timezone.utc).isoformat()
    STATUS_CACHE.write_text(json.dumps(result, indent=2))


def stop_services(reason: str):
    log(f"=> SUSPENSION ({reason}) — stop des {len(LEURRE_SERVICES)} services leurres")
    for svc in LEURRE_SERVICES:
        subprocess.run(["systemctl", "--no-block", "stop", svc], timeout=10, capture_output=True)
    SUSPENDED_FLAG.parent.mkdir(parents=True, exist_ok=True)
    SUSPENDED_FLAG.write_text(f"{datetime.now(timezone.utc).isoformat()} {reason}\n")
    if reason != "no_license":
        # Banner maintenance explicite (sauf pour 'no_license' = VM pas activée, on garde le banner activation)
        try:
            Path("/etc/issue").write_text(
                "\n"
                "===================================================================\n"
                "  Corp Backup Infrastructure - MAINTENANCE MODE\n"
                "===================================================================\n"
                f"\n  System temporarily unavailable.\n"
                f"  Reason: license suspended ({reason})\n"
                f"  Contact your SOC administrator.\n\n"
            )
        except Exception:
            pass


def start_services():
    log(f"=> REPRISE — démarrage des {len(LEURRE_SERVICES)} services leurres")
    for svc in LEURRE_SERVICES:
        subprocess.run(["systemctl", "--no-block", "start", svc], timeout=10, capture_output=True)
    if SUSPENDED_FLAG.exists():
        SUSPENDED_FLAG.unlink()
    subprocess.run(["/opt/sylink-honeypot/bin/init.py"], timeout=15, capture_output=True)


# ──────────────────────────────────────────────────────────────────────────
# Pending actions admin (reboot / update / network) — déclenchées via portail
# La VM persiste le timestamp du dernier traitement dans action_state.json
# pour éviter de re-exécuter une action déjà appliquée.
# ──────────────────────────────────────────────────────────────────────────

ACTION_STATE = CONFIG_DIR / "action_state.json"


def load_action_state() -> dict:
    if ACTION_STATE.exists():
        try:
            return json.loads(ACTION_STATE.read_text())
        except Exception:
            pass
    return {}


def save_action_state(s: dict):
    ACTION_STATE.write_text(json.dumps(s, indent=2))


def process_pending_actions(actions: dict):
    """Exécute les actions admin pending (cf. license/verify response)."""
    if not actions:
        return
    state = load_action_state()

    # 1. Network config — si requested_at différent du last_processed
    nc_at = actions.get("network_config_requested_at")
    if nc_at and nc_at != state.get("network_processed_at"):
        nc = actions.get("network_config")
        if nc:
            log(f"=> ACTION network_config : {nc}")
            try:
                # Import poll.apply_network_config (déjà compatible netplan + ifupdown)
                import importlib.util as _u
                spec = _u.spec_from_file_location("poll_mod", "/opt/sylink-honeypot/bin/poll.py")
                mod = _u.module_from_spec(spec); spec.loader.exec_module(mod)
                mod.apply_network_config(nc)
                state["network_processed_at"] = nc_at
                save_action_state(state)
            except Exception as e:
                log(f"   network apply échec : {e}")

    # 2. Update — si requested_at différent
    up_at = actions.get("update_requested_at")
    if up_at and up_at != state.get("update_processed_at"):
        log(f"=> ACTION update demandée")
        try:
            subprocess.Popen(["/opt/sylink-honeypot/bin/self_update.py", "--now"])
            state["update_processed_at"] = up_at
            save_action_state(state)
        except Exception as e:
            log(f"   update échec : {e}")

    # 3. Reboot — si requested_at différent
    # ⚠ subprocess.Popen serait killé à la fin de ce service (Type=oneshot tue ses children).
    # On utilise systemd-run --on-active qui planifie une unité systemd transient qui survit.
    rb_at = actions.get("reboot_requested_at")
    if rb_at and rb_at != state.get("reboot_processed_at"):
        log(f"=> ACTION reboot demandée — exécution dans 5 s (via systemd-run)")
        state["reboot_processed_at"] = rb_at
        save_action_state(state)
        subprocess.run([
            "systemd-run", "--on-active=5s", "--unit=unisoc-honeypot-reboot.service",
            "/bin/systemctl", "reboot"
        ], capture_output=True, timeout=8)


def main() -> int:
    log("== license_check start ==")
    token = read_license_token()
    if not token:
        # VM pas activée → s'assurer que les services leurres sont OFF
        # ET que setup-ui (port 8080 pour activation) est UP
        if not SUSPENDED_FLAG.exists():
            stop_services("no_license")
        else:
            log("license.json absent et déjà suspended — rien à faire")
        subprocess.run(["systemctl", "--no-block", "enable", "--now", "sylink-honeypot-setup-ui"],
                       capture_output=True, timeout=10)
        return 0

    result = verify_remote(token)
    if result is None:
        cached = read_cache()
        if cached:
            log(f"UniSOC injoignable → utilise cache (valid={cached.get('valid')}, age cache)")
            result = cached
        else:
            log("UniSOC injoignable + cache expiré — décision : maintenir l'état actuel (fail-open)")
            return 0

    write_cache(result)
    suspended = SUSPENDED_FLAG.exists()

    if result.get("valid"):
        if suspended:
            log("licence redevenue valide → reprise")
            start_services()
        else:
            log("licence valide, services nominaux — rien à faire")
        # Pending actions admin déclenchées via portail
        process_pending_actions(result.get("pending_actions") or {})
    else:
        reason = result.get("reason", "unknown")
        if not suspended:
            stop_services(reason)
        else:
            log(f"licence toujours invalide ({reason}) — services restent stoppés")

    log("== license_check done ==")
    return 0


if __name__ == "__main__":
    sys.exit(main())
