diff --git a/README.fr.md b/README.fr.md index 35d5bcb..0cc5f3f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -228,6 +228,13 @@ couleur, est mémorisé par navigateur. Tags par défaut (pastel) : `warning` (orange), `error` (rouge), `ok` (vert). Les tags par défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux couleurs pastel. + Le menu *+ Préréglage…* ajoute des tags tout faits pour les logs d'accès HTTP/HTTPS (nginx et + Apache common/combined, Traefik CLF et JSON, Caddy JSON, HAProxy httplog) : codes de statut + (2xx vert, 3xx bleu, 4xx orange, 5xx rouge), méthodes, sondes et attaques (`wp-login.php`, + `/.env`, `../`…), robots et scripts, erreurs TLS et proxy. Les tags déjà présents ne sont pas + ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression + régulière, un groupe nommé `hl` (`(?…)`) ne colore que cette partie de la correspondance : + les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour. - **Interface** - *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le bouton soleil/lune de l'en-tête bascule entre clair et sombre. diff --git a/README.md b/README.md index c909084..7343e0f 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,13 @@ remembered per browser. (`logstream-data` volume), so they are shared by every browser. Default tags (pastel): `warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of earlier versions are switched to the pastel ones automatically. + The *+ Preset…* menu adds ready-made tags for HTTP/HTTPS access logs (nginx and Apache + common/combined, Traefik CLF and JSON, Caddy JSON, HAProxy httplog): status codes (2xx green, + 3xx blue, 4xx orange, 5xx red), methods, probes and attacks (`wp-login.php`, `/.env`, + `../`…), bots and scripts, TLS and proxy errors. Tags already in the list are skipped, and the + added tags can be edited like any other. In a regular expression, a group named `hl` + (`(?…)`) colors only that part of the match: the presets use it to color the status code + or the method, not the text around it. - **Interface** - *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon button in the header switches between light and dark. diff --git a/tags.go b/tags.go index 1b59c62..5dbaaa2 100644 --- a/tags.go +++ b/tags.go @@ -17,6 +17,7 @@ import ( type Tag struct { ID string `json:"id"` Pattern string `json:"pattern"` + Label string `json:"label,omitempty"` // shown instead of the pattern (presets) Color string `json:"color"` WholeWord bool `json:"wholeWord"` CaseSensitive bool `json:"caseSensitive"` @@ -72,6 +73,7 @@ var ( func (t *Tag) validate() error { t.Pattern = strings.TrimSpace(t.Pattern) + t.Label = strings.TrimSpace(t.Label) if t.Pattern == "" { return &codedError{code: "pattern_required", msg: "the keyword is required"} } diff --git a/web/app.js b/web/app.js index 07ca01b..f20ca92 100644 --- a/web/app.js +++ b/web/app.js @@ -48,6 +48,12 @@ const I18N = { newTag: 'new', confirmDelete: (p) => `Delete tag "${p}"?`, confirmReset: 'Replace all tags with the defaults (warning, error, ok)?', + presetAria: 'Add a preset', presetPick: '+ Preset…', + preset_http_status: 'HTTP status codes', preset_http_methods: 'HTTP methods', + preset_http_probes: 'Probes and attacks', preset_http_bots: 'Bots and scripts', + preset_http_errors: 'TLS/HTTPS and proxy errors', + pl_probes: 'probes / attacks', pl_bots: 'bots / scripts', pl_tls: 'TLS errors', pl_proxy: 'proxy errors', + presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'), tagsLoadErr: 'Tags: ', err_pattern_required: 'The keyword is required', err_invalid_color: 'Invalid color (expected #rrggbb)', @@ -192,6 +198,12 @@ const I18N = { newTag: 'nouveau', confirmDelete: (p) => `Supprimer le tag « ${p} » ?`, confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?', + presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…', + preset_http_status: 'Codes HTTP', preset_http_methods: 'Méthodes HTTP', + preset_http_probes: 'Sondes et attaques', preset_http_bots: 'Robots et scripts', + preset_http_errors: 'Erreurs TLS/HTTPS et proxy', + pl_probes: 'sondes / attaques', pl_bots: 'robots / scripts', pl_tls: 'erreurs TLS', pl_proxy: 'erreurs proxy', + presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'), tagsLoadErr: 'Tags : ', err_pattern_required: 'Le mot-clé est obligatoire', err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', @@ -681,7 +693,10 @@ function compileMatchers() { let src = tag.regex ? tag.pattern : escapeRe(tag.pattern); if (tag.wholeWord) src = `(?` }); + // A regex may name a group "hl" to color only that part of the match. + const hl = tag.regex && /\(\?P?/.test(tag.pattern); + if (hl) src = src.replace(/\(\?P/g, '(?'); + out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `` }); } catch (e) { console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); } @@ -706,6 +721,8 @@ function highlight(text) { let x; while ((x = m.re.exec(text)) !== null) { if (x[0] === '') { m.re.lastIndex++; continue; } + const g = x.indices?.groups?.hl; + if (g) { if (g[1] > g[0]) spans.push({ s: g[0], e: g[1], prio, html: m.html }); continue; } spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html }); } }); @@ -1978,6 +1995,28 @@ $('#purgeBtn').addEventListener('click', async () => { const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; +// Ready-made tags for HTTP/HTTPS access logs. The patterns are valid in both +// JavaScript and Go (RE2) and cover nginx/Apache (common, combined), Traefik +// (CLF, JSON), Caddy (JSON) and HAProxy (httplog). For status codes and +// methods only the "hl" group is colored, not the context around it. +const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )'; +const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?${d}\\d\\d)\\b`, color }); +const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?${m})[ "]`, color, caseSensitive: true }); +const PRESETS = { + http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')], + http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')], + http_probes: () => [{ label: t('pl_probes'), color: '#fda4af', + pattern: '(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd| [{ label: t('pl_bots'), color: '#fde68a', + pattern: '\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b' }], + http_errors: () => [ + { label: t('pl_tls'), color: '#f0abfc', + pattern: '(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)' }, + { label: t('pl_proxy'), color: '#fdba74', + pattern: '(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)' }, + ], +}; + async function loadTags() { try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); } compileMatchers(); @@ -1989,7 +2028,7 @@ function tagRowHTML(tag) { return `
- ${esc(tag.pattern || t('preview'))} + ${esc(tag.label || tag.pattern || t('preview'))}
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))} ${opt('caseSensitive', 'Aa', t('optCaseTitle'))} @@ -2032,7 +2071,7 @@ $('#tagList').addEventListener('input', (ev) => { tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value; rowEl.classList.toggle('off', !tag.enabled); const mark = rowEl.querySelector('.preview mark'); - mark.textContent = tag.pattern || t('preview'); + mark.textContent = tag.label || tag.pattern || t('preview'); mark.setAttribute('style', tagStyle(tag.color)); scheduleSave(tag, rowEl); applyTags(); @@ -2065,6 +2104,24 @@ $('#addTag').addEventListener('click', async () => { } catch (e) { toast(e.message); } }); +// Adds a preset group, skipping tags whose pattern is already in the list. +$('#presetTags').addEventListener('change', async (ev) => { + const make = PRESETS[ev.target.value]; + ev.target.value = ''; + if (!make) return; + let added = 0; + try { + for (const p of make()) { + if (state.tags.some((x) => x.pattern === p.pattern)) continue; + state.tags.push(await api('/api/tags', { method: 'POST', body: { regex: true, enabled: true, ...p } })); + added++; + } + } catch (e) { toast(e.message); } + renderTagList(); + applyTags(); + toast(t('presetAdded', added)); +}); + $('#resetTags').addEventListener('click', async () => { if (!confirm(t('confirmReset'))) return; try { diff --git a/web/index.html b/web/index.html index 63bf160..9946332 100644 --- a/web/index.html +++ b/web/index.html @@ -188,7 +188,17 @@

diff --git a/web/style.css b/web/style.css index a6be526..34411d3 100644 --- a/web/style.css +++ b/web/style.css @@ -578,6 +578,8 @@ input.switch:disabled { cursor: not-allowed; } input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } +.tag-add { display: flex; flex-wrap: wrap; gap: 8px; } +.tag-add select.field { width: auto; } .seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } .seg button {