#!/usr/bin/env python3
"""net_apply — minimal privileged 'apply' step for network configuration.

SECURITY / PRIVILEGE MODEL
--------------------------
This is the ONLY part of the network set-configuration flow that runs as root
(granted NOPASSWD sudo to the csl service user). Following the get_machine_uuid.sh
precedent it is intentionally small and its behaviour is fully compiled-in:

  * It takes NO path or command arguments (argv is ignored).
  * It reads a JSON object on STDIN whose keys are a FIXED, closed set of "slots".
    Each slot's value is content the unprivileged front-end
    (setting_network_configuration.py, run as the service user) already validated
    and rendered.
  * Each content slot is written to a HARDCODED /etc target; each command is a
    HARDCODED, list-form (no shell) invocation. The only scalars interpolated into
    commands (hostname, flush interface, ntp service) are RE-VALIDATED here so this
    helper is safe even if invoked directly, not just through the front-end.

No YAML parsing, no validation of config semantics, no `apt`, and no arbitrary
path/command — all of that lives in the unprivileged front-end.
"""
import json
import os
import re
import subprocess
import sys
import tempfile

# slot name -> hardcoded target path (content slots)
TARGETS = {
    "hosts": "/etc/hosts",
    "interfaces": "/etc/network/interfaces",
    "resolv_conf": "/etc/resolv.conf",
    "ntp_conf": "/etc/ntpsec/ntp.conf",
    "environment": "/etc/environment",
    "postfix_main_cf": "/etc/postfix/main.cf",
    "sasl_passwd": "/etc/postfix/sasl_passwd",
}
# scalar slots interpolated into commands (re-validated below)
SCALARS = {"hostname", "flush_iface", "ntp_service"}
ALLOWED_KEYS = set(TARGETS) | SCALARS

HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9\-\.]{0,98}[a-zA-Z0-9]$")
IFACE_RE = re.compile(r"^[a-zA-Z0-9]+([\-a-zA-Z0-9_]*[a-zA-Z0-9])?$")
ALLOWED_NTP_SERVICES = {"ntp", "ntpsec"}


def _die(msg, code=1):
    print(f"net_apply: {msg}", file=sys.stderr)
    sys.exit(code)


def _write_root_file(path, content, mode=0o644):
    directory = os.path.dirname(path)
    try:
        fd, tmp = tempfile.mkstemp(dir=directory, prefix=".net_apply.")
    except OSError as exc:
        _die(f"failed to write {path}: {exc}")
    try:
        with os.fdopen(fd, "w") as fh:
            fh.write(content)
        os.chmod(tmp, mode)
        os.replace(tmp, path)
    except OSError as exc:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        _die(f"failed to write {path}: {exc}")
    print(f"net_apply: wrote {path}")


def _run(cmd):
    try:
        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    except OSError as exc:
        _die(f"command failed: {' '.join(cmd)}: {exc}")
    if result.returncode != 0:
        _die(f"command failed: {' '.join(cmd)}: {result.stderr.strip()}")
    print(f"net_apply: ran {' '.join(cmd)}")


def main():
    try:
        payload = json.loads(sys.stdin.read())
    except (json.JSONDecodeError, ValueError) as exc:
        _die(f"invalid JSON on stdin: {exc}", code=2)

    if not isinstance(payload, dict):
        _die("stdin JSON must be an object", code=2)
    unknown = set(payload) - ALLOWED_KEYS
    if unknown:
        _die(f"unknown key(s) in payload: {sorted(unknown)}", code=2)
    for key in TARGETS:
        if key in payload and not isinstance(payload[key], str):
            _die(f"slot '{key}' must be a string", code=2)

    # Re-validate scalars interpolated into commands (defense-in-depth on top of list-form exec).
    hostname = payload.get("hostname")
    if hostname is not None and not HOSTNAME_RE.match(str(hostname)):
        _die(f"invalid hostname: {hostname!r}", code=2)
    flush_iface = payload.get("flush_iface")
    if flush_iface is not None and not IFACE_RE.match(str(flush_iface)):
        _die(f"invalid flush interface: {flush_iface!r}", code=2)
    ntp_service = payload.get("ntp_service", "ntp")
    if ntp_service not in ALLOWED_NTP_SERVICES:
        _die(f"invalid ntp_service: {ntp_service!r}", code=2)

    # --- apply in the same order as the original monolithic script ---
    if hostname is not None:
        _run(["hostnamectl", "set-hostname", hostname])

    if "hosts" in payload:
        _write_root_file(TARGETS["hosts"], payload["hosts"], 0o644)

    if "interfaces" in payload:
        _write_root_file(TARGETS["interfaces"], payload["interfaces"], 0o644)
        if flush_iface is not None:
            _run(["ip", "addr", "flush", "dev", flush_iface])
        _run(["systemctl", "restart", "networking"])

    if "resolv_conf" in payload:
        _write_root_file(TARGETS["resolv_conf"], payload["resolv_conf"], 0o644)

    if "ntp_conf" in payload:
        _write_root_file(TARGETS["ntp_conf"], payload["ntp_conf"], 0o644)
        _run(["systemctl", "restart", ntp_service])

    if "environment" in payload:
        _write_root_file(TARGETS["environment"], payload["environment"], 0o644)

    if "postfix_main_cf" in payload:
        _write_root_file(TARGETS["postfix_main_cf"], payload["postfix_main_cf"], 0o644)
    if "sasl_passwd" in payload:
        _write_root_file(TARGETS["sasl_passwd"], payload["sasl_passwd"], 0o600)
        _run(["postmap", TARGETS["sasl_passwd"]])
    if "postfix_main_cf" in payload or "sasl_passwd" in payload:
        _run(["systemctl", "restart", "postfix"])

    print("net_apply: network configuration applied.")


if __name__ == "__main__":
    main()
