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.
- {ipscout-2.0.0 → ipscout-2.1.0}/CHANGELOG.md +26 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/PKG-INFO +4 -2
- {ipscout-2.0.0 → ipscout-2.1.0}/README.md +2 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/docs/systemdesign/module_reference.md +7 -2
- {ipscout-2.0.0 → ipscout-2.1.0}/docs/usage.md +54 -18
- {ipscout-2.0.0 → ipscout-2.1.0}/pyproject.toml +4 -4
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__init__.py +7 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__init__conf__.py +1 -1
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/api.py +5 -5
- ipscout-2.1.0/src/ipscout/banner.py +287 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/cli.py +112 -3
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/models.py +59 -1
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/portscan.py +2 -26
- ipscout-2.1.0/src/ipscout/sockerr.py +58 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_tcp.py +9 -6
- ipscout-2.1.0/tests/test_banner.py +393 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_cli.py +108 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_integration_loopback.py +10 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_models.py +38 -1
- ipscout-2.1.0/tests/test_sockerr.py +52 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/.gitignore +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/LICENSE +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/docs/development.md +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/docs/handover-observe-dhcp.md +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/docs/installation.md +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/__main__.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/arp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bootp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bpf.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/bsdroute.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_linux.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_macos.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/dhcp_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/errors.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/factory.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces_posix.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/interfaces_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/leases_linux.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/mtu.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_linux.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_macos.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/neighbours_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/netlink.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/packet.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/pool.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/ports.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/py.typed +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/resolve.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_linux.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_macos.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/routes_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/scan.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/serialize.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/service.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/static_neighbours.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/subnet.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/tcpsyn.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/traceroute.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_posix.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/transport_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/typed_click.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/unixio.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/winapi.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/src/ipscout/wol.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/capture_support.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/conftest.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/dhcp_capture_fixture.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_arp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_bootp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_capability_probe.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_macos.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_dhcp_windows.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_factory.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_family_filter.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_integration_internet.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_interfaces.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_metadata.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_module_entry.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_neighbours.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_observe_dhcp.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_packaging.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_packet.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_platform_markers.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_resolve.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_routes.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_scan_and_subnet.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_service.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_static_neighbours.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_tcp_fallback.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_traceroute.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_transport_posix_logic.py +0 -0
- {ipscout-2.0.0 → ipscout-2.1.0}/tests/test_ttl_capability_probe.py +0 -0
- {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.
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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
|
-
| `
|
|
555
|
-
| `
|
|
556
|
-
| `
|
|
557
|
-
| `
|
|
558
|
-
| `
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
+
)
|