aiodiscover 2.2.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.
- aiodiscover/__init__.py +13 -0
- aiodiscover/discovery.py +169 -0
- aiodiscover/network.py +262 -0
- aiodiscover/tests/__init__.py +1 -0
- aiodiscover/tests/conftest.py +1 -0
- aiodiscover/tests/test_discovery.py +146 -0
- aiodiscover/tests/test_init.py +8 -0
- aiodiscover/util.py +8 -0
- aiodiscover-2.2.0.dist-info/LICENSE +15 -0
- aiodiscover-2.2.0.dist-info/METADATA +136 -0
- aiodiscover-2.2.0.dist-info/RECORD +12 -0
- aiodiscover-2.2.0.dist-info/WHEEL +4 -0
aiodiscover/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Top-level package for Async Host discovery."""
|
|
2
|
+
|
|
3
|
+
__author__ = "J. Nick Koston"
|
|
4
|
+
__email__ = "nick@koston.org"
|
|
5
|
+
# Do not edit this string manually, always use bumpversion
|
|
6
|
+
# Details in CONTRIBUTING.md
|
|
7
|
+
__version__ = "2.2.0"
|
|
8
|
+
|
|
9
|
+
from .discovery import DiscoverHosts # noqa: F401
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_module_version() -> str:
|
|
13
|
+
return __version__
|
aiodiscover/discovery.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from functools import lru_cache, partial
|
|
8
|
+
from ipaddress import IPv4Address
|
|
9
|
+
from itertools import islice
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
from aiodns import DNSResolver
|
|
13
|
+
|
|
14
|
+
from .network import SystemNetworkData
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pyroute2.iproute import IPRoute
|
|
18
|
+
|
|
19
|
+
HOSTNAME = "hostname"
|
|
20
|
+
MAC_ADDRESS = "macaddress"
|
|
21
|
+
IP_ADDRESS = "ip"
|
|
22
|
+
MAX_ADDRESSES = 2048
|
|
23
|
+
QUERY_BUCKET_SIZE = 64
|
|
24
|
+
|
|
25
|
+
DNS_RESPONSE_TIMEOUT = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@lru_cache(maxsize=MAX_ADDRESSES)
|
|
32
|
+
def decode_idna(name: str) -> str:
|
|
33
|
+
"""Decode an idna name."""
|
|
34
|
+
try:
|
|
35
|
+
return name.encode().decode("idna")
|
|
36
|
+
except UnicodeError:
|
|
37
|
+
return name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dns_message_short_hostname(dns_message: Any | None) -> str | None:
|
|
41
|
+
"""Get the short hostname from a dns message."""
|
|
42
|
+
if dns_message is None:
|
|
43
|
+
return None
|
|
44
|
+
name: str = dns_message.name
|
|
45
|
+
if name.startswith("xn--"):
|
|
46
|
+
name = decode_idna(name)
|
|
47
|
+
return name.partition(".")[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def async_query_for_ptrs(
|
|
51
|
+
nameserver: str, ips_to_lookup: list[IPv4Address]
|
|
52
|
+
) -> list[Any | None]:
|
|
53
|
+
"""Fetch PTR records for a list of ips."""
|
|
54
|
+
resolver = DNSResolver(nameservers=[nameserver], timeout=DNS_RESPONSE_TIMEOUT)
|
|
55
|
+
results: list[Any | None] = []
|
|
56
|
+
for ip_chunk in chunked(ips_to_lookup, QUERY_BUCKET_SIZE):
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
ip_chunk = cast("list[IPv4Address]", ip_chunk)
|
|
59
|
+
futures = [resolver.query(ip.reverse_pointer, "PTR") for ip in ip_chunk]
|
|
60
|
+
await asyncio.wait(futures)
|
|
61
|
+
results.extend(
|
|
62
|
+
None if future.exception() else future.result() for future in futures
|
|
63
|
+
)
|
|
64
|
+
resolver.cancel()
|
|
65
|
+
return results
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def take(take_num: int, iterable: Iterable[Any]) -> list[Any]:
|
|
69
|
+
"""
|
|
70
|
+
Return first n items of the iterable as a list.
|
|
71
|
+
|
|
72
|
+
From itertools recipes
|
|
73
|
+
"""
|
|
74
|
+
return list(islice(iterable, take_num))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def chunked(iterable: Iterable[Any], chunked_num: int) -> Iterable[Any]:
|
|
78
|
+
"""
|
|
79
|
+
Break *iterable* into lists of length *n*.
|
|
80
|
+
|
|
81
|
+
From more-itertools
|
|
82
|
+
"""
|
|
83
|
+
return iter(partial(take, chunked_num, iter(iterable)), [])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DiscoverHosts:
|
|
87
|
+
"""Discover hosts on the network by ARP and PTR lookup."""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
"""Init the discovery hosts."""
|
|
91
|
+
self._sys_network_data: SystemNetworkData | None = None
|
|
92
|
+
|
|
93
|
+
def _setup_sys_network_data(self) -> None:
|
|
94
|
+
ip_route: IPRoute | None = None
|
|
95
|
+
with suppress(Exception):
|
|
96
|
+
from pyroute2.iproute import (
|
|
97
|
+
IPRoute,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
ip_route = IPRoute()
|
|
101
|
+
sys_network_data = SystemNetworkData(ip_route)
|
|
102
|
+
sys_network_data.setup()
|
|
103
|
+
self._sys_network_data = sys_network_data
|
|
104
|
+
|
|
105
|
+
async def async_discover(self) -> list[dict[str, str]]:
|
|
106
|
+
"""Discover hosts on the network by ARP and PTR lookup."""
|
|
107
|
+
if not self._sys_network_data:
|
|
108
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
109
|
+
None, self._setup_sys_network_data
|
|
110
|
+
)
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
assert self._sys_network_data is not None
|
|
113
|
+
sys_network_data = self._sys_network_data
|
|
114
|
+
network = sys_network_data.network
|
|
115
|
+
if TYPE_CHECKING:
|
|
116
|
+
assert network is not None
|
|
117
|
+
if network.num_addresses > MAX_ADDRESSES:
|
|
118
|
+
_LOGGER.debug(
|
|
119
|
+
"The network %s exceeds the maximum number of addresses, %s; No scanning performed",
|
|
120
|
+
network,
|
|
121
|
+
MAX_ADDRESSES,
|
|
122
|
+
)
|
|
123
|
+
return []
|
|
124
|
+
hostnames = await self.async_get_hostnames(sys_network_data)
|
|
125
|
+
neighbours = await sys_network_data.async_get_neighbors(hostnames.keys())
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
HOSTNAME: hostname,
|
|
129
|
+
MAC_ADDRESS: neighbours[ip],
|
|
130
|
+
IP_ADDRESS: ip,
|
|
131
|
+
}
|
|
132
|
+
for ip, hostname in hostnames.items()
|
|
133
|
+
if ip in neighbours
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
async def _async_get_nameservers(
|
|
137
|
+
self, sys_network_data: SystemNetworkData
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
"""Get nameservers to query."""
|
|
140
|
+
all_nameservers = list(sys_network_data.nameservers)
|
|
141
|
+
router_ip = sys_network_data.router_ip
|
|
142
|
+
assert router_ip is not None
|
|
143
|
+
if router_ip not in all_nameservers:
|
|
144
|
+
neighbours = await sys_network_data.async_get_neighbors([router_ip])
|
|
145
|
+
if router_ip in neighbours:
|
|
146
|
+
all_nameservers.insert(0, router_ip)
|
|
147
|
+
return all_nameservers
|
|
148
|
+
|
|
149
|
+
async def async_get_hostnames(
|
|
150
|
+
self, sys_network_data: SystemNetworkData
|
|
151
|
+
) -> dict[str, str]:
|
|
152
|
+
"""Lookup PTR records for all addresses in the network."""
|
|
153
|
+
all_nameservers = await self._async_get_nameservers(sys_network_data)
|
|
154
|
+
assert sys_network_data.network is not None
|
|
155
|
+
ips = list(sys_network_data.network.hosts())
|
|
156
|
+
hostnames: dict[str, str] = {}
|
|
157
|
+
for nameserver in all_nameservers:
|
|
158
|
+
ips_to_lookup = [ip for ip in ips if str(ip) not in hostnames]
|
|
159
|
+
results = await async_query_for_ptrs(nameserver, ips_to_lookup)
|
|
160
|
+
for idx, ip in enumerate(ips_to_lookup):
|
|
161
|
+
short_host = dns_message_short_hostname(results[idx])
|
|
162
|
+
if short_host is None:
|
|
163
|
+
continue
|
|
164
|
+
hostnames[str(ip)] = short_host
|
|
165
|
+
if hostnames:
|
|
166
|
+
# As soon as we have a responsive nameserver, there
|
|
167
|
+
# is no need to query additional fallbacks
|
|
168
|
+
break
|
|
169
|
+
return hostnames
|
aiodiscover/network.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from ipaddress import IPv4Network, ip_network
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import ifaddr # type: ignore
|
|
13
|
+
from cached_ipaddress import cached_ip_addresses
|
|
14
|
+
|
|
15
|
+
from .util import asyncio_timeout
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pyroute2.iproute import IPRoute
|
|
19
|
+
# Some MAC addresses will drop the leading zero so
|
|
20
|
+
# our mac validation must allow a single char
|
|
21
|
+
VALID_MAC_ADDRESS = re.compile("^([0-9A-Fa-f]{1,2}[:-]){5}([0-9A-Fa-f]{1,2})$")
|
|
22
|
+
|
|
23
|
+
ARP_CACHE_POPULATE_TIME = 10
|
|
24
|
+
ARP_TIMEOUT = 10
|
|
25
|
+
|
|
26
|
+
DEFAULT_NETWORK_PREFIX = 24
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PRIVATE_AND_LOCAL_NETWORKS = (
|
|
30
|
+
ip_network("127.0.0.0/8"),
|
|
31
|
+
ip_network("10.0.0.0/8"),
|
|
32
|
+
ip_network("172.16.0.0/12"),
|
|
33
|
+
ip_network("192.168.0.0/16"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_TARGET = "10.255.255.255"
|
|
37
|
+
MDNS_TARGET_IP = "224.0.0.251"
|
|
38
|
+
PUBLIC_TARGET_IP = "8.8.8.8"
|
|
39
|
+
LOOPBACK_TARGET_IP = "127.0.0.1"
|
|
40
|
+
|
|
41
|
+
IGNORE_MACS = {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_resolv_conf() -> list[str]:
|
|
45
|
+
"""Load the resolv.conf."""
|
|
46
|
+
with open("/etc/resolv.conf") as file:
|
|
47
|
+
lines = tuple(file)
|
|
48
|
+
nameservers = set()
|
|
49
|
+
for line in lines:
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not len(line):
|
|
52
|
+
continue
|
|
53
|
+
if line[0] in ("#", ";"):
|
|
54
|
+
continue
|
|
55
|
+
key, value = line.split(None, 1)
|
|
56
|
+
if key == "nameserver":
|
|
57
|
+
if ip_addr := cached_ip_addresses(value):
|
|
58
|
+
nameservers.add(ip_addr)
|
|
59
|
+
return list(nameservers)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_local_ip(target: str = DEFAULT_TARGET) -> str | None:
|
|
63
|
+
"""Find the local ip address."""
|
|
64
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
65
|
+
s.setblocking(False)
|
|
66
|
+
try:
|
|
67
|
+
s.connect((target, 1))
|
|
68
|
+
return s.getsockname()[0]
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
finally:
|
|
72
|
+
s.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_network(local_ip: str, adapters: Any) -> IPv4Network:
|
|
76
|
+
"""Search adapters for the network and broadcast ip."""
|
|
77
|
+
network_prefix = (
|
|
78
|
+
get_ip_prefix_from_adapters(local_ip, adapters) or DEFAULT_NETWORK_PREFIX
|
|
79
|
+
)
|
|
80
|
+
network = ip_network(f"{local_ip}/{network_prefix}", False)
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
assert isinstance(network, IPv4Network)
|
|
83
|
+
return network
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_ip_prefix_from_adapters(local_ip: str, adapters: Any) -> int | None:
|
|
87
|
+
"""Find the network prefix for an adapter."""
|
|
88
|
+
for adapter in adapters:
|
|
89
|
+
for ip in adapter.ips:
|
|
90
|
+
if local_ip == ip.ip:
|
|
91
|
+
return ip.network_prefix
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_attrs_key(data: Any, key: Any) -> Any:
|
|
96
|
+
"""Lookup an attrs key in pyroute2 data."""
|
|
97
|
+
for attr_key, attr_value in data["attrs"]:
|
|
98
|
+
if attr_key == key:
|
|
99
|
+
return attr_value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_router_ip(ipr: IPRoute) -> Any:
|
|
103
|
+
"""Obtain the router ip from the default route."""
|
|
104
|
+
return get_attrs_key(ipr.get_default_routes()[0], "RTA_GATEWAY")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fill_neighbor(neighbours: dict[str, str], ip: str, mac: str) -> None:
|
|
108
|
+
"""Add a neighbor if it is valid."""
|
|
109
|
+
if not (ip_addr := cached_ip_addresses(ip)):
|
|
110
|
+
return
|
|
111
|
+
if (
|
|
112
|
+
ip_addr.is_loopback
|
|
113
|
+
or ip_addr.is_link_local
|
|
114
|
+
or ip_addr.is_multicast
|
|
115
|
+
or ip_addr.is_unspecified
|
|
116
|
+
):
|
|
117
|
+
return
|
|
118
|
+
if not VALID_MAC_ADDRESS.match(mac):
|
|
119
|
+
return
|
|
120
|
+
mac = ":".join([i.zfill(2) for i in mac.split(":")])
|
|
121
|
+
if mac in IGNORE_MACS:
|
|
122
|
+
return
|
|
123
|
+
neighbours[ip] = mac
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def async_populate_arp(ip_addresses: Iterable[str]) -> socket.socket:
|
|
127
|
+
"""Send an empty packet to a host to populate the arp cache."""
|
|
128
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
|
|
129
|
+
sock.setblocking(False)
|
|
130
|
+
for ip_addr in ip_addresses:
|
|
131
|
+
try:
|
|
132
|
+
sock.sendto(b"", (ip_addr, 80))
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
return sock
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SystemNetworkData:
|
|
139
|
+
"""Gather system network data."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, ip_route: IPRoute | None, local_ip: str | None = None) -> None:
|
|
142
|
+
"""Init system network data."""
|
|
143
|
+
self.ip_route = ip_route
|
|
144
|
+
self.local_ip = local_ip
|
|
145
|
+
self.broadcast_ip: str | None = None
|
|
146
|
+
self.router_ip: str | None = None
|
|
147
|
+
self.network: IPv4Network | None = None
|
|
148
|
+
self.adapters: Any = None
|
|
149
|
+
self.nameservers: list[str] = []
|
|
150
|
+
|
|
151
|
+
def setup(self) -> None:
|
|
152
|
+
"""Obtain the local network data."""
|
|
153
|
+
try:
|
|
154
|
+
resolvers = load_resolv_conf()
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
if sys.platform != "win32":
|
|
157
|
+
raise
|
|
158
|
+
else:
|
|
159
|
+
self.nameservers = [
|
|
160
|
+
str(ip_addr)
|
|
161
|
+
for ip_addr in resolvers
|
|
162
|
+
if any(ip_addr in network for network in PRIVATE_AND_LOCAL_NETWORKS)
|
|
163
|
+
]
|
|
164
|
+
self.adapters = ifaddr.get_adapters()
|
|
165
|
+
if not self.local_ip:
|
|
166
|
+
self.local_ip = (
|
|
167
|
+
get_local_ip(DEFAULT_TARGET)
|
|
168
|
+
or get_local_ip(MDNS_TARGET_IP)
|
|
169
|
+
or get_local_ip(PUBLIC_TARGET_IP)
|
|
170
|
+
or get_local_ip(LOOPBACK_TARGET_IP)
|
|
171
|
+
)
|
|
172
|
+
assert self.local_ip is not None
|
|
173
|
+
self.network = get_network(self.local_ip, self.adapters)
|
|
174
|
+
if self.ip_route:
|
|
175
|
+
try:
|
|
176
|
+
self.router_ip = get_router_ip(self.ip_route)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
if not self.router_ip:
|
|
180
|
+
# On MacOS netifaces is the only reliable way to get the default gateway
|
|
181
|
+
with suppress(Exception):
|
|
182
|
+
import netifaces # type: ignore # pylint: disable=import-outside-toplevel
|
|
183
|
+
|
|
184
|
+
self.router_ip = netifaces.gateways()["default"][netifaces.AF_INET][0]
|
|
185
|
+
if not self.router_ip:
|
|
186
|
+
network_address = str(self.network.network_address)
|
|
187
|
+
self.router_ip = f"{network_address[:-1]}1"
|
|
188
|
+
|
|
189
|
+
async def async_get_neighbors(self, ips: Iterable[str]) -> dict[str, str]:
|
|
190
|
+
"""Get neighbors with best available method."""
|
|
191
|
+
neighbors = await self._async_get_neighbors()
|
|
192
|
+
ips_missing_arp = [ip for ip in ips if ip not in neighbors]
|
|
193
|
+
if not ips_missing_arp:
|
|
194
|
+
return neighbors
|
|
195
|
+
sock = async_populate_arp(ips_missing_arp)
|
|
196
|
+
await asyncio.sleep(ARP_CACHE_POPULATE_TIME)
|
|
197
|
+
sock.close()
|
|
198
|
+
neighbors.update(await self._async_get_neighbors())
|
|
199
|
+
return neighbors
|
|
200
|
+
|
|
201
|
+
async def _async_get_neighbors(self) -> dict[str, str]:
|
|
202
|
+
"""Get neighbors from the arp table."""
|
|
203
|
+
if self.ip_route:
|
|
204
|
+
return await self._async_get_neighbors_ip_route()
|
|
205
|
+
return await self._async_get_neighbors_arp()
|
|
206
|
+
|
|
207
|
+
async def _async_get_neighbors_arp(self) -> dict[str, str]:
|
|
208
|
+
"""Get neighbors with arp command."""
|
|
209
|
+
neighbours: dict[str, str] = {}
|
|
210
|
+
arp = await asyncio.create_subprocess_exec(
|
|
211
|
+
"arp",
|
|
212
|
+
"-a",
|
|
213
|
+
"-n",
|
|
214
|
+
stdin=None,
|
|
215
|
+
stdout=asyncio.subprocess.PIPE,
|
|
216
|
+
stderr=asyncio.subprocess.PIPE,
|
|
217
|
+
close_fds=False,
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
async with asyncio_timeout(ARP_TIMEOUT):
|
|
221
|
+
out_data, _ = await arp.communicate()
|
|
222
|
+
except asyncio.TimeoutError:
|
|
223
|
+
if arp:
|
|
224
|
+
with suppress(TypeError):
|
|
225
|
+
await arp.kill() # type: ignore
|
|
226
|
+
del arp
|
|
227
|
+
return neighbours
|
|
228
|
+
except AttributeError:
|
|
229
|
+
return neighbours
|
|
230
|
+
|
|
231
|
+
for line in out_data.decode().splitlines():
|
|
232
|
+
chomped = line.strip()
|
|
233
|
+
data = chomped.split()
|
|
234
|
+
if len(data) < 4:
|
|
235
|
+
continue
|
|
236
|
+
ip = data[1].strip("()")
|
|
237
|
+
mac = data[3]
|
|
238
|
+
_fill_neighbor(neighbours, ip, mac)
|
|
239
|
+
|
|
240
|
+
return neighbours
|
|
241
|
+
|
|
242
|
+
async def _async_get_neighbors_ip_route(self) -> dict[str, str]:
|
|
243
|
+
"""Get neighbors with pyroute2."""
|
|
244
|
+
neighbours: dict[str, str] = {}
|
|
245
|
+
loop = asyncio.get_running_loop()
|
|
246
|
+
# This shouldn't ever block but it does
|
|
247
|
+
# interact with netlink so its safer to run
|
|
248
|
+
# in the executor
|
|
249
|
+
if TYPE_CHECKING:
|
|
250
|
+
assert self.ip_route is not None
|
|
251
|
+
for neighbour in await loop.run_in_executor(None, self.ip_route.get_neighbours):
|
|
252
|
+
ip = None
|
|
253
|
+
mac = None
|
|
254
|
+
for key, value in neighbour["attrs"]:
|
|
255
|
+
if key == "NDA_DST":
|
|
256
|
+
ip = value
|
|
257
|
+
elif key == "NDA_LLADDR":
|
|
258
|
+
mac = value
|
|
259
|
+
if ip and mac:
|
|
260
|
+
_fill_neighbor(neighbours, ip, mac)
|
|
261
|
+
|
|
262
|
+
return neighbours
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unit test package for aiodiscover."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import asyncio
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from ipaddress import IPv4Address, IPv4Network
|
|
6
|
+
from typing import Any
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from aiodiscover import discovery
|
|
12
|
+
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_async_discover_hosts() -> None:
|
|
19
|
+
"""Verify discover hosts does not throw."""
|
|
20
|
+
discover_hosts = discovery.DiscoverHosts()
|
|
21
|
+
with patch.object(discovery, "MAX_ADDRESSES", 16):
|
|
22
|
+
hosts = await discover_hosts.async_discover()
|
|
23
|
+
assert isinstance(hosts, list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.asyncio
|
|
27
|
+
async def test_async_discover_hosts_with_dns_mock() -> None:
|
|
28
|
+
"""Verify discover hosts does not throw."""
|
|
29
|
+
discover_hosts = discovery.DiscoverHosts()
|
|
30
|
+
with (
|
|
31
|
+
patch.object(discovery, "MAX_ADDRESSES", 2),
|
|
32
|
+
patch(
|
|
33
|
+
"aiodiscover.discovery.dns_message_short_hostname", return_value="router"
|
|
34
|
+
),
|
|
35
|
+
):
|
|
36
|
+
hosts = await discover_hosts.async_discover()
|
|
37
|
+
assert isinstance(hosts, list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_async_discover_hosts_with_dns_mock_neighbor_mock() -> None:
|
|
42
|
+
"""Verify discover hosts does not throw."""
|
|
43
|
+
discover_hosts = discovery.DiscoverHosts()
|
|
44
|
+
|
|
45
|
+
async def _async_get_hostnames(sys_network_data: Any) -> dict[str, str]:
|
|
46
|
+
return {"1.2.3.4": "router", "4.5.5.6": "any"}
|
|
47
|
+
|
|
48
|
+
discover_hosts.async_get_hostnames = _async_get_hostnames # type: ignore
|
|
49
|
+
with (
|
|
50
|
+
patch(
|
|
51
|
+
"aiodiscover.network.SystemNetworkData.async_get_neighbors",
|
|
52
|
+
return_value={
|
|
53
|
+
"1.2.3.4": "aa:bb:cc:dd:ee:ff",
|
|
54
|
+
"4.5.5.6": "ff:bb:cc:0d:ee:ff",
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
patch(
|
|
58
|
+
"aiodiscover.network.get_network",
|
|
59
|
+
return_value=IPv4Network("1.2.3.0/24", False),
|
|
60
|
+
),
|
|
61
|
+
):
|
|
62
|
+
hosts = await discover_hosts.async_discover()
|
|
63
|
+
|
|
64
|
+
assert hosts == [
|
|
65
|
+
{"hostname": "router", "ip": "1.2.3.4", "macaddress": "aa:bb:cc:dd:ee:ff"},
|
|
66
|
+
{"hostname": "any", "ip": "4.5.5.6", "macaddress": "ff:bb:cc:0d:ee:ff"},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_async_query_for_ptrs() -> None:
|
|
72
|
+
"""Test async_query_for_ptrs handles missing ips."""
|
|
73
|
+
loop = asyncio.get_running_loop()
|
|
74
|
+
count = 0
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class MockReply:
|
|
78
|
+
name: str
|
|
79
|
+
|
|
80
|
+
def mock_query(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
nonlocal count
|
|
82
|
+
count += 1
|
|
83
|
+
future = loop.create_future()
|
|
84
|
+
if count == 2:
|
|
85
|
+
future.set_exception(Exception("test"))
|
|
86
|
+
else:
|
|
87
|
+
future.set_result(MockReply(name=f"name{count}"))
|
|
88
|
+
return future
|
|
89
|
+
|
|
90
|
+
with (
|
|
91
|
+
patch.object(discovery, "DNS_RESPONSE_TIMEOUT", 0),
|
|
92
|
+
patch("aiodiscover.discovery.DNSResolver.query", mock_query),
|
|
93
|
+
):
|
|
94
|
+
response = await discovery.async_query_for_ptrs(
|
|
95
|
+
"192.168.107.1",
|
|
96
|
+
[
|
|
97
|
+
IPv4Address("192.168.107.2"),
|
|
98
|
+
IPv4Address("192.168.107.3"),
|
|
99
|
+
IPv4Address("192.168.107.4"),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert len(response) == 3
|
|
104
|
+
assert response[0].name == "name1" # type: ignore
|
|
105
|
+
assert response[1] is None # type: ignore
|
|
106
|
+
assert response[2].name == "name3" # type: ignore
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_async_query_for_ptrs_chunked() -> None:
|
|
111
|
+
"""Test async_query_for_ptrs chunkeds."""
|
|
112
|
+
loop = asyncio.get_running_loop()
|
|
113
|
+
count = 0
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class MockReply:
|
|
117
|
+
name: str
|
|
118
|
+
|
|
119
|
+
def mock_query(*args: Any, **kwargs: Any) -> Any:
|
|
120
|
+
nonlocal count
|
|
121
|
+
count += 1
|
|
122
|
+
future = loop.create_future()
|
|
123
|
+
if count == 2:
|
|
124
|
+
future.set_exception(Exception("test"))
|
|
125
|
+
else:
|
|
126
|
+
future.set_result(MockReply(name=f"name{count}"))
|
|
127
|
+
return future
|
|
128
|
+
|
|
129
|
+
with (
|
|
130
|
+
patch.object(discovery, "DNS_RESPONSE_TIMEOUT", 0),
|
|
131
|
+
patch("aiodiscover.discovery.DNSResolver.query", mock_query),
|
|
132
|
+
patch.object(discovery, "QUERY_BUCKET_SIZE", 1),
|
|
133
|
+
):
|
|
134
|
+
response = await discovery.async_query_for_ptrs(
|
|
135
|
+
"192.168.107.1",
|
|
136
|
+
[
|
|
137
|
+
IPv4Address("192.168.107.2"),
|
|
138
|
+
IPv4Address("192.168.107.3"),
|
|
139
|
+
IPv4Address("192.168.107.4"),
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert len(response) == 3
|
|
144
|
+
assert response[0].name == "name1" # type: ignore
|
|
145
|
+
assert response[1] is None
|
|
146
|
+
assert response[2].name == "name3" # type: ignore
|
aiodiscover/util.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache Software License 2.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021, J. Nick Koston
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: aiodiscover
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Discover hosts by arp and ptr lookup
|
|
5
|
+
Home-page: https://github.com/uilibs/aiodiscover
|
|
6
|
+
Author: J. Nick Koston
|
|
7
|
+
Author-email: nick@koston.org
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Dist: aiodns (>=3.1.1)
|
|
21
|
+
Requires-Dist: async_timeout (>=4.0.1)
|
|
22
|
+
Requires-Dist: cached_ipaddress (>=0.2.0)
|
|
23
|
+
Requires-Dist: ifaddr (>0.0.0)
|
|
24
|
+
Requires-Dist: netifaces (>=0.11.0)
|
|
25
|
+
Requires-Dist: pyroute2 (>=0.7.3)
|
|
26
|
+
Project-URL: Bug Tracker, https://github.com/bdraco/aiodiscover/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/bdraco/aiodiscover/blob/main/CHANGELOG.md
|
|
28
|
+
Project-URL: Documentation, https://aiodiscover.readthedocs.io
|
|
29
|
+
Project-URL: Repository, https://github.com/uilibs/aiodiscover
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Async Host discovery
|
|
33
|
+
|
|
34
|
+
[](https://github.com/bdraco/aiodiscover/actions)
|
|
35
|
+
[](https://bdraco.github.io/aiodiscover/)
|
|
36
|
+
[](https://codecov.io/gh/bdraco/aiodiscover)
|
|
37
|
+
|
|
38
|
+
Discover hosts by arp and ptr lookup
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- Discover hosts on the network via ARP and PTR lookup
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
import pprint
|
|
51
|
+
from aiodiscover import DiscoverHosts
|
|
52
|
+
|
|
53
|
+
discover_hosts = DiscoverHosts()
|
|
54
|
+
hosts = asyncio.run(discover_hosts.async_discover())
|
|
55
|
+
pprint.pprint(hosts)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
**Stable Release:** `pip install aiodiscover`<br>
|
|
61
|
+
**Development Head:** `pip install git+https://github.com/bdraco/aiodiscover.git`
|
|
62
|
+
|
|
63
|
+
## Documentation
|
|
64
|
+
|
|
65
|
+
For full package documentation please visit [bdraco.github.io/aiodiscover](https://bdraco.github.io/aiodiscover).
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for information related to developing the code.
|
|
70
|
+
|
|
71
|
+
## The Four Commands You Need To Know
|
|
72
|
+
|
|
73
|
+
1. `pip install -e .[dev]`
|
|
74
|
+
|
|
75
|
+
This will install your package in editable mode with all the required development
|
|
76
|
+
dependencies (i.e. `tox`).
|
|
77
|
+
|
|
78
|
+
2. `make build`
|
|
79
|
+
|
|
80
|
+
This will run `tox` which will run all your tests in both Python 3.7
|
|
81
|
+
and Python 3.8 as well as linting your code.
|
|
82
|
+
|
|
83
|
+
3. `make clean`
|
|
84
|
+
|
|
85
|
+
This will clean up various Python and build generated files so that you can ensure
|
|
86
|
+
that you are working in a clean environment.
|
|
87
|
+
|
|
88
|
+
4. `make docs`
|
|
89
|
+
|
|
90
|
+
This will generate and launch a web browser to view the most up-to-date
|
|
91
|
+
documentation for your Python package.
|
|
92
|
+
|
|
93
|
+
#### Additional Optional Setup Steps:
|
|
94
|
+
|
|
95
|
+
- Turn your project into a GitHub repository:
|
|
96
|
+
- Make an account on [github.com](https://github.com)
|
|
97
|
+
- Go to [make a new repository](https://github.com/new)
|
|
98
|
+
- _Recommendations:_
|
|
99
|
+
- _It is strongly recommended to make the repository name the same as the Python
|
|
100
|
+
package name_
|
|
101
|
+
- _A lot of the following optional steps are *free* if the repository is Public,
|
|
102
|
+
plus open source is cool_
|
|
103
|
+
- After a GitHub repo has been created, run the commands listed under:
|
|
104
|
+
"...or push an existing repository from the command line"
|
|
105
|
+
- Register your project with Codecov:
|
|
106
|
+
- Make an account on [codecov.io](https://codecov.io)(Recommended to sign in with GitHub)
|
|
107
|
+
everything else will be handled for you.
|
|
108
|
+
- Ensure that you have set GitHub pages to build the `gh-pages` branch by selecting the
|
|
109
|
+
`gh-pages` branch in the dropdown in the "GitHub Pages" section of the repository settings.
|
|
110
|
+
([Repo Settings](https://github.com/bdraco/aiodiscover/settings))
|
|
111
|
+
- Register your project with PyPI:
|
|
112
|
+
- Make an account on [pypi.org](https://pypi.org)
|
|
113
|
+
- Go to your GitHub repository's settings and under the
|
|
114
|
+
[Secrets tab](https://github.com/bdraco/aiodiscover/settings/secrets/actions),
|
|
115
|
+
add a secret called `PYPI_TOKEN` with your password for your PyPI account.
|
|
116
|
+
Don't worry, no one will see this password because it will be encrypted.
|
|
117
|
+
- Next time you push to the branch `main` after using `bump2version`, GitHub
|
|
118
|
+
actions will build and deploy your Python package to PyPI.
|
|
119
|
+
|
|
120
|
+
#### Suggested Git Branch Strategy
|
|
121
|
+
|
|
122
|
+
1. `main` is for the most up-to-date development, very rarely should you directly
|
|
123
|
+
commit to this branch. GitHub Actions will run on every push and on a CRON to this
|
|
124
|
+
branch but still recommended to commit to your development branches and make pull
|
|
125
|
+
requests to main. If you push a tagged commit with bumpversion, this will also release to PyPI.
|
|
126
|
+
2. Your day-to-day work should exist on branches separate from `main`. Even if it is
|
|
127
|
+
just yourself working on the repository, make a PR from your working branch to `main`
|
|
128
|
+
so that you can ensure your commits don't break the development head. GitHub Actions
|
|
129
|
+
will run on every push to any branch or any pull request from any branch to any other
|
|
130
|
+
branch.
|
|
131
|
+
3. It is recommended to use "Squash and Merge" commits when committing PR's. It makes
|
|
132
|
+
each set of changes to `main` atomic and as a side effect naturally encourages small
|
|
133
|
+
well defined PR's.
|
|
134
|
+
|
|
135
|
+
**Apache Software License 2.0**
|
|
136
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
aiodiscover/__init__.py,sha256=6vpH6FNQRJyhHExxDYDMv5e1dYZoIgxLV4MB-dt357g,331
|
|
2
|
+
aiodiscover/discovery.py,sha256=x-G54ilyP5jmF17wZ-ZTvgCrlPVyH8JjEinUibHE4co,5608
|
|
3
|
+
aiodiscover/network.py,sha256=-Tj7FK5uDrhAWwdnYchF6s2x5BnfcmcujvEAmutWQrg,8486
|
|
4
|
+
aiodiscover/tests/__init__.py,sha256=muJYZG2c0S4gX2epuq-XqFa4x2bU2pkRU84m24p63is,41
|
|
5
|
+
aiodiscover/tests/conftest.py,sha256=-JIt3g1eBf4bVQNNQd4JBeQNA7o0LK0W9tYKKX_r5s8,22
|
|
6
|
+
aiodiscover/tests/test_discovery.py,sha256=zNpAKIIcCKTdMXfzczE3VC3aQbxfhpSCVHLAd-g2IgI,4461
|
|
7
|
+
aiodiscover/tests/test_init.py,sha256=UKj1UfbDaeQ1m47P8-AXObkT35SCpHtevM_My0mvDg0,206
|
|
8
|
+
aiodiscover/util.py,sha256=zDWuAJhWkZtz_J0IqpE6sGFJXzjViGxQJKUfdjHogdY,211
|
|
9
|
+
aiodiscover-2.2.0.dist-info/LICENSE,sha256=CRuitFRo3zhJ7rtke-w2aMSDaveQ0nEURZfVd_rFDs0,585
|
|
10
|
+
aiodiscover-2.2.0.dist-info/METADATA,sha256=M3Ioccqhi6bmstFbYqUDhWqrZsgLl_rHfsQn6EXUfMU,5571
|
|
11
|
+
aiodiscover-2.2.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
12
|
+
aiodiscover-2.2.0.dist-info/RECORD,,
|