From 688a7dc2e6349354405a889a6cd98544de034c3b Mon Sep 17 00:00:00 2001 From: Cedric Date: Sat, 3 Oct 2026 16:05:47 +0200 Subject: [PATCH] Load color tag presets from an editable presets.json file The presets move from app.js to presets.json, built into the binary and served by /api/presets. PRESETS_FILE (default /data/presets.json) replaces the list when present; it is read again each time Settings opens and the built-in list is used if it is invalid. docs/presets.md (EN/FR) explains each preset and the file format. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 5 + Dockerfile | 2 +- README.fr.md | 17 ++-- README.md | 16 +-- api.go | 6 ++ docker-compose.yml | 3 + docs/presets.fr.md | 114 ++++++++++++++++++++++ docs/presets.md | 111 +++++++++++++++++++++ main.go | 3 +- presets.go | 110 +++++++++++++++++++++ presets.json | 237 +++++++++++++++++++++++++++++++++++++++++++++ presets_test.go | 46 +++++++++ web/app.js | 114 ++++++---------------- web/index.html | 22 ----- 14 files changed, 680 insertions(+), 126 deletions(-) create mode 100644 docs/presets.fr.md create mode 100644 docs/presets.md create mode 100644 presets.go create mode 100644 presets.json create mode 100644 presets_test.go diff --git a/.env.example b/.env.example index 6051395..dbc809d 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ AUTH_PASS= # local mode: PNG logo shown on the login page, path inside the container (empty = no logo). # Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png LOGIN_LOGO= + +# Ready-made color tags offered in Settings > Filters (see docs/presets.md). +# Empty: /data/presets.json if present, else the built-in list. To use your own file, +# mount it in docker-compose.yml, e.g. ./presets.json:/config/presets.json:ro +PRESETS_FILE= # Session lifetime, both modes (e.g. 8h, 24h) SESSION_TTL=12h # oidc mode: issuer URL exactly as the provider announces it diff --git a/Dockerfile b/Dockerfile index 3bcf99e..61141c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.27.1-alpine3.24 AS build WORKDIR /src COPY go.mod ./ -COPY *.go ./ +COPY *.go presets.json ./ COPY web ./web RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream . diff --git a/README.fr.md b/README.fr.md index 58df5d1..2633397 100644 --- a/README.fr.md +++ b/README.fr.md @@ -228,14 +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. D'autres préréglages couvrent les - logs système (échecs et succès de connexion SSH, commandes sudo, OOM et erreurs du noyau, - services systemd, UFW/iptables et fail2ban), les applications (conteneurs Docker, erreurs - PostgreSQL et MySQL/MariaDB) et des motifs généraux (niveaux de log, adresses IPv4). 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 + Le menu *+ Préréglage…* ajoute des tags tout faits : logs d'accès HTTP/HTTPS (codes de statut, + méthodes, sondes, robots, erreurs TLS et proxy), logs système (SSH, sudo, noyau, systemd, + pare-feu), applications (Docker, bases de données) et motifs généraux (niveaux de log, + adresses IPv4). Les tags déjà présents ne sont pas ajoutés en double, et les tags ajoutés se + modifient comme les autres. La liste vient d'un fichier texte modifiable (`PRESETS_FILE`) : + voir [docs/presets.fr.md](docs/presets.fr.md) pour le détail de chaque préréglage et le + format du fichier. 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. Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste @@ -352,6 +351,7 @@ résolutions. | `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) | | `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres | | `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV | +| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) | | `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux | | `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) | | `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois | @@ -418,6 +418,7 @@ Pour mettre à jour l'une d'elles : | `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources | | `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` | | `tags.go` | stockage des tags de couleur | +| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée | | `api.go` | routes HTTP `/api/*` | | `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion | diff --git a/README.md b/README.md index c301b03..d460125 100644 --- a/README.md +++ b/README.md @@ -207,14 +207,12 @@ 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. Other presets cover system logs (SSH and - login failures/successes, sudo commands, kernel OOM and errors, systemd services, UFW/iptables - and fail2ban), applications (Docker containers, PostgreSQL and MySQL/MariaDB errors) and - general patterns (log levels, IPv4 addresses). 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` + The *+ Preset…* menu adds ready-made tags: HTTP/HTTPS access logs (status codes, methods, + probes, bots, TLS and proxy errors), system logs (SSH, sudo, kernel, systemd, firewall), + applications (Docker, databases) and general patterns (log levels, IPv4 addresses). Tags + already in the list are skipped, and the added tags can be edited like any other. The list + comes from a text file you can edit (`PRESETS_FILE`): see [docs/presets.md](docs/presets.md) + for each preset and the file format. 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. Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag @@ -322,6 +320,7 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set | `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) | | `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings | | `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export | +| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) | | `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers | | `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) | | `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time | @@ -385,6 +384,7 @@ To update one of them: | `syslogserver.go` | syslog listeners opened and closed from Settings > Sources | | `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower | | `tags.go` | color tag storage | +| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list | | `api.go` | `/api/*` HTTP routes | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page | diff --git a/api.go b/api.go index 60705d2..bd48da6 100644 --- a/api.go +++ b/api.go @@ -16,6 +16,7 @@ type API struct { store *Store hub *Hub tags *TagStore + presets string // presets file, built-in presets when missing rdns *ReverseDNS allowPurge bool exportMax int @@ -34,6 +35,7 @@ func (a *API) Routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/tags", a.listTags) mux.HandleFunc("POST /api/tags", a.createTag) mux.HandleFunc("POST /api/tags/reset", a.resetTags) + mux.HandleFunc("GET /api/presets", a.listPresets) mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag) mux.HandleFunc("GET /api/purge", a.purgeStatus) @@ -316,6 +318,10 @@ func (a *API) listTags(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, a.tags.List()) } +func (a *API) listPresets(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, loadPresets(a.presets)) +} + func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) { var t Tag err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t) diff --git a/docker-compose.yml b/docker-compose.yml index 01d266a..f72132b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik AUTH_PASS: ${AUTH_PASS:-} LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes) + PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md) OIDC_ISSUER: ${OIDC_ISSUER:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} @@ -41,6 +42,8 @@ services: - /run/log/journal:/host/run/log/journal:ro # journal systemd volatile # logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env # - ./logo.png:/config/logo.png:ro + # prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env + # - ./presets.json:/config/presets.json:ro labels: logstream.exclude: "true" # pas de collect des logs logstream diff --git a/docs/presets.fr.md b/docs/presets.fr.md new file mode 100644 index 0000000..d7a5c67 --- /dev/null +++ b/docs/presets.fr.md @@ -0,0 +1,114 @@ +[English](presets.md) · **Français** + +# Préréglages de tags de couleur + +Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de +couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur, +leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est +pas ajouté en double. + +La liste vient d'un fichier texte, [`presets.json`](../presets.json), intégré à LogStream. Vous +pouvez le remplacer par votre propre fichier (voir [Utiliser votre propre fichier](#utiliser-votre-propre-fichier)). + +## Préréglages intégrés + +### HTTP/HTTPS + +Ces préréglages lisent les logs d'accès de nginx et Apache (formats common et combined), Traefik +(CLF et JSON), Caddy (JSON) et HAProxy (`option httplog`). + +| Préréglage | Tags | Ce qui est coloré | +| --- | --- | --- | +| Codes HTTP | `HTTP 2xx` vert, `HTTP 3xx` bleu, `HTTP 4xx` orange, `HTTP 5xx` rouge | seulement le code de statut, par exemple `404` dans `"GET /x HTTP/1.1" 404 153`, `"status":404` ou `"DownstreamStatus":404`. Les autres nombres de la ligne (taille, chemin) ne sont pas touchés. | +| Méthodes HTTP | `GET/HEAD/OPTIONS` gris, `POST/PUT/PATCH` violet, `DELETE` rose | seulement la méthode dans `"GET /chemin` ou `"method":"GET"` (en majuscules uniquement) | +| Sondes et attaques | `sondes / attaques` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `…)`, ne colore que cette partie de la correspondance, comme le font +les préréglages HTTP avec `(?5\\d\\d)`. diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000..9ec5e38 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,111 @@ +**English** · [Français](presets.fr.md) + +# Color tag presets + +In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one +click. Added tags are ordinary tags: you can change their color, pattern or options, or delete +them. A tag whose pattern is already in the list is not added twice. + +The list comes from a text file, [`presets.json`](../presets.json), built into LogStream. You +can replace it with your own file (see [Using your own file](#using-your-own-file)). + +## Built-in presets + +### HTTP/HTTPS + +These presets read access logs from nginx and Apache (common and combined formats), Traefik +(CLF and JSON), Caddy (JSON) and HAProxy (`option httplog`). + +| Preset | Tags | What gets colored | +| --- | --- | --- | +| HTTP status codes | `HTTP 2xx` green, `HTTP 3xx` blue, `HTTP 4xx` orange, `HTTP 5xx` red | only the status code, e.g. `404` in `"GET /x HTTP/1.1" 404 153`, `"status":404` or `"DownstreamStatus":404`. Other numbers on the line (size, path) are left alone. | +| HTTP methods | `GET/HEAD/OPTIONS` grey, `POST/PUT/PATCH` purple, `DELETE` pink | only the method in `"GET /path` or `"method":"GET"` (upper case only) | +| Probes and attacks | `probes / attacks` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `…)`, colors only +that part of the match, as the HTTP presets do with `(?5\\d\\d)`. diff --git a/main.go b/main.go index c916c02..ae4d058 100644 --- a/main.go +++ b/main.go @@ -143,7 +143,8 @@ func main() { log.Fatal(err) } mux := http.NewServeMux() - api := &API{store: store, hub: hub, tags: tags, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv} + presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json")) + api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv} if cfg.dockerLogs { dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink) if err != nil { diff --git a/presets.go b/presets.go new file mode 100644 index 0000000..514a13b --- /dev/null +++ b/presets.go @@ -0,0 +1,110 @@ +package main + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" +) + +// Built-in tag presets, used when no presets file is found (PRESETS_FILE, +// /data/presets.json by default). See docs/presets.md. +// +//go:embed presets.json +var builtinPresets []byte + +// i18nText is a text per language ({"en": "...", "fr": "..."}) or one plain +// string for every language. +type i18nText map[string]string + +func (t *i18nText) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + *t = i18nText{"en": s} + return nil + } + var m map[string]string + if err := json.Unmarshal(b, &m); err != nil { + return errors.New("expected a string or an object of strings per language") + } + *t = m + return nil +} + +type PresetTag struct { + Label i18nText `json:"label,omitempty"` + Pattern string `json:"pattern"` + Color string `json:"color"` + Regex *bool `json:"regex,omitempty"` // regular expression unless false + WholeWord bool `json:"wholeWord,omitempty"` + CaseSensitive bool `json:"caseSensitive,omitempty"` +} + +type Preset struct { + ID string `json:"id"` + Name i18nText `json:"name"` + Tags []PresetTag `json:"tags"` +} + +type PresetGroup struct { + Group i18nText `json:"group"` + Presets []Preset `json:"presets"` +} + +// parsePresets decodes and checks a presets file with the same rules as the +// tags, so that every preset can be added as is. +func parsePresets(b []byte) ([]PresetGroup, error) { + var groups []PresetGroup + if err := json.Unmarshal(b, &groups); err != nil { + return nil, err + } + seen := map[string]bool{} + for _, g := range groups { + for _, p := range g.Presets { + if p.ID == "" || seen[p.ID] { + return nil, fmt.Errorf("preset %q: missing or duplicate id", p.ID) + } + seen[p.ID] = true + if len(p.Tags) == 0 { + return nil, fmt.Errorf("preset %q: no tags", p.ID) + } + for i, pt := range p.Tags { + t := Tag{Pattern: pt.Pattern, Color: pt.Color, Regex: pt.Regex == nil || *pt.Regex} + if err := t.validate(); err != nil { + return nil, fmt.Errorf("preset %q, tag %d: %w", p.ID, i+1, err) + } + } + } + } + return groups, nil +} + +// presetsResponse is what GET /api/presets returns. +type presetsResponse struct { + Source string `json:"source"` // "file" or "builtin" + File string `json:"file"` + Error string `json:"error,omitempty"` // the file is invalid: built-in presets are used + Groups []PresetGroup `json:"groups"` +} + +// loadPresets reads the presets file on every call, so that edits apply +// without a restart, and falls back to the built-in presets. +func loadPresets(path string) presetsResponse { + res := presetsResponse{Source: "builtin", File: path} + if path != "" { + b, err := os.ReadFile(path) + switch { + case err == nil: + if res.Groups, err = parsePresets(b); err == nil { + res.Source = "file" + return res + } + res.Error = err.Error() + case !errors.Is(err, os.ErrNotExist): + res.Error = err.Error() + } + } + res.Groups, _ = parsePresets(builtinPresets) // checked by the tests + return res +} diff --git a/presets.json b/presets.json new file mode 100644 index 0000000..7572acd --- /dev/null +++ b/presets.json @@ -0,0 +1,237 @@ +[ + { + "group": "HTTP/HTTPS", + "presets": [ + { + "id": "http_status", + "name": { "en": "HTTP status codes", "fr": "Codes HTTP" }, + "tags": [ + { + "label": "HTTP 2xx", + "color": "#86efac", + "pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?2\\d\\d)\\b" + }, + { + "label": "HTTP 3xx", + "color": "#93c5fd", + "pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?3\\d\\d)\\b" + }, + { + "label": "HTTP 4xx", + "color": "#fdba74", + "pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?4\\d\\d)\\b" + }, + { + "label": "HTTP 5xx", + "color": "#f87171", + "pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?5\\d\\d)\\b" + } + ] + }, + { + "id": "http_methods", + "name": { "en": "HTTP methods", "fr": "Méthodes HTTP" }, + "tags": [ + { + "label": "GET/HEAD/OPTIONS", + "color": "#cbd5e1", + "caseSensitive": true, + "pattern": "\"(?GET|HEAD|OPTIONS)[ \"]" + }, + { + "label": "POST/PUT/PATCH", + "color": "#c4b5fd", + "caseSensitive": true, + "pattern": "\"(?POST|PUT|PATCH)[ \"]" + }, + { + "label": "DELETE", + "color": "#f9a8d4", + "caseSensitive": true, + "pattern": "\"(?DELETE)[ \"]" + } + ] + }, + { + "id": "http_probes", + "name": { "en": "Probes and attacks", "fr": "Sondes et attaques" }, + "tags": [ + { + "label": { "en": "probes / attacks", "fr": "sondes / attaques" }, + "color": "#fda4af", + "pattern": "(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd| `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', - presetGroupSys: 'System', presetGroupApps: 'Applications', presetGroupGen: 'General', - preset_sys_auth: 'SSH and logins', preset_sys_sudo: 'sudo commands', preset_sys_kernel: 'Kernel: OOM, crashes, disks', - preset_sys_systemd: 'systemd services', preset_sys_firewall: 'Firewall and fail2ban', - preset_app_docker: 'Docker and containers', preset_app_db: 'Databases', - preset_gen_levels: 'Log levels', preset_gen_ip: 'IPv4 addresses', - pl_authFail: 'login failures', pl_authOk: 'logins', pl_sudo: 'sudo commands', pl_oom: 'out of memory', - pl_kernel: 'kernel errors', pl_unitFail: 'failed services', pl_unitOk: 'service start/stop', - pl_firewall: 'firewall', pl_docker: 'container problems', pl_db: 'database errors', - pl_fatal: 'fatal / critical', pl_info: 'info / notice', pl_debug: 'debug / trace', pl_ip: 'IPv4 addresses', presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'), + presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `, tagsLoadErr: 'Tags: ', err_pattern_required: 'The keyword is required', err_invalid_color: 'Invalid color (expected #rrggbb)', @@ -212,20 +200,8 @@ const I18N = { 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', - presetGroupSys: 'Système', presetGroupApps: 'Applications', presetGroupGen: 'Général', - preset_sys_auth: 'SSH et connexions', preset_sys_sudo: 'Commandes sudo', preset_sys_kernel: 'Noyau : OOM, plantages, disques', - preset_sys_systemd: 'Services systemd', preset_sys_firewall: 'Pare-feu et fail2ban', - preset_app_docker: 'Docker et conteneurs', preset_app_db: 'Bases de données', - preset_gen_levels: 'Niveaux de log', preset_gen_ip: 'Adresses IPv4', - pl_authFail: 'échecs de connexion', pl_authOk: 'connexions', pl_sudo: 'commandes sudo', pl_oom: 'mémoire épuisée', - pl_kernel: 'erreurs noyau', pl_unitFail: 'services en échec', pl_unitOk: 'démarrage/arrêt de service', - pl_firewall: 'pare-feu', pl_docker: 'problèmes de conteneur', pl_db: 'erreurs base de données', - pl_fatal: 'fatal / critique', pl_info: 'info / notice', pl_debug: 'debug / trace', pl_ip: 'adresses IPv4', presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'), + presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `, tagsLoadErr: 'Tags : ', err_pattern_required: 'Le mot-clé est obligatoire', err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', @@ -475,6 +451,7 @@ const state = { rows: [], // displayed records, newest first pending: [], // live messages received while scrolled down tags: [], + presets: [], // preset groups from /api/presets matchers: [], // compiled tags + search terms mode: store.get('mode', 'simple'), live: store.get('live', '1') === '1', @@ -497,7 +474,6 @@ function applyLang() { for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : ''); for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh); - for (const el of document.querySelectorAll('[data-i18n-label]')) el.label = t(el.dataset.i18nLabel); for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); } @@ -514,6 +490,7 @@ function setLang(next) { renderHisto(); renderStats(); renderTagList(); + renderPresets(); renderTimeSettings(); renderPurge(); renderInterface(); @@ -2074,61 +2051,24 @@ $('#purgeBtn').addEventListener('click', async () => { const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; -// Ready-made tags. The patterns are valid in both JavaScript and Go (RE2). -// The HTTP ones 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. The others target the -// usual messages of sshd/PAM, sudo, the kernel, systemd, UFW/iptables, -// fail2ban, Docker, PostgreSQL and MySQL/MariaDB. -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)' }, - ], - sys_auth: () => [ - { label: t('pl_authFail'), color: '#fca5a5', - pattern: '(?:Failed (?:password|publickey|none)|Invalid user|authentication failures?|Connection closed by (?:invalid|authenticating) user|maximum authentication attempts exceeded|FAILED (?:LOGIN|SU)|incorrect password attempts?|NOT in sudoers)' }, - { label: t('pl_authOk'), color: '#86efac', - pattern: '(?:Accepted (?:password|publickey|keyboard-interactive(?:/pam)?)|session opened for user|New session \\S+ of user)' }, - ], - sys_sudo: () => [{ label: t('pl_sudo'), color: '#fde68a', pattern: '\\bCOMMAND=\\S+', caseSensitive: true }], - sys_kernel: () => [ - { label: t('pl_oom'), color: '#f87171', - pattern: '(?:Out of memory|oom-kill(?:er)?|oom_reaper|Killed process \\d+|invoked oom-killer)' }, - { label: t('pl_kernel'), color: '#fda4af', - pattern: '(?:Kernel panic|\\bBUG: |\\bOops\\b|Call Trace|segfault at|general protection fault|I/O error|EXT4-fs error|Buffer I/O error|blocked for more than \\d+ seconds|Hardware Error|soft lockup|hard LOCKUP)' }, - ], - sys_systemd: () => [ - { label: t('pl_unitFail'), color: '#fca5a5', - pattern: '(?:Failed to start|failed with result|Main process exited, code=(?:exited|killed|dumped)|entered failed state|Start request repeated too quickly|Dependency failed)' }, - { label: t('pl_unitOk'), color: '#bbf7d0', caseSensitive: true, - pattern: '\\b(?:Started|Starting|Stopped|Stopping|Reloaded|Reloading|Reached target)\\b' }, - ], - sys_firewall: () => [{ label: t('pl_firewall'), color: '#fdba74', caseSensitive: true, - pattern: '(?:\\[UFW (?:BLOCK|ALLOW|AUDIT|LIMIT BLOCK)\\]|\\b(?:DROP|REJECT)\\b|\\b(?:Ban|Unban|Found) \\d{1,3}(?:\\.\\d{1,3}){3}\\b)' }], - app_docker: () => [{ label: t('pl_docker'), color: '#fcd34d', - pattern: '(?:\\bOOMKilled\\b|exited with code [1-9]\\d*|exit code: [1-9]\\d*|health_status: unhealthy|\\bunhealthy\\b|Back-off restarting|CrashLoopBackOff|container (?:die|kill|oom)\\b|restarting \\(\\d+\\))' }], - app_db: () => [{ label: t('pl_db'), color: '#c4b5fd', - pattern: '(?:\\bdeadlock(?: detected| found)?\\b|duplicate key|too many (?:connections|clients)|lock wait timeout|slow query|could not connect to server|server has gone away|out of shared memory|terminating connection|Access denied for user|password authentication failed)' }], - gen_levels: () => [ - { label: t('pl_fatal'), color: '#ef4444', pattern: '\\b(?:fatal|crit(?:ical)?|panic|emerg(?:ency)?)\\b' }, - { label: t('pl_info'), color: '#bfdbfe', pattern: '\\b(?:info|notice)\\b' }, - { label: t('pl_debug'), color: '#e5e7eb', pattern: '\\b(?:debug|trace)\\b' }, - ], - gen_ip: () => [{ label: t('pl_ip'), color: '#a5f3fc', - pattern: '\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b' }], -}; +// Ready-made tags, from the presets file (or the built-in presets.json): +// see docs/presets.md. A text is a string or an object per language. +const pickText = (x) => (typeof x === 'string' ? x : (x?.[lang] ?? x?.en ?? Object.values(x || {})[0] ?? '')); + +async function loadPresets() { + try { + const res = await api('/api/presets'); + state.presets = res.groups || []; + if (res.error) toast(t('presetsFileErr', res.file) + res.error); + } catch (e) { toast(e.message); } + renderPresets(); +} + +function renderPresets() { + $('#presetTags').innerHTML = `` + + state.presets.map((g) => `` + + g.presets.map((p) => ``).join('') + '').join(''); +} async function loadTags() { try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); } @@ -2220,14 +2160,15 @@ $('#addTag').addEventListener('click', async () => { // Adds a preset group, skipping tags whose pattern is already in the list. $('#presetTags').addEventListener('change', async (ev) => { - const make = PRESETS[ev.target.value]; + const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value); ev.target.value = ''; - if (!make) return; + if (!preset) return; let added = 0; try { - for (const p of make()) { + for (const p of preset.tags) { 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 } })); + const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true }; + state.tags.push(await api('/api/tags', { method: 'POST', body: tag })); added++; } } catch (e) { toast(e.message); } @@ -2248,6 +2189,7 @@ $('#resetTags').addEventListener('click', async () => { $('#settingsBtn').addEventListener('click', () => { renderTimeSettings(); renderTagList(); + loadPresets(); renderInterface(); loadPurgeStatus(); loadSyslog(); diff --git a/web/index.html b/web/index.html index 6e542ea..84e4820 100644 --- a/web/index.html +++ b/web/index.html @@ -193,28 +193,6 @@