#!/usr/bin/env python3
"""Fixed BusyBox udhcpc hook: configure ONLY otwan inside otche-egress.

No default client hooks, DNS writes, shell, host route changes, classless routes,
NTP hooks or remotely supplied executables. A changed/lost WAN lease atomically
revokes all guest ports BEFORE changing network state. The broker then refuses
renewal until operator restart/reconciliation.
"""
import ipaddress
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile

NS = 'otche-egress'
WAN = 'otwan'
STATE = Path('/var/lib/otche-network')


def run(argv, text=None):
    result = subprocess.run(argv, input=text, text=True, stdout=subprocess.DEVNULL,
                            stderr=subprocess.PIPE, timeout=10, check=False,
                            env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
    if result.returncode:
        raise RuntimeError('DHCP hook system command failed')


def lease_from_environment(environment):
    address = ipaddress.IPv4Address(environment['ip'])
    network = ipaddress.IPv4Network(str(address) + '/' + environment['subnet'], strict=False)
    routers = environment.get('router', '').split()
    if len(routers) != 1:
        raise RuntimeError('exactly one DHCP router required')
    gateway = ipaddress.IPv4Address(routers[0])
    if network.prefixlen == 0 or gateway not in network or address in (network.network_address, network.broadcast_address) or gateway == address:
        raise RuntimeError('invalid DHCP WAN network')
    return {'address': str(address) + '/' + str(network.prefixlen), 'gateway': str(gateway)}


def main():
    if os.geteuid() != 0 or os.environ.get('interface') != WAN:
        raise RuntimeError('unexpected DHCP execution context')
    if os.stat('/proc/self/ns/net').st_ino != os.stat('/var/run/netns/' + NS).st_ino:
        raise RuntimeError('DHCP hook is not in broker namespace')
    info = STATE.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
        raise RuntimeError('unsafe DHCP state directory')
    if len(sys.argv) != 2:
        raise RuntimeError('single udhcpc event argument required')
    reason = sys.argv[1]
    if reason in ('deconfig', 'nak', 'leasefail'):
        run(['/usr/sbin/nft', '-f', '-'], 'flush set inet otche_filter active\n')
        run(['/usr/sbin/ip', '-4', 'route', 'flush', 'dev', WAN])
        run(['/usr/sbin/ip', '-4', 'address', 'flush', 'dev', WAN])
        return
    if reason not in ('bound', 'renew'):
        raise RuntimeError('unsupported udhcpc event')
    lease = lease_from_environment(os.environ)
    # Only the verified IPv4 address/netmask and single on-link gateway survive
    # input validation; ignore offered DNS, domains, routes and executable data.
    file = STATE / 'wan.json'
    previous = None
    if file.exists():
        info = file.lstat()
        if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
            raise RuntimeError('unsafe DHCP lease file')
        previous = json.loads(file.read_text())
    if previous != lease:
        run(['/usr/sbin/nft', '-f', '-'], 'flush set inet otche_filter active\n')
    # Calls run in the verified namespace; no distro DHCP scripts or hooks.
    run(['/usr/sbin/ip', '-4', 'address', 'replace', lease['address'], 'dev', WAN])
    run(['/usr/sbin/ip', '-4', 'route', 'replace', 'default', 'via', lease['gateway'], 'dev', WAN])
    fd, name = tempfile.mkstemp(prefix='.wan-', dir=STATE)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, 'w') as stream:
            json.dump(lease, stream, separators=(',', ':'))
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(name, file)
        directory = os.open(STATE, os.O_DIRECTORY)
        try:
            os.fsync(directory)
        finally:
            os.close(directory)
    finally:
        if os.path.exists(name):
            os.unlink(name)


if __name__ == '__main__':
    try:
        main()
    except Exception as exc:
        print('otche DHCP hook refused: ' + str(exc)[:180], file=sys.stderr)
        sys.exit(1)
