hostlens 0.1.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.
hostlens/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """High-level LAN device discovery and identification"""
2
+
3
+ from hostlens.api import discover, discover_async, identify, identify_async
4
+ from hostlens.client import HostLens
5
+ from hostlens.config import ScanMode
6
+ from hostlens.models import DeviceProfile
7
+
8
+ __all__ = [
9
+ "DeviceProfile",
10
+ "HostLens",
11
+ "ScanMode",
12
+ "discover",
13
+ "discover_async",
14
+ "identify",
15
+ "identify_async",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
hostlens/api.py ADDED
@@ -0,0 +1,28 @@
1
+ """Convenient module-level sync and async APIs"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from hostlens.client import HostLens
8
+ from hostlens.models import DeviceProfile
9
+
10
+
11
+ async def identify_async(target: str) -> DeviceProfile:
12
+ """Identify one network device asynchronously"""
13
+ return await HostLens().identify(target)
14
+
15
+
16
+ def identify(target: str) -> DeviceProfile:
17
+ """Identify one network device from synchronous code"""
18
+ return asyncio.run(identify_async(target))
19
+
20
+
21
+ async def discover_async(subnet: str | None = None) -> list[DeviceProfile]:
22
+ """Discover devices on a subnet asynchronously"""
23
+ return await HostLens().discover(subnet)
24
+
25
+
26
+ def discover(subnet: str | None = None) -> list[DeviceProfile]:
27
+ """Discover devices on a subnet from synchronous code"""
28
+ return asyncio.run(discover_async(subnet))
hostlens/cache.py ADDED
@@ -0,0 +1,30 @@
1
+ """Small in-memory TTL cache"""
2
+
3
+ import time
4
+ from typing import Generic, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class MemoryCache(Generic[T]):
10
+ def __init__(self, ttl: float = 300.0) -> None:
11
+ self.ttl = ttl
12
+ self.entries: dict[str, tuple[T, float]] = {}
13
+
14
+ def get(self, key: str) -> T | None:
15
+ entry = self.entries.get(key)
16
+ if entry is None:
17
+ return None
18
+
19
+ value, expires_at = entry
20
+ if expires_at <= time.monotonic():
21
+ del self.entries[key]
22
+ return None
23
+
24
+ return value
25
+
26
+ def set(self, key: str, value: T) -> None:
27
+ self.entries[key] = (value, time.monotonic() + self.ttl)
28
+
29
+ def clear(self) -> None:
30
+ self.entries.clear()
@@ -0,0 +1,5 @@
1
+ """HostLens command line interface"""
2
+
3
+ from hostlens.cli.app import app
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,3 @@
1
+ from hostlens.cli.app import app
2
+
3
+ app()
hostlens/cli/app.py ADDED
@@ -0,0 +1,88 @@
1
+ """HostLens command-line interface"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from hostlens.cli.output import show_device, show_found, show_summary
12
+ from hostlens.client import HostLens
13
+ from hostlens.config import ScanMode
14
+ from hostlens.models import DeviceFound, DeviceIdentified, ScanCompleted
15
+
16
+ app = typer.Typer(no_args_is_help=True, help="Discover and identify devices on a local network")
17
+
18
+
19
+ def _mode(fast: bool, deep: bool) -> ScanMode:
20
+ if fast and deep:
21
+ raise typer.BadParameter("Choose either --fast or --deep")
22
+ if fast:
23
+ return ScanMode.FAST
24
+ if deep:
25
+ return ScanMode.DEEP
26
+ return ScanMode.NORMAL
27
+
28
+
29
+ @app.command()
30
+ def identify(
31
+ target: str,
32
+ deep: Annotated[bool, typer.Option("--deep", help="Use deeper service probes")] = False,
33
+ ) -> None:
34
+ """Identify one device"""
35
+ device = asyncio.run(
36
+ HostLens().scan_host(target, mode=ScanMode.DEEP if deep else ScanMode.NORMAL)
37
+ )
38
+ show_device(device)
39
+
40
+
41
+ @app.command()
42
+ def scan(
43
+ subnet: str | None = None,
44
+ fast: Annotated[bool, typer.Option("--fast", help="Favor discovery speed")] = False,
45
+ deep: Annotated[bool, typer.Option("--deep", help="Use deeper service probes")] = False,
46
+ ) -> None:
47
+ """Discover and identify a local subnet"""
48
+
49
+ async def run() -> None:
50
+ async for event in HostLens().scan_stream(subnet, mode=_mode(fast, deep)):
51
+ if isinstance(event, DeviceFound):
52
+ show_found(event.device.ip)
53
+ elif isinstance(event, DeviceIdentified):
54
+ show_device(event.device)
55
+ elif isinstance(event, ScanCompleted):
56
+ show_summary(event.summary.devices_found, event.summary.duration)
57
+
58
+ asyncio.run(run())
59
+
60
+
61
+ @app.command()
62
+ def discover(subnet: str | None = None) -> None:
63
+ """Run a fast discovery scan"""
64
+ devices = asyncio.run(HostLens().discover(subnet))
65
+ for device in devices:
66
+ show_device(device)
67
+
68
+
69
+ @app.command()
70
+ def watch(
71
+ subnet: str | None = None,
72
+ passive: Annotated[bool, typer.Option("--passive", help="Do not probe devices")] = True,
73
+ ) -> None:
74
+ """Watch for devices observed by the operating system"""
75
+ if not passive:
76
+ raise typer.BadParameter("watch currently supports passive observation only")
77
+
78
+ async def run() -> None:
79
+ async for event in HostLens().watch_network(subnet):
80
+ if isinstance(event, DeviceFound):
81
+ show_device(event.device)
82
+
83
+ with suppress(KeyboardInterrupt):
84
+ asyncio.run(run())
85
+
86
+
87
+ if __name__ == "__main__":
88
+ app()
hostlens/cli/output.py ADDED
@@ -0,0 +1,39 @@
1
+ """Terminal output for the HostLens CLI"""
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from hostlens.models import DeviceProfile
7
+
8
+ console = Console()
9
+
10
+
11
+ def show_device(device: DeviceProfile) -> None:
12
+ table = Table(title=device.best_name, show_header=False, box=None)
13
+ table.add_column("Field", style="bold")
14
+ table.add_column("Value")
15
+
16
+ rows = (
17
+ ("IP", device.ip),
18
+ ("MAC", device.mac),
19
+ ("Hostname", device.hostname),
20
+ ("Manufacturer", device.manufacturer),
21
+ ("Type", device.device_type),
22
+ ("Model", device.model),
23
+ ("OS", device.os),
24
+ ("Confidence", f"{device.confidence:.0%}"),
25
+ )
26
+
27
+ for label, value in rows:
28
+ if value is not None:
29
+ table.add_row(label, str(value))
30
+
31
+ console.print(table)
32
+
33
+
34
+ def show_found(ip: str) -> None:
35
+ console.print(f"Found {ip}")
36
+
37
+
38
+ def show_summary(devices_found: int, duration: float) -> None:
39
+ console.print(f"Found {devices_found} devices in {duration:.2f}s")
hostlens/client.py ADDED
@@ -0,0 +1,271 @@
1
+ """The public async HostLens client"""
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ import logging
6
+ import time
7
+ from collections.abc import AsyncIterator, Sequence
8
+
9
+ from hostlens.cache import MemoryCache
10
+ from hostlens.collectors import (
11
+ Collector,
12
+ HostnameCollector,
13
+ MdnsCollector,
14
+ NetbiosCollector,
15
+ OuiCollector,
16
+ ServiceCollector,
17
+ SsdpCollector,
18
+ UpnpCollector,
19
+ collect_evidence,
20
+ )
21
+ from hostlens.config import (
22
+ ScanMode,
23
+ parse_scan_mode,
24
+ scan_collectors,
25
+ scan_concurrency,
26
+ scan_timeout,
27
+ validate_options,
28
+ )
29
+ from hostlens.identity.fingerbank import FingerbankClient
30
+ from hostlens.identity.fingerprints import apply_local_rules
31
+ from hostlens.identity.fusion import build_profile
32
+ from hostlens.models import (
33
+ DeviceFound,
34
+ DeviceIdentified,
35
+ DeviceProfile,
36
+ DeviceUpdated,
37
+ ScanCompleted,
38
+ ScanEvent,
39
+ ScanResult,
40
+ ScanSummary,
41
+ Target,
42
+ )
43
+ from hostlens.network.addressing import local_subnet, normalize_ip, validate_subnet
44
+ from hostlens.network.discovery import discover_targets, known_neighbors
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _built_in_collectors() -> tuple[Collector, ...]:
50
+ return (
51
+ HostnameCollector(),
52
+ OuiCollector(),
53
+ MdnsCollector(),
54
+ SsdpCollector(),
55
+ UpnpCollector(),
56
+ NetbiosCollector(),
57
+ ServiceCollector(),
58
+ )
59
+
60
+
61
+ class HostLens:
62
+ """Discover and identify devices through one async API"""
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ interface: str | None = None,
68
+ timeout: float = 3.0,
69
+ concurrency: int = 64,
70
+ cloud: bool = False,
71
+ fingerbank_api_key: str | None = None,
72
+ collectors: Sequence[Collector] | None = None,
73
+ ) -> None:
74
+ validate_options(timeout, concurrency, cloud, fingerbank_api_key)
75
+
76
+ self.interface = interface
77
+ self.timeout = timeout
78
+ self.concurrency = concurrency
79
+ self.cache: MemoryCache[DeviceProfile] = MemoryCache(ttl=300)
80
+
81
+ available = collectors if collectors is not None else _built_in_collectors()
82
+ self.collectors = {collector.name: collector for collector in available}
83
+
84
+ self.fingerbank = None
85
+ if cloud and fingerbank_api_key:
86
+ self.fingerbank = FingerbankClient(fingerbank_api_key, timeout)
87
+
88
+ async def identify(
89
+ self,
90
+ target: str,
91
+ *,
92
+ mode: ScanMode | str = ScanMode.NORMAL,
93
+ ) -> DeviceProfile:
94
+ """Identify one IP address"""
95
+ return await self.scan_host(target, mode=mode)
96
+
97
+ async def scan_host(
98
+ self,
99
+ target: str,
100
+ *,
101
+ mode: ScanMode | str = ScanMode.NORMAL,
102
+ ) -> DeviceProfile:
103
+ """Scan and identify one host"""
104
+ ip = normalize_ip(target)
105
+ cached = self.cache.get(ip)
106
+ if cached:
107
+ return cached
108
+
109
+ resolved_target = await self._find_neighbor(ip)
110
+ device = await self._identify_target(resolved_target, parse_scan_mode(mode))
111
+ self.cache.set(ip, device)
112
+ return device
113
+
114
+ async def scan_hosts(
115
+ self,
116
+ targets: Sequence[str],
117
+ *,
118
+ mode: ScanMode | str = ScanMode.NORMAL,
119
+ ) -> list[DeviceProfile]:
120
+ """Identify several hosts with bounded concurrency"""
121
+ scan_mode = parse_scan_mode(mode)
122
+ limit = min(self.concurrency, scan_concurrency(scan_mode))
123
+ semaphore = asyncio.Semaphore(limit)
124
+
125
+ async def scan_one(target: str) -> DeviceProfile:
126
+ async with semaphore:
127
+ return await self.scan_host(target, mode=scan_mode)
128
+
129
+ tasks = [scan_one(target) for target in targets]
130
+ return list(await asyncio.gather(*tasks))
131
+
132
+ async def scan_network(
133
+ self,
134
+ subnet: str | None = None,
135
+ *,
136
+ mode: ScanMode | str = ScanMode.NORMAL,
137
+ return_result: bool = False,
138
+ ) -> list[DeviceProfile] | ScanResult:
139
+ """Discover and identify devices on one subnet"""
140
+ devices: dict[str, DeviceProfile] = {}
141
+ summary = None
142
+
143
+ async for event in self.scan_stream(subnet, mode=mode):
144
+ if isinstance(event, (DeviceFound, DeviceUpdated, DeviceIdentified)):
145
+ key = event.device.mac or event.device.ip
146
+ devices[key] = event.device
147
+ if isinstance(event, ScanCompleted):
148
+ summary = event.summary
149
+
150
+ found = list(devices.values())
151
+ if return_result:
152
+ if summary is None:
153
+ raise RuntimeError("Scan finished without a summary")
154
+ return ScanResult(devices=found, summary=summary)
155
+ return found
156
+
157
+ async def discover(self, subnet: str | None = None) -> list[DeviceProfile]:
158
+ """Run a fast network scan"""
159
+ result = await self.scan_network(subnet, mode=ScanMode.FAST)
160
+ if isinstance(result, ScanResult):
161
+ return result.devices
162
+ return result
163
+
164
+ async def scan(self, subnet: str | None = None) -> AsyncIterator[ScanEvent]:
165
+ """Stream a normal network scan"""
166
+ async for event in self.scan_stream(subnet):
167
+ yield event
168
+
169
+ async def scan_stream(
170
+ self,
171
+ subnet: str | None = None,
172
+ *,
173
+ mode: ScanMode | str = ScanMode.NORMAL,
174
+ ) -> AsyncIterator[ScanEvent]:
175
+ """Yield devices early and enrich them as the scan continues"""
176
+ started = time.monotonic()
177
+ scan_mode = parse_scan_mode(mode)
178
+ if scan_mode is ScanMode.PASSIVE:
179
+ raise ValueError("Use watch_network for passive observation")
180
+
181
+ network = self._network(subnet)
182
+ targets: list[Target] = []
183
+ arp_timeout = min(scan_timeout(scan_mode), 1.5)
184
+
185
+ async for target in discover_targets(network, self.interface, arp_timeout):
186
+ targets.append(target)
187
+ yield DeviceFound(device=build_profile(target, []))
188
+
189
+ limit = min(self.concurrency, scan_concurrency(scan_mode))
190
+ semaphore = asyncio.Semaphore(limit)
191
+
192
+ async def enrich(target: Target) -> DeviceProfile:
193
+ async with semaphore:
194
+ return await self._identify_target(target, scan_mode)
195
+
196
+ tasks = [asyncio.create_task(enrich(target)) for target in targets]
197
+ for task in asyncio.as_completed(tasks):
198
+ device = await task
199
+ self.cache.set(device.ip, device)
200
+ yield DeviceUpdated(device=device)
201
+ yield DeviceIdentified(device=device)
202
+
203
+ duration = time.monotonic() - started
204
+ checked = max(0, ipaddress.ip_network(network).num_addresses - 2)
205
+ summary = ScanSummary(
206
+ duration=duration,
207
+ targets_checked=checked,
208
+ devices_found=len(targets),
209
+ mode=scan_mode.value,
210
+ )
211
+ yield ScanCompleted(summary=summary)
212
+
213
+ async def watch_network(
214
+ self,
215
+ subnet: str | None = None,
216
+ *,
217
+ mode: ScanMode | str = ScanMode.PASSIVE,
218
+ interval: float = 2.0,
219
+ ) -> AsyncIterator[ScanEvent]:
220
+ """Watch the OS neighbor table without sending probes"""
221
+ if parse_scan_mode(mode) is not ScanMode.PASSIVE:
222
+ raise ValueError("watch_network only supports passive mode")
223
+
224
+ network = self._network(subnet)
225
+ seen: set[str] = set()
226
+
227
+ while True:
228
+ async for target in known_neighbors(network):
229
+ key = target.mac or target.ip
230
+ if key in seen:
231
+ continue
232
+
233
+ seen.add(key)
234
+ device = await self._identify_target(target, ScanMode.PASSIVE)
235
+ yield DeviceFound(device=device)
236
+
237
+ await asyncio.sleep(interval)
238
+
239
+ def preview_cloud_payload(self, device: DeviceProfile) -> dict[str, str]:
240
+ """Show the data that cloud enrichment would receive"""
241
+ if self.fingerbank is None:
242
+ return {}
243
+
244
+ target = Target(ip=device.ip, mac=device.mac)
245
+ return self.fingerbank.preview_payload(target, device.evidence)
246
+
247
+ async def _identify_target(self, target: Target, mode: ScanMode) -> DeviceProfile:
248
+ names = scan_collectors(mode)
249
+ collectors = [self.collectors[name] for name in names if name in self.collectors]
250
+ timeout = min(self.timeout, scan_timeout(mode))
251
+ evidence = await collect_evidence(collectors, target, timeout)
252
+
253
+ if mode in {ScanMode.NORMAL, ScanMode.DEEP, ScanMode.PASSIVE}:
254
+ evidence.extend(apply_local_rules(evidence))
255
+
256
+ if self.fingerbank and mode is not ScanMode.FAST:
257
+ try:
258
+ evidence.extend(await self.fingerbank.resolve(target, evidence))
259
+ except Exception as error:
260
+ logger.debug("Fingerbank enrichment failed: %s", error)
261
+
262
+ return build_profile(target, evidence)
263
+
264
+ async def _find_neighbor(self, ip: str) -> Target:
265
+ subnet = str(ipaddress.ip_network(f"{ip}/32", strict=False))
266
+ async for target in known_neighbors(subnet):
267
+ return target
268
+ return Target(ip=ip)
269
+
270
+ def _network(self, subnet: str | None) -> str:
271
+ return validate_subnet(subnet or local_subnet(self.interface))
@@ -0,0 +1,21 @@
1
+ """Built-in evidence collectors"""
2
+
3
+ from hostlens.collectors.base import Collector, collect_evidence
4
+ from hostlens.collectors.basic import HostnameCollector, OuiCollector
5
+ from hostlens.collectors.mdns import MdnsCollector
6
+ from hostlens.collectors.netbios import NetbiosCollector
7
+ from hostlens.collectors.services import ServiceCollector
8
+ from hostlens.collectors.ssdp import SsdpCollector
9
+ from hostlens.collectors.upnp import UpnpCollector
10
+
11
+ __all__ = [
12
+ "Collector",
13
+ "HostnameCollector",
14
+ "MdnsCollector",
15
+ "NetbiosCollector",
16
+ "OuiCollector",
17
+ "ServiceCollector",
18
+ "SsdpCollector",
19
+ "UpnpCollector",
20
+ "collect_evidence",
21
+ ]
@@ -0,0 +1,41 @@
1
+ """Shared collector runner"""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Sequence
6
+ from typing import Protocol
7
+
8
+ from hostlens.models import Evidence, Target
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Collector(Protocol):
14
+ name: str
15
+
16
+ async def collect(self, target: Target, timeout: float) -> list[Evidence]: ...
17
+
18
+
19
+ async def collect_evidence(
20
+ collectors: Sequence[Collector],
21
+ target: Target,
22
+ timeout: float,
23
+ ) -> list[Evidence]:
24
+ """Run independent collectors together and isolate their failures"""
25
+ results = await asyncio.gather(
26
+ *(_collect_safely(collector, target, timeout) for collector in collectors)
27
+ )
28
+ return [item for result in results for item in result]
29
+
30
+
31
+ async def _collect_safely(
32
+ collector: Collector,
33
+ target: Target,
34
+ timeout: float,
35
+ ) -> list[Evidence]:
36
+ try:
37
+ async with asyncio.timeout(timeout):
38
+ return await collector.collect(target, timeout)
39
+ except Exception as error:
40
+ logger.debug("Collector %s failed for %s: %s", collector.name, target.ip, error)
41
+ return []
@@ -0,0 +1,65 @@
1
+ """Fast hostname and manufacturer collectors"""
2
+
3
+ import asyncio
4
+ import socket
5
+
6
+ from hostlens.models import Evidence, Target
7
+
8
+
9
+ class HostnameCollector:
10
+ name = "hostname"
11
+
12
+ async def collect(self, target: Target, timeout: float) -> list[Evidence]:
13
+ try:
14
+ result = await asyncio.wait_for(
15
+ asyncio.to_thread(socket.gethostbyaddr, target.ip),
16
+ timeout=timeout,
17
+ )
18
+ except (TimeoutError, OSError):
19
+ return []
20
+
21
+ hostname = result[0].rstrip(".")
22
+ return [
23
+ Evidence(
24
+ source=self.name,
25
+ field="hostname",
26
+ value=hostname,
27
+ confidence=0.75,
28
+ description=f"Reverse DNS reports hostname {hostname}",
29
+ )
30
+ ]
31
+
32
+
33
+ class OuiCollector:
34
+ name = "oui"
35
+
36
+ def __init__(self) -> None:
37
+ self.parser: object | None = None
38
+
39
+ async def collect(self, target: Target, timeout: float) -> list[Evidence]:
40
+ del timeout
41
+ if not target.mac:
42
+ return []
43
+
44
+ manufacturer = await asyncio.to_thread(self._lookup, target.mac)
45
+ if not manufacturer:
46
+ return []
47
+
48
+ return [
49
+ Evidence(
50
+ source=self.name,
51
+ field="manufacturer",
52
+ value=manufacturer,
53
+ confidence=0.78,
54
+ description=f"MAC prefix matches {manufacturer}",
55
+ )
56
+ ]
57
+
58
+ def _lookup(self, mac: str) -> str | None:
59
+ from manuf import manuf # type: ignore[import-untyped]
60
+
61
+ if self.parser is None:
62
+ self.parser = manuf.MacParser(update=False)
63
+
64
+ manufacturer = self.parser.get_manuf_long(mac) or self.parser.get_manuf(mac) # type: ignore[attr-defined]
65
+ return str(manufacturer).strip() if manufacturer else None