#!/usr/bin/env python3
"""Check that sdan MAC-table device names match this AP's hostapd BSS names.

WHY
    The WSS client resolves a connected client's VLAN by looking it up in the
    sdan MAC table, and must keep only rows learned on the BSS it is scanning.
    That table is keyed on MAC alone and ages entries out over five minutes, so
    a client that roamed away keeps its old device and VLAN until traffic
    arrives on the new one -- accepting a row from another BSS would hand back
    a stale VLAN, and tunnels key on VLAN alone, so a wrong answer can
    re-point the whole VLAN.

    The scoping rule is that a row's "dev" is either the BSS's base interface
    or a per-VLAN sub-interface beneath it ("<base>-v<vlan>"). That has been
    confirmed on two AP models. This script checks it on a third.

USAGE
    Copy onto the AP and run it, or paste it over ssh:

        python3 sdan-mac-bss-check.py

    A client must be ASSOCIATED to a tunneled or VLAN'd SSID on this AP first.
    With no clients the MAC table is empty and the check proves nothing -- do
    not read an empty table as a pass.

READ-ONLY
    Three reads: "ubus list", "rxg_sdanctl -j macs", and /sys/class/net.
    Nothing is written, restarted or reconfigured.

EXIT CODES
    0  PASS          every wireless VLAN-carrying row is claimed by one BSS
    1  FAIL          a wireless row with a real VLAN matches no BSS; send output
    2  INCONCLUSIVE  no radios up, or no wireless clients associated yet

    Wired rows are listed for context and never fail the check: wired clients
    reach their VLAN through the 802.1X path, not the wireless lookup.
"""

import glob
import json
import subprocess
import sys


def sh(cmd):
    """Run cmd, return stdout, swallow every failure mode."""
    try:
        return subprocess.run(cmd, shell=True, capture_output=True,
                              text=True, timeout=15).stdout
    except Exception:
        return ''


def main():
    objs = [l.strip() for l in sh('ubus list').splitlines()
            if l.startswith('hostapd.') and 'auth' not in l]
    bases = [o.split('.')[-1] for o in objs]

    raw = sh('rxg_sdanctl -j macs')
    if raw.strip():
        try:
            rows = json.loads(raw)
        except Exception:
            print('FAIL  rxg_sdanctl -j macs did not return JSON:')
            print('      ' + raw.strip()[:200])
            return 1
    else:
        rows = []

    netdevs = sorted(p.rsplit('/', 1)[-1] for p in glob.glob('/sys/class/net/*'))
    print('ubus BSSes :', objs or 'NONE')
    print('base ifaces:', bases or 'NONE')
    print('netdevs    :', [d for d in netdevs
                           if d.startswith(('wlan', 'phy', 'eth', 'br'))])
    print('mac rows   :', len(rows))
    print()

    if not objs:
        print('INCONCLUSIVE  no hostapd ubus objects found; are the radios up?')
        return 2
    if not rows:
        print('INCONCLUSIVE  the sdan MAC table is empty. Associate a client to a')
        print('              tunneled or VLAN SSID on this AP, then run again.')
        return 2

    # Radio names, i.e. base ifaces with any secondary-BSS suffix removed.
    # Used only to tell a wireless row from a wired one: the MAC table also
    # holds wired clients, which reach their VLAN through the 802.1X path and
    # are correctly ignored by the wireless lookup. Counting them as failures
    # here would be a false alarm.
    radios = sorted({b.split('-')[0] for b in bases})

    unmatched = []
    in_scope = 0
    for r in rows:
        dev, vlan = r.get('dev'), r.get('vlan')
        wireless = any((dev or '').startswith(rad) for rad in radios)
        owners = [b for b in bases
                  if dev == b or (dev or '').startswith(b + '-v')]
        if not wireless:
            tag = 'wired'
        elif len(owners) == 1:
            tag = 'ok'
        elif not owners:
            tag = 'UNCLAIMED'
        else:
            tag = 'AMBIGUOUS'
        if wireless:
            in_scope += 1
            # VLAN 0 means untagged on a bare BSS; those are not resolved
            # either way, so they are reported but never counted as a failure.
            if len(owners) != 1 and vlan not in (0, None):
                unmatched.append(r)
        print('  [%9s] mac=%s dev=%s vlan=%s age=%s owner=%s'
              % (tag, r.get('mac'), dev, vlan, r.get('age'), owners or '-'))

    print()
    print('radios     :', radios)
    print('wireless rows in scope:', in_scope)
    print()
    if not in_scope:
        print('INCONCLUSIVE  the table holds only wired rows. Associate a WIRELESS')
        print('              client to a tunneled or VLAN SSID on this AP.')
        return 2
    if unmatched:
        print('FAIL  %d row(s) carrying a real VLAN match no BSS name on this AP.'
              % len(unmatched))
        print('      Clients on those rows would go unresolved on this hardware,')
        print('      falling back to the real-time association event or the next')
        print('      refresh. Please send this whole output.')
        return 1

    print('PASS  every VLAN-carrying row is claimed by exactly one BSS;')
    print('      device names prefix-match the hostapd BSS names on this model.')
    return 0


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