lanfence 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.
lanfence/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026-present Stable State Consulting Ltd
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """LAN Fence - defensive LAN device monitor."""
5
+
6
+ __version__ = "0.1.0"
lanfence/alerts.py ADDED
@@ -0,0 +1,134 @@
1
+ # Copyright (c) 2026-present Stable State Consulting Ltd
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Alert dispatch: syslog, email, webhook.
5
+
6
+ LAN Fence never calls out to any third-party service on its own - the
7
+ operator opts into each channel explicitly in config, and every channel here
8
+ is a destination *they* configured (their own syslog daemon, mail relay, or
9
+ webhook endpoint).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import smtplib
16
+ import syslog
17
+ import urllib.error
18
+ import urllib.request
19
+ from email.message import EmailMessage
20
+
21
+ from lanfence.config import AlertConfig
22
+ from lanfence.logging_config import get_logger
23
+ from lanfence.models import Finding
24
+
25
+ log = get_logger("alerts")
26
+
27
+ _SEVERITY_RANK = {"info": 0, "medium": 1, "high": 2}
28
+
29
+ _SYSLOG_FACILITIES = {
30
+ "user": syslog.LOG_USER,
31
+ "daemon": syslog.LOG_DAEMON,
32
+ "local0": syslog.LOG_LOCAL0,
33
+ "local1": syslog.LOG_LOCAL1,
34
+ "local2": syslog.LOG_LOCAL2,
35
+ "local3": syslog.LOG_LOCAL3,
36
+ "local4": syslog.LOG_LOCAL4,
37
+ "local5": syslog.LOG_LOCAL5,
38
+ "local6": syslog.LOG_LOCAL6,
39
+ "local7": syslog.LOG_LOCAL7,
40
+ }
41
+
42
+ _SYSLOG_SEVERITY = {
43
+ "high": syslog.LOG_ALERT,
44
+ "medium": syslog.LOG_WARNING,
45
+ "info": syslog.LOG_INFO,
46
+ }
47
+
48
+
49
+ def findings_to_alert(findings: list[Finding], cfg: AlertConfig) -> list[Finding]:
50
+ """Findings at or above ``cfg.min_severity``, worth dispatching."""
51
+
52
+ threshold = _SEVERITY_RANK[cfg.min_severity]
53
+ return [f for f in findings if _SEVERITY_RANK[f.severity] >= threshold]
54
+
55
+
56
+ def send_syslog(findings: list[Finding], cfg: AlertConfig) -> None:
57
+ if not cfg.syslog.enabled or not findings:
58
+ return
59
+ facility = _SYSLOG_FACILITIES.get(cfg.syslog.facility, syslog.LOG_USER)
60
+ syslog.openlog(ident="lanfence", facility=facility)
61
+ try:
62
+ for finding in findings:
63
+ priority = _SYSLOG_SEVERITY.get(finding.severity, syslog.LOG_INFO)
64
+ syslog.syslog(priority, f"[{finding.severity.upper()}] {finding.title} (mac={finding.mac})")
65
+ finally:
66
+ syslog.closelog()
67
+
68
+
69
+ def send_email(findings: list[Finding], cfg: AlertConfig) -> None:
70
+ if not cfg.email.enabled or not findings:
71
+ return
72
+ if not cfg.email.to_addrs or not cfg.email.from_addr:
73
+ log.warning("email alerts enabled but from_addr/to_addrs not configured; skipping")
74
+ return
75
+
76
+ lines = [f"LAN Fence: {len(findings)} finding(s)\n"]
77
+ for finding in findings:
78
+ lines.append(f"[{finding.severity.upper()}] {finding.title}")
79
+ lines.append(f" MAC: {finding.mac}")
80
+ if finding.rationale:
81
+ lines.append(f" {finding.rationale}")
82
+ if finding.recommendation:
83
+ lines.append(f" Recommendation: {finding.recommendation}")
84
+ lines.append("")
85
+
86
+ msg = EmailMessage()
87
+ msg["Subject"] = f"LAN Fence: {len(findings)} finding(s) on your network"
88
+ msg["From"] = cfg.email.from_addr
89
+ msg["To"] = ", ".join(cfg.email.to_addrs)
90
+ msg.set_content("\n".join(lines))
91
+
92
+ try:
93
+ with smtplib.SMTP(cfg.email.smtp_host, cfg.email.smtp_port, timeout=10) as smtp:
94
+ if cfg.email.use_tls:
95
+ smtp.starttls()
96
+ if cfg.email.username and cfg.email.password:
97
+ smtp.login(cfg.email.username, cfg.email.password)
98
+ smtp.send_message(msg)
99
+ except (smtplib.SMTPException, OSError) as exc:
100
+ log.error("failed to send email alert: %s", exc)
101
+
102
+
103
+ def send_webhook(findings: list[Finding], cfg: AlertConfig) -> None:
104
+ if not cfg.webhook.enabled or not findings:
105
+ return
106
+ if not cfg.webhook.url:
107
+ log.warning("webhook alerts enabled but no url configured; skipping")
108
+ return
109
+
110
+ payload = json.dumps({"findings": [f.model_dump(mode="json") for f in findings]}).encode("utf-8")
111
+ request = urllib.request.Request(
112
+ cfg.webhook.url,
113
+ data=payload,
114
+ headers={"Content-Type": "application/json", "User-Agent": "lanfence"},
115
+ method="POST",
116
+ )
117
+ try:
118
+ with urllib.request.urlopen(request, timeout=cfg.webhook.timeout_seconds) as resp: # noqa: S310
119
+ resp.read()
120
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
121
+ log.error("failed to send webhook alert: %s", exc)
122
+
123
+
124
+ def dispatch(findings: list[Finding], cfg: AlertConfig) -> list[Finding]:
125
+ """Send every finding at or above ``cfg.min_severity`` to every enabled
126
+ channel. Returns the findings that were dispatched."""
127
+
128
+ to_send = findings_to_alert(findings, cfg)
129
+ if not to_send:
130
+ return []
131
+ send_syslog(to_send, cfg)
132
+ send_email(to_send, cfg)
133
+ send_webhook(to_send, cfg)
134
+ return to_send
lanfence/allowlist.py ADDED
@@ -0,0 +1,101 @@
1
+ # Copyright (c) 2026-present Stable State Consulting Ltd
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """The allowlist of devices you trust (``lanfence allow``).
5
+
6
+ Findings about an allowlisted device are downgraded to ``info`` so your own
7
+ router, phones and laptops stop showing up as "unknown device" every time they
8
+ reconnect. This is just data - allowlisting a MAC does not verify it, and MAC
9
+ addresses are trivially spoofed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from lanfence.fsutil import atomic_write
20
+ from lanfence.logging_config import get_logger
21
+ from lanfence.netutil import normalize_mac
22
+
23
+ log = get_logger("allowlist")
24
+
25
+
26
+ @dataclass
27
+ class AllowEntry:
28
+ mac: str
29
+ name: str
30
+ notes: str = ""
31
+
32
+ def as_dict(self) -> dict:
33
+ row = {"mac": self.mac, "name": self.name}
34
+ if self.notes:
35
+ row["notes"] = self.notes
36
+ return row
37
+
38
+
39
+ class Allowlist:
40
+ def __init__(self, entries: list[AllowEntry], path: Path | None = None) -> None:
41
+ self.entries = entries
42
+ self.path = path
43
+
44
+ def __len__(self) -> int:
45
+ return len(self.entries)
46
+
47
+ @classmethod
48
+ def load(cls, path: Path | str | None) -> "Allowlist":
49
+ if path is None:
50
+ return cls([], None)
51
+ path = Path(path)
52
+ if not path.is_file():
53
+ return cls([], path)
54
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
55
+ entries: list[AllowEntry] = []
56
+ for row in data.get("allow", []) or []:
57
+ try:
58
+ entries.append(
59
+ AllowEntry(
60
+ mac=normalize_mac(str(row["mac"])),
61
+ name=str(row.get("name") or "unnamed"),
62
+ notes=str(row.get("notes") or ""),
63
+ )
64
+ )
65
+ except (KeyError, TypeError, ValueError):
66
+ log.warning("skipping malformed allowlist entry: %r", row)
67
+ return cls(entries, path)
68
+
69
+ def save(self) -> None:
70
+ if self.path is None:
71
+ raise ValueError("allowlist has no path to save to")
72
+ self.path.parent.mkdir(parents=True, exist_ok=True)
73
+ body = yaml.safe_dump({"allow": [e.as_dict() for e in self.entries]}, sort_keys=False)
74
+ atomic_write(
75
+ self.path,
76
+ "# LAN Fence allowlist - devices you trust; their findings are\n"
77
+ "# downgraded to info. A MAC address is trivially spoofed, so this is\n"
78
+ "# a mute button, not proof of identity.\n" + body,
79
+ mode=0o644,
80
+ )
81
+
82
+ def match(self, mac: str) -> AllowEntry | None:
83
+ norm = normalize_mac(mac)
84
+ for entry in self.entries:
85
+ if entry.mac == norm:
86
+ return entry
87
+ return None
88
+
89
+ def add(self, mac: str, name: str, notes: str = "") -> AllowEntry:
90
+ norm = normalize_mac(mac)
91
+ entry = AllowEntry(norm, name, notes)
92
+ self.entries = [e for e in self.entries if e.mac != norm]
93
+ self.entries.append(entry)
94
+ return entry
95
+
96
+ def remove(self, mac: str) -> AllowEntry | None:
97
+ norm = normalize_mac(mac)
98
+ existing = self.match(norm)
99
+ if existing is not None:
100
+ self.entries = [e for e in self.entries if e.mac != norm]
101
+ return existing
lanfence/cli.py ADDED
@@ -0,0 +1,394 @@
1
+ # Copyright (c) 2026-present Stable State Consulting Ltd
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """LAN Fence command-line interface (Typer)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import queue
11
+ import sys
12
+ import threading
13
+ import time
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+
20
+ from lanfence import __version__, alerts, scanner
21
+ from lanfence.allowlist import Allowlist
22
+ from lanfence.config import Config
23
+ from lanfence.db import DeviceStore
24
+ from lanfence.engine import build_findings, process_sighting, run_active_sweep
25
+ from lanfence.fingerprint import SignatureSet, fingerprint_device
26
+ from lanfence.logging_config import setup_logging
27
+ from lanfence.models import Finding
28
+ from lanfence.report import (
29
+ exit_code_for,
30
+ exit_code_for_findings,
31
+ render_events,
32
+ render_findings,
33
+ render_scan_result,
34
+ )
35
+
36
+ app = typer.Typer(
37
+ add_completion=False,
38
+ no_args_is_help=True,
39
+ help=(
40
+ "LAN Fence - defensive LAN device monitor.\n\n"
41
+ "Scans for connected devices via ARP, tracks them against an allowlist "
42
+ "of devices you trust, and alerts when something unknown joins your "
43
+ "network. Observation only; LAN Fence never modifies the network."
44
+ ),
45
+ )
46
+
47
+
48
+ def _version_callback(value: bool) -> None:
49
+ if value:
50
+ typer.echo(f"lanfence {__version__}")
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback()
55
+ def _root(
56
+ version: bool = typer.Option(
57
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
58
+ ),
59
+ ) -> None:
60
+ pass
61
+
62
+
63
+ def _load_config(config_path: Optional[Path]) -> Config:
64
+ try:
65
+ return Config.load(config_path)
66
+ except Exception as exc: # noqa: BLE001
67
+ typer.secho(f"error: could not load config: {exc}", fg="red", err=True)
68
+ raise typer.Exit(code=2) from exc
69
+
70
+
71
+ def _is_root() -> bool:
72
+ return hasattr(os, "geteuid") and os.geteuid() == 0
73
+
74
+
75
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
76
+
77
+
78
+ def _parse_since(value: str) -> datetime:
79
+ """Parse a duration like ``24h``, ``30m``, ``7d`` into a UTC cutoff datetime."""
80
+
81
+ value = value.strip().lower()
82
+ if value and value[-1] in _UNIT_SECONDS and value[:-1].replace(".", "", 1).isdigit():
83
+ amount = float(value[:-1])
84
+ seconds = amount * _UNIT_SECONDS[value[-1]]
85
+ return datetime.now(timezone.utc) - timedelta(seconds=seconds)
86
+ typer.secho(
87
+ f"error: could not parse --since {value!r} (expected e.g. 24h, 30m, 7d)",
88
+ fg="red", err=True,
89
+ )
90
+ raise typer.Exit(code=2)
91
+
92
+
93
+ @app.command()
94
+ def scan(
95
+ interface: Optional[str] = typer.Option(None, "--interface", "-i", help="Network interface to scan."),
96
+ subnet: Optional[str] = typer.Option(None, "--subnet", "-s", help="CIDR subnet to scan (default: auto-detect)."),
97
+ output_format: str = typer.Option("table", "--format", "-f", help="table | json"),
98
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
99
+ alert: bool = typer.Option(False, "--alert", help="Dispatch alerts for findings via configured channels."),
100
+ fail_on_findings: bool = typer.Option(
101
+ False, "--fail-on-findings", help="Exit non-zero when medium+ findings are present."
102
+ ),
103
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
104
+ ) -> None:
105
+ """One-time active ARP scan; shows connected devices and any findings."""
106
+
107
+ setup_logging(verbose)
108
+ cfg = _load_config(config)
109
+
110
+ if not _is_root():
111
+ typer.secho(
112
+ "warning: not running as root - active ARP scanning needs raw-socket "
113
+ "access. Re-run with sudo if this returns no devices.",
114
+ fg="yellow", err=True,
115
+ )
116
+
117
+ signatures = SignatureSet.load(cfg.rogue_signatures_file)
118
+ allowlist = Allowlist.load(cfg.resolved_allowlist_file())
119
+
120
+ with DeviceStore(cfg.resolved_db_path()) as store:
121
+ result = run_active_sweep(cfg, store, allowlist, signatures, interface=interface, subnet=subnet)
122
+
123
+ if alert:
124
+ alerts.dispatch(result.findings, cfg.alerts)
125
+
126
+ if output_format == "json":
127
+ typer.echo(result.to_json())
128
+ else:
129
+ render_scan_result(result)
130
+
131
+ if fail_on_findings:
132
+ raise typer.Exit(code=exit_code_for(result))
133
+
134
+
135
+ def _emit_findings(findings: list[Finding], *, alert: bool, cfg: Config) -> None:
136
+ colour = {"high": "red", "medium": "yellow", "info": "cyan"}
137
+ for finding in findings:
138
+ typer.secho(
139
+ f"[{finding.severity.upper()}] {finding.title} (mac={finding.mac})",
140
+ fg=colour.get(finding.severity, "white"), bold=(finding.severity == "high"),
141
+ )
142
+ if finding.rationale:
143
+ typer.echo(f" {finding.rationale}")
144
+ if finding.recommendation:
145
+ typer.secho(f" Recommendation: {finding.recommendation}", fg="cyan")
146
+ if alert and findings:
147
+ alerts.dispatch(findings, cfg.alerts)
148
+
149
+
150
+ @app.command()
151
+ def monitor(
152
+ interface: Optional[str] = typer.Option(None, "--interface", "-i", help="Network interface to monitor."),
153
+ subnet: Optional[str] = typer.Option(None, "--subnet", "-s", help="CIDR subnet to actively sweep."),
154
+ interval: Optional[float] = typer.Option(None, "--interval", help="Active-sweep interval override (seconds)."),
155
+ passive: Optional[bool] = typer.Option(
156
+ None, "--passive/--no-passive", help="Also passively sniff ARP traffic between sweeps."
157
+ ),
158
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
159
+ alert: bool = typer.Option(True, "--alert/--no-alert", help="Dispatch alerts for findings as they occur."),
160
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
161
+ ) -> None:
162
+ """Continuously watch for new/changed devices until interrupted (Ctrl+C)."""
163
+
164
+ setup_logging(verbose)
165
+ cfg = _load_config(config)
166
+ if interval is not None:
167
+ cfg.scan.scan_interval_seconds = interval
168
+ if passive is not None:
169
+ cfg.scan.passive = passive
170
+
171
+ if not _is_root():
172
+ typer.secho(
173
+ "warning: not running as root - ARP scanning/sniffing needs raw-socket "
174
+ "access. Re-run with sudo for full monitoring.",
175
+ fg="yellow", err=True,
176
+ )
177
+
178
+ signatures = SignatureSet.load(cfg.rogue_signatures_file)
179
+ allowlist = Allowlist.load(cfg.resolved_allowlist_file())
180
+ store = DeviceStore(cfg.resolved_db_path())
181
+
182
+ iface = interface or cfg.scan.interface or scanner.default_interface()
183
+ net = subnet or cfg.scan.subnet
184
+
185
+ typer.secho(f"LAN Fence {__version__} - monitoring (Ctrl+C to stop)", fg="green", bold=True)
186
+ typer.echo(
187
+ f"interface: {iface or '(auto)'} scan interval: {cfg.scan.scan_interval_seconds:.0f}s "
188
+ f"passive: {cfg.scan.passive}"
189
+ )
190
+
191
+ stop_event = threading.Event()
192
+ passive_queue: "queue.Queue[scanner.ArpSighting]" = queue.Queue()
193
+
194
+ def _run_passive() -> None:
195
+ try:
196
+ scanner.passive_sniff(on_sighting=passive_queue.put, interface=iface, stop_event=stop_event)
197
+ except scanner.ScannerUnavailable as exc:
198
+ typer.secho(f"passive monitoring unavailable: {exc}", fg="yellow", err=True)
199
+
200
+ passive_thread: threading.Thread | None = None
201
+ if cfg.scan.passive:
202
+ passive_thread = threading.Thread(target=_run_passive, daemon=True)
203
+ passive_thread.start()
204
+
205
+ try:
206
+ last_sweep = 0.0
207
+ while True:
208
+ now = time.monotonic()
209
+ if now - last_sweep >= cfg.scan.scan_interval_seconds:
210
+ result = run_active_sweep(cfg, store, allowlist, signatures, interface=iface, subnet=net)
211
+ last_sweep = now
212
+ for err in result.errors:
213
+ typer.secho(f"error: {err}", fg="red", err=True)
214
+ _emit_findings(result.findings, alert=alert, cfg=cfg)
215
+
216
+ drained = 0
217
+ while drained < 200:
218
+ try:
219
+ sighting = passive_queue.get_nowait()
220
+ except queue.Empty:
221
+ break
222
+ drained += 1
223
+ _, _event_type, findings = process_sighting(
224
+ mac=sighting.mac, ip=sighting.ip, seen_at=sighting.seen_at,
225
+ store=store, allowlist=allowlist, signatures=signatures, cfg=cfg,
226
+ )
227
+ _emit_findings(findings, alert=alert, cfg=cfg)
228
+
229
+ time.sleep(1.0)
230
+ except KeyboardInterrupt:
231
+ typer.echo("\nstopping monitor...")
232
+ finally:
233
+ stop_event.set()
234
+ store.close()
235
+
236
+
237
+ @app.command()
238
+ def report(
239
+ since: str = typer.Option("24h", "--since", help="How far back to report, e.g. 30m, 24h, 7d."),
240
+ output_format: str = typer.Option("table", "--format", "-f", help="table | json"),
241
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
242
+ fail_on_findings: bool = typer.Option(
243
+ False, "--fail-on-findings", help="Exit non-zero when medium+ findings are present in the window."
244
+ ),
245
+ ) -> None:
246
+ """Summarize device activity (connects/disconnects/reappearances) since a point in time."""
247
+
248
+ cfg = _load_config(config)
249
+ since_dt = _parse_since(since)
250
+ signatures = SignatureSet.load(cfg.rogue_signatures_file)
251
+ allowlist = Allowlist.load(cfg.resolved_allowlist_file())
252
+
253
+ with DeviceStore(cfg.resolved_db_path()) as store:
254
+ events = store.events_since(since_dt)
255
+ devices_by_mac = {d.mac: d for d in store.all_devices()}
256
+
257
+ findings: list[Finding] = []
258
+ for event in events:
259
+ if event.event_type == "disconnected":
260
+ continue
261
+ device = devices_by_mac.get(event.mac)
262
+ if device is None:
263
+ continue
264
+ allow_entry = allowlist.match(event.mac)
265
+ device = device.model_copy(
266
+ update={
267
+ "allowlisted": allow_entry is not None,
268
+ "allowlist_name": allow_entry.name if allow_entry else None,
269
+ }
270
+ )
271
+ _, matches = fingerprint_device(
272
+ event.mac, device.hostname, signatures=signatures, vendor_file=cfg.vendor_file
273
+ )
274
+ findings.extend(build_findings(device, event.event_type, matches))
275
+
276
+ if output_format == "json":
277
+ payload = {
278
+ "since": since_dt.isoformat(),
279
+ "events": [e.model_dump(mode="json") for e in events],
280
+ "findings": [f.model_dump(mode="json") for f in findings],
281
+ }
282
+ typer.echo(json.dumps(payload, indent=2))
283
+ else:
284
+ typer.secho(f"LAN Fence report - since {since_dt.isoformat()}", fg="cyan", bold=True)
285
+ render_events(events)
286
+ typer.echo("")
287
+ render_findings(findings)
288
+
289
+ if fail_on_findings:
290
+ raise typer.Exit(code=exit_code_for_findings(findings))
291
+
292
+
293
+ @app.command()
294
+ def allow(
295
+ mac: Optional[str] = typer.Argument(None, help="MAC address to trust."),
296
+ name: Optional[str] = typer.Option(None, "--name", help="Label for this device."),
297
+ notes: Optional[str] = typer.Option(None, "--notes", help="Freeform notes."),
298
+ list_entries: bool = typer.Option(False, "--list", help="Show current allowlist entries."),
299
+ remove: Optional[str] = typer.Option(None, "--remove", help="MAC address to remove from the allowlist."),
300
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
301
+ ) -> None:
302
+ """Manage the allowlist of devices you trust.
303
+
304
+ Findings about an allowlisted device are downgraded to *info*, so your own
305
+ hardware stops shouting every time it reconnects. With no MAC and no
306
+ options, lists the current entries.
307
+ """
308
+
309
+ cfg = _load_config(config)
310
+ path = cfg.resolved_allowlist_file()
311
+ al = Allowlist.load(path)
312
+ al.path = path
313
+
314
+ if remove is not None:
315
+ entry = al.remove(remove)
316
+ if entry is None:
317
+ typer.secho(f"error: {remove} is not on the allowlist", fg="red", err=True)
318
+ raise typer.Exit(code=2)
319
+ al.save()
320
+ typer.secho(f"removed: {entry.name} ({entry.mac})", fg="green")
321
+ return
322
+
323
+ if list_entries or mac is None:
324
+ if not al.entries:
325
+ typer.echo(f"allowlist is empty ({path})")
326
+ return
327
+ typer.secho(f"Allowlist ({path}):\n", fg="cyan", bold=True)
328
+ for i, e in enumerate(al.entries, start=1):
329
+ tail = f" {e.notes}" if e.notes else ""
330
+ typer.echo(f" {i:>3}. {e.name:<28} {e.mac}{tail}")
331
+ return
332
+
333
+ entry = al.add(mac, name or mac, notes or "")
334
+ al.save()
335
+ typer.secho(f"added: {entry.name} ({entry.mac})", fg="green")
336
+
337
+
338
+ @app.command()
339
+ def check(
340
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="YAML config file."),
341
+ ) -> None:
342
+ """Check that this host can run LAN Fence (permissions, scapy, interface)."""
343
+
344
+ cfg = _load_config(config)
345
+
346
+ typer.secho("Host", fg="cyan", bold=True)
347
+ typer.echo(f" root: {_is_root()}")
348
+ typer.echo(f" platform: {sys.platform}")
349
+
350
+ typer.secho("\nScanning", fg="cyan", bold=True)
351
+ try:
352
+ import scapy # noqa: F401
353
+
354
+ typer.secho(" ok scapy is installed", fg="green")
355
+ except ImportError:
356
+ typer.secho(
357
+ " MISSING scapy is not installed (pip install 'lanfence[scan]')", fg="yellow"
358
+ )
359
+
360
+ iface = cfg.scan.interface or scanner.default_interface()
361
+ typer.echo(f" interface: {iface or '(could not auto-detect)'}")
362
+ net = cfg.scan.subnet or scanner.local_subnet(iface)
363
+ typer.echo(f" subnet: {net or '(could not auto-detect)'}")
364
+
365
+ typer.secho("\nStorage", fg="cyan", bold=True)
366
+ db_path = cfg.resolved_db_path()
367
+ try:
368
+ db_path.parent.mkdir(parents=True, exist_ok=True)
369
+ DeviceStore(db_path).close()
370
+ typer.secho(f" ok database writable: {db_path}", fg="green")
371
+ db_ok = True
372
+ except OSError as exc:
373
+ typer.secho(f" FAIL database not writable: {db_path} ({exc})", fg="red")
374
+ db_ok = False
375
+
376
+ allowlist_file = cfg.resolved_allowlist_file()
377
+ typer.echo(f" allowlist: {allowlist_file} ({'exists' if allowlist_file.is_file() else 'not created yet'})")
378
+
379
+ if not _is_root():
380
+ typer.secho(
381
+ "\nnot running as root: active/passive ARP scanning will likely fail. "
382
+ "Re-run with sudo for a full check.",
383
+ fg="yellow",
384
+ )
385
+
386
+ raise typer.Exit(code=0 if db_ok else 1)
387
+
388
+
389
+ def main() -> None: # pragma: no cover - entry point shim
390
+ app()
391
+
392
+
393
+ if __name__ == "__main__": # pragma: no cover
394
+ sys.exit(app())