netimps 0.0.0__py3-none-any.whl
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.
- netimps/AGENTS.md +424 -0
- netimps/README.md +189 -0
- netimps/__init__.py +405 -0
- netimps/_dns.py +160 -0
- netimps/_iface_spec.py +96 -0
- netimps/_ifaddrs.py +777 -0
- netimps/_ip.py +385 -0
- netimps/_mac.py +205 -0
- netimps/_multicast.py +197 -0
- netimps/_ping.py +399 -0
- netimps/_retry.py +130 -0
- netimps/_scan.py +245 -0
- netimps/_scheme.py +140 -0
- netimps/_sockets.py +1022 -0
- netimps/_udp.py +189 -0
- netimps/py.typed +0 -0
- netimps-0.0.0.dist-info/METADATA +223 -0
- netimps-0.0.0.dist-info/RECORD +20 -0
- netimps-0.0.0.dist-info/WHEEL +4 -0
- netimps-0.0.0.dist-info/licenses/LICENSE +21 -0
netimps/AGENTS.md
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# `netimps` — public API header
|
|
2
|
+
|
|
3
|
+
Header-file-style reference for the `netimps` package: every `__all__` export
|
|
4
|
+
with its signature, arguments, contract, and gotchas, so this module can be
|
|
5
|
+
consumed without reading its source.
|
|
6
|
+
|
|
7
|
+
Everything is imported from `netimps` directly. The `_`-prefixed submodules
|
|
8
|
+
(`_ip`, `_mac`, `_ifaddrs`, `_sockets`, `_dns`, `_ping`, `_scan`, `_multicast`,
|
|
9
|
+
`_scheme`, `_retry`, `_udp`, `_iface_spec`) are implementation detail —
|
|
10
|
+
**do not import them**.
|
|
11
|
+
|
|
12
|
+
**This file documents using the library**, and ships inside the package, so it
|
|
13
|
+
is self-contained: it references nothing outside the installed distribution.
|
|
14
|
+
`README.md` ships alongside it as the overview -- read either with
|
|
15
|
+
`importlib.resources.files("netimps")`. Development documentation (building,
|
|
16
|
+
testing, releasing) is not shipped; it lives with the source at
|
|
17
|
+
<https://github.com/jose-pr/netimps>.
|
|
18
|
+
|
|
19
|
+
`netimps.__version__` — the package version string (currently `"0.0.0"`).
|
|
20
|
+
|
|
21
|
+
## Argument naming
|
|
22
|
+
|
|
23
|
+
The package is consistent about what the first argument means:
|
|
24
|
+
|
|
25
|
+
| Name | Meaning | Examples |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `dst` | where traffic is **sent** | `ping`, `tcp_check`, `wait_for_port`, `get_route`, `hop_count`, `discover_mtu` |
|
|
28
|
+
| `src` | where traffic is **sent from** | `ping(src=)`, `get_free_port(src=)`, `discover_mtu(src=)` |
|
|
29
|
+
| `host` / `network` | the thing being **examined** | `scan_ports(host)`, `scan_hosts(network)` |
|
|
30
|
+
| `address` / `ip` | an address being **classified** (no DNS) | `get_ip`, `interface_for`, `is_multicast`, `is_link_scoped` |
|
|
31
|
+
|
|
32
|
+
`dst`/`src` are abbreviated symmetrically, matching packet-header convention.
|
|
33
|
+
A `dst` accepts a hostname; an `address` does not.
|
|
34
|
+
|
|
35
|
+
## Types vs parsing — read this first
|
|
36
|
+
|
|
37
|
+
The noun names are **types you annotate with**; you turn values into them with
|
|
38
|
+
one function:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
def route(dst: netimps.IPAddress, via: netimps.IPNetwork) -> None: ... # type
|
|
42
|
+
|
|
43
|
+
addr = parse("10.0.0.5") # -> IPv4Address
|
|
44
|
+
net = parse("10.0.0.5/24", IPNetwork) # -> IPv4Network('10.0.0.0/24')
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The union aliases are **not callable** — `IPAddress("10.0.0.5")` is a
|
|
48
|
+
`TypeError`. Use `parse`.
|
|
49
|
+
|
|
50
|
+
## Type aliases
|
|
51
|
+
|
|
52
|
+
| Name | Meaning |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `IPAddress` | `IPv4Address \| IPv6Address` |
|
|
55
|
+
| `IPInterface` | `IPv4Interface \| IPv6Interface` (address + prefix) |
|
|
56
|
+
| `IPNetwork` | `IPv4Network \| IPv6Network` |
|
|
57
|
+
| `IPAddressLike` | anything accepted *as input* for an address |
|
|
58
|
+
| `IPNetworkLike` | anything accepted *as input* for a network |
|
|
59
|
+
| `MACLike` | `str \| int \| bytes \| MACAddress` |
|
|
60
|
+
|
|
61
|
+
Plus the stdlib concretes re-exported so callers need not import `ipaddress`:
|
|
62
|
+
`IPv4Address`, `IPv4Interface`, `IPv4Network`, `IPv6Address`, `IPv6Interface`,
|
|
63
|
+
`IPv6Network`.
|
|
64
|
+
|
|
65
|
+
**The `*Like` aliases are input-only** and are rejected in a `type` position —
|
|
66
|
+
they describe what goes in, not what to build.
|
|
67
|
+
|
|
68
|
+
## Parsing
|
|
69
|
+
|
|
70
|
+
- **`parse(value, type=IPAddress, **kwargs)`** — build `type` from `value`,
|
|
71
|
+
raising on bad input. `type` is a union alias, a concrete class, or any
|
|
72
|
+
callable. Extra `kwargs` pass to the underlying builder.
|
|
73
|
+
- **`try_parse(value, type=IPAddress, default=None, **kwargs)`** — same, but
|
|
74
|
+
returns `default` instead of raising.
|
|
75
|
+
- **`is_valid(value, type=IPAddress, **kwargs)`** — same, returning `bool`.
|
|
76
|
+
|
|
77
|
+
All three spell the second argument `type`, so it works positionally or by
|
|
78
|
+
keyword. Key behaviours:
|
|
79
|
+
|
|
80
|
+
- **Every type accepts the full stdlib input range** — `str`, `int`, packed
|
|
81
|
+
`bytes`, or an existing object — because the builders are `ipaddress.ip_*`,
|
|
82
|
+
not the concrete constructors.
|
|
83
|
+
- **Unions accept either family; concrete types are strict.**
|
|
84
|
+
`parse("::1", IPAddress)` works; `parse("::1", IPv4Address)` raises, because
|
|
85
|
+
asking for v4 and receiving v6 would defeat the request.
|
|
86
|
+
- **Networks are non-strict by default**, unlike the stdlib:
|
|
87
|
+
`parse("10.0.0.5/24", IPNetwork)` normalises to `10.0.0.0/24` instead of
|
|
88
|
+
raising. Pass `strict=True` for stdlib behaviour.
|
|
89
|
+
- **Only `ValueError`/`TypeError` count as "invalid".** Anything else (an
|
|
90
|
+
`OSError` from a network-touching builder, a bug in it) propagates rather
|
|
91
|
+
than being disguised as a rejected value.
|
|
92
|
+
- An unusable `type` raises `TypeError` **even from `try_parse`** — a caller
|
|
93
|
+
bug is not a rejected value.
|
|
94
|
+
|
|
95
|
+
> **Gotcha:** `is_valid` uses an internal sentinel rather than testing
|
|
96
|
+
> `try_parse(...) is not None`, so a builder that legitimately returns `None`
|
|
97
|
+
> for valid input still counts as valid. Do not "simplify" that away.
|
|
98
|
+
|
|
99
|
+
## `MACAddress`
|
|
100
|
+
|
|
101
|
+
**`MACAddress(value)`** — an IEEE 802 hardware address. Accepts colon
|
|
102
|
+
(`AA:BB:CC:DD:EE:FF`), hyphen, dot/Cisco (`aabb.ccdd.eeff`) or bare
|
|
103
|
+
(`AABBCCDDEEFF`) text, a 48-bit `int`, 6 raw `bytes`, or another `MACAddress`.
|
|
104
|
+
|
|
105
|
+
Normalised to lowercase, compared and hashed by canonical bytes, so it works as
|
|
106
|
+
a dict key and two values differing only in parsed case are equal.
|
|
107
|
+
|
|
108
|
+
| Member | Meaning |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `.as_str(sep=":", upper=False)` | render with any separator; `sep=""` for bare form |
|
|
111
|
+
| `.packed` | the 6 raw bytes |
|
|
112
|
+
| `.oui` | 3-byte vendor prefix |
|
|
113
|
+
| `.is_multicast` | group bit (low bit of octet 0) |
|
|
114
|
+
| `.is_local` / `.is_universal` | the U/L bit |
|
|
115
|
+
| `int(mac)`, `str(mac)` | integer / colon form |
|
|
116
|
+
| `<`, `<=`, `>`, `>=` | ordering, so MACs sort |
|
|
117
|
+
| `MACAddress.is_valid(v)` / `.try_parse(v)` | classmethods; the type-local spelling |
|
|
118
|
+
|
|
119
|
+
- **Not a `bytes` subclass** — deliberately, matching how `ipaddress` models
|
|
120
|
+
addresses. Use `.packed` at wire boundaries.
|
|
121
|
+
- Case is presentational only: `upper=True` never affects equality or hashing.
|
|
122
|
+
- `.is_local` means *locally administered* (VMs, containers, MAC randomisation),
|
|
123
|
+
so such addresses are **not stable identifiers**.
|
|
124
|
+
- The classmethods are `classmethod`, not `staticmethod`, so a subclass
|
|
125
|
+
validates against itself.
|
|
126
|
+
|
|
127
|
+
## Interface discovery
|
|
128
|
+
|
|
129
|
+
**`get_interfaces(raw=False) -> List[Interface]`** — adapter names, MACs, MTU
|
|
130
|
+
and **real prefix lengths**, via `ctypes` bindings to `getifaddrs(3)` (POSIX)
|
|
131
|
+
and `GetAdaptersAddresses` (Windows). **No third-party dependency**; `ifaddr`
|
|
132
|
+
is deliberately not used.
|
|
133
|
+
|
|
134
|
+
**`Interface`** — normalised identically across platforms:
|
|
135
|
+
|
|
136
|
+
| Attribute | Meaning |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `.name` | human-usable name (`eth0`, `en0`, Windows *friendly* name — never a GUID) |
|
|
139
|
+
| `.index` | `if_nametoindex` value, `0` if unknown |
|
|
140
|
+
| `.mac` | `MACAddress` or `None` |
|
|
141
|
+
| `.ips` | every address with its real prefix |
|
|
142
|
+
| `.ipv4` / `.ipv6` | the split views |
|
|
143
|
+
| `.mtu` | link MTU in bytes, or `None` |
|
|
144
|
+
| `.primary_ip(ipv6=False, loopback_ok=True)` | pick **one** entry from `.ips` (non-loopback preferred), or `None` |
|
|
145
|
+
| `.is_loopback` | **computed from the addresses, not the name** |
|
|
146
|
+
| `.raw` | `None` unless `raw=True`; platform-specific leftovers |
|
|
147
|
+
|
|
148
|
+
- **`is_loopback` never matches on names.** `lo`, `lo0` and
|
|
149
|
+
`Loopback Pseudo-Interface 1` share no spelling; `127.0.0.0/8` and `::1` do.
|
|
150
|
+
- **`.raw` is not portable** and sits outside the stability guarantee — the
|
|
151
|
+
escape hatch for adapter GUIDs, `IFF_*` flags, WMI correlation.
|
|
152
|
+
- **Never raises for enumeration failure.** If the native call is unavailable it
|
|
153
|
+
degrades to hostname resolution, where **prefixes are fiction** (every address
|
|
154
|
+
becomes `/32` or `/128` under an interface named `"<unknown>"`). Check
|
|
155
|
+
`iface.name == "<unknown>"` to detect it.
|
|
156
|
+
- **`primary_ip()` is a selection, not "the" address** — an adapter routinely
|
|
157
|
+
has several. It returns the **same element type as `.ips`** (an
|
|
158
|
+
`ip_interface`, carrying the prefix) and the result *is* one of them; use
|
|
159
|
+
`.ip` for the bare address that socket options take.
|
|
160
|
+
|
|
161
|
+
**`iter_addresses(interfaces=None, family=None)`** — the flattened
|
|
162
|
+
`(interface, address)` view, yielded once per address rather than per adapter,
|
|
163
|
+
for consumers that filter or act per address. The full `Interface` comes along,
|
|
164
|
+
so nothing is lost. Pass an existing enumeration in a loop; it is a syscall.
|
|
165
|
+
|
|
166
|
+
## Address and network helpers
|
|
167
|
+
|
|
168
|
+
- **`get_ip(address) -> IPAddress | None`** — literal *or hostname* to an
|
|
169
|
+
address. **May block on DNS**, unlike `try_parse`, which never touches the
|
|
170
|
+
network.
|
|
171
|
+
- **`is_link_scoped(ip) -> bool`** — loopback (host scope) or link-local (link
|
|
172
|
+
scope): confined to this host or link. **Not "is private"** — RFC 1918 ranges
|
|
173
|
+
are globally scoped and return `False`.
|
|
174
|
+
- **`collapse(networks) -> List[IPNetwork]`** — merge adjacent/overlapping
|
|
175
|
+
networks into the minimal equivalent list. Mixed families collapse
|
|
176
|
+
independently.
|
|
177
|
+
- **`subtract(networks, remove) -> List[IPNetwork]`** — set difference, which
|
|
178
|
+
`ipaddress` omits (it ships `collapse_addresses` but nothing to punch holes).
|
|
179
|
+
Result is collapsed.
|
|
180
|
+
- **`normalize_host(text, default_port=None) -> (host, port)`** — split
|
|
181
|
+
`host:port`, handling IPv6 brackets. **`"::1"` stays an address**, never host
|
|
182
|
+
`"::"` port `1` — the mistake hand-rolled splitters make. Only a bracketed v6
|
|
183
|
+
address may carry a port; scope ids are preserved.
|
|
184
|
+
|
|
185
|
+
## Scheme ↔ port registry
|
|
186
|
+
|
|
187
|
+
- **`get_default_port(scheme) -> int | None`** — built-in table (~30 entries,
|
|
188
|
+
including the socks variants absent from `/etc/services`), then
|
|
189
|
+
`getservbyname`. Case-insensitive.
|
|
190
|
+
- **`get_default_scheme(port) -> str | None`** — the inverse, then
|
|
191
|
+
`getservbyport`.
|
|
192
|
+
- **`register_port(scheme, port, canonical=False)`** — extend or override.
|
|
193
|
+
|
|
194
|
+
Where several schemes share a port (1080 → socks/socks4/socks5) the **canonical**
|
|
195
|
+
one is returned. Registering an alias does not steal that slot unless
|
|
196
|
+
`canonical=True`.
|
|
197
|
+
|
|
198
|
+
## DNS
|
|
199
|
+
|
|
200
|
+
**`resolve(query, rdtype="a", ns=None, timeout=5.0, port=53, tcp=False)`**
|
|
201
|
+
|
|
202
|
+
Returns a `list`, **empty on any genuine lookup failure** (NXDOMAIN, no answer,
|
|
203
|
+
all nameservers failed, timeout) — never `None`, so `if result:` and
|
|
204
|
+
`result[0]` are safe.
|
|
205
|
+
|
|
206
|
+
**Records are native types**: `A`/`AAAA` are `ipaddress` objects, everything
|
|
207
|
+
else is `str` with the trailing root dot stripped and TXT strings unquoted.
|
|
208
|
+
|
|
209
|
+
- **A malformed query or unknown record type raises `ValueError`** — a caller
|
|
210
|
+
bug, not a DNS result. A broad `except` here would make a typo'd record type
|
|
211
|
+
indistinguishable from "no such record".
|
|
212
|
+
- `timeout` bounds the **whole resolution including retries**, so a list of
|
|
213
|
+
dead nameservers cannot run past it.
|
|
214
|
+
- Requires `dnspython` — the package's only runtime dependency.
|
|
215
|
+
|
|
216
|
+
## Reachability
|
|
217
|
+
|
|
218
|
+
**`ping(dst, tries=1, timeout=1.0, ipv6=None, src=None, size=None, ttl=None, dont_fragment=False, method="icmp", port=None) -> PingResult`**
|
|
219
|
+
|
|
220
|
+
`PingResult` is **truthy on success** and compares equal to `bool`, so
|
|
221
|
+
`if ping(host):` and `== True` keep working, while carrying `.ok`, `.rtt_ms`,
|
|
222
|
+
`.ttl`, `.source`, `.attempts`.
|
|
223
|
+
|
|
224
|
+
| Argument | Notes |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `src` | `Interface`, address, **MAC**, adapter name or string. A MAC is resolved to the adapter holding it. |
|
|
227
|
+
| `size` | ICMP payload bytes. The wire packet is **28 bytes larger** (20 IP + 8 ICMP). |
|
|
228
|
+
| `ttl` | initial hop limit — `-i` on Windows, `-t` on POSIX (the letters are **swapped**). |
|
|
229
|
+
| `dont_fragment` | DF bit. With `size`, the manual MTU probe: largest passing `size` + 28 = path MTU. Ignored on macOS/BSD. |
|
|
230
|
+
| `method` | `"icmp"` (default), `"tcp"` or `"udp"`. The latter two reach hosts through firewalls that drop echo. |
|
|
231
|
+
| `port` | required for `tcp`/`udp`, ignored for ICMP. |
|
|
232
|
+
|
|
233
|
+
**All three methods ask "is the *host* up?"** — so a TCP refusal counts as
|
|
234
|
+
success (the RST proves something answered), as does an ICMP port-unreachable
|
|
235
|
+
for UDP. Use `tcp_check` for "is the *service* up?", where a refusal is a
|
|
236
|
+
failure. `tcp` and `udp` also report `rtt_ms`; only ICMP reports `ttl`.
|
|
237
|
+
|
|
238
|
+
- **`ttl` behaves identically on every platform.** Windows `ping` exits `0` for
|
|
239
|
+
"TTL expired in transit", so the reply address is verified rather than
|
|
240
|
+
trusting the exit code. Locale-independent — it matches on addresses, never
|
|
241
|
+
prose.
|
|
242
|
+
- An unusable `src` (unknown MAC, adapter with no address, foreign address)
|
|
243
|
+
gives a falsy result — it **never silently falls back** to the default route.
|
|
244
|
+
- Never raises: missing binary, hung subprocess and non-zero exit are all falsy.
|
|
245
|
+
- **ICMP echo is not "is the host up"** — most cloud firewalls drop it. Prefer
|
|
246
|
+
`tcp_check`.
|
|
247
|
+
|
|
248
|
+
## Socket helpers
|
|
249
|
+
|
|
250
|
+
- **`bind(address="", port=0, *, family, kind, reuse_address=True, reuse_port=False, broadcast=False, interface=None, options=(), listen=None)`**
|
|
251
|
+
— create, configure and bind in one call. `interface` accepts the usual union
|
|
252
|
+
(`Interface`, MAC, adapter name, address) and **raises** if unresolvable
|
|
253
|
+
rather than silently binding the wildcard. `reuse_port` is a **no-op where
|
|
254
|
+
`SO_REUSEPORT` does not exist** (Windows), not an error. The socket is closed
|
|
255
|
+
before any exception propagates, so a failed call leaks nothing.
|
|
256
|
+
- **`bind_error_hint(exc, port=None) -> str | None`** — an actionable sentence
|
|
257
|
+
for a bind failure, recognising POSIX errnos *and* Windows `10013`/`10048`.
|
|
258
|
+
Returns `None` for anything unrecognised, so the caller keeps the original
|
|
259
|
+
error. **Does not raise** — what to do with a failure is the caller's call.
|
|
260
|
+
- **`interface_for(address, strict=True) -> Interface | None`** — reverse
|
|
261
|
+
lookup. `strict=False` synthesizes a host-route interface named
|
|
262
|
+
`"<unknown>"` instead of returning `None`.
|
|
263
|
+
- **`get_source_ip(dst="8.8.8.8", port=80)`** — which local address the kernel
|
|
264
|
+
would use to reach `dst`. **Sends no packets.** The answer depends on
|
|
265
|
+
`dst`: with a VPN up, a public probe returns the tunnel address and a LAN
|
|
266
|
+
probe the physical one. Correct where hostname resolution picks a VM adapter.
|
|
267
|
+
- **`get_free_port(src="127.0.0.1", family=AF_INET) -> int`** — bind port 0 and
|
|
268
|
+
read it back. **Inherently racy** — the port frees the instant it returns; if
|
|
269
|
+
you can, bind port 0 in the server itself instead. `SO_REUSEADDR` is
|
|
270
|
+
deliberately *not* set (it would hand back a `TIME_WAIT` port).
|
|
271
|
+
- **`tcp_check(dst, port, timeout=3.0) -> bool`** — the honest reachability
|
|
272
|
+
test. Never raises. Proves the handshake completed, not that the service is
|
|
273
|
+
healthy; a filtered port is indistinguishable from a closed one.
|
|
274
|
+
- **`wait_for_port(dst, port, timeout=30.0, interval=0.1, connect_timeout=None)`**
|
|
275
|
+
— poll until it answers. Backs off to 1s; honours the overall deadline even
|
|
276
|
+
when individual connects block.
|
|
277
|
+
|
|
278
|
+
## Routing, hops and MTU
|
|
279
|
+
|
|
280
|
+
- **`get_route(dst="8.8.8.8") -> Route`** — `.source`, `.gateway`,
|
|
281
|
+
`.interface_index`, `.on_link`. **First hop only, deliberately** — that is
|
|
282
|
+
available unprivileged everywhere, unlike the full path. Never raises;
|
|
283
|
+
unknown pieces are `None`/`0`. The gateway resolves on Windows and Linux only.
|
|
284
|
+
- **`hop_count(dst, max_hops=30, timeout=1.0, allow_traceroute=True)`** — uses
|
|
285
|
+
raw-socket probes when permitted, otherwise drives the system
|
|
286
|
+
`traceroute`/`tracert`, so it **works unprivileged**. Only the hop number and
|
|
287
|
+
destination address are parsed, never localised prose.
|
|
288
|
+
`allow_traceroute=False` requires the in-process path and raises
|
|
289
|
+
`PermissionError` instead. **`None` means "no answer", never "unreachable"** —
|
|
290
|
+
firewalls routinely drop ICMP even for an elevated process.
|
|
291
|
+
- **`discover_mtu(dst, low=576, high=9000, timeout=1.0, src=None, port=80, probe=True, method="icmp", **ping_kwargs)`**
|
|
292
|
+
— **measures** the path MTU by binary-searching probes, so packets really
|
|
293
|
+
traverse the path. Returns the MTU **including headers**, comparable with
|
|
294
|
+
`Interface.mtu`. `None` means the destination never answered —
|
|
295
|
+
indistinguishable from "every size was too big".
|
|
296
|
+
|
|
297
|
+
| `method` | How |
|
|
298
|
+
| --- | --- |
|
|
299
|
+
| `"icmp"` | DF-flagged echo. Default; works anywhere `ping` does. |
|
|
300
|
+
| `"udp"` | Datagrams of growing size to `port`. Needs something there that replies. Measures what a **UDP application** can actually push, which a middlebox may cap below the ICMP figure. |
|
|
301
|
+
| `"tcp"` | **Does not probe** — TCP is a stream and the kernel segments it, so a large `send()` becomes many packets. Reads the negotiated MSS and adds the header back. |
|
|
302
|
+
|
|
303
|
+
Extra `**ping_kwargs` reach `ping` for the ICMP method (`ipv6=`, `tries=`).
|
|
304
|
+
`size` and `dont_fragment` are what the search varies, so passing them raises.
|
|
305
|
+
- **`get_tcp_mss(dst, port, timeout=3.0) -> int | None`** — the negotiated TCP
|
|
306
|
+
maximum segment size. Opens a real connection to read it. A reduced value
|
|
307
|
+
signals a tunnel shrinking the path; `None` where `TCP_MAXSEG` is
|
|
308
|
+
unavailable.
|
|
309
|
+
- **`get_pmtu(dst, port=80) -> int | None`** — a **lookup**, not a
|
|
310
|
+
measurement: reads the `IP_MTU` the kernel has *already* learned, and sends
|
|
311
|
+
nothing. Usually `None`, because the kernel only knows a path MTU once prior
|
|
312
|
+
traffic forced it to learn one; **always `None` on Windows**, which has no
|
|
313
|
+
`IP_MTU`. `discover_mtu(..., probe=False)` is exactly this.
|
|
314
|
+
|
|
315
|
+
> **These answer different questions.** On one real host the local link was
|
|
316
|
+
> 9000, `get_pmtu` returned `None`, and `discover_mtu` found the true 1500 — a
|
|
317
|
+
> bottleneck several hops away that nothing local could reveal. Use
|
|
318
|
+
> `Interface.mtu` for the local link, `discover_mtu` for the path.
|
|
319
|
+
>
|
|
320
|
+
> **Header sizes are family-aware.** IPv4 overhead is 20+8 (ICMP/UDP) or 20+20
|
|
321
|
+
> (TCP); IPv6 is 40+8 and 40+20. Assuming IPv4 on a v6 path under-reports by
|
|
322
|
+
> exactly 20 bytes.
|
|
323
|
+
|
|
324
|
+
## Scanning
|
|
325
|
+
|
|
326
|
+
- **`scan_ports(host, ports="common", timeout=1.0, workers=100) -> List[int]`**
|
|
327
|
+
- **`scan_hosts(network, port=None, ports=None, timeout=1.0, workers=100)`** —
|
|
328
|
+
returns `[(address, [open_ports]), ...]`, hosts with nothing open omitted.
|
|
329
|
+
|
|
330
|
+
`ports` accepts a **`PORT_RANGES` name** (`"common"`, `"well-known"`, `"all"`),
|
|
331
|
+
a **scheme name** resolved via `get_default_port` (`"https"` → 443), a number, a
|
|
332
|
+
numeric string, or any iterable mixing those. Range names win over scheme names
|
|
333
|
+
where they collide.
|
|
334
|
+
|
|
335
|
+
- **`scan_hosts` refuses anything larger than /16** (IPv6 /112): a /8 sweep is
|
|
336
|
+
16M addresses, a mistake rather than an intention.
|
|
337
|
+
- A **TCP** sweep, so a host answering on none of the probed ports does not
|
|
338
|
+
appear — it is not ARP/ICMP discovery, and a firewalled host is
|
|
339
|
+
indistinguishable from an absent one.
|
|
340
|
+
- Ordinary full connects: **no SYN/stealth scanning, no fingerprinting**.
|
|
341
|
+
Connections are logged by the target like any other. Use on hosts you are
|
|
342
|
+
responsible for.
|
|
343
|
+
|
|
344
|
+
## Multicast
|
|
345
|
+
|
|
346
|
+
- **`multicast_socket(group=None, port=0, interface=None, ttl=1, loop=True, bind=True, reuse=True)`**
|
|
347
|
+
— a UDP socket configured and joined in one call. `group=None` gives a
|
|
348
|
+
send-only socket.
|
|
349
|
+
- **`join_group(sock, group, interface=None)`** / **`leave_group(...)`**
|
|
350
|
+
- **`is_multicast(address) -> bool`** — `224.0.0.0/4` or `ff00::/8`; never raises.
|
|
351
|
+
|
|
352
|
+
The failure modes this exists to prevent are all **silent** — the socket binds,
|
|
353
|
+
receives nothing, and looks fine:
|
|
354
|
+
|
|
355
|
+
- Binds to `""`, not the group address: **binding to the group fails on Windows**.
|
|
356
|
+
- `SO_REUSEPORT` **does not exist on Windows** and is skipped there rather than
|
|
357
|
+
raising.
|
|
358
|
+
- **`ttl=1` by default**, keeping traffic on the local link; raise it
|
|
359
|
+
deliberately.
|
|
360
|
+
- `interface` accepts an `Interface`, MAC, adapter name or address, and pins
|
|
361
|
+
*both* send and receive. Without it the kernel picks by routing table, which
|
|
362
|
+
on a multi-homed host is regularly the wrong adapter. An unknown interface
|
|
363
|
+
**raises** rather than falling back.
|
|
364
|
+
|
|
365
|
+
## UDP with arrival interface
|
|
366
|
+
|
|
367
|
+
**`UdpEndpoint(sock, pktinfo=True)`** — wraps a bound UDP socket so each
|
|
368
|
+
datagram reports which interface it arrived on, via `IP_PKTINFO`. Essential for
|
|
369
|
+
broadcast protocols, where a wildcard-bound server otherwise cannot tell which
|
|
370
|
+
network a request came from.
|
|
371
|
+
|
|
372
|
+
`recv(bufsize, resolve_interface=True) -> Datagram`, with `.data`, `.sender`,
|
|
373
|
+
`.local_address`, `.interface_index` and `.interface`.
|
|
374
|
+
`send(data, address, port, source=None)` pins the outgoing interface.
|
|
375
|
+
|
|
376
|
+
- **Degrades rather than failing.** `recvmsg` does not exist on Windows and
|
|
377
|
+
`IP_PKTINFO` is not universal; there the interface fields are simply empty.
|
|
378
|
+
Check `.supports_pktinfo` to know which mode you are in.
|
|
379
|
+
- Pass `resolve_interface=False` in a hot loop and use `.interface_index` —
|
|
380
|
+
enumeration is a syscall.
|
|
381
|
+
- Wraps rather than subclasses the socket; the raw one stays on `.socket`.
|
|
382
|
+
|
|
383
|
+
## Retry
|
|
384
|
+
|
|
385
|
+
**`retry(func, attempts=3, delay=0.5, multiplier=2.0, max_delay=30.0, jitter=0.1, retryable=(OSError,), on_retry=None)`**
|
|
386
|
+
|
|
387
|
+
Calls `func()`, retrying transient failures with exponential backoff. Returns
|
|
388
|
+
whatever `func` returns; if every attempt fails **the last exception is
|
|
389
|
+
re-raised unwrapped**, so the traceback still points at the real problem.
|
|
390
|
+
|
|
391
|
+
- **Only `OSError` is retried by default** — that covers the socket family. A
|
|
392
|
+
`ValueError` means the call is malformed and will fail identically, so it
|
|
393
|
+
propagates immediately.
|
|
394
|
+
- `attempts` counts *total* calls: `attempts=1` calls once and never sleeps.
|
|
395
|
+
- `jitter` spreads retries so simultaneous failures do not resynchronise into a
|
|
396
|
+
thundering herd. Applied **after** the cap and only ever shortens, so
|
|
397
|
+
`max_delay` is a real ceiling.
|
|
398
|
+
- `on_retry(attempt, exc, next_delay)` is the logging hook; this logs nothing
|
|
399
|
+
itself.
|
|
400
|
+
- Synchronous — it blocks. For async, drive **`backoff_delays(...)`** from your
|
|
401
|
+
own loop; it yields the same schedule.
|
|
402
|
+
|
|
403
|
+
## Host
|
|
404
|
+
|
|
405
|
+
**`Host(value)`** — a host named by either an address or a hostname.
|
|
406
|
+
|
|
407
|
+
`str(host)` is **always the original text**, so a URL can still be rebuilt when
|
|
408
|
+
resolution fails — the case a bare `get_ip()` handles badly, since it returns
|
|
409
|
+
`None` and loses the name.
|
|
410
|
+
|
|
411
|
+
- `.is_address` — already a literal, no DNS needed.
|
|
412
|
+
- `.ip(refresh=False)` — resolve to an address or `None`. **Cached, including
|
|
413
|
+
failure**, since the common use is several lookups on one object; pass
|
|
414
|
+
`refresh=True` to retry.
|
|
415
|
+
- Compares equal to a plain `str`, and hashes by its text.
|
|
416
|
+
|
|
417
|
+
## Constants
|
|
418
|
+
|
|
419
|
+
- **`HOST_DN`** — `platform.node()`, captured **at import time** (a later
|
|
420
|
+
hostname change is not reflected).
|
|
421
|
+
- **`PORT_RANGES`** — `{"well-known", "common", "all"}` port tuples.
|
|
422
|
+
- **`APIPA`** (`169.254.0.0/16`), **`LOOPBACK_V4`** (`127.0.0.0/8`),
|
|
423
|
+
**`LOOPBACK_V6`** (`::1/128`), **`LINK_LOCAL_V6`** (`fe80::/10`) — named
|
|
424
|
+
networks, so callers stop spelling the literals out.
|
netimps/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# netimps
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/netimps/)
|
|
4
|
+
[](https://pypi.org/project/netimps/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/jose-pr/netimps/actions/workflows/test.yml)
|
|
7
|
+
|
|
8
|
+
**The network utilities every tool ends up rewriting** — interface discovery,
|
|
9
|
+
"which is my IP", reachability checks, CIDR maths, host:port parsing, DNS,
|
|
10
|
+
ping, scanning and multicast — as one typed, flat-import library.
|
|
11
|
+
|
|
12
|
+
Built on the standard library: the only runtime dependency is `dnspython`, and
|
|
13
|
+
only `resolve()` uses it. Interface enumeration, routing and multicast are
|
|
14
|
+
`ctypes` bindings to the platform's own APIs, so there is nothing to compile
|
|
15
|
+
and no wheel to miss for your platform.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Interface discovery, no dependencies** — `get_interfaces()` gives adapter
|
|
20
|
+
names, MACs, MTU and **real prefix lengths** on Linux, macOS/BSD and Windows,
|
|
21
|
+
via `getifaddrs(3)` / `GetAdaptersAddresses`. No `ifaddr` required.
|
|
22
|
+
- **One parsing entry point** — `parse(value, type)` with non-raising
|
|
23
|
+
`try_parse` and boolean `is_valid` siblings, all typed so a checker narrows
|
|
24
|
+
the result.
|
|
25
|
+
- **`MACAddress`** — colon/hyphen/dot/bare plus `int`/`bytes`, hashable and
|
|
26
|
+
ordered, with `.oui`, `.is_multicast`, `.is_local` and case-selectable
|
|
27
|
+
rendering.
|
|
28
|
+
- **The socket helpers everyone rewrites** — `get_source_ip`, `get_free_port`,
|
|
29
|
+
`tcp_check`, `wait_for_port`.
|
|
30
|
+
- **Routing and MTU** — `get_route` (first hop, unprivileged), `hop_count`
|
|
31
|
+
(raw sockets *or* traceroute fallback), `discover_mtu` / `get_pmtu`, `Interface.mtu`.
|
|
32
|
+
- **CIDR set maths** — `collapse` and `subtract`, the latter missing from
|
|
33
|
+
`ipaddress` entirely.
|
|
34
|
+
- **`normalize_host`** — `host:port` splitting that gets IPv6 brackets right.
|
|
35
|
+
- **Scanning** — concurrent `scan_ports` / `scan_hosts`.
|
|
36
|
+
- **Socket setup** — `bind()` with the options named, `UdpEndpoint` for UDP
|
|
37
|
+
servers that need to know which interface a datagram arrived on, and
|
|
38
|
+
`retry()` for the backoff loop everyone writes without jitter.
|
|
39
|
+
- **Multicast** — `multicast_socket` handling the join dance whose failure
|
|
40
|
+
modes are otherwise silent.
|
|
41
|
+
- **DNS and ping** — `resolve()` returning native types; `ping()` returning
|
|
42
|
+
round-trip time and TTL, not just a boolean.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install netimps
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.9+.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import netimps
|
|
56
|
+
from netimps import IPAddress, IPNetwork, MACAddress, parse
|
|
57
|
+
|
|
58
|
+
# Interfaces: names, MACs, MTU and real prefixes on every OS
|
|
59
|
+
for iface in netimps.get_interfaces():
|
|
60
|
+
print(iface.name, iface.mac, iface.mtu, [str(ip) for ip in iface.ips])
|
|
61
|
+
# 'Wi-Fi' 00:00:5e:00:53:01 1500 ['192.0.2.10/24', 'fe80::.../64']
|
|
62
|
+
|
|
63
|
+
# Types annotate; parse builds
|
|
64
|
+
def route(dst: IPAddress, via: IPNetwork) -> None: ...
|
|
65
|
+
|
|
66
|
+
parse("10.0.0.5") # IPv4Address('10.0.0.5')
|
|
67
|
+
parse("10.0.0.5/24", IPNetwork) # IPv4Network('10.0.0.0/24')
|
|
68
|
+
netimps.try_parse("nope", IPAddress) # None
|
|
69
|
+
netimps.is_valid("::1", IPAddress) # True
|
|
70
|
+
|
|
71
|
+
# Which of my addresses actually reaches that host?
|
|
72
|
+
netimps.get_source_ip("8.8.8.8") # IPv4Address('192.0.2.10')
|
|
73
|
+
netimps.get_route("8.8.8.8").gateway # IPv4Address('192.0.2.1')
|
|
74
|
+
|
|
75
|
+
# Honest reachability, and waiting for a service
|
|
76
|
+
netimps.tcp_check("example.com", 443) # True
|
|
77
|
+
netimps.wait_for_port("localhost", 5432, timeout=60)
|
|
78
|
+
|
|
79
|
+
# CIDR set maths
|
|
80
|
+
netimps.subtract(["10.0.0.0/24"], ["10.0.0.64/26"])
|
|
81
|
+
# [IPv4Network('10.0.0.0/26'), IPv4Network('10.0.0.128/25')]
|
|
82
|
+
|
|
83
|
+
# host:port, including the IPv6 case people get wrong
|
|
84
|
+
netimps.normalize_host("[::1]:8080") # ('::1', 8080)
|
|
85
|
+
netimps.normalize_host("::1") # ('::1', None) -- not port 1
|
|
86
|
+
|
|
87
|
+
# MAC addresses
|
|
88
|
+
mac = MACAddress("AA-BB-CC-DD-EE-FF")
|
|
89
|
+
mac.as_str("-", upper=True) # 'AA-BB-CC-DD-EE-FF'
|
|
90
|
+
mac.is_local, mac.oui.hex() # (True, 'aabbcc') -- AA has the U/L bit
|
|
91
|
+
|
|
92
|
+
# DNS returns native types
|
|
93
|
+
netimps.resolve("example.com")[0].is_global # an IPv4Address, not a str
|
|
94
|
+
netimps.resolve("example.com", "txt") # ['v=spf1 -all'] -- unquoted
|
|
95
|
+
|
|
96
|
+
# ping carries the details, and can use TCP or UDP where ICMP is blocked
|
|
97
|
+
result = netimps.ping("8.8.8.8")
|
|
98
|
+
result.ok, result.rtt_ms, result.ttl # (True, 9.0, 119)
|
|
99
|
+
netimps.ping("8.8.8.8", method="tcp", port=53) # times the handshake
|
|
100
|
+
|
|
101
|
+
# Path MTU, measured rather than guessed
|
|
102
|
+
netimps.discover_mtu("8.8.8.8") # 1500
|
|
103
|
+
netimps.discover_mtu("10.0.0.5", method="udp", port=9999)
|
|
104
|
+
netimps.get_tcp_mss("example.com", 443) # 1460
|
|
105
|
+
|
|
106
|
+
# Scanning and multicast
|
|
107
|
+
netimps.scan_ports("192.168.1.1", ["ssh", "https"]) # [22, 443]
|
|
108
|
+
sock = netimps.multicast_socket("224.0.0.251", 5353) # mDNS listener
|
|
109
|
+
|
|
110
|
+
# Server-side: bind with the options named, and know where packets came from
|
|
111
|
+
server = netimps.bind("", 6767, broadcast=True)
|
|
112
|
+
endpoint = netimps.UdpEndpoint(server)
|
|
113
|
+
|
|
114
|
+
# Retry with backoff and jitter
|
|
115
|
+
netimps.retry(lambda: netimps.tcp_check("example.com", 443), attempts=3)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## API overview
|
|
119
|
+
|
|
120
|
+
| Name | Purpose |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `IPAddress`, `IPInterface`, `IPNetwork` | v4/v6 **union aliases** for annotations |
|
|
123
|
+
| `IPAddressLike`, `IPNetworkLike`, `MACLike` | accepted-input unions |
|
|
124
|
+
| `IPv4Address`, `IPv4Interface`, ... | stdlib concrete-type re-exports |
|
|
125
|
+
| `parse`, `try_parse`, `is_valid` | build a type from a value (raising / `None` / `bool`) |
|
|
126
|
+
| `MACAddress` | parse / classify / render MAC addresses |
|
|
127
|
+
| `get_interfaces`, `Interface`, `iter_addresses` | native cross-platform NIC discovery |
|
|
128
|
+
| `get_ip`, `is_link_scoped` | address resolution and scope classification |
|
|
129
|
+
| `collapse`, `subtract` | CIDR set maths |
|
|
130
|
+
| `normalize_host` | `host:port` splitting, IPv6-aware |
|
|
131
|
+
| `get_default_port`, `get_default_scheme`, `register_port` | scheme ↔ port registry |
|
|
132
|
+
| `resolve` | DNS lookup → native records (`[]` on failure) |
|
|
133
|
+
| `ping`, `PingResult` | reachability with RTT and TTL |
|
|
134
|
+
| `bind`, `bind_error_hint`, `interface_for` | socket creation and diagnosis |
|
|
135
|
+
| `get_source_ip`, `get_free_port`, `tcp_check`, `wait_for_port` | socket helpers |
|
|
136
|
+
| `UdpEndpoint`, `Datagram` | UDP receive with arrival interface (`IP_PKTINFO`) |
|
|
137
|
+
| `Host` | hostname-or-address value type |
|
|
138
|
+
| `retry`, `backoff_delays` | bounded retry with exponential backoff |
|
|
139
|
+
| `APIPA`, `LOOPBACK_V4`, `LOOPBACK_V6`, `LINK_LOCAL_V6` | named networks |
|
|
140
|
+
| `get_route`, `Route`, `hop_count` | routing and distance |
|
|
141
|
+
| `discover_mtu`, `get_pmtu`, `get_tcp_mss` | path MTU by ICMP/UDP/TCP, the kernel's cached guess, or the negotiated MSS |
|
|
142
|
+
| `scan_ports`, `scan_hosts`, `PORT_RANGES` | concurrent scanning |
|
|
143
|
+
| `multicast_socket`, `join_group`, `leave_group`, `is_multicast` | multicast |
|
|
144
|
+
| `HOST_DN` | `platform.node()`, captured at import time |
|
|
145
|
+
|
|
146
|
+
Full per-export reference, with contracts and gotchas, lives in
|
|
147
|
+
[`src/netimps/AGENTS.md`](src/netimps/AGENTS.md).
|
|
148
|
+
|
|
149
|
+
## Design notes
|
|
150
|
+
|
|
151
|
+
A few behaviours are deliberate and worth knowing:
|
|
152
|
+
|
|
153
|
+
- **`Interface.is_loopback` is computed from addresses, not names** — `lo`,
|
|
154
|
+
`lo0` and `Loopback Pseudo-Interface 1` share no spelling.
|
|
155
|
+
- **Concrete types are strict about family.** `parse("::1", IPAddress)` works;
|
|
156
|
+
`parse("::1", IPv4Address)` raises rather than quietly returning v6.
|
|
157
|
+
- **Networks parse non-strict by default**, so `10.0.0.5/24` normalises instead
|
|
158
|
+
of raising. Pass `strict=True` for stdlib behaviour.
|
|
159
|
+
- **`resolve` raises on a malformed query** rather than returning `[]` — a
|
|
160
|
+
typo'd record type should not look like "no such record".
|
|
161
|
+
- **`ping(ttl=...)` behaves the same on every OS.** Windows `ping` exits `0`
|
|
162
|
+
for "TTL expired in transit", so the reply address is verified instead of
|
|
163
|
+
trusting the exit code.
|
|
164
|
+
- **`hop_count` works unprivileged**, falling back to the system traceroute
|
|
165
|
+
when a raw socket is unavailable.
|
|
166
|
+
- **`discover_mtu` measures; `get_pmtu` only reports what the kernel cached.**
|
|
167
|
+
The latter is usually `None`, and always `None` on Windows. On one real host
|
|
168
|
+
the local link was 9000 and the true path MTU 1500 — only probing found it.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
python -m venv .venv/dev
|
|
174
|
+
.venv/dev/Scripts/pip install -e ".[dev]" # POSIX: .venv/dev/bin/pip
|
|
175
|
+
.venv/dev/Scripts/pytest -q
|
|
176
|
+
.venv/dev/Scripts/black src/ tests/
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Tested on Python 3.9 (the floor) and 3.14.
|
|
180
|
+
|
|
181
|
+
### Releasing
|
|
182
|
+
|
|
183
|
+
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
184
|
+
[`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
185
|
+
workflow: test gate → build → publish → docs deploy.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT — see [LICENSE](LICENSE).
|