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".