From bad28a3bc0fa3869404a658052a602bd9e4e8ad2 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:14:21 +0100 Subject: [PATCH] feat(sources): name devices by MAC address in a rule, on the local network only A rule that names an address loses its device when DHCP gives it another. SourceMatch.MACs names devices by MAC instead: a MAC whole, in any spelling, or a pattern for a maker's devices (04:03:12:*). The reader asks the neighbor table (#104) for the MAC of the address each message comes from, as it arrives; the preview and the reading again of stored messages ask the same table. A MAC is only known on this machine's own networks. Behind a router every device arrives with the router's MAC, so the table is not asked there and a rule naming MACs never matches: an address with no MAC known - routed, or not in the table yet - is no match. The editor says so under the field, in plain sight, and so do the guide and the documentation page. - When a rule names one address whose MAC is known, the editor offers to name the MAC instead. - The Devices tab finds the rule that names a device by its MAC too. - Entries that are no MAC are refused (macEntry). --- README.md | 3 +- docs/USER_GUIDE.md | 7 +++ docs/documentation.html | 4 +- frontend/screenshots/bridge/App.js | 10 ++- frontend/src/components/SourceDevices.svelte | 6 +- .../src/components/SourceRuleEditor.svelte | 38 ++++++++++- frontend/src/components/SourceRules.svelte | 2 +- frontend/src/lib/i18n/de.json | 3 + frontend/src/lib/i18n/en.json | 3 + frontend/src/lib/i18n/es.json | 3 + frontend/src/lib/i18n/fr.json | 3 + frontend/src/lib/i18n/it.json | 3 + frontend/src/lib/i18n/ja.json | 3 + frontend/src/lib/i18n/pt.json | 3 + frontend/src/lib/i18n/zh.json | 3 + frontend/src/lib/sources.ts | 24 ++++++- frontend/wailsjs/go/models.ts | 2 + internal/engine/engine.go | 1 + internal/engine/reread.go | 3 + internal/engine/sources.go | 10 ++- internal/models/sources.go | 9 ++- internal/sources/inventory_test.go | 2 +- internal/sources/sources.go | 63 +++++++++++++++++-- internal/sources/sources_test.go | 39 +++++++++++- 24 files changed, 227 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index efad8fa..0e4204a 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ starts with the computer, before anyone signs in, and that the window then drive or encoding, and watch its latest messages change before saving — then read the stored history again with it. Start from a template, save your own, and export them to share. **Settings > Devices** lists every sender, and marks those that could use a rule; on the - local network, with its MAC address and maker, and the devices whose address changed + local network, with its MAC address and maker, and the devices whose address changed — and a + rule can name a device by its MAC, which follows it through DHCP (local network only) - **Mutual TLS** where senders must prove who they are - **Allowed sources** by IP literal or CIDR (hygiene, not authentication — see the note below) - **Per-IP connection cap** so one host cannot occupy every slot diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fef75d1..5d0173e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -162,6 +162,13 @@ A rule has three parts. - **Addresses**: comma-separated, each matched whole: an address (`10.0.7.12`), a CIDR range (`10.0.7.0/24`) or a pattern (`10.0.7.*`). An address is that device only; a rule naming `10.0.7.1` does not read `10.0.7.12`. +- **MAC addresses** — **local network only**: a MAC whole, in any spelling (`aa:bb:cc:dd:ee:ff`, + `AA-BB-CC-DD-EE-FF`, `aabb.ccdd.eeff`), or a pattern for a maker's devices (`04:03:12:*`). A rule + by MAC follows a device whose address changes (DHCP). The MAC comes from this computer's neighbor + table, read every 30 seconds, which only knows the devices on its own networks: behind a router, + every device arrives with the router's MAC, and **a rule by MAC never matches it** — name its + address instead. A device's very first messages may arrive before its MAC is known. When a rule + names one address whose MAC is known, the editor offers to name the MAC instead. - **Hostnames** and **Applications**: patterns such as `fw-*`, matched against the message as read without the rule. - **Raw text contains**: a regular expression, case-insensitive. Behind a relay every device diff --git a/docs/documentation.html b/docs/documentation.html index b666d6c..8d8ed0e 100644 --- a/docs/documentation.html +++ b/docs/documentation.html @@ -170,7 +170,9 @@

Messages that bend the standard

send no hostname with no rule to read them. On this computer's own networks it adds each device's MAC address and maker, read from the system's neighbor table — a device behind a router shows none, since only the router's would be known — and points out a device - whose address changed or an address whose device did. The messages already stored can be read + whose address changed or an address whose device did. A rule can name devices by MAC + address too, and follow one through DHCP — on the local network only, since behind a + router every device arrives with the router's MAC. The messages already stored can be read again from their raw text with the rules as they are now — from every device or from one rule's addresses, over the last day, week, month or all of it.

diff --git a/frontend/screenshots/bridge/App.js b/frontend/screenshots/bridge/App.js index accdb4f..22f9f21 100644 --- a/frontend/screenshots/bridge/App.js +++ b/frontend/screenshots/bridge/App.js @@ -105,11 +105,17 @@ let sourceTemplates = clone(SOURCE_TEMPLATES); */ function trySource(rule, samples) { const named = (rule.match.sources || []).filter((s) => s.trim()).map((s) => addressMatcher(s, false)); - const matches = (ip) => named.some((take) => take(ip)); + const macs = (rule.match.macs || []).map((m) => m.trim().toLowerCase()).filter(Boolean); + const macOf = (ip) => (SOURCE_DEVICES.find((d) => d.address === ip) || {}).mac || ''; + const matches = (ip) => (!named.length || named.some((take) => take(ip))) + && (!macs.length || macs.some((m) => { + const mac = macOf(ip); + return mac && (m.endsWith('*') ? mac.startsWith(m.slice(0, -1)) : mac === m); + })); return samples.map((s) => { const before = messages.find((m) => m.rawMessage === s.raw) || { ...messages[0], rawMessage: s.raw, message: s.raw, sourceIP: s.sourceIP }; - const matched = named.length > 0 && matches(s.sourceIP); + const matched = (named.length > 0 || macs.length > 0) && matches(s.sourceIP); const after = { ...before }; if (matched) { if (rule.hostnameFromSource) after.hostname = s.sourceIP; diff --git a/frontend/src/components/SourceDevices.svelte b/frontend/src/components/SourceDevices.svelte index 76a3d9d..67c6686 100644 --- a/frontend/src/components/SourceDevices.svelte +++ b/frontend/src/components/SourceDevices.svelte @@ -80,7 +80,7 @@ $: needing = rows.filter(r => r.device && attention(r.device)).length; function readDifferently(row: Row) { - const existing = ruleForSource(rules, row.address); + const existing = ruleForSource(rules, row.address, row.device?.mac); const rule = existing ? structuredClone(existing) : ruleForDevice(row.address, row.device); let samples = samplesForRule($messages, rule); if (!samples.some(s => s.sourceIP === row.address) && row.device?.lastRaw) { @@ -92,7 +92,7 @@ /** The rule that names an address this device had, when none names its own. */ function oldRule(d: SourceDevice): { name: string; address: string } | undefined { - if (ruleForSource(rules, d.address)) return undefined; + if (ruleForSource(rules, d.address, d.mac)) return undefined; for (const a of d.alsoAt || []) { const r = rules.find(x => (x.match.sources || []).some(s => s.trim() === a)); if (r) return { name: r.name, address: a }; @@ -215,7 +215,7 @@ diff --git a/frontend/src/components/SourceRuleEditor.svelte b/frontend/src/components/SourceRuleEditor.svelte index 7f58d5b..1903002 100644 --- a/frontend/src/components/SourceRuleEditor.svelte +++ b/frontend/src/components/SourceRuleEditor.svelte @@ -16,7 +16,7 @@ import FormatFields from './FormatFields.svelte'; import SaveTemplate from './SaveTemplate.svelte'; import { - deleteSourceRule, getSourceTemplates, isOwnTemplate, listText, parseList, ruleDraft, rulesVersion, + deleteSourceRule, getSourceTemplates, isOwnTemplate, listText, lookupAddress, parseList, ruleDraft, rulesVersion, samplesForRule, saveSourceRule, templateName, templatesVersion, trySourceRule, withTemplate, } from '../lib/sources'; import type { SourceRule, SourceSample, SourceTemplate, SourceTrial } from '../lib/sources'; @@ -34,6 +34,25 @@ rule.levelFrom = rule.levelFrom || ''; rule.encoding = rule.encoding || ''; let sourcesText = listText(rule.match.sources); + let macsText = listText(rule.match.macs); + // A rule naming one address whose MAC is known can name the MAC + // instead, and follow the device when its address changes. + let knownMAC = ''; + $: single = parseList(sourcesText).length === 1 ? parseList(sourcesText)[0] : ''; + $: lookupMAC(single); + async function lookupMAC(address: string) { + knownMAC = ''; + if (!address || /[*?/]/.test(address)) return; + try { + const info = await lookupAddress(address); + if (address === single && info.mac && !info.thisMachine) knownMAC = info.mac; + } catch { /* nothing to offer */ } + } + function useMAC() { + macsText = knownMAC; + sourcesText = ''; + retry(); + } // The samples it opened with, until the addresses change: then the // latest messages from the addresses as now written. const openedWith = sourcesText; @@ -77,6 +96,7 @@ ...rule, match: { sources: parseList(sourcesText), + macs: parseList(macsText), hostnames: parseList(hostnamesText), appNames: parseList(appNamesText), pattern: (rule.match.pattern || '').trim(), @@ -217,6 +237,14 @@

{$_('sources.sourcesHint')}

+ +

{$_('sources.macsHint')}

+ {#if knownMAC && !parseList(macsText).length} + + {/if}