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 @@
readDifferently(row)}
title={d?.lastRaw || ''}>
- {ruleForSource(rules, row.address) ? $_('sources.devEditRule') : $_('sources.devRead')}
+ {ruleForSource(rules, row.address, d?.mac) ? $_('sources.devEditRule') : $_('sources.devRead')}
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.macs')}
+
+
+ {$_('sources.macsHint')}
+ {#if knownMAC && !parseList(macsText).length}
+ {$_('sources.useMac', { values: { mac: knownMAC } })}
+ {/if}
{$_('sources.hostnames')}
@@ -396,6 +424,14 @@
padding: 12px 16px; border-top: 1px solid var(--border-color);
}
.spacer { flex: 1; }
+ .note.warn {
+ padding: 6px 8px; border-left: 3px solid var(--warning); background: var(--warning-bg);
+ color: var(--text-primary); font-size: 11px;
+ }
+ .link {
+ align-self: flex-start; background: none; border: none; padding: 0;
+ color: var(--accent); font-size: 11px; cursor: pointer; text-decoration: underline;
+ }
.suggest {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px;
border: 1px solid var(--accent); border-radius: 6px; background: var(--accent-bg);
diff --git a/frontend/src/components/SourceRules.svelte b/frontend/src/components/SourceRules.svelte
index 83e1f8b..7d32cbc 100644
--- a/frontend/src/components/SourceRules.svelte
+++ b/frontend/src/components/SourceRules.svelte
@@ -35,7 +35,7 @@
/** What a rule matches, as one line. */
function who(r: SourceRule): string {
const m = r.match;
- return [listText(m.sources), listText(m.hostnames), listText(m.appNames), m.pattern ? `/${m.pattern}/` : '']
+ return [listText(m.sources), listText(m.macs), listText(m.hostnames), listText(m.appNames), m.pattern ? `/${m.pattern}/` : '']
.filter(Boolean).join(' · ');
}
diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json
index 8aaea4a..27b6ae8 100644
--- a/frontend/src/lib/i18n/de.json
+++ b/frontend/src/lib/i18n/de.json
@@ -760,6 +760,9 @@
"which": "Welche Meldungen",
"sources": "Adressen",
"sourcesHint": "Jede Adresse wird vollständig verglichen: eine Adresse (10.0.0.9), ein CIDR-Bereich (10.0.1.0/24) oder ein Muster, in dem * für beliebige Zeichen und ? für ein Zeichen steht (10.0.2.*).",
+ "macs": "MAC-Adressen",
+ "macsHint": "Nur im lokalen Netz. Eine MAC ist nur für Geräte in den eigenen Netzen dieses Computers bekannt, aus seiner Nachbartabelle (alle 30 Sekunden gelesen). Hinter einem Router kommt jedes Gerät mit der MAC des Routers an: Eine Regel nach MAC trifft es nie — nennen Sie stattdessen seine Adresse. Ein Muster erfasst die Geräte eines Herstellers: 04:03:12:*.",
+ "useMac": "Seine MAC, {mac}, statt seiner Adresse nennen — die Regel folgt ihm, wenn sich seine Adresse ändert",
"hostnames": "Hostnamen",
"appNames": "Anwendungen",
"pattern": "Rohtext enthält",
diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index a1be519..5769bfa 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -760,6 +760,9 @@
"which": "Which messages",
"sources": "Addresses",
"sourcesHint": "Each address is matched whole: one address (10.0.0.9), a CIDR range (10.0.1.0/24), or a pattern where * stands for any characters and ? for one (10.0.2.*).",
+ "macs": "MAC addresses",
+ "macsHint": "Local network only. A MAC is known only for the devices on this computer’s own networks, from its neighbor table (read every 30 seconds). Behind a router, every device arrives with the router’s MAC: a rule by MAC never matches it — name its address instead. A pattern takes a maker’s devices: 04:03:12:*.",
+ "useMac": "Name its MAC, {mac}, instead of its address — the rule follows it when its address changes",
"hostnames": "Hostnames",
"appNames": "Applications",
"pattern": "Raw text contains",
diff --git a/frontend/src/lib/i18n/es.json b/frontend/src/lib/i18n/es.json
index 87f71be..b60dfea 100644
--- a/frontend/src/lib/i18n/es.json
+++ b/frontend/src/lib/i18n/es.json
@@ -760,6 +760,9 @@
"which": "Qué mensajes",
"sources": "Direcciones",
"sourcesHint": "Cada dirección se compara entera: una dirección (10.0.0.9), un rango CIDR (10.0.1.0/24) o un patrón donde * sustituye a cualquier secuencia de caracteres y ? a uno solo (10.0.2.*).",
+ "macs": "Direcciones MAC",
+ "macsHint": "Solo red local. Una MAC solo se conoce para los dispositivos de las propias redes de este equipo, por su tabla de vecinos (leída cada 30 segundos). Tras un router, todos los dispositivos llegan con la MAC del router: una regla por MAC nunca los reconoce — indique su dirección. Un patrón abarca los dispositivos de un fabricante: 04:03:12:*.",
+ "useMac": "Indicar su MAC, {mac}, en lugar de su dirección — la regla lo sigue cuando su dirección cambia",
"hostnames": "Nombres de host",
"appNames": "Aplicaciones",
"pattern": "El texto bruto contiene",
diff --git a/frontend/src/lib/i18n/fr.json b/frontend/src/lib/i18n/fr.json
index 1b8bb8e..d44e45a 100644
--- a/frontend/src/lib/i18n/fr.json
+++ b/frontend/src/lib/i18n/fr.json
@@ -760,6 +760,9 @@
"which": "Quels messages",
"sources": "Adresses",
"sourcesHint": "Chaque adresse est comparée en entier : une adresse (10.0.0.9), une plage CIDR (10.0.1.0/24), ou un motif où * remplace n'importe quels caractères et ? un seul (10.0.2.*).",
+ "macs": "Adresses MAC",
+ "macsHint": "Réseau local uniquement. Une MAC n’est connue que pour les appareils des propres réseaux de cet ordinateur, d’après sa table de voisinage (lue toutes les 30 secondes). Derrière un routeur, tous les appareils arrivent avec la MAC du routeur : une règle par MAC ne les reconnaît jamais — visez plutôt leur adresse. Un motif désigne les appareils d’un fabricant : 04:03:12:*.",
+ "useMac": "Viser sa MAC, {mac}, plutôt que son adresse — la règle le suit quand son adresse change",
"hostnames": "Noms d’hôte",
"appNames": "Applications",
"pattern": "Le texte brut contient",
diff --git a/frontend/src/lib/i18n/it.json b/frontend/src/lib/i18n/it.json
index d952394..1e16630 100644
--- a/frontend/src/lib/i18n/it.json
+++ b/frontend/src/lib/i18n/it.json
@@ -760,6 +760,9 @@
"which": "Quali messaggi",
"sources": "Indirizzi",
"sourcesHint": "Ogni indirizzo viene confrontato per intero: un indirizzo (10.0.0.9), un intervallo CIDR (10.0.1.0/24) o un modello in cui * sostituisce qualsiasi sequenza di caratteri e ? uno solo (10.0.2.*).",
+ "macs": "Indirizzi MAC",
+ "macsHint": "Solo rete locale. Un MAC è noto solo per i dispositivi delle reti di questo computer, dalla sua tabella dei vicini (letta ogni 30 secondi). Dietro un router, ogni dispositivo arriva con il MAC del router: una regola per MAC non lo riconosce mai — indica invece il suo indirizzo. Un modello comprende i dispositivi di un produttore: 04:03:12:*.",
+ "useMac": "Indicare il suo MAC, {mac}, invece dell’indirizzo — la regola lo segue quando l’indirizzo cambia",
"hostnames": "Nomi host",
"appNames": "Applicazioni",
"pattern": "Il testo grezzo contiene",
diff --git a/frontend/src/lib/i18n/ja.json b/frontend/src/lib/i18n/ja.json
index fd350f6..238bab8 100644
--- a/frontend/src/lib/i18n/ja.json
+++ b/frontend/src/lib/i18n/ja.json
@@ -760,6 +760,9 @@
"which": "対象のメッセージ",
"sources": "アドレス",
"sourcesHint": "各アドレスは全体で照合されます: 1 つのアドレス (10.0.0.9)、CIDR 範囲 (10.0.1.0/24)、または * が任意の文字列、? が 1 文字を表すパターン (10.0.2.*)。",
+ "macs": "MAC アドレス",
+ "macsHint": "ローカルネットワークのみ。MAC はこのコンピューター自身のネットワーク上のデバイスについてのみ、近隣テーブル (30 秒ごとに読み取り) から判明します。ルーターの先にあるデバイスはすべてルーターの MAC で届くため、MAC によるルールには一致しません。代わりにアドレスを指定してください。パターンでメーカーのデバイスを指定できます: 04:03:12:*。",
+ "useMac": "アドレスの代わりに MAC ({mac}) を指定 — アドレスが変わってもルールが追従します",
"hostnames": "ホスト名",
"appNames": "アプリケーション",
"pattern": "生テキストに含む",
diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json
index a07f721..f161131 100644
--- a/frontend/src/lib/i18n/pt.json
+++ b/frontend/src/lib/i18n/pt.json
@@ -760,6 +760,9 @@
"which": "Que mensagens",
"sources": "Endereços",
"sourcesHint": "Cada endereço é comparado por inteiro: um endereço (10.0.0.9), um intervalo CIDR (10.0.1.0/24) ou um padrão em que * substitui quaisquer caracteres e ? um só (10.0.2.*).",
+ "macs": "Endereços MAC",
+ "macsHint": "Só rede local. Um MAC só é conhecido para os dispositivos das próprias redes deste computador, pela sua tabela de vizinhos (lida a cada 30 segundos). Atrás de um router, todos os dispositivos chegam com o MAC do router: uma regra por MAC nunca os reconhece — indique o endereço. Um padrão abrange os dispositivos de um fabricante: 04:03:12:*.",
+ "useMac": "Indicar o seu MAC, {mac}, em vez do endereço — a regra segue-o quando o endereço muda",
"hostnames": "Nomes de host",
"appNames": "Aplicações",
"pattern": "O texto bruto contém",
diff --git a/frontend/src/lib/i18n/zh.json b/frontend/src/lib/i18n/zh.json
index 35f5209..078e2d9 100644
--- a/frontend/src/lib/i18n/zh.json
+++ b/frontend/src/lib/i18n/zh.json
@@ -760,6 +760,9 @@
"which": "哪些消息",
"sources": "地址",
"sourcesHint": "每个地址都按整体匹配:单个地址 (10.0.0.9)、CIDR 范围 (10.0.1.0/24),或用 * 代表任意字符、? 代表一个字符的模式 (10.0.2.*)。",
+ "macs": "MAC 地址",
+ "macsHint": "仅限局域网。只有本机所在网络中的设备才能从邻居表(每 30 秒读取一次)得知 MAC。路由器之后的设备都以路由器的 MAC 到达:按 MAC 的规则永远不会匹配它们——请改用其地址。模式可指定某制造商的设备:04:03:12:*。",
+ "useMac": "改用其 MAC({mac})而不是地址——地址变化时规则仍会跟随",
"hostnames": "主机名",
"appNames": "应用",
"pattern": "原始文本包含",
diff --git a/frontend/src/lib/sources.ts b/frontend/src/lib/sources.ts
index 631b927..941ba0d 100644
--- a/frontend/src/lib/sources.ts
+++ b/frontend/src/lib/sources.ts
@@ -13,6 +13,8 @@ import type { SyslogMessage } from './stores';
export interface SourceMatch {
/** Addresses messages arrive from: an IP, a CIDR, or a pattern with "*". */
sources?: string[];
+ /** MAC addresses, whole or a pattern ("04:03:12:*"): on the local network only. */
+ macs?: string[];
/** Globs, matched against the message as read before the rule. */
hostnames?: string[];
appNames?: string[];
@@ -248,14 +250,32 @@ export function namesAddress(rule: SourceRule, ip: string): boolean {
return (rule.match.sources || []).some(s => s.trim() !== '' && addressMatcher(s, false)(ip));
}
+/** A MAC as the engine writes it — lowercase, colon-separated — from any spelling; '' if it is none. */
+export function normalizeMAC(s: string): string {
+ const hex = s.trim().toLowerCase().replace(/[:.-]/g, '');
+ return /^[0-9a-f]{12}$/.test(hex) ? hex.match(/../g)!.join(':') : '';
+}
+
+/** Whether one of a rule's MACs takes mac, as the engine reads them (sources.macEntry). */
+export function namesMAC(rule: SourceRule, mac?: string): boolean {
+ if (!mac) return false;
+ return (rule.match.macs || []).some(e => {
+ const t = e.trim().toLowerCase();
+ if (!t) return false;
+ if (/[*?]/.test(t)) return addressMatcher(t.replace(/-/g, ':'), false)(mac);
+ return normalizeMAC(t) === mac;
+ });
+}
+
/**
* The rule that already reads a device by its address — the first in order,
* as the engine tries them; a rule turned off only when none is on. A CIDR
* or a pattern counts: a new rule for one of its devices, added after it,
* would never be reached.
*/
-export function ruleForSource(rules: SourceRule[], ip: string): SourceRule | undefined {
- return rules.find(r => r.enabled && namesAddress(r, ip)) ?? rules.find(r => namesAddress(r, ip));
+export function ruleForSource(rules: SourceRule[], ip: string, mac?: string): SourceRule | undefined {
+ const names = (r: SourceRule) => namesAddress(r, ip) || namesMAC(r, mac);
+ return rules.find(r => r.enabled && names(r)) ?? rules.find(names);
}
/**
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 3edd325..4e156ad 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1006,6 +1006,7 @@ export namespace models {
}
export class SourceMatch {
sources?: string[];
+ macs?: string[];
hostnames?: string[];
appNames?: string[];
pattern?: string;
@@ -1017,6 +1018,7 @@ export namespace models {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sources = source["sources"];
+ this.macs = source["macs"];
this.hostnames = source["hostnames"];
this.appNames = source["appNames"];
this.pattern = source["pattern"];
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index cf9df8d..c8830df 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -136,6 +136,7 @@ func (e *Engine) Open(emitter event.EventEmitter) error {
e.inventory = sources.NewInventory()
e.reader.SetInventory(e.inventory)
e.neighbors = neighbors.NewCache()
+ e.reader.SetMACLookup(e.neighbors.MAC)
e.stopNeighbors = make(chan struct{})
go e.watchNeighbors(e.stopNeighbors)
e.server.SetReader(e.reader.Read)
diff --git a/internal/engine/reread.go b/internal/engine/reread.go
index 6b2d573..51b5552 100644
--- a/internal/engine/reread.go
+++ b/internal/engine/reread.go
@@ -45,6 +45,9 @@ func (e *Engine) RereadHistory(opts models.RereadOptions) error {
// A rule that does not compile is left out, as when it is applied
// to what arrives.
_ = reader.SetRules(opts.Rules)
+ if lookup := e.macLookup(); lookup != nil {
+ reader.SetMACLookup(lookup)
+ }
}
var since time.Time
if opts.Since != "" {
diff --git a/internal/engine/sources.go b/internal/engine/sources.go
index b65311e..c46baff 100644
--- a/internal/engine/sources.go
+++ b/internal/engine/sources.go
@@ -55,6 +55,14 @@ func (e *Engine) GetSourceDevices() []models.SourceDevice {
return devices
}
+// macLookup is the MAC of an address, for the rules that name MACs.
+func (e *Engine) macLookup() func(string) string {
+ if e.neighbors == nil {
+ return nil
+ }
+ return e.neighbors.MAC
+}
+
// LookupAddress says what is known of an address: whether it is this
// machine or on one of its networks, and then its MAC and maker.
func (e *Engine) LookupAddress(address string) models.AddressInfo {
@@ -183,5 +191,5 @@ func (e *Engine) TrySourceRule(r models.SourceRule, samples []models.SourceSampl
if strings.TrimSpace(r.Name) == "" {
r.Name = defaultRuleName(r.Match)
}
- return sources.Try(r, samples)
+ return sources.Try(r, samples, e.macLookup())
}
diff --git a/internal/models/sources.go b/internal/models/sources.go
index 1732d6d..da3b5e9 100644
--- a/internal/models/sources.go
+++ b/internal/models/sources.go
@@ -56,6 +56,11 @@ type SourceMatch struct {
// pattern with "*" ("10.0.7.*"), as the Source IP filter takes them
// (search.Address; checked where rules are compiled, internal/sources).
Sources []string `json:"sources,omitempty"`
+ // MACs are the MAC addresses the messages come from: whole, or a
+ // pattern ("04:03:12:*", a maker). Known only on this machine's own
+ // networks, from the neighbor table: a device behind a router has none,
+ // and a rule naming MACs never matches it.
+ MACs []string `json:"macs,omitempty"`
// Hostnames and AppNames are globs ("fw-*"), matched against the
// message as read before the rule.
Hostnames []string `json:"hostnames,omitempty"`
@@ -211,9 +216,9 @@ type HistoryReread struct {
// The format's own pattern is compiled where it is read (importer).
func ValidateSourceRule(r SourceRule) error {
m := r.Match
- if len(nonEmpty(m.Sources)) == 0 && len(nonEmpty(m.Hostnames)) == 0 &&
+ if len(nonEmpty(m.Sources)) == 0 && len(nonEmpty(m.MACs)) == 0 && len(nonEmpty(m.Hostnames)) == 0 &&
len(nonEmpty(m.AppNames)) == 0 && strings.TrimSpace(m.Pattern) == "" {
- return fmt.Errorf("say which messages the rule applies to: a source, a hostname, an application or a pattern")
+ return fmt.Errorf("say which messages the rule applies to: a source, a MAC, a hostname, an application or a pattern")
}
for _, g := range append(nonEmpty(m.Hostnames), nonEmpty(m.AppNames)...) {
if _, err := path.Match(g, ""); err != nil {
diff --git a/internal/sources/inventory_test.go b/internal/sources/inventory_test.go
index f4ff098..06f5bf0 100644
--- a/internal/sources/inventory_test.go
+++ b/internal/sources/inventory_test.go
@@ -25,7 +25,7 @@ func TestInventory(t *testing.T) {
r.Read([]byte(`<189>date=2026-10-27 time=09:14:02 devname="FGT60F" level="warning" msg="x"`), "10.0.0.1", "UDP")
// Read again and tried: not received, not counted.
r.ReadAt([]byte("<14>Oct 27 09:14:03 relay-c sshd: hi"), "10.0.0.9", "UDP", time.Now())
- Try(models.SourceRule{Name: "t", Enabled: true, Match: bySource("10.0.0.9")}, []models.SourceSample{{Raw: "x", SourceIP: "10.0.0.9", Protocol: "UDP"}})
+ Try(models.SourceRule{Name: "t", Enabled: true, Match: bySource("10.0.0.9")}, []models.SourceSample{{Raw: "x", SourceIP: "10.0.0.9", Protocol: "UDP"}}, nil)
devs := inv.Devices(time.Now())
by := map[string]models.SourceDevice{}
diff --git a/internal/sources/sources.go b/internal/sources/sources.go
index f50693c..fd107a5 100644
--- a/internal/sources/sources.go
+++ b/internal/sources/sources.go
@@ -12,6 +12,7 @@ package sources
import (
"fmt"
"regexp"
+ "slices"
"strings"
"sync/atomic"
"time"
@@ -21,6 +22,7 @@ import (
"SyslogStudio/internal/importer"
"SyslogStudio/internal/models"
+ "SyslogStudio/internal/neighbors"
"SyslogStudio/internal/notify"
"SyslogStudio/internal/search"
"SyslogStudio/internal/syslog"
@@ -34,6 +36,21 @@ type Reader struct {
// inventory, when set, counts what Read reads: messages received, not
// those read again or tried.
inventory atomic.Pointer[Inventory]
+ // macOf gives the MAC of an address, for the rules that name MACs.
+ macOf atomic.Pointer[func(string) string]
+}
+
+// SetMACLookup has the rules that name MACs ask lookup for the MAC of the
+// address a message came from: the neighbor table's, on this machine's own
+// networks, "" anywhere else.
+func (r *Reader) SetMACLookup(lookup func(address string) string) { r.macOf.Store(&lookup) }
+
+// lookup is the MAC lookup set, nil if none is.
+func (r *Reader) lookup() func(string) string {
+ if f := r.macOf.Load(); f != nil {
+ return *f
+ }
+ return nil
}
// SetInventory has what is read from now on counted in inv.
@@ -48,6 +65,8 @@ func New() *Reader {
type rule struct {
models.SourceRule
sources []search.Address
+ // macs are MACs and MAC patterns, as neighbors.FormatMAC writes them.
+ macs []string
pattern *regexp.Regexp
format *importer.FormatReader
charmap *charmap.Charmap
@@ -67,6 +86,16 @@ func compile(r models.SourceRule) (*rule, error) {
}
c.sources = append(c.sources, search.CompileAddress(s, false))
}
+ for _, s := range r.Match.MACs {
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ m, err := macEntry(s)
+ if err != nil {
+ return nil, err
+ }
+ c.macs = append(c.macs, m)
+ }
if p := strings.TrimSpace(r.Match.Pattern); p != "" {
re, err := models.SafeCompileRegex(p)
if err != nil {
@@ -144,15 +173,26 @@ func (r *Reader) read(raw []byte, sourceIP, protocol string, at time.Time) (mode
if rules == nil {
return msg, rd
}
+ macOf := r.lookup()
for _, c := range *rules {
- if c.matches(msg) {
+ if c.matches(msg, macOf) {
return r.apply(c, raw, msg, rd, pri), rd
}
}
return msg, rd
}
-func (c *rule) matches(msg models.SyslogMessage) bool {
+func (c *rule) matches(msg models.SyslogMessage, macOf func(string) string) bool {
+ if len(c.macs) > 0 {
+ // No MAC known — routed, or not in the table yet — is no match.
+ mac := ""
+ if macOf != nil {
+ mac = macOf(msg.SourceIP)
+ }
+ if mac == "" || !slices.ContainsFunc(c.macs, func(p string) bool { return search.Glob(p, mac) }) {
+ return false
+ }
+ }
m := c.Match
return search.MatchAddresses(c.sources, msg.SourceIP) &&
notify.MatchesGlob(m.Hostnames, msg.Hostname) &&
@@ -225,7 +265,7 @@ func setSeverity(msg *models.SyslogMessage, sev models.Severity) {
// Try reads samples without any rule and with r, enabled or not, and says
// which it matched: what the rule editor shows before a rule is saved.
-func Try(sr models.SourceRule, samples []models.SourceSample) ([]models.SourceTrial, error) {
+func Try(sr models.SourceRule, samples []models.SourceSample, macOf func(string) string) ([]models.SourceTrial, error) {
c, err := compile(sr)
if err != nil {
return nil, err
@@ -238,7 +278,7 @@ func Try(sr models.SourceRule, samples []models.SourceSample) ([]models.SourceTr
pri := msg.Severity
reader.wire.Reread(&msg, rd)
trial := models.SourceTrial{Before: msg, After: msg}
- if c.matches(msg) {
+ if c.matches(msg, macOf) {
trial.Matched = true
trial.After = reader.apply(c, raw, msg, rd, pri)
}
@@ -246,3 +286,18 @@ func Try(sr models.SourceRule, samples []models.SourceSample) ([]models.SourceTr
}
return out, nil
}
+
+// macEntry reads a MAC entry of a rule: a whole MAC in any of its spellings,
+// or a pattern with "*" and "?" — "04:03:12:*" for a maker's devices.
+func macEntry(s string) (string, error) {
+ t := strings.ToLower(strings.TrimSpace(s))
+ if strings.ContainsAny(t, "*?") {
+ t = strings.ReplaceAll(t, "-", ":")
+ if strings.Trim(t, "0123456789abcdef:*?") == "" && len(t) <= 17 {
+ return t, nil
+ }
+ } else if mac, ok := neighbors.ParseMAC(t); ok {
+ return mac, nil
+ }
+ return "", fmt.Errorf("%q is not a MAC address: write one whole (aa:bb:cc:dd:ee:ff) or a pattern (aa:bb:cc:*)", strings.TrimSpace(s))
+}
diff --git a/internal/sources/sources_test.go b/internal/sources/sources_test.go
index 36e98fd..591dec1 100644
--- a/internal/sources/sources_test.go
+++ b/internal/sources/sources_test.go
@@ -189,7 +189,7 @@ func TestTry(t *testing.T) {
[]models.SourceSample{
{Raw: "<14>motion", SourceIP: "10.0.0.9", Protocol: "UDP"},
{Raw: "<14>motion", SourceIP: "10.0.0.8", Protocol: "UDP"},
- })
+ }, nil)
if err != nil {
t.Fatal(err)
}
@@ -233,3 +233,40 @@ func TestCompile_Refuses(t *testing.T) {
t.Errorf("the fine rule did not apply: %+v", m)
}
}
+
+// A rule naming MACs follows a device whatever its address, and only as far
+// as a MAC is known: a device with none — behind a router — never matches.
+func TestRule_MACs(t *testing.T) {
+ table := map[string]string{"192.168.1.20": "04:03:12:6e:58:a1", "192.168.1.31": "04:03:12:6e:58:a1", "192.168.1.40": "00:80:77:b2:19:4e"}
+ lookup := func(ip string) string { return table[ip] }
+ r := reader(t,
+ models.SourceRule{Name: "camera", Enabled: true, Match: models.SourceMatch{MACs: []string{"04-03-12-6E-58-A1"}}, AppName: "camera"},
+ models.SourceRule{Name: "maker", Enabled: true, Match: models.SourceMatch{MACs: []string{"00:80:77:*"}}, AppName: "printer"},
+ )
+ r.SetMACLookup(lookup)
+ for ip, want := range map[string]string{
+ "192.168.1.20": "camera", // its address
+ "192.168.1.31": "camera", // its address after a new lease
+ "192.168.1.40": "maker", // a maker's pattern
+ "10.20.0.5": "", // behind a router: no MAC, no match
+ } {
+ if got := r.Read([]byte("<14>motion"), ip, "UDP").ReadBy; got != want {
+ t.Errorf("%s: read by %q, want %q", ip, got, want)
+ }
+ }
+ // Tried, it says the same.
+ trials, err := Try(models.SourceRule{Name: "t", Match: models.SourceMatch{MACs: []string{"04:03:12:6e:58:a1"}}, AppName: "x"},
+ []models.SourceSample{{Raw: "<14>a", SourceIP: "192.168.1.31", Protocol: "UDP"}, {Raw: "<14>b", SourceIP: "10.20.0.5", Protocol: "UDP"}}, lookup)
+ if err != nil || !trials[0].Matched || trials[1].Matched {
+ t.Errorf("tried: %+v, %v", trials, err)
+ }
+ // No lookup at all: no match either.
+ if Compile(models.SourceRule{Name: "x", Match: models.SourceMatch{MACs: []string{"04:03:12:*"}}}) != nil {
+ t.Error("a maker's pattern refused")
+ }
+ for _, bad := range []string{"04:03:12", "camera", "zz:zz:zz:*", "04:03:12:6e:58:a1:00"} {
+ if Compile(models.SourceRule{Name: "x", Match: models.SourceMatch{MACs: []string{bad}}}) == nil {
+ t.Errorf("%q accepted as a MAC", bad)
+ }
+ }
+}