"""Command-line interface for aiodiscover.""" from __future__ import annotations import argparse import asyncio import ipaddress import json import logging import pprint import sys from typing import TYPE_CHECKING from . import __version__ from ._sanitize import MAX_HOSTNAME_LEN, MAX_IP_LEN, MAX_MAC_LEN, safe_label_str from .discovery import HOSTNAME, IP_ADDRESS, MAC_ADDRESS, DiscoverHosts if TYPE_CHECKING: from collections.abc import Sequence _LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") _FORMATS = ("table", "json", "pprint") _TABLE_COLUMNS = (("Hostname", HOSTNAME), ("IP", IP_ADDRESS), ("MAC", MAC_ADDRESS)) _FIELD_LIMITS = { HOSTNAME: MAX_HOSTNAME_LEN, IP_ADDRESS: MAX_IP_LEN, MAC_ADDRESS: MAX_MAC_LEN, } def _ipv4_arg(value: str) -> str: """Argparse type for --local-ip: accept only valid IPv4 strings.""" try: ipaddress.IPv4Address(value) except ValueError as exc: msg = f"{value!r} is not a valid IPv4 address" raise argparse.ArgumentTypeError(msg) from exc return value def build_parser() -> argparse.ArgumentParser: """Construct the argparse parser for the aiodiscover CLI.""" parser = argparse.ArgumentParser( prog="aiodiscover", description=( "Discover hosts on the local network via ARP probing and reverse " "DNS (PTR) lookups." ), ) parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}", ) parser.add_argument( "-f", "--format", choices=_FORMATS, default="table", help="Output format (default: table).", ) parser.add_argument( "-j", "--json", action="store_const", const="json", dest="format", help="Shortcut for --format json.", ) parser.add_argument( "--indent", type=int, default=2, help="Indent size for JSON output (default: 2). Ignored without --format json.", ) parser.add_argument( "--log-level", choices=_LOG_LEVELS, default="WARNING", help="Logging verbosity (default: WARNING).", ) parser.add_argument( "-v", "--verbose", action="store_const", const="INFO", dest="log_level", help="Shortcut for --log-level INFO.", ) parser.add_argument( "--debug", action="store_const", const="DEBUG", dest="log_level", help="Shortcut for --log-level DEBUG.", ) recurse = parser.add_mutually_exclusive_group() recurse.add_argument( "--no-recurse", dest="no_recurse", action="store_true", default=True, help=( "Set the DNS no-recursion flag on PTR queries (default). " "Avoids leaking queries to upstream public resolvers." ), ) recurse.add_argument( "--recurse", dest="no_recurse", action="store_false", help="Allow recursive DNS PTR queries (the aiodns/pycares default).", ) parser.add_argument( "--local-ip", dest="local_ip", type=_ipv4_arg, default=None, help=( "IPv4 address of the interface to discover on. Pins discovery to the " "subnet of this address instead of using the default route." ), ) return parser def _sanitize_host(host: dict[str, str]) -> dict[str, str]: """Return a copy of ``host`` with each known field length-capped + printable-only.""" return { key: safe_label_str(host.get(key, ""), limit) for key, limit in _FIELD_LIMITS.items() } def _ip_sort_key(host: dict[str, str]) -> tuple[int, object]: """Sort key that orders IPv4 numerically, IPv6 after, malformed last.""" raw = host.get(IP_ADDRESS, "") try: addr = ipaddress.ip_address(raw) except ValueError: return (2, raw) return (0 if isinstance(addr, ipaddress.IPv4Address) else 1, addr) def _render_table(hosts: list[dict[str, str]]) -> str: """Render hosts as an aligned three-column table sorted by IP.""" headers = [label for label, _ in _TABLE_COLUMNS] rows = [[host[field] for _, field in _TABLE_COLUMNS] for host in hosts] widths = [ max(len(headers[i]), *(len(row[i]) for row in rows)) if rows else len(headers[i]) for i in range(len(headers)) ] fmt = " ".join(f"{{: <{w}}}" for w in widths) lines = [ fmt.format(*headers).rstrip(), fmt.format(*("-" * w for w in widths)).rstrip(), ] lines.extend(fmt.format(*row).rstrip() for row in rows) return "\n".join(lines) + "\n" async def _discover(no_recurse: bool, local_ip: str | None) -> list[dict[str, str]]: async with DiscoverHosts( no_recurse=no_recurse, local_ip=local_ip ) as discover_hosts: return await discover_hosts.async_discover() def main(argv: Sequence[str] | None = None) -> int: """Run the aiodiscover CLI. Returns a shell exit code.""" parser = build_parser() args = parser.parse_args(argv) logging.basicConfig(level=getattr(logging, args.log_level)) try: hosts = asyncio.run( _discover(no_recurse=args.no_recurse, local_ip=args.local_ip) ) except KeyboardInterrupt: return 130 sanitized = sorted((_sanitize_host(h) for h in hosts), key=_ip_sort_key) if args.format == "json": json.dump(sanitized, sys.stdout, indent=args.indent, sort_keys=True) sys.stdout.write("\n") elif args.format == "pprint": pprint.pprint(sanitized) else: sys.stdout.write(_render_table(sanitized)) return 0