Skip to main content

LAN Fence

LAN Fence is an open-source defensive network device monitor for Linux (developed and tested on Raspberry Pi OS / Debian, and reasonably portable to other Debian/Ubuntu systems).

LAN Fence runs on a small Linux box (a Raspberry Pi is the common case) sitting on your network. It continuously scans for connected devices via ARP and IPv6 neighbor discovery, maintains an allowlist of devices you already trust, and alerts in plain language when something unknown joins - a rogue device, unauthorized hardware, or a supply-chain implant on your LAN.

LAN Fence only observes. It sends nothing beyond a standard ARP "who-has" request or IPv6 multicast ping (the same things every device on your LAN does routinely) and never touches, blocks, deauthenticates or spoofs anything.

⚠️ LAN Fence cannot prove a device is malicious, or that a MAC address is genuine. MAC vendor prefixes and hostnames are trivially spoofed by anyone deliberately trying to blend in. A finding is a lead worth checking by hand, not a verdict - use it as one input to your own judgement.

How it works

  1. Active scanning - LAN Fence periodically ARP-sweeps your IPv4 subnet and, unless disabled, pings the IPv6 all-nodes multicast address to reach every IPv6-enabled host on the link too (lanfence scan for one sweep, or on an interval inside lanfence monitor) - so a device that's IPv6-only, or deliberately configured off IPv4 to dodge an ARP-only monitor, doesn't go unseen. Every discovered MAC/IP pairing feeds the same pipeline regardless of address family.
  2. Passive monitoring - between active sweeps, lanfence monitor also listens for ARP and IPv6 neighbor-discovery traffic on the wire, so a device that joins mid-interval is caught sooner rather than waiting for the next sweep. It also snoops DHCP traffic for a device's self-reported hostname (option 12) - often available, faster, and more reliable than reverse-DNS, and especially useful right when a brand-new device joins and sends its first DHCP request.
  3. Every sighting is folded into a persistent SQLite database keyed by MAC address, which tracks each device's lifecycle: new_device the first time it's ever seen, reappeared if it had gone offline and came back, and disconnected once an active sweep has confirmed it's actually gone (see "Offline detection and grace periods" below - one missed reply doesn't mean gone).
  4. Each device is fingerprinted: an offline OUI → vendor lookup, a set of built-in rogue-device signatures (see below), and a check of whether its MAC is locally administered (randomized/spoofed rather than vendor-assigned).
  5. Each device is checked against your allowlist (lanfence allow <mac>). A brand-new or reappearing device not on the allowlist produces a plain-language finding with a severity (high/medium/info), a rationale, and a recommendation; an allowlisted device is downgraded to info so your own hardware stops shouting every time it reconnects. LAN Fence also automatically identifies and trusts itself - its own MAC address on the interface it's using - so its own ARP traffic during a sweep, or its own frames a passive capture inevitably sees, is never mistaken for an unknown device (see below).
  6. Findings can be dispatched to syslog, email, a generic webhook, Slack, Discord, Microsoft Teams, ntfy, or Twilio SMS, and everything is available as a CLI table or JSON for automation.

Built-in rogue-device signatures

Heuristics, not proof - a match is a lead to check by hand:

Signal Category Why it matters
Vendor: Espressif / Ai-Thinker esp32_esp8266 ESP32/ESP8266 - the chipset behind most cheap DIY hidden cameras, rogue APs, and ESP32-based Wi-Fi implants (as well as plenty of legitimate IoT).
Vendor: Raspberry Pi raspberry_pi Legitimate everywhere, but also the common hardware basis for rogue network-tap / implant projects (P4wnP1, home-built taps).
Vendor: Orange Pi (Shenzhen Xunlong) orange_pi Same rationale as Raspberry Pi - a legitimate SBC also common in DIY implant projects.
Vendor: Allwinner allwinner_sbc_or_camera Common in budget SBCs, Android TV boxes, and cheap white-label Wi-Fi cameras.
Vendor: HiSilicon hisilicon_camera_soc The Hi3516/Hi3518-family SoC behind huge numbers of cheap white-label IP cameras/DVRs - and the hardware base widely reported behind the Mirai botnet and its successors.
Vendor: ASIX Electronics usb_ethernet_gadget USB-Ethernet chipset used both by ordinary dongles and by BadUSB tools (Bash Bunny, LAN Turtle, O.MG cable) presenting as a network adapter.
Hostname contains pwnagotchi pwnagotchi Pwnagotchi's own default hostname (main.name in its default config) - confirmed from the project's source.
Hostname contains bunny / turtle / pineapple Hak5 tooling Commonly-reported default hostnames for Bash Bunny / LAN Turtle / WiFi Pineapple - corroborated from Hak5's own community forum and docs, not an official spec page, and short enough to occasionally match an unrelated device.
Locally administered MAC locally_administered_mac No vendor OUI - common for privacy MAC-randomization on phones/laptops, but also for spoofed or gadget hardware.

Extend or override these with your own rogue_signatures_file: (same YAML shape as lanfence/data/rogue_signatures.yaml) and vendor_file: (same tab-separated shape as lanfence/data/oui_vendors.txt) in config.

LAN Fence trusts itself

The host running lanfence scan/monitor is on the network it's watching, so its own MAC address inevitably shows up - in its own ARP request during an active sweep, and in whatever a concurrent passive capture sees. LAN Fence detects its own MAC on the interface it's using (via the OS, not a network probe) and treats it as trusted automatically, the same way an lanfence allow-ed device is: findings about it are downgraded to info, and it never occupies the lanfence review queue.

This self-trust is never written to your allowlist file - it's computed fresh each run from the live interface, so moving LAN Fence to different hardware or a different NIC never leaves a stale entry behind. If you've already explicitly lanfence allow-ed this same MAC yourself under your own name, that choice is left alone rather than overwritten.

Vendor lookups

The bundled lanfence/data/oui_vendors.txt is a full snapshot (~40,000 entries) of the IEEE's public MA-L OUI registry, taken when this version was built. LAN Fence makes no network calls on its own and does not auto-update it; run lanfence vendor-refresh to pull a current copy on demand:

lanfence vendor-refresh                       # -> ~/.config/lanfence/oui_vendors.txt
lanfence vendor-refresh --output my_ouis.txt  # or choose where to save it

It's saved as an extra table (never overwriting the packaged one) - add vendor_file: <path it printed> to your config to have scan/monitor merge it on top of the built-in table. This is the one deliberate, operator-triggered exception to "no network calls", the same as lanfence upgrade checking PyPI - it only ever runs when you type the command.

Install

pipx install "lanfence[scan]"     # isolated, recommended - includes scapy for scanning

scan/monitor need the scan extra (scapy) to actually send/receive ARP packets; allow, report and check work without it. Already installed without the extra? Add it in place:

pipx inject lanfence scapy

or, without pipx:

python3 -m venv ~/.venvs/lanfence
~/.venvs/lanfence/bin/pip install 'lanfence[scan]'

Scanning needs raw-socket access, so scan/monitor typically need sudo (or CAP_NET_RAW on the interpreter). allow, report and check do not.

Commands

lanfence scan                  # one-time active ARP scan; table + findings
lanfence run                    # exact alias for `scan`
lanfence scan --format json    # same, machine-readable
lanfence monitor                # continuous: active sweeps + passive sniffing
lanfence allow <MAC> --name X   # trust a device; its findings become info
lanfence allow <MAC> --yes      # skip the device-context confirmation prompt (scripted use)
lanfence allow --list           # show the allowlist
lanfence allow --remove <MAC>   # untrust a device
lanfence reset                  # permanently wipe scanned device history (and allowlist)
lanfence devices                # list previously observed devices - no scan
lanfence devices --review-needed --format json
lanfence device <MAC>           # one device's details, trust state, timeline
lanfence device <MAC> --presence intermittent   # set a presence policy (separate from trust)
lanfence devices --presence always-on
lanfence review                 # interactively work through devices needing review
lanfence review <MAC> --trust --name "Kitchen speaker"
lanfence review <MAC> --snooze 24h
lanfence review <MAC> --investigate --notes "..."
lanfence review <MAC> --clear
lanfence inspect <MAC>          # optional: actively probe one known device's open ports
lanfence inspect <MAC> --no-nmap  # force the built-in scan, skip the optional nmap integration
lanfence report --since 24h     # summarize events/findings from the database
lanfence digest                 # preview a 24h summary; add --send to deliver it
lanfence digest --since 7d --send --channel email
lanfence dhcp-servers            # observed DHCP servers and their approval status
lanfence check                  # verify permissions, scapy, nmap, interface, storage
lanfence upgrade                # check PyPI and install a newer release, if any
lanfence upgrade --check        # only report whether an update is available
lanfence link                   # make `sudo lanfence` work (pipx/--user installs)
lanfence vendor-refresh         # pull a current copy of the IEEE OUI registry

scan/monitor warn (and show copy-pasteable fixes) if not run as root, since ARP scanning needs raw-socket access. A pipx / pip install --user install puts the lanfence launcher in ~/.local/bin, which sudo does not see by default - sudo lanfence scan then fails with "command not found". Run lanfence link once (no sudo needed up front - it re-execs itself under sudo and prompts for your password) to symlink the launcher onto root's PATH; after that, a bare sudo lanfence scan / sudo lanfence monitor works. lanfence link --remove undoes it.

Mixing sudo lanfence scan/monitor (needs root for raw sockets) with a plain, unprivileged lanfence devices/review/allow/report is the normal way to use LAN Fence, and both read/write the same database and allowlist: the default ~/.local/share/lanfence/.../~/.config/lanfence/... paths resolve against your own home directory even under sudo (which would otherwise reset $HOME to root's), not root's.

Example: an unknown device joins

$ sudo lanfence scan

Devices seen (4)
┌───────────────────┬──────────────┬──────────────┬────────────────────┬────────┬─────────┐
│ MAC                │ IP           │ Hostname     │ Vendor              │ Status │ Trusted │
├───────────────────┼──────────────┼──────────────┼────────────────────┼────────┼─────────┤
│ b8:27:eb:12:34:56  │ 192.168.1.10 │ nas.local    │ Raspberry Pi        │ online │ yes (NAS)│
│ 52:8a:1c:99:f4:2d  │ 192.168.1.47 │ [unknown]    │ [unknown]           │ online │ no      │
└───────────────────┴──────────────┴──────────────┴────────────────────┴────────┴─────────┘

Findings (1)

  MEDIUM Unknown device connected
    MAC: 52:8a:1c:99:f4:2d
    The vendor bit pattern indicates a locally administered address rather
    than one assigned by a hardware vendor. Common causes: MAC-randomization
    privacy features on modern phones/laptops, virtual machines/containers,
    or a device deliberately spoofing its address.
    Recommendation: Verify this device belongs on your network. If it's
    yours, run `lanfence allow 52:8a:1c:99:f4:2d` to stop future alerts.
      • MAC: 52:8a:1c:99:f4:2d
      • IP: 192.168.1.47
      • Hostname: [unknown]
      • Vendor: [unknown]
      • mac = 52:8a:1c:99:f4:2d (U/L bit set, no vendor OUI match)

Overall: 1 finding(s), highest severity: medium

Monitor's live dashboard

lanfence monitor shows a bordered, continuously-updating dashboard by default when run at an interactive terminal - a compact header, a scrolling feed of recent activity, and a statistics footer that's always visible:

┌─ LAN Fence · Monitoring ───────────────────────────────────────────┐
│ Interface: eth0  ·  Network: 192.168.1.0/24  ·  Running: 00:14:32  │
│ lanfence 0.3.10  ·  passive (ipv6, dhcp)  ·  last sweep 10:41:58   │
│                                                                    │
│ 10:42:03  NEW         Unknown device · 192.168.1.42                │
│ 10:42:10  RETURNED    Office laptop · 192.168.1.10                 │
│ 10:44:01  WARNING     scan.passive is on but discovery.mdns is...  │
│                                                                    │
├────────────────────────────────────────────────────────────────────┤
│ Known: 38 · Seen: 24 · New: 2 · Review: 3 · Scan: 8s               │
└────────────────────────────────────────────────────────────────────┘
lanfence monitor            # live dashboard if the terminal supports it, plain output otherwise
lanfence monitor --live     # force the live dashboard (fails helpfully, not silently, if unsupported)
lanfence monitor --no-live  # force plain, append-only output (e.g. when redirecting to a log file)

Header: interface, network scope, elapsed session time, LAN Fence's version, which discovery mechanisms are active (from actual config, not guessed), and the last completed sweep time (or "scanning now" while one is in progress). Activity feed: new/reappeared/disconnected devices and findings, most recent first - a device that also produced a security finding gets one combined line, not two, and a routine "still online" sighting never adds a line at all. Every timestamp is your system's local time. A repeated identical operational error (e.g. a failing sweep) is shown once with a growing count rather than flooding the feed.

Footer statistics (a device is identified the same way everywhere else in LAN Fence - see Device inventory and review):

  • Known: distinct devices in the database right now.
  • Seen: distinct devices this monitor process has itself positively observed this session (active or passive, deduplicated across every address/mechanism) - retained even if a device later goes offline. A reported DHCP-offered address or an mDNS/SSDP service target is never counted here; only a direct sighting is.
  • New: devices this session's positive observations inserted into the database for the first time - a previously-known device reappearing is never counted as new.
  • Review: the same needs-review count lanfence devices --review-needed uses (trust/snooze/investigation rules included).
  • Scan: time until the next scheduled active sweep, or "scanning" while one is running - computed from the real scheduler, never a separate UI timer. A wide enough terminal also shows this session's finding count, sweep success/failure counts, and passive-listener status. An unavailable statistic is always shown as such (e.g. "n/a"), never as a fabricated 0.

Refreshes about once a second and never triggers a scan on its own. On a narrow terminal, labels shorten and lower-priority statistics drop off (Known/Seen/New are kept longest); a terminal too small for any usable layout falls back to one compact line rather than a garbled one. --live on output that isn't a real interactive terminal (a pipe, a redirected log file, TERM=dumb) falls back to plain output with one clear message rather than emitting raw control sequences into a file; the default (no --live/--no-live) auto-detects this the same way. Plain/append-only mode's output is unchanged from previous versions. Press q (no Enter needed) to exit the live dashboard; the bottom border shows the shortcut. Ctrl+C also works, including in plain output mode. A queued quit takes effect after the current scan or delivery operation finishes. Terminal input settings are restored on exit. On shutdown, the dashboard exits cleanly (restoring your normal terminal) before printing a short session summary with real counters:

Monitoring stopped after 00:42:18.
Seen this session: 24 devices · Newly discovered: 2 · Findings: 3

Device inventory and review

scan/monitor find devices; devices, device, and review let you work through what's already in the database, without touching the network.

lanfence devices                          # every observed device, no scan
lanfence devices --status online          # combine filters with AND
lanfence devices --untrusted --review-needed --format json
lanfence device aa:bb:cc:dd:ee:ff          # one device's details + timeline
lanfence device aa:bb:cc:dd:ee:ff --since 7d --format json
lanfence review                           # walk the review queue interactively
lanfence review <MAC> --trust --name "Kitchen speaker" --notes "..."
lanfence review <MAC> --snooze 24h
lanfence review <MAC> --investigate --notes "..."
lanfence review <MAC> --clear

lanfence devices lists every device ever observed, straight from SQLite - it never scans. Each row's trusted/untrusted state is looked up fresh against the current allowlist file, not whatever it was on that device's last scan. --status online|offline, --untrusted, and --review-needed combine with AND: --status online --untrusted shows only devices that are both online and off the allowlist. Review-needed means untrusted, not currently snoozed, and not already flagged investigating - trusting, an active snooze, or an investigation flag all take it out of the queue.

lanfence device <MAC> shows one device in two clearly separated parts: current details (IP/hostname/vendor/status/trust/review state), which reflect only the most recent sighting, and a lifecycle timeline of connect/reappear/disconnect events since --since (default 30d). The timeline is an append-only event log, not a full history of every address a MAC has ever held - LAN Fence does not retain that. An invalid or never-before-seen MAC exits non-zero with a clear error.

lanfence review is how you act on the queue. With no MAC, it walks devices needing review one at a time, ordered by review priority (see Device classification and review priority below - stronger security signals and weaker identity evidence first, never a numeric score), showing a compact dossier before asking what to do:

Device 3 of 18  ·  Needs identification

52:8a:1c:99:f4:2d
192.168.1.47

Likely device: Unknown device (no supporting evidence)

First seen: 14 Sep 2026 09:10
Last seen:  14 Sep 2026 09:41
Status:     Online

Evidence:
  - MAC is locally administered (randomized or manually set) - no vendor to identify

Actions: [T]rust  [I]nvestigate  [S]nooze  [D] Full details  [N]ext  [Q]uit
  choice:
  • [T]rust - prompts for a friendly name and optional notes, then adds the device to the same allowlist lanfence allow writes to. LAN Fence never trusts a device on its own; a human always makes this call.
  • [I]nvestigate - records an investigation flag and optional notes without trusting the device or suppressing its alerts.
  • [S]nooze - suppresses external alert dispatch (Slack/Discord/Teams/ Twilio/webhook/etc.) for this MAC for a bounded duration (default 24h). Findings keep being recorded and still show up in scan/report/devices output and JSON - snoozing hides notifications, not the device.
  • [D] Full details - shows the same full report lanfence device <MAC> does (all retained address/name evidence, advertised services, findings), then returns to this same device's menu - it makes no decision by itself.
  • [N]ext (the default - just press Enter) - no changes; the device stays in the queue for next time.
  • [Q]uit - stops the session immediately; every decision made so far is already persisted.

It requires a real terminal and exits with a helpful error instead of hanging if stdin isn't interactive (e.g. in a script or cron job) - use the noninteractive form there instead, passing exactly one of --trust, --snooze, --investigate, or --clear alongside a MAC. --clear removes a snooze/investigation flag and returns the device to "pending"; it does not remove allowlist membership - lanfence allow --remove <MAC> is still what untrusts a device.

lanfence allow <MAC> shows this same compact dossier before asking you to confirm trusting an already-observed device, but only when run at an interactive terminal - a never-before-seen MAC (nothing to show yet) and any non-interactive invocation (scripts, cron, CI) skip the prompt entirely and behave exactly as before, so existing automation needs no changes. Pass --yes to skip the confirmation even at an interactive terminal.

Device classification and review priority

Every device gets a conservative "likely device" guess, built only from evidence LAN Fence already retains elsewhere (vendor OUI, self-reported hostname, advertised mDNS/SSDP services, an existing rogue-signature match) - never fabricated, always labeled with a confidence (High/Medium/ Low) and the specific evidence behind it, and defaulting to "Unknown device" with no confidence when the evidence doesn't reasonably support more:

$ lanfence device b8:e9:37:aa:bb:cc

Likely device: Sonos speaker
Confidence:    High
  - Vendor OUI: Sonos, Inc.
  - Advertises AirPlay/remote-audio services

None of this evidence is authenticated - a MAC's vendor prefix, a self-reported hostname, and anything advertised over mDNS/SSDP are all trivially spoofable by a device that wants to blend in, exactly like the rogue-device signatures this classification reuses. It is always a labeled inference, never presented as verified identity.

lanfence review's queue is ordered by review priority, a deterministic, plain-language tier built from existing signals (an existing medium/high rogue-signature match, a locally-administered/randomized MAC, how confident the classification is, whether a hostname or service corroborates it) - never a numeric risk score claiming a precision this evidence doesn't support:

  • Priority - an existing medium/high-severity rogue-signature match, or a locally-administered/randomized MAC (no vendor identity to go on at all).
  • Needs identification - nothing (or only a low-confidence guess) reasonably identifies the device.
  • Likely familiar - a known manufacturer, ideally corroborated by a hostname or advertised service.

After a scan, the same three tiers (plus the security-flagged case) drive a short orientation summary alongside the usual compact device table - the table stays a compact inventory view; this is a separate, human-readable breakdown of what deserves a closer look:

$ sudo lanfence scan

Devices seen (47)
...

LAN Fence has discovered 47 devices.

31 appear straightforward
 9 need identification
 5 use private/randomised MAC addresses
 2 have higher-priority security characteristics

None have been reviewed yet.

Run `lanfence review` to work through them.

Only shown for --format table (the default) - --format json's ScanResult payload is unchanged.

Trust, snooze, and investigate are mutually exclusive persisted states (pending is the default); if a device is both trusted and, say, mid-snooze from before it was trusted, "trusted" always wins for display purposes. A snooze that expires simply lets the device fall back into the review queue - expiry alone never fabricates a new connect/disconnect event or fires a retroactive alert. A lanfence monitor process already running reloads the allowlist on its normal sweep cadence, so a review --trust or allow made from another terminal takes effect without restarting it; review/snooze state itself is read fresh from the database on every finding, so it needs no such reload.

Active device inspection

Everything above is built entirely from passive evidence - LAN Fence never sends traffic aimed at a specific device during scan, monitor, passive discovery, or review. lanfence inspect <MAC> is the one explicit, opt-in exception: a bounded TCP connect-scan of a short, curated list of common service ports on one already-known device, to help answer "what is this thing" when passive evidence isn't enough.

$ lanfence inspect aa:bb:cc:dd:ee:ff

Sending active inspection probes to 192.168.1.47 (aa:bb:cc:dd:ee:ff)...

Active inspection of aa:bb:cc:dd:ee:ff (192.168.1.47)
Method: nmap  ·  Observed: 14 Sep 2026 09:41 (just now)

Confirmed open ports:
  22/tcp   inferred service: ssh
  80/tcp   inferred service: http

Probable platform: not enough evidence to guess

This is an inference from which ports responded, not OS fingerprinting -
treat it as a hint, not a verified fact.

Confirmed vs. inferred, always kept separate: an open port is a fact - the TCP handshake succeeded. The service label next to it, and any overall "Probable platform" guess, are inferences from the port number and (for a couple of cleartext protocols) a passively-read greeting banner or a single harmless HEAD / request - never a verified capability, and never OS fingerprinting. A platform guess is always labeled Medium or Low confidence, never presented as definitive.

Two ways to scan, no hard dependency: a dependency-free, bounded ThreadPoolExecutor connect-scan (pure standard library) always works; if the optional nmap binary is installed, inspect prefers it (-sT -sV, no raw sockets, no root required) for better service labels, falling back to the built-in scan if nmap is missing or fails to run. Pass --no-nmap to force the built-in scan. Neither path ever shells out with untrusted text - the target is validated as a literal IP address before either scan runs.

Never automatic: inspect only ever runs when you explicitly invoke it, or explicitly accept the offer lanfence review makes for the device currently on screen:

Actions: [T]rust  [I]nvestigate  [S]nooze  [X] Inspect  [D] Full details  [N]ext  [Q]uit
  choice: x

  More information may be available by actively inspecting this device.
  Active inspection sends probe traffic directly to 192.168.1.47.
  Run active inspection? [y/N]:

Declining leaves the device untouched and returns you to its menu. Results are persisted (one row per MAC, the latest run replacing any earlier one) and shown again - labeled with their age, and flagged STALE past 24 hours - by lanfence device <MAC> and inside review's [D] Full details view, so old inspection data is never presented as current.

Identification only, by design: no exploitation, no credential testing, no protocol negotiation beyond a bare connect and reading a greeting a service sends unprompted - the same defensive scope as the rest of LAN Fence.

Address and name history

Older versions of LAN Fence only ever showed a device's latest IP and hostname. Every address and name a device has ever presented is now retained as durable evidence, each entry tagged with where it came from, when it was first and most recently observed, and which interface it was seen on:

$ lanfence device aa:bb:cc:dd:ee:ff

...
Addresses (2 retained)
  10.0.0.5
    Source: ARP · Interface: eth0
    First observed: 2026-01-01T09:00:00+00:00   Last observed: 2026-01-05T08:00:00+00:00
  fe80::1234
    Source: IPv6 ND · Interface: eth0
    First observed: 2026-01-02T10:00:00+00:00   Last observed: 2026-01-02T10:00:00+00:00

Names (2 retained)
  office-laptop
    Source: DHCP option 12
    First observed: 2026-01-01T09:00:00+00:00   Last observed: 2026-01-05T08:00:00+00:00
  office-laptop.lan
    Source: reverse DNS for 10.0.0.5
    First observed: 2026-01-03T09:00:00+00:00   Last observed: 2026-01-03T09:00:00+00:00

The plain IP:/Hostname: fields in "Current details" (and Device.ip/ Device.hostname in JSON, everywhere a device is returned) are preferred values computed from this evidence, not simply "whichever was written last": a directly-observed address (ARP or IPv6 neighbor discovery) always outranks a DHCP-reported lease, which outranks data imported from an older database, regardless of which is more recent - only within the same tier does recency decide. Names work the same way: a DHCP-reported name (option 12) always outranks a reverse-DNS name. A failed reverse-DNS lookup never erases a name already on file. This is a convenience for a quick glance, not a claim that other retained addresses/names are wrong or gone - see the full evidence list for that.

A dual-stack device correctly retains both its IPv4 and IPv6 addresses

  • they are never collapsed to "whichever was seen last" the way earlier versions' one-value-per-device model forced. A DHCP client merely requesting or being offered an address is deliberately not treated as evidence the device is using it (only a server's confirmed lease - an ACK - or LAN Fence directly observing the address via ARP/ND counts); it still counts as the device being alive on the network, just not as proof of that specific address.

History, not lease tracking: first/last-observed timestamps summarize when a specific piece of evidence was seen, not a continuous assignment interval - an old-looking entry does not mean that address was released, only that nothing has re-confirmed it recently, and LAN Fence never invents a "device moved networks" or "address changed" narrative from this alone.

Upgrading an existing database: a device's pre-existing ip/ hostname are imported once as legacy_snapshot evidence (lowest preference tier, since its original source is no longer known) the first time the database is opened after upgrading - timestamped as of that import, not backdated to the device's original first-seen time, and never re-imported on a later restart.

Device inventory metadata

Beyond what LAN Fence observes on the wire, you can attach your own notes to a device - who owns it, what it's for, which group it belongs to, and where it physically lives:

$ lanfence device aa:bb:cc:dd:ee:ff --owner "Alice" --purpose "Work laptop" \
    --group staff --location "Office"
metadata for aa:bb:cc:dd:ee:ff updated: owner, purpose, group, location

...
Inventory details (user-provided)
Owner:      Alice
Purpose:    Work laptop
Group:      staff
Location:   Office

Any combination of --owner/--purpose/--group/--location may be set in one call; an omitted field is left unchanged. --clear-owner (and the --clear-purpose/--clear-group/--clear-location equivalents) removes a field - setting and clearing the same field in one call is rejected. Metadata edits never scan, alert, fire a lifecycle event, or interact with trust/review/presence in any way - they are pure inventory bookkeeping.

lanfence devices gained matching filters (--owner, --group, --location - exact match, case-insensitive) and an opt-in --details flag that adds Owner/Purpose/Group/Location columns to the table without bloating the default view. JSON output always includes metadata (nested under "metadata") regardless of --details.

The interactive lanfence review queue offers an optional "Add device details?" step (default no) right after trusting a device, pre-populated with any existing values; skipping it, or aborting partway through, never undoes the trust or presence decisions already made in that same session.

Owner/group also appear as brief context alongside a device's row in lanfence digest output - purpose and location are left out there to keep digest rows terse; the full detail is one lanfence device <MAC> away.

Like the allowlist name, none of this is authoritative or derived from network traffic - it's exactly what you typed, unvalidated against reality, and lanfence reset clears it along with the rest of a device's history.

Starting over

lanfence reset                    # asks for confirmation first
lanfence reset --yes              # noninteractive - for scripts
lanfence reset --yes --keep-allowlist

lanfence reset permanently deletes every previously scanned device: its history, lifecycle events, alert cooldowns, and review/snooze state - and, unless --keep-allowlist is given, the allowlist too, so trust decisions start over from scratch as well. This cannot be undone. It asks for confirmation and requires a terminal to do so; pass --yes to run it noninteractively (e.g. before re-provisioning a device, or in a script). Nothing about your configuration (config.yaml) is touched.

Offline detection and grace periods

scan:
  offline_grace_seconds: 180     # default
  offline_after_missed_scans: 3  # default

By default, LAN Fence does not mark a device offline the moment one active sweep misses it - a single missed ARP reply is normal noise (a device asleep, a busy Wi-Fi channel, a dropped packet), not proof a device disconnected. A device is only actually marked offline once both conditions are true:

  1. It has been missed by this many consecutive eligible active sweeps in a row (offline_after_missed_scans, default 3); and
  2. At least this much time has passed since it was last actually seen (offline_grace_seconds, default 180).

"Online" during that window means "not yet confirmed absent," not necessarily still connected - and only an active sweep ever confirms absence; elapsed wall-clock time alone never disconnects a device, no matter how long monitor has been running. That also means scan cadence sets the floor: with the defaults, a device can't be confirmed offline sooner than offline_after_missed_scans x scan_interval_seconds (3 x the default 60s = 3 minutes), even though offline_grace_seconds is also 180s - raise scan_interval_seconds and the wait grows accordingly. Any positive sighting (from an active sweep or passive ARP/ND/DHCP traffic) immediately resets the missed-sweep count back to zero and keeps the device online; only a completed active sweep is ever authoritative for absence.

A sweep only counts as a genuine "miss" for a device when it actually examined that device's known network path - interface, address family, and (for IPv4) subnet. A failed, skipped, or out-of-scope sweep never counts:

  • If every scan mechanism failed this round (e.g. no root), there is no information at all, and no device is ever marked offline on that basis.
  • A successful but empty scan still counts as real evidence of absence for devices within its coverage.
  • If IPv4 scanning failed but IPv6 succeeded (or vice versa), only a device known through the failed family is spared - a device known through both needs both covered before a miss counts at all.
  • Switching --interface/--subnet (or moving to a different network) never marks devices from the other network offline - it's simply outside what the current sweep examined.
  • A device's own last_seen timestamp is never advanced by going offline - it stays the time it was actually last seen; the disconnected event's own timestamp records when the absence was confirmed instead.

Compatibility: set offline_grace_seconds: 0 and offline_after_missed_scans: 1 to restore the pre-grace-period behavior of disconnecting on the very first eligible missed sweep.

Upgrading an existing database: devices recorded before this feature existed have no discovery-path information on file yet. LAN Fence treats that conservatively - such a device is never marked offline via the missed-scan logic until a fresh sighting (from either an active sweep or passive traffic) establishes its real coverage; from then on, normal grace-period rules apply. The database schema itself is upgraded automatically and idempotently the next time it's opened - no data is lost or reset.

Presence policies

Laptops, phones, and tablets routinely leave and rejoin the network - that's normal, not a problem. A server, printer, or NAS staying connected is the opposite: its absence is the problem. Presence policies let you tell LAN Fence which is which, per device - separate from trust. Trusting a device (the allowlist) says "I recognize this device"; a presence policy says "here's what normal looks like for it." A device can be trusted and have any presence policy, or neither, independently.

lanfence device <MAC> --presence intermittent    # normal to come and go
lanfence device <MAC> --presence always-on       # sustained absence is unexpected
lanfence device <MAC> --presence always-on --offline-after 10m
lanfence device <MAC> --presence unspecified     # back to the default
lanfence device <MAC> --clear-offline-after      # restore the global default delay
lanfence devices --presence intermittent

Three policies, per device:

  • unspecified (the default) - no change from existing behavior.
  • intermittent - routine absence and return are expected. LAN Fence keeps tracking real online/offline status and keeps recording disconnected/reappeared events in the timeline exactly as before; what's suppressed is only the routine "it came back" notification and finding - a finding whose sole purpose is announcing an ordinary return. A brand-new device's first-ever discovery is never suppressed, and neither is any independent security signal (e.g. a rogue-device signature match) carried alongside a reappearance - only the routine announcement itself is dropped. Setting this never trusts, snoozes, or otherwise approves the device.
  • always-on - sustained absence is unexpected. Online/offline status still comes from the same scan-coverage rules, consecutive-miss threshold, and global offline_grace_seconds as every other device (see above) - presence policy doesn't change when a device is confirmed offline, only what happens next. Once confirmed offline, if it stays absent for the effective absence duration - its own --offline-after override, or scan.offline_grace_seconds when no override is set - LAN Fence emits one medium-severity availability finding ("this device has been gone longer than expected"), and exactly one info-severity recovery finding the moment it's seen again. --offline-after is an alert delay, not a grace period: it does not affect when a device is marked offline (that's still purely the coverage/miss-threshold/grace-period logic above) - it only controls how much additional time an already-offline always-on device gets before its absence is treated as noteworthy. If that delay elapses while a device is already offline, the alert fires on the next eligible sweep - no new disconnect is needed to trigger it. Trust is irrelevant here: even an allowlisted always-on device gets its availability finding.

Editing a policy never fabricates a lifecycle event or fires an alert by itself - it only changes how future observations are interpreted. Setting always-on on a device that's already offline makes it eligible for evaluation on the very next qualifying sweep; switching a device away from always-on clears any pending absence-alert state without firing a recovery (there's nothing to recover from once it's no longer being watched). A lanfence monitor process already running picks up a policy edit made from another terminal immediately, on the next sighting - no restart needed, the same as trust and review state.

lanfence review's interactive flow asks about presence right after you choose to trust a device ("Should this device always be online, or is it normal for it to come and go?") - answering is optional and defaults to whatever the device's policy already was (unspecified if never set); exiting that follow-up prompt never undoes the trust decision you just made.

Digest

lanfence report and scan --alert are about every event as it happens; lanfence digest is the opposite - one concise summary of a rolling window (default 24h) so you can check in without a notification for every routine connect/reappear. It never scans the network and never changes trust, review, snooze, or lifecycle state - a pure read of what's already in the database, same as lanfence devices.

lanfence digest                        # preview only - sends nothing
lanfence digest --since 7d             # a longer window
lanfence digest --format json          # machine-readable
lanfence digest --send                 # also deliver, via digest.channels
lanfence digest --send --channel email
lanfence digest --send --channel email --channel ntfy --send-empty

A digest reports, clearly separated:

  • Activity in the window: new devices (name, MAC, IP, hostname, vendor, current trust status, and first-seen time), and a compact count of devices that reappeared or disconnected (each device counted once per activity type, even if it flapped repeatedly).
  • Current inventory/review state as of generation time (not scoped to the window - a device flagged for review last month still shows up until it's resolved): devices needing review, current investigations (with notes and last-seen time), and - if presence policies are in use - currently-missing always-on devices.
  • Monitoring health: this version has no durable record of monitor uptime or alert-delivery success/failure to draw on, so this always reads "Monitoring health unavailable" rather than guessing "healthy" - a known, documented gap, not a bug.

A device can legitimately appear in more than one section (e.g. new and still needing review) since each section states a different fact; within a single section a device is never duplicated. Every section is capped at digest.max_devices_per_section (default 20), with an explicit "and N more" rather than an unbounded dump. Historical accuracy matters: "new devices" and the activity summary come from the persisted lifecycle event log, not from re-deriving security severity out of today's allowlist/signatures - a device trusted after it was recorded as new-in-window still correctly shows as new-in-window, just with its now-current trust status alongside it. Security findings themselves aren't persisted anywhere in this version, so a digest never claims to show historical finding severity - only current trust/review state, exactly what's actually stored.

Sending a digest

digest:
  channels: [email]        # which existing alert destinations also get a digest
  send_when_empty: false
  max_devices_per_section: 20

Delivery reuses your existing alerts.<channel> destinations (email, webhook, Slack, Discord, Teams, ntfy) - enabling a channel under alerts: does not by itself add it to digests; list it under digest.channels (or pass --channel explicitly, which limits --send to just those, still requiring each to already be enabled and configured). SMS (Twilio) and syslog are not available for digest delivery and are rejected with a clear error if requested. --send with no digest.channels configured and no --channel given fails with a helpful error rather than silently doing nothing; a preview with no destinations configured still works fine.

A digest is empty when there's no window activity, no outstanding review/investigation items, no missing always-on devices, and no known monitoring/delivery problems - an unchanged device count alone does not make it nonempty, and it never invents a problem just because monitoring health is unavailable. --send on an empty digest is suppressed by default (digest.send_when_empty: false); pass --send-empty to override for one run, or set send_when_empty: true to always send.

Every requested channel is attempted independently - one failing (a bad webhook URL, an SMTP timeout) never stops the others, and lanfence digest --send exits non-zero if any requested channel failed, with a per-channel sent/FAILED line. Digest delivery is entirely independent of the immediate-alert pipeline: it ignores alerts.min_severity and never reads or writes the per-MAC alert cooldown, so sending a digest can never suppress (or be suppressed by) an immediate alert for the same device.

Communication channels

Configuring Slack/Discord/Teams/ntfy/email/webhook/Twilio/syslog by hand means editing YAML and hunting down each provider's webhook-setup screen. lanfence channels is an interactive wizard for the same alerts.<channel> settings above - it doesn't add a new configuration system, just a safer, guided way to edit the one that already exists.

lanfence channels setup          # unified setup: communications and application settings
lanfence channels setup --config /etc/lanfence/config.yaml
lanfence channels setup slack    # go directly to Slack setup
lanfence channels                # status table - enabled? configured? safe summary
lanfence channels test slack     # send one clearly-labeled test message
$ lanfence channels
                          Channels
┏━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━┓
┃ Channel ┃ Enabled ┃ Configured ┃ Digest ┃ Destination    ┃
┡━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━┩
│ slack   │ yes     │ yes        │ yes    │ hooks.slack.com│
│ discord │ no      │ no         │ no     │ not configured │
│ ...

The status table and every summary shown during setup are deliberately safe to screenshot: never a password, token, full webhook URL, URL query string, or credential-bearing path - only a hostname (Slack/Discord/Teams/ webhook/ntfy), a masked recipient address or count (email), masked phone numbers (Twilio), or a socket path (syslog). "Configured" means the required fields are present, never that delivery has actually been tested - use channels test for that.

lanfence channels setup opens a numbered application setup menu. Choose Communications to edit destinations, or Scanning, Offline detection, DHCP servers, Service discovery, Daily digest, Storage, or Alert delivery. All sections share an unsaved draft: Review shows a redacted before/after summary; Save validates everything and asks for confirmation; Discard restores the last saved configuration. Exit with unsaved edits offers Save, Discard, or Return. Ctrl+C/EOF discards only edits since the last save. Opening setup, reviewing changes, or saving never scans or sends messages.

Fields show their effective value and whether it is explicit or inherited. Blank keeps a value; reset removes the override; nullable fields accept null for explicit auto/unset. Time fields accept seconds or durations such as 5m. Enumerations show their choices; digest destinations are a comma-separated list. Cross-setting warnings identify inactive discovery and disabled digest destinations. Save does not rewrite a file when nothing changed.

The DHCP server section includes a — Approved DHCP servers, with Add, Edit, Remove, Observed, Reset, and Back actions. Observed opens a read-only inventory: select a server, review its identifier/interface and explicitly confirm approval. Its server_ip is option 54, not necessarily the source or relay IP. VLAN interfaces such as eth0.20 have separate approval scope. Approving a DHCP role does not trust a device. An unavailable inventory never prevents manual entry.

Storage edits change paths only: existing databases are not migrated, moved, or deleted. Retention periods are currently fixed in code and change-notification settings are not implemented; neither is offered as a setting. Schema-unknown keys are rejected by the existing configuration model, so files containing them are left untouched rather than silently dropping their data. Supported settings and unrelated raw values are preserved when editing valid files.

The unified editor refuses to save through a configuration symlink; rerun with its intended target path. It warns before replacing comments/formatting or restricting file permissions. A running monitor must be restarted with the same --config path to load saved application/channel settings. No restart or schedule installation happens automatically. After saving changed enabled channels, it offers an optional test for each destination, defaulting to no.

lanfence channels setup slack still goes directly to the channel wizard, prompting for its real config fields (existing values shown as defaults where it's safe to display them), a one-line pointer to where to obtain each setting, local validation (URL scheme/hostname, port range, E.164 phone numbers, email syntax, timeouts, supported priorities/facilities - never a network request, so passing this never proves delivery will actually work), a sanitized preview, and a save/cancel prompt. A secret (webhook URL, SMTP/Twilio credentials) is never echoed back: an existing one shows as "already configured", and you choose to leave it, type a new value, or type clear to remove it - leaving the prompt blank always preserves what's already there. Digest-eligible channels (email, webhook, Slack, Discord, Teams, ntfy - not Twilio/syslog) get one extra "use this for daily digests too?" prompt, touching only digest.channels; the digest schedule, severity thresholds, and per-MAC cooldowns are never touched by this command. After a successful save you can optionally send a test message (defaults to no; Twilio warns that a test SMS may incur provider charges).

lanfence channels enable/disable <channel> make the same edit noninteractively, since the requested change is already fully explicit: enable refuses if required fields are missing, disable preserves every setting and credential (and any existing digest selection - digest delivery to a disabled channel is simply inactive, not removed from the list). lanfence channels test <channel> requires the channel to already be enabled and sends one message via the real transport, reporting its actual outcome (never "success" on a swallowed exception) with a nonzero exit code on failure - it bypasses alerts.min_severity entirely and never creates a device, finding, lifecycle event, or alert-dispatch cooldown entry.

Email/SMTP: every email send path (alerts, digests, and channels test email) verifies the SMTP relay's certificate and hostname before authenticating or sending anything - email.use_tls: true (the default) never falls back to an unverified STARTTLS upgrade. If your relay's certificate is signed by a private/internal CA, set email.ca_file to a PEM bundle to trust it in addition to the system trust store; there is no setting to disable verification itself. email.username/password are refused (delivery aborts rather than sending a password in the clear) if use_tls is disabled - an explicitly configured unauthenticated local relay (use_tls: false with no username/password) is unaffected.

Config file location: LAN Fence has no other default writable config file (every other command treats a missing --config as "built-in defaults, touch no file"), so channels uses a conventional per-user path, ~/.config/lanfence/config.yaml, when --config isn't given - shown before saving, along with a reminder to pass the same --config path to monitor (config is read once at startup, not while running, so a running monitor needs a restart to pick up a change here). Saving is atomic and safe: existing unrelated sections, other channels, and disabled channels' own settings/secrets are always preserved; malformed YAML is never overwritten (the file is left untouched with a clear error instead); a concurrent edit between load and save is detected and refused rather than clobbered; a newly-written file is owner-readable/writable only (0600), and an existing file found more permissive than that is tightened with a clear note. Values are always preserved, but - like lanfence allow's own YAML writer - hand-written comments and formatting are not, since that would need a new dependency this project avoids.

Unexpected DHCP servers

Passively detects a DHCP server (a DHCPOFFER/ACK/NAK reply) that isn't on your approved list for the interface it answered on - a rogue or misconfigured DHCP server on your LAN can silently redirect every new client's traffic through itself. Purely observation: LAN Fence never sends a DHCP request of its own, and this reuses the existing passive DHCP capture rather than opening a new one.

dhcp_servers:
  enabled: false                 # opt-in - off by default
  approved:
    - interface: eth0
      server_ip: 192.168.1.1     # DHCP option 54 - the server identifier
      name: Main router
    - interface: eth0
      server_ip: 192.168.1.2
      name: Backup DHCP
  alert_cooldown_seconds: 3600   # per (interface, server) - don't flood findings from one noisy server
lanfence dhcp-servers            # every observed server + approval status - a database read, no scan
lanfence dhcp-servers --format json

Approval is scoped by interface - a VLAN sub-interface (e.g. eth0.20) is already its own interface name at the OS level, so it's covered with no separate VLAN setting; this project does not parse raw 802.1Q tags from captured frames, so no VLAN-isolation claim is made beyond what the interface name itself expresses. Multiple servers can be approved per interface (a primary and a failover, say). Turning this on with an empty approved list means every server observed is treated as unexpected - LAN Fence never auto-approves the first responder, and an existing device allowlist entry never implies DHCP server approval either; they're independent trust decisions, checked separately. This version has no config-writing workflow for approval - add entries to dhcp_servers.approved by hand and (since this config is only read at startup) restart monitor for the change to take effect.

Detection only ever runs during lanfence monitor (scan, a one-shot active sweep, has no equivalent - DHCP servers only speak when spoken to by a real client, which nothing here simulates) and depends on the same passive DHCP capture the hostname-snooping feature uses (scan.passive/scan.dhcp_snooping) - if dhcp_servers.enabled is true but that capture is off, monitor's startup banner says so plainly rather than silently providing no protection.

An unapproved server produces one medium-severity "Unexpected DHCP server observed" finding, explaining that this alone doesn't establish malicious intent (it might be a legitimate second router, a failover server, or a misconfiguration) and recommending you check it and approve it if expected. This finding has no MAC address - a DHCP server's identity is its option 54 server identifier, not any one Ethernet address (a relayed reply's source MAC belongs to the relay, not the server, and BOOTP.chaddr identifies the client the reply was for) - so it's shown by its interface and server identifier instead. Role approval is independent of device trust: a device already on your allowlist that starts answering DHCP requests without approval still produces this finding, and an intermittent presence policy has no bearing on it either (it isn't about a device at all). A server's approval status is computed fresh each time from current config - approving a server later never rewrites the evidence already recorded for findings raised while it was still unapproved.

Visibility limitations - detection only covers replies actually visible at the capture interface: a switched network can hide a unicast reply entirely, and a quiet network may produce no observations until a client next renews or joins. Multiple DHCP servers/relays on a network can be entirely legitimate (redundancy, VLAN-specific scopes). Server identifiers and MAC addresses seen on the wire are claims, not authenticated identities

  • treat a finding as a lead to check, the same as every other signature in this tool. This feature does not detect DHCPv6 servers.

Passive advertised-service discovery

Enriches your device inventory with services devices advertise about themselves over mDNS/DNS-SD (Bonjour) and SSDP/UPnP - "this device speaks printing (IPP)", "this device advertises AirPlay", "this is a UPnP MediaRenderer". These are device-advertised claims, not verified capabilities, authenticated identities, or proof a service is actually reachable - treat them the same skeptical way as a vendor OUI or a self-reported hostname.

Strictly passive, same as every other discovery mechanism in this project: LAN Fence never sends an mDNS query, an SSDP M-SEARCH request, an HTTP request, or any other discovery traffic, and it never fetches an SSDP LOCATION URL. It only parses mDNS/SSDP traffic that's already flowing on the network and reaching the existing passive capture.

discovery:
  mdns: false   # opt-in - off by default
  ssdp: false   # opt-in - off by default

Both narrowly extend the existing passive capture filter (UDP port 5353 for mDNS, 1900 for SSDP) and only take effect when scan.passive is also true - monitor prints a warning if you enable one without the other, rather than silently doing nothing. Like every other scan.*/discovery.* setting, this is only read at monitor startup, so a config change needs a restart to take effect - or pass --mdns/--no-mdns/--ssdp/--no-ssdp directly:

lanfence monitor --mdns --ssdp
$ lanfence device aa:bb:cc:dd:ee:ff

...
Advertised services (1 known)
  Printing — _ipp._tcp
    Instance: Office Printer
    Target: printer.local:631
    Source: mDNS/DNS-SD · Interface: eth0
    Last observed: 2026-01-05T08:00:00+00:00
    Advertisement expires: 2026-01-05T08:02:00+00:00
    Association: target IP matched observed device address

$ lanfence services
lanfence services --protocol mdns
lanfence services --protocol ssdp
lanfence services --unassociated       # only services that couldn't be confidently matched to a device
lanfence services --include-expired    # also show expired/withdrawn history
lanfence services --format json

Attribution is deliberately conservative. Who transmitted an advertisement and which device it's actually about are two different questions - an mDNS proxy, reflector, or shared responder can legitimately advertise services on behalf of other hosts, so LAN Fence never assigns a service to the packet's own Ethernet/IP source. Instead, it correlates the service's target address (the mDNS SRV record's host, or - for SSDP, which has no separate target concept - the packet's own source address) against address evidence it has directly observed itself (ARP/IPv6 ND - never a DHCP-reported lease claim or older imported data). If that match is unique, the service is attributed; if it's ambiguous (more than one MAC has ever held that address) or there's no match at all, the service is shown as unassociated rather than guessing. Attribution is recomputed fresh every time you look, so it can improve as better evidence arrives - and the original advertisement evidence is never rewritten to reflect it.

TTL and expiry semantics follow each protocol's own rules: an mDNS "goodbye" record (TTL 0) or an SSDP ssdp:byebye immediately withdraws that specific advertisement (never every service the device advertises); absent that, a service's advertised lifetime (its DNS TTL, or SSDP's CACHE-CONTROL: max-age) determines when it's shown as expired. A missing/invalid SSDP max-age never grants an immortal advertisement - it falls back to a short, bounded default instead. lanfence services and lanfence device <MAC> show only current advertisements by default; --include-expired shows the bounded history too, each status explicitly labeled. None of this ever fabricates a device lifecycle event, changes presence/reachability, or fires a finding/alert - a service expiring does not mean the device went offline, and this feature raises no new findings in this release.

What's retained: for mDNS, the service type (with a friendly label for a small set of well-known types - printing, AirPlay, remote audio, cast, generic web service; an unrecognized type is kept with its raw name, never guessed at), the instance name, the target host/port, and a small, documented allowlist of TXT attributes (bounded in count and size) - never a raw TXT blob or an arbitrary unknown key. For SSDP: USN (its stable identity), NT/ST, SERVER, LOCATION (stored as untrusted advertised metadata - never fetched, followed, or embedded as a resource), and CACHE-CONTROL's max-age. SERVER/TXT model-like attributes are always advertised claims, labeled as such wherever shown - never treated as verified vendor/model identity.

Visibility limitations: absence of an observation here is not evidence a service doesn't exist - only that nothing advertising it has reached this capture point yet (a quiet device, a switched/segmented network, or discovery simply not having been enabled long enough all look the same as "nothing to report"). Expired/withdrawn evidence is retained for a bounded period (30 days) then opportunistically pruned - lanfence services --include-expired shows what's still on file. lanfence reset clears all discovery evidence along with the rest of a device's history.

Running unattended

LAN Fence does not ship its own scheduler; use systemd (recommended on a Pi) or cron.

Continuous monitoring - see packaging/lanfence.service for the full, hardened example unit (a dedicated unprivileged service account with only CAP_NET_RAW, filesystem/capability sandboxing, and step-by-step setup/migration instructions in its own comments) - a condensed version:

[Unit]
Description=LAN Fence continuous network device monitoring
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/lanfence monitor --config /etc/lanfence/config.yaml
Restart=on-failure
User=lanfence
Group=lanfence
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
NoNewPrivileges=true
StateDirectory=lanfence
StateDirectoryMode=0700
ProtectSystem=strict
ProtectHome=true
UMask=0077
# ... see packaging/lanfence.service for the complete sandboxing set and
# the one-time `useradd`/config-ownership setup this depends on.

[Install]
WantedBy=multi-user.target
sudo cp packaging/lanfence.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lanfence

Running as root (an earlier version's only documented option) still works if you genuinely need it, but is no longer the recommended or example configuration - the capability LAN Fence's scanning actually needs (CAP_NET_RAW) is granted directly to this one service by systemd above, never via setcap on the shared Python interpreter or the lanfence script itself, which would hand that capability to anything else run with that interpreter/script too.

Daily report - a cron entry (sudo crontab -e):

0 7 * * * /usr/local/bin/lanfence report --since 24h --format json > /var/log/lanfence/daily.json

Daily digest (see Digest below) - lanfence monitor already runs continuously and writes to the same database digest reads from; run digest as a separate, periodic job as whichever user can read that database and config.yaml (typically the same user/root that runs monitor). A cron entry (sudo crontab -e):

0 7 * * * /usr/local/bin/lanfence digest --send --config /etc/lanfence/config.yaml

Or a systemd oneshot service + timer - /etc/systemd/system/lanfence-digest.service:

[Unit]
Description=LAN Fence daily digest
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/lanfence digest --send --config /etc/lanfence/config.yaml
# digest only reads the database and sends alerts - no raw-socket access,
# so no capability is needed at all; the same dedicated, unprivileged
# account `lanfence.service` runs as (see packaging/lanfence.service) is
# enough, as long as it can read config.yaml and the database/allowlist.
User=lanfence
Group=lanfence

/etc/systemd/system/lanfence-digest.timer:

[Unit]
Description=Run the LAN Fence daily digest every day at 07:00

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl enable --now lanfence-digest.timer

Two things to keep in mind when scheduling either way: OnCalendar/cron times are in the scheduler's local timezone, while the digest's own rolling window (--since, default 24h) is always computed in UTC ending at the moment digest runs - "daily at 07:00 local time" does not mean "midnight-to-midnight UTC". And this first version does not promise exactly-once delivery or automatic catch-up after downtime: if the host is off when the timer would have fired, that run is simply skipped (no backlog is queued), and running digest --send twice sends twice - it is not idempotent.

Configuration

All settings are optional; everything has a sensible default. Pass --config path/to/config.yaml to any command.

scan:
  interface: null              # null = auto-detect
  subnet: null                 # null = derive from the interface's own address
  scan_interval_seconds: 60    # how often `monitor` repeats an active sweep
  active_scan_timeout_seconds: 3
  passive: true                # also sniff ARP/ND traffic between sweeps
  ipv6: true                   # also discover devices via IPv6 neighbor discovery
  dhcp_snooping: true          # snoop DHCP for a self-reported hostname (needs passive: true)
  resolve_hostnames: true      # try reverse DNS for each device
  dns_timeout_seconds: 1
  passive_queue_maxsize: 2000  # cap per passive processing queue; excess is dropped (counted), never blocks capture

alerts:
  min_severity: medium         # info | medium | high - dispatch threshold
  rate_limit_seconds: 900      # per-MAC cooldown between alerts; 0 = alert every time
  global_rate_limit_max: 20            # cap on total alert dispatches per window, across every MAC/subject
  global_rate_limit_window_seconds: 60 # ...within this many seconds; 0 (either field) disables it
  syslog:
    enabled: false
    address: /dev/log
    facility: user
  email:
    enabled: false
    smtp_host: localhost
    smtp_port: 587
    use_tls: true
    username: null
    password: null
    from_addr: null
    to_addrs: []
    ca_file: null                # extra private CA bundle (PEM path), if your relay needs one
  webhook:
    enabled: false
    url: null
    timeout_seconds: 5
  slack:
    enabled: false
    webhook_url: null            # Slack app settings -> Incoming Webhooks
    timeout_seconds: 5
  discord:
    enabled: false
    webhook_url: null            # channel settings -> Integrations -> Webhooks
    timeout_seconds: 5
  teams:
    enabled: false
    webhook_url: null            # incoming webhook / Workflow URL
    timeout_seconds: 5
  ntfy:
    enabled: false
    url: null                    # e.g. https://ntfy.sh/my-lanfence-topic
    priority: null               # min | low | default | high | urgent
    timeout_seconds: 5
  twilio:
    enabled: false
    account_sid: null
    auth_token: null              # sensitive - treat this file like a credential
    from_number: null             # E.164, e.g. "+15551234567"
    to_numbers: []
    timeout_seconds: 10
    max_segments_per_day: 200     # durable daily SMS-segment budget across all recipients; 0 = unlimited

digest:
  channels: []                  # which alerts.<channel> destinations also get a digest, e.g. [email]
  send_when_empty: false
  max_devices_per_section: 20

dhcp_servers:
  enabled: false                # opt-in; needs scan.passive/scan.dhcp_snooping too - see "Unexpected DHCP servers"
  approved: []                  # e.g. [{interface: eth0, server_ip: 192.168.1.1, name: Main router}]
  alert_cooldown_seconds: 3600  # per (interface, server) - don't flood findings from one noisy server

retention:
  max_evidence_rows_per_mac: 100      # retained address/name evidence rows kept per MAC; oldest pruned first
  max_dhcp_server_findings: 5000      # total DHCP-server-finding rows retained; oldest pruned first
  max_discovery_rows_per_table: 5000  # total rows per mDNS/SSDP table; oldest pruned first (on top of TTL expiry)

db_path: ~/.local/share/lanfence/lanfence.db
allowlist_file: ~/.config/lanfence/allowlist.yaml
vendor_file: null             # extra OUI table, merged with the packaged one
rogue_signatures_file: null   # extra signatures, merged with the packaged ones

Every channel dispatches independently and only when enabled: true and fully configured; min_severity gates all of them at once. Twilio SMS is capped at ~480 characters per alert (a compact one-line summary, not the full multi-line report the other channels get) since SMS is billed per segment.

rate_limit_seconds (default 15 minutes) is a per-MAC cooldown on top of that: once a device has triggered a dispatch, further alerts about it are suppressed until the cooldown elapses - unless a new finding's severity is higher than what was last alerted, which always gets through immediately. This only throttles the external channels above; the CLI table, JSON output, and the database's event history are always complete, so a flapping device (a phone's Wi-Fi cycling, a laptop sleeping/waking) doesn't spam every channel - or run up a Twilio bill - once per scan interval. Set it to 0 to alert every time, matching earlier versions' behavior.

global_rate_limit_max/global_rate_limit_window_seconds cap total alert volume across every device combined, independent of the per-MAC cooldown above - a per-MAC cooldown alone can't bound volume from many distinct or rotating identities (e.g. randomized MAC addresses), since each one looks "new" to it. An escalation still counts against this global cap even though it bypasses its own per-MAC cooldown. Set global_rate_limit_max to 0 to disable it.

twilio.max_segments_per_day is a durable (survives a restart) daily budget on total SMS segments sent, counting every recipient and every ~153-character segment of each message - a cost-safety guardrail against a flood of findings driving unbounded SMS billing, independent of the per-alert 480-character cap above. Once exhausted, remaining recipients for that dispatch are skipped (not sent) until the next UTC calendar day. Set it to 0 for no budget. lanfence reset does not clear this budget - it's a billing safeguard, not device inventory.

lanfence monitor's alert delivery (network I/O to each channel) runs on a bounded background thread, so a slow or unreachable destination (e.g. a webhook endpoint that's down) never blocks the main loop from continuing to process new sightings and active sweeps. If delivery genuinely can't keep up, newer alert batches are dropped (logged, not silently lost) rather than buffering without limit - the underlying finding and its database record are never affected by whether delivery itself succeeded.

Exit codes (--fail-on-findings)

scan and report accept --fail-on-findings for CI/scripting use:

Highest severity in the result Exit code
none / info 0
medium 10
high 20

Privacy and security

  • No telemetry, no automatic external calls. LAN Fence never phones home on its own. Alert destinations are ones you configure and enable (your own syslog daemon, SMTP relay, webhook, Slack/Discord/Teams webhook, ntfy topic, or Twilio account) - nothing is contacted unless you set enabled: true and fill in its details. The only other network access is two commands that exist purely to fetch something you asked for, only when you run them: lanfence upgrade (checks/installs from PyPI) and lanfence vendor-refresh (downloads the IEEE OUI registry). Every other command touches only your local network (ARP/ND) and disk.
  • The bundled vendor and signature tables are static snapshots taken when this version was built; nothing is fetched automatically to "keep them fresh" - that's what vendor-refresh is for, on request.
  • The device database and allowlist are written atomically. The database's containing directory is created (or, if it already exists, tightened) to mode 0700 and the database file itself to 0600 - regardless of the process umask - and the same applied to any pre-existing SQLite journal/WAL sidecar files; only the directory LAN Fence itself owns is ever touched, never a shared ancestor like ~/.local/share. A directory or file unexpectedly owned by a different user, or that is actually a symlink, is refused with a clear error rather than silently used - the database file, its containing directory, and its SQLite sidecar files are all opened with an atomic, symlink-refusing syscall (O_NOFOLLOW/ O_EXCL), never a separate exists-then-open check that a symlink swap could race between, so a database path an attacker redirected to another file is refused rather than followed.
  • Bounded against a hostile or flooding LAN. monitor's passive processing queues (scan.passive_queue_maxsize) are bounded and drop (counted, logged) rather than grow without limit under a packet flood; repeated identical observations in one burst are coalesced without losing any distinct evidence; alert delivery is bounded and backgrounded so a slow/unreachable destination can't stall sighting processing; a global alert-volume cap (alerts.global_rate_limit_max) bounds total external alert dispatch even from many distinct or rotating (e.g. randomized MAC) identities, which a per-MAC cooldown alone cannot; retained per-MAC evidence and DHCP-server/discovery-advertisement rows are capped (retention.*), independent of (and tighter than) time-based expiry, so a burst of spoofed/rotating observations can't grow the database without bound before any individually expire.
  • Delivery failures never log or display raw transport details. A channel's server can influence what a raised exception's text contains (an HTTP "reason phrase", an SMTP response line) - LAN Fence never logs or shows that raw text. Every alert/digest/test-message failure is reported as the exception's class name plus a validated numeric code where the transport provides one (an HTTP status, an SMTP reply code) - e.g. HTTPError (code 502) - never a full webhook URL, credentials, a response body, or (for Twilio) a recipient's phone number.
  • The example systemd service runs as a dedicated, unprivileged account, not root - see packaging/lanfence.service, which grants only CAP_NET_RAW (the one capability scanning needs, documented above) directly via systemd, never via setcap on the shared Python interpreter or the lanfence script itself, plus filesystem/capability/syscall sandboxing (ProtectSystem=strict, NoNewPrivileges, UMask=0077, and more).

Development

python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev,scan]"
pytest

See CONTRIBUTING.md for scope and pull-request guidelines, and DISTRIBUTING.md for licensing notes on the optional scapy (GPL-2.0) dependency.

License

MIT - see LICENSE.

Release files for lanfence 0.4.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lanfence 0.4.5
File Size Uploaded
lanfence-0.4.5.tar.gz 773.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lanfence 0.4.5
File Interpreter ABI Platform
lanfence-0.4.5-py3-none-any.whl Python 3 none any Details

Total release size: 1.4 MB

Release files / lanfence-0.4.5.tar.gz

Download URL lanfence-0.4.5.tar.gz
Size 773.7 kB
Tags Source
SHA-256 checksum
How to use checksums
5a2dd51e05da444d2c84fedfe5243745a42f19183fb88efebaad6c39e577e564
BLAKE2b-256 checksum
How to use checksums
cc3a80189b066258d627edbb3bccd6bf6b59f90fea374d8b3e0790a392e0c1e1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release files / lanfence-0.4.5-py3-none-any.whl

Download URL lanfence-0.4.5-py3-none-any.whl
Size 613.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cdb88a64473e46b0aae286021c218ec8525dd7c844474a14d4e2962012c2bea5
BLAKE2b-256 checksum
How to use checksums
426e1c667a1d8b09910976cab929cf4935c286151fa9faf0048e459e99910905
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.5.7

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.5.4

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.4.6

2 release files

This release

0.4.5 This release

2 release files

0.4.4

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.3.13

2 release files

0.3.12

2 release files

0.3.11

2 release files

0.3.10

2 release files

0.3.9

2 release files

0.3.8

2 release files

0.3.7

2 release files

0.3.6

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page