@@ -51,6 +51,17 @@ const CH_LINE_RE =
5151 '(?:\\s+email:\\s*(\\S+))?' +
5252 '\\s*$' ;
5353
54+ // Fallback for connection-level error lines that are NOT access records, e.g.:
55+ // 2026/07/10 17:41:28 from 95.24.24.226:9048 rejected proxy/vless/encoding: invalid request user id: <uuid>
56+ // These carry a timestamp, source and action but no "tcp:/udp:" destination
57+ // (an attacker/scanner hitting an inbound with a bad UUID). Captures:
58+ // 1 ts, 2 src, 3 action. The tail (error text) stays in `raw`.
59+ const CH_ERR_RE =
60+ '^(\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?)\\s+' +
61+ '(?:from\\s+)?' +
62+ '(\\S+?)\\s+' +
63+ '(accepted|rejected|blocked)(?:\\s|$)' ;
64+
5465// Escape a JS string for use inside a single-quoted ClickHouse SQL literal.
5566// ClickHouse collapses unknown escapes ('\d' -> 'd'), so every backslash must
5667// be doubled or the inlined regex silently loses all its character classes.
@@ -63,9 +74,9 @@ function sqlString(s) {
6374// the stale one). The version is part of the MV name; ensureSchema drops any
6475// older names listed here. Bump MV_VERSION whenever the MV definition changes
6576// and append the previous name to LEGACY_MV_NAMES.
66- const MV_VERSION = 2 ;
77+ const MV_VERSION = 3 ;
6778const MV_NAME = `access_events_mv_v${ MV_VERSION } ` ;
68- const LEGACY_MV_NAMES = [ 'access_events_mv' ] ;
79+ const LEGACY_MV_NAMES = [ 'access_events_mv' , 'access_events_mv_v2' ] ;
6980
7081// ── Config ────────────────────────────────────────────────────────────────
7182
@@ -248,16 +259,21 @@ function schemaStatements(retentionDays) {
248259 SETTINGS non_replicated_deduplication_window = 1000` ,
249260
250261 // Parse raw -> structured on insert. Everything derives from `raw`.
251- // A line that does not match the regex (or carries a broken timestamp)
252- // still lands with parse_ok = 0 and event_time = now(), so no data is
253- // lost and it stays searchable by raw text within the retention window.
262+ // Two shapes are recognised: (n) a normal access line and, as a fallback,
263+ // (ne) a connection-level error line ("from IP rejected <msg>", no
264+ // destination) which is tagged outbound_tag = 'handshake-error' so the
265+ // action counters stay accurate and the attacking source IP is visible.
266+ // A line matching neither still lands with parse_ok = 0 and
267+ // event_time = now(), so nothing is lost and it stays searchable by raw.
254268 `CREATE MATERIALIZED VIEW IF NOT EXISTS ${ MV_NAME } TO access_events AS
255269 WITH
256270 extractGroups(raw, '${ sqlString ( CH_LINE_RE ) } ') AS g,
257271 length(g) AS n,
258- if(n > 0, g[1], '') AS ts_str,
259- if(n > 0, g[2], '') AS src,
260- if(n > 0, g[3], '') AS act,
272+ extractGroups(raw, '${ sqlString ( CH_ERR_RE ) } ') AS ge,
273+ length(ge) AS ne,
274+ if(n > 0, g[1], if(ne > 0, ge[1], '')) AS ts_str,
275+ if(n > 0, g[2], if(ne > 0, ge[2], '')) AS src,
276+ if(n > 0, g[3], if(ne > 0, ge[3], '')) AS act,
261277 if(n > 0, g[4], '') AS net,
262278 if(n > 0, g[5], '') AS dst,
263279 if(n > 0, g[6], '') AS route,
@@ -283,10 +299,10 @@ function schemaStatements(retentionDays) {
283299 dst_port AS dest_port,
284300 net AS network,
285301 in_tag AS inbound_tag,
286- out_tag AS outbound_tag,
302+ if(n > 0, out_tag, if(ne > 0, 'handshake-error', '')) AS outbound_tag,
287303 act AS action,
288304 raw,
289- toUInt8(n > 0) AS parse_ok
305+ toUInt8(n > 0 OR ne > 0 ) AS parse_ok
290306 FROM access_ingest` ,
291307 ] ;
292308}
@@ -392,6 +408,7 @@ async function truncate() {
392408
393409module . exports = {
394410 CH_LINE_RE ,
411+ CH_ERR_RE ,
395412 readConfig,
396413 getClient,
397414 reset,
0 commit comments