#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Jeffrey Clemmons <jeff@antisocialparadise.net>
# Copyright (C) 2026 Bardia Moshiri <bardia@furilabs.com>
"""
Manages per-interface clatd (464XLAT) instances for the cellular data
bearer and, independently, the MMS bearer
"""

import argparse
import fcntl
import json
import os
import re
import string
import subprocess
import syslog
import time

import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib

LOCK_PATH = "/run/clatd-bearer-manager.lock"

# Reaches the journal as `journalctl -t clatd-bearer-manager`.
LOG_TAG = "clatd-bearer-manager"

OFONO_BUS = "org.ofono"
OFONO_MANAGER_IFACE = "org.ofono.Manager"
OFONO_CM_IFACE = "org.ofono.ConnectionManager"

SYSTEMD_BUS = "org.freedesktop.systemd1"
SYSTEMD_PATH = "/org/freedesktop/systemd1"
SYSTEMD_MANAGER_IFACE = "org.freedesktop.systemd1.Manager"
SYSTEMD_UNIT_IFACE = "org.freedesktop.systemd1.Unit"

# Running multiple clatd instances concurrently requires overriding several
# defaults that would otherwise collide:
#   - clat-dev: each instance needs its own TUN device name, since the
#     default is "clat".
#   - clat-v4-addr: each instance needs a unique IPv4 CLAT address, since
#     the default is 192.0.0.1.
#   - route-table: each instance needs a unique routing table. The default
#     is 0xc1a7, and clatd internally installs an IPv6 rule into that table
#     using a fixed priority, so sharing it causes a collision. This option
#     expects a decimal value, not the hexadecimal form used in the
#     documentation.
MMS_CLAT_DEV = "clat-mms"
MMS_CLAT_V4_ADDR = "192.0.0.2"
MMS_ROUTE_TABLE = 49576

# TAYGA copies the IPv4 TTL into the IPv6 hop limit and decrements it on
# translation, so traffic entering the CLAT at the Linux default of 64 leaves
# at 62.
#
# At least one carrier (T-Mobile US, observed) drops translated packets whose
# hop limit is below 64: measured 0/5 success at 60, 62 and 63 against 5/5 at
# 64 and 65, with the reply path showing the NAT64 only ~13 hops away, so the
# packets are being filtered rather than expiring. The device's own native
# IPv6 is unaffected because the carrier's router advertisement sets 255.
#
# Entering the CLAT at 66 therefore lands egress at 64 - exactly the value the
# device's own IPv4 already carries, and nothing more.
CLAT_TTL = 66

# The default clat-dev, i.e. the cellular instance. Scoping every rule to this
# device is what makes the whole thing inert on carriers that provide native
# IPv4: no CLAT, no such interface, nothing matches.
DEFAULT_CLAT_DEV = "clat"

# Generous enough to cover a start that still has to discover the PLAT prefix.
CLATD_START_TIMEOUT = 20

NOTIFY_SCRIPT_UP = "script-up=/usr/bin/systemd-notify --ready || true"

# Setting plat-prefix makes clatd skip RFC 7050 discovery entirely.
PLAT_PREFIX_CACHE = "/var/lib/clatd-gsm/plat-prefix"

# Bounded staleness: a carrier that renumbers costs one slow start per day.
PLAT_PREFIX_MAX_AGE = 24 * 3600

# Never cached: a local DNS64 stub synthesizes against this rather than the
# carrier's real prefix. ensure_nat64_prefix_routes() routes it regardless.
WELL_KNOWN_PREFIX = "64:ff9b::/96"

# clatd only defaults this on for clat-v6-addr=shared, so pinning the address
# means setting it back. cfgint() accepts decimal only, as for route-table.
CLAT_CTMARK = 49575

# clatd's default route-table, same numeric value as its default ctmark.
CLAT_ROUTE_TABLE = 49575

# Andromeda (the Android app container) is IPv4-only and its traffic is
# forwarded rather than locally generated, so it arrives at the CLAT already
# decremented and lands below the carrier's threshold. It is on-device app
# usage, so compensating it restores parity with a native Android handset,
# which does 464XLAT in the modem and never decrements at all.
ANDROMEDA_SUBNET = "192.168.240.0/24"

CLATD_UNIT_RE = re.compile(r"^clatd@(.*)\.service$")

def read_cached_plat_prefix():
    """
    The last known-good PLAT prefix, or None if unknown or too old.
    """
    try:
        age = time.time() - os.path.getmtime(PLAT_PREFIX_CACHE)
        if age > PLAT_PREFIX_MAX_AGE:
            return None
        with open(PLAT_PREFIX_CACHE) as f:
            return f.read().strip() or None
    except OSError:
        return None

def write_cached_plat_prefix(prefix):
    if not prefix or prefix == WELL_KNOWN_PREFIX:
        return
    if prefix == read_cached_plat_prefix():
        return
    try:
        os.makedirs(os.path.dirname(PLAT_PREFIX_CACHE), exist_ok=True)
        with open(PLAT_PREFIX_CACHE, "w") as f:
            f.write(prefix + "\n")
        log(f"cached PLAT prefix {prefix}")
    except OSError as error:
        log(f"could not cache PLAT prefix: {error}")

def observed_plat_prefix():
    """
    The PLAT prefix the running cellular clatd instance actually settled on,
    read back from the ip rule it installs for the return path.

    Only the cellular instance produces this rule - it keeps clatd's default
    ctmark, whereas the MMS instance pins clat-v6-addr and so gets ctmark 0 and
    no fwmark rule.
    """
    for rule in rules_in_table(CLAT_ROUTE_TABLE):
        fwmark = rule.get("fwmark")
        if fwmark and int(fwmark, 16) == CLAT_CTMARK:
            return rule_prefix(rule, "src")
    return None

def log(message):
    syslog.syslog(syslog.LOG_INFO, message)
    print(message)

def run_ip(*args):
    """
    Run an ip command, reporting a failure rather than discarding it.

    Every caller acts on something it has just read back, so a failure here
    means the two disagree and the routing state is not what the caller
    believes. Not fatal - the next dispatcher event gets another go - but it
    should not pass silently.
    """
    if any(argument is None for argument in args):
        # A selector the caller could not resolve. Refuse rather than let
        # subprocess raise, which would abandon the rest of the run and leave
        # the CLAT unconfigured over one unusable rule.
        log(f"ip called with an unresolved argument, skipped: {args}")
        return
    result = subprocess.run(["ip", *args], capture_output=True, text=True)
    if result.returncode:
        detail = result.stderr.strip() or f"exit {result.returncode}"
        log(f"ip {' '.join(args)}: {detail}")

def ip_json(*args):
    """
    iproute2's JSON output, so listings do not have to be parsed out of the
    text form.
    """
    result = subprocess.run(["ip", "-j", *args], capture_output=True, text=True)
    if result.returncode:
        return []
    try:
        return json.loads(result.stdout or "[]")
    except json.JSONDecodeError:
        return []

def rule_prefix(rule, key):
    """
    A rule's "src" or "dst" selector as address/length.

    iproute2 omits the length when the match is full-length, and reports "all"
    when the selector is absent altogether.
    """
    address = rule.get(key)
    if not address or address == "all":
        return None
    return f"{address}/{rule.get(key + 'len', 128)}"

def rules_in_table(table):
    for rule in ip_json("-6", "rule", "show"):
        if str(rule.get("table")) == str(table):
            yield rule

def ensure_mangle_rule(*rule):
    """
    Append a mangle rule unless it is already present.

    iptables accepts rules naming an interface that does not exist yet, so
    these can be installed before clatd has created the CLAT device without
    having to order against it.
    """
    check = subprocess.run(
        ["iptables", "-t", "mangle", "-C", *rule], capture_output=True
    )
    if check.returncode != 0:
        result = subprocess.run(
            ["iptables", "-t", "mangle", "-A", *rule], capture_output=True, text=True
        )
        if result.returncode:
            detail = result.stderr.strip() or f"exit {result.returncode}"
            log(f"iptables -t mangle -A {' '.join(rule)}: {detail}")

def ensure_hoplimit_compat(clat_dev=DEFAULT_CLAT_DEV, forward_subnet=ANDROMEDA_SUBNET):
    """
    Compensate TAYGA's decrement for on-device traffic entering the CLAT.

    Deliberately limited to traffic that originates on the device:

      - OUTPUT covers the host's own IPv4.
      - FORWARD covers Andromeda's subnet only.

    forward_subnet is None for the MMS instance: mmsd's traffic is
    host-originated, so it is already covered by OUTPUT, and Andromeda has
    no path to the MMS bearer at all.
    """
    ensure_mangle_rule(
        "OUTPUT", "-o", clat_dev, "-j", "TTL", "--ttl-set", str(CLAT_TTL)
    )
    if forward_subnet:
        ensure_mangle_rule(
            "FORWARD", "-s", forward_subnet, "-o", clat_dev,
            "-j", "TTL", "--ttl-set", str(CLAT_TTL),
        )

def global_addr(iface, family):
    for link in ip_json(family, "addr", "show", "dev", iface, "scope", "global"):
        for addr in link.get("addr_info", []):
            if addr.get("local"):
                return addr["local"]
    return None

def ipv6_global_addr(iface):
    return global_addr(iface, "-6")

def ipv4_global_addr(iface):
    return global_addr(iface, "-4")

def wait_for_addr(iface, attempts=5, delay=1):
    """
    An interface reaching the "up" state does not guarantee its global
    IPv6 address has been assigned yet, oFono can take a little longer to
    configure it. Retry rather than starting clatd without a usable source
    address, since clatd@.service has no Restart= and a failed start will
    remain down until the next invocation.
    """
    for i in range(attempts):
        addr = ipv6_global_addr(iface)
        if addr:
            return addr
        if i + 1 < attempts:
            time.sleep(delay)
    return None

class Systemd:
    def __init__(self, bus):
        self.bus = bus
        self.manager = dbus.Interface(
            bus.get_object(SYSTEMD_BUS, SYSTEMD_PATH), SYSTEMD_MANAGER_IFACE
        )
        # JobRemoved is only emitted to subscribers.
        self.manager.Subscribe()

    def restart_and_wait(self, unit, timeout=CLATD_START_TIMEOUT):
        """
        Restart a unit and block until systemd reports the job finished.

        clatd@.service is Type=notify, so the job completes only once clatd has
        signalled readiness from its script-up hook.
        """
        loop = GLib.MainLoop()
        outcome = {}

        def on_job_removed(_job_id, job_path, _unit, result):
            if str(job_path) == outcome.get("path"):
                outcome["result"] = str(result)
                loop.quit()

        def on_timeout():
            # Recorded so the source is not removed twice: GLib drops a
            # one-shot timeout once it has fired, and removing it again warns.
            outcome["expired"] = True
            loop.quit()
            return False

        match = self.bus.add_signal_receiver(
            on_job_removed,
            signal_name="JobRemoved",
            dbus_interface=SYSTEMD_MANAGER_IFACE,
        )
        expiry = GLib.timeout_add_seconds(timeout, on_timeout)
        try:
            outcome["path"] = str(self.manager.RestartUnit(unit, "replace"))
            loop.run()
        finally:
            if not outcome.get("expired"):
                GLib.source_remove(expiry)
            match.remove()
        if outcome.get("expired"):
            log(f"{unit} did not finish starting within {timeout}s")
        return outcome.get("result") == "done"

    def stop(self, unit):
        try:
            self.manager.StopUnit(unit, "replace")
        except dbus.exceptions.DBusException:
            pass

    def get_active_state(self, unit):
        try:
            path = self.manager.GetUnit(unit)
        except dbus.exceptions.DBusException:
            return None
        props = dbus.Interface(
            self.bus.get_object(SYSTEMD_BUS, path), "org.freedesktop.DBus.Properties"
        )
        return str(props.Get(SYSTEMD_UNIT_IFACE, "ActiveState"))

    def is_active(self, unit):
        return self.get_active_state(unit) == "active"

    def clatd_units(self):
        """
        All currently-loaded clatd@<iface>.service units, regardless of
        state. used to find leftovers from a previous interface name
        after a reconnect.
        """
        units = self.manager.ListUnitsByPatterns(
            dbus.Array([], signature="s"), dbus.Array(["clatd@*"], signature="s")
        )
        return [str(u[0]) for u in units]

def plat_route_ready(plat_prefix):
    """
    Whether a route toward the PLAT exists yet.

    clatd resolves its PLAT-facing device with `ip -6 route get <plat-prefix>`
    and treats a failure as fatal, so starting without one cannot succeed.
    """
    if not plat_prefix:
        return True
    destination = plat_prefix.split("/")[0]
    return subprocess.run(
        ["ip", "-6", "route", "get", destination], capture_output=True
    ).returncode == 0

def prune_return_path_rules(route_table):
    """
    clatd removes its return-path rule only on a clean exit, and re-adding an
    identical one fails with EEXIST.

    Every rule selecting on both source and destination goes: those are the
    return-path rules, and the instance owns all of them in its own table. The
    MMS instance's outbound rule selects on source alone and stays.
    """
    for rule in list(rules_in_table(route_table)):
        source = rule_prefix(rule, "src")
        destination = rule_prefix(rule, "dst")
        if source is None or destination is None:
            continue
        args = ["-6", "rule", "del", "prio", str(rule.get("priority", 0)),
                "from", source, "to", destination]
        if rule.get("fwmark"):
            args += ["fwmark", rule["fwmark"]]
        run_ip(*args, "table", str(route_table))

def start_clatd(systemd, unit, clat_dev, plat_prefix=None, route_table=None,
                attempts=3, retry_delay=1):
    """
    Restart a clatd instance and return once it is ready.

    The unit is Type=notify and the generated config makes clatd notify from
    script-up, so the restart job completes only once the instance is genuinely
    usable, and a failed start is reported at once rather than at the timeout.
    """
    for attempt in range(attempts):
        if plat_prefix and not plat_route_ready(plat_prefix):
            log(f"{unit} deferred: no route toward {plat_prefix}")
        else:
            if route_table is not None:
                prune_return_path_rules(route_table)
            started = time.monotonic()
            if systemd.restart_and_wait(unit):
                log(f"{unit} up, {clat_dev} ready in {time.monotonic() - started:.2f}s")
                return True
        if attempt + 1 < attempts:
            log(f"{unit} failed to start, retrying")
            time.sleep(retry_delay)
    log(f"{unit} failed to start after {attempts} attempts")
    return False

def discover_context(bus, ctx_type, attempts=1, delay=1):
    """
    Find the first active oFono ConnectionManager context of the given
    Type ("internet" or "mms"), returning its interface name and whatever
    DNS64 resolvers it reports for itself

    An interface's "up" dispatcher event fires as soon as the kernel netdev
    comes up while oFono's own context bookkeeping can take a moment longer to
    catch up, especially right after a reconnect. Retry rather than giving
    up on the first empty result.
    """
    manager = dbus.Interface(bus.get_object(OFONO_BUS, "/"), OFONO_MANAGER_IFACE)
    for i in range(attempts):
        try:
            modems = manager.GetModems()
        except dbus.exceptions.DBusException:
            modems = []
        for modem_path, _props in modems:
            cm = dbus.Interface(bus.get_object(OFONO_BUS, modem_path), OFONO_CM_IFACE)
            try:
                contexts = cm.GetContexts()
            except dbus.exceptions.DBusException:
                continue
            for _ctx_path, ctx_props in contexts:
                if str(ctx_props.get("Type")) != ctx_type or not ctx_props.get("Active"):
                    continue
                settings = ctx_props.get("Settings", {})
                ipv6 = ctx_props.get("IPv6.Settings", {})
                iface = settings.get("Interface") or ipv6.get("Interface")
                dns64_servers = [str(d) for d in ipv6.get("DomainNameServers", [])]
                if iface:
                    return str(iface), dns64_servers
        if i + 1 < attempts:
            time.sleep(delay)
    return None, []

def clatd_conf(dns64_servers, *, v4_defaultroute_enable=True, clat_dev=None, clat_v4_addr=None, clat_v6_addr=None, route_table=None, plat_prefix=None, proxynd_enable=None, ctmark=None):
    # clatd runs script-up once every rule and the CLAT device are in place and
    # just before it starts TAYGA, which is exactly when the instance is usable.
    # `|| true` because clatd treats a non-zero script-up as fatal, and
    # systemd-notify exits 1 when it is run outside systemd.
    lines = ["v4-conncheck-enable=0", NOTIFY_SCRIPT_UP]
    if plat_prefix:
        lines.append(f"plat-prefix={plat_prefix}")
    if dns64_servers:
        lines.append(f"dns64-servers={','.join(dns64_servers)}")
    if not v4_defaultroute_enable:
        lines.append("v4-defaultroute-enable=0")
    if clat_dev:
        lines.append(f"clat-dev={clat_dev}")
    if clat_v4_addr:
        lines.append(f"clat-v4-addr={clat_v4_addr}")
    if clat_v6_addr:
        lines.append(f"clat-v6-addr={clat_v6_addr}")
    if route_table is not None:
        lines.append(f"route-table={route_table}")
    if proxynd_enable is not None:
        lines.append(f"proxynd-enable={proxynd_enable}")
    if ctmark is not None:
        lines.append(f"ctmark={ctmark}")

    return "\n".join(lines) + "\n"

def write_clatd_conf(iface, contents):
    """
    Write an instance's config, reporting whether it differed from what was
    already there. Callers restart on any difference.
    """
    path = f"/etc/clatd/{iface}.conf"
    try:
        with open(path) as existing:
            if existing.read() == contents:
                return False
    except OSError:
        pass
    os.makedirs("/etc/clatd", exist_ok=True)
    with open(path, "w") as f:
        f.write(contents)
    return True

def cellular_clatd_conf(dns64_servers, src_addr, plat_prefix):
    """
    The cellular instance's config. Both callers must generate it identically
    or write_clatd_conf() reports a difference that is not one.
    """
    return clatd_conf(
        dns64_servers,
        plat_prefix=plat_prefix,
        clat_v6_addr=src_addr,
        proxynd_enable=0,
        ctmark=CLAT_CTMARK,
    )

def add_dns64_routes(iface, src_addr, dns64_servers):
    for dns in dns64_servers:
        args = ["-6", "route", "replace", f"{dns}/128", "dev", iface]
        if src_addr:
            args += ["src", src_addr]
        run_ip(*args)

def ensure_mms_outbound_route(iface, src_addr):
    """
    clatd's `route-table` option (see its man page) only installs the
    *return* path for PLAT-prefix traffic addressed back to the CLAT. It
    provides no equivalent outbound route because, in the default
    configuration (shared clat-v6-addr), the CLAT simply uses the host's
    existing route to the PLAT.

    The MMS instance uses its own clat-v6-addr to avoid address collisions
    (see manage_mms's docstring), which means it also needs its own
    outbound route. Since the MMS bearer never owns the system default
    route (v4-defaultroute-enable=0), destination-based routing would
    otherwise send packets through the cellular bearer instead. The
    carrier drops those packets because their source address belongs to
    the MMS bearer.

    MMS_ROUTE_TABLE can be reused safely because it is dedicated to the MMS
    bearer and is only used for traffic sourced from src_addr.

    src_addr changes whenever the interface is recreated, so stale rules
    from previous addresses must be removed. This only touches outbound
    rules of the form "from <addr> lookup <table>"
    """
    run_ip("-6", "route", "replace", "default", "dev", iface, "table", str(MMS_ROUTE_TABLE))
    existing = list(rules_in_table(MMS_ROUTE_TABLE))
    current = f"{src_addr}/128"
    has_current = False
    for rule in existing:
        source = rule_prefix(rule, "src")
        if source is None or rule_prefix(rule, "dst") is not None:
            continue
        if source == current:
            has_current = True
        else:
            run_ip("-6", "rule", "del", "from", source,
                   "lookup", str(MMS_ROUTE_TABLE))
    if not has_current:
        run_ip("-6", "rule", "add", "from", src_addr, "lookup", str(MMS_ROUTE_TABLE))

    # clatd installs its own return-path rule ("from <plat-prefix> to
    # <clat-v6-addr>") and removes it on a clean stop, but a bearer that
    # re-activates leaves the old one behind for an address the interface no
    # longer has. They accumulate one per re-activation and each is a dead
    # entry, so drop any that do not name the current address.
    for rule in existing:
        source = rule_prefix(rule, "src")
        destination = rule_prefix(rule, "dst")
        if source is None or destination is None or destination == current:
            continue
        run_ip("-6", "rule", "del", "from", source,
               "to", destination, "lookup", str(MMS_ROUTE_TABLE))

def manage_mms(systemd, mms_iface, dns64_servers):
    """
    Give the MMS APN context its own clatd instance, separate from the
    cellular one managed by manage_cellular().

    v4-defaultroute-enable=0 (via write_clatd_conf) keeps the
    cellular instance as the system's IPv4 default route. The MMS
    instance only needs to reach the carrier's private (RFC1918) address
    space, where the MMSC lives, rather than general internet traffic.

    The MMS instance uses an explicit clat-v6-addr instead of the default
    "shared" auto-selection. With multiple cellular interfaces active,
    clatd's route-based address selection can choose the cellular
    instance's address instead of the MMS one, causing the two instances
    to collide. Using an explicit address also removes the need for the
    self-selected-address watcher used by the cellular instance.

    If the MMS bearer already has a native IPv4 address, no CLAT is
    needed. Dual-stack and IPv4-only bearers can reach the MMSC directly,
    so any existing MMS clatd instance is stopped instead.

    This function does not install routes for the carrier's private
    address space. routectld adds a host route for each MMSC destination
    on demand before every send or receive, after first ensuring
    the MMS CLAT instance is running.
    """
    unit = f"clatd@{mms_iface}.service"

    if ipv4_global_addr(mms_iface):
        if systemd.is_active(unit):
            systemd.stop(unit)
        return

    # The MMS bearer is IPv6-only, so mmsd reaches the MMSC's IPv4 address
    # through clat-mms - and that traffic is subject to the same carrier
    # hop-limit threshold as the cellular CLAT, which TAYGA's decrement would
    # otherwise leave it below. Whether the MMSC path enforces the threshold
    # today is unclear (it currently works without this), but an uncovered
    # translator sitting one hop under a known drop threshold is not a state
    # worth relying on. iptables accepts a rule naming an interface that does
    # not exist yet, so this can run before clatd creates the device.
    ensure_hoplimit_compat(MMS_CLAT_DEV, forward_subnet=None)

    src_addr = wait_for_addr(mms_iface)
    if not src_addr:
        return

    changed = write_clatd_conf(mms_iface, clatd_conf(
        dns64_servers,
        v4_defaultroute_enable=False,
        clat_dev=MMS_CLAT_DEV,
        clat_v4_addr=MMS_CLAT_V4_ADDR,
        clat_v6_addr=src_addr,
        route_table=MMS_ROUTE_TABLE,
    ))
    add_dns64_routes(mms_iface, src_addr, dns64_servers)
    ensure_mms_outbound_route(mms_iface, src_addr)

    if not changed and systemd.is_active(unit):
        return

    if systemd.is_active(unit):
        # clatd reads clat-v6-addr once at startup and cannot learn a new one,
        # so stop it first and let it remove the rules naming the old address.
        systemd.stop(unit)
    start_clatd(systemd, unit, MMS_CLAT_DEV, route_table=MMS_ROUTE_TABLE)

def ensure_nat64_prefix_routes(iface, prefix=None):
    """
    Route NAT64/DNS64-synthesized destinations via the cellular interface.

    This device is routinely Wi-Fi + cellular dual-connected, and Wi-Fi's
    default route is normally preferred for general traffic. Anything
    synthesized against the carrier's own NAT64 prefix (used both by
    clatd's own translation and, independently, by any client - e.g.
    mmsd - that resolves a hostname to a DNS64-synthesized address and
    connects to it natively over IPv6, no CLAT involved) has no path back
    when it falls through to Wi-Fi, since that prefix only means anything
    to the carrier's own network. Without an explicit route here, this
    class of traffic silently fails the moment Wi-Fi is connected.

    Two prefixes are routed:
      - 64:ff9b::/96, the RFC 6052 well-known prefix - static, since some
        resolvers (systemd-resolved's own local DNS64 stub, notably)
        synthesize against this fixed prefix instead of asking the
        carrier's resolver to do it server-side.
      - Whatever prefix the carrier's own resolver is actually using,
        when known - discovered from clatd's own fwmark rule, since
        that's the same prefix clatd already confirmed live traffic
        against for its own translation.
    """
    run_ip("-6", "route", "replace", "64:ff9b::/96", "dev", iface)
    if prefix:
        run_ip("-6", "route", "replace", prefix, "dev", iface)

def default_route_iface(family="-6"):
    """
    The interface currently carrying the default route, or None.
    """
    for route in ip_json(family, "route", "show", "default"):
        if route.get("dev"):
            return route["dev"]
    return None

def iface_family(iface):
    """
    An interface name with its trailing index removed: ccmni3 -> ccmni.
    """
    return iface.rstrip(string.digits)

def bearer_iface_is_current(iface, attempts=5, delay=1):
    """
    Whether iface is the one actually carrying traffic right now.

    oFono's reported context interface and the kernel's default route can
    disagree while a bearer is moving between ccmni devices. Configuring
    clatd for the interface oFono names during that window leaves the CLAT
    and its routes pinned to a device that no longer carries anything -
    the host's own native IPv6 keeps working over the real interface, so
    nothing looks wrong locally, while every translated IPv4 flow is
    silently dead. Forwarded traffic feels this immediately: a tethered
    client sees the connection go dead and, on Android, drops back to its
    own mobile data within seconds.

    A missing default route is not treated as a mismatch. It legitimately
    happens before NetworkManager has finished applying the bearer's
    configuration, and refusing to act then would leave clatd down with
    nothing scheduled to bring it up.
    """
    # Compared by name family rather than a hardcoded list, so ccmni, rmnet
    # and wwan all work.
    family = iface_family(iface)
    for i in range(attempts):
        routed = default_route_iface()
        # Only another cellular interface holding the default route means a
        # rotation is under way. A non-cellular one means Wi-Fi is simply
        # preferred, which is not a reason to leave the CLAT down.
        rotating = (
            routed is not None and routed != iface and routed.startswith(family)
        )
        if not rotating:
            return True
        if i + 1 < attempts:
            time.sleep(delay)

    return False

def manage_cellular(iface, systemd, dns64_servers, plat_prefix=None):
    unit = f"clatd@{iface}.service"

    # The NAT64 prefix routes are needed whenever the bearer is configured, not
    # only when it owns the default route - that is precisely the Wi-Fi-preferred
    # case they exist for, since a DNS64-synthesized destination means nothing to
    # any network but the carrier's. Installing them ahead of the rotation guard
    # keeps them present while Wi-Fi is default; gating on a global address
    # rather than on the default route avoids pointing them at an interface that
    # is not actually configured yet.
    if ipv6_global_addr(iface):
        ensure_nat64_prefix_routes(iface, plat_prefix)

    if not bearer_iface_is_current(iface):
        # Mid-rotation: leave the existing setup alone rather than pinning it
        # to an interface the kernel is not routing through. The dispatcher
        # fires again once the move settles.
        return

    src_addr = wait_for_addr(iface)
    if not src_addr:
        log(f"{unit} not started: {iface} has no global address yet")
        return

    changed = write_clatd_conf(
        iface, cellular_clatd_conf(dns64_servers, src_addr, plat_prefix)
    )
    add_dns64_routes(iface, src_addr, dns64_servers)
    ensure_nat64_prefix_routes(iface, plat_prefix)
    ensure_hoplimit_compat()

    # Routes and mangle rules above are refreshed either way, all idempotent.
    if not changed and systemd.is_active(unit):
        log(f"{unit} already current, not restarting")
        return
    if start_clatd(systemd, unit, DEFAULT_CLAT_DEV, plat_prefix, CLAT_ROUTE_TABLE) and not plat_prefix:
        # clatd discovered the prefix itself, so it is in neither the cache
        # nor the conf this instance was started from. Record it in both.
        write_cached_plat_prefix(observed_plat_prefix())
        learned = read_cached_plat_prefix()
        if learned:
            write_clatd_conf(
                iface, cellular_clatd_conf(dns64_servers, src_addr, learned)
            )


def parse_args():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--mms-only",
        action="store_true",
        help="Only manage the MMS bearer's clatd instance",
    )
    return parser.parse_args()

def main():
    args = parse_args()

    syslog.openlog(LOG_TAG, syslog.LOG_PID, syslog.LOG_DAEMON)

    # Must be installed before the bus connection is made, so signals dispatch.
    DBusGMainLoop(set_as_default=True)

    lock_file = open(LOCK_PATH, "w")
    try:
        fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        log("another instance holds the lock, skipping this invocation")
        return

    try:
        bus = dbus.SystemBus()
        systemd = Systemd(bus)

        mms_iface, mms_dns64 = discover_context(bus, "mms")

        if args.mms_only:
            cellular_iface = None
        else:
            cellular_iface, cellular_dns64 = discover_context(bus, "internet", attempts=8, delay=1)

        plat_prefix = read_cached_plat_prefix()
        log(
            f"bearers: internet={cellular_iface or '-'} mms={mms_iface or '-'} "
            f"plat-prefix={plat_prefix or 'discover'}"
        )

        if not args.mms_only:
            wanted = {iface for iface in (cellular_iface, mms_iface) if iface}
            for unit in systemd.clatd_units():
                m = CLATD_UNIT_RE.match(unit)
                if m and m.group(1) not in wanted:
                    log(f"stopping stale {unit}")
                    systemd.stop(unit)

        if mms_iface:
            manage_mms(systemd, mms_iface, mms_dns64)

        if args.mms_only:
            return

        if cellular_iface:
            manage_cellular(cellular_iface, systemd, cellular_dns64, plat_prefix)
    except dbus.exceptions.DBusException as error:
        log(f"D-Bus error: {error}")

if __name__ == "__main__":
    main()
