ipscout 2.0.0__tar.gz → 2.1.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 (97) hide show
  1. {ipscout-2.0.0 → ipscout-2.1.0}/CHANGELOG.md +26 -0
  2. {ipscout-2.0.0 → ipscout-2.1.0}/PKG-INFO +4 -2
  3. {ipscout-2.0.0 → ipscout-2.1.0}/README.md +2 -0
  4. {ipscout-2.0.0 → ipscout-2.1.0}/docs/systemdesign/module_reference.md +7 -2
  5. {ipscout-2.0.0 → ipscout-2.1.0}/docs/usage.md +54 -18
  6. {ipscout-2.0.0 → ipscout-2.1.0}/pyproject.toml +4 -4
  7. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__init__.py +7 -0
  8. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__init__conf__.py +1 -1
  9. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/api.py +5 -5
  10. ipscout-2.1.0/src/ipscout/banner.py +287 -0
  11. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/cli.py +112 -3
  12. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/models.py +59 -1
  13. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/portscan.py +2 -26
  14. ipscout-2.1.0/src/ipscout/sockerr.py +58 -0
  15. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_tcp.py +9 -6
  16. ipscout-2.1.0/tests/test_banner.py +393 -0
  17. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_cli.py +108 -0
  18. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_integration_loopback.py +10 -0
  19. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_models.py +38 -1
  20. ipscout-2.1.0/tests/test_sockerr.py +52 -0
  21. {ipscout-2.0.0 → ipscout-2.1.0}/.gitignore +0 -0
  22. {ipscout-2.0.0 → ipscout-2.1.0}/LICENSE +0 -0
  23. {ipscout-2.0.0 → ipscout-2.1.0}/docs/development.md +0 -0
  24. {ipscout-2.0.0 → ipscout-2.1.0}/docs/handover-observe-dhcp.md +0 -0
  25. {ipscout-2.0.0 → ipscout-2.1.0}/docs/installation.md +0 -0
  26. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__main__.py +0 -0
  27. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/arp.py +0 -0
  28. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bootp.py +0 -0
  29. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bpf.py +0 -0
  30. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bsdroute.py +0 -0
  31. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp.py +0 -0
  32. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_linux.py +0 -0
  33. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_macos.py +0 -0
  34. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_windows.py +0 -0
  35. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/errors.py +0 -0
  36. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/factory.py +0 -0
  37. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces.py +0 -0
  38. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces_posix.py +0 -0
  39. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces_windows.py +0 -0
  40. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/leases_linux.py +0 -0
  41. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/mtu.py +0 -0
  42. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours.py +0 -0
  43. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_linux.py +0 -0
  44. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_macos.py +0 -0
  45. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_windows.py +0 -0
  46. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/netlink.py +0 -0
  47. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/packet.py +0 -0
  48. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/pool.py +0 -0
  49. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/ports.py +0 -0
  50. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/py.typed +0 -0
  51. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/resolve.py +0 -0
  52. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes.py +0 -0
  53. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_linux.py +0 -0
  54. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_macos.py +0 -0
  55. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_windows.py +0 -0
  56. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/scan.py +0 -0
  57. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/serialize.py +0 -0
  58. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/service.py +0 -0
  59. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/static_neighbours.py +0 -0
  60. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/subnet.py +0 -0
  61. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/tcpsyn.py +0 -0
  62. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/traceroute.py +0 -0
  63. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_posix.py +0 -0
  64. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_windows.py +0 -0
  65. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/typed_click.py +0 -0
  66. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/unixio.py +0 -0
  67. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/winapi.py +0 -0
  68. {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/wol.py +0 -0
  69. {ipscout-2.0.0 → ipscout-2.1.0}/tests/capture_support.py +0 -0
  70. {ipscout-2.0.0 → ipscout-2.1.0}/tests/conftest.py +0 -0
  71. {ipscout-2.0.0 → ipscout-2.1.0}/tests/dhcp_capture_fixture.py +0 -0
  72. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_arp.py +0 -0
  73. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_bootp.py +0 -0
  74. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_capability_probe.py +0 -0
  75. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_macos.py +0 -0
  76. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_windows.py +0 -0
  77. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_factory.py +0 -0
  78. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_family_filter.py +0 -0
  79. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_integration_internet.py +0 -0
  80. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_interfaces.py +0 -0
  81. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_metadata.py +0 -0
  82. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_module_entry.py +0 -0
  83. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_neighbours.py +0 -0
  84. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_observe_dhcp.py +0 -0
  85. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_packaging.py +0 -0
  86. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_packet.py +0 -0
  87. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_platform_markers.py +0 -0
  88. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_resolve.py +0 -0
  89. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_routes.py +0 -0
  90. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_scan_and_subnet.py +0 -0
  91. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_service.py +0 -0
  92. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_static_neighbours.py +0 -0
  93. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_tcp_fallback.py +0 -0
  94. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_traceroute.py +0 -0
  95. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_transport_posix_logic.py +0 -0
  96. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_ttl_capability_probe.py +0 -0
  97. {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_winapi_layout.py +0 -0
@@ -5,6 +5,32 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.1.0] 2026-10-24 17:18:42
9
+
10
+ ### Added
11
+
12
+ - `grab_banners` / `agrab_banners`: read the first line each of many hosts sends on one TCP port,
13
+ connecting, reading, optionally sending a `farewell` such as `b"QUIT\r\n"`, and closing on ONE
14
+ connection per host, so a device that refuses a second connect right after a session is still
15
+ read. Each host gets a `Banner` whose `BannerState` is `GREETED` (with `line`), `SILENT`,
16
+ `REFUSED` or `UNREACHABLE` (with `error`); a name that does not resolve is a result, not an
17
+ exception. One `timeout` per host covers resolution, connect and read; results keep input order.
18
+ - `ipscout banners HOSTS... --port N`, the 22nd subcommand. A `HOSTS` item written as a network
19
+ expands to its hosts within the sweep bound; `--farewell` decodes `\r`, `\n`, `\t` and `\\`.
20
+ Exits 1 when no host greeted.
21
+
22
+ ### Fixed
23
+
24
+ - `ping_many` / `aping_many` return their mapping in the order the targets were given, as documented.
25
+ It was in completion order, so a target that timed out ahead of one that answered came after it.
26
+ - The TCP fallback (`allow_tcp_fallback`, `is_reachable`) counts a refused connection as an answer
27
+ in every spelling a platform uses for it. It recognised only `ConnectionRefusedError`, so a refusal
28
+ arriving as a bare `OSError` with `ECONNREFUSED`, or with the Windows numbering 10061 / 1225,
29
+ reported a live host as unanswered.
30
+ - In `--json` and `--json-bare` modes, an invalid option value is reported with the option's name
31
+ (`Invalid value for '--timeout': ...`). The JSON error message carried only Click's bare text,
32
+ which did not say which option was wrong.
33
+
8
34
  ## [2.0.0] 2026-10-24 12:32:19
9
35
 
10
36
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ipscout
3
- Version: 2.0.0
3
+ Version: 2.1.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,7 @@ 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
204
205
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
205
206
  ipscout find-ip dc:b2:2f:44:34:59 --scan
206
207
  ipscout arp-scan --network 192.168.1.0/24
@@ -234,6 +235,7 @@ ipscout.sweep_scope() # what a default sweep covers, and what is too wide for i
234
235
  ipscout.default_gateway() # and query_route() for any destination
235
236
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
236
237
  ipscout.scan_ports(host, "22,80,8000-8100")
238
+ ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
237
239
  ipscout.path_mtu("8.8.8.8")
238
240
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
239
241
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -155,6 +155,7 @@ 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
158
159
  ipscout mac 8.8.8.8 # the gateway's, labelled NEXT_HOP
159
160
  ipscout find-ip dc:b2:2f:44:34:59 --scan
160
161
  ipscout arp-scan --network 192.168.1.0/24
@@ -188,6 +189,7 @@ ipscout.sweep_scope() # what a default sweep covers, and what is too wide for i
188
189
  ipscout.default_gateway() # and query_route() for any destination
189
190
  ipscout.subnet_info() # addressing, gateway, stored DHCP facts
190
191
  ipscout.scan_ports(host, "22,80,8000-8100")
192
+ ipscout.grab_banners(hosts, 21, farewell=b"QUIT\r\n") # each host's first line, on one connection
191
193
  ipscout.path_mtu("8.8.8.8")
192
194
  ipscout.wake_on_lan(mac, broadcast="192.168.1.255")
193
195
  ipscout.observe_dhcp(mac, interface="br0") # a machine that has no address yet (needs elevation)
@@ -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 : 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
+ : dhcp_linux : dhcp_windows : dhcp_macos
74
75
  packet : resolve : ports : winapi : netlink : bsdroute : arp : tcpsyn
76
+ : pool : sockerr : unixio : bootp : bpf
75
77
  models
76
78
  errors
77
79
  ```
@@ -208,6 +210,7 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
208
210
  | `scan.py` | `arp_scan`, `find_ip_by_mac`, `local_networks`, `sweep_scope`. Sweep, then read what the kernel learned. |
209
211
  | `subnet.py` | `subnet_info`, composing interfaces, routes and the stored lease. |
210
212
  | `portscan.py` | `scan_ports`, `ascan_ports`, `syn_scan`, `parse_ports`. |
213
+ | `banner.py` | `grab_banners`, `agrab_banners`: connect, read the first line, say goodbye and close, all on one connection per host. |
211
214
  | `mtu.py` | `path_mtu`, by kernel query on Linux and by bisection elsewhere. |
212
215
  | `wol.py` | `wake_on_lan` and the magic packet it builds. |
213
216
  | `dhcp.py` | `observe_dhcp` and its session: the capture loop, the quiet window and the platform dispatch. |
@@ -229,6 +232,8 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
229
232
  | `bootp.py` | BOOTP/DHCP reply codec, separate from the capture for the same reason. |
230
233
  | `bpf.py` | BSD packet filter ioctl encoding and record splitting, shared by two backends. |
231
234
  | `tcpsyn.py` | TCP SYN codec and its pseudo-header checksum, for the half-open scan. |
235
+ | `pool.py` | `gather_bounded`: a fixed worker pool, so a long work list never becomes one Task per item. |
236
+ | `sockerr.py` | `is_refusal`: one reading of a refused connect across every platform's spelling, shared by every module that connects. |
232
237
 
233
238
  ### Platform backends
234
239
 
@@ -255,7 +260,7 @@ own value only as a fallback, which keeps loopback and LAN timings meaningful.
255
260
 
256
261
  | Module | Role |
257
262
  |---------------------|----------------------------------------------------------------------------|
258
- | `cli.py` | The rich-click group, twenty-one subcommands, `_emit` and `_fail`, `main`. |
263
+ | `cli.py` | The rich-click group, twenty-two subcommands, `_emit` and `_fail`, `main`. |
259
264
  | `serialize.py` | `to_jsonable` and `dumps` at the output boundary. |
260
265
  | `typed_click.py` | Typed facade over rich-click's partially-typed decorators. |
261
266
  | `__main__.py` | `python -m ipscout`. |
@@ -487,6 +487,36 @@ ipscout.scan_ports(host, "1-1024", method=ipscout.ScanMethod.SYN)
487
487
  Both methods hold only `concurrency` probes alive at a time, so a full-range scan is bounded in
488
488
  memory rather than allocating a task per port.
489
489
 
490
+ ## Reading a banner
491
+
492
+ Many services announce themselves the moment a client connects: an FTP or SMTP server sends a `220`
493
+ line, an SSH server its version string. `grab_banners` reads that first line from many hosts at once,
494
+ which is how a device is identified by what it says rather than by which port it has open:
495
+
496
+ ```python
497
+ results = ipscout.grab_banners(["192.0.2.10", "192.0.2.11"], 21, farewell=b"QUIT\r\n")
498
+ for host, banner in results.items():
499
+ if banner.line is not None and banner.line.startswith("220 Print Server"):
500
+ print(host, banner.line) # 192.0.2.10 220 Print Server Ready.
501
+ ```
502
+
503
+ Match the part of the greeting that names the device: every FTP server sends a `220` line.
504
+
505
+ Each `Banner` carries one of four states. `GREETED` holds the decoded first line in `line`, and only
506
+ a `GREETED` result has one, so `banner.line is not None` is the same test and narrows the type; `SILENT`
507
+ means the host accepted the connection and sent nothing before the deadline; `REFUSED` means the port
508
+ actively refused; `UNREACHABLE` puts the reason in `error`, and also covers a name that did not
509
+ resolve, so one bad name never costs the other results.
510
+
511
+ Everything for one host happens on a single connection: connect, read up to the first newline (at
512
+ most `max_line_bytes`, 512 by default), send `farewell` if given and a line was read, close. Some
513
+ embedded servers refuse a new connection for a moment after each session, so scanning for open
514
+ ports first and connecting again to read misses exactly the devices a banner identifies.
515
+
516
+ `timeout` (3.0 s by default) is one deadline per host for resolving, connecting and reading
517
+ together. A refused connection is reported rather than retried; retry it yourself when the device is
518
+ known to need it. `agrab_banners` is the same on the event loop.
519
+
490
520
  ## Waking a host, and the path to it
491
521
 
492
522
  ```python
@@ -502,7 +532,7 @@ one produces a silent black hole.
502
532
 
503
533
  ## The CLI
504
534
 
505
- 21 commands. `ipscout`, `python -m ipscout` and `uvx ipscout` are the same entry point.
535
+ 22 commands. `ipscout`, `python -m ipscout` and `uvx ipscout` are the same entry point.
506
536
 
507
537
  | Command | What it does |
508
538
  |--------------------|-------------------------------------------------------------------------|
@@ -522,6 +552,7 @@ one produces a silent black hole.
522
552
  | `remove-neighbour` | Delete an IPv4 neighbour entry; exits 1 when there was none. |
523
553
  | `subnet` | Show addressing, gateway and stored DHCP facts per subnet. |
524
554
  | `scan-ports` | Report which ports are open, closed or filtered. |
555
+ | `banners` | Read the first line each host sends on a TCP port. |
525
556
  | `mtu` | Show the largest packet that reaches a target unfragmented. |
526
557
  | `wake` | Send a wake-on-LAN magic packet. |
527
558
  | `observe-dhcp` | Watch a DHCP handshake for the addresses offered to a hardware address. |
@@ -542,20 +573,21 @@ Global options, which live on the group so they compose with every subcommand:
542
573
 
543
574
  Per-command options:
544
575
 
545
- | Command | Options |
546
- |--------------------|-----------------------------------------------------------------------------------------------------------|
547
- | `ping` | `--times/-c` (4), `--timeout` (2.0), `--interval` (0.2), `-4`, `-6`, `--tcp-fallback`, `--tcp-port` (443) |
548
- | `ping-many` | `--times/-c` (4), `--timeout` (2.0), `--concurrency` (64) |
549
- | `reachable` | `--timeout` (2.0), `--tcp-port` (443) |
550
- | `traceroute` | `--max-hops` (30), `--timeout` (2.0), `-4`, `-6`, `--resolve-names` |
551
- | `resolve` | `-4`, `-6` |
552
- | `reverse-dns` | none beyond the global flags |
553
- | `interfaces` | none beyond the global flags |
554
- | `observe-dhcp` | `--interface` (the default route's), `--timeout` (60.0), `--settle` (12.0) |
555
- | `set-neighbour` | `--interface` (the one whose subnet holds IP) |
556
- | `remove-neighbour` | `--interface` (every interface the cache lists IP on) |
557
- | `capabilities` | none beyond the global flags |
558
- | `info` | none beyond the global flags |
576
+ | Command | Options |
577
+ |--------------------|------------------------------------------------------------------------------------------------------------------|
578
+ | `ping` | `--times/-c` (4), `--timeout` (2.0), `--interval` (0.2), `-4`, `-6`, `--tcp-fallback`, `--tcp-port` (443) |
579
+ | `ping-many` | `--times/-c` (4), `--timeout` (2.0), `--concurrency` (64) |
580
+ | `reachable` | `--timeout` (2.0), `--tcp-port` (443) |
581
+ | `traceroute` | `--max-hops` (30), `--timeout` (2.0), `-4`, `-6`, `--resolve-names` |
582
+ | `resolve` | `-4`, `-6` |
583
+ | `reverse-dns` | none beyond the global flags |
584
+ | `interfaces` | none beyond the global flags |
585
+ | `banners` | `--port` (required), `--timeout` (3.0), `--concurrency` (64), `--max-line-bytes` (512), `--farewell`, `-4`, `-6` |
586
+ | `observe-dhcp` | `--interface` (the default route's), `--timeout` (60.0), `--settle` (12.0) |
587
+ | `set-neighbour` | `--interface` (the one whose subnet holds IP) |
588
+ | `remove-neighbour` | `--interface` (every interface the cache lists IP on) |
589
+ | `capabilities` | none beyond the global flags |
590
+ | `info` | none beyond the global flags |
559
591
 
560
592
  `-4` and `-6` are mutually exclusive.
561
593
 
@@ -576,6 +608,7 @@ ipscout find-ip dc:b2:2f:44:34:59 --scan # which addresses hold a MAC
576
608
  ipscout arp-scan --network 192.168.1.0/24 # sweep, then read what was learned
577
609
  ipscout subnet # addressing, gateway, stored DHCP facts
578
610
  ipscout scan-ports 192.168.1.10 --ports 22,80,443,8000-8100
611
+ ipscout banners 192.0.2.0/24 --port 21 --farewell 'QUIT\r\n' # each host's greeting; networks expand
579
612
  ipscout mtu 8.8.8.8
580
613
  ipscout wake aa:bb:cc:dd:ee:ff --broadcast 192.168.1.255
581
614
  ipscout observe-dhcp 02:00:5e:10:00:00 --interface br0 # needs root; last line is the bound address
@@ -747,12 +780,12 @@ print(muted.reached, muted.error)
747
780
 
748
781
  ## Public surface
749
782
 
750
- Everything below is exported from the package root. This table is generated from
751
-
752
- `ipscout.__all__`, so a name here is a name that exists.
783
+ Everything below is exported from the package root, and every name in `ipscout.__all__` is listed
784
+ here or, for the exceptions, in the paragraph below the tables.
753
785
 
754
786
  | Name | Kind | Purpose |
755
787
  |--------------------------------|-----------|-------------------------------------------------------------------|
788
+ | `agrab_banners` | coroutine | The same banner grab, on the event loop. |
756
789
  | `ais_reachable` | coroutine | The same contract, on the event loop. |
757
790
  | `aping` | coroutine | The same, without blocking the event loop. |
758
791
  | `aping_many` | coroutine | Probe many targets concurrently. |
@@ -763,6 +796,7 @@ Everything below is exported from the package root. This table is generated from
763
796
  | `default_gateway` | function | The route used when nothing more specific matches. |
764
797
  | `find_ip_by_mac` | function | Which addresses currently hold a hardware address. |
765
798
  | `get_mac_address` | function | The direct layer-2 address, or None for anything routed. |
799
+ | `grab_banners` | function | The first line each host sends on a TCP port, on one connection. |
766
800
  | `icmp_available` | function | Whether an ICMP probe could be made right now, per family. |
767
801
  | `is_reachable` | function | Total yes-or-no shortcut. Never raises, always falls back to TCP. |
768
802
  | `local_interfaces` | function | Every local network interface. |
@@ -796,6 +830,7 @@ Result models, all frozen Pydantic models with `extra="forbid"`:
796
830
 
797
831
  | Name | Returned by |
798
832
  |----------------------|--------------------------------------------------|
833
+ | `Banner` | grab_banners, agrab_banners |
799
834
  | `CapabilityReport` | the capabilities CLI command |
800
835
  | `Interface` | local_interfaces |
801
836
  | `InterfaceAddress` | the ipv4 and ipv6 fields of Interface |
@@ -817,6 +852,7 @@ Enums, all `str` subclasses so their members are already strings on the wire:
817
852
  | Name | Members |
818
853
  |------------------|--------------------------------------------------------|
819
854
  | `AddressFamily` | IPV4, IPV6 |
855
+ | `BannerState` | GREETED, SILENT, REFUSED, UNREACHABLE |
820
856
  | `CommandName` | one member per CLI subcommand |
821
857
  | `MacScope` | DIRECT, NEXT_HOP, UNKNOWN |
822
858
  | `NeighbourState` | REACHABLE, STALE, PERMANENT, INCOMPLETE, FAILED, OTHER |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ipscout"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "Ping, traceroute and local-network inspection in pure Python - no subprocess, and no admin rights on the default paths"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -68,7 +68,7 @@ dev = [
68
68
  "import-linter>=2.15",
69
69
  "setuptools>=84.2.1",
70
70
  "rtoml>=0.14.0",
71
- "certifi>=2026.10.19", # PYSEC-2026-4320
71
+ "certifi>=2026.10.23", # PYSEC-2026-4320
72
72
  ]
73
73
 
74
74
  [project.scripts]
@@ -263,7 +263,7 @@ root_package = "ipscout"
263
263
  name = "Layered dependencies point one way only"
264
264
  type = "layers"
265
265
  layers = [
266
- "ipscout.scan : ipscout.subnet : ipscout.portscan : ipscout.mtu : ipscout.wol : ipscout.dhcp",
266
+ "ipscout.scan : ipscout.subnet : ipscout.portscan : ipscout.banner : ipscout.mtu : ipscout.wol : ipscout.dhcp",
267
267
  "ipscout.api",
268
268
  "ipscout.traceroute",
269
269
  "ipscout.static_neighbours",
@@ -273,7 +273,7 @@ layers = [
273
273
  "ipscout.service",
274
274
  "ipscout.factory",
275
275
  "ipscout.transport_posix : ipscout.transport_windows : ipscout.transport_tcp : ipscout.interfaces_posix : ipscout.interfaces_windows : ipscout.routes_linux : ipscout.routes_macos : ipscout.routes_windows : ipscout.neighbours_linux : ipscout.neighbours_macos : ipscout.neighbours_windows : ipscout.dhcp_linux : ipscout.dhcp_windows : ipscout.dhcp_macos",
276
- "ipscout.packet : ipscout.resolve : ipscout.ports : ipscout.winapi : ipscout.netlink : ipscout.bsdroute : ipscout.arp : ipscout.tcpsyn : ipscout.pool : ipscout.unixio : ipscout.bootp : ipscout.bpf",
276
+ "ipscout.packet : ipscout.resolve : ipscout.ports : ipscout.winapi : ipscout.netlink : ipscout.bsdroute : ipscout.arp : ipscout.tcpsyn : ipscout.pool : ipscout.sockerr : ipscout.unixio : ipscout.bootp : ipscout.bpf",
277
277
  "ipscout.models",
278
278
  "ipscout.errors",
279
279
  ]
@@ -37,6 +37,7 @@ from .api import (
37
37
  ping,
38
38
  ping_many,
39
39
  )
40
+ from .banner import agrab_banners, grab_banners
40
41
  from .dhcp import (
41
42
  DhcpSession,
42
43
  dhcp_capture_available,
@@ -57,6 +58,8 @@ from .factory import icmp_available
57
58
  from .interfaces import local_interfaces
58
59
  from .models import (
59
60
  AddressFamily,
61
+ Banner,
62
+ BannerState,
60
63
  CapabilityReport,
61
64
  CommandName,
62
65
  Interface,
@@ -92,6 +95,8 @@ from .wol import wake_on_lan
92
95
 
93
96
  __all__ = [
94
97
  "AddressFamily",
98
+ "Banner",
99
+ "BannerState",
95
100
  "CapabilityReport",
96
101
  "CommandName",
97
102
  "DhcpSession",
@@ -121,6 +126,7 @@ __all__ = [
121
126
  "SubnetInfo",
122
127
  "SweepScope",
123
128
  "TraceHop",
129
+ "agrab_banners",
124
130
  "ais_reachable",
125
131
  "aping",
126
132
  "aping_many",
@@ -132,6 +138,7 @@ __all__ = [
132
138
  "dhcp_capture_available",
133
139
  "find_ip_by_mac",
134
140
  "get_mac_address",
141
+ "grab_banners",
135
142
  "icmp_available",
136
143
  "is_reachable",
137
144
  "local_interfaces",
@@ -26,7 +26,7 @@ name = "ipscout"
26
26
  #: Human-readable summary shown in CLI help output.
27
27
  title = "Ping, traceroute and local-network inspection in pure Python - no subprocess, and no admin rights on the default paths"
28
28
  #: Current release version pulled from ``pyproject.toml`` by automation.
29
- version = "2.0.0"
29
+ version = "2.1.0"
30
30
  #: Repository homepage presented to users.
31
31
  homepage = "https://github.com/bitranox/ipscout"
32
32
  #: Author attribution surfaced in CLI output.
@@ -285,12 +285,12 @@ async def aping_many( # noqa: PLR0913 - public API: every knob is keyword-only
285
285
  # A worker pool rather than a semaphore inside a gather over everything:
286
286
  # the semaphore bounds probes in flight while still creating one Task per
287
287
  # target up front, so a sweep of a large subnet holds them all at once.
288
- completed = await gather_bounded(unique, one, concurrency)
289
- results = dict(completed)
290
- if len(results) != len(unique): # pragma: no cover - a lost result is a bug, not a condition
291
- msg = f"sweep returned {len(results)} results for {len(unique)} targets"
288
+ completed = dict(await gather_bounded(unique, one, concurrency))
289
+ if len(completed) != len(unique): # pragma: no cover - a lost result is a bug, not a condition
290
+ msg = f"sweep returned {len(completed)} results for {len(unique)} targets"
292
291
  raise RuntimeError(msg)
293
- return results
292
+ # Rebuilt from the input list: the pool's own order is completion order.
293
+ return {target: completed[target] for target in unique}
294
294
 
295
295
 
296
296
  def ping_many( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
@@ -0,0 +1,287 @@
1
+ """Banner grabs: what each host says first on a TCP port, read on one connection.
2
+
3
+ Contents:
4
+ grab_banners / agrab_banners: The first line many hosts send on one port.
5
+
6
+ Note:
7
+ Everything for one host happens on ONE connection - connect, read the
8
+ greeting, optionally say goodbye, close. That is not a convenience: some
9
+ embedded servers refuse a new connect for a moment after each session
10
+ (measured on a print server: about 0.1 s), so "find the open ports, then
11
+ connect again to read" misses exactly the devices a banner identifies.
12
+
13
+ One deadline per host covers name resolution, the connect and the read
14
+ together, so a caller's ``timeout`` bounds how long any one host can take,
15
+ rather than each phase taking up to that long on its own.
16
+
17
+ A refused connection is reported, not retried. Whether a refusal is worth a
18
+ second try depends on the device, and only the caller knows the device.
19
+
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import socket
27
+ from typing import TYPE_CHECKING, NamedTuple, TypeVar
28
+
29
+ from .errors import IPScoutError
30
+ from .models import AddressFamily, Banner, BannerState
31
+ from .pool import gather_bounded
32
+ from .resolve import resolve_one
33
+ from .sockerr import is_refusal
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Coroutine, Iterable
37
+
38
+ __all__ = ["DEFAULT_CONCURRENCY", "DEFAULT_MAX_LINE_BYTES", "DEFAULT_TIMEOUT", "agrab_banners", "grab_banners"]
39
+
40
+ #: Connections in flight at once. Enough for a /24 in a few rounds while
41
+ #: staying far from any per-process file-handle limit.
42
+ DEFAULT_CONCURRENCY = 64
43
+
44
+ #: Seconds per host. Generous on purpose: with dozens of connects in flight a
45
+ #: present but slow embedded device was measured taking over 2 s to accept.
46
+ DEFAULT_TIMEOUT = 3.0
47
+
48
+ #: A greeting is one short line; this bounds what a hostile or chatty server
49
+ #: can make the caller buffer per host.
50
+ DEFAULT_MAX_LINE_BYTES = 512
51
+
52
+ _MIN_PORT = 1
53
+ _MAX_PORT = 65535
54
+
55
+ _SOCKET_FAMILY = {AddressFamily.IPV4: socket.AF_INET, AddressFamily.IPV6: socket.AF_INET6}
56
+
57
+ _T = TypeVar("_T")
58
+
59
+
60
+ class _Settings(NamedTuple):
61
+ """The per-grab knobs, bundled so every host is grabbed the same way."""
62
+
63
+ port: int
64
+ timeout: float
65
+ max_line_bytes: int
66
+ farewell: bytes | None
67
+ family: AddressFamily | None
68
+
69
+
70
+ def _validate(settings: _Settings, concurrency: int) -> None:
71
+ """Refuse an argument that could never produce a useful grab, before any connect."""
72
+
73
+ if not _MIN_PORT <= settings.port <= _MAX_PORT:
74
+ msg = f"port must be {_MIN_PORT}-{_MAX_PORT}, got {settings.port}"
75
+ raise ValueError(msg)
76
+ if not settings.timeout > 0:
77
+ msg = f"timeout must be greater than 0 seconds, got {settings.timeout}"
78
+ raise ValueError(msg)
79
+ if settings.max_line_bytes < 1:
80
+ msg = f"max_line_bytes must be at least 1, got {settings.max_line_bytes}"
81
+ raise ValueError(msg)
82
+ if concurrency < 1:
83
+ msg = f"concurrency must be at least 1, got {concurrency}"
84
+ raise ValueError(msg)
85
+
86
+
87
+ async def _within(deadline: float, awaitable: Coroutine[object, object, _T]) -> _T:
88
+ """Await ``awaitable`` with whatever is left until ``deadline``.
89
+
90
+ Raises:
91
+ TimeoutError: The deadline has passed or passes while waiting. Raised
92
+ as the builtin on every version, so callers need not also catch
93
+ ``asyncio.TimeoutError``, which is a different class before 3.11.
94
+ """
95
+
96
+ remaining = deadline - asyncio.get_running_loop().time()
97
+ if remaining <= 0:
98
+ awaitable.close()
99
+ raise TimeoutError
100
+ try:
101
+ return await asyncio.wait_for(awaitable, remaining)
102
+ except asyncio.TimeoutError as exc:
103
+ raise TimeoutError from exc
104
+
105
+
106
+ async def _read_line(reader: asyncio.StreamReader, deadline: float, cap: int) -> bytes:
107
+ """Read until a newline, the close, ``cap`` bytes or the deadline - whichever comes first.
108
+
109
+ Whatever arrived by then is the line, terminator excluded: a server that
110
+ writes a partial greeting and hangs up has still said something.
111
+ """
112
+
113
+ received = bytearray()
114
+ while len(received) < cap:
115
+ try:
116
+ chunk = await _within(deadline, reader.read(cap - len(received)))
117
+ except OSError: # TimeoutError included: the deadline ends the line too
118
+ break
119
+ if not chunk:
120
+ break
121
+ end = chunk.find(b"\n")
122
+ if end >= 0:
123
+ received += chunk[:end]
124
+ break
125
+ received += chunk
126
+ return bytes(received)
127
+
128
+
129
+ async def _say_goodbye(writer: asyncio.StreamWriter, farewell: bytes) -> None:
130
+ """Send the farewell; a peer that already left changes nothing about what it said."""
131
+
132
+ with contextlib.suppress(OSError):
133
+ writer.write(farewell)
134
+ await writer.drain()
135
+
136
+
137
+ async def _close(writer: asyncio.StreamWriter) -> None:
138
+ writer.close()
139
+ with contextlib.suppress(OSError, asyncio.TimeoutError, ConnectionError):
140
+ await writer.wait_closed()
141
+
142
+
143
+ async def _converse(host: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, settings: _Settings, deadline: float) -> Banner:
144
+ """Read the greeting on an open connection, say goodbye if asked, and close it."""
145
+
146
+ try:
147
+ raw = await _read_line(reader, deadline, settings.max_line_bytes)
148
+ if raw and settings.farewell:
149
+ await _say_goodbye(writer, settings.farewell)
150
+ finally:
151
+ await _close(writer)
152
+ if not raw:
153
+ return Banner(host=host, port=settings.port, state=BannerState.SILENT)
154
+ line = raw.decode("utf-8", errors="replace").strip()
155
+ return Banner(host=host, port=settings.port, state=BannerState.GREETED, line=line)
156
+
157
+
158
+ async def _grab_one(host: str, settings: _Settings) -> Banner:
159
+ """Grab one host's banner. Every failure is a result, never a raise."""
160
+
161
+ deadline = asyncio.get_running_loop().time() + settings.timeout
162
+
163
+ def unreachable(reason: str) -> Banner:
164
+ return Banner(host=host, port=settings.port, state=BannerState.UNREACHABLE, error=reason)
165
+
166
+ try:
167
+ # In a thread: a slow DNS answer for one name must not stall every other host.
168
+ target = await _within(deadline, asyncio.to_thread(resolve_one, host, family=settings.family))
169
+ family = _SOCKET_FAMILY[target.family]
170
+ reader, writer = await _within(deadline, asyncio.open_connection(target.address, settings.port, family=family))
171
+ except IPScoutError as exc:
172
+ return unreachable(str(exc))
173
+ except TimeoutError:
174
+ return unreachable(f"no connection within {settings.timeout:g} s")
175
+ except OSError as exc:
176
+ if is_refusal(exc):
177
+ return Banner(host=host, port=settings.port, state=BannerState.REFUSED)
178
+ return unreachable(str(exc) or type(exc).__name__)
179
+ return await _converse(host, reader, writer, settings, deadline)
180
+
181
+
182
+ async def agrab_banners( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
183
+ hosts: Iterable[str],
184
+ port: int,
185
+ *,
186
+ timeout: float = DEFAULT_TIMEOUT,
187
+ max_line_bytes: int = DEFAULT_MAX_LINE_BYTES,
188
+ farewell: bytes | None = None,
189
+ concurrency: int = DEFAULT_CONCURRENCY,
190
+ family: AddressFamily | None = None,
191
+ ) -> dict[str, Banner]:
192
+ """Read the first line each host sends on a TCP port, many hosts at once.
193
+
194
+ Args:
195
+ hosts: Hostnames or address literals; a link-local IPv6 address needs
196
+ its ``%zone``. Duplicates are collapsed, since the result is keyed
197
+ by host and a repeat could only overwrite itself.
198
+ port: The TCP port to connect to on every host.
199
+ timeout: Seconds allowed per host for resolving, connecting and
200
+ reading together. A host that connects but has not finished its
201
+ line by then returns what it sent so far.
202
+ max_line_bytes: The most bytes of greeting kept per host. Reading stops
203
+ at the first newline, at the close, or here.
204
+ farewell: Bytes to send after a line was read and before closing, such
205
+ as ``b"QUIT\\r\\n"`` for FTP or SMTP, so the server logs a clean exit
206
+ rather than a dropped session. Sent to any host that said
207
+ something, and never to one that stayed silent.
208
+ concurrency: Connections in flight at once.
209
+ family: Use only IPv4 or IPv6 addresses for every host.
210
+
211
+ Returns:
212
+ A mapping of host, exactly as given, to its :class:`Banner`, in the
213
+ order the hosts were given. Every host has an entry: a name that does
214
+ not resolve is an UNREACHABLE result, never an exception.
215
+
216
+ Raises:
217
+ ValueError: ``port`` is outside 1-65535, or ``timeout``,
218
+ ``max_line_bytes`` or ``concurrency`` is not positive. Raised
219
+ before anything connects.
220
+
221
+ Examples:
222
+ >>> import asyncio
223
+ >>> results = asyncio.run(agrab_banners(["nothing.invalid"], 21))
224
+ >>> results["nothing.invalid"].state
225
+ <BannerState.UNREACHABLE: 'unreachable'>
226
+
227
+ """
228
+
229
+ settings = _Settings(port, timeout, max_line_bytes, farewell, family)
230
+ _validate(settings, concurrency)
231
+ unique = list(dict.fromkeys(hosts))
232
+
233
+ async def one(host: str) -> tuple[str, Banner]:
234
+ # Paired with its host: the pool completes out of order.
235
+ return host, await _grab_one(host, settings)
236
+
237
+ completed = dict(await gather_bounded(unique, one, concurrency))
238
+ return {host: completed[host] for host in unique}
239
+
240
+
241
+ def grab_banners( # noqa: PLR0913 - public API: every knob is keyword-only and independently useful
242
+ hosts: Iterable[str],
243
+ port: int,
244
+ *,
245
+ timeout: float = DEFAULT_TIMEOUT,
246
+ max_line_bytes: int = DEFAULT_MAX_LINE_BYTES,
247
+ farewell: bytes | None = None,
248
+ concurrency: int = DEFAULT_CONCURRENCY,
249
+ family: AddressFamily | None = None,
250
+ ) -> dict[str, Banner]:
251
+ """Read the first line each host sends on a TCP port, from synchronous code.
252
+
253
+ Takes the same arguments as :func:`agrab_banners`.
254
+
255
+ Returns:
256
+ A mapping of host to :class:`Banner`, in the order the hosts were given.
257
+
258
+ Raises:
259
+ ValueError: As :func:`agrab_banners`.
260
+ RuntimeError: Called from inside a running event loop, where it would
261
+ deadlock. Await :func:`agrab_banners` there instead.
262
+
263
+ Examples:
264
+ >>> grab_banners(["nothing.invalid"], 21)["nothing.invalid"].state
265
+ <BannerState.UNREACHABLE: 'unreachable'>
266
+
267
+ """
268
+
269
+ try:
270
+ asyncio.get_running_loop()
271
+ except RuntimeError:
272
+ pass
273
+ else:
274
+ msg = "grab_banners() cannot run inside an event loop; await agrab_banners() instead"
275
+ raise RuntimeError(msg)
276
+
277
+ return asyncio.run(
278
+ agrab_banners(
279
+ hosts,
280
+ port,
281
+ timeout=timeout,
282
+ max_line_bytes=max_line_bytes,
283
+ farewell=farewell,
284
+ concurrency=concurrency,
285
+ family=family,
286
+ )
287
+ )