#!/usr/bin/python3
# A domain member's machine Group Policy (D1): the computer's registry policy
# from the domain.
#
#   sg-gpo-machine
#
# Run as root by sg-gpupdate (at boot and every 90 minutes, as Windows
# refreshes Group Policy). The GPOs that apply to this computer -- its OU
# chain, link order, enforced links, security filtering on the computer
# account's token -- are Samba's (samba.gp.gpclass.get_gpo_list with the
# machine account). Each one's Machine\Registry.pol is fetched from SYSVOL
# with the machine account (smbclient -P) and put in the machine policy
# directory as 60-domain-NN-{GUID}.pol, in the order the GPOs apply; the
# machine's policy machinery (sg-session's sg_apply_policy, as SYSTEM)
# imports it into HKLM. A GPO that stops applying loses its file.
#
# Computer startup scripts: each GPO's Machine\Scripts\scripts.ini
# [Startup] entries, with the scripts they name (in the GPO's
# Machine\Scripts\Startup folder, or a UNC path), are fetched the same way
# into STARTUP_DIR, and STARTUP_DIR/startup.list names them in order
# (path, TAB, parameters). sg-gpupdate runs them as SYSTEM once per boot, as
# Windows does at startup. The machine account fetches them; a script that
# itself reaches the network does so as the machine's Windows system, which
# has no domain credentials of its own yet.
#
# Best effort: a failure leaves the policy the machine had.
#
# Copyright (C) 2026 Stained Glass OS contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import configparser
import glob
import os
import re
import shutil
import subprocess
import sys
import syslog
import tempfile

POLICY_DIR = os.environ.get("SG_POLICY_DIR", "/etc/stained-glass/policy.d")
STARTUP_DIR = os.environ.get("SG_GPO_STARTUP_DIR", "/var/lib/stained-glass/gpo-startup")
PREFIX = "60-domain-"


def log(msg):
    syslog.syslog(syslog.LOG_INFO, msg)


def fetch(dc, path, dest, share="sysvol"):
    """Fetch path (relative to the share, SYSVOL by default) with the machine account."""
    if '"' in path or '"' in dest:
        return False
    cmd = 'get "%s" "%s"' % (path.replace("/", "\\"), dest)
    r = subprocess.run(["smbclient", "//%s/%s" % (dc, share), "-P", "-c", cmd],
                       capture_output=True, text=True, timeout=60)
    return r.returncode == 0 and os.path.isfile(dest) and os.path.getsize(dest) > 0


def read_ini(data):
    if data.startswith(b"\xff\xfe") or data.startswith(b"\xfe\xff"):
        text = data.decode("utf-16")
    else:
        text = data.decode("utf-8", "replace")
    parser = configparser.ConfigParser(interpolation=None, strict=False)
    parser.optionxform = str
    parser.read_string(text)
    return parser


def startup_entries(ini_data):
    """[Startup] nCmdLine / nParameters, in order."""
    try:
        ini = read_ini(ini_data)
    except Exception:
        return []
    section = next((s for s in ini.sections() if s.lower() == "startup"), None)
    if not section:
        return []
    keys = {k.lower(): v for k, v in ini.items(section)}
    out, n = [], 0
    while ("%dcmdline" % n) in keys:
        cmd = keys["%dcmdline" % n].strip()
        if cmd:
            out.append((cmd, keys.get("%dparameters" % n, "").strip()))
        n += 1
    return out


def safe_name(name):
    name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
    return name.lstrip(".") or "script"


def fetch_startup_scripts(dc, gpos_rel, lines):
    """Each GPO's startup scripts into STARTUP_DIR; returns the list's lines."""
    tmp = tempfile.mkdtemp()
    try:
        for n, rel in gpos_rel:
            ini = os.path.join(tmp, "%02d.ini" % n)
            if not fetch(dc, rel + "/Machine/Scripts/scripts.ini", ini):
                continue
            with open(ini, "rb") as f:
                entries = startup_entries(f.read())
            for i, (cmd, params) in enumerate(entries):
                base = cmd.replace("/", "\\").split("\\")[-1]
                dest_dir = os.path.join(STARTUP_DIR, "%02d" % n)
                os.makedirs(dest_dir, exist_ok=True)
                dest = os.path.join(dest_dir, "%d-%s" % (i, safe_name(base)))
                if cmd.startswith("\\\\"):
                    parts = [p for p in cmd.split("\\") if p]
                    if len(parts) < 3 or ".." in parts:
                        continue
                    ok = fetch(parts[0], "/".join(parts[2:]), dest, share=parts[1])
                elif re.match(r"^[A-Za-z]:", cmd):
                    # a path on the machine itself
                    lines.append("%s\t%s" % (cmd, params))
                    continue
                else:
                    parts = [p for p in cmd.replace("/", "\\").split("\\") if p]
                    if ".." in parts:
                        continue
                    ok = fetch(dc, rel + "/Machine/Scripts/Startup/" + "/".join(parts), dest)
                if ok:
                    os.chmod(dest, 0o644)
                    lines.append("%s\t%s" % (dest, params))
                    log("startup script from GPO %02d: %s" % (n, cmd))
                else:
                    log("startup script %s could not be fetched" % cmd)
    finally:
        for f in glob.glob(os.path.join(tmp, "*")):
            os.unlink(f)
        os.rmdir(tmp)


def main():
    syslog.openlog("sg-gpo-machine")
    role = open("/etc/stained-glass/role").read() if os.path.exists("/etc/stained-glass/role") else ""
    if "role=member" not in role.split():
        return 0

    from samba.param import LoadParm
    from samba.credentials import Credentials
    from samba.gp.gpclass import get_dc_hostname, get_gpo_list

    lp = LoadParm()
    lp.load_default()
    creds = Credentials()
    creds.guess(lp)
    creds.set_machine_account(lp)
    dc = get_dc_hostname(creds, lp)
    machine = (lp.get("netbios name") or "").upper() + "$"
    gpos = get_gpo_list(dc, creds, lp, machine)
    log("%s: %d GPOs apply: %s" % (machine, len(gpos), ", ".join(g.display_name or g.name for g in gpos)))

    os.makedirs(POLICY_DIR, exist_ok=True)
    keep = set()
    gpos_rel = []
    tmp = tempfile.mkdtemp()
    try:
        for n, gpo in enumerate(gpos):
            if not gpo.file_sys_path:
                continue
            parts = [p for p in gpo.file_sys_path.split("\\") if p]
            low = [p.lower() for p in parts]
            if "sysvol" not in low or ".." in parts:
                continue
            gpos_rel.append((n, "/".join(parts[low.index("sysvol") + 1:])))
            rel = "/".join(parts[low.index("sysvol") + 1:] + ["Machine", "Registry.pol"])
            local = os.path.join(tmp, "%02d.pol" % n)
            if not fetch(dc, rel, local):
                continue
            name = "%s%02d-%s.pol" % (PREFIX, n, gpo.name)
            target = os.path.join(POLICY_DIR, name)
            with open(local, "rb") as f:
                data = f.read()
            with open(target + ".new", "wb") as f:
                f.write(data)
            os.chmod(target + ".new", 0o644)
            os.replace(target + ".new", target)
            keep.add(name)
            log("machine policy from %s (%s)" % (gpo.display_name or gpo.name, name))
    finally:
        for f in glob.glob(os.path.join(tmp, "*")):
            os.unlink(f)
        os.rmdir(tmp)

    # a GPO that no longer applies: its policy goes
    for path in glob.glob(os.path.join(POLICY_DIR, PREFIX + "*.pol")):
        if os.path.basename(path) not in keep:
            os.unlink(path)
            log("removed %s" % os.path.basename(path))

    # the computer's startup scripts: fetched afresh, the list replaced
    if os.path.isdir(STARTUP_DIR):
        shutil.rmtree(STARTUP_DIR)
    os.makedirs(STARTUP_DIR, mode=0o755)
    lines = []
    fetch_startup_scripts(dc, gpos_rel, lines)
    with open(os.path.join(STARTUP_DIR, "startup.list.new"), "w") as f:
        f.write("".join(line + "\n" for line in lines))
    os.chmod(os.path.join(STARTUP_DIR, "startup.list.new"), 0o644)
    os.replace(os.path.join(STARTUP_DIR, "startup.list.new"), os.path.join(STARTUP_DIR, "startup.list"))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        import traceback
        syslog.openlog("sg-gpo-machine")
        log("machine Group Policy not refreshed: %s" % e)
        for line in traceback.format_exc().splitlines():
            log(line)
        sys.exit(0)
