ipscout 2.0.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. {ipscout-2.0.0 → ipscout-2.3.0}/CHANGELOG.md +254 -0
  2. {ipscout-2.0.0 → ipscout-2.3.0}/PKG-INFO +9 -3
  3. {ipscout-2.0.0 → ipscout-2.3.0}/README.md +7 -1
  4. {ipscout-2.0.0 → ipscout-2.3.0}/docs/systemdesign/module_reference.md +54 -41
  5. {ipscout-2.0.0 → ipscout-2.3.0}/docs/usage.md +153 -30
  6. {ipscout-2.0.0 → ipscout-2.3.0}/pyproject.toml +12 -5
  7. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/__init__.py +18 -0
  8. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/__init__conf__.py +1 -1
  9. ipscout-2.3.0/src/ipscout/api.py +720 -0
  10. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/arp.py +100 -8
  11. ipscout-2.3.0/src/ipscout/banner.py +282 -0
  12. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/bootp.py +96 -34
  13. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/bpf.py +3 -2
  14. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/bsdroute.py +64 -10
  15. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/cli.py +386 -51
  16. ipscout-2.3.0/src/ipscout/deadline.py +70 -0
  17. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/dhcp.py +60 -10
  18. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/dhcp_linux.py +5 -13
  19. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/dhcp_macos.py +21 -12
  20. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/dhcp_windows.py +10 -13
  21. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/errors.py +104 -0
  22. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/factory.py +6 -5
  23. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/interfaces_posix.py +5 -4
  24. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/interfaces_windows.py +73 -33
  25. ipscout-2.3.0/src/ipscout/models.py +1528 -0
  26. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/mtu.py +66 -40
  27. ipscout-2.3.0/src/ipscout/neighbours.py +606 -0
  28. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/neighbours_linux.py +28 -49
  29. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/neighbours_macos.py +54 -33
  30. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/neighbours_windows.py +104 -56
  31. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/netlink.py +10 -0
  32. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/packet.py +132 -25
  33. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/pool.py +49 -5
  34. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/ports.py +34 -3
  35. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/portscan.py +161 -65
  36. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/resolve.py +84 -73
  37. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/routes.py +44 -23
  38. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/routes_linux.py +22 -21
  39. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/routes_macos.py +35 -41
  40. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/routes_windows.py +23 -26
  41. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/scan.py +149 -25
  42. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/service.py +82 -14
  43. ipscout-2.3.0/src/ipscout/snmp.py +458 -0
  44. ipscout-2.3.0/src/ipscout/snmpber.py +385 -0
  45. ipscout-2.3.0/src/ipscout/sockerr.py +58 -0
  46. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/subnet.py +1 -1
  47. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/tcpsyn.py +5 -33
  48. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/traceroute.py +121 -31
  49. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/transport_posix.py +73 -60
  50. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/transport_tcp.py +74 -25
  51. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/transport_windows.py +77 -60
  52. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/winapi.py +296 -62
  53. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/wol.py +56 -11
  54. ipscout-2.3.0/tests/snmp_agent.py +121 -0
  55. ipscout-2.3.0/tests/test_api_resolve_once.py +243 -0
  56. ipscout-2.3.0/tests/test_async_resolution.py +110 -0
  57. ipscout-2.3.0/tests/test_banner.py +393 -0
  58. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_cli.py +201 -0
  59. ipscout-2.3.0/tests/test_deadline.py +49 -0
  60. ipscout-2.3.0/tests/test_defaults.py +94 -0
  61. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_family_filter.py +1 -1
  62. ipscout-2.3.0/tests/test_family_options.py +193 -0
  63. ipscout-2.3.0/tests/test_family_socket.py +100 -0
  64. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_integration_loopback.py +10 -0
  65. ipscout-2.3.0/tests/test_mac_fallback.py +312 -0
  66. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_models.py +86 -11
  67. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_neighbours.py +5 -5
  68. ipscout-2.3.0/tests/test_p3core_bounds.py +150 -0
  69. ipscout-2.3.0/tests/test_p3main_interfaces_windows_family.py +50 -0
  70. ipscout-2.3.0/tests/test_p3win_family_numbering.py +72 -0
  71. ipscout-2.3.0/tests/test_p4a1_bounds.py +431 -0
  72. ipscout-2.3.0/tests/test_p4a2_argument_bounds.py +316 -0
  73. ipscout-2.3.0/tests/test_p4b1_bootp_bounds.py +76 -0
  74. ipscout-2.3.0/tests/test_p4b1_packet_bounds.py +82 -0
  75. ipscout-2.3.0/tests/test_p4b2_bsdroute.py +96 -0
  76. ipscout-2.3.0/tests/test_p4c_windows_data.py +246 -0
  77. ipscout-2.3.0/tests/test_p4main_handoffs.py +42 -0
  78. ipscout-2.3.0/tests/test_pass5_A_record_bounds.py +344 -0
  79. ipscout-2.3.0/tests/test_pass5_B_arguments.py +280 -0
  80. ipscout-2.3.0/tests/test_pass5_C_bounds.py +170 -0
  81. ipscout-2.3.0/tests/test_pass5_D_platform_backends.py +291 -0
  82. ipscout-2.3.0/tests/test_pass5_main_handoffs.py +38 -0
  83. ipscout-2.3.0/tests/test_pass5_main_ping_strict.py +35 -0
  84. ipscout-2.3.0/tests/test_port_bounds.py +94 -0
  85. ipscout-2.3.0/tests/test_route_backends_family.py +159 -0
  86. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_routes.py +6 -4
  87. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_scan_and_subnet.py +25 -3
  88. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_service.py +17 -10
  89. ipscout-2.3.0/tests/test_snmp.py +356 -0
  90. ipscout-2.3.0/tests/test_snmp_device.py +30 -0
  91. ipscout-2.3.0/tests/test_snmp_models.py +68 -0
  92. ipscout-2.3.0/tests/test_snmpber.py +207 -0
  93. ipscout-2.3.0/tests/test_sockerr.py +52 -0
  94. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_tcp_fallback.py +31 -0
  95. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_traceroute.py +24 -0
  96. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_transport_posix_logic.py +22 -18
  97. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_ttl_capability_probe.py +1 -4
  98. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_winapi_layout.py +4 -4
  99. ipscout-2.0.0/src/ipscout/api.py +0 -433
  100. ipscout-2.0.0/src/ipscout/models.py +0 -797
  101. ipscout-2.0.0/src/ipscout/neighbours.py +0 -407
  102. {ipscout-2.0.0 → ipscout-2.3.0}/.gitignore +0 -0
  103. {ipscout-2.0.0 → ipscout-2.3.0}/LICENSE +0 -0
  104. {ipscout-2.0.0 → ipscout-2.3.0}/docs/development.md +0 -0
  105. {ipscout-2.0.0 → ipscout-2.3.0}/docs/handover-observe-dhcp.md +0 -0
  106. {ipscout-2.0.0 → ipscout-2.3.0}/docs/installation.md +0 -0
  107. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/__main__.py +0 -0
  108. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/interfaces.py +0 -0
  109. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/leases_linux.py +0 -0
  110. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/py.typed +0 -0
  111. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/serialize.py +0 -0
  112. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/static_neighbours.py +0 -0
  113. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/typed_click.py +0 -0
  114. {ipscout-2.0.0 → ipscout-2.3.0}/src/ipscout/unixio.py +0 -0
  115. {ipscout-2.0.0 → ipscout-2.3.0}/tests/capture_support.py +0 -0
  116. {ipscout-2.0.0 → ipscout-2.3.0}/tests/conftest.py +0 -0
  117. {ipscout-2.0.0 → ipscout-2.3.0}/tests/dhcp_capture_fixture.py +0 -0
  118. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_arp.py +0 -0
  119. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_bootp.py +0 -0
  120. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_dhcp_capability_probe.py +0 -0
  121. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_dhcp_macos.py +0 -0
  122. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_dhcp_windows.py +0 -0
  123. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_factory.py +0 -0
  124. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_integration_internet.py +0 -0
  125. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_interfaces.py +0 -0
  126. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_metadata.py +0 -0
  127. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_module_entry.py +0 -0
  128. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_observe_dhcp.py +0 -0
  129. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_packaging.py +0 -0
  130. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_packet.py +0 -0
  131. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_platform_markers.py +0 -0
  132. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_resolve.py +0 -0
  133. {ipscout-2.0.0 → ipscout-2.3.0}/tests/test_static_neighbours.py +0 -0
@@ -5,6 +5,260 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.3.0] 2026-10-26 12:19:25
9
+
10
+ ### Added
11
+
12
+ - `lookup_mac(ip, active=True, fallback_to_cache=True)` (and the same on `get_mac_address`, plus
13
+ `ipscout mac --active --fallback-to-cache`): ask actively, and read the neighbour cache instead
14
+ only when this host cannot ask, meaning no privilege (`IPScoutPermissionError`), or no interface
15
+ or route that can carry the request, or a send or read that failed (`IPScoutUnsupportedError`). An active ask nobody answered stays an
16
+ active answer and is never replaced by a cache entry; a host name and a zone naming no interface
17
+ still raise. `fallback_to_cache=True` without `active=True` raises `ValueError`.
18
+ - `MacMethod` (`ACTIVE`, `CACHE`) and `MacLookup.method`: every MAC answer says whether it was asked
19
+ for or read from the cache, and `ipscout mac` prints and emits it. `--strict` keeps it too, and now
20
+ also carries the interface of a direct answer.
21
+
22
+ ### Fixed
23
+
24
+ - An active MAC lookup no longer reports a request it could not make as "nobody answered":
25
+ - On Windows, a `SendARP` or `ResolveIpNetEntry2` failure other than no reply (no route to the
26
+ address, among others) and a missing `iphlpapi.dll` now raise `IPScoutUnsupportedError`.
27
+ They used to return `None`. Only `ERROR_BAD_NET_NAME` reads as no reply.
28
+ - On Linux and macOS, a send or receive that fails after the socket opens (the interface went
29
+ away, the link is down) now raises `IPScoutUnsupportedError` too, instead of returning `None`.
30
+ - `lookup_mac(..., active=True)` resolves a zone before anything is sent on every platform, by
31
+ index or by either Windows name (the friendly one `local_interfaces()` reports, or the
32
+ `if_nametoindex` one), and raises `IPScoutResolutionError` when it names no interface. Before,
33
+ the routed branch and Windows never looked at the zone, so a typo passed unseen. The cache read
34
+ now matches on the interface the zone resolves to, so a numeric zone such as `%2` finds the
35
+ entry on that link rather than whichever link holds the address.
36
+ - On Windows, an active lookup of an address with no route raises `IPScoutUnsupportedError`
37
+ instead of reporting nobody answered: `SendARP` gives the same status for both.
38
+
39
+ ## [2.2.0] 2026-10-26 11:06:07
40
+
41
+ ### Added
42
+
43
+ - `read_snmp_system` / `aread_snmp_system` and the `ipscout snmp-system` command: the SNMP system
44
+ group (sysUpTime, sysName, sysDescr, sysObjectID) of many hosts, one GET each, SNMPv1 or v2c as the
45
+ caller chooses. Each host gets an `SnmpSystem` record with an `SnmpState` (ANSWERED, SILENT,
46
+ REFUSED, ERROR, UNREACHABLE) instead of an exception; under v1 a value the agent lacks is asked
47
+ around and reported as `None`. The uptime tells a restart (it fell) from mere reachability.
48
+ - `ipscout.deadline`: the per-host deadline helpers, shared by the banner grab and the SNMP read.
49
+ - `packet.max_payload_size(family)` and `packet.require_payload_size(payload_size, family)`: the
50
+ one per-family payload bound, which `ping` and `build_echo_request` both apply.
51
+ - `models.RoundTrip` (a finite round trip of 0 or more milliseconds), `api.HostNames` /
52
+ `api.require_host_names` (a collection of host names, refusing one bare string),
53
+ `traceroute.TracerouteSettings` and `traceroute.DEFAULT_MAX_HOPS`, `bpf.MAX_BPF_DEVICES`, and a
54
+ `method` field on `portscan.PortScanSettings`.
55
+ - `ipscout.models.require_port`, `Port`, `MIN_PORT` and `MAX_PORT`: the one port-range check
56
+ (1-65535) every entry point and record taking a port number now applies.
57
+ - `ipscout.packet.MAX_PAYLOAD_SIZE_IPV4` (65507) and `MAX_PAYLOAD_SIZE_IPV6` (65527): the most ICMP
58
+ payload one echo can carry.
59
+ - `AddressFamily.ip_version` and `AddressFamily.of_version()`.
60
+ - `ipscout.portscan.DEFAULT_TIMEOUT` (1.0 s per port), and `DEFAULT_CONCURRENCY` exported from
61
+ `ipscout.api` and `ipscout.scan`; the CLI options take their defaults from these.
62
+ - Argument models and checks every entry point shares: `banner.BannerSettings`,
63
+ `portscan.PortScanSettings`, `scan.SweepSettings`; `models.Timeout` / `require_timeout` (above 0,
64
+ at most 2147483 s, finite), `models.MAX_TIMES` (65535, the 16-bit echo sequence),
65
+ `models.MAX_HOP_LIMIT` (255), `models.parse_address` / `require_address`, `models.needs_zone`,
66
+ `pool.Concurrency` / `require_concurrency` (at least 1), and `errors.validation_problems` /
67
+ `refused_arguments`, which word every argument refusal the same way.
68
+ - `models.DEFAULT_TIMEOUT` (2.0 s, re-exported by `service`), `models.DISCARD_PORT`,
69
+ `AddressFamily.unspecified_address`, `packet.MAX_IP_LENGTH`, `packet.UDP_HEADER_SIZE`,
70
+ `packet.IPV4_MIN_HEADER_SIZE`, `arp.normalise_mac` (where `neighbours.normalise_mac` now comes
71
+ from), `arp.format_hardware_address`, `arp.FRAME_RECEIVE_SIZE`, `ports.CAPTURE_RECEIVE_BUFFER`,
72
+ `ports.MIN_CAPTURE_WAIT`, `wol.DEFAULT_BROADCAST`, `scan.refuse_a_partial_scope`,
73
+ `scan.sweep_argument_refusal`.
74
+ - Windows backend helpers: `winapi.IpStatus` (an `IntEnum` of the IP_STATUS codes with their text),
75
+ `win_family` / `family_from_win`, `sockaddr_in6`, `sockaddr_inet_address`, `address_text`,
76
+ `reply_buffer_size`, `DEFAULT_TTL`, `ICMP_REPLY_HEADROOM`, `MAX_ADAPTER_ADDRESS_LENGTH`,
77
+ `NO_ERROR`, `MS_LAYOUT`; `transport_windows.ipv6_destination` and `echo_outcome`;
78
+ `neighbours_windows.declared_mac`. macOS route-socket helpers in `bsdroute`: `RTM_GET`,
79
+ `NO_METRICS`, `pack_sockaddr_in6`, `open_route_socket`.
80
+
81
+ ### Changed
82
+
83
+ - Every argument refusal reads `<operation> refused its arguments: <argument>: <reason>` (one part
84
+ per bad argument) and is a plain `ValueError` with pydantic's report kept as `__cause__`. `ping`
85
+ raised the `pydantic.ValidationError` itself, and with `raise_on_error=False` its `.error` held
86
+ pydantic's text, error-type tags and a docs URL; the CLI printed the same text.
87
+ - Arguments that were accepted and then misreported are refused before anything is sent:
88
+ `concurrency` below 1 (silently run as 1); a `timeout` of zero, below zero, infinite or above
89
+ 2147483 s in `ping`, `traceroute`, `scan_ports`, `syn_scan`, `arp_scan` and `grab_banners` (a zero
90
+ timeout reported open ports as `FILTERED`, a sweep returned the cache as its result, a traceroute
91
+ walked 30 silent hops, and 1e12 raised `OverflowError` mid-probe); `times` above 65535; a negative
92
+ `payload_size`; `max_hops` above 255; a sweep `limit` below 1; an empty `farewell` (silently not
93
+ sent); a NaN or infinite `DhcpSession` window.
94
+ - Address arguments are parsed at entry: `query_route` and `lookup_mac` refuse a host name, a
95
+ malformed literal, a literal of the other family or a zone on an IPv4 address with `ValueError`
96
+ (they returned `None` / `UNKNOWN`, the same as "no route"); `wake_on_lan` refuses a broadcast that
97
+ is empty, `0.0.0.0`, a name or not IPv4 (an empty one sent the packet to this host and reported it
98
+ sent); `scan_ports` resolves the host once and uses that address for every port (a name was
99
+ resolved per port, and the SYN path passed the name to `inet_aton`); an unresolvable host raises
100
+ `IPScoutResolutionError` instead of reporting every port `FILTERED`.
101
+ - Records refuse values no producer makes: `ResponseObject` (counts 0-65535, received at most sent
102
+ and matching the answered round trips, `reached` exactly when something answered, `ip` of its
103
+ `family`, a non-empty `error`), `TraceHop.ttl` 1-255, `InterfaceAddress` / `SubnetInfo` prefix
104
+ length within the family, `mtu` above 0, `Interface.index` at least 1, `SweepScope.limit` at least
105
+ 1, `ObserveDhcpReport` windows, `JsonEnvelope` (`ok` exactly when `error` is absent),
106
+ `ParsedReply` field widths, `EchoPayload.token` exactly 8 bytes, and `DhcpReply` (canonical MAC,
107
+ a non-zero IPv4 offer, a 32-bit transaction id). `build_echo_request` refuses a token of the wrong
108
+ length and a sequence or identifier outside 0-65535.
109
+ - A `ping` that fails before sending reports what it knew: the resolved address and family when
110
+ resolution succeeded (it reported `0.0.0.0` / IPv4 for an IPv6 target), and 0 pings sent.
111
+ - `bootp.offers_from_frames(mac=...)` accepts any written form of the address; another spelling
112
+ than lowercase-colon silently matched nothing.
113
+ - CLI: a bad option value is a usage error (exit 2, naming the option) before any probe, checked
114
+ through the library's own argument types; `ping-many -c 0` exited 1 with every row failed.
115
+ `wake`, `gateway --to` and `mac` with a bad argument exit 2 naming the command (they escaped with
116
+ exit 1 or 22 and `command: null`). `traceroute --max-hops` is limited to 1-255. In human mode a
117
+ usage error shows Click's usage text instead of a traceback.
118
+
119
+ - Every port number is held to 1-65535 the same way, with the message
120
+ `port out of range 1-65535: N`. `scan_ports` / `ascan_ports` / `syn_scan` refuse a port LIST
121
+ holding an out-of-range port with `ValueError` (only a specification string was checked);
122
+ `grab_banners` / `agrab_banners` keep refusing a bad port, now in that wording; and
123
+ `PortResult`, `Banner` and `WakeReport` refuse one at construction, where pydantic's
124
+ `greater than or equal to 1` wording used to appear.
125
+ - `ping` / `aping` / `ping_many` check `tcp_port` on every call, also when no TCP fallback is
126
+ used: out of range it raises, or with `raise_on_error=False` is reported on `.error`. It used
127
+ to be ignored unless the fallback engaged.
128
+ - On Windows, `path_mtu()` sends its don't-fragment probes with TTL 128, Windows' own default, as
129
+ `ping` does. It used 64, so a target more than 64 hops away could not be probed.
130
+ - `Banner` requires `error` exactly when the state is `UNREACHABLE` and refuses an empty one. It
131
+ used to forbid `error` on the other states but allowed an `UNREACHABLE` banner without a reason.
132
+
133
+ - More arguments are checked strictly before anything is sent, in the
134
+ `<operation> refused its arguments: <argument>: <reason>` shape:
135
+ - `ping` / `aping` / `ping_many` / `is_reachable`: `times=True` sent one echo and `timeout="1"`
136
+ waited a second; both are refused now (`is_reachable`, which never raises, answers `False`).
137
+ - `ping_many` / `aping_many` and `grab_banners` / `agrab_banners`: one string passed as the host
138
+ collection (`ping_many("printer.lan")`) probed each character as a host; it is refused, and so
139
+ is a host that is not a string (a raw pydantic error from `Banner` before). `ping_many` checks
140
+ `concurrency` before it takes anything from the targets; the refusal used to come after the
141
+ targets iterable was consumed, as `concurrency must be at least 1, got 0`.
142
+ - `traceroute` / `atraceroute` / `trace_path` / `atrace_path`: `max_hops=True` walked one hop and
143
+ `3.0` or `"5"` raised `TypeError`; a non-bool `resolve_names` (`"no"` switched lookups on) and a
144
+ `timeout` of the wrong type are refused. The messages change from
145
+ `max_hops must be 1-255, got 256` (and `max_hops must be at least 1, got 0` in 2.1.0) to
146
+ `traceroute refused its arguments: max_hops: Input should be less than or equal to 255`. The
147
+ arguments are checked before the name is resolved, so a bad `max_hops` with an unresolvable
148
+ name is a `ValueError`, no longer `IPScoutResolutionError`.
149
+ - `scan_ports` / `ascan_ports` / `syn_scan` with a port LIST: `[True]` and `[80.5]` scanned port
150
+ 1 and 80 and keyed the result `True` / `80.5`; they are refused, and a list port out of range
151
+ now reads `port scan refused its arguments: ports.0: port out of range 1-65535: 0` (a
152
+ specification string keeps the bare `port out of range 1-65535: 0`).
153
+ `scan_ports(method="syn")` with a plain string silently ran a connect scan; it is refused.
154
+ - `observe_dhcp_first_reachable`: a `probe_timeout` of 0, negative, NaN or text, or a `tcp_port`
155
+ outside 1-65535, watched the whole window and returned `None` (port 0 silently dropped the TCP
156
+ attempt); they are refused before the capture opens.
157
+ - `DhcpSession` / `observe_dhcp`: a `settle` above 2147483 s is refused. A `timeout` of 0 reads
158
+ `timeout must be above 0 and at most 2147483 seconds, got 0` (2.1.0:
159
+ `timeout must be positive, got 0: a window of zero could never see anything`).
160
+ - `get_mac_address`, `lookup_mac` and `resolve_active`: text that is not an address literal
161
+ raises `ValueError`. In 2.1.0 `get_mac_address("nonsense")` returned `None`, and with
162
+ `active=True` raised `IPScoutUnsupportedError` (no route). `resolve_active` checks its
163
+ `timeout` (0 returned `None` without waiting, -1 raised a raw `Timeout value out of range`).
164
+ - `SweepScope.from_networks("192.0.2.0/24", ...)` (one network as text) is refused instead of
165
+ being split into characters.
166
+ - `resolve_active` and `lookup_mac(active=True)` on Linux and macOS honour an RFC 4007 zone
167
+ (`fe80::1%eth0`, by name or index): the request goes out on that interface. The zoned text used to
168
+ make the backend's `inet_pton` fail, and `None` came back with nothing sent. A zone naming no
169
+ interface raises `IPScoutResolutionError`. On Windows the zone is still not used.
170
+ - `build_echo_request` refuses a `payload_size` its family cannot carry with the same message
171
+ `ping` uses (`10**12` raised `MemoryError`, 70000 a raw pydantic error, and IPv4 65508-65527 was
172
+ built). `build_arp_request` / `build_neighbour_solicitation` refuse a sender address that is not
173
+ six bytes (`"aa:bb"` was zero-padded into the frame).
174
+ - Records refuse more values no producer makes: `EchoResult.rtt_ms` NaN, infinite or negative, a
175
+ round trip without a source (or the reverse), `ttl_expired` without an answer; `TraceHop` reached
176
+ or named without an address, a round trip without an address, an empty `hostname`;
177
+ `Interface` with an empty name, an address filed under the other family, a hardware address not
178
+ in lowercase colon form or all zero, an MTU above 0xFFFFFFFF; `Neighbour` whose `ip` is not a
179
+ bare literal of its `family` or whose `mac` is not `aa:bb:cc:dd:ee:ff`; `MacLookup` whose
180
+ `via_ip` and `NEXT_HOP` scope disagree, `DIRECT` without a MAC or `UNKNOWN` with one;
181
+ `SubnetInfo` whose network is not the one its address sits on, whose broadcast is not that
182
+ network's, or whose gateway is not of its family; `SweepScope` networks covering more than its
183
+ limit; `FindIpReport` with `scanned` and `scope` disagreeing; `ObserveDhcpReport` and
184
+ `WakeReport` with a non-canonical MAC, unbounded windows, or a broadcast that is not a usable
185
+ IPv4 address.
186
+ - `SubnetInfo.network` is an `IPv4Network` / `IPv6Network` instead of text. JSON output is
187
+ unchanged; Python callers reading it as a string need `str(...)`.
188
+ - CLI: `wake`, `find-ip` and `observe-dhcp` report the MAC as `aa:bb:cc:dd:ee:ff` whatever spelling
189
+ was typed (they echoed the input).
190
+
191
+ ### Fixed
192
+
193
+ - TCP fallback probes over IPv6 (`TcpEchoTransport`, `AsyncTcpEchoTransport`) could reach an IPv4
194
+ listener: an IPv4-mapped target (`::ffff:a.b.c.d`) connected through IPv4 on Linux and macOS,
195
+ and on macOS with Python 3.15 a plain IPv4 literal did too, so the host read as reachable over
196
+ IPv6. The probe socket now sets `IPV6_V6ONLY`, and such a target reads as unanswered.
197
+ - Windows `local_interfaces()`: an adapter with no friendly name was named `b'{GUID}'` (the repr
198
+ of the bytes) and one with no name at all `''`; it is now `{GUID}`, or `ifindex-<n>` /
199
+ `adapter-<position>` when even that is missing.
200
+ - macOS `dhcp_capture_available()`: a busy `/dev/bpf0` answered `False` although a capture would
201
+ claim the next free device; it now tries the same pool a capture does.
202
+ - `reverse_dns` answers `None` for an empty PTR name, which a traceroute with `resolve_names=True`
203
+ would otherwise have failed on.
204
+ - `aping`, `aping_many`, `ais_reachable` and `atraceroute` resolve their target in a worker
205
+ thread, and `atrace_path(resolve_names=True)` looks up each hop's name there too. They called
206
+ `getaddrinfo` / `gethostbyaddr` on the event-loop thread, so one slow name server stalled every
207
+ other task in the loop: in `aping_many`, one slow name held up the whole sweep.
208
+ - `is_reachable` / `ais_reachable` resolve the target once and probe that one address on both the
209
+ ICMP and the TCP attempt; they resolved twice, so round-robin DNS could make the two attempts
210
+ probe different hosts. ICMP versus TCP is now chosen for the family the target resolved to, not
211
+ for IPv4, and a socket error from either attempt answers `False` instead of escaping. A
212
+ `tcp_port` outside 1-65535 raised an uncaught `OverflowError` from the connect; it now makes only
213
+ the TCP attempt answer `False`, so a host that answers ICMP still reads as reachable.
214
+ - `ping` / `aping` / `ping_many` refuse a `payload_size` above 65507 bytes for an IPv4 target or
215
+ 65527 for IPv6 (raised, or on `.error` with `raise_on_error=False`). Such an echo cannot be sent,
216
+ and the failed send was reported as a live host that never answered.
217
+ - `wake_on_lan` refuses a port outside 1-65535 with `ValueError`. It raised an uncaught
218
+ `OverflowError` from the socket layer, which the CLI printed as a traceback.
219
+ - An interface whose sysfs MTU reads 0 is listed with no MTU instead of failing the listing.
220
+
221
+ ### Removed
222
+
223
+ Backend-module names replaced by the shared definitions above. Nothing in the top-level `ipscout`
224
+ namespace is removed or changed.
225
+
226
+ - `winapi.IP_SUCCESS`, `IP_DEST_NET_UNREACHABLE`, `IP_DEST_HOST_UNREACHABLE`, `IP_REQ_TIMED_OUT`,
227
+ `IP_TTL_EXPIRED_TRANSIT`: use `winapi.IpStatus` members, which compare equal to the same numbers.
228
+ - `neighbours_linux`, `neighbours_windows`, `bsdroute` and `tcpsyn` no longer define their own
229
+ `format_mac`, `MAC_LENGTH` and `checksum`; they import `arp.format_mac`, `arp.MAC_LENGTH` and
230
+ `packet.checksum` (the same RFC 1071 sum) and drop them from `__all__`. An old import still
231
+ resolves, except that `neighbours_windows.format_mac` now takes one argument: for a
232
+ length-prefixed buffer use `neighbours_windows.declared_mac(raw, length)`.
233
+ - `neighbours_windows.resolve_active(ip)` takes the family as a second argument; an IPv4 address in
234
+ a non-dotted-quad form such as `192.0.2` resolves to nothing instead of being read as `192.0.0.2`.
235
+
236
+ ## [2.1.0] 2026-10-24 17:18:42
237
+
238
+ ### Added
239
+
240
+ - `grab_banners` / `agrab_banners`: read the first line each of many hosts sends on one TCP port,
241
+ connecting, reading, optionally sending a `farewell` such as `b"QUIT\r\n"`, and closing on ONE
242
+ connection per host, so a device that refuses a second connect right after a session is still
243
+ read. Each host gets a `Banner` whose `BannerState` is `GREETED` (with `line`), `SILENT`,
244
+ `REFUSED` or `UNREACHABLE` (with `error`); a name that does not resolve is a result, not an
245
+ exception. One `timeout` per host covers resolution, connect and read; results keep input order.
246
+ - `ipscout banners HOSTS... --port N`, the 22nd subcommand. A `HOSTS` item written as a network
247
+ expands to its hosts within the sweep bound; `--farewell` decodes `\r`, `\n`, `\t` and `\\`.
248
+ Exits 1 when no host greeted.
249
+
250
+ ### Fixed
251
+
252
+ - `ping_many` / `aping_many` return their mapping in the order the targets were given, as documented.
253
+ It was in completion order, so a target that timed out ahead of one that answered came after it.
254
+ - The TCP fallback (`allow_tcp_fallback`, `is_reachable`) counts a refused connection as an answer
255
+ in every spelling a platform uses for it. It recognised only `ConnectionRefusedError`, so a refusal
256
+ arriving as a bare `OSError` with `ECONNREFUSED`, or with the Windows numbering 10061 / 1225,
257
+ reported a live host as unanswered.
258
+ - In `--json` and `--json-bare` modes, an invalid option value is reported with the option's name
259
+ (`Invalid value for '--timeout': ...`). The JSON error message carried only Click's bare text,
260
+ which did not say which option was wrong.
261
+
8
262
  ## [2.0.0] 2026-10-24 12:32:19
9
263
 
10
264
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ipscout
3
- Version: 2.0.0
3
+ Version: 2.3.0
4
4
  Summary: Ping, traceroute and local-network inspection in pure Python - no subprocess, and no admin rights on the default paths
5
5
  Project-URL: Homepage, https://github.com/bitranox/ipscout
6
6
  Project-URL: Repository, https://github.com/bitranox/ipscout.git
@@ -29,7 +29,7 @@ Requires-Dist: rtoml>=0.14.0
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: bandit>=1.9.4; extra == 'dev'
31
31
  Requires-Dist: build>=1.6.1; extra == 'dev'
32
- Requires-Dist: certifi>=2026.10.19; extra == 'dev'
32
+ Requires-Dist: certifi>=2026.10.23; extra == 'dev'
33
33
  Requires-Dist: click>=8.5.0; extra == 'dev'
34
34
  Requires-Dist: hatchling>=1.32.4; extra == 'dev'
35
35
  Requires-Dist: import-linter>=2.15; extra == 'dev'
@@ -201,6 +201,8 @@ ipscout ping-many 127.0.0.1 ::1 -c 1
201
201
  ipscout reachable example.com
202
202
  ipscout traceroute 1.1.1.1 --max-hops 10
203
203
  ipscout scan-ports 192.168.1.10 --ports 22,80,443,8000-8100
204
+ ipscout banners 192.0.2.0/24 --port 21 --farewell 'QUIT\r\n' # what each host says first
205
+ ipscout snmp-system 192.0.2.0/28 # uptime, name and model over SNMP
204
206
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
205
207
  ipscout find-ip dc:b2:2f:44:34:59 --scan
206
208
  ipscout arp-scan --network 192.168.1.0/24
@@ -234,6 +236,8 @@ ipscout.sweep_scope() # what a default sweep covers, and what is too wide for i
234
236
  ipscout.default_gateway() # and query_route() for any destination
235
237
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
236
238
  ipscout.scan_ports(host, "22,80,8000-8100")
239
+ ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
240
+ ipscout.read_snmp_system(hosts) # uptime, sysName, sysDescr, sysObjectID; a falling uptime is a restart
237
241
  ipscout.path_mtu("8.8.8.8")
238
242
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
239
243
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -242,7 +246,9 @@ ipscout.remove_neighbour(ip, interface="eth0") # and out again
242
246
  ```
243
247
 
244
248
  A MAC address does not survive a router hop, so `lookup_mac` puts the scope in the answer and
245
- `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. A
249
+ `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. Every
250
+ answer also says in `method` whether it was asked for (`active=True`) or read from the cache, and
251
+ `fallback_to_cache=True` reads the cache only when this host cannot ask actively. A
246
252
  sweep with no network given covers the subnets this host is attached to that fit inside one sweep's
247
253
  4096-address budget, and names the ones left out - a container bridge on a `/16` is skipped rather than
248
254
  cancelling the sweep, and a search that matched nothing while a network went uncovered says so instead of
@@ -155,6 +155,8 @@ ipscout ping-many 127.0.0.1 ::1 -c 1
155
155
  ipscout reachable example.com
156
156
  ipscout traceroute 1.1.1.1 --max-hops 10
157
157
  ipscout scan-ports 192.168.1.10 --ports 22,80,443,8000-8100
158
+ ipscout banners 192.0.2.0/24 --port 21 --farewell 'QUIT\r\n' # what each host says first
159
+ ipscout snmp-system 192.0.2.0/28 # uptime, name and model over SNMP
158
160
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
159
161
  ipscout find-ip dc:b2:2f:44:34:59 --scan
160
162
  ipscout arp-scan --network 192.168.1.0/24
@@ -188,6 +190,8 @@ ipscout.sweep_scope() # what a default sweep covers, and what is too wide for i
188
190
  ipscout.default_gateway() # and query_route() for any destination
189
191
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
190
192
  ipscout.scan_ports(host, "22,80,8000-8100")
193
+ ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
194
+ ipscout.read_snmp_system(hosts) # uptime, sysName, sysDescr, sysObjectID; a falling uptime is a restart
191
195
  ipscout.path_mtu("8.8.8.8")
192
196
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
193
197
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -196,7 +200,9 @@ ipscout.remove_neighbour(ip, interface="eth0") # and out again
196
200
  ```
197
201
 
198
202
  A MAC address does not survive a router hop, so `lookup_mac` puts the scope in the answer and
199
- `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. A
203
+ `get_mac_address` returns `None` for anything routed rather than passing off the gateway's as the host's. Every
204
+ answer also says in `method` whether it was asked for (`active=True`) or read from the cache, and
205
+ `fallback_to_cache=True` reads the cache only when this host cannot ask actively. A
200
206
  sweep with no network given covers the subnets this host is attached to that fit inside one sweep's
201
207
  4096-address budget, and names the ones left out - a container bridge on a `/16` is skipped rather than
202
208
  cancelling the sweep, and a search that matched nothing while a network went uncovered says so instead of
@@ -58,7 +58,7 @@ The import-linter contract `Layered dependencies point one way only` is enforced
58
58
  listed highest first; each may import only from those below it.
59
59
 
60
60
  ```
61
- scan : subnet : portscan : mtu : wol sweeps and single-purpose probes
61
+ scan : subnet : portscan : banner : snmp : mtu : wol : dhcp sweeps and single-purpose probes
62
62
  api ping / aping / ping_many / aping_many / is_reachable / ais_reachable
63
63
  traceroute traceroute / atraceroute / trace_path / atrace_path
64
64
  static_neighbours set_neighbour / remove_neighbour
@@ -71,7 +71,9 @@ transport_posix : transport_windows : transport_tcp
71
71
  : interfaces_posix : interfaces_windows
72
72
  : routes_linux : routes_macos : routes_windows
73
73
  : neighbours_linux : neighbours_macos : neighbours_windows
74
- packet : resolve : ports : winapi : netlink : bsdroute : arp : tcpsyn
74
+ : dhcp_linux : dhcp_windows : dhcp_macos
75
+ packet : deadline : resolve : ports : winapi : netlink : bsdroute : arp : tcpsyn
76
+ : pool : sockerr : unixio : bootp : bpf : snmpber
75
77
  models
76
78
  errors
77
79
  ```
@@ -102,8 +104,11 @@ caller asked for and what the machine could do. A transport reports what happene
102
104
  ### Data flow, one ping
103
105
 
104
106
  1. `api.ping` resolves the target once through `resolve.resolve_one`, which fixes the address
105
- family up front rather than guessing it later.
106
- 2. `api._prepare` builds a `service.PingRequest`. Pydantic field bounds reject a bad `times`,
107
+ family up front rather than guessing it later. The async entry points (`aping`,
108
+ `ais_reachable`, `atraceroute`, and the hop names of `atrace_path`) run that lookup in a worker
109
+ thread, because `getaddrinfo` blocks for as long as the name server takes and one slow name on
110
+ the loop thread would stall every other probe in a sweep.
111
+ 2. `api._request` builds a `service.PingRequest`. Pydantic field bounds reject a bad `times`,
107
112
  `timeout` or `interval` here, before any packet moves, identically for every caller path.
108
113
  3. `factory.make_transport` picks the backend.
109
114
  4. `service.run_ping` sends the echoes, paced against the run's start so a slow reply does not push
@@ -196,39 +201,45 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
196
201
 
197
202
  ### Public surface
198
203
 
199
- | Module | Role |
200
- |------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
201
- | `__init__.py` | Re-exports the public API so importers depend on the package, not its layout. |
202
- | `api.py` | `ping`, `aping`, `ping_many`, `aping_many`, `is_reachable`, `ais_reachable`. Owns the error contract. |
203
- | `traceroute.py` | `traceroute`, `atraceroute`, `trace_path`, `atrace_path`, and the capability refusal. |
204
- | `interfaces.py` | `local_interfaces`, dispatching to the per-OS backend. |
205
- | `routes.py` | `query_route`, `default_gateway`, dispatching to the per-OS backend. |
206
- | `neighbours.py` | `neighbours`, `lookup_mac`, `get_mac_address`, `resolve_active`, `normalise_mac`. |
207
- | `static_neighbours.py` | `set_neighbour`, `remove_neighbour`: write and delete a permanent IPv4 entry, choosing the interface and reading the result back. |
208
- | `scan.py` | `arp_scan`, `find_ip_by_mac`, `local_networks`, `sweep_scope`. Sweep, then read what the kernel learned. |
209
- | `subnet.py` | `subnet_info`, composing interfaces, routes and the stored lease. |
210
- | `portscan.py` | `scan_ports`, `ascan_ports`, `syn_scan`, `parse_ports`. |
211
- | `mtu.py` | `path_mtu`, by kernel query on Linux and by bisection elsewhere. |
212
- | `wol.py` | `wake_on_lan` and the magic packet it builds. |
213
- | `dhcp.py` | `observe_dhcp` and its session: the capture loop, the quiet window and the platform dispatch. |
214
- | `resolve.py` | `resolve`, `resolve_one` (returns a `ResolvedTarget` named tuple), `reverse_dns`, `family_of`. |
215
- | `factory.py` | `make_transport`, `make_async_transport`, `icmp_available`. The only `sys.platform` branch in the probing path. |
204
+ | Module | Role |
205
+ |------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
206
+ | `__init__.py` | Re-exports the public API so importers depend on the package, not its layout. |
207
+ | `api.py` | `ping`, `aping`, `ping_many`, `aping_many`, `is_reachable`, `ais_reachable`. Owns the error contract. |
208
+ | `traceroute.py` | `traceroute`, `atraceroute`, `trace_path`, `atrace_path`, and the capability refusal. |
209
+ | `interfaces.py` | `local_interfaces`, dispatching to the per-OS backend. |
210
+ | `routes.py` | `query_route`, `default_gateway`, dispatching to the per-OS backend. |
211
+ | `neighbours.py` | `neighbours`, `lookup_mac`, `get_mac_address`, `resolve_active`, `normalise_mac`, and `ask_or_read_cache`, the one rule for falling back from an active ask to the cache. |
212
+ | `static_neighbours.py` | `set_neighbour`, `remove_neighbour`: write and delete a permanent IPv4 entry, choosing the interface and reading the result back. |
213
+ | `scan.py` | `arp_scan`, `find_ip_by_mac`, `local_networks`, `sweep_scope`. Sweep, then read what the kernel learned. |
214
+ | `subnet.py` | `subnet_info`, composing interfaces, routes and the stored lease. |
215
+ | `portscan.py` | `scan_ports`, `ascan_ports`, `syn_scan`, `parse_ports`. |
216
+ | `banner.py` | `grab_banners`, `agrab_banners`: connect, read the first line, say goodbye and close, all on one connection per host. |
217
+ | `snmp.py` | `read_snmp_system`, `aread_snmp_system`: one SNMP GET of the system group per host on a connected UDP socket, re-asking under v1 without a value the agent lacks. |
218
+ | `mtu.py` | `path_mtu`, by kernel query on Linux and by bisection elsewhere. |
219
+ | `wol.py` | `wake_on_lan` and the magic packet it builds. |
220
+ | `dhcp.py` | `observe_dhcp` and its session: the capture loop, the quiet window and the platform dispatch. |
221
+ | `resolve.py` | `resolve`, `resolve_one` (returns a `ResolvedTarget` named tuple), `reverse_dns`, `family_of`. |
222
+ | `factory.py` | `make_transport`, `make_async_transport`, `icmp_available`. The only `sys.platform` branch in the probing path. |
216
223
 
217
224
  ### Core
218
225
 
219
- | Module | Role |
220
- |---------------|-------------------------------------------------------------------------------------------------------------------------|
221
- | `errors.py` | `IPScoutError` and its subclasses, sweep refusals included. No dependencies. |
222
- | `models.py` | Frozen result models and the `str`-subclass enums. |
223
- | `ports.py` | `EchoResult`, `EchoTransport`, `AsyncEchoTransport`. The seams. |
224
- | `packet.py` | ICMP encode and decode. Total functions over bytes: no sockets, no clock, no I/O. |
225
- | `service.py` | `PingRequest`, `run_ping`, `arun_ping`. Probe sequencing and aggregation. |
226
- | `netlink.py` | Message and attribute walkers and encoders, and the acknowledgement reader, for the Linux route and neighbour backends. |
227
- | `bsdroute.py` | Routing-message and `sockaddr` walkers and encoders for the macOS backends, plus `sysctl`. |
228
- | `arp.py` | ARP and NDP codecs. Testable without the privilege the sockets need. |
229
- | `bootp.py` | BOOTP/DHCP reply codec, separate from the capture for the same reason. |
230
- | `bpf.py` | BSD packet filter ioctl encoding and record splitting, shared by two backends. |
231
- | `tcpsyn.py` | TCP SYN codec and its pseudo-header checksum, for the half-open scan. |
226
+ | Module | Role |
227
+ |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
228
+ | `errors.py` | `IPScoutError` and its subclasses, sweep refusals included, and `could_not_ask` for a resolution request that could not be sent. No dependencies. |
229
+ | `models.py` | Frozen result models and the `str`-subclass enums. |
230
+ | `ports.py` | `EchoResult`, `EchoTransport`, `AsyncEchoTransport`. The seams. |
231
+ | `packet.py` | ICMP encode and decode. Total functions over bytes: no sockets, no clock, no I/O. |
232
+ | `service.py` | `PingRequest`, `run_ping`, `arun_ping`. Probe sequencing and aggregation. |
233
+ | `netlink.py` | Message and attribute walkers and encoders, and the acknowledgement reader, for the Linux route and neighbour backends. |
234
+ | `bsdroute.py` | Routing-message and `sockaddr` walkers and encoders for the macOS backends, plus `sysctl`. |
235
+ | `arp.py` | ARP and NDP codecs. Testable without the privilege the sockets need. |
236
+ | `bootp.py` | BOOTP/DHCP reply codec, separate from the capture for the same reason. |
237
+ | `bpf.py` | BSD packet filter ioctl encoding and record splitting, shared by two backends. |
238
+ | `tcpsyn.py` | TCP SYN codec and its pseudo-header checksum, for the half-open scan. |
239
+ | `deadline.py` | `within` and `failure_reason`: one deadline across several awaits, shared by the banner grab and the SNMP read. |
240
+ | `snmpber.py` | BER encode of an SNMP GET and a structural parse of the reply. Total functions over bytes, like `packet.py`. |
241
+ | `pool.py` | `gather_bounded`: a fixed worker pool, so a long work list never becomes one Task per item. |
242
+ | `sockerr.py` | `is_refusal`: one reading of a refused connect across every platform's spelling, shared by every module that connects. |
232
243
 
233
244
  ### Platform backends
234
245
 
@@ -253,13 +264,13 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
253
264
 
254
265
  ### CLI adapter
255
266
 
256
- | Module | Role |
257
- |---------------------|----------------------------------------------------------------------------|
258
- | `cli.py` | The rich-click group, twenty-one subcommands, `_emit` and `_fail`, `main`. |
259
- | `serialize.py` | `to_jsonable` and `dumps` at the output boundary. |
260
- | `typed_click.py` | Typed facade over rich-click's partially-typed decorators. |
261
- | `__main__.py` | `python -m ipscout`. |
262
- | `__init__conf__.py` | Static package metadata, kept in sync with `pyproject.toml` by automation. |
267
+ | Module | Role |
268
+ |---------------------|------------------------------------------------------------------------------|
269
+ | `cli.py` | The rich-click group, twenty-three subcommands, `_emit` and `_fail`, `main`. |
270
+ | `serialize.py` | `to_jsonable` and `dumps` at the output boundary. |
271
+ | `typed_click.py` | Typed facade over rich-click's partially-typed decorators. |
272
+ | `__main__.py` | `python -m ipscout`. |
273
+ | `__init__conf__.py` | Static package metadata, kept in sync with `pyproject.toml` by automation. |
263
274
 
264
275
  ---
265
276
 
@@ -276,6 +287,8 @@ and mutating it can only make it disagree with reality.
276
287
  | `InterfaceAddress` | `address`, `prefix_len` |
277
288
  | `MacLookup` | `ip`, `mac`, `scope`, `via_ip`, `interface` |
278
289
  | `SubnetInfo` | `interface`, `address`, `prefix_len`, `network`, `family`, `broadcast`, `gateway`, `dns_servers`, `domain`, `dhcp_server`, `lease_obtained`, `lease_expires`, `mtu` |
290
+ | `Banner` | `host`, `port`, `state`, `line`, `error` |
291
+ | `SnmpSystem` | `host`, `state`, `uptime_seconds`, `name`, `description`, `object_id`, `error` |
279
292
  | `CapabilityReport` | `icmp_ipv4`, `icmp_ipv6`, `traceroute` |
280
293
  | `ReachabilityReport` | `target`, `reachable` |
281
294
  | `ResolveReport` | `target`, `addresses` |