From 00cf9490f222f45210c548f0b618ed5ef3b3959c Mon Sep 17 00:00:00 2001
From: Wasabules <39313803+Wasabules@users.noreply.github.com>
Date: Tue, 27 Oct 2026 10:20:16 +0100
Subject: [PATCH] fix(filters): a whole address is that address; CIDR and
patterns too
The Source IP box kept every address containing what was typed, so
10.10.1.1 also showed 10.10.1.10-19 and 10.10.1.100-199 (#98). It now
reads what is typed as an address:
- a whole address is that address, an IPv6 one however written;
- a CIDR (10.10.1.0/24) is its range;
- "*" and "?" make a pattern (10.10.1.*), matched whole;
- anything else is still text the address contains, while it is typed.
One definition, search.Address, for the filter in the live view, the
history and exports, and for the addresses of source rules and routes.
The history answers a CIDR through an ss_addr SQL function, after the
index has narrowed the rows to its whole octets. The window repeats it
in lib/search.ts: parseIP and ipString follow Go's netip step by step,
held to it by testdata/addresses.json (52 texts, 42 cases), and the
filters spec gains the address cases. The case that said 10.0.0.1
finds 10.0.0.10 was the bug, and is gone.
Source rules: an address entry that is none of the above (half an
address, a name) is refused when saved, since it could match nothing.
The editor's preview follows the addresses as they are typed, CIDR and
patterns included, and right-clicking a message opens the rule that
reads its device by a range too: a new rule added after it would never
be reached. The Source IP box and the rule's addresses say what they
take, in the 8 languages.
---
docs/USER_GUIDE.md | 19 +-
frontend/screenshots/bridge/App.js | 6 +-
frontend/src/components/FilterBar.svelte | 2 +-
.../src/components/SourceRuleEditor.svelte | 11 +-
frontend/src/lib/i18n/de.json | 2 +
frontend/src/lib/i18n/en.json | 2 +
frontend/src/lib/i18n/es.json | 2 +
frontend/src/lib/i18n/fr.json | 2 +
frontend/src/lib/i18n/it.json | 2 +
frontend/src/lib/i18n/ja.json | 2 +
frontend/src/lib/i18n/pt.json | 2 +
frontend/src/lib/i18n/zh.json | 2 +
frontend/src/lib/search.ts | 215 +++++++-
frontend/src/lib/sources.ts | 23 +-
frontend/tests/search.test.ts | 17 +-
internal/models/sources.go | 15 +-
internal/notify/route.go | 43 +-
internal/search/address.go | 242 +++++++++
internal/search/address_test.go | 177 +++++++
internal/search/filter.go | 13 +-
internal/search/filter_test.go | 22 +-
internal/search/testdata/addresses.json | 466 ++++++++++++++++++
internal/search/testdata/filters.json | 258 +++++++++-
internal/sources/sources.go | 13 +-
internal/sources/sources_test.go | 30 ++
internal/storage/query.go | 56 ++-
26 files changed, 1570 insertions(+), 74 deletions(-)
create mode 100644 internal/search/address.go
create mode 100644 internal/search/address_test.go
create mode 100644 internal/search/testdata/addresses.json
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 8e71d0b..05cea8d 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -159,7 +159,9 @@ the address, that rule opens instead. Rules are listed, ordered, turned off and
A rule has three parts.
**Which messages**
-- **Addresses**: an IP, a CIDR (`10.0.7.0/24`) or a pattern, comma-separated.
+- **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`.
- **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
@@ -232,13 +234,24 @@ Select one or more facilities to filter by the source subsystem. Common faciliti
### Hostname, App Name and Source IP Filters
-Enter part of a hostname, an application name or an IP address. Messages whose field contains the
-entered text are displayed:
+Enter part of a hostname or an application name. Messages whose field contains the entered text are
+displayed:
- Case does not matter, and neither does an accent on a Latin letter: `cafe` finds `CAFÉ-01`.
- Everything else counts as typed, spaces included. `web_01` finds `web_01` and not `web-01`, and
`50%` is a percent sign, not a wildcard.
+The **Source IP** box takes an address the way a network is described:
+
+| Typed | Shows the messages from |
+|---|---|
+| `10.10.1.1` | that address only — not `10.10.1.10` or `10.10.1.100` |
+| `10.10.1.0/24` | the CIDR range, here `10.10.1.0` to `10.10.1.255` |
+| `10.10.1.*` | the addresses matching the pattern: `*` stands for any characters, `?` for one |
+| `10.10.1.` | the addresses containing the text, while it is being typed |
+
+An IPv6 address matches however it is written: `fe80:0::1` finds `fe80::1`.
+
### Search
The search box looks in each message's text and in its raw line. The button beside it switches
diff --git a/frontend/screenshots/bridge/App.js b/frontend/screenshots/bridge/App.js
index 8fc9949..b0e8776 100644
--- a/frontend/screenshots/bridge/App.js
+++ b/frontend/screenshots/bridge/App.js
@@ -24,7 +24,7 @@
import './runtime.js';
import { isDemo, override, delayed } from './scene.js';
-import { words, filterMatcher, sortMessages, groupMessages } from '../../src/lib/search';
+import { words, filterMatcher, sortMessages, groupMessages, addressMatcher } from '../../src/lib/search';
import {
MESSAGES, STATS, SERVER_CONFIG, SERVER_STATUS,
ALERT_RULES, ALERT_HISTORY,
@@ -103,8 +103,8 @@ let sourceRules = clone(SOURCE_RULES);
* applies. Reading a format is the engine's; the demo does not pretend to.
*/
function trySource(rule, samples) {
- const named = (rule.match.sources || []).map((s) => s.trim()).filter(Boolean);
- const matches = (ip) => named.some((s) => s === ip || (s.endsWith('.0/24') && ip.startsWith(s.slice(0, -4))));
+ const named = (rule.match.sources || []).filter((s) => s.trim()).map((s) => addressMatcher(s, false));
+ const matches = (ip) => named.some((take) => take(ip));
return samples.map((s) => {
const before = messages.find((m) => m.rawMessage === s.raw)
|| { ...messages[0], rawMessage: s.raw, message: s.raw, sourceIP: s.sourceIP };
diff --git a/frontend/src/components/FilterBar.svelte b/frontend/src/components/FilterBar.svelte
index f2f8ea2..9371dc6 100644
--- a/frontend/src/components/FilterBar.svelte
+++ b/frontend/src/components/FilterBar.svelte
@@ -210,7 +210,7 @@
+ title={$_('filter.sourceIPHint')} on:input={setSourceIP} class="filter-input" />
diff --git a/frontend/src/components/SourceRuleEditor.svelte b/frontend/src/components/SourceRuleEditor.svelte
index 00836d1..e4b2518 100644
--- a/frontend/src/components/SourceRuleEditor.svelte
+++ b/frontend/src/components/SourceRuleEditor.svelte
@@ -7,13 +7,15 @@
* preview is here to prevent.
*/
import { onDestroy, onMount } from 'svelte';
+ import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
+ import { messages } from '../lib/stores';
import { SEVERITY_COLORS } from '../lib/constants';
import { activeZone, formatInZone } from '../lib/timezone';
import { toastError, toastSuccess } from '../lib/toast';
import FormatFields from './FormatFields.svelte';
import {
- deleteSourceRule, listText, parseList, ruleDraft, rulesVersion, saveSourceRule, trySourceRule,
+ deleteSourceRule, listText, parseList, ruleDraft, rulesVersion, samplesForRule, saveSourceRule, trySourceRule,
} from '../lib/sources';
import type { SourceRule, SourceSample, SourceTrial } from '../lib/sources';
@@ -28,6 +30,9 @@
rule.levelFrom = rule.levelFrom || '';
rule.encoding = rule.encoding || '';
let sourcesText = listText(rule.match.sources);
+ // The samples it opened with, until the addresses change: then the
+ // latest messages from the addresses as now written.
+ const openedWith = sourcesText;
let hostnamesText = listText(rule.match.hostnames);
let appNamesText = listText(rule.match.appNames);
let hostMode: 'sent' | 'source' | 'fixed' =
@@ -62,6 +67,7 @@
clearTimeout(debounce);
debounce = setTimeout(async () => {
const mine = ++generation;
+ if (sourcesText !== openedWith) samples = samplesForRule(get(messages), current());
try {
const got = samples.length ? await trySourceRule(current(), samples) : [];
if (mine !== generation) return;
@@ -156,8 +162,9 @@
{$_('sources.which')}
{$_('sources.sources')}
-
+
+ {$_('sources.sourcesHint')}
{$_('sources.hostnames')}
diff --git a/frontend/src/lib/i18n/de.json b/frontend/src/lib/i18n/de.json
index 220c996..c29b03e 100644
--- a/frontend/src/lib/i18n/de.json
+++ b/frontend/src/lib/i18n/de.json
@@ -44,6 +44,7 @@
"severity": "Schweregrad",
"severityCount": "Schweregrad ({count})",
"sourceIP": "Quell-IP",
+ "sourceIPHint": "Eine Adresse (10.0.0.5), ein CIDR-Bereich (10.0.0.0/24), ein Muster (10.0.0.*) oder ein Teil einer Adresse",
"hostname": "Hostname",
"appName": "Anwendungsname",
"searchMessages": "Nachrichten durchsuchen...",
@@ -724,6 +725,7 @@
"name": "Name",
"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.*).",
"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 7cac1ab..e85bd7f 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -44,6 +44,7 @@
"severity": "Severity",
"severityCount": "Severity ({count})",
"sourceIP": "Source IP",
+ "sourceIPHint": "An address (10.0.0.5), a CIDR range (10.0.0.0/24), a pattern (10.0.0.*), or part of an address",
"hostname": "Hostname",
"appName": "App Name",
"searchMessages": "Search messages...",
@@ -724,6 +725,7 @@
"name": "Name",
"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.*).",
"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 f81d190..d07c1c5 100644
--- a/frontend/src/lib/i18n/es.json
+++ b/frontend/src/lib/i18n/es.json
@@ -44,6 +44,7 @@
"severity": "Severidad",
"severityCount": "Severidad ({count})",
"sourceIP": "IP de origen",
+ "sourceIPHint": "Una dirección (10.0.0.5), un rango CIDR (10.0.0.0/24), un patrón (10.0.0.*) o parte de una dirección",
"hostname": "Hostname",
"appName": "Nombre de aplicación",
"searchMessages": "Buscar mensajes...",
@@ -724,6 +725,7 @@
"name": "Nombre",
"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.*).",
"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 43f30ff..aa0908f 100644
--- a/frontend/src/lib/i18n/fr.json
+++ b/frontend/src/lib/i18n/fr.json
@@ -44,6 +44,7 @@
"severity": "Sévérité",
"severityCount": "Sévérité ({count})",
"sourceIP": "IP source",
+ "sourceIPHint": "Une adresse (10.0.0.5), une plage CIDR (10.0.0.0/24), un motif (10.0.0.*) ou une partie d'adresse",
"hostname": "Nom d'hôte",
"appName": "Nom de l'application",
"searchMessages": "Rechercher dans les messages...",
@@ -724,6 +725,7 @@
"name": "Nom",
"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.*).",
"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 0241e39..7766a46 100644
--- a/frontend/src/lib/i18n/it.json
+++ b/frontend/src/lib/i18n/it.json
@@ -44,6 +44,7 @@
"severity": "Gravità",
"severityCount": "Gravità ({count})",
"sourceIP": "IP di origine",
+ "sourceIPHint": "Un indirizzo (10.0.0.5), un intervallo CIDR (10.0.0.0/24), un modello (10.0.0.*) o parte di un indirizzo",
"hostname": "Hostname",
"appName": "Nome applicazione",
"searchMessages": "Cerca messaggi...",
@@ -724,6 +725,7 @@
"name": "Nome",
"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.*).",
"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 ac331f0..881a4f2 100644
--- a/frontend/src/lib/i18n/ja.json
+++ b/frontend/src/lib/i18n/ja.json
@@ -44,6 +44,7 @@
"severity": "重大度",
"severityCount": "重大度 ({count})",
"sourceIP": "送信元 IP",
+ "sourceIPHint": "アドレス (10.0.0.5)、CIDR 範囲 (10.0.0.0/24)、パターン (10.0.0.*)、またはアドレスの一部",
"hostname": "ホスト名",
"appName": "アプリケーション名",
"searchMessages": "メッセージを検索...",
@@ -724,6 +725,7 @@
"name": "名前",
"which": "対象のメッセージ",
"sources": "アドレス",
+ "sourcesHint": "各アドレスは全体で照合されます: 1 つのアドレス (10.0.0.9)、CIDR 範囲 (10.0.1.0/24)、または * が任意の文字列、? が 1 文字を表すパターン (10.0.2.*)。",
"hostnames": "ホスト名",
"appNames": "アプリケーション",
"pattern": "生テキストに含む",
diff --git a/frontend/src/lib/i18n/pt.json b/frontend/src/lib/i18n/pt.json
index 276bda2..d258997 100644
--- a/frontend/src/lib/i18n/pt.json
+++ b/frontend/src/lib/i18n/pt.json
@@ -44,6 +44,7 @@
"severity": "Severidade",
"severityCount": "Severidade ({count})",
"sourceIP": "IP de origem",
+ "sourceIPHint": "Um endereço (10.0.0.5), um intervalo CIDR (10.0.0.0/24), um padrão (10.0.0.*) ou parte de um endereço",
"hostname": "Hostname",
"appName": "Nome da aplicação",
"searchMessages": "Pesquisar mensagens...",
@@ -724,6 +725,7 @@
"name": "Nome",
"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.*).",
"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 e6cbcaf..e4daf4a 100644
--- a/frontend/src/lib/i18n/zh.json
+++ b/frontend/src/lib/i18n/zh.json
@@ -44,6 +44,7 @@
"severity": "严重程度",
"severityCount": "严重程度 ({count})",
"sourceIP": "源 IP",
+ "sourceIPHint": "一个地址 (10.0.0.5)、CIDR 范围 (10.0.0.0/24)、模式 (10.0.0.*) 或地址的一部分",
"hostname": "主机名",
"appName": "应用名称",
"searchMessages": "搜索消息...",
@@ -724,6 +725,7 @@
"name": "名称",
"which": "哪些消息",
"sources": "地址",
+ "sourcesHint": "每个地址都按整体匹配:单个地址 (10.0.0.9)、CIDR 范围 (10.0.1.0/24),或用 * 代表任意字符、? 代表一个字符的模式 (10.0.2.*)。",
"hostnames": "主机名",
"appNames": "应用",
"pattern": "原始文本包含",
diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts
index 7b1d51e..753d255 100644
--- a/frontend/src/lib/search.ts
+++ b/frontend/src/lib/search.ts
@@ -325,6 +325,218 @@ function containsMatcher(needle: string): (text: string) => boolean {
return text => re.test(text) || (NON_ASCII.test(text) && foldText(text).includes(needle));
}
+// --- Addresses: search.Address in address.go. ---
+
+// An IPv4 address's four fields, as Go's netip reads them: decimal, no
+// leading zero, none above 255.
+function parseV4(s: string): number[] | null {
+ const out: number[] = [];
+ let val = 0;
+ let digits = 0;
+ for (let i = 0; i < s.length; i++) {
+ const c = s.charCodeAt(i);
+ if (c >= 48 && c <= 57) {
+ if (digits === 1 && val === 0) return null;
+ val = val * 10 + (c - 48);
+ digits++;
+ if (val > 255) return null;
+ } else if (c === 46) {
+ if (i === 0 || i === s.length - 1 || s.charCodeAt(i - 1) === 46 || out.length === 3) return null;
+ out.push(val);
+ val = 0;
+ digits = 0;
+ } else {
+ return null;
+ }
+ }
+ if (out.length < 3) return null;
+ out.push(val);
+ return out;
+}
+
+// An IPv6 address as Go's netip reads it, step for step: groups of one to
+// four hex digits, one "::" standing for at least one group of zeros, an
+// IPv4 address in the last two groups. No zone.
+function parseV6(input: string): Uint8Array | null {
+ if (input.includes('%')) return null;
+ let s = input;
+ const ip = new Uint8Array(16);
+ let ellipsis = -1;
+ if (s.length >= 2 && s[0] === ':' && s[1] === ':') {
+ ellipsis = 0;
+ s = s.slice(2);
+ if (s.length === 0) return ip;
+ }
+ let i = 0;
+ while (i < 16) {
+ let off = 0;
+ let acc = 0;
+ for (; off < s.length; off++) {
+ const c = s.charCodeAt(off);
+ let d: number;
+ if (c >= 48 && c <= 57) d = c - 48;
+ else if (c >= 97 && c <= 102) d = c - 87;
+ else if (c >= 65 && c <= 70) d = c - 55;
+ else break;
+ acc = acc * 16 + d;
+ if (off > 3 || acc > 0xffff) return null;
+ }
+ if (off === 0) return null;
+ if (off < s.length && s[off] === '.') {
+ if ((ellipsis < 0 && i !== 12) || i + 4 > 16) return null;
+ const v4 = parseV4(s);
+ if (!v4) return null;
+ ip.set(v4, i);
+ s = '';
+ i += 4;
+ break;
+ }
+ ip[i] = acc >> 8;
+ ip[i + 1] = acc & 0xff;
+ i += 2;
+ s = s.slice(off);
+ if (s.length === 0) break;
+ if (s[0] !== ':' || s.length === 1) return null;
+ s = s.slice(1);
+ if (s[0] === ':') {
+ if (ellipsis >= 0) return null;
+ ellipsis = i;
+ s = s.slice(1);
+ if (s.length === 0) break;
+ }
+ }
+ if (s.length !== 0) return null;
+ if (i < 16) {
+ if (ellipsis < 0) return null;
+ const n = 16 - i;
+ for (let j = i - 1; j >= ellipsis; j--) ip[j + n] = ip[j];
+ ip.fill(0, ellipsis, ellipsis + n);
+ } else if (ellipsis >= 0) {
+ return null;
+ }
+ return ip;
+}
+
+/**
+ * An IP address as Go's net.ParseIP reads it, in the 16-byte form where an
+ * IPv4 address is ::ffff:a.b.c.d; null when the text is not one.
+ */
+export function parseIP(s: string): Uint8Array | null {
+ for (let i = 0; i < s.length; i++) {
+ if (s[i] === '.') {
+ const v4 = parseV4(s);
+ if (!v4) return null;
+ const ip = new Uint8Array(16);
+ ip[10] = ip[11] = 0xff;
+ ip.set(v4, 12);
+ return ip;
+ }
+ if (s[i] === ':') return parseV6(s);
+ if (s[i] === '%') return null;
+ }
+ return null;
+}
+
+function isV4(ip: Uint8Array): boolean {
+ for (let i = 0; i < 10; i++) if (ip[i]) return false;
+ return ip[10] === 0xff && ip[11] === 0xff;
+}
+
+/**
+ * An address written as the receiver writes it (Go's net.IP.String): IPv4
+ * dotted, IPv6 in lowercase with its longest run of two zero groups or more,
+ * the first of equals, written "::".
+ */
+export function ipString(ip: Uint8Array): string {
+ if (isV4(ip)) return `${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}`;
+ const group = (k: number) => (ip[2 * k] << 8) | ip[2 * k + 1];
+ let zeroStart = 255;
+ let zeroEnd = 255;
+ for (let i = 0; i < 8; i++) {
+ let j = i;
+ while (j < 8 && group(j) === 0) j++;
+ if (j - i >= 2 && j - i > zeroEnd - zeroStart) {
+ zeroStart = i;
+ zeroEnd = j;
+ }
+ }
+ let out = '';
+ for (let i = 0; i < 8; i++) {
+ if (i === zeroStart) {
+ out += '::';
+ i = zeroEnd;
+ if (i >= 8) break;
+ } else if (i > 0) {
+ out += ':';
+ }
+ out += group(i).toString(16);
+ }
+ return out;
+}
+
+// address/length, as parseCIDR in address.go reads it; bits counted in the
+// 16-byte form, so an IPv4 length plus 96.
+function parseCIDR(s: string): { base: Uint8Array; bits: number } | null {
+ const slash = s.indexOf('/');
+ if (slash < 0) return null;
+ const addr = s.slice(0, slash);
+ const length = s.slice(slash + 1);
+ if (!/^[0-9]{1,3}$/.test(length)) return null;
+ const ip = parseIP(addr);
+ if (!ip) return null;
+ const ones = Number(length);
+ const v4 = !addr.includes(':');
+ if ((v4 && ones > 32) || ones > 128) return null;
+ return { base: ip, bits: ones === 0 ? 0 : v4 ? ones + 96 : ones };
+}
+
+function sameBits(a: Uint8Array, b: Uint8Array, n: number): boolean {
+ for (let i = 0; i < 16; i++) {
+ const keep = n >= 8 * (i + 1) ? 0xff : n <= 8 * i ? 0 : (0xff << (8 - (n - 8 * i))) & 0xff;
+ if ((a[i] & keep) !== (b[i] & keep)) return false;
+ }
+ return true;
+}
+
+// A glob, whole: "*" any run of characters, "?" one.
+function globMatcher(pattern: string): RegExp {
+ let src = '';
+ for (const ch of pattern) {
+ src += ch === '*' ? '[^]*' : ch === '?' ? '[^]' : ch.replace(REGEX_SPECIALS, '\\$&');
+ }
+ return new RegExp('^' + src + '$', 'u');
+}
+
+/**
+ * What an address is matched against, as typed: one address exactly, a CIDR
+ * (10.10.1.0/24), a pattern with "*" or "?" (10.10.1.*), or anything else —
+ * text the address contains for the Source IP box (partial), the address
+ * itself for a rule's list.
+ */
+export function addressMatcher(typed: string, partial: boolean): (value: string) => boolean {
+ const t = typed.trim();
+ if (!t) return () => true;
+ const net = parseCIDR(t);
+ if (net) {
+ if (net.bits === 0) return () => true;
+ return value => {
+ const ip = parseIP(value);
+ return !!ip && ipString(ip) === foldText(value) && sameBits(ip, net.base, net.bits);
+ };
+ }
+ const ip = parseIP(t);
+ if (ip) {
+ const want = ipString(ip);
+ return value => foldText(value) === want;
+ }
+ if (/[*?]/.test(t)) {
+ const re = globMatcher(foldText(t));
+ return value => re.test(foldText(value));
+ }
+ const needle = foldText(t);
+ return partial ? containsMatcher(needle) : value => foldText(value) === needle;
+}
+
// A bound of the time range: its instant and how long it stands for. The
// window sends instants; a bare date or time — a filter saved before it did —
// is the machine's wall clock, for that whole day, minute or second.
@@ -367,10 +579,11 @@ export function filterMatcher(c: Criteria, plan: SearchPlan | null): (m: Filtera
const sev = c.severities?.length ? new Set(c.severities) : null;
const fac = c.facilities?.length ? new Set(c.facilities) : null;
const fields: [keyof Filterable, (text: string) => boolean][] = [];
- for (const [key, typed] of [['hostname', c.hostname], ['appName', c.appName], ['sourceIP', c.sourceIP]] as const) {
+ for (const [key, typed] of [['hostname', c.hostname], ['appName', c.appName]] as const) {
const needle = typed ? foldText(typed) : '';
if (needle) fields.push([key, containsMatcher(needle)]);
}
+ if (c.sourceIP?.trim()) fields.push(['sourceIP', addressMatcher(c.sourceIP, true)]);
const [from, to] = timeRange(c);
const group = c.groupField || '';
const search = c.search ? planMatcher(plan) : null;
diff --git a/frontend/src/lib/sources.ts b/frontend/src/lib/sources.ts
index 7e6ed6d..57003fa 100644
--- a/frontend/src/lib/sources.ts
+++ b/frontend/src/lib/sources.ts
@@ -6,10 +6,11 @@
*/
import { writable } from 'svelte/store';
import type { ImportFormat } from './api';
+import { addressMatcher } from './search';
import type { SyslogMessage } from './stores';
export interface SourceMatch {
- /** Addresses messages arrive from: an IP, a CIDR, or a glob. */
+ /** Addresses messages arrive from: an IP, a CIDR, or a pattern with "*". */
sources?: string[];
/** Globs, matched against the message as read before the rule. */
hostnames?: string[];
@@ -82,9 +83,19 @@ export function ruleFromMessage(msg: SyslogMessage): SourceRule {
return rule;
}
-/** The rule that already reads a device, if one names its address. */
+/** Whether one of a rule's addresses takes ip, as the engine reads them. */
+export function namesAddress(rule: SourceRule, ip: string): boolean {
+ return (rule.match.sources || []).some(s => s.trim() !== '' && addressMatcher(s, false)(ip));
+}
+
+/**
+ * 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.match.sources || []).some(s => s.trim() === ip));
+ return rules.find(r => r.enabled && namesAddress(r, ip)) ?? rules.find(r => namesAddress(r, ip));
}
/**
@@ -113,14 +124,14 @@ export function samplesOf(messages: SyslogMessage[], first?: SyslogMessage, limi
* which of them it matches.
*/
export function samplesForRule(messages: SyslogMessage[], rule: SourceRule, limit = 60): SourceSample[] {
- const ips = new Set((rule.match.sources || []).map(s => s.trim()).filter(s => /^[\d.:a-fA-F]+$/.test(s)));
+ const named = (rule.match.sources || []).filter(s => s.trim() !== '').map(s => addressMatcher(s, false));
const out: SourceSample[] = [];
for (let i = messages.length - 1; i >= 0 && out.length < limit; i--) {
const m = messages[i];
- if (m.protocol === 'file' || (ips.size && !ips.has(m.sourceIP))) continue;
+ if (m.protocol === 'file' || (named.length && !named.some(take => take(m.sourceIP)))) continue;
out.push({ raw: m.rawMessage, sourceIP: m.sourceIP, protocol: m.protocol });
}
- return out.length || !ips.size ? out : samplesOf(messages);
+ return out.length || !named.length ? out : samplesOf(messages);
}
/** A list as typed in one box, comma-separated. */
diff --git a/frontend/tests/search.test.ts b/frontend/tests/search.test.ts
index b544b93..e4f5c5d 100644
--- a/frontend/tests/search.test.ts
+++ b/frontend/tests/search.test.ts
@@ -8,7 +8,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
- words, planMatcher, filterMatcher, sortMessages, groupMessages,
+ words, planMatcher, filterMatcher, sortMessages, groupMessages, parseIP, ipString, addressMatcher,
type SearchPlan, type Filterable, type Criteria,
} from '../src/lib/search.ts';
import { wallToInstant, instantToWall, minuteAfter } from '../src/lib/zonedTime.ts';
@@ -84,3 +84,18 @@ test('the date boxes name instants in the displayed zone', () => {
}
assert.equal(minuteAfter('2026-10-23T08:00:00Z'), '2026-10-23T08:01:00Z');
});
+
+test('addresses are read and matched as Go reads and matches them', () => {
+ const golden: {
+ ips: { text: string; want: string | null }[];
+ matches: { typed: string; partial: boolean; value: string; match: boolean }[];
+ } = load('addresses.json');
+ for (const c of golden.ips) {
+ const ip = parseIP(c.text);
+ assert.equal(ip ? ipString(ip) : null, c.want, `parseIP(${JSON.stringify(c.text)})`);
+ }
+ for (const c of golden.matches) {
+ assert.equal(addressMatcher(c.typed, c.partial)(c.value), c.match,
+ `${JSON.stringify(c.typed)}${c.partial ? ' (box)' : ''} against ${JSON.stringify(c.value)}`);
+ }
+});
diff --git a/internal/models/sources.go b/internal/models/sources.go
index 35817a0..d92e128 100644
--- a/internal/models/sources.go
+++ b/internal/models/sources.go
@@ -2,7 +2,6 @@ package models
import (
"fmt"
- "net"
"path"
"strings"
)
@@ -48,7 +47,8 @@ type SourceRule struct {
// at least one has to be.
type SourceMatch struct {
// Sources are the addresses messages arrive from: an IP, a CIDR, or a
- // glob, as routing rules take them.
+ // 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"`
// Hostnames and AppNames are globs ("fw-*"), matched against the
// message as read before the rule.
@@ -107,17 +107,6 @@ func ValidateSourceRule(r SourceRule) error {
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")
}
- for _, s := range nonEmpty(m.Sources) {
- if net.ParseIP(s) != nil {
- continue
- }
- if _, _, err := net.ParseCIDR(s); err == nil {
- continue
- }
- if _, err := path.Match(s, ""); err != nil {
- return fmt.Errorf("source %q is not an address, a CIDR or a pattern", s)
- }
- }
for _, g := range append(nonEmpty(m.Hostnames), nonEmpty(m.AppNames)...) {
if _, err := path.Match(g, ""); err != nil {
return fmt.Errorf("%q is not a valid pattern", g)
diff --git a/internal/notify/route.go b/internal/notify/route.go
index ca52668..2759fab 100644
--- a/internal/notify/route.go
+++ b/internal/notify/route.go
@@ -21,6 +21,7 @@ import (
"time"
"SyslogStudio/internal/models"
+ "SyslogStudio/internal/search"
)
// Route is one rule: what to match, and where matches go.
@@ -56,9 +57,8 @@ type RouteMatch struct {
Hostnames []string `json:"hostnames,omitempty"`
AppNames []string `json:"appNames,omitempty"`
- // Sources accept a CIDR or a glob. Anything that parses as a prefix is
- // treated as CIDR; the rest falls back to path.Match, whose syntax has no
- // backtracking and therefore cannot hang on a hostile pattern.
+ // Sources accept an address, a CIDR or a pattern with "*" and "?", read
+ // as the Source IP filter reads them (search.Address).
Sources []string `json:"sources,omitempty"`
// Pattern matches the message text. UseRegex switches from a
@@ -114,35 +114,13 @@ func matchesInt(list []int, value int) bool {
return false
}
-// MatchesSource accepts a CIDR or a glob against the sending address.
+// MatchesSource reports whether the sending address matches one of the
+// entries: an address, a CIDR or a pattern (search.Address). A zero-length
+// prefix is how an operator writes "everything", and takes both families:
+// 0.0.0.0/0 dropping every IPv6 sender would make the pattern that looks
+// most like "all" the one that halves the estate.
func MatchesSource(patterns []string, source string) bool {
- if len(patterns) == 0 {
- return true
- }
- ip := net.ParseIP(source)
- for _, p := range patterns {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- if _, cidr, err := net.ParseCIDR(p); err == nil {
- // A zero-length prefix is how an operator writes "everything".
- // Contains across families is false, so 0.0.0.0/0 would otherwise
- // silently drop every IPv6 sender — the pattern that looks most
- // like "all" being the one that halves the estate.
- if ones, _ := cidr.Mask.Size(); ones == 0 {
- return true
- }
- if ip != nil && cidr.Contains(ip) {
- return true
- }
- continue
- }
- if ok, err := path.Match(strings.ToLower(p), strings.ToLower(source)); err == nil && ok {
- return true
- }
- }
- return false
+ return search.MatchAddresses(search.CompileAddresses(patterns), source)
}
func parseHHMM(s string) (int, bool) {
@@ -313,9 +291,6 @@ func ValidateRoute(r Route) error {
if _, _, err := net.ParseCIDR(s); err == nil {
continue
}
- if _, err := path.Match(s, ""); err != nil {
- return errf("source pattern %q is not a valid CIDR or glob", s)
- }
}
if w := r.Match.Window; w != nil {
if _, ok := parseHHMM(w.Start); !ok {
diff --git a/internal/search/address.go b/internal/search/address.go
new file mode 100644
index 0000000..9c39ec7
--- /dev/null
+++ b/internal/search/address.go
@@ -0,0 +1,242 @@
+package search
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+)
+
+// AddressKind is how an address box or an address list entry is read.
+type AddressKind int
+
+const (
+ // AddressAny is an empty box: every address passes.
+ AddressAny AddressKind = iota
+ // AddressExact is one address, written whole: "10.10.1.1" is that
+ // device and not 10.10.1.10 to 10.10.1.199.
+ AddressExact
+ // AddressNet is a CIDR: "10.10.1.0/24".
+ AddressNet
+ // AddressGlob holds a "*" (any run of characters) or a "?" (one):
+ // "10.10.1.*".
+ AddressGlob
+ // AddressText is anything else. The Source IP filter box reads it as
+ // text the address contains — "10.10." while it is being typed; a list
+ // entry (source rules, routing) as the address itself.
+ AddressText
+)
+
+// Address is what an address is matched against, in the Source IP filter,
+// the source rules and the routing — one meaning for all three, and for the
+// history's SQL (storage/query.go) and the window (lib/search.ts), held to it
+// by testdata/filters.json.
+//
+// Addresses are compared as the receiver writes them (net.IP.String: dotted
+// IPv4, RFC 5952 IPv6, lowercase): an exact address is written that way
+// before it is compared, and a CIDR contains only the addresses written that
+// way, which is every address a message arrives from.
+//
+// A CIDR is a number of leading bits of the 16-byte form, where an IPv4
+// address is ::ffff:a.b.c.d — so 10.0.0.0/8 is the first 104 bits of
+// ::ffff:10.0.0.0. Plain arithmetic, which the window repeats exactly.
+type Address struct {
+ Kind AddressKind
+ // Text is the exact address as the receiver writes it, the glob, or the
+ // text contained, folded.
+ Text string
+ // Base and Bits are a CIDR's network in the 16-byte form, and how many of
+ // its leading bits an address has to share. Bits is 0 for a /0, which is
+ // everything, both families included.
+ Base [16]byte
+ Bits int
+ // Prefix is what every IPv4 address of the CIDR starts with as text: its
+ // whole octets and a dot ("10.10.1." for a /24). The history narrows on
+ // it through the index before checking each address.
+ Prefix string
+}
+
+// CompileAddress reads an address as typed. partial says what text that is
+// not an address means: contained, for the filter box; the address itself,
+// for a list entry.
+func CompileAddress(typed string, partial bool) Address {
+ t := strings.TrimSpace(typed)
+ if t == "" {
+ return Address{Kind: AddressAny}
+ }
+ if ip, ones, v4, ok := parseCIDR(t); ok {
+ a := Address{Kind: AddressNet}
+ if ones == 0 {
+ return a
+ }
+ a.Bits = ones
+ if v4 {
+ a.Bits += 96
+ }
+ copy(a.Base[:], ip.To16())
+ maskBits(&a.Base, a.Bits)
+ // An IPv4 network, however it was written, with a whole octet or
+ // more: three at most, since a /32's fourth is followed by no dot.
+ if base := net.IP(a.Base[:]); base.To4() != nil && a.Bits >= 104 {
+ octets := strings.Split(base.String(), ".")
+ a.Prefix = strings.Join(octets[:min((a.Bits-96)/8, 3)], ".") + "."
+ }
+ return a
+ }
+ if ip := net.ParseIP(t); ip != nil {
+ return Address{Kind: AddressExact, Text: ip.String()}
+ }
+ if strings.ContainsAny(t, "*?") {
+ return Address{Kind: AddressGlob, Text: Fold(t)}
+ }
+ if partial {
+ return Address{Kind: AddressText, Text: Fold(t)}
+ }
+ return Address{Kind: AddressExact, Text: Fold(t)}
+}
+
+// parseCIDR reads address/length: the address as ParseIP takes it, and a
+// length of one to three digits that fits it — an IPv4 address written
+// dotted has 32 bits (v4), any other 128.
+func parseCIDR(s string) (ip net.IP, ones int, v4, ok bool) {
+ addr, length, found := strings.Cut(s, "/")
+ if !found || len(length) == 0 || len(length) > 3 || strings.Trim(length, "0123456789") != "" {
+ return nil, 0, false, false
+ }
+ if ip = net.ParseIP(addr); ip == nil {
+ return nil, 0, false, false
+ }
+ ones, _ = strconv.Atoi(length)
+ v4 = !strings.Contains(addr, ":")
+ if (v4 && ones > 32) || ones > 128 {
+ return nil, 0, false, false
+ }
+ return ip, ones, v4, true
+}
+
+// maskBits zeroes every bit of b after the first n.
+func maskBits(b *[16]byte, n int) {
+ for i := range b {
+ switch {
+ case n >= 8*(i+1):
+ case n <= 8*i:
+ b[i] = 0
+ default:
+ b[i] &= byte(0xff << (8 - (n - 8*i)))
+ }
+ }
+}
+
+// Match reports whether an address passes.
+func (a Address) Match(value string) bool {
+ switch a.Kind {
+ case AddressAny:
+ return true
+ case AddressExact:
+ return Fold(value) == a.Text
+ case AddressNet:
+ if a.Bits == 0 {
+ return true
+ }
+ ip := net.ParseIP(value)
+ if ip == nil || ip.String() != Fold(value) {
+ return false
+ }
+ var b [16]byte
+ copy(b[:], ip.To16())
+ maskBits(&b, a.Bits)
+ return b == a.Base
+ case AddressGlob:
+ return Glob(a.Text, Fold(value))
+ case AddressText:
+ return strings.Contains(Fold(value), a.Text)
+ }
+ return false
+}
+
+// CIDR writes a CIDR back as one CompileAddress reads the same, in the
+// 16-byte form: an IPv4 network as ::ffff:a.b.c.d, since 0.0.0.0/0 would
+// read as everything and ::ffff:0:0/96 is every IPv4 address only.
+func (a Address) CIDR() string {
+ ip := net.IP(a.Base[:])
+ if ip.To4() != nil {
+ return "::ffff:" + ip.String() + "/" + strconv.Itoa(a.Bits)
+ }
+ return ip.String() + "/" + strconv.Itoa(a.Bits)
+}
+
+// Glob reports whether text matches pattern whole, "*" standing for any run
+// of characters and "?" for one. Linear in practice: on a mismatch it only
+// ever goes back to just after the last "*".
+func Glob(pattern, text string) bool {
+ p, t := 0, 0
+ star, mark := -1, 0
+ for t < len(text) {
+ if p < len(pattern) {
+ switch pattern[p] {
+ case '*':
+ star, mark = p, t
+ p++
+ continue
+ case '?':
+ _, n := utf8.DecodeRuneInString(text[t:])
+ p, t = p+1, t+n
+ continue
+ default:
+ pr, pn := utf8.DecodeRuneInString(pattern[p:])
+ tr, tn := utf8.DecodeRuneInString(text[t:])
+ if pr == tr {
+ p, t = p+pn, t+tn
+ continue
+ }
+ }
+ }
+ if star < 0 {
+ return false
+ }
+ _, n := utf8.DecodeRuneInString(text[mark:])
+ mark += n
+ p, t = star+1, mark
+ }
+ for p < len(pattern) && pattern[p] == '*' {
+ p++
+ }
+ return p == len(pattern)
+}
+
+// CheckAddressEntry says what is wrong with an entry of an address list (a
+// source rule's): anything that is not an address, a CIDR or a pattern can
+// match nothing a message arrives from.
+func CheckAddressEntry(entry string) error {
+ a := CompileAddress(entry, false)
+ if a.Kind != AddressExact || net.ParseIP(strings.TrimSpace(entry)) != nil {
+ return nil
+ }
+ return fmt.Errorf("%q is not an address: write one whole (10.0.7.12), a CIDR (10.0.7.0/24) or a pattern (10.0.7.*)", strings.TrimSpace(entry))
+}
+
+// MatchAddresses reports whether value matches one of the entries of an
+// address list; an empty list matches everything.
+func MatchAddresses(entries []Address, value string) bool {
+ if len(entries) == 0 {
+ return true
+ }
+ for _, a := range entries {
+ if a.Match(value) {
+ return true
+ }
+ }
+ return false
+}
+
+// CompileAddresses compiles an address list, leaving out blank entries.
+func CompileAddresses(list []string) []Address {
+ var out []Address
+ for _, s := range list {
+ if strings.TrimSpace(s) != "" {
+ out = append(out, CompileAddress(s, false))
+ }
+ }
+ return out
+}
diff --git a/internal/search/address_test.go b/internal/search/address_test.go
new file mode 100644
index 0000000..e09e678
--- /dev/null
+++ b/internal/search/address_test.go
@@ -0,0 +1,177 @@
+package search
+
+import (
+ "net"
+ "testing"
+)
+
+// addressTexts are read as addresses: testdata/addresses.json records how
+// Go reads and writes each, which the window's parseIP and ipString repeat.
+var addressTexts = []string{
+ "", "10.0.0.1", "0.0.0.0", "255.255.255.255", "256.0.0.1", "010.0.0.1", "10.0.0", "10.0.0.1.2",
+ "10..0.1", ".10.0.0.1", "10.0.0.1.", "1.2.3.4 ", " 1.2.3.4", "1.2.3.a", "1.2.3.4:514",
+ "::", "::1", "1::", "fe80::1", "FE80::0001", "fe80:0:0:0:0:0:0:1", "1:2:3:4:5:6:7:8",
+ "1:2:3:4:5:6:7::", "::2:3:4:5:6:7:8", "1:2:3:4:5:6:7:8::", "1:2:3:4:5:6:7:8:9", "1:2:3:4:5:6:7",
+ "1::2::3", ":1::2", "1:::2", "1:2:", "12345::", "fe80::1%eth0", "%eth0", "::ffff:10.0.0.5",
+ "::ffff:a00:5", "::10.0.0.5", "1:2:3:4:5:6:10.0.0.5", "1:2:3:4:5:10.0.0.5", "1:2:3:4:5:6:7:10.0.0.5",
+ "::01.2.3.4", "::1.2.3.4.5", "2001:db8:0:0:1:0:0:1", "2001:db8::1:0:0:1", "2001:0:0:1:0:0:0:1",
+ "0:0:1:0:0:1:0:0", "2001:db8:0:1:1:1:1:1", "1:0:0:0:0:0:0:0", "0:0:0:0:0:0:0:1", "ABCD:EF01::",
+ "g::1", "::ffff:255.255.255.255",
+}
+
+// addressCases are an address typed against an address received.
+var addressCases = []struct {
+ Typed string `json:"typed"`
+ Partial bool `json:"partial"`
+ Value string `json:"value"`
+}{
+ {"10.10.1.1", true, "10.10.1.1"},
+ {"10.10.1.1", true, "10.10.1.10"},
+ {"10.10.1.1", true, "10.10.1.199"},
+ {"10.10.1.1", false, "10.10.1.1"},
+ {" 10.10.1.1 ", false, "10.10.1.1"},
+ {"10.10.1.", true, "10.10.1.199"},
+ {"10.10.1.", false, "10.10.1.199"},
+ {"10.10.1.0/24", false, "10.10.1.199"},
+ {"10.10.1.0/24", false, "10.10.2.1"},
+ {"10.10.1.0/24", false, "::ffff:10.10.1.5"},
+ {"10.10.1.128/25", false, "10.10.1.127"},
+ {"10.10.1.128/25", false, "10.10.1.128"},
+ {"10.10.1.7/32", false, "10.10.1.7"},
+ {"10.10.1.7/32", false, "10.10.1.8"},
+ {"0.0.0.0/0", false, "fe80::1"},
+ {"::/0", false, "10.0.0.1"},
+ {"0.0.0.0/1", false, "fe80::1"},
+ {"::/1", false, "10.0.0.1"},
+ {"::ffff:0:0/96", false, "10.0.0.1"},
+ {"::ffff:0:0/96", false, "fe80::1"},
+ {"::/80", false, "10.0.0.1"},
+ {"::/80", false, "::1"},
+ {"fe80::/10", false, "FE80::1"},
+ {"fe80::/10", false, "fec0::1"},
+ {"2001:db8::/32", false, "2001:db8:0:0:0:0:0:1"},
+ {"2001:db8::/32", false, "2001:db8::1"},
+ {"10.0.0.0/8", false, "not an address"},
+ {"10.0.0.0/033", false, "10.0.0.1"},
+ {"10.0.0.0/0x8", true, "10.0.0.0/0x8"},
+ {"10.10.1.*", false, "10.10.1.1"},
+ {"10.10.1.*", false, "10.10.10.1"},
+ {"10.10.*.1", false, "10.10.200.1"},
+ {"10.10.1.?", false, "10.10.1.12"},
+ {"10.10.1.??", false, "10.10.1.12"},
+ {"*", false, ""},
+ {"FE80::*", false, "fe80::1"},
+ {"fe80:0::1", false, "fe80::1"},
+ {"gateway", false, "GATEWAY"},
+ {"gateway", false, "gateway-2"},
+ {"gate", true, "gateway-2"},
+ {"10.0.0.1%", false, "10.0.0.1%"},
+ {"", false, "10.0.0.1"},
+}
+
+type goldenIP struct {
+ Text string `json:"text"`
+ Want *string `json:"want"`
+}
+
+type goldenAddress struct {
+ Typed string `json:"typed"`
+ Partial bool `json:"partial"`
+ Value string `json:"value"`
+ Match bool `json:"match"`
+}
+
+func TestAddresses(t *testing.T) {
+ var golden struct {
+ IPs []goldenIP `json:"ips"`
+ Matches []goldenAddress `json:"matches"`
+ }
+ for _, s := range addressTexts {
+ g := goldenIP{Text: s}
+ if ip := net.ParseIP(s); ip != nil {
+ w := ip.String()
+ g.Want = &w
+ }
+ golden.IPs = append(golden.IPs, g)
+ }
+ for _, c := range addressCases {
+ golden.Matches = append(golden.Matches, goldenAddress{c.Typed, c.Partial, c.Value, CompileAddress(c.Typed, c.Partial).Match(c.Value)})
+ }
+ checkGolden(t, "addresses.json", golden)
+
+ // The cases #98 is about, said plainly.
+ for _, c := range []struct {
+ typed, value string
+ want bool
+ }{
+ {"10.10.1.1", "10.10.1.1", true},
+ {"10.10.1.1", "10.10.1.10", false},
+ {"10.10.1.1", "10.10.1.100", false},
+ {"10.10.1.0/24", "10.10.1.100", true},
+ {"10.10.1.*", "10.10.1.100", true},
+ {"10.10.1.*", "10.10.10.1", false},
+ } {
+ if got := CompileAddress(c.typed, true).Match(c.value); got != c.want {
+ t.Errorf("%q against %q: %v, want %v", c.typed, c.value, got, c.want)
+ }
+ }
+}
+
+// A CIDR written back reads the same, which is how ss_addr gets it.
+func TestAddress_CIDRRoundTrip(t *testing.T) {
+ for _, s := range []string{"10.10.1.0/24", "10.10.1.9/31", "0.0.0.0/1", "::ffff:10.1.0.0/112", "fe80::/10", "::/80", "::ffff:0:0/96", "2001:db8::/127"} {
+ a := CompileAddress(s, false)
+ if b := CompileAddress(a.CIDR(), false); b != a {
+ t.Errorf("%s: written back as %s, read as %+v, want %+v", s, a.CIDR(), b, a)
+ }
+ }
+}
+
+func TestAddress_Prefix(t *testing.T) {
+ for typed, want := range map[string]string{
+ "10.10.1.0/24": "10.10.1.", "10.10.0.0/20": "10.10.", "10.0.0.0/8": "10.", "10.0.0.0/7": "",
+ "10.10.1.7/32": "10.10.1.", "::ffff:10.1.0.0/112": "10.1.", "fe80::/10": "",
+ } {
+ if got := CompileAddress(typed, false).Prefix; got != want {
+ t.Errorf("%s: prefix %q, want %q", typed, got, want)
+ }
+ }
+}
+
+func TestGlob(t *testing.T) {
+ for _, c := range []struct {
+ pattern, text string
+ want bool
+ }{
+ {"*", "", true},
+ {"", "", true},
+ {"", "a", false},
+ {"a*b", "ab", true},
+ {"a*b", "axxb", true},
+ {"a*b", "axxbx", false},
+ {"*b*b", "abxb", true},
+ {"?", "é", true},
+ {"??", "é", false},
+ {"a?c", "abc", true},
+ {"a?c", "ac", false},
+ {"**", "x", true},
+ {"*a*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
+ } {
+ if got := Glob(c.pattern, c.text); got != c.want {
+ t.Errorf("Glob(%q, %q) = %v, want %v", c.pattern, c.text, got, c.want)
+ }
+ }
+}
+
+func TestCheckAddressEntry(t *testing.T) {
+ for _, ok := range []string{"10.0.0.1", "fe80::1", "10.0.0.0/24", "10.0.0.*", "*", "fe80::/10"} {
+ if err := CheckAddressEntry(ok); err != nil {
+ t.Errorf("%q refused: %v", ok, err)
+ }
+ }
+ for _, bad := range []string{"10.0.0", "gateway", "10.0.0.0/33", "fe80::1%eth0"} {
+ if CheckAddressEntry(bad) == nil {
+ t.Errorf("%q accepted", bad)
+ }
+ }
+}
diff --git a/internal/search/filter.go b/internal/search/filter.go
index 42c7b5e..1a54358 100644
--- a/internal/search/filter.go
+++ b/internal/search/filter.go
@@ -19,9 +19,12 @@ type Filter struct {
Severities map[int]bool
Facilities map[int]bool
- // Hostname, AppName and SourceIP are the text boxes: a field passes when
- // it contains what was typed, case and Latin accents aside.
- Hostname, AppName, SourceIP Field
+ // Hostname and AppName are text boxes: a field passes when it contains
+ // what was typed, case and Latin accents aside.
+ Hostname, AppName Field
+ // SourceIP is the address box: one address exactly, a CIDR, a pattern
+ // with "*", or text the address contains (Address).
+ SourceIP Address
// From and To bound the time range: From included, To excluded. Zero
// means unbounded.
@@ -46,7 +49,7 @@ func CompileFilter(c models.FilterCriteria) Filter {
Plan: Compile(c.Search, c.SearchMode),
Hostname: NewField(c.Hostname),
AppName: NewField(c.AppName),
- SourceIP: NewField(c.SourceIP),
+ SourceIP: CompileAddress(c.SourceIP, true),
GroupField: c.GroupField,
GroupKey: c.GroupKey,
}
@@ -134,7 +137,7 @@ func GroupKey(msg models.SyslogMessage, field string) string {
return ""
}
-// Field is a field box: hostname, application, source IP.
+// Field is a text field box: hostname, application.
type Field struct {
// Needle is what was typed, folded.
Needle string
diff --git a/internal/search/filter_test.go b/internal/search/filter_test.go
index 79c5c51..8d58eb9 100644
--- a/internal/search/filter_test.go
+++ b/internal/search/filter_test.go
@@ -87,8 +87,28 @@ var filterCases = []filterCase{
{"greek lowercase", models.FilterCriteria{AppName: "συσ"}, []string{"k"}},
{"app nginx", models.FilterCriteria{AppName: "NGINX"}, []string{"a", "d", "e", "h"}},
{"app underscore", models.FilterCriteria{AppName: "app_"}, []string{"i"}},
- {"ip prefix", models.FilterCriteria{SourceIP: "10.0.0.1"}, []string{"b"}},
+ // The address box: a whole address is that address (#98), a CIDR its
+ // range, "*" and "?" a pattern, anything else text the address contains.
+ {"ip whole is exact", models.FilterCriteria{SourceIP: "10.0.0.1"}, []string{}},
+ {"ip exact", models.FilterCriteria{SourceIP: "10.0.0.5"}, []string{"a", "g", "h"}},
+ {"ip exact v6 written long", models.FilterCriteria{SourceIP: "FE80:0:0::0001"}, []string{"f"}},
+ {"ip being typed", models.FilterCriteria{SourceIP: "10.0.0."}, []string{"a", "b", "c", "g", "h"}},
{"ip case", models.FilterCriteria{SourceIP: "FE80"}, []string{"f"}},
+ {"ip cidr", models.FilterCriteria{SourceIP: "10.0.0.0/28"}, []string{"a", "b", "c", "g", "h"}},
+ {"ip cidr not on octets", models.FilterCriteria{SourceIP: "10.0.0.8/29"}, []string{"b", "c"}},
+ {"ip cidr host bits set", models.FilterCriteria{SourceIP: "10.0.0.9/31"}, []string{"c"}},
+ {"ip cidr one address", models.FilterCriteria{SourceIP: "10.0.0.5/32"}, []string{"a", "g", "h"}},
+ {"ip cidr wide", models.FilterCriteria{SourceIP: "10.0.0.0/8"}, []string{"a", "b", "c", "g", "h", "i", "j", "k"}},
+ {"ip cidr short of an octet", models.FilterCriteria{SourceIP: "192.0.0.0/4"}, []string{"d", "e"}},
+ {"ip cidr everything", models.FilterCriteria{SourceIP: "0.0.0.0/0"}, []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}},
+ {"ip cidr v6", models.FilterCriteria{SourceIP: "fe80::/10"}, []string{"f"}},
+ {"ip cidr v4 written as v6", models.FilterCriteria{SourceIP: "::ffff:10.1.0.0/112"}, []string{"i", "j", "k"}},
+ {"ip cidr v4 holds no v6", models.FilterCriteria{SourceIP: "0.0.0.0/1"}, []string{"a", "b", "c", "g", "h", "i", "j", "k"}},
+ {"ip not a cidr", models.FilterCriteria{SourceIP: "10.0.0.0/33"}, []string{}},
+ {"ip glob", models.FilterCriteria{SourceIP: "10.1.0.*"}, []string{"i", "j", "k"}},
+ {"ip glob one character", models.FilterCriteria{SourceIP: "192.168.1.?"}, []string{"e"}},
+ {"ip glob is whole", models.FilterCriteria{SourceIP: "10.0.0.1*"}, []string{"b"}},
+ {"ip glob case", models.FilterCriteria{SourceIP: "FE80::*"}, []string{"f"}},
{"host typed with a space", models.FilterCriteria{Hostname: "web "}, []string{}},
// Levels.
{"severity", models.FilterCriteria{Severities: []int{int(models.SevError)}}, []string{"a", "d"}},
diff --git a/internal/search/testdata/addresses.json b/internal/search/testdata/addresses.json
new file mode 100644
index 0000000..6c4d0ec
--- /dev/null
+++ b/internal/search/testdata/addresses.json
@@ -0,0 +1,466 @@
+{
+ "ips": [
+ {
+ "text": "",
+ "want": null
+ },
+ {
+ "text": "10.0.0.1",
+ "want": "10.0.0.1"
+ },
+ {
+ "text": "0.0.0.0",
+ "want": "0.0.0.0"
+ },
+ {
+ "text": "255.255.255.255",
+ "want": "255.255.255.255"
+ },
+ {
+ "text": "256.0.0.1",
+ "want": null
+ },
+ {
+ "text": "010.0.0.1",
+ "want": null
+ },
+ {
+ "text": "10.0.0",
+ "want": null
+ },
+ {
+ "text": "10.0.0.1.2",
+ "want": null
+ },
+ {
+ "text": "10..0.1",
+ "want": null
+ },
+ {
+ "text": ".10.0.0.1",
+ "want": null
+ },
+ {
+ "text": "10.0.0.1.",
+ "want": null
+ },
+ {
+ "text": "1.2.3.4 ",
+ "want": null
+ },
+ {
+ "text": " 1.2.3.4",
+ "want": null
+ },
+ {
+ "text": "1.2.3.a",
+ "want": null
+ },
+ {
+ "text": "1.2.3.4:514",
+ "want": null
+ },
+ {
+ "text": "::",
+ "want": "::"
+ },
+ {
+ "text": "::1",
+ "want": "::1"
+ },
+ {
+ "text": "1::",
+ "want": "1::"
+ },
+ {
+ "text": "fe80::1",
+ "want": "fe80::1"
+ },
+ {
+ "text": "FE80::0001",
+ "want": "fe80::1"
+ },
+ {
+ "text": "fe80:0:0:0:0:0:0:1",
+ "want": "fe80::1"
+ },
+ {
+ "text": "1:2:3:4:5:6:7:8",
+ "want": "1:2:3:4:5:6:7:8"
+ },
+ {
+ "text": "1:2:3:4:5:6:7::",
+ "want": "1:2:3:4:5:6:7:0"
+ },
+ {
+ "text": "::2:3:4:5:6:7:8",
+ "want": "0:2:3:4:5:6:7:8"
+ },
+ {
+ "text": "1:2:3:4:5:6:7:8::",
+ "want": null
+ },
+ {
+ "text": "1:2:3:4:5:6:7:8:9",
+ "want": null
+ },
+ {
+ "text": "1:2:3:4:5:6:7",
+ "want": null
+ },
+ {
+ "text": "1::2::3",
+ "want": null
+ },
+ {
+ "text": ":1::2",
+ "want": null
+ },
+ {
+ "text": "1:::2",
+ "want": null
+ },
+ {
+ "text": "1:2:",
+ "want": null
+ },
+ {
+ "text": "12345::",
+ "want": null
+ },
+ {
+ "text": "fe80::1%eth0",
+ "want": null
+ },
+ {
+ "text": "%eth0",
+ "want": null
+ },
+ {
+ "text": "::ffff:10.0.0.5",
+ "want": "10.0.0.5"
+ },
+ {
+ "text": "::ffff:a00:5",
+ "want": "10.0.0.5"
+ },
+ {
+ "text": "::10.0.0.5",
+ "want": "::a00:5"
+ },
+ {
+ "text": "1:2:3:4:5:6:10.0.0.5",
+ "want": "1:2:3:4:5:6:a00:5"
+ },
+ {
+ "text": "1:2:3:4:5:10.0.0.5",
+ "want": null
+ },
+ {
+ "text": "1:2:3:4:5:6:7:10.0.0.5",
+ "want": null
+ },
+ {
+ "text": "::01.2.3.4",
+ "want": null
+ },
+ {
+ "text": "::1.2.3.4.5",
+ "want": null
+ },
+ {
+ "text": "2001:db8:0:0:1:0:0:1",
+ "want": "2001:db8::1:0:0:1"
+ },
+ {
+ "text": "2001:db8::1:0:0:1",
+ "want": "2001:db8::1:0:0:1"
+ },
+ {
+ "text": "2001:0:0:1:0:0:0:1",
+ "want": "2001:0:0:1::1"
+ },
+ {
+ "text": "0:0:1:0:0:1:0:0",
+ "want": "::1:0:0:1:0:0"
+ },
+ {
+ "text": "2001:db8:0:1:1:1:1:1",
+ "want": "2001:db8:0:1:1:1:1:1"
+ },
+ {
+ "text": "1:0:0:0:0:0:0:0",
+ "want": "1::"
+ },
+ {
+ "text": "0:0:0:0:0:0:0:1",
+ "want": "::1"
+ },
+ {
+ "text": "ABCD:EF01::",
+ "want": "abcd:ef01::"
+ },
+ {
+ "text": "g::1",
+ "want": null
+ },
+ {
+ "text": "::ffff:255.255.255.255",
+ "want": "255.255.255.255"
+ }
+ ],
+ "matches": [
+ {
+ "typed": "10.10.1.1",
+ "partial": true,
+ "value": "10.10.1.1",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.1",
+ "partial": true,
+ "value": "10.10.1.10",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.1",
+ "partial": true,
+ "value": "10.10.1.199",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.1",
+ "partial": false,
+ "value": "10.10.1.1",
+ "match": true
+ },
+ {
+ "typed": " 10.10.1.1 ",
+ "partial": false,
+ "value": "10.10.1.1",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.",
+ "partial": true,
+ "value": "10.10.1.199",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.",
+ "partial": false,
+ "value": "10.10.1.199",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.0/24",
+ "partial": false,
+ "value": "10.10.1.199",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.0/24",
+ "partial": false,
+ "value": "10.10.2.1",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.0/24",
+ "partial": false,
+ "value": "::ffff:10.10.1.5",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.128/25",
+ "partial": false,
+ "value": "10.10.1.127",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.128/25",
+ "partial": false,
+ "value": "10.10.1.128",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.7/32",
+ "partial": false,
+ "value": "10.10.1.7",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.7/32",
+ "partial": false,
+ "value": "10.10.1.8",
+ "match": false
+ },
+ {
+ "typed": "0.0.0.0/0",
+ "partial": false,
+ "value": "fe80::1",
+ "match": true
+ },
+ {
+ "typed": "::/0",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": true
+ },
+ {
+ "typed": "0.0.0.0/1",
+ "partial": false,
+ "value": "fe80::1",
+ "match": false
+ },
+ {
+ "typed": "::/1",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": true
+ },
+ {
+ "typed": "::ffff:0:0/96",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": true
+ },
+ {
+ "typed": "::ffff:0:0/96",
+ "partial": false,
+ "value": "fe80::1",
+ "match": false
+ },
+ {
+ "typed": "::/80",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": true
+ },
+ {
+ "typed": "::/80",
+ "partial": false,
+ "value": "::1",
+ "match": true
+ },
+ {
+ "typed": "fe80::/10",
+ "partial": false,
+ "value": "FE80::1",
+ "match": true
+ },
+ {
+ "typed": "fe80::/10",
+ "partial": false,
+ "value": "fec0::1",
+ "match": false
+ },
+ {
+ "typed": "2001:db8::/32",
+ "partial": false,
+ "value": "2001:db8:0:0:0:0:0:1",
+ "match": false
+ },
+ {
+ "typed": "2001:db8::/32",
+ "partial": false,
+ "value": "2001:db8::1",
+ "match": true
+ },
+ {
+ "typed": "10.0.0.0/8",
+ "partial": false,
+ "value": "not an address",
+ "match": false
+ },
+ {
+ "typed": "10.0.0.0/033",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": false
+ },
+ {
+ "typed": "10.0.0.0/0x8",
+ "partial": true,
+ "value": "10.0.0.0/0x8",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.*",
+ "partial": false,
+ "value": "10.10.1.1",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.*",
+ "partial": false,
+ "value": "10.10.10.1",
+ "match": false
+ },
+ {
+ "typed": "10.10.*.1",
+ "partial": false,
+ "value": "10.10.200.1",
+ "match": true
+ },
+ {
+ "typed": "10.10.1.?",
+ "partial": false,
+ "value": "10.10.1.12",
+ "match": false
+ },
+ {
+ "typed": "10.10.1.??",
+ "partial": false,
+ "value": "10.10.1.12",
+ "match": true
+ },
+ {
+ "typed": "*",
+ "partial": false,
+ "value": "",
+ "match": true
+ },
+ {
+ "typed": "FE80::*",
+ "partial": false,
+ "value": "fe80::1",
+ "match": true
+ },
+ {
+ "typed": "fe80:0::1",
+ "partial": false,
+ "value": "fe80::1",
+ "match": true
+ },
+ {
+ "typed": "gateway",
+ "partial": false,
+ "value": "GATEWAY",
+ "match": true
+ },
+ {
+ "typed": "gateway",
+ "partial": false,
+ "value": "gateway-2",
+ "match": false
+ },
+ {
+ "typed": "gate",
+ "partial": true,
+ "value": "gateway-2",
+ "match": true
+ },
+ {
+ "typed": "10.0.0.1%",
+ "partial": false,
+ "value": "10.0.0.1%",
+ "match": true
+ },
+ {
+ "typed": "",
+ "partial": false,
+ "value": "10.0.0.1",
+ "match": true
+ }
+ ]
+}
diff --git a/internal/search/testdata/filters.json b/internal/search/testdata/filters.json
index 25045dc..55c7cc4 100644
--- a/internal/search/testdata/filters.json
+++ b/internal/search/testdata/filters.json
@@ -394,12 +394,52 @@
}
},
{
- "name": "ip prefix",
+ "name": "ip whole is exact",
"criteria": {
"sourceIP": "10.0.0.1"
},
+ "want": [],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip exact",
+ "criteria": {
+ "sourceIP": "10.0.0.5"
+ },
"want": [
- "b"
+ "a",
+ "g",
+ "h"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip exact v6 written long",
+ "criteria": {
+ "sourceIP": "FE80:0:0::0001"
+ },
+ "want": [
+ "f"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip being typed",
+ "criteria": {
+ "sourceIP": "10.0.0."
+ },
+ "want": [
+ "a",
+ "b",
+ "c",
+ "g",
+ "h"
],
"plan": {
"mode": "text"
@@ -417,6 +457,220 @@
"mode": "text"
}
},
+ {
+ "name": "ip cidr",
+ "criteria": {
+ "sourceIP": "10.0.0.0/28"
+ },
+ "want": [
+ "a",
+ "b",
+ "c",
+ "g",
+ "h"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr not on octets",
+ "criteria": {
+ "sourceIP": "10.0.0.8/29"
+ },
+ "want": [
+ "b",
+ "c"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr host bits set",
+ "criteria": {
+ "sourceIP": "10.0.0.9/31"
+ },
+ "want": [
+ "c"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr one address",
+ "criteria": {
+ "sourceIP": "10.0.0.5/32"
+ },
+ "want": [
+ "a",
+ "g",
+ "h"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr wide",
+ "criteria": {
+ "sourceIP": "10.0.0.0/8"
+ },
+ "want": [
+ "a",
+ "b",
+ "c",
+ "g",
+ "h",
+ "i",
+ "j",
+ "k"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr short of an octet",
+ "criteria": {
+ "sourceIP": "192.0.0.0/4"
+ },
+ "want": [
+ "d",
+ "e"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr everything",
+ "criteria": {
+ "sourceIP": "0.0.0.0/0"
+ },
+ "want": [
+ "a",
+ "b",
+ "c",
+ "d",
+ "e",
+ "f",
+ "g",
+ "h",
+ "i",
+ "j",
+ "k"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr v6",
+ "criteria": {
+ "sourceIP": "fe80::/10"
+ },
+ "want": [
+ "f"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr v4 written as v6",
+ "criteria": {
+ "sourceIP": "::ffff:10.1.0.0/112"
+ },
+ "want": [
+ "i",
+ "j",
+ "k"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip cidr v4 holds no v6",
+ "criteria": {
+ "sourceIP": "0.0.0.0/1"
+ },
+ "want": [
+ "a",
+ "b",
+ "c",
+ "g",
+ "h",
+ "i",
+ "j",
+ "k"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip not a cidr",
+ "criteria": {
+ "sourceIP": "10.0.0.0/33"
+ },
+ "want": [],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip glob",
+ "criteria": {
+ "sourceIP": "10.1.0.*"
+ },
+ "want": [
+ "i",
+ "j",
+ "k"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip glob one character",
+ "criteria": {
+ "sourceIP": "192.168.1.?"
+ },
+ "want": [
+ "e"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip glob is whole",
+ "criteria": {
+ "sourceIP": "10.0.0.1*"
+ },
+ "want": [
+ "b"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
+ {
+ "name": "ip glob case",
+ "criteria": {
+ "sourceIP": "FE80::*"
+ },
+ "want": [
+ "f"
+ ],
+ "plan": {
+ "mode": "text"
+ }
+ },
{
"name": "host typed with a space",
"criteria": {
diff --git a/internal/sources/sources.go b/internal/sources/sources.go
index 2dbf66c..5552e5e 100644
--- a/internal/sources/sources.go
+++ b/internal/sources/sources.go
@@ -22,6 +22,7 @@ import (
"SyslogStudio/internal/importer"
"SyslogStudio/internal/models"
"SyslogStudio/internal/notify"
+ "SyslogStudio/internal/search"
"SyslogStudio/internal/syslog"
)
@@ -40,6 +41,7 @@ func New() *Reader {
// rule is a SourceRule ready to apply.
type rule struct {
models.SourceRule
+ sources []search.Address
pattern *regexp.Regexp
format *importer.FormatReader
charmap *charmap.Charmap
@@ -50,6 +52,15 @@ func compile(r models.SourceRule) (*rule, error) {
return nil, err
}
c := &rule{SourceRule: r}
+ for _, s := range r.Match.Sources {
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ if err := search.CheckAddressEntry(s); err != nil {
+ return nil, err
+ }
+ c.sources = append(c.sources, search.CompileAddress(s, false))
+ }
if p := strings.TrimSpace(r.Match.Pattern); p != "" {
re, err := models.SafeCompileRegex(p)
if err != nil {
@@ -121,7 +132,7 @@ func (r *Reader) Read(raw []byte, sourceIP, protocol string) models.SyslogMessag
func (c *rule) matches(msg models.SyslogMessage) bool {
m := c.Match
- return notify.MatchesSource(m.Sources, msg.SourceIP) &&
+ return search.MatchAddresses(c.sources, msg.SourceIP) &&
notify.MatchesGlob(m.Hostnames, msg.Hostname) &&
notify.MatchesGlob(m.AppNames, msg.AppName) &&
(c.pattern == nil || c.pattern.MatchString(msg.RawMessage))
diff --git a/internal/sources/sources_test.go b/internal/sources/sources_test.go
index 6dace7f..36e98fd 100644
--- a/internal/sources/sources_test.go
+++ b/internal/sources/sources_test.go
@@ -40,6 +40,33 @@ func TestRule_AppliesToWhatItMatches(t *testing.T) {
}
}
+// An address is that device, not the ones it is the start of (#98); a CIDR
+// and a pattern take a range.
+func TestRule_Addresses(t *testing.T) {
+ for _, c := range []struct {
+ entry string
+ take []string
+ leave []string
+ }{
+ {"10.10.1.1", []string{"10.10.1.1"}, []string{"10.10.1.10", "10.10.1.100", "10.10.1.199"}},
+ {"10.10.1.0/24", []string{"10.10.1.1", "10.10.1.254"}, []string{"10.10.2.1", "fe80::1"}},
+ {"10.10.1.*", []string{"10.10.1.1", "10.10.1.199"}, []string{"10.10.10.1"}},
+ {"fe80::/10", []string{"fe80::1"}, []string{"10.10.1.1"}},
+ } {
+ r := reader(t, models.SourceRule{Name: "device", Enabled: true, Match: bySource(c.entry), AppName: "x"})
+ for _, ip := range c.take {
+ if m := r.Read([]byte("<14>hello"), ip, "UDP"); m.ReadBy != "device" {
+ t.Errorf("%s: %s not taken", c.entry, ip)
+ }
+ }
+ for _, ip := range c.leave {
+ if m := r.Read([]byte("<14>hello"), ip, "UDP"); m.ReadBy != "" {
+ t.Errorf("%s: %s taken", c.entry, ip)
+ }
+ }
+ }
+}
+
// Behind a relay every device arrives from the same address: the raw message
// tells them apart, and the first rule that matches is the one applied.
func TestRules_InOrder_PatternTellsDevicesApart(t *testing.T) {
@@ -185,6 +212,9 @@ func TestCompile_Refuses(t *testing.T) {
"an unknown zone": {Match: bySource("10.0.0.1"), Format: models.ImportFormat{Timezone: "Mars/Olympus"}},
"an unknown level": {Match: bySource("10.0.0.1"), LevelFrom: "guess"},
"an unknown encoding": {Match: bySource("10.0.0.1"), Encoding: "ebcdic"},
+ "half an address": {Match: bySource("10.0.0")},
+ "a name for address": {Match: bySource("gateway")},
+ "a CIDR too long": {Match: bySource("10.0.0.0/33")},
} {
if err := Compile(r); err == nil {
t.Errorf("%s: accepted", name)
diff --git a/internal/storage/query.go b/internal/storage/query.go
index feef0a3..556fb2d 100644
--- a/internal/storage/query.go
+++ b/internal/storage/query.go
@@ -5,6 +5,7 @@ import (
"database/sql/driver"
"log/slog"
"strings"
+ "sync"
"time"
"SyslogStudio/internal/search"
@@ -30,8 +31,25 @@ func init() {
}
return args[0], nil
})
+ // ss_addr is search.Address.Match in SQL, for what plain SQL cannot say:
+ // whether an address is inside a CIDR.
+ sqlite.MustRegisterDeterministicScalarFunction("ss_addr", 2,
+ func(_ *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
+ value, _ := args[0].(string)
+ typed, _ := args[1].(string)
+ if a, ok := compiledAddresses.Load(typed); ok {
+ return a.(search.Address).Match(value), nil
+ }
+ a := search.CompileAddress(typed, false)
+ compiledAddresses.Store(typed, a)
+ return a.Match(value), nil
+ })
}
+// compiledAddresses keeps ss_addr from compiling its CIDR once per row. One
+// entry per CIDR filtered on in this run, which a person types.
+var compiledAddresses sync.Map
+
// buildWhereClause turns a compiled filter into SQL. A search plan that needs
// a scan — a regex — is left for the caller to apply to the rows.
func buildWhereClause(f *search.Filter) (string, []any) {
@@ -56,7 +74,6 @@ func buildWhereClause(f *search.Filter) (string, []any) {
}{
{"hostname", f.Hostname.Needle},
{"app_name", f.AppName.Needle},
- {"source_ip", f.SourceIP.Needle},
} {
if field.needle != "" {
cond, a := containsCondition(field.col, field.needle)
@@ -64,6 +81,10 @@ func buildWhereClause(f *search.Filter) (string, []any) {
args = append(args, a...)
}
}
+ if cond, a := addressCondition("source_ip", f.SourceIP); cond != "" {
+ conditions = append(conditions, cond)
+ args = append(args, a...)
+ }
if match := f.Plan.FTS5(); match != "" {
conditions = append(conditions, "rowid IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)")
@@ -120,6 +141,39 @@ func containsCondition(col, needle string) (string, []any) {
return cond, []any{"%" + escapeLike(needle) + "%", needle}
}
+// addressCondition is search.Address's test in SQL. An exact address and a
+// pattern are answered as containsCondition answers text: by the column's
+// own comparison for a row of plain ASCII, through ss_fold for the others. A
+// CIDR goes through ss_addr, after the index has narrowed the rows to those
+// starting with its whole octets when it has some.
+func addressCondition(col string, a search.Address) (string, []any) {
+ ascii := "length(" + col + ") = length(CAST(" + col + " AS BLOB))"
+ switch a.Kind {
+ case search.AddressExact:
+ return "(" + col + " = ? COLLATE NOCASE OR (NOT " + ascii + " AND ss_fold(" + col + ") = ?))",
+ []any{a.Text, a.Text}
+ case search.AddressNet:
+ if a.Bits == 0 {
+ return "", nil
+ }
+ if a.Prefix != "" {
+ return "(" + col + ` LIKE ? ESCAPE '\' AND ss_addr(` + col + ", ?))",
+ []any{escapeLike(a.Prefix) + "%", a.CIDR()}
+ }
+ return "ss_addr(" + col + ", ?)", []any{a.CIDR()}
+ case search.AddressGlob:
+ like := globEscaper.Replace(escapeLike(a.Text))
+ return "((" + ascii + " AND " + col + ` LIKE ? ESCAPE '\') OR (NOT ` + ascii +
+ " AND ss_fold(" + col + `) LIKE ? ESCAPE '\'))`, []any{like, like}
+ case search.AddressText:
+ return containsCondition(col, a.Text)
+ }
+ return "", nil
+}
+
+// globEscaper turns a glob, its LIKE wildcards already escaped, into LIKE.
+var globEscaper = strings.NewReplacer("*", "%", "?", "_")
+
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
// escapeLike makes LIKE's wildcards literal: "web_01" is not "web?01".