#!/usr/bin/python3
"""ogroos-activation-proxy — an organisation's OgroOS activation service (volume activation, as
Windows KMS). It holds the organisation's volume licence from Dhaka Tek and gives its PCs
hardware-bound leases (180 days, renewed every 7 days). It talks to Dhaka Tek's server only to
renew, or by a file exchange for air-gapped sites. Design: docs/ogroos-activation-design.md §7.3.

  ogroos-activation-proxy activate --key VOLUMEKEY [--server URL] [--label NAME]
  ogroos-activation-proxy renew
  ogroos-activation-proxy offline-request [--key VOLUMEKEY] > request.json   (air-gapped)
  ogroos-activation-proxy offline-install answer.json
  ogroos-activation-proxy status
  ogroos-activation-proxy serve            (the service; ogroos-activation-proxy.service)

SPDX-FileCopyrightText: 2026 Dhaka Tek
SPDX-License-Identifier: LicenseRef-OgroOS-Proprietary
"""
import argparse
import configparser
import datetime as dt
import glob
import http.server
import json
import os
import socketserver
import sys
import threading
import time
import urllib.error
import urllib.request

sys.dont_write_bytecode = True  # nothing left behind in /usr/lib after a purge
sys.path.insert(0, "/usr/lib/ogroos-activation-proxy")
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib"))
import ogroos_licensing as L  # noqa: E402

VERSION = "1.0~alpha1"
CONF = os.environ.get("OGROOS_PROXY_CONF", "/etc/ogroos/activation-proxy.conf")
SHARE = os.environ.get("OGROOS_PROXY_SHARE", "/usr/share/ogroos/activation-proxy")
LOCK = threading.Lock()


class ProxyError(Exception):
    pass


def config():
    cp = configparser.ConfigParser(interpolation=None)
    cp.read(CONF)
    return cp


class Proxy:
    def __init__(self, state_dir):
        self.dir = state_dir
        self.cp = config()
        os.makedirs(self.dir, mode=0o700, exist_ok=True)
        self.state_file = os.path.join(self.dir, "state.json")
        try:
            with open(self.state_file) as fh:
                self.st = json.load(fh)
        except (OSError, ValueError):
            self.st = {"leases": {}}
        kf = os.path.join(self.dir, "proxy-key.pem")
        if not os.path.exists(kf):
            fd = os.open(kf, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, "wb") as fh:
                fh.write(L.private_pem(L.new_private_key()))
        with open(kf, "rb") as fh:
            self.key = L.load_private_pem(fh.read())
        self.pub = L.public_text(self.key, "OgroOS activation proxy").strip()
        self.trust = self._trust()

    def _trust(self):
        roots = {}
        for f in sorted(glob.glob(SHARE + "/trusted-keys/*.pub")):
            with open(f) as fh:
                roots[os.path.basename(f)[:-4]] = L.public_from_text(fh.read())
        certs, crls = list(self.st.get("certs") or []), []
        for f in sorted(glob.glob(SHARE + "/working-keys/*.json")):
            with open(f) as fh:
                certs.append(json.load(fh))
        if os.path.exists(SHARE + "/revocations.json"):
            with open(SHARE + "/revocations.json") as fh:
                crls.append(json.load(fh))
        if self.st.get("crl"):
            crls.append(self.st["crl"])
        return L.Trust(roots, certs, crls)

    def save(self):
        self.st["certs"] = self.trust.cert_envelopes()
        if self.trust.crl:
            self.st["crl"] = self.trust.crl
        tmp = self.state_file + ".new"
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as fh:
            json.dump(self.st, fh, indent=1)
        os.replace(tmp, self.state_file)

    def server(self, override=None):
        url = override or self.st.get("server") or self.cp.get("proxy", "server", fallback="")
        if not url:
            raise ProxyError("No Dhaka Tek activation server configured (--server, or [proxy] server = in %s)." % CONF)
        return url.rstrip("/") + "/"

    def proof(self, nonce):
        return L.b64e(self.key.sign(b"ogroos-proxy\0" + nonce.encode()))

    def leases_report(self):
        now = L.utcnow()
        return [dict(le, last_seen=le.get("last_seen", "")) for le in self.st["leases"].values()
                if L.parse_time(le["valid_until"]) > now]

    def request_body(self, key=None, label=None):
        nonce = L.new_nonce()
        body = {"type": "ogroos-proxy-request", "nonce": nonce, "proof": self.proof(nonce), "proxy_pub": self.pub,
                "leases": self.leases_report(), "label": label or self.st.get("label") or os.uname().nodename}
        if key:
            body["key"] = L.parse_key(key)[0]
        elif self.st.get("proxy_id"):
            body["proxy_id"] = self.st["proxy_id"]
        else:
            raise ProxyError("This proxy is not activated: give its volume key (--key).")
        return body

    def accept(self, ans, nonce):
        for c in ans.get("certs") or []:
            try:
                self.trust.add_cert(c)
            except L.LicenceError:
                pass
        if ans.get("crl"):
            try:
                self.trust.add_crl(ans["crl"])
            except L.LicenceError:
                pass
        if "status" in ans:
            p = L.verify(ans["status"], self.trust)
            if p.get("nonce") == nonce and p.get("state") == "revoked":
                self.st["revoked"] = True
                self.save()
                raise ProxyError("Dhaka Tek revoked this proxy's volume licence.")
        vl = L.verify(ans["volume_licence"], self.trust)
        if vl.get("type") != "volume-licence" or vl.get("nonce") != nonce or vl.get("proxy_pub") != self.pub:
            raise ProxyError("The answer is not for this proxy or this request.")
        self.st.update(volume_licence=ans["volume_licence"], proxy_id=vl["proxy_id"], repo=ans.get("repo"),
                       renewed=L.iso(L.utcnow()), revoked=False)
        self.st.pop("pending_nonce", None)
        self.save()
        return vl

    def post(self, url, body):
        req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read())
        except urllib.error.HTTPError as exc:
            try:
                raise ProxyError(json.loads(exc.read()).get("message", str(exc))) from None
            except ValueError:
                raise ProxyError(str(exc)) from None
        except (urllib.error.URLError, OSError) as exc:
            raise ProxyError("Can't reach Dhaka Tek's activation server (%s)." % getattr(exc, "reason", exc)) from None

    def activate(self, key, server=None, label=None):
        url = self.server(server)
        self.st["server"] = url
        if label:
            self.st["label"] = label
        body = self.request_body(key=key, label=label)
        return self.accept(self.post(url + "api/v1/volume/activate", body), body["nonce"])

    def renew(self):
        body = self.request_body()
        return self.accept(self.post(self.server() + "api/v1/volume/renew", body), body["nonce"])

    def offline_request(self, key=None, label=None):
        if label:
            self.st["label"] = label
        body = self.request_body(key=key, label=label)
        self.st["pending_nonce"] = body["nonce"]
        self.save()
        return body

    def offline_install(self, answer):
        if answer.get("type") != "ogroos-proxy-answer":
            raise ProxyError("This is not an answer file from Dhaka Tek.")
        return self.accept(answer, self.st.get("pending_nonce", ""))

    def volume_licence(self):
        if not self.st.get("volume_licence"):
            raise ProxyError("This proxy is not activated.")
        return L.verify(self.st["volume_licence"], self.trust)

    def lease(self, req):
        vl = self.volume_licence()
        ident = {k: (req.get(k) or "").lower() for k in ("hw", "board", "serial", "inst")}
        if not ident["inst"] or any(v and len(v) != 64 for v in ident.values()):
            raise ProxyError("malformed request")
        if self.st.get("revoked"):
            raise ProxyError("This organisation's volume licence was revoked.")
        pc_id = ident["hw"] or ident["inst"]
        now = L.utcnow()
        with LOCK:
            active = {k for k, le in self.st["leases"].items() if L.parse_time(le["valid_until"]) > now}
            if pc_id not in active and len(active) >= vl["seats"]:
                raise ProxyError("All %d seats of the organisation's volume licence are in use." % vl["seats"])
            until = min(now + dt.timedelta(days=vl.get("lease_days", 180)), L.parse_time(vl["valid_until"]))
            n = self.st.setdefault("lease_serial", 0) + 1
            self.st["lease_serial"] = n
            payload = {"type": "lease", "serial": "P%d-%06d" % (vl["proxy_id"], n), "proxy_id": vl["proxy_id"],
                       "hw": {"hw": ident["hw"], "board": ident["board"] if ident["hw"] else "",
                              "inst": "" if ident["hw"] else ident["inst"]},
                       "org": vl["org"], "edition": vl["subscription"]["edition"], "issued_at": L.iso(now),
                       "valid_until": L.iso(until), "renew_days": vl.get("renew_days", 7), "nonce": req.get("nonce", "")}
            lease = L.Signer("proxy:%d" % vl["proxy_id"], self.key).sign(payload)
            old = self.st["leases"].get(pc_id, {})
            self.st["leases"][pc_id] = {"hw": ident["hw"], "inst": ident["inst"], "name": (req.get("name") or "")[:80],
                                        "model": (req.get("model") or "")[:80], "issued": old.get("issued", L.iso(now)),
                                        "valid_until": L.iso(until), "last_seen": L.iso(now)}
            self.save()
        return {"ok": True, "volume_licence": self.st["volume_licence"], "lease": lease, "repo": self.st.get("repo"),
                "certs": self.trust.cert_envelopes(), "crl": self.trust.crl}


def make_handler(proxy):
    class Handler(http.server.BaseHTTPRequestHandler):
        server_version = "ogroos-activation-proxy/" + VERSION

        def log_message(self, fmt, *args):
            sys.stderr.write("%s %s\n" % (self.command, self.path))

        def reply(self, code, obj):
            body = json.dumps(obj).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self):
            if self.path == "/v1/info":
                try:
                    vl = proxy.volume_licence()
                    return self.reply(200, {"ok": True, "org": vl["org"], "edition": vl["subscription"]["edition_name"],
                                            "seats": vl["seats"], "leases": len(proxy.leases_report()),
                                            "valid_until": vl["valid_until"]})
                except (ProxyError, L.LicenceError) as exc:
                    return self.reply(503, {"ok": False, "error": "not-activated", "message": str(exc)})
            self.reply(404, {"ok": False, "error": "not-found", "message": "unknown"})

        def do_POST(self):
            if self.path != "/v1/lease":
                return self.reply(404, {"ok": False, "error": "not-found", "message": "unknown"})
            try:
                n = int(self.headers.get("Content-Length") or 0)
                req = json.loads(self.rfile.read(min(n, 65536)) or b"{}")
                self.reply(200, proxy.lease(req))
            except ProxyError as exc:
                self.reply(403, {"ok": False, "error": "refused", "message": str(exc)})
            except (L.LicenceError, ValueError) as exc:
                self.reply(503, {"ok": False, "error": "not-activated", "message": str(exc)})
    return Handler


class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def renew_loop(proxy):
    """Renew with Dhaka Tek every 7 days (air-gapped proxies: by file, so this only logs)."""
    while True:
        last = L.parse_time(proxy.st.get("renewed"))
        if proxy.st.get("server") and proxy.st.get("proxy_id") and \
                (last is None or L.utcnow() - last >= dt.timedelta(days=7)):
            try:
                proxy.renew()
                sys.stderr.write("volume licence renewed\n")
            except (ProxyError, L.LicenceError) as exc:
                sys.stderr.write("renewal failed: %s\n" % exc)
        time.sleep(6 * 3600)


def fix_owner(state_dir):
    """Commands run by root leave the state readable by the service's own user."""
    if os.geteuid() != 0:
        return
    try:
        import pwd
        pw = pwd.getpwnam("ogroos-actproxy")
    except KeyError:
        return
    for dirpath, _, files in os.walk(state_dir):
        os.chown(dirpath, pw.pw_uid, pw.pw_gid)
        for f in files:
            os.chown(os.path.join(dirpath, f), pw.pw_uid, pw.pw_gid)


def main():
    ap = argparse.ArgumentParser(prog="ogroos-activation-proxy")
    ap.add_argument("command", choices=["serve", "activate", "renew", "offline-request", "offline-install", "status"])
    ap.add_argument("file", nargs="?")
    ap.add_argument("--key")
    ap.add_argument("--server")
    ap.add_argument("--label")
    ap.add_argument("--state", default=os.environ.get("OGROOS_PROXY_STATE", "/var/lib/ogroos-activation-proxy"))
    ap.add_argument("--listen")
    ap.add_argument("--port", type=int)
    a = ap.parse_args()
    try:
        p = Proxy(a.state)
        if a.command == "activate":
            vl = p.activate(a.key, a.server, a.label)
            print("Activated: %s, %s, %d seats, until %s." % (vl["org"]["name"], vl["subscription"]["edition_name"],
                                                               vl["seats"], vl["subscription"]["ends"]))
        elif a.command == "renew":
            vl = p.renew()
            print("Renewed until %s." % vl["subscription"]["ends"])
        elif a.command == "offline-request":
            json.dump(p.offline_request(a.key, a.label), sys.stdout, indent=1)
            print()
        elif a.command == "offline-install":
            with open(a.file) as fh:
                vl = p.offline_install(json.load(fh))
            print("Installed: %s, %d seats, until %s." % (vl["org"]["name"], vl["seats"], vl["subscription"]["ends"]))
        elif a.command == "status":
            vl = p.volume_licence()
            print("Organisation: %s\nEdition: %s\nSeats: %d (%d leased)\nSubscription ends: %s\nLast renewal: %s"
                  % (vl["org"]["name"], vl["subscription"]["edition_name"], vl["seats"], len(p.leases_report()),
                     vl["subscription"]["ends"], p.st.get("renewed")))
            for le in p.leases_report():
                print("  %s %-20s %-24s until %s" % ((le["hw"] or le["inst"])[:12], le["name"], le["model"], le["valid_until"][:10]))
        else:
            cp = p.cp
            listen = a.listen or cp.get("proxy", "listen", fallback="0.0.0.0")
            port = a.port or cp.getint("proxy", "port", fallback=8192)
            threading.Thread(target=renew_loop, args=(p,), daemon=True).start()
            sys.stderr.write("ogroos-activation-proxy %s on %s:%d\n" % (VERSION, listen, port))
            Server((listen, port), make_handler(p)).serve_forever()
    except (ProxyError, L.LicenceError) as exc:
        print("ogroos-activation-proxy: %s" % exc, file=sys.stderr)
        return 1
    finally:
        if a.command != "serve" and os.path.isdir(a.state):
            fix_owner(a.state)
    return 0


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