Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076c01aac4 | ||
|
|
f3373e1f66 | ||
|
|
0beaa24f82 | ||
|
|
b626b3b8db | ||
|
|
aff7cf8839 | ||
|
|
389a679c9f | ||
|
|
8b5ee73ee1 | ||
|
|
e901de442c | ||
|
|
2f84bc8deb | ||
|
|
524f5dc0fd | ||
|
|
4f4cb3e02b | ||
|
|
42f6137391 | ||
|
|
3504263992 | ||
|
|
3466a29692 |
No files matched your search
+22
-1
@@ -9,6 +9,9 @@ AUTH_MODE=local
|
||||
# local mode: user and password (empty = no authentication)
|
||||
AUTH_USER=
|
||||
AUTH_PASS=
|
||||
# local mode: optional read-only account (can search and export, cannot change tags, sources or purge)
|
||||
AUTH_VIEWER_USER=
|
||||
AUTH_VIEWER_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=
|
||||
@@ -28,12 +31,16 @@ OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
||||
# Requested scopes (openid is always added)
|
||||
OIDC_SCOPES=openid profile email
|
||||
# oidc mode: only members of this group are admins, the others are read-only (empty = everyone is admin).
|
||||
# The groups come from the ID token claim OIDC_GROUPS_CLAIM (default groups)
|
||||
OIDC_ADMIN_GROUP=
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
# Reverse DNS: show host names instead of IP addresses (on/off)
|
||||
RDNS=on
|
||||
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
|
||||
DNS_SERVER=
|
||||
# Allow "Delete all logs" in Settings (true/false)
|
||||
ALLOW_PURGE=true
|
||||
ALLOW_PURGE=false
|
||||
# Maximum number of rows in a CSV export
|
||||
EXPORT_MAX=100000
|
||||
# Collect the logs of the Docker containers of this machine (on/off)
|
||||
@@ -46,3 +53,17 @@ DOCKER_BACKFILL=1h
|
||||
HOST_LOGS_GID=4
|
||||
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new entries)
|
||||
HOST_LOGS_BACKFILL=1h
|
||||
# Kernel messages of the host (dmesg, enable them in Settings > Sources). The container needs
|
||||
# /dev/kmsg: uncomment "devices" in docker-compose.yml. When the host has
|
||||
# kernel.dmesg_restrict=1 (default on Debian/Ubuntu), either run on the host
|
||||
# sysctl -w kernel.dmesg_restrict=0 (persist it in /etc/sysctl.d/)
|
||||
# or run the container as root with the SYSLOG capability ("user" and "cap_add" in docker-compose.yml).
|
||||
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new messages)
|
||||
DMESG_BACKFILL=1h
|
||||
# Linux audit log of the host (auditd, enable it in Settings > Sources). Uncomment the
|
||||
# /var/log/audit volume in docker-compose.yml and let the container read the file:
|
||||
# log_group = adm in /etc/audit/auditd.conf, and chgrp adm + chmod 750 on /var/log/audit.
|
||||
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new events)
|
||||
AUDIT_BACKFILL=1h
|
||||
# Host name of the audit events (empty: node= of the records, else /host/etc/hostname, else localhost)
|
||||
AUDIT_HOST=
|
||||
+163
-15
@@ -18,8 +18,10 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
|
||||

|
||||
|
||||
- **Ingestion** : les messages syslog (UDP/TCP) et les logs des conteneurs Docker passent tous
|
||||
par `sink()` (résolution DNS inverse des hôtes donnés par leur IP), puis par la file du
|
||||
`Store`, qui les envoie par lots à VictoriaLogs.
|
||||
par `sink()` (résolution DNS inverse des hôtes donnés par leur IP, sans bloquer la
|
||||
réception), puis par la file du `Store`, qui les envoie par lots à VictoriaLogs. Quand
|
||||
VictoriaLogs est injoignable, les lots sont gardés sur disque (`/data/spool`, jusqu'à
|
||||
`SPOOL_MAX_MB`) et renvoyés, les plus anciens d'abord, dès son retour.
|
||||
- **Direct** : `sink()` publie aussi chaque message dans le `Hub`, qui le diffuse aux
|
||||
navigateurs en SSE.
|
||||
- **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à
|
||||
@@ -100,6 +102,16 @@ restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Inter
|
||||
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
|
||||
présentation sur deux lignes, sans colonnes.
|
||||
|
||||
## Modes Flux et Période
|
||||
|
||||
Le premier contrôle de la barre de filtres bascule entre deux modes d'affichage :
|
||||
|
||||
- **Flux** : les derniers logs sur une durée glissante (5 min à 30 jours, ou tout), avec le direct.
|
||||
- **Période** : les logs entre une date de début et une date de fin, saisies dans le fuseau choisi
|
||||
dans les Paramètres. ◀ et ▶ passent à la période précédente ou suivante de même durée, la loupe
|
||||
la double autour de son milieu. Le direct se met en pause ; le mode et la période sont conservés
|
||||
au rechargement.
|
||||
|
||||
## Frise
|
||||
|
||||
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
|
||||
@@ -107,10 +119,9 @@ de réception** sur l'horloge du serveur (elle correspond donc aux heures affich
|
||||
lignes).
|
||||
|
||||
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité.
|
||||
- Un clic sur une barre zoome sur cet intervalle ; un glisser sur plusieurs barres zoome sur la
|
||||
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le
|
||||
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom,
|
||||
et le direct se met en pause.
|
||||
- Un clic sur une barre affiche cet intervalle en mode Période ; un glisser sur plusieurs barres
|
||||
affiche la sélection. « × Revenir au flux » repasse en mode Flux. La liste, les compteurs et
|
||||
l'export CSV suivent la période.
|
||||
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à
|
||||
chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ;
|
||||
la frise se met à jour dès qu'il redevient visible.
|
||||
@@ -153,8 +164,10 @@ colorés et exportés comme les messages syslog :
|
||||
quand les conteneurs sont nombreux. Les nouveaux conteneurs sont suivis automatiquement, sauf
|
||||
si cette option est désactivée. Les choix sont enregistrés par service compose (ou nom de
|
||||
conteneur) dans `/data/docker.json` : ils survivent aux recréations.
|
||||
- Logstream mémorise la position lue dans chaque conteneur (`/data/docker-state.json`) : après
|
||||
un redémarrage, il reprend sans perdre ni dupliquer de lignes. Un conteneur vu pour la
|
||||
- Logstream mémorise la position de la dernière ligne stockée pour chaque conteneur
|
||||
(`/data/docker-state.json`) : après un redémarrage, il reprend sans perdre de lignes. La
|
||||
position n'avance qu'une fois la ligne dans VictoriaLogs ou dans le tampon disque, et une file
|
||||
pleine ralentit la lecture au lieu de perdre des lignes. Un conteneur vu pour la
|
||||
première fois est lu à partir de `DOCKER_BACKFILL` en arrière (1 heure par défaut).
|
||||
- Logstream lui-même et le proxy ci-dessous ne sont jamais collectés ; ajoutez l'étiquette
|
||||
`logstream.exclude=true` à tout autre conteneur pour l'exclure définitivement.
|
||||
@@ -190,6 +203,108 @@ configurer sur l'hôte. La source est **désactivée par défaut** : activez-la
|
||||
sont comptés dans les Paramètres et affichés comme « (compressed journal entry) ». Les fichiers
|
||||
texte tournés ou compressés (`*.1`, `*.gz`) ne sont pas lus.
|
||||
|
||||
## Messages du noyau (dmesg)
|
||||
|
||||
Logstream peut collecter le buffer circulaire du noyau de l'hôte (ce qu'affiche `dmesg` :
|
||||
matériel, pilotes, OOM killer, pare-feu…) en lisant `/dev/kmsg`. La source est **désactivée par
|
||||
défaut** : activez-la dans **Paramètres > Sources > Messages du noyau (dmesg)** (enregistré dans
|
||||
`/data/dmesg.json`).
|
||||
|
||||
- **Périphérique** : le conteneur ne voit pas `/dev/kmsg` par défaut. Décommentez la section
|
||||
`devices` de `docker-compose.yml` (`/dev/kmsg:/dev/kmsg:r`, en lecture seule), puis
|
||||
`docker compose up -d`.
|
||||
- **Droits** : si l'hôte a `kernel.dmesg_restrict=1` (le défaut sur Debian et Ubuntu), seuls
|
||||
les processus qui ont la capacité `CAP_SYSLOG` peuvent lire le log du noyau, et le conteneur
|
||||
tourne avec un utilisateur sans privilège. Au choix :
|
||||
- l'autoriser sur l'hôte : `sysctl -w kernel.dmesg_restrict=0`, conservé au redémarrage avec
|
||||
`echo kernel.dmesg_restrict=0 | sudo tee /etc/sysctl.d/50-dmesg.conf` (tous les
|
||||
utilisateurs de l'hôte peuvent alors lancer `dmesg`) ; ce réglage ne peut pas être changé
|
||||
depuis un conteneur ;
|
||||
- ou lancer Logstream en root avec la capacité `SYSLOG` : décommentez `user: "0:0"` et
|
||||
`cap_add: [SYSLOG]` dans `docker-compose.yml`.
|
||||
|
||||
Paramètres > Sources indique si le périphérique est absent ou l'accès refusé ; le reste de
|
||||
Logstream continue de fonctionner et le périphérique est réessayé toutes les 10 secondes.
|
||||
- **Champs** : **app** vaut `kernel` (ou le nom du programme pour les messages écrits par des
|
||||
programmes, comme `systemd[1]: …`), la sévérité et la facility viennent de l'enregistrement,
|
||||
**host** vaut `localhost`, `subsystem` et `device` sont conservés quand le noyau les fournit.
|
||||
Ces logs ont la source `kernel` (`proto` `kmsg`) : le filtre **Source** permet de les afficher
|
||||
seuls.
|
||||
- **Heure** : les enregistrements portent le temps écoulé depuis le démarrage ; Logstream le
|
||||
convertit en date grâce à l'heure de démarrage (`/proc/uptime`). Comme avec `dmesg -T`,
|
||||
l'heure peut être trop tôt après une mise en veille de l'hôte.
|
||||
- À l'activation, la dernière heure est lue d'abord (`DMESG_BACKFILL`, `0` = seulement les
|
||||
nouveaux messages). Le numéro de séquence du dernier message et l'identifiant de démarrage
|
||||
sont enregistrés dans `/data/dmesg-state.json` : après un redémarrage de Logstream, la lecture
|
||||
reprend après le dernier message (sans doublon) ; après un redémarrage de l'hôte, le nouveau
|
||||
buffer est lu depuis `DMESG_BACKFILL`. Les messages écrasés dans le buffer du noyau avant
|
||||
d'être lus sont comptés dans Paramètres.
|
||||
|
||||
## Audit Linux (auditd)
|
||||
|
||||
Logstream peut suivre le journal du démon d'audit Linux (`/var/log/audit/audit.log`) :
|
||||
connexions, `sudo`, fichiers surveillés, appels système visés par vos règles `auditctl`… La
|
||||
source est **désactivée par défaut** : activez-la dans **Réglages > Sources > Audit Linux
|
||||
(auditd)**, où le chemin du fichier peut aussi être changé (enregistré dans `/data/audit.json` ;
|
||||
`AUDIT_LOG` fixe le chemin par défaut).
|
||||
|
||||
- **Montage** : décommentez `/var/log/audit:/var/log/audit:ro` dans `docker-compose.yml`, puis
|
||||
`docker compose up -d`. (`/var/log` est déjà monté sous `/host` pour les logs système de
|
||||
l'hôte : `/host/var/log/audit/audit.log` fonctionne donc aussi comme chemin, sans nouveau
|
||||
volume.)
|
||||
- **Droits** (côté hôte) : auditd écrit son journal en `root`, mode `0600`, dans un répertoire
|
||||
`0700`, et Logstream tourne avec l'UID `10001`. Le plus fiable est le groupe que le conteneur
|
||||
reçoit déjà pour les logs de l'hôte (`HOST_LOGS_GID`, `adm` par défaut) :
|
||||
```bash
|
||||
sudo sed -i 's/^log_group.*/log_group = adm/' /etc/audit/auditd.conf
|
||||
sudo chgrp adm /var/log/audit && sudo chmod 750 /var/log/audit
|
||||
sudo service auditd restart # systemctl refuse de redémarrer auditd sur certaines distributions
|
||||
```
|
||||
auditd crée alors chaque fichier, rotations comprises, en mode `0640` avec le groupe `adm`.
|
||||
Une ACL fonctionne aussi
|
||||
(`sudo setfacl -m u:10001:rx /var/log/audit && sudo setfacl -m u:10001:r /var/log/audit/audit.log`)
|
||||
mais doit être réappliquée à chaque nouveau fichier après une rotation. Réglages > Sources
|
||||
indique si le fichier est absent ou l'accès refusé ; il est réessayé chaque seconde.
|
||||
- **Événements** : les enregistrements d'un même événement (`SYSCALL`, `EXECVE`, `CWD`, `PATH`,
|
||||
`PROCTITLE`…) partagent le même `msg=audit(heure:numéro)` et deviennent **un seul** log, dès
|
||||
qu'aucun enregistrement n'est arrivé depuis 2 secondes. Le message est un résumé — qui, quoi,
|
||||
résultat — comme `cedric (as root): exec apt install nginx [exec] → success` ou
|
||||
`bob: PAM:authentication via /usr/sbin/sshd from 203.0.113.9 on ssh → failed` ; les
|
||||
enregistrements bruts sont gardés dans `audit_raw` et affichés sous le résumé (repliés en
|
||||
densité compacte tant que la ligne n'est pas ouverte). Les valeurs hexadécimales
|
||||
(`proctitle`, `cmd`, arguments avec espaces) sont décodées.
|
||||
- **Champs** : `audit_type` (type du premier enregistrement, aussi utilisé comme **app**),
|
||||
`audit_serial`, `auid` (identifiant de l'utilisateur connecté), `user` (son nom, ou l'uid s'il
|
||||
ne peut pas être résolu), `exe`, `res` (`success` / `failed`, d'après `res=` ou `success=`) et
|
||||
`key` (clé de la règle). La sévérité est `warning` en cas d'échec, `info` sinon ; facility
|
||||
`authpriv` ; source `audit`. Dans la liste, les lignes d'audit ont un liseré gauche violet,
|
||||
un fond légèrement teinté et un badge **AUDIT** devant l'app ; le filtre **Source** permet de
|
||||
les afficher seules.
|
||||
- **Noms** : `user` vient du format enrichi (`log_format = ENRICHED`, le défaut des versions
|
||||
récentes d'auditd), sinon du `/etc/passwd` de l'hôte s'il est monté en `/host/etc/passwd`.
|
||||
**host** est le `node=` des enregistrements (`name_format = hostname` dans `auditd.conf`),
|
||||
sinon `AUDIT_HOST`, sinon `/host/etc/hostname`, sinon `localhost`.
|
||||
- À l'activation, la dernière heure est lue d'abord (`AUDIT_BACKFILL`, `0` = seulement les
|
||||
nouveaux événements). L'inode et une position avant laquelle tout événement a été envoyé sont
|
||||
enregistrés dans `/data/audit-state.json` : après un redémarrage, la lecture reprend là ;
|
||||
après une rotation par auditd, la fin de `audit.log.1` est lue avant le nouveau fichier.
|
||||
|
||||
## Logs internes de LogStream
|
||||
|
||||
Logstream enregistre ses propres événements comme des logs, avec l'**hôte** `logstream`,
|
||||
l'**app** du composant (`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) et la
|
||||
source `logstream` : VictoriaLogs injoignable puis de retour, lots perdus, lots refusés depuis
|
||||
le tampon disque, erreurs du listener syslog, API Docker ou flux d'un conteneur décrochés,
|
||||
sources qui ne peuvent pas lire, démarrage et arrêt. Ils sont actifs par défaut à partir du
|
||||
niveau **warning** ; **Réglages > Sources > Logs internes de LogStream** permet de les couper
|
||||
ou de changer le niveau minimal (enregistré dans `/data/selflog.json`). Un même événement
|
||||
(composant et message) est enregistré au plus une fois toutes les 30 secondes, avec le nombre
|
||||
de répétitions écartées ; les événements passent par une file bornée et n'attendent jamais le
|
||||
stockage : un VictoriaLogs injoignable ne peut ni les accumuler ni les faire boucler. Tout
|
||||
continue d'aller dans `docker compose logs logstream` comme avant. Dans la liste, ces lignes
|
||||
ont un liseré gauche turquoise et un badge **LS** devant l'app ; le filtre **Source** permet de
|
||||
les afficher seules.
|
||||
|
||||
## Export CSV
|
||||
|
||||
Le bouton **Exporter** (à côté du nombre de logs) télécharge tous les logs stockés qui
|
||||
@@ -257,11 +372,15 @@ couleur, est mémorisé par navigateur.
|
||||
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
|
||||
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
|
||||
quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
|
||||
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
|
||||
- **Données** : chiffres de la base lus dans VictoriaLogs (actualisés au plus toutes les 30 s) :
|
||||
lignes stockées, taille sur disque (index compris), taille brute et taux de compression,
|
||||
période couverte avec la rétention, lignes des dernières 24 h et de la dernière heure, hôtes
|
||||
et applications distincts, espace disque libre. Les tailles sont en Ko/Mo/Go (KB/MB/GB en
|
||||
anglais). « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
|
||||
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
|
||||
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez
|
||||
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface
|
||||
peut purger : activez l'[authentification](#authentification) si l'interface est accessible
|
||||
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
|
||||
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
||||
alors purger : activez l'[authentification](#authentification) si l'interface est accessible
|
||||
à d'autres.
|
||||
|
||||
## Authentification
|
||||
@@ -270,6 +389,7 @@ couleur, est mémorisé par navigateur.
|
||||
|
||||
- **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
|
||||
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà).
|
||||
L'interface affiche alors un bandeau d'avertissement, que l'on peut fermer.
|
||||
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
|
||||
flux « authorization code » avec PKCE.
|
||||
|
||||
@@ -280,6 +400,10 @@ déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrit
|
||||
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
|
||||
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
|
||||
|
||||
Un **compte en lecture seule** optionnel, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, peut chercher,
|
||||
suivre le direct et exporter, mais pas modifier les tags, les sources ni purger : ces réglages
|
||||
sont grisés dans son interface et l'API répond `403`.
|
||||
|
||||
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
|
||||
son chemin dans `LOGIN_LOGO` :
|
||||
|
||||
@@ -320,6 +444,16 @@ l'application). Les connexions sont écrites dans les logs de logstream (`oidc:
|
||||
Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être
|
||||
joint à travers un reverse proxy TLS.
|
||||
|
||||
Pour donner un accès en lecture seule à certains utilisateurs, définissez `OIDC_ADMIN_GROUP` (par
|
||||
exemple `logstream-admins`) : seuls ses membres sont administrateurs, les autres sont en lecture
|
||||
seule. Les groupes sont lus dans la revendication `groups` du jeton d'identité
|
||||
(`OIDC_GROUPS_CLAIM` pour en utiliser une autre) ; dans Keycloak, ajoutez au client un mapper
|
||||
« Group Membership » (le `/` initial est ignoré).
|
||||
|
||||
Quel que soit le mode, chaque réponse porte des en-têtes de sécurité (Content-Security-Policy,
|
||||
X-Frame-Options…), et l'API refuse les modifications envoyées depuis un autre site (requêtes
|
||||
intersites).
|
||||
|
||||
## Noms d'hôtes (DNS inverse)
|
||||
|
||||
Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout),
|
||||
@@ -342,15 +476,20 @@ résolutions.
|
||||
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
||||
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
|
||||
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
|
||||
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | vide | compte optionnel en lecture seule (mode `local`) |
|
||||
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
|
||||
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
|
||||
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
|
||||
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur |
|
||||
| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
|
||||
| `OIDC_SCOPES` | `openid profile email` | scopes demandés |
|
||||
| `OIDC_ADMIN_GROUP` | vide | seuls les membres de ce groupe sont administrateurs, les autres en lecture seule (vide = tout le monde est administrateur) |
|
||||
| `OIDC_GROUPS_CLAIM` | `groups` | revendication du jeton d'identité qui liste les groupes |
|
||||
| `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS |
|
||||
| `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 |
|
||||
| `ALLOW_PURGE` | `false` | autoriser « Supprimer tous les logs » dans les Paramètres |
|
||||
| `SYSLOG_TCP_MAX_CONNS` | `512` | connexions syslog TCP ouvertes en même temps ; au-delà, elles sont refusées |
|
||||
| `SYSLOG_TCP_IDLE` | `30m` | une connexion syslog TCP silencieuse pendant cette durée est fermée (les émetteurs se reconnectent) |
|
||||
| `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 |
|
||||
@@ -359,15 +498,21 @@ résolutions.
|
||||
| `HOST_LOGS_GID` | `4` (adm) dans compose | groupe donné au conteneur pour lire les logs de l'hôte |
|
||||
| `HOST_LOGS_BACKFILL` | `1h` | historique lu à l'activation de la source « logs système de l'hôte » |
|
||||
| `HOST_LOGS_ROOT` | `/host` | emplacement de montage des répertoires de l'hôte |
|
||||
| `DMESG_BACKFILL` | `1h` | historique lu à l'activation de la source « messages du noyau » |
|
||||
| `DMESG_DEVICE` | `/dev/kmsg` | périphérique du log noyau lu par la source « messages du noyau » |
|
||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | chemin par défaut du journal d'audit Linux (modifiable dans Réglages > Sources) |
|
||||
| `AUDIT_BACKFILL` | `1h` | historique lu à l'activation de la source « audit Linux » |
|
||||
| `AUDIT_HOST` | vide | nom d'hôte des événements d'audit quand les enregistrements n'ont pas de `node=` |
|
||||
| `TZ` | `Europe/Paris` | fuseau horaire des horodatages RFC 3164 (qui n'en portent pas) |
|
||||
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | réglage de l'ingestion |
|
||||
| `SPOOL_MAX_MB` | `1024` | taille du tampon disque des lots refusés par VictoriaLogs (`0` = pas de tampon : 15 s de tentatives, puis perte) |
|
||||
|
||||
## Débogage
|
||||
|
||||
- `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers
|
||||
VictoriaLogs.
|
||||
- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de
|
||||
stockage.
|
||||
- La barre du bas affiche les compteurs reçus / stockés / perdus, les messages en attente dans
|
||||
le tampon disque et la dernière erreur de stockage.
|
||||
- <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes
|
||||
LogsQL.
|
||||
- API :
|
||||
@@ -418,6 +563,9 @@ Pour mettre à jour l'une d'elles :
|
||||
| `docker.go` | logs des conteneurs Docker (API, lecteurs, positions, détection du niveau) |
|
||||
| `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` |
|
||||
| `dmesg.go` | messages du noyau : lecture de `/dev/kmsg`, analyse des enregistrements, position par identifiant de démarrage et numéro de séquence |
|
||||
| `audit.go` | audit Linux : suivi de `audit.log` (rotation, position enregistrée), analyse des enregistrements, regroupement par événement, résumé |
|
||||
| `selflog.go` | logs internes de LogStream : `logEvent`, niveau minimal, limitation des répétitions, file bornée |
|
||||
| `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/*` |
|
||||
|
||||
@@ -17,7 +17,9 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
|
||||

|
||||
|
||||
- **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS
|
||||
on IP hosts), then the `Store` queue, which sends them in batches to VictoriaLogs.
|
||||
on IP hosts, without holding up the listeners), then the `Store` queue, which sends them in
|
||||
batches to VictoriaLogs. When VictoriaLogs is unreachable, batches are kept on disk
|
||||
(`/data/spool`, up to `SPOOL_MAX_MB`) and sent again, oldest first, once it is back.
|
||||
- **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the
|
||||
browsers over SSE.
|
||||
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
|
||||
@@ -90,15 +92,25 @@ width; the message takes the remaining space. Widths are remembered by the brows
|
||||
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
|
||||
two-line layout without columns.
|
||||
|
||||
## Stream and Time range modes
|
||||
|
||||
The first control of the filter bar switches between two display modes:
|
||||
|
||||
- **Stream**: the latest logs over a sliding duration (5 min to 30 days, or all), with the live
|
||||
view.
|
||||
- **Time range**: the logs between a start and an end date, typed in the time zone chosen in
|
||||
Settings. ◀ and ▶ move to the previous or next range of the same length, the magnifier doubles
|
||||
it around its middle. The live view pauses; the mode and the range are kept across reloads.
|
||||
|
||||
## Timeline
|
||||
|
||||
The timeline above the list shows the volume of logs per interval, counted by **reception
|
||||
time** on the server clock (so it matches the times shown in the rows).
|
||||
|
||||
- Hover an interval: its bounds, total and detail per severity.
|
||||
- Click a bar to zoom on that interval, or drag across several bars to zoom on the selection.
|
||||
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it);
|
||||
the list, the counters and the CSV export follow the zoom, and the live view pauses.
|
||||
- Click a bar to show that interval in Time range mode, or drag across several bars to show the
|
||||
selection; "× Back to stream" returns to Stream mode. The list, the counters and the CSV export
|
||||
follow the range.
|
||||
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new
|
||||
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
|
||||
|
||||
@@ -137,8 +149,10 @@ colored and exported like syslog messages:
|
||||
to the labels shown) help with many containers. New containers are followed automatically
|
||||
unless that option is turned off. Choices are saved per compose service (or container name)
|
||||
in `/data/docker.json`, so they survive re-creations.
|
||||
- Logstream remembers the position read in each container (`/data/docker-state.json`): after a
|
||||
restart it resumes without losing or duplicating lines. A container seen for the first time
|
||||
- Logstream remembers the position of the last line stored for each container
|
||||
(`/data/docker-state.json`): after a restart it resumes without losing lines. The position
|
||||
only moves once a line is in VictoriaLogs or in the disk buffer, and a full queue slows the
|
||||
reading down instead of dropping lines. A container seen for the first time
|
||||
is read from `DOCKER_BACKFILL` ago (1 hour by default).
|
||||
- Logstream itself and the proxy below are never collected; add the label
|
||||
`logstream.exclude=true` to any other container to exclude it for good.
|
||||
@@ -173,6 +187,98 @@ configuring anything on the host. The source is **off by default**: turn it on i
|
||||
shown as "(compressed journal entry)". Rotated or compressed text files (`*.1`, `*.gz`) are
|
||||
not read.
|
||||
|
||||
## Kernel messages (dmesg)
|
||||
|
||||
Logstream can collect the kernel ring buffer of the host (what `dmesg` shows: hardware,
|
||||
drivers, OOM killer, firewall…) by reading `/dev/kmsg`. The source is **off by default**: turn it
|
||||
on in **Settings > Sources > Kernel messages (dmesg)** (saved in `/data/dmesg.json`).
|
||||
|
||||
- **Device**: the container does not see `/dev/kmsg` by default. Uncomment the `devices`
|
||||
section of `docker-compose.yml` (`/dev/kmsg:/dev/kmsg:r`, read-only), then
|
||||
`docker compose up -d`.
|
||||
- **Permissions**: when the host has `kernel.dmesg_restrict=1` (the default on Debian and
|
||||
Ubuntu), only processes with the `CAP_SYSLOG` capability can read the kernel log, and the
|
||||
container runs as an unprivileged user. Either:
|
||||
- allow it on the host: `sysctl -w kernel.dmesg_restrict=0`, kept across reboots with
|
||||
`echo kernel.dmesg_restrict=0 | sudo tee /etc/sysctl.d/50-dmesg.conf` (every user of the
|
||||
host can then run `dmesg`); this setting cannot be changed from a container;
|
||||
- or run Logstream as root with the `SYSLOG` capability: uncomment `user: "0:0"` and
|
||||
`cap_add: [SYSLOG]` in `docker-compose.yml`.
|
||||
|
||||
Settings > Sources says when the device is missing or access is denied; the rest of
|
||||
Logstream keeps working and the device is tried again every 10 seconds.
|
||||
- **Fields**: **app** is `kernel` (or the program name for messages written by programs such
|
||||
as `systemd[1]: …`), severity and facility come from the record, **host** is `localhost`,
|
||||
`subsystem` and `device` are kept when the kernel gives them. These logs have the `kernel`
|
||||
source (`proto` `kmsg`): the **Source** filter shows them alone.
|
||||
- **Time**: records carry the time since boot; Logstream turns it into a date using the boot
|
||||
time (`/proc/uptime`). As with `dmesg -T`, the time can be early after the host was suspended.
|
||||
- When the source is turned on, the last hour is read first (`DMESG_BACKFILL`, `0` = only new
|
||||
messages). The sequence number of the last message and the boot ID are saved in
|
||||
`/data/dmesg-state.json`: after a restart of Logstream, reading resumes after the last
|
||||
message (no duplicates); after a reboot of the host, the new buffer is read from
|
||||
`DMESG_BACKFILL` ago. Messages overwritten in the kernel buffer before being read are counted
|
||||
in Settings.
|
||||
|
||||
## Linux audit (auditd)
|
||||
|
||||
Logstream can follow the log of the Linux audit daemon (`/var/log/audit/audit.log`): logins,
|
||||
`sudo`, watched files, syscalls matched by your `auditctl` rules… The source is **off by
|
||||
default**: turn it on in **Settings > Sources > Linux audit (auditd)**, where the file path can
|
||||
also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path).
|
||||
|
||||
- **Mount**: uncomment `/var/log/audit:/var/log/audit:ro` in `docker-compose.yml`, then
|
||||
`docker compose up -d`. (`/var/log` is already mounted under `/host` for the host system
|
||||
logs, so `/host/var/log/audit/audit.log` also works as the path, without a new volume.)
|
||||
- **Permissions** (host side): auditd writes its log as `root`, mode `0600`, in a `0700`
|
||||
directory, and Logstream runs as UID `10001`. The reliable way is the group the container
|
||||
already gets for the host logs (`HOST_LOGS_GID`, `adm` by default):
|
||||
```bash
|
||||
sudo sed -i 's/^log_group.*/log_group = adm/' /etc/audit/auditd.conf
|
||||
sudo chgrp adm /var/log/audit && sudo chmod 750 /var/log/audit
|
||||
sudo service auditd restart # systemctl refuses to restart auditd on some distributions
|
||||
```
|
||||
auditd then creates every rotated file with mode `0640` and group `adm`. An ACL also works
|
||||
(`sudo setfacl -m u:10001:rx /var/log/audit && sudo setfacl -m u:10001:r /var/log/audit/audit.log`)
|
||||
but must be applied again to each new file after a rotation. Settings > Sources says when the
|
||||
file is missing or access is denied; it is tried again every second.
|
||||
- **Events**: the records of one event (`SYSCALL`, `EXECVE`, `CWD`, `PATH`, `PROCTITLE`…) share
|
||||
the same `msg=audit(time:serial)` and become **one** log, once no record came for 2 seconds.
|
||||
The message is a summary — who, what, result — such as
|
||||
`cedric (as root): exec apt install nginx [exec] → success` or
|
||||
`bob: PAM:authentication via /usr/sbin/sshd from 203.0.113.9 on ssh → failed`; the raw
|
||||
records are kept in `audit_raw` and shown under the summary (folded in compact density until
|
||||
the line is opened). Hexadecimal values (`proctitle`, `cmd`, arguments with spaces) are
|
||||
decoded.
|
||||
- **Fields**: `audit_type` (type of the first record, also used as **app**), `audit_serial`,
|
||||
`auid` (login user ID), `user` (its name, or the uid when it cannot be resolved), `exe`, `res`
|
||||
(`success` / `failed`, from `res=` or `success=`) and `key` (rule key). Severity is `warning`
|
||||
when the result is a failure, `info` otherwise; facility `authpriv`; source `audit`. In the
|
||||
list, audit lines have a violet left edge, a light tint and an **AUDIT** badge before the app;
|
||||
the **Source** filter shows them alone.
|
||||
- **Names**: `user` comes from the enriched format (`log_format = ENRICHED`, the default of
|
||||
recent auditd versions), else from the host's `/etc/passwd` when it is mounted as
|
||||
`/host/etc/passwd`. **host** is the `node=` of the records (`name_format = hostname` in
|
||||
`auditd.conf`), else `AUDIT_HOST`, else `/host/etc/hostname`, else `localhost`.
|
||||
- When the source is turned on, the last hour is read first (`AUDIT_BACKFILL`, `0` = only new
|
||||
events). The inode and an offset before which every event was sent are saved in
|
||||
`/data/audit-state.json`: after a restart, reading resumes there; after a rotation by auditd,
|
||||
the end of `audit.log.1` is read before the new file.
|
||||
|
||||
## LogStream internal logs
|
||||
|
||||
Logstream records its own events as logs, with **host** `logstream`, **app** the component
|
||||
(`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) and the `logstream` source:
|
||||
VictoriaLogs unreachable and back, lost batches, batches refused from the disk buffer, syslog
|
||||
listener errors, Docker API or container streams lost, sources that cannot read, start and
|
||||
stop. They are on by default from level **warning**; **Settings > Sources > LogStream internal
|
||||
logs** turns them off or changes the minimum level (saved in `/data/selflog.json`). The same
|
||||
event (component and message) is recorded at most once every 30 seconds, with the number of
|
||||
repeats left out; events go through a bounded queue and never wait for the storage, so an
|
||||
unreachable VictoriaLogs cannot make them pile up or loop. Everything still goes to
|
||||
`docker compose logs logstream` as before. In the list, these lines have a turquoise left edge
|
||||
and an **LS** badge before the app; the **Source** filter shows them alone.
|
||||
|
||||
## CSV export
|
||||
|
||||
The **Export** button (next to the log count) downloads every stored log matching the
|
||||
@@ -233,10 +339,13 @@ remembered per browser.
|
||||
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
|
||||
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
|
||||
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
||||
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
||||
- **Data**: database figures read from VictoriaLogs (refreshed at most every 30 s): stored
|
||||
lines, size on disk (index included), raw size and compression ratio, period covered with
|
||||
the retention, lines of the last 24 h and last hour, distinct hosts and apps, free disk
|
||||
space. Sizes use KB/MB/GB (Ko/Mo/Go in French). "Delete all logs" permanently erases every stored log (you must type
|
||||
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
|
||||
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
|
||||
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI
|
||||
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
|
||||
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
|
||||
is reachable by others.
|
||||
|
||||
## Authentication
|
||||
@@ -244,7 +353,8 @@ remembered per browser.
|
||||
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
|
||||
|
||||
- **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them
|
||||
empty to have no authentication (for instance behind a reverse proxy that already checks).
|
||||
empty to have no authentication (for instance behind a reverse proxy that already checks). The
|
||||
UI then shows a warning banner, which can be closed.
|
||||
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
|
||||
authorization code flow with PKCE.
|
||||
|
||||
@@ -254,6 +364,10 @@ ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) end
|
||||
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
|
||||
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
|
||||
|
||||
An optional **read-only account**, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, can search, follow
|
||||
the live view and export, but cannot change tags, sources or purge: those settings are greyed
|
||||
out in its UI and the API answers `403`.
|
||||
|
||||
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
|
||||
|
||||
```yaml
|
||||
@@ -291,6 +405,14 @@ Every user the provider accepts for this client can log in: restrict access in t
|
||||
in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are
|
||||
only sent over HTTPS: logstream must be reached through a TLS reverse proxy.
|
||||
|
||||
To give read-only access to some users, set `OIDC_ADMIN_GROUP` (for instance
|
||||
`logstream-admins`): only its members are admins, the others are read-only. The groups are read
|
||||
from the `groups` claim of the ID token (`OIDC_GROUPS_CLAIM` to use another one); in Keycloak,
|
||||
add a "Group Membership" mapper to the client (a leading `/` is ignored).
|
||||
|
||||
Whatever the mode, every answer carries security headers (Content-Security-Policy,
|
||||
X-Frame-Options…), and the API refuses changes sent from another site (cross-site requests).
|
||||
|
||||
## Host names (reverse DNS)
|
||||
|
||||
When a device sends its IP address as host name (or no host name at all), Logstream looks up
|
||||
@@ -311,15 +433,20 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
||||
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
|
||||
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
|
||||
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | empty | optional read-only account (`local` mode) |
|
||||
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
|
||||
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
|
||||
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
|
||||
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
|
||||
| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
|
||||
| `OIDC_SCOPES` | `openid profile email` | requested scopes |
|
||||
| `OIDC_ADMIN_GROUP` | empty | only members of this group are admins, the others read-only (empty = everyone is admin) |
|
||||
| `OIDC_GROUPS_CLAIM` | `groups` | ID token claim that lists the groups |
|
||||
| `RDNS` | `on` | resolve IP hosts to DNS names |
|
||||
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
||||
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
|
||||
| `ALLOW_PURGE` | `false` | allow "Delete all logs" in Settings |
|
||||
| `SYSLOG_TCP_MAX_CONNS` | `512` | syslog TCP connections open at once; more are refused |
|
||||
| `SYSLOG_TCP_IDLE` | `30m` | a syslog TCP connection silent this long is closed (senders reconnect) |
|
||||
| `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 |
|
||||
@@ -328,13 +455,20 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
||||
| `HOST_LOGS_GID` | `4` (adm) in compose | group given to the container to read the host logs |
|
||||
| `HOST_LOGS_BACKFILL` | `1h` | history read when the host system logs source is turned on |
|
||||
| `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted |
|
||||
| `DMESG_BACKFILL` | `1h` | history read when the kernel messages source is turned on |
|
||||
| `DMESG_DEVICE` | `/dev/kmsg` | kernel log device read by the kernel messages source |
|
||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | default path of the Linux audit log (can be changed in Settings > Sources) |
|
||||
| `AUDIT_BACKFILL` | `1h` | history read when the Linux audit source is turned on |
|
||||
| `AUDIT_HOST` | empty | host name of the audit events when the records carry no `node=` |
|
||||
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
|
||||
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
|
||||
| `SPOOL_MAX_MB` | `1024` | disk buffer size for batches VictoriaLogs could not take (`0` = no buffer: retried for 15 s, then dropped) |
|
||||
|
||||
## Debugging
|
||||
|
||||
- `docker compose logs -f logstream`: receive errors and errors sending to VictoriaLogs.
|
||||
- The bottom bar shows received / stored / dropped counters and the last storage error.
|
||||
- The bottom bar shows received / stored / dropped counters, the messages waiting in the disk
|
||||
buffer, and the last storage error.
|
||||
- <http://localhost:9428/select/vmui>: VictoriaLogs' own UI to try LogsQL queries.
|
||||
- API:
|
||||
```bash
|
||||
@@ -384,6 +518,9 @@ To update one of them:
|
||||
| `docker.go` | Docker container logs (API, followers, positions, level detection) |
|
||||
| `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 |
|
||||
| `dmesg.go` | kernel messages: `/dev/kmsg` reader, record parsing, position by boot ID and sequence number |
|
||||
| `audit.go` | Linux audit: `audit.log` follower (rotation, saved offset), record parsing, grouping by event, summary |
|
||||
| `selflog.go` | LogStream internal logs: `logEvent`, minimum level, throttling, bounded queue |
|
||||
| `tags.go` | color tag storage |
|
||||
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
||||
| `api.go` | `/api/*` HTTP routes |
|
||||
|
||||
@@ -23,6 +23,10 @@ type API struct {
|
||||
docker *DockerManager // nil when DOCKER_LOGS is off
|
||||
syslog *SyslogServer
|
||||
host *HostLogs
|
||||
dmesg *Dmesg
|
||||
audit *Audit
|
||||
selfLog *SelfLog
|
||||
dbstats dbStatsCache
|
||||
}
|
||||
|
||||
func (a *API) Routes(mux *http.ServeMux) {
|
||||
@@ -32,6 +36,7 @@ func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/facets", a.facets)
|
||||
mux.HandleFunc("GET /api/stream", a.stream)
|
||||
mux.HandleFunc("GET /api/stats", a.stats)
|
||||
mux.HandleFunc("GET /api/dbstats", a.dbStats)
|
||||
mux.HandleFunc("GET /api/tags", a.listTags)
|
||||
mux.HandleFunc("POST /api/tags", a.createTag)
|
||||
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
||||
@@ -47,6 +52,72 @@ func (a *API) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
|
||||
mux.HandleFunc("GET /api/hostlogs", a.hostLogsStatus)
|
||||
mux.HandleFunc("PUT /api/hostlogs", a.hostLogsConfigure)
|
||||
mux.HandleFunc("GET /api/dmesg", a.dmesgStatus)
|
||||
mux.HandleFunc("PUT /api/dmesg", a.dmesgConfigure)
|
||||
mux.HandleFunc("GET /api/audit", a.auditStatus)
|
||||
mux.HandleFunc("PUT /api/audit", a.auditConfigure)
|
||||
mux.HandleFunc("GET /api/selflog", a.selfLogStatus)
|
||||
mux.HandleFunc("PUT /api/selflog", a.selfLogConfigure)
|
||||
}
|
||||
|
||||
// GET /api/audit: state of the Linux audit source (Settings > Sources).
|
||||
func (a *API) auditStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.audit.Status())
|
||||
}
|
||||
|
||||
// PUT /api/audit {"enabled": bool, "path": "/var/log/audit/audit.log"}
|
||||
func (a *API) auditConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg auditConfig
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.audit.Configure(cfg); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
log.Printf("audit source changed from %s: %+v", r.RemoteAddr, cfg)
|
||||
writeJSON(w, http.StatusOK, a.audit.Status())
|
||||
}
|
||||
|
||||
// GET /api/selflog: state of the LogStream internal logs (Settings > Sources).
|
||||
func (a *API) selfLogStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.selfLog.Status())
|
||||
}
|
||||
|
||||
// PUT /api/selflog {"enabled": bool, "min_level": "warning"}
|
||||
func (a *API) selfLogConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg selfLogConfig
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.selfLog.Configure(cfg); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
log.Printf("internal logs changed from %s: %+v", r.RemoteAddr, cfg)
|
||||
writeJSON(w, http.StatusOK, a.selfLog.Status())
|
||||
}
|
||||
|
||||
// GET /api/dmesg: state of the kernel messages source (Settings > Sources).
|
||||
func (a *API) dmesgStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.dmesg.Status())
|
||||
}
|
||||
|
||||
// PUT /api/dmesg {"enabled": bool}
|
||||
func (a *API) dmesgConfigure(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg dmesgConfig
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := a.dmesg.Configure(cfg); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
log.Printf("kernel messages source changed from %s: %+v", r.RemoteAddr, cfg)
|
||||
writeJSON(w, http.StatusOK, a.dmesg.Status())
|
||||
}
|
||||
|
||||
// GET /api/hostlogs: state of the host system logs source (Settings > Sources).
|
||||
@@ -314,6 +385,17 @@ func (a *API) stats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
// GET /api/dbstats[?refresh=1]: size and content of the VictoriaLogs base
|
||||
// (Settings > Data), cached for 30 s.
|
||||
func (a *API) dbStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := a.dbstats.get(r.Context(), a.store, r.URL.Query().Get("refresh") == "1")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.tags.List())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// auditConfig is saved in /data/audit.json and edited in Settings > Sources.
|
||||
type auditConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"` // empty: AUDIT_LOG (/var/log/audit/audit.log)
|
||||
}
|
||||
|
||||
// auditPos is where reading resumes: the inode of the file and an offset
|
||||
// before which every event was sent.
|
||||
type auditPos struct {
|
||||
Ino uint64 `json:"ino"`
|
||||
Off int64 `json:"off"`
|
||||
}
|
||||
|
||||
// Audit follows the log of the Linux audit daemon (auditd) and sends one
|
||||
// entry per audit event: the records of an event (SYSCALL, EXECVE, PATH…)
|
||||
// share the same msg=audit(time:serial) and are grouped together.
|
||||
type Audit struct {
|
||||
defPath string
|
||||
hostRoot string // to read the host's /etc/passwd and /etc/hostname when mounted
|
||||
hostName string // AUDIT_HOST: host name of the entries when records have no node=
|
||||
sink func(*Entry)
|
||||
backfill time.Duration
|
||||
cfgPath string
|
||||
statePath string
|
||||
wake chan struct{}
|
||||
|
||||
// Owned by the Run goroutine.
|
||||
pos auditPos // checkpoint saved in /data
|
||||
readOff int64 // where the next read starts (after the lines of open events)
|
||||
known bool
|
||||
dirty bool
|
||||
notBefore time.Time
|
||||
asm *auditAssembler
|
||||
users *userNames
|
||||
|
||||
mu sync.Mutex
|
||||
cfg auditConfig
|
||||
reset bool
|
||||
open bool
|
||||
read uint64
|
||||
errCode string
|
||||
errDetail string
|
||||
}
|
||||
|
||||
func NewAudit(defPath, hostRoot, hostName, dataDir string, backfill time.Duration, sink func(*Entry)) *Audit {
|
||||
a := &Audit{
|
||||
defPath: defPath,
|
||||
hostRoot: hostRoot,
|
||||
hostName: hostName,
|
||||
sink: sink,
|
||||
backfill: backfill,
|
||||
cfgPath: filepath.Join(dataDir, "audit.json"),
|
||||
statePath: filepath.Join(dataDir, "audit-state.json"),
|
||||
wake: make(chan struct{}, 1),
|
||||
asm: newAuditAssembler(),
|
||||
users: &userNames{path: filepath.Join(hostRoot, "etc/passwd")},
|
||||
}
|
||||
if b, err := os.ReadFile(a.cfgPath); err == nil {
|
||||
_ = json.Unmarshal(b, &a.cfg)
|
||||
}
|
||||
if b, err := os.ReadFile(a.statePath); err == nil && json.Unmarshal(b, &a.pos) == nil {
|
||||
a.known, a.readOff = true, a.pos.Off
|
||||
}
|
||||
if a.hostName == "" {
|
||||
a.hostName = readTrim(filepath.Join(hostRoot, "etc/hostname"))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Audit) path() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.cfg.Path != "" {
|
||||
return a.cfg.Path
|
||||
}
|
||||
return a.defPath
|
||||
}
|
||||
|
||||
// Run polls the file every second while the source is enabled.
|
||||
func (a *Audit) Run(ctx context.Context) {
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.emit(a.asm.flushAll())
|
||||
a.checkpoint()
|
||||
a.saveState()
|
||||
return
|
||||
case <-tick.C:
|
||||
case <-a.wake:
|
||||
}
|
||||
a.mu.Lock()
|
||||
enabled, reset := a.cfg.Enabled, a.reset
|
||||
a.reset = false
|
||||
a.mu.Unlock()
|
||||
if reset {
|
||||
// Disabled then enabled again (or another file): start over from
|
||||
// AUDIT_BACKFILL ago rather than reading everything in between.
|
||||
a.asm = newAuditAssembler()
|
||||
a.pos, a.readOff, a.known, a.dirty = auditPos{}, 0, false, true
|
||||
}
|
||||
if enabled {
|
||||
a.poll(time.Now())
|
||||
}
|
||||
if n++; n%5 == 0 || reset {
|
||||
a.saveState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Audit) saveState() {
|
||||
if !a.dirty {
|
||||
return
|
||||
}
|
||||
a.dirty = false
|
||||
if err := writeJSONFile(a.statePath, a.pos); err != nil {
|
||||
logEvent("audit", 4, "saving position: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Audit) fail(err error) {
|
||||
code := "read"
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
code = "not_found"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
code = "permission"
|
||||
}
|
||||
a.mu.Lock()
|
||||
changed := code != a.errCode
|
||||
a.open, a.errCode, a.errDetail = false, code, err.Error()
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
logEvent("audit", 4, "cannot read the audit log: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const maxAuditRead = 4 << 20 // per poll
|
||||
|
||||
// poll reads the lines added since the previous one, follows rotations and
|
||||
// sends the events that are complete.
|
||||
func (a *Audit) poll(now time.Time) {
|
||||
path := a.path()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
a.fail(err)
|
||||
return
|
||||
}
|
||||
ino := inodeOf(fi)
|
||||
if !a.known {
|
||||
// First read: the last AUDIT_BACKFILL (0: only new events).
|
||||
a.pos, a.readOff, a.known, a.dirty = auditPos{Ino: ino}, 0, true, true
|
||||
a.notBefore = time.Time{}
|
||||
if a.backfill <= 0 {
|
||||
a.readOff = fi.Size()
|
||||
} else {
|
||||
a.notBefore = now.Add(-a.backfill)
|
||||
}
|
||||
}
|
||||
if a.pos.Ino != ino {
|
||||
// Rotated by auditd (audit.log -> audit.log.1): finish the old file,
|
||||
// then read the new one from its start.
|
||||
if old, err := os.Stat(path + ".1"); err == nil && inodeOf(old) == a.pos.Ino {
|
||||
for {
|
||||
off, more, err := a.readFrom(path+".1", a.readOff, now)
|
||||
if err != nil || !more {
|
||||
break
|
||||
}
|
||||
a.readOff = off
|
||||
}
|
||||
}
|
||||
a.emit(a.asm.flushAll())
|
||||
a.pos, a.readOff, a.dirty = auditPos{Ino: ino}, 0, true
|
||||
} else if fi.Size() < a.readOff {
|
||||
a.emit(a.asm.flushAll()) // truncated
|
||||
a.readOff = 0
|
||||
}
|
||||
if fi.Size() > a.readOff {
|
||||
off, _, err := a.readFrom(path, a.readOff, now)
|
||||
if err != nil {
|
||||
a.fail(err)
|
||||
return
|
||||
}
|
||||
a.readOff = off
|
||||
}
|
||||
a.mu.Lock()
|
||||
if !a.open {
|
||||
a.open, a.errCode, a.errDetail = true, "", ""
|
||||
}
|
||||
a.mu.Unlock()
|
||||
a.emit(a.asm.flush(now, auditCloseDelay))
|
||||
a.checkpoint()
|
||||
}
|
||||
|
||||
// checkpoint saves an offset before which every event is sent: the start of
|
||||
// the oldest event still open, or the read offset.
|
||||
func (a *Audit) checkpoint() {
|
||||
off := a.readOff
|
||||
if o, ok := a.asm.minOff(); ok && o < off {
|
||||
off = o
|
||||
}
|
||||
if off != a.pos.Off {
|
||||
a.pos.Off, a.dirty = off, true
|
||||
}
|
||||
}
|
||||
|
||||
// readFrom feeds the complete lines written after off to the assembler and
|
||||
// returns the new offset; more tells whether the read window was full.
|
||||
func (a *Audit) readFrom(path string, off int64, now time.Time) (int64, bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return off, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
buf, err := io.ReadAll(io.NewSectionReader(f, off, maxAuditRead))
|
||||
if err != nil {
|
||||
return off, false, err
|
||||
}
|
||||
full := len(buf) == maxAuditRead
|
||||
end := bytes.LastIndexByte(buf, '\n')
|
||||
if end < 0 {
|
||||
if !full {
|
||||
return off, false, nil // partial line: wait for its end
|
||||
}
|
||||
end = len(buf) - 1
|
||||
}
|
||||
start := off
|
||||
for _, line := range bytes.Split(buf[:end], []byte{'\n'}) {
|
||||
a.emit(a.asm.add(string(line), start, now))
|
||||
start += int64(len(line)) + 1
|
||||
}
|
||||
return off + int64(end) + 1, full, nil
|
||||
}
|
||||
|
||||
func (a *Audit) emit(evs []*auditEvent) {
|
||||
for _, ev := range evs {
|
||||
if !a.notBefore.IsZero() && ev.Time.Before(a.notBefore) {
|
||||
continue
|
||||
}
|
||||
a.sink(auditEntry(ev, a.users, a.hostName, time.Now()))
|
||||
a.mu.Lock()
|
||||
a.read++
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func inodeOf(fi os.FileInfo) uint64 {
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
return uint64(st.Ino)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Configure saves and applies the configuration.
|
||||
func (a *Audit) Configure(cfg auditConfig) error {
|
||||
cfg.Path = strings.TrimSpace(cfg.Path)
|
||||
a.mu.Lock()
|
||||
if (a.cfg.Enabled && !cfg.Enabled) || cfg.Path != a.cfg.Path {
|
||||
a.reset = true
|
||||
a.open, a.errCode, a.errDetail = false, "", ""
|
||||
}
|
||||
a.cfg = cfg
|
||||
a.mu.Unlock()
|
||||
select {
|
||||
case a.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return writeJSONFile(a.cfgPath, cfg)
|
||||
}
|
||||
|
||||
// Status is the state shown in Settings > Sources.
|
||||
func (a *Audit) Status() map[string]any {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return map[string]any{
|
||||
"enabled": a.cfg.Enabled,
|
||||
"path": a.cfg.Path,
|
||||
"defaultPath": a.defPath,
|
||||
"open": a.open && a.cfg.Enabled,
|
||||
"read": a.read,
|
||||
"code": a.errCode,
|
||||
"error": a.errDetail,
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Records and events ---------- */
|
||||
|
||||
// auditCloseDelay: an event is complete when no line of it came for this
|
||||
// long, or when the log has moved this far past its time (auditd does not
|
||||
// write the EOE record that ends multi-record events).
|
||||
const auditCloseDelay = 2 * time.Second
|
||||
|
||||
type auditField struct {
|
||||
k, v string
|
||||
bare bool // written without quotes: may be hexadecimal
|
||||
}
|
||||
|
||||
type auditRecord struct {
|
||||
Type string
|
||||
Fields []auditField
|
||||
Raw string
|
||||
}
|
||||
|
||||
type auditEvent struct {
|
||||
ID string // "1700000000.123:456"
|
||||
Serial string
|
||||
Time time.Time
|
||||
Node string
|
||||
Records []auditRecord
|
||||
off int64 // offset of its first line in the file
|
||||
seen time.Time // when its last line was read
|
||||
order int
|
||||
}
|
||||
|
||||
// parseAuditLine decodes "[node=N ]type=T msg=audit(SEC.MS:SERIAL): k=v …".
|
||||
func parseAuditLine(line string) (rec auditRecord, id, node string, ok bool) {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
rec.Raw = line
|
||||
head, rest, found := strings.Cut(line, "): ")
|
||||
if !found {
|
||||
head, found = strings.CutSuffix(line, ")")
|
||||
if !found {
|
||||
return rec, "", "", false
|
||||
}
|
||||
}
|
||||
pre, ts, found := strings.Cut(head, "msg=audit(")
|
||||
if !found || ts == "" {
|
||||
return rec, "", "", false
|
||||
}
|
||||
for _, f := range parseAuditKV(pre) {
|
||||
switch f.k {
|
||||
case "type":
|
||||
rec.Type = f.v
|
||||
case "node":
|
||||
node = f.v
|
||||
}
|
||||
}
|
||||
if rec.Type == "" {
|
||||
return rec, "", "", false
|
||||
}
|
||||
// Enriched format (log_format=ENRICHED): resolved values after a GS byte.
|
||||
main, enriched, _ := strings.Cut(rest, "\x1d")
|
||||
rec.Fields = parseAuditKV(main)
|
||||
for _, f := range parseAuditKV(enriched) {
|
||||
rec.Fields = append(rec.Fields, auditField{k: "@" + f.k, v: f.v})
|
||||
}
|
||||
return rec, ts, node, true
|
||||
}
|
||||
|
||||
// hexFields are the fields auditd writes in hexadecimal when they contain
|
||||
// spaces, quotes or control characters (and then without quotes).
|
||||
var hexFields = map[string]bool{
|
||||
"proctitle": true, "cmd": true, "exe": true, "comm": true, "key": true, "acct": true,
|
||||
"name": true, "cwd": true, "data": true, "path": true, "old": true, "new": true,
|
||||
}
|
||||
|
||||
// parseAuditKV splits "k=v k2="v 2" msg='k3=v3 …'"; the fields of a quoted
|
||||
// msg='…' (user space records) follow the outer ones.
|
||||
func parseAuditKV(s string) []auditField {
|
||||
var out []auditField
|
||||
var inner []auditField
|
||||
for i := 0; i < len(s); {
|
||||
for i < len(s) && s[i] == ' ' {
|
||||
i++
|
||||
}
|
||||
eq := strings.IndexByte(s[i:], '=')
|
||||
if eq <= 0 {
|
||||
break
|
||||
}
|
||||
k := s[i : i+eq]
|
||||
if sp := strings.IndexByte(k, ' '); sp >= 0 { // word without value
|
||||
i += sp + 1
|
||||
continue
|
||||
}
|
||||
i += eq + 1
|
||||
var v string
|
||||
quoted := i < len(s) && (s[i] == '"' || s[i] == '\'')
|
||||
if quoted {
|
||||
q := s[i]
|
||||
end := strings.IndexByte(s[i+1:], q)
|
||||
if end < 0 {
|
||||
end = len(s) - i - 1
|
||||
}
|
||||
v = s[i+1 : i+1+end]
|
||||
i += end + 2
|
||||
} else {
|
||||
end := strings.IndexByte(s[i:], ' ')
|
||||
if end < 0 {
|
||||
end = len(s) - i
|
||||
}
|
||||
v = s[i : i+end]
|
||||
i += end
|
||||
}
|
||||
if k == "msg" && quoted && strings.Contains(v, "=") {
|
||||
inner = append(inner, parseAuditKV(v)...)
|
||||
continue
|
||||
}
|
||||
if !quoted && hexFields[k] {
|
||||
v = decodeHex(k, v)
|
||||
}
|
||||
if !quoted && (v == "(null)" || v == "?") {
|
||||
v = ""
|
||||
}
|
||||
out = append(out, auditField{k: k, v: v, bare: !quoted})
|
||||
}
|
||||
return append(out, inner...)
|
||||
}
|
||||
|
||||
// decodeHex decodes a hexadecimal value, or returns it unchanged.
|
||||
func decodeHex(k, v string) string {
|
||||
if len(v) < 2 || len(v)%2 != 0 {
|
||||
return v
|
||||
}
|
||||
b, err := hex.DecodeString(v)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
if k == "proctitle" {
|
||||
b = bytes.ReplaceAll(bytes.TrimRight(b, "\x00"), []byte{0}, []byte{' '})
|
||||
}
|
||||
if !utf8.Valid(b) {
|
||||
return v
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// parseAuditTime decodes "1700000000.123:456" into a time and a serial.
|
||||
func parseAuditTime(id string) (time.Time, string) {
|
||||
ts, serial, _ := strings.Cut(id, ":")
|
||||
sec, frac, _ := strings.Cut(ts, ".")
|
||||
s, err := strconv.ParseInt(sec, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, serial
|
||||
}
|
||||
ms, _ := strconv.ParseInt((frac + "000")[:3], 10, 64)
|
||||
return time.Unix(s, ms*int64(time.Millisecond)), serial
|
||||
}
|
||||
|
||||
// auditAssembler groups the records of each event.
|
||||
type auditAssembler struct {
|
||||
open map[string]*auditEvent
|
||||
newest time.Time // most recent event time read
|
||||
n int
|
||||
}
|
||||
|
||||
func newAuditAssembler() *auditAssembler {
|
||||
return &auditAssembler{open: map[string]*auditEvent{}}
|
||||
}
|
||||
|
||||
const maxOpenAudit = 2000
|
||||
|
||||
// add takes one line read at offset off and returns the events it completes
|
||||
// (EOE record, or too many events open).
|
||||
func (a *auditAssembler) add(line string, off int64, now time.Time) []*auditEvent {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return nil
|
||||
}
|
||||
rec, id, node, ok := parseAuditLine(line)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ev := a.open[id]
|
||||
if rec.Type == "EOE" {
|
||||
if ev == nil {
|
||||
return nil
|
||||
}
|
||||
delete(a.open, id)
|
||||
return []*auditEvent{ev}
|
||||
}
|
||||
if ev == nil {
|
||||
t, serial := parseAuditTime(id)
|
||||
a.n++
|
||||
ev = &auditEvent{ID: id, Serial: serial, Time: t, Node: node, off: off, order: a.n}
|
||||
a.open[id] = ev
|
||||
if t.After(a.newest) {
|
||||
a.newest = t
|
||||
}
|
||||
}
|
||||
ev.Records = append(ev.Records, rec)
|
||||
ev.seen = now
|
||||
if len(a.open) > maxOpenAudit {
|
||||
return a.closeOldest(len(a.open) - maxOpenAudit/2)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flush returns the events with no new line for delay, or that the log has
|
||||
// left delay behind, in the order they started.
|
||||
func (a *auditAssembler) flush(now time.Time, delay time.Duration) []*auditEvent {
|
||||
var out []*auditEvent
|
||||
for id, ev := range a.open {
|
||||
if now.Sub(ev.seen) >= delay || a.newest.Sub(ev.Time) >= delay {
|
||||
out = append(out, ev)
|
||||
delete(a.open, id)
|
||||
}
|
||||
}
|
||||
sortEvents(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *auditAssembler) flushAll() []*auditEvent {
|
||||
return a.closeOldest(len(a.open))
|
||||
}
|
||||
|
||||
func (a *auditAssembler) closeOldest(n int) []*auditEvent {
|
||||
all := make([]*auditEvent, 0, len(a.open))
|
||||
for _, ev := range a.open {
|
||||
all = append(all, ev)
|
||||
}
|
||||
sortEvents(all)
|
||||
all = all[:n]
|
||||
for _, ev := range all {
|
||||
delete(a.open, ev.ID)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func sortEvents(evs []*auditEvent) {
|
||||
sort.Slice(evs, func(i, j int) bool { return evs[i].order < evs[j].order })
|
||||
}
|
||||
|
||||
func (a *auditAssembler) minOff() (int64, bool) {
|
||||
var m int64
|
||||
ok := false
|
||||
for _, ev := range a.open {
|
||||
if !ok || ev.off < m {
|
||||
m, ok = ev.off, true
|
||||
}
|
||||
}
|
||||
return m, ok
|
||||
}
|
||||
|
||||
/* ---------- Entries ---------- */
|
||||
|
||||
// get returns the first non-empty value of a field across the records.
|
||||
func (ev *auditEvent) get(k string) string {
|
||||
for _, r := range ev.Records {
|
||||
for _, f := range r.Fields {
|
||||
if f.k == k && f.v != "" {
|
||||
return f.v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ev *auditEvent) record(typ string) *auditRecord {
|
||||
for i := range ev.Records {
|
||||
if ev.Records[i].Type == typ {
|
||||
return &ev.Records[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// result is "success", "failed" or "" from res= or success=.
|
||||
func (ev *auditEvent) result() string {
|
||||
v := strings.ToLower(firstNonEmpty(ev.get("res"), ev.get("success"), ev.get("result")))
|
||||
switch v {
|
||||
case "success", "yes", "1":
|
||||
return "success"
|
||||
case "failed", "fail", "failure", "no", "0":
|
||||
return "failed"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const auditUnset = "4294967295"
|
||||
|
||||
// userOf resolves a uid: enriched name, then the host's /etc/passwd, then the number.
|
||||
func (ev *auditEvent) userOf(field string, users *userNames) string {
|
||||
uid := ev.get(field)
|
||||
if uid == "" || uid == auditUnset || uid == "-1" {
|
||||
return ""
|
||||
}
|
||||
if n := strings.Trim(ev.get("@"+strings.ToUpper(field)), `"`); n != "" && n != "unset" && n != uid {
|
||||
return n
|
||||
}
|
||||
if n := users.name(uid); n != "" {
|
||||
return n
|
||||
}
|
||||
return uid
|
||||
}
|
||||
|
||||
// auditEntry turns an event into an Entry: app is the type of its first record.
|
||||
func auditEntry(ev *auditEvent, users *userNames, hostName string, now time.Time) *Entry {
|
||||
typ := "UNKNOWN"
|
||||
if len(ev.Records) > 0 {
|
||||
typ = ev.Records[0].Type
|
||||
}
|
||||
res := ev.result()
|
||||
auid := ev.get("auid")
|
||||
user := ev.userOf("auid", users)
|
||||
if user == "" {
|
||||
user = ev.userOf("uid", users)
|
||||
}
|
||||
sev := 6
|
||||
if res == "failed" {
|
||||
sev = 4
|
||||
}
|
||||
raw := make([]string, len(ev.Records))
|
||||
for i, r := range ev.Records {
|
||||
raw[i] = r.Raw
|
||||
}
|
||||
host := firstNonEmpty(ev.Node, hostName, "localhost")
|
||||
e := &Entry{
|
||||
Time: ev.Time,
|
||||
Received: now,
|
||||
Host: host,
|
||||
App: typ,
|
||||
ProcID: ev.get("pid"),
|
||||
Facility: "authpriv",
|
||||
Severity: severityNames[sev],
|
||||
SevNum: sev,
|
||||
Message: auditSummary(ev, typ, user, users, res),
|
||||
Proto: "file",
|
||||
SourceType: "audit",
|
||||
Extra: map[string]string{
|
||||
"audit_type": typ,
|
||||
"audit_serial": ev.Serial,
|
||||
"auid": auid,
|
||||
"user": user,
|
||||
"exe": ev.get("exe"),
|
||||
"res": res,
|
||||
"key": ev.get("key"),
|
||||
"audit_raw": strings.Join(raw, "\n"),
|
||||
},
|
||||
Wait: true,
|
||||
}
|
||||
if e.Time.IsZero() {
|
||||
e.Time = now
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// auditSummary writes "who: what [key] → result".
|
||||
func auditSummary(ev *auditEvent, typ, user string, users *userNames, res string) string {
|
||||
who := firstNonEmpty(user, "?")
|
||||
// Syscalls: the login user and, when different, the user the process ran
|
||||
// as (sudo, su). In user space records, uid is the daemon's (sshd: root).
|
||||
if uid := ev.userOf("uid", users); ev.get("syscall") != "" && uid != "" && user != "" && uid != user {
|
||||
who += " (as " + uid + ")"
|
||||
}
|
||||
var what string
|
||||
exe := ev.get("exe")
|
||||
switch {
|
||||
case typ == "USER_CMD":
|
||||
what = "sudo " + firstNonEmpty(ev.get("cmd"), "?")
|
||||
if cwd := ev.get("cwd"); cwd != "" {
|
||||
what += " (in " + cwd + ")"
|
||||
}
|
||||
case strings.HasPrefix(typ, "USER_") || strings.HasPrefix(typ, "CRED_") || typ == "LOGIN" ||
|
||||
typ == "ADD_USER" || typ == "DEL_USER" || typ == "ADD_GROUP" || typ == "DEL_GROUP":
|
||||
if acct := ev.get("acct"); acct != "" {
|
||||
who = acct
|
||||
}
|
||||
what = firstNonEmpty(ev.get("op"), strings.ToLower(typ))
|
||||
if exe != "" {
|
||||
what += " via " + exe
|
||||
}
|
||||
if addr := firstNonEmpty(ev.get("hostname"), ev.get("addr")); addr != "" {
|
||||
what += " from " + addr
|
||||
}
|
||||
if term := ev.get("terminal"); term != "" && term != "?" {
|
||||
what += " on " + term
|
||||
}
|
||||
case ev.record("EXECVE") != nil:
|
||||
what = "exec " + execLine(ev.record("EXECVE"))
|
||||
case ev.get("syscall") != "":
|
||||
what = "syscall " + firstNonEmpty(ev.get("@SYSCALL"), ev.get("syscall"))
|
||||
if name := pathName(ev); name != "" {
|
||||
what += " " + name
|
||||
}
|
||||
if prog := firstNonEmpty(exe, ev.get("comm")); prog != "" {
|
||||
what += " by " + prog
|
||||
}
|
||||
default:
|
||||
what = strings.ToLower(typ)
|
||||
if op := ev.get("op"); op != "" {
|
||||
what += " " + op
|
||||
}
|
||||
if exe != "" {
|
||||
what += " by " + exe
|
||||
}
|
||||
}
|
||||
if key := ev.get("key"); key != "" {
|
||||
what += " [" + key + "]"
|
||||
}
|
||||
if res != "" {
|
||||
what += " → " + res
|
||||
}
|
||||
return who + ": " + what
|
||||
}
|
||||
|
||||
// execLine joins the arguments of an EXECVE record (a0, a1…).
|
||||
func execLine(r *auditRecord) string {
|
||||
args := map[int]string{}
|
||||
argc := 0
|
||||
for _, f := range r.Fields {
|
||||
if f.k == "argc" {
|
||||
argc, _ = strconv.Atoi(f.v)
|
||||
} else if len(f.k) > 1 && f.k[0] == 'a' && isDigits(f.k[1:]) {
|
||||
n, _ := strconv.Atoi(f.k[1:])
|
||||
if f.bare {
|
||||
args[n] = decodeHex(f.k, f.v) // arguments with spaces or quotes
|
||||
} else {
|
||||
args[n] = f.v
|
||||
}
|
||||
}
|
||||
}
|
||||
if argc == 0 {
|
||||
argc = len(args)
|
||||
}
|
||||
parts := make([]string, 0, argc)
|
||||
for i := 0; i < argc; i++ {
|
||||
if v, ok := args[i]; ok {
|
||||
parts = append(parts, v)
|
||||
}
|
||||
}
|
||||
s := strings.Join(parts, " ")
|
||||
if len(s) > 300 {
|
||||
s = s[:300] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pathName is the file a syscall worked on: the first PATH record that is not
|
||||
// its parent directory.
|
||||
func pathName(ev *auditEvent) string {
|
||||
var first string
|
||||
for _, r := range ev.Records {
|
||||
if r.Type != "PATH" {
|
||||
continue
|
||||
}
|
||||
var name, nametype string
|
||||
for _, f := range r.Fields {
|
||||
switch f.k {
|
||||
case "name":
|
||||
name = f.v
|
||||
case "nametype":
|
||||
nametype = f.v
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if nametype != "PARENT" {
|
||||
return name
|
||||
}
|
||||
if first == "" {
|
||||
first = name
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
/* ---------- User names ---------- */
|
||||
|
||||
// userNames reads uid -> name from the host's /etc/passwd when it is mounted,
|
||||
// at most once a minute.
|
||||
type userNames struct {
|
||||
path string
|
||||
names map[string]string
|
||||
loaded time.Time
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func (u *userNames) name(uid string) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
if time.Since(u.loaded) > time.Minute {
|
||||
u.loaded = time.Now()
|
||||
if fi, err := os.Stat(u.path); err == nil && !fi.ModTime().Equal(u.modTime) {
|
||||
u.modTime = fi.ModTime()
|
||||
u.names = readPasswd(u.path)
|
||||
}
|
||||
}
|
||||
return u.names[uid]
|
||||
}
|
||||
|
||||
func readPasswd(path string) map[string]string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
names := map[string]string{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
p := strings.Split(sc.Text(), ":")
|
||||
if len(p) >= 3 && p[0] != "" && !strings.HasPrefix(p[0], "#") {
|
||||
names[p[2]] = p[0]
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func hexOf(s string) string { return strings.ToUpper(hex.EncodeToString([]byte(s))) }
|
||||
|
||||
// Lines of real audit.log events (trimmed).
|
||||
var (
|
||||
auditExec = []string{
|
||||
`type=SYSCALL msg=audit(1759572000.123:4242): arch=c000003e syscall=59 success=yes exit=0 a0=4142 a1=55d2 a2=55d3 a3=0 items=2 ppid=1000 pid=1234 auid=1000 uid=0 gid=0 euid=0 tty=pts0 ses=3 comm="apt" exe="/usr/bin/apt" subj=unconfined key="exec"`,
|
||||
`type=EXECVE msg=audit(1759572000.123:4242): argc=3 a0="apt" a1="install" a2=` + hexOf(`my "pkg"`),
|
||||
`type=CWD msg=audit(1759572000.123:4242): cwd="/root"`,
|
||||
`type=PATH msg=audit(1759572000.123:4242): item=0 name="/usr/bin/apt" inode=1 dev=fd:00 mode=0100755 ouid=0 ogid=0 nametype=NORMAL`,
|
||||
`type=PROCTITLE msg=audit(1759572000.123:4242): proctitle=` + hexOf("apt\x00install\x00my \"pkg\""),
|
||||
}
|
||||
auditSudo = `type=USER_CMD msg=audit(1759572001.500:4250): pid=2000 uid=1000 auid=1000 ses=3 subj=unconfined msg='cwd="/home/cedric" cmd=` + hexOf("systemctl restart nginx") + ` exe="/usr/bin/sudo" terminal=pts/0 res=success'`
|
||||
auditSSH = `type=USER_AUTH msg=audit(1759572002.000:4260): pid=3000 uid=0 auid=4294967295 ses=4294967295 subj=unconfined msg='op=PAM:authentication grantors=? acct="bob" exe="/usr/sbin/sshd" hostname=203.0.113.9 addr=203.0.113.9 terminal=ssh res=failed'`
|
||||
auditOpen = []string{
|
||||
"node=web1 type=SYSCALL msg=audit(1759572003.250:4270): arch=c000003e syscall=257 success=no exit=-13 items=1 pid=4000 auid=1001 uid=1001 comm=\"cat\" exe=\"/usr/bin/cat\" key=\"shadow\"\x1dARCH=x86_64 SYSCALL=openat AUID=\"alice\" UID=\"alice\"",
|
||||
`node=web1 type=PATH msg=audit(1759572003.250:4270): item=0 name="/etc/shadow" inode=7 nametype=NORMAL`,
|
||||
}
|
||||
)
|
||||
|
||||
func TestParseAuditLine(t *testing.T) {
|
||||
rec, id, node, ok := parseAuditLine(auditOpen[0])
|
||||
if !ok || rec.Type != "SYSCALL" || id != "1759572003.250:4270" || node != "web1" {
|
||||
t.Fatalf("got %+v %q %q %v", rec, id, node, ok)
|
||||
}
|
||||
ev := &auditEvent{Records: []auditRecord{rec}}
|
||||
for k, want := range map[string]string{"syscall": "257", "exe": "/usr/bin/cat", "key": "shadow", "@SYSCALL": "openat", "@AUID": "alice"} {
|
||||
if got := ev.get(k); got != want {
|
||||
t.Errorf("%s = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "garbage", "type=X no header", "msg=audit(1.2:3): no type"} {
|
||||
if _, _, _, ok := parseAuditLine(bad); ok {
|
||||
t.Errorf("%q parsed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuditKV(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want map[string]string
|
||||
}{
|
||||
{`a=1 b="two words" c=`, map[string]string{"a": "1", "b": "two words", "c": ""}},
|
||||
{`pid=1 msg='op=login acct="bob" res=success'`, map[string]string{"pid": "1", "op": "login", "acct": "bob", "res": "success"}},
|
||||
{`comm=` + hexOf("my prog") + ` exe="/bin/x"`, map[string]string{"comm": "my prog", "exe": "/bin/x"}},
|
||||
{`key=(null) hostname=? terminal=?`, map[string]string{"key": "", "hostname": "", "terminal": ""}},
|
||||
{`a0=4142 name=ABC`, map[string]string{"a0": "4142", "name": "ABC"}}, // not hexadecimal fields, or odd length
|
||||
{`avc: denied { read } for pid=12 comm="x"`, map[string]string{"pid": "12", "comm": "x"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := map[string]string{}
|
||||
for _, f := range parseAuditKV(tt.in) {
|
||||
got[f.k] = f.v
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%q: %s = %q, want %q (all: %v)", tt.in, k, got[k], v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditAssembler(t *testing.T) {
|
||||
now := time.Unix(1759572010, 0)
|
||||
a := newAuditAssembler()
|
||||
// Two interleaved events, then an EOE that closes the first one.
|
||||
lines := []string{auditExec[0], auditSudo, auditExec[1], auditExec[2]}
|
||||
off := int64(0)
|
||||
for _, l := range lines {
|
||||
if out := a.add(l, off, now); out != nil {
|
||||
t.Fatalf("closed early: %v", out)
|
||||
}
|
||||
off += int64(len(l)) + 1
|
||||
}
|
||||
if len(a.open) != 2 {
|
||||
t.Fatalf("%d open events", len(a.open))
|
||||
}
|
||||
if o, ok := a.minOff(); !ok || o != 0 {
|
||||
t.Fatalf("minOff %d %v", o, ok)
|
||||
}
|
||||
out := a.add(`type=EOE msg=audit(1759572000.123:4242): `, off, now)
|
||||
if len(out) != 1 || out[0].Serial != "4242" || len(out[0].Records) != 3 {
|
||||
t.Fatalf("EOE: %+v", out)
|
||||
}
|
||||
if o, _ := a.minOff(); o != int64(len(lines[0])+1) {
|
||||
t.Fatalf("minOff after EOE: %d", o)
|
||||
}
|
||||
// An EOE of an unknown event is ignored.
|
||||
if out := a.add(`type=EOE msg=audit(1.0:1): `, 0, now); out != nil {
|
||||
t.Fatal(out)
|
||||
}
|
||||
// The sudo event is closed by time.
|
||||
if out := a.flush(now.Add(time.Second), 2*time.Second); len(out) != 0 {
|
||||
t.Fatalf("closed too early: %v", out)
|
||||
}
|
||||
out = a.flush(now.Add(3*time.Second), 2*time.Second)
|
||||
if len(out) != 1 || out[0].Records[0].Type != "USER_CMD" {
|
||||
t.Fatalf("flush: %+v", out)
|
||||
}
|
||||
// Events the log has left behind are closed without waiting.
|
||||
a.add(auditSSH, 0, now)
|
||||
a.add(auditOpen[0], 0, now)
|
||||
out = a.flush(now, 1*time.Second)
|
||||
if len(out) != 1 || out[0].Serial != "4260" || len(a.open) != 1 {
|
||||
t.Fatalf("newest: %+v, open %d", out, len(a.open))
|
||||
}
|
||||
if out = a.flushAll(); len(out) != 1 || out[0].Serial != "4270" || len(a.open) != 0 {
|
||||
t.Fatalf("flushAll: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func auditEv(t *testing.T, lines ...string) *auditEvent {
|
||||
t.Helper()
|
||||
a := newAuditAssembler()
|
||||
for _, l := range lines {
|
||||
a.add(l, 0, time.Time{})
|
||||
}
|
||||
out := a.flushAll()
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("%d events", len(out))
|
||||
}
|
||||
return out[0]
|
||||
}
|
||||
|
||||
func TestAuditEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
passwd := filepath.Join(dir, "passwd")
|
||||
if err := os.WriteFile(passwd, []byte("root:x:0:0::/root:/bin/sh\ncedric:x:1000:1000::/home/cedric:/bin/bash\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1759572100, 0)
|
||||
tests := []struct {
|
||||
name string
|
||||
lines []string
|
||||
host string
|
||||
app string
|
||||
sev string
|
||||
msg string
|
||||
fields map[string]string
|
||||
}{
|
||||
{"exec", auditExec, "srv", "SYSCALL", "info",
|
||||
`cedric (as root): exec apt install my "pkg" [exec] → success`,
|
||||
map[string]string{"audit_type": "SYSCALL", "audit_serial": "4242", "auid": "1000", "user": "cedric", "exe": "/usr/bin/apt", "res": "success", "key": "exec"}},
|
||||
{"sudo", []string{auditSudo}, "srv", "USER_CMD", "info",
|
||||
"cedric: sudo systemctl restart nginx (in /home/cedric) → success",
|
||||
map[string]string{"user": "cedric", "exe": "/usr/bin/sudo", "res": "success", "key": ""}},
|
||||
{"ssh failed", []string{auditSSH}, "srv", "USER_AUTH", "warning",
|
||||
"bob: PAM:authentication via /usr/sbin/sshd from 203.0.113.9 on ssh → failed",
|
||||
map[string]string{"auid": "4294967295", "user": "root", "res": "failed"}},
|
||||
{"enriched, node", auditOpen, "web1", "SYSCALL", "warning",
|
||||
"alice: syscall openat /etc/shadow by /usr/bin/cat [shadow] → failed",
|
||||
map[string]string{"user": "alice", "auid": "1001", "res": "failed", "key": "shadow"}},
|
||||
{"unknown uid", []string{`type=USER_LOGIN msg=audit(1759572004.000:4280): pid=1 uid=0 auid=4242 ses=9 msg='op=login id=4242 exe="/usr/sbin/sshd" hostname=? addr=10.0.0.5 terminal=/dev/pts/1 res=success'`},
|
||||
"srv", "USER_LOGIN", "info", "4242: login via /usr/sbin/sshd from 10.0.0.5 on /dev/pts/1 → success",
|
||||
map[string]string{"user": "4242"}},
|
||||
{"no result", []string{`type=CONFIG_CHANGE msg=audit(1759572005.000:4290): auid=1000 ses=3 op=add_rule key="watch" list=4`},
|
||||
"srv", "CONFIG_CHANGE", "info", "cedric: config_change add_rule [watch]",
|
||||
map[string]string{"res": ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev := auditEv(t, tt.lines...)
|
||||
e := auditEntry(ev, &userNames{path: passwd}, "srv", now)
|
||||
if e.Host != tt.host || e.App != tt.app || e.Severity != tt.sev || e.SourceType != "audit" || !e.Wait {
|
||||
t.Errorf("entry %+v", e)
|
||||
}
|
||||
if e.Message != tt.msg {
|
||||
t.Errorf("message\n got %q\nwant %q", e.Message, tt.msg)
|
||||
}
|
||||
for k, v := range tt.fields {
|
||||
if e.Extra[k] != v {
|
||||
t.Errorf("%s = %q, want %q", k, e.Extra[k], v)
|
||||
}
|
||||
}
|
||||
if e.Extra["audit_raw"] != strings.Join(tt.lines, "\n") {
|
||||
t.Errorf("raw %q", e.Extra["audit_raw"])
|
||||
}
|
||||
if !e.Time.Equal(ev.Time) || e.Time.IsZero() {
|
||||
t.Errorf("time %v", e.Time)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type entrySink struct {
|
||||
mu sync.Mutex
|
||||
es []*Entry
|
||||
}
|
||||
|
||||
func (s *entrySink) add(e *Entry) {
|
||||
s.mu.Lock()
|
||||
s.es = append(s.es, e)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *entrySink) serials() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []string
|
||||
for _, e := range s.es {
|
||||
out = append(out, e.Extra["audit_serial"])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendLines(t *testing.T, path string, lines ...string) {
|
||||
t.Helper()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
for _, l := range lines {
|
||||
if _, err := f.WriteString(l + "\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditFollow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "audit.log")
|
||||
data := filepath.Join(dir, "data")
|
||||
if err := os.Mkdir(data, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sink := &entrySink{}
|
||||
newAuditT := func() *Audit {
|
||||
a := NewAudit(logPath, dir, "srv", data, 100*365*24*time.Hour, sink.add)
|
||||
a.cfg.Enabled = true
|
||||
return a
|
||||
}
|
||||
a := newAuditT()
|
||||
now := time.Unix(1759572100, 0)
|
||||
|
||||
// Missing file: reported, then read once it exists.
|
||||
a.poll(now)
|
||||
if st := a.Status(); st["code"] != "not_found" || st["open"] != false {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
appendLines(t, logPath, auditExec[:3]...)
|
||||
appendLines(t, logPath, auditSudo)
|
||||
a.poll(now)
|
||||
if got := sink.serials(); len(got) != 0 { // events may still get records
|
||||
t.Fatalf("after first poll: %v", got)
|
||||
}
|
||||
// A partial line is not read.
|
||||
f, _ := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0)
|
||||
f.WriteString(auditSSH[:20])
|
||||
f.Close()
|
||||
a.poll(now.Add(5 * time.Second))
|
||||
if got := sink.serials(); len(got) != 2 || got[0] != "4242" || got[1] != "4250" {
|
||||
t.Fatalf("after second poll: %v", got)
|
||||
}
|
||||
if a.Status()["open"] != true || a.Status()["read"] != uint64(2) {
|
||||
t.Fatalf("status %v", a.Status())
|
||||
}
|
||||
a.saveState()
|
||||
|
||||
// Restart: nothing is sent twice, the partial line is read once complete.
|
||||
a = newAuditT()
|
||||
f, _ = os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0)
|
||||
f.WriteString(auditSSH[20:] + "\n")
|
||||
f.Close()
|
||||
a.poll(now.Add(10 * time.Second))
|
||||
a.poll(now.Add(20 * time.Second))
|
||||
if got := sink.serials(); len(got) != 3 || got[2] != "4260" {
|
||||
t.Fatalf("after restart: %v", got)
|
||||
}
|
||||
|
||||
// Rotation: lines added to the old file before the switch are not lost.
|
||||
appendLines(t, logPath, auditOpen[0])
|
||||
a.poll(now.Add(21 * time.Second)) // read, still open
|
||||
appendLines(t, logPath, auditOpen[1])
|
||||
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendLines(t, logPath, strings.Replace(auditSudo, "4250", "4300", 1))
|
||||
a.poll(now.Add(22 * time.Second))
|
||||
a.poll(now.Add(30 * time.Second))
|
||||
got := sink.serials()
|
||||
if len(got) != 5 || got[3] != "4270" || got[4] != "4300" {
|
||||
t.Fatalf("after rotation: %v", got)
|
||||
}
|
||||
sink.mu.Lock()
|
||||
if n := len(sink.es[3].Extra["audit_raw"]); n == 0 || !strings.Contains(sink.es[3].Extra["audit_raw"], "/etc/shadow") {
|
||||
t.Errorf("rotated event missing its PATH record: %q", sink.es[3].Extra["audit_raw"])
|
||||
}
|
||||
sink.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestAuditBackfillAndConfigure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "audit.log")
|
||||
appendLines(t, logPath, auditExec...)
|
||||
appendLines(t, logPath, auditSudo)
|
||||
sink := &entrySink{}
|
||||
// Only events newer than the backfill window are sent on the first read.
|
||||
a := NewAudit(logPath, dir, "", dir, time.Hour, sink.add)
|
||||
a.cfg.Enabled = true
|
||||
a.poll(time.Unix(1759572001+7200, 0))
|
||||
a.emit(a.asm.flushAll())
|
||||
if got := sink.serials(); len(got) != 0 {
|
||||
t.Fatalf("too old: %v", got)
|
||||
}
|
||||
a = NewAudit(logPath, dir, "", filepath.Join(dir, "other"), time.Hour, sink.add)
|
||||
if err := os.Mkdir(filepath.Join(dir, "other"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.cfg.Enabled = true
|
||||
a.poll(time.Unix(1759572001+60, 0))
|
||||
a.emit(a.asm.flushAll())
|
||||
if got := sink.serials(); len(got) != 2 {
|
||||
t.Fatalf("backfill: %v", got)
|
||||
}
|
||||
if e := sink.es[0]; e.Host != "localhost" {
|
||||
t.Errorf("host %q", e.Host)
|
||||
}
|
||||
|
||||
// Run picks the configuration up; turning it off resets the position.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { a.Run(ctx); close(done) }()
|
||||
if err := a.Configure(auditConfig{Enabled: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "other", "audit-state.json")); err == nil && strings.Contains(string(b), `"off": 0`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
if st := a.Status(); st["enabled"] != false || st["defaultPath"] != logPath {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "other", "audit-state.json"))
|
||||
if !strings.Contains(string(b), `"off": 0`) {
|
||||
t.Fatalf("state %s", b)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ const (
|
||||
type authConfig struct {
|
||||
mode string
|
||||
user, pass string // local mode
|
||||
viewerUser string // local mode: optional read-only account
|
||||
viewerPass string
|
||||
adminGroup string // oidc: only members of this group are admins (empty: everyone)
|
||||
groupsClaim string // oidc: ID token claim listing the groups
|
||||
issuer string
|
||||
clientID string
|
||||
clientSecret string
|
||||
@@ -124,6 +128,9 @@ func newOIDC(c authConfig) (*OIDC, error) {
|
||||
if !strings.Contains(" "+c.scopes+" ", " openid ") {
|
||||
c.scopes = "openid " + c.scopes
|
||||
}
|
||||
if c.groupsClaim == "" {
|
||||
c.groupsClaim = "groups"
|
||||
}
|
||||
return &OIDC{
|
||||
cfg: c,
|
||||
callback: ru.Path,
|
||||
@@ -157,8 +164,9 @@ func sessionKey(dir string) []byte {
|
||||
}
|
||||
|
||||
type session struct {
|
||||
User string `json:"u"`
|
||||
Exp int64 `json:"e"`
|
||||
User string `json:"u"`
|
||||
Exp int64 `json:"e"`
|
||||
Viewer bool `json:"v,omitempty"` // read-only user
|
||||
}
|
||||
|
||||
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
|
||||
@@ -191,9 +199,12 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var s session
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
|
||||
if r.URL.Path == "/auth/me" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User, "role": roleName(s.Viewer)})
|
||||
return
|
||||
}
|
||||
if s.Viewer {
|
||||
r = asViewer(r)
|
||||
}
|
||||
o.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -257,16 +268,16 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
|
||||
|
||||
user, err := o.exchange(r, q.Get("code"), ls)
|
||||
user, viewer, err := o.exchange(r, q.Get("code"), ls)
|
||||
if err != nil {
|
||||
log.Printf("oidc: login failed: %v", err)
|
||||
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
log.Printf("oidc: %s logged in", user)
|
||||
log.Printf("oidc: %s logged in (%s)", user, roleName(viewer))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
|
||||
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix(), Viewer: viewer}),
|
||||
Path: "/",
|
||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -286,14 +297,15 @@ func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// exchange trades the code for tokens and returns the user name from the verified ID token.
|
||||
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) {
|
||||
// exchange trades the code for tokens and returns the user name from the verified ID
|
||||
// token, and whether the user is read-only (not in OIDC_ADMIN_GROUP).
|
||||
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, bool, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("no code in the callback")
|
||||
return "", false, errors.New("no code in the callback")
|
||||
}
|
||||
meta, err := o.discover()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", false, err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
@@ -309,7 +321,7 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -318,29 +330,53 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
|
||||
}
|
||||
res, err := o.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token endpoint: %w", err)
|
||||
return "", false, fmt.Errorf("token endpoint: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
|
||||
return "", false, fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
|
||||
}
|
||||
var tok struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
|
||||
return "", errors.New("token endpoint: no id_token in the response")
|
||||
return "", false, errors.New("token endpoint: no id_token in the response")
|
||||
}
|
||||
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", false, err
|
||||
}
|
||||
viewer := o.cfg.adminGroup != "" && !hasGroup(claims[o.cfg.groupsClaim], o.cfg.adminGroup)
|
||||
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
|
||||
if v, _ := claims[k].(string); v != "" {
|
||||
return v, nil
|
||||
return v, viewer, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("id_token: no sub")
|
||||
return "", false, errors.New("id_token: no sub")
|
||||
}
|
||||
|
||||
// hasGroup tells whether the groups claim (a list, or a single string) holds
|
||||
// group; a leading "/" (Keycloak group paths) is ignored.
|
||||
func hasGroup(claim any, group string) bool {
|
||||
group = strings.TrimPrefix(group, "/")
|
||||
var groups []string
|
||||
switch v := claim.(type) {
|
||||
case string:
|
||||
groups = strings.Fields(strings.ReplaceAll(v, ",", " "))
|
||||
case []any:
|
||||
for _, g := range v {
|
||||
if s, ok := g.(string); ok {
|
||||
groups = append(groups, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
if strings.TrimPrefix(g, "/") == group {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
|
||||
|
||||
+38
-16
@@ -23,6 +23,8 @@ var loginFailDelay = time.Second // slows down password guessing
|
||||
|
||||
type Local struct {
|
||||
user, pass string
|
||||
viewerUser string // optional read-only account
|
||||
viewerPass string
|
||||
ttl time.Duration
|
||||
logo string // LOGIN_LOGO, served at /auth/logo
|
||||
key []byte
|
||||
@@ -32,14 +34,17 @@ type Local struct {
|
||||
func newLocal(c authConfig) *Local {
|
||||
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
||||
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
|
||||
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass + "\x00" + c.viewerUser + "\x00" + c.viewerPass))
|
||||
if c.loginLogo != "" {
|
||||
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("local authentication enabled (user %s)", c.user)
|
||||
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
||||
if c.viewerUser != "" {
|
||||
log.Printf("local read-only account enabled (user %s)", c.viewerUser)
|
||||
}
|
||||
return &Local{user: c.user, pass: c.pass, viewerUser: c.viewerUser, viewerPass: c.viewerPass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
||||
}
|
||||
|
||||
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -58,12 +63,17 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, ok := l.sessionUser(r)
|
||||
s, ok := l.sessionUser(r)
|
||||
if !ok {
|
||||
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
||||
user, ok = u, true
|
||||
if u, p, basic := r.BasicAuth(); basic {
|
||||
if viewer, valid := l.check(u, p); valid {
|
||||
s, ok = session{User: u, Viewer: viewer}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok && s.Viewer {
|
||||
r = asViewer(r)
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == loginPage:
|
||||
if ok {
|
||||
@@ -73,7 +83,7 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
l.next.ServeHTTP(w, r)
|
||||
case ok && r.URL.Path == "/auth/me":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": s.User, "role": roleName(s.Viewer)})
|
||||
case ok:
|
||||
l.next.ServeHTTP(w, r)
|
||||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||
@@ -94,7 +104,8 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||
ret := safeReturn(r.PostFormValue("r"))
|
||||
if !l.check(user, pass) {
|
||||
viewer, valid := l.check(user, pass)
|
||||
if !valid {
|
||||
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||
time.Sleep(loginFailDelay)
|
||||
q := url.Values{"e": {"1"}}
|
||||
@@ -104,10 +115,10 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
log.Printf("auth: %s logged in from %s", user, clientIP(r))
|
||||
log.Printf("auth: %s logged in from %s (%s)", user, clientIP(r), roleName(viewer))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
|
||||
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix(), Viewer: viewer}),
|
||||
Path: "/",
|
||||
MaxAge: int(l.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -117,19 +128,30 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (l *Local) sessionUser(r *http.Request) (string, bool) {
|
||||
func (l *Local) sessionUser(r *http.Request) (session, bool) {
|
||||
var s session
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||
return "", false
|
||||
return session{}, false
|
||||
}
|
||||
return s.User, true
|
||||
return s, true
|
||||
}
|
||||
|
||||
func (l *Local) check(user, pass string) bool {
|
||||
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
||||
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
||||
return u&p == 1
|
||||
// check validates a user and password: the admin account, or the read-only one
|
||||
// (viewer=true) when AUTH_VIEWER_USER is set.
|
||||
func (l *Local) check(user, pass string) (viewer, ok bool) {
|
||||
if same(user, l.user) && same(pass, l.pass) {
|
||||
return false, true
|
||||
}
|
||||
if l.viewerUser != "" && same(user, l.viewerUser) && same(pass, l.viewerPass) {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// same compares in constant time, so the answer time says nothing of the secret.
|
||||
func same(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DBStats describes what VictoriaLogs holds (Settings > Data). Sizes are in
|
||||
// bytes; a field VictoriaLogs did not give stays at its zero value.
|
||||
type DBStats struct {
|
||||
Rows int64 `json:"rows"` // stored log lines
|
||||
DiskBytes int64 `json:"diskBytes"` // compressed data + index, on disk
|
||||
IndexBytes int64 `json:"indexBytes"` // index part of DiskBytes
|
||||
RawBytes int64 `json:"rawBytes"` // data before compression
|
||||
FreeBytes int64 `json:"freeBytes"` // free space on the VictoriaLogs volume
|
||||
Days int64 `json:"days"` // per-day partitions
|
||||
Retention string `json:"retention"` // -retentionPeriod, e.g. "30d"
|
||||
Oldest string `json:"oldest,omitempty"`
|
||||
Newest string `json:"newest,omitempty"`
|
||||
Last1h int64 `json:"last1h"`
|
||||
Last24h int64 `json:"last24h"`
|
||||
Hosts int64 `json:"hosts"`
|
||||
Apps int64 `json:"apps"`
|
||||
QueryError string `json:"queryError,omitempty"` // the LogsQL part failed, the metrics are still valid
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// dbStatsCache keeps the last answer for a while: the LogsQL part reads the
|
||||
// whole base, which is too heavy to repeat each time the tab is opened.
|
||||
type dbStatsCache struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
last *DBStats
|
||||
}
|
||||
|
||||
const dbStatsTTL = 30 * time.Second
|
||||
|
||||
func (c *dbStatsCache) get(ctx context.Context, s *Store, force bool) (*DBStats, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.last != nil && !force && time.Since(c.at) < dbStatsTTL {
|
||||
return c.last, nil
|
||||
}
|
||||
st, err := s.DBStats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.last, c.at = st, time.Now()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// DBStats reads the storage metrics of VictoriaLogs (/metrics), then counts
|
||||
// with LogsQL what the metrics do not tell (period, recent volume, sources).
|
||||
func (s *Store) DBStats(ctx context.Context) (*DBStats, error) {
|
||||
st, err := s.storageMetrics(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Updated = time.Now().UTC().Format(time.RFC3339)
|
||||
if st.Rows == 0 {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
qctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := s.Query(qctx, "* | stats min(_time) oldest, max(_time) newest, count_uniq(host) hosts, count_uniq(app) apps")
|
||||
if err == nil && len(rows) > 0 {
|
||||
r := rows[0]
|
||||
st.Oldest, _ = r["oldest"].(string)
|
||||
st.Newest, _ = r["newest"].(string)
|
||||
st.Hosts, st.Apps = toInt(r["hosts"]), toInt(r["apps"])
|
||||
rows, err = s.Query(qctx, "_time:24h | stats count() last24h, count() if (_time:1h) last1h")
|
||||
if err == nil && len(rows) > 0 {
|
||||
st.Last24h, st.Last1h = toInt(rows[0]["last24h"]), toInt(rows[0]["last1h"])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
st.QueryError = err.Error()
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) storageMetrics(ctx context.Context) (*DBStats, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/metrics", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("VictoriaLogs /metrics: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return parseStorageMetrics(resp.Body)
|
||||
}
|
||||
|
||||
// parseStorageMetrics picks the storage figures out of the Prometheus text
|
||||
// format of VictoriaLogs' /metrics (app/vlstorage).
|
||||
func parseStorageMetrics(r io.Reader) (*DBStats, error) {
|
||||
st := &DBStats{}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
name, labels, value := splitMetric(line)
|
||||
switch name {
|
||||
case "vl_storage_rows":
|
||||
st.Rows += int64(value)
|
||||
case "vl_data_size_bytes":
|
||||
st.DiskBytes += int64(value)
|
||||
if labels["type"] == "indexdb" {
|
||||
st.IndexBytes += int64(value)
|
||||
}
|
||||
case "vl_uncompressed_data_size_bytes":
|
||||
st.RawBytes += int64(value)
|
||||
case "vl_free_disk_space_bytes":
|
||||
st.FreeBytes = int64(value)
|
||||
case "vl_partitions":
|
||||
st.Days = int64(value)
|
||||
case "flag":
|
||||
if labels["name"] == "retentionPeriod" {
|
||||
st.Retention = labels["value"]
|
||||
}
|
||||
}
|
||||
}
|
||||
return st, sc.Err()
|
||||
}
|
||||
|
||||
// splitMetric splits `name{a="x",b="y"} 12` into its parts. Label values with
|
||||
// escaped quotes are not expected in the metrics read here.
|
||||
func splitMetric(line string) (string, map[string]string, float64) {
|
||||
sp := strings.LastIndexByte(line, ' ')
|
||||
if sp < 0 {
|
||||
return "", nil, 0
|
||||
}
|
||||
value, _ := strconv.ParseFloat(line[sp+1:], 64)
|
||||
head := line[:sp]
|
||||
name, rest, ok := strings.Cut(head, "{")
|
||||
if !ok {
|
||||
return head, nil, value
|
||||
}
|
||||
labels := map[string]string{}
|
||||
rest = strings.TrimSuffix(rest, "}")
|
||||
for rest != "" {
|
||||
k, v, ok := strings.Cut(rest, `="`)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
val, after, _ := strings.Cut(v, `"`)
|
||||
labels[strings.TrimSpace(k)] = val
|
||||
rest = strings.TrimPrefix(after, ",")
|
||||
}
|
||||
return name, labels, value
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Excerpt of VictoriaLogs v1.52 /metrics.
|
||||
const vlMetrics = `# a comment
|
||||
vl_free_disk_space_bytes{path="/vlogs"} 52428800000
|
||||
vl_storage_rows{type="storage/inmemory"} 120
|
||||
vl_storage_rows{type="storage/small"} 3000
|
||||
vl_storage_rows{type="storage/big"} 1000000
|
||||
vl_partitions 12
|
||||
vl_data_size_bytes{type="indexdb"} 2048
|
||||
vl_data_size_bytes{type="storage"} 1048576
|
||||
vl_compressed_data_size_bytes{type="storage/big"} 1000000
|
||||
vl_uncompressed_data_size_bytes{type="storage/inmemory"} 4096
|
||||
vl_uncompressed_data_size_bytes{type="storage/small"} 100000
|
||||
vl_uncompressed_data_size_bytes{type="storage/big"} 20000000
|
||||
flag{name="retentionPeriod", value="30d", is_set="true"} 1
|
||||
flag{name="httpListenAddr", value=":9428", is_set="true"} 1
|
||||
`
|
||||
|
||||
func TestParseStorageMetrics(t *testing.T) {
|
||||
st, err := parseStorageMetrics(strings.NewReader(vlMetrics))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := DBStats{Rows: 1003120, DiskBytes: 1050624, IndexBytes: 2048, RawBytes: 20104096, FreeBytes: 52428800000, Days: 12, Retention: "30d"}
|
||||
if *st != want {
|
||||
t.Fatalf("got %+v\nwant %+v", *st, want)
|
||||
}
|
||||
}
|
||||
|
||||
// statsVL answers /metrics and the LogsQL queries of DBStats.
|
||||
type statsVL struct{ queries int }
|
||||
|
||||
func (v *statsVL) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body := vlMetrics
|
||||
if r.URL.Path == "/select/logsql/query" {
|
||||
v.queries++
|
||||
_ = r.ParseForm()
|
||||
if strings.HasPrefix(r.PostForm.Get("query"), "_time:24h") {
|
||||
body = `{"last24h":"5000","last1h":"300"}` + "\n"
|
||||
} else {
|
||||
body = `{"oldest":"2026-09-21T08:00:00Z","newest":"2026-10-03T15:00:00Z","hosts":"4","apps":"17"}` + "\n"
|
||||
}
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
|
||||
}
|
||||
|
||||
func TestDBStatsHandler(t *testing.T) {
|
||||
vl := &statsVL{}
|
||||
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||
store.client.Transport = vl
|
||||
store.streamClient.Transport = vl
|
||||
a := &API{store: store}
|
||||
|
||||
get := func(url string) DBStats {
|
||||
rec := httptest.NewRecorder()
|
||||
a.dbStats(rec, httptest.NewRequest("GET", url, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var st DBStats
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
st := get("/api/dbstats")
|
||||
if st.Rows != 1003120 || st.Oldest != "2026-09-21T08:00:00Z" || st.Hosts != 4 || st.Apps != 17 || st.Last24h != 5000 || st.Last1h != 300 || st.QueryError != "" {
|
||||
t.Fatalf("unexpected stats: %+v", st)
|
||||
}
|
||||
get("/api/dbstats")
|
||||
if vl.queries != 2 {
|
||||
t.Fatalf("second call within the TTL should be cached, got %d queries", vl.queries)
|
||||
}
|
||||
get("/api/dbstats?refresh=1")
|
||||
if vl.queries != 4 {
|
||||
t.Fatalf("refresh=1 should query again, got %d queries", vl.queries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// dmesgConfig is saved in /data/dmesg.json and edited in Settings > Sources.
|
||||
type dmesgConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// dmesgPos is where reading resumes: the last record sent, valid for one boot
|
||||
// only (the kernel numbers its records from 0 at each boot).
|
||||
type dmesgPos struct {
|
||||
BootID string `json:"boot_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
}
|
||||
|
||||
// Dmesg collects the kernel ring buffer of the machine hosting the stack by
|
||||
// reading /dev/kmsg, the device dmesg itself reads.
|
||||
type Dmesg struct {
|
||||
device string // /dev/kmsg
|
||||
bootIDPath string // identifies the current boot
|
||||
uptimePath string // gives the boot time, to date the records
|
||||
sink func(*Entry)
|
||||
backfill time.Duration
|
||||
cfgPath string
|
||||
statePath string
|
||||
retry time.Duration // delay before opening the device again after a failure
|
||||
wake chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
cfg dmesgConfig
|
||||
reset bool
|
||||
pos dmesgPos
|
||||
dirty bool
|
||||
open bool
|
||||
read uint64
|
||||
lost uint64
|
||||
errCode string
|
||||
errDetail string
|
||||
}
|
||||
|
||||
func NewDmesg(device, dataDir string, backfill time.Duration, sink func(*Entry)) *Dmesg {
|
||||
d := &Dmesg{
|
||||
device: device,
|
||||
bootIDPath: "/proc/sys/kernel/random/boot_id",
|
||||
uptimePath: "/proc/uptime",
|
||||
sink: sink,
|
||||
backfill: backfill,
|
||||
cfgPath: filepath.Join(dataDir, "dmesg.json"),
|
||||
statePath: filepath.Join(dataDir, "dmesg-state.json"),
|
||||
retry: 10 * time.Second,
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
if b, err := os.ReadFile(d.cfgPath); err == nil {
|
||||
_ = json.Unmarshal(b, &d.cfg)
|
||||
}
|
||||
if b, err := os.ReadFile(d.statePath); err == nil {
|
||||
_ = json.Unmarshal(b, &d.pos)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// kmsgSession is one opening of the device, read by its own goroutine.
|
||||
type kmsgSession struct {
|
||||
f *os.File
|
||||
done chan struct{}
|
||||
err error
|
||||
stopped bool // closed on purpose: err is not a failure
|
||||
}
|
||||
|
||||
func (s *kmsgSession) stop() {
|
||||
s.stopped = true
|
||||
s.f.Close() // ends a pending read (/dev/kmsg goes through the poller)
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
// Run keeps the device open while the source is enabled.
|
||||
func (d *Dmesg) Run(ctx context.Context) {
|
||||
save := time.NewTicker(5 * time.Second)
|
||||
defer save.Stop()
|
||||
var cur *kmsgSession
|
||||
var retryAt time.Time
|
||||
for {
|
||||
d.mu.Lock()
|
||||
enabled, reset := d.cfg.Enabled, d.reset
|
||||
d.reset = false
|
||||
d.mu.Unlock()
|
||||
if reset {
|
||||
// Disabled then enabled again: start over from DMESG_BACKFILL ago
|
||||
// rather than reading everything logged in between.
|
||||
if cur != nil {
|
||||
cur.stop()
|
||||
cur = nil
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.pos, d.dirty = dmesgPos{}, true
|
||||
d.mu.Unlock()
|
||||
d.saveState()
|
||||
retryAt = time.Time{}
|
||||
}
|
||||
if !enabled && cur != nil {
|
||||
cur.stop()
|
||||
cur = nil
|
||||
d.mu.Lock()
|
||||
d.open = false
|
||||
d.mu.Unlock()
|
||||
}
|
||||
if enabled && cur == nil && !time.Now().Before(retryAt) {
|
||||
var err error
|
||||
if cur, err = d.start(); err != nil {
|
||||
d.fail(err)
|
||||
retryAt = time.Now().Add(d.retry)
|
||||
}
|
||||
}
|
||||
var done chan struct{}
|
||||
if cur != nil {
|
||||
done = cur.done
|
||||
}
|
||||
wait := d.retry
|
||||
if cur == nil && enabled {
|
||||
wait = max(time.Until(retryAt), 10*time.Millisecond)
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
if cur != nil {
|
||||
cur.stop()
|
||||
}
|
||||
d.saveState()
|
||||
return
|
||||
case <-d.wake:
|
||||
case <-save.C:
|
||||
d.saveState()
|
||||
case <-timer.C:
|
||||
case <-done:
|
||||
if !cur.stopped {
|
||||
d.fail(cur.err)
|
||||
}
|
||||
cur = nil
|
||||
retryAt = time.Now().Add(d.retry)
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dmesg) saveState() {
|
||||
d.mu.Lock()
|
||||
if !d.dirty {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.dirty = false
|
||||
pos := d.pos
|
||||
d.mu.Unlock()
|
||||
if err := writeJSONFile(d.statePath, pos); err != nil {
|
||||
logEvent("dmesg", 4, "saving position: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records why the device is not being read (nil error: end of a
|
||||
// regular file, only seen in tests).
|
||||
func (d *Dmesg) fail(err error) {
|
||||
code, detail := "", ""
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
detail = err.Error()
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
code = "not_found"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
code = "permission"
|
||||
default:
|
||||
code = "read"
|
||||
}
|
||||
}
|
||||
d.mu.Lock()
|
||||
changed := code != d.errCode
|
||||
d.open, d.errCode, d.errDetail = false, code, detail
|
||||
d.mu.Unlock()
|
||||
if changed && code != "" {
|
||||
logEvent("dmesg", 4, "cannot read the kernel messages: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// start opens the device and starts reading it in the background.
|
||||
func (d *Dmesg) start() (*kmsgSession, error) {
|
||||
f, err := os.Open(d.device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bootID := readTrim(d.bootIDPath)
|
||||
boot := d.bootTime(time.Now())
|
||||
d.mu.Lock()
|
||||
pos := d.pos
|
||||
d.open, d.errCode, d.errDetail = true, "", ""
|
||||
d.mu.Unlock()
|
||||
|
||||
// Same boot as the saved position: skip what was already sent. Otherwise
|
||||
// read from DMESG_BACKFILL ago (0: only new records).
|
||||
resume := pos.BootID != "" && pos.BootID == bootID
|
||||
var notBefore time.Time
|
||||
if !resume {
|
||||
if d.backfill <= 0 {
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else if !boot.IsZero() {
|
||||
notBefore = time.Now().Add(-d.backfill)
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.pos, d.dirty = dmesgPos{BootID: bootID}, true
|
||||
d.mu.Unlock()
|
||||
}
|
||||
s := &kmsgSession{f: f, done: make(chan struct{})}
|
||||
go func() {
|
||||
defer close(s.done)
|
||||
s.err = d.consume(f, bootID, boot, resume, pos.Seq, notBefore)
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func readTrim(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// bootTime is now minus the uptime; zero when unknown (records are then dated
|
||||
// when they are read).
|
||||
func (d *Dmesg) bootTime(now time.Time) time.Time {
|
||||
f := strings.Fields(readTrim(d.uptimePath))
|
||||
if len(f) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
up, err := strconv.ParseFloat(f[0], 64)
|
||||
if err != nil || up <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return now.Add(-time.Duration(up * float64(time.Second)))
|
||||
}
|
||||
|
||||
// consume reads records until an error. Each read of /dev/kmsg returns one
|
||||
// record; several records in one read (a regular file, in tests) are split.
|
||||
func (d *Dmesg) consume(r io.Reader, bootID string, boot time.Time, resume bool, after uint64, notBefore time.Time) error {
|
||||
buf := make([]byte, 64<<10) // a record is at most 8 KiB
|
||||
last, haveLast := after, resume
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
for _, raw := range splitKmsg(buf[:n]) {
|
||||
rec, ok := parseKmsg(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if haveLast && rec.Seq <= last {
|
||||
continue // already sent before a restart
|
||||
}
|
||||
var lost uint64
|
||||
if haveLast && rec.Seq > last+1 {
|
||||
lost = rec.Seq - last - 1 // overwritten in the ring buffer before being read
|
||||
}
|
||||
last, haveLast = rec.Seq, true
|
||||
now := time.Now()
|
||||
t := now
|
||||
if !boot.IsZero() {
|
||||
t = boot.Add(time.Duration(rec.Usec) * time.Microsecond)
|
||||
}
|
||||
if !notBefore.IsZero() && t.Before(notBefore) {
|
||||
d.advance(bootID, rec.Seq, 0, 0)
|
||||
continue
|
||||
}
|
||||
d.sink(kmsgEntry(rec, t, now))
|
||||
d.advance(bootID, rec.Seq, 1, lost)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, syscall.EPIPE) {
|
||||
continue // records overwritten while reading: the next read goes on
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dmesg) advance(bootID string, seq, read, lost uint64) {
|
||||
d.mu.Lock()
|
||||
d.pos, d.dirty = dmesgPos{BootID: bootID, Seq: seq}, true
|
||||
d.read += read
|
||||
d.lost += lost
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// kmsgRecord is one record of /dev/kmsg:
|
||||
//
|
||||
// prio,seq,usec,flags[,...];message
|
||||
// KEY=value (optional dictionary lines)
|
||||
type kmsgRecord struct {
|
||||
Prio int
|
||||
Seq uint64
|
||||
Usec uint64
|
||||
Msg string
|
||||
Dict map[string]string
|
||||
}
|
||||
|
||||
// splitKmsg cuts a buffer into records: a new record starts after a newline
|
||||
// that is not followed by a space (dictionary lines start with one).
|
||||
func splitKmsg(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
start := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == '\n' && (i+1 == len(b) || b[i+1] != ' ') {
|
||||
out = append(out, b[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(b) {
|
||||
out = append(out, b[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseKmsg(raw []byte) (kmsgRecord, bool) {
|
||||
var rec kmsgRecord
|
||||
lines := bytes.Split(raw, []byte{'\n'})
|
||||
head, msg, ok := bytes.Cut(lines[0], []byte{';'})
|
||||
if !ok {
|
||||
return rec, false
|
||||
}
|
||||
f := strings.Split(string(head), ",")
|
||||
if len(f) < 3 {
|
||||
return rec, false
|
||||
}
|
||||
prio, err1 := strconv.Atoi(f[0])
|
||||
seq, err2 := strconv.ParseUint(f[1], 10, 64)
|
||||
usec, err3 := strconv.ParseUint(f[2], 10, 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil || prio < 0 {
|
||||
return rec, false
|
||||
}
|
||||
rec.Prio, rec.Seq, rec.Usec = prio, seq, usec
|
||||
rec.Msg = unescapeKmsg(string(msg))
|
||||
for _, l := range lines[1:] {
|
||||
if k, v, ok := strings.Cut(strings.TrimPrefix(string(l), " "), "="); ok && k != "" {
|
||||
if rec.Dict == nil {
|
||||
rec.Dict = map[string]string{}
|
||||
}
|
||||
rec.Dict[k] = unescapeKmsg(v)
|
||||
}
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// unescapeKmsg decodes the \xNN escapes the kernel uses for non-printable bytes.
|
||||
func unescapeKmsg(s string) string {
|
||||
if !strings.Contains(s, `\x`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) && s[i+1] == 'x' {
|
||||
if v, err := strconv.ParseUint(s[i+2:i+4], 16, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// kmsgEntry turns a record into an Entry. Kernel records have the kern
|
||||
// facility; programs writing to /dev/kmsg (systemd at boot…) use another
|
||||
// one and prefix their name: "systemd[1]: …".
|
||||
func kmsgEntry(rec kmsgRecord, t, now time.Time) *Entry {
|
||||
sev := rec.Prio & 7
|
||||
fac := rec.Prio >> 3
|
||||
if fac >= len(facilityNames) {
|
||||
fac = 1 // user
|
||||
}
|
||||
msg := strings.TrimRight(rec.Msg, "\n")
|
||||
if !utf8.ValidString(msg) {
|
||||
msg = strings.ToValidUTF8(msg, "�")
|
||||
}
|
||||
app, pid := "kernel", ""
|
||||
if fac != 0 {
|
||||
if a, p, rest, ok := splitTag(msg); ok {
|
||||
app, pid, msg = a, p, rest
|
||||
}
|
||||
}
|
||||
e := &Entry{
|
||||
Time: t,
|
||||
Received: now,
|
||||
Host: "localhost",
|
||||
App: app,
|
||||
ProcID: pid,
|
||||
Facility: facilityNames[fac],
|
||||
Severity: severityNames[sev],
|
||||
SevNum: sev,
|
||||
Message: msg,
|
||||
Proto: "kmsg",
|
||||
SourceType: "kernel",
|
||||
Wait: true,
|
||||
}
|
||||
if s, dev := rec.Dict["SUBSYSTEM"], rec.Dict["DEVICE"]; s != "" || dev != "" {
|
||||
e.Extra = map[string]string{}
|
||||
if s != "" {
|
||||
e.Extra["subsystem"] = s
|
||||
}
|
||||
if dev != "" {
|
||||
e.Extra["device"] = dev
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// splitTag cuts "name[pid]: message" or "name: message".
|
||||
func splitTag(msg string) (app, pid, rest string, ok bool) {
|
||||
tag, rest, found := strings.Cut(msg, ": ")
|
||||
if !found || tag == "" || len(tag) > 48 || strings.ContainsAny(tag, " \t") {
|
||||
return "", "", "", false
|
||||
}
|
||||
if i := strings.IndexByte(tag, '['); i > 0 && strings.HasSuffix(tag, "]") {
|
||||
return tag[:i], tag[i+1 : len(tag)-1], rest, true
|
||||
}
|
||||
return tag, "", rest, true
|
||||
}
|
||||
|
||||
// Configure saves and applies the configuration.
|
||||
func (d *Dmesg) Configure(cfg dmesgConfig) error {
|
||||
d.mu.Lock()
|
||||
if d.cfg.Enabled && !cfg.Enabled {
|
||||
d.reset = true
|
||||
d.errCode, d.errDetail = "", ""
|
||||
}
|
||||
d.cfg = cfg
|
||||
d.mu.Unlock()
|
||||
select {
|
||||
case d.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return writeJSONFile(d.cfgPath, cfg)
|
||||
}
|
||||
|
||||
// Status is the state shown in Settings > Sources.
|
||||
func (d *Dmesg) Status() map[string]any {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return map[string]any{
|
||||
"enabled": d.cfg.Enabled,
|
||||
"open": d.open && d.cfg.Enabled,
|
||||
"device": d.device,
|
||||
"read": d.read,
|
||||
"lost": d.lost,
|
||||
"code": d.errCode,
|
||||
"error": d.errDetail,
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseKmsg(t *testing.T) {
|
||||
rec, ok := parseKmsg([]byte("6,339,5140900,-;NET: Registered protocol family 10\n SUBSYSTEM=net\n DEVICE=n2"))
|
||||
if !ok || rec.Prio != 6 || rec.Seq != 339 || rec.Usec != 5140900 || rec.Msg != "NET: Registered protocol family 10" {
|
||||
t.Fatalf("record %+v", rec)
|
||||
}
|
||||
if rec.Dict["SUBSYSTEM"] != "net" || rec.Dict["DEVICE"] != "n2" {
|
||||
t.Fatalf("dict %+v", rec.Dict)
|
||||
}
|
||||
// Extra header fields (newer kernels: caller id) and a ';' in the message.
|
||||
rec, ok = parseKmsg([]byte("4,12,99,c,caller=T1;a;b \\x1b[31mred\\x5c"))
|
||||
if !ok || rec.Seq != 12 || rec.Msg != "a;b \x1b[31mred\\" {
|
||||
t.Fatalf("record %+v", rec)
|
||||
}
|
||||
for _, bad := range []string{"", "no header", "6,1;short", "x,1,2,-;msg", "6,-1,2,-;msg", "-1,1,2,-;msg"} {
|
||||
if _, ok := parseKmsg([]byte(bad)); ok {
|
||||
t.Errorf("%q parsed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnescapeKmsg(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
`plain`: "plain",
|
||||
`a\x0ab`: "a\nb",
|
||||
`end\x4`: `end\x4`,
|
||||
`bad\xzz`: `bad\xzz`,
|
||||
`\x41\x42\x4`: `AB\x4`,
|
||||
} {
|
||||
if got := unescapeKmsg(in); got != want {
|
||||
t.Errorf("%q: got %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitKmsg(t *testing.T) {
|
||||
got := splitKmsg([]byte("6,1,0,-;one\n SUBSYSTEM=pci\n6,2,0,-;two\n6,3,0,-;three"))
|
||||
if len(got) != 3 || string(got[0]) != "6,1,0,-;one\n SUBSYSTEM=pci" || string(got[2]) != "6,3,0,-;three" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKmsgEntry(t *testing.T) {
|
||||
boot := time.Date(2026, 10, 4, 6, 0, 0, 0, time.UTC)
|
||||
now := boot.Add(time.Hour)
|
||||
rec, _ := parseKmsg([]byte("3,7,2500000,-;usb 1-1: device descriptor read/64, error -71\n SUBSYSTEM=usb\n DEVICE=c189:1"))
|
||||
e := kmsgEntry(rec, boot.Add(2500*time.Millisecond), now)
|
||||
if e.App != "kernel" || e.Facility != "kern" || e.Severity != "err" || e.SevNum != 3 || e.SourceType != "kernel" || e.Proto != "kmsg" || !e.Wait {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
if e.Message != "usb 1-1: device descriptor read/64, error -71" || e.Extra["subsystem"] != "usb" || e.Extra["device"] != "c189:1" {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
if !e.Time.Equal(boot.Add(2500*time.Millisecond)) || !e.Received.Equal(now) {
|
||||
t.Fatalf("times %v %v", e.Time, e.Received)
|
||||
}
|
||||
// Written by a program (facility daemon = 3<<3 | info).
|
||||
rec, _ = parseKmsg([]byte("30,8,3000000,-;systemd[1]: Started Journal Service."))
|
||||
e = kmsgEntry(rec, now, now)
|
||||
if e.App != "systemd" || e.ProcID != "1" || e.Facility != "daemon" || e.Severity != "info" || e.Message != "Started Journal Service." || e.Extra != nil {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
// Facility out of range.
|
||||
rec, _ = parseKmsg([]byte("255,9,0,-;odd"))
|
||||
if e = kmsgEntry(rec, now, now); e.Facility != "user" || e.Severity != "debug" {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeHost writes a kmsg file, a boot id and an uptime in a temp dir.
|
||||
func fakeHost(t *testing.T, uptime float64, records ...string) (*Dmesg, *[]*Entry, *sync.Mutex) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
write := func(name, s string) string {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var got []*Entry
|
||||
d := NewDmesg(write("kmsg", strings.Join(records, "\n")+"\n"), filepath.Join(dir, "data"), time.Hour, func(e *Entry) {
|
||||
mu.Lock()
|
||||
got = append(got, e)
|
||||
mu.Unlock()
|
||||
})
|
||||
d.bootIDPath = write("boot_id", "b1\n")
|
||||
d.uptimePath = write("uptime", strconv.FormatFloat(uptime, 'f', 2, 64)+" 1234.00\n")
|
||||
d.retry = 20 * time.Millisecond
|
||||
return d, &got, &mu
|
||||
}
|
||||
|
||||
func TestDmesgBackfillAndResume(t *testing.T) {
|
||||
// Booted 3 h ago: the record at 1 s is outside the 1 h backfill window,
|
||||
// the ones at 2.5 h and 2.9 h are inside.
|
||||
d, got, mu := fakeHost(t, 3*3600,
|
||||
"6,1,1000000,-;Linux version 6.12",
|
||||
"4,2,9000000000,-;eth0: link down",
|
||||
"6,3,10440000000,-;eth0: link up",
|
||||
)
|
||||
s, err := d.start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.done
|
||||
mu.Lock()
|
||||
if len(*got) != 2 || (*got)[0].Message != "eth0: link down" || (*got)[1].Message != "eth0: link up" {
|
||||
t.Fatalf("got %+v", *got)
|
||||
}
|
||||
if age := time.Since((*got)[1].Time); age < 5*time.Minute || age > 7*time.Minute {
|
||||
t.Fatalf("record dated %v ago", age)
|
||||
}
|
||||
mu.Unlock()
|
||||
d.saveState()
|
||||
|
||||
// Restart with the saved position: same boot, nothing is sent again,
|
||||
// and a new record is.
|
||||
f, _ := os.OpenFile(d.device, os.O_APPEND|os.O_WRONLY, 0)
|
||||
_, _ = f.WriteString("6,6,10500000000,-;eth0: renamed\n")
|
||||
f.Close()
|
||||
d2 := NewDmesg(d.device, filepath.Dir(d.statePath), time.Hour, d.sink)
|
||||
d2.bootIDPath, d2.uptimePath = d.bootIDPath, d.uptimePath
|
||||
if d2.pos != (dmesgPos{BootID: "b1", Seq: 3}) {
|
||||
t.Fatalf("saved position %+v", d2.pos)
|
||||
}
|
||||
s, err = d2.start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.done
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 3 || (*got)[2].Message != "eth0: renamed" || (*got)[2].App != "kernel" {
|
||||
t.Fatalf("after restart %+v", (*got)[2:])
|
||||
}
|
||||
if st := d2.Status(); st["lost"] != uint64(2) || st["read"] != uint64(1) {
|
||||
t.Fatalf("status %+v", st) // records 4 and 5 were overwritten
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmesgNewBoot(t *testing.T) {
|
||||
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;booted")
|
||||
d.pos = dmesgPos{BootID: "old", Seq: 500}
|
||||
s, err := d.start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.done
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 1 || d.pos != (dmesgPos{BootID: "b1", Seq: 1}) {
|
||||
t.Fatalf("got %d entries, position %+v", len(*got), d.pos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmesgNoBackfill(t *testing.T) {
|
||||
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;old")
|
||||
d.backfill = 0
|
||||
s, err := d.start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.done
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 0 {
|
||||
t.Fatalf("got %+v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmesgRun(t *testing.T) {
|
||||
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;first", "6,2,2000000,-;second")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
if err := d.Configure(dmesgConfig{Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(*got)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("got %d entries", n)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// The file is re-opened after each end of file: still no duplicate.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 2 {
|
||||
t.Fatalf("got %d entries", len(*got))
|
||||
}
|
||||
b, err := os.ReadFile(d.statePath)
|
||||
if err != nil || !strings.Contains(string(b), `"seq": 2`) {
|
||||
t.Fatalf("state %s %v", b, err)
|
||||
}
|
||||
b, _ = os.ReadFile(d.cfgPath)
|
||||
if !strings.Contains(string(b), `"enabled": true`) {
|
||||
t.Fatalf("config %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmesgMissingDevice(t *testing.T) {
|
||||
d := NewDmesg(filepath.Join(t.TempDir(), "kmsg"), t.TempDir(), time.Hour, func(*Entry) {})
|
||||
d.retry = 20 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
d.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
_ = d.Configure(dmesgConfig{Enabled: true})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for d.Status()["code"] != "not_found" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("status %+v", d.Status())
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// Turning it off clears the error.
|
||||
_ = d.Configure(dmesgConfig{Enabled: false})
|
||||
if st := d.Status(); st["code"] != "" || st["enabled"] != false {
|
||||
t.Fatalf("status %+v", st)
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestDmesgPermission(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root reads everything")
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "kmsg")
|
||||
_ = os.WriteFile(p, []byte("6,1,0,-;x\n"), 0o000)
|
||||
d := NewDmesg(p, t.TempDir(), time.Hour, func(*Entry) {})
|
||||
_, err := d.start()
|
||||
d.fail(err)
|
||||
if st := d.Status(); st["code"] != "permission" {
|
||||
t.Fatalf("status %+v", st)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -17,6 +17,8 @@ services:
|
||||
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc
|
||||
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
|
||||
AUTH_PASS: ${AUTH_PASS:-}
|
||||
AUTH_VIEWER_USER: ${AUTH_VIEWER_USER:-} # compte en lecture seule (optionnel)
|
||||
AUTH_VIEWER_PASS: ${AUTH_VIEWER_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:-}
|
||||
@@ -24,22 +26,43 @@ services:
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-} # vide = tout le monde est admin, sinon les autres sont en lecture seule
|
||||
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
|
||||
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
|
||||
RDNS: ${RDNS:-on} # resol dns
|
||||
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
|
||||
ALLOW_PURGE: ${ALLOW_PURGE:-true}
|
||||
ALLOW_PURGE: ${ALLOW_PURGE:-false} # true pour autoriser « Supprimer tous les logs »
|
||||
EXPORT_MAX: ${EXPORT_MAX:-100000}
|
||||
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
|
||||
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker
|
||||
DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h}
|
||||
HOST_LOGS_BACKFILL: ${HOST_LOGS_BACKFILL:-1h} # historique lu a l'activation des logs systeme de l'hote
|
||||
DMESG_BACKFILL: ${DMESG_BACKFILL:-1h} # historique lu a l'activation des messages du noyau (dmesg)
|
||||
AUDIT_BACKFILL: ${AUDIT_BACKFILL:-1h} # historique lu a l'activation de l'audit Linux (auditd)
|
||||
AUDIT_HOST: ${AUDIT_HOST:-} # nom d'hote des evenements d'audit (vide : node= des enregistrements, sinon /host/etc/hostname)
|
||||
group_add:
|
||||
- "${HOST_LOGS_GID:-4}" # groupe autorise a lire les logs de l'hote (4 = adm sur Debian/Ubuntu)
|
||||
# messages du noyau de l'hote (dmesg), source a activer dans Reglages > Sources :
|
||||
# decommenter devices pour donner /dev/kmsg (lecture seule) au conteneur
|
||||
# devices:
|
||||
# - /dev/kmsg:/dev/kmsg:r
|
||||
# si l'hote a kernel.dmesg_restrict=1 (defaut Debian/Ubuntu), soit sur l'hote :
|
||||
# sysctl -w kernel.dmesg_restrict=0 (et kernel.dmesg_restrict = 0 dans /etc/sysctl.d/ pour le garder)
|
||||
# soit lancer le conteneur en root avec la capacite SYSLOG (decommenter les deux) :
|
||||
# user: "0:0"
|
||||
# cap_add:
|
||||
# - SYSLOG
|
||||
volumes:
|
||||
- logstream-data:/data # tags.json, docker.json (les choix des conteneurs)
|
||||
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
|
||||
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
||||
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
||||
# audit Linux (auditd), source a activer dans Reglages > Sources ; le fichier doit etre
|
||||
# lisible par l'UID 10001 : log_group = adm dans /etc/audit/auditd.conf (voir le README)
|
||||
# - /var/log/audit:/var/log/audit:ro
|
||||
# noms des utilisateurs et de l'hote pour les evenements d'audit (optionnel)
|
||||
# - /etc/passwd:/host/etc/passwd:ro
|
||||
# - /etc/hostname:/host/etc/hostname:ro
|
||||
# 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
|
||||
|
||||
@@ -61,8 +61,8 @@ type DockerManager struct {
|
||||
cfg dockerConfig
|
||||
hostName string
|
||||
containers []DockerContainer
|
||||
followers map[string]*follower // container ID -> running follower
|
||||
checkpoint map[string]time.Time // container ID -> timestamp of the last line read
|
||||
followers map[string]*follower // container ID -> running follower
|
||||
checkpoint map[string]time.Time // container ID -> timestamp of the last line stored
|
||||
dirty bool
|
||||
connected bool
|
||||
lastErr string
|
||||
@@ -242,7 +242,7 @@ func (m *DockerManager) setError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.lastErr != err.Error() {
|
||||
log.Printf("docker: %v", err)
|
||||
logEvent("docker", 3, "Docker API unreachable: %v", err)
|
||||
}
|
||||
m.connected = false
|
||||
m.lastErr = err.Error()
|
||||
@@ -290,7 +290,7 @@ func (m *DockerManager) sync(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.connected {
|
||||
log.Printf("docker: connected, %d containers", len(list))
|
||||
logEvent("docker", 6, "connected to the Docker API, %d containers", len(list))
|
||||
}
|
||||
m.connected, m.lastErr = true, ""
|
||||
seen := map[string]bool{}
|
||||
@@ -371,7 +371,7 @@ func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("docker %s: %v", c.Name, err)
|
||||
logEvent("docker", 4, "container %s: cannot inspect it: %v", c.Name, err)
|
||||
sleepCtx(ctx, 5*time.Second)
|
||||
continue
|
||||
}
|
||||
@@ -383,22 +383,25 @@ func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
log.Printf("docker %s: %v", c.Name, err)
|
||||
logEvent("docker", 4, "container %s: log stream lost, reconnecting: %v", c.Name, err)
|
||||
}
|
||||
sleepCtx(ctx, time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) {
|
||||
// sleepCtx waits for d, or returns false if the context ends first.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// since returns where to resume reading: just after the last line read, or
|
||||
// since returns where to resume reading: just after the last line stored, or
|
||||
// DOCKER_BACKFILL ago for a container seen for the first time.
|
||||
func (m *DockerManager) since(id string) time.Time {
|
||||
m.mu.Lock()
|
||||
@@ -530,8 +533,6 @@ func (m *DockerManager) emit(c DockerContainer, stream string, line []byte) {
|
||||
} else if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
ts, s = t, ""
|
||||
}
|
||||
defer m.setCheckpoint(c.ID, ts)
|
||||
|
||||
s = ansiRe.ReplaceAllString(s, "")
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, string(utf8.RuneError))
|
||||
@@ -575,6 +576,10 @@ func (m *DockerManager) emit(c DockerContainer, stream string, line []byte) {
|
||||
"compose_service": c.Service,
|
||||
"stream": stream,
|
||||
},
|
||||
// Docker keeps the logs: wait for room in the queue rather than drop the
|
||||
// line, and resume after it only once it is stored.
|
||||
Wait: true,
|
||||
Done: func() { m.setCheckpoint(c.ID, ts) },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Request guards shared by every auth mode: security headers, a cross-site
|
||||
// request check, and the read-only role.
|
||||
|
||||
type viewerKey struct{}
|
||||
|
||||
// asViewer marks the request as made by a read-only user.
|
||||
func asViewer(r *http.Request) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), viewerKey{}, true))
|
||||
}
|
||||
|
||||
func isViewer(r *http.Request) bool {
|
||||
v, _ := r.Context().Value(viewerKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func roleName(viewer bool) string {
|
||||
if viewer {
|
||||
return "viewer"
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func isSafeMethod(m string) bool {
|
||||
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
|
||||
}
|
||||
|
||||
// readOnly refuses the API calls that change something (tags, sources, purge)
|
||||
// to read-only users. Without authentication everyone is admin.
|
||||
func readOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isViewer(r) && !isSafeMethod(r.Method) && strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// crossSite tells whether a request that changes something comes from another
|
||||
// site (a form or script on a third-party page), using the headers browsers
|
||||
// add; tools such as curl send neither and are let through.
|
||||
func crossSite(r *http.Request) bool {
|
||||
switch r.Header.Get("Sec-Fetch-Site") {
|
||||
case "same-origin", "none":
|
||||
return false
|
||||
case "":
|
||||
default: // same-site, cross-site
|
||||
return true
|
||||
}
|
||||
o := r.Header.Get("Origin")
|
||||
if o == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(o)
|
||||
return err != nil || !strings.EqualFold(u.Host, r.Host)
|
||||
}
|
||||
|
||||
// secure adds the security headers to every answer and refuses cross-site
|
||||
// changes. Without authentication it also answers /auth/me, so the UI can
|
||||
// warn that anyone on the network has full access.
|
||||
func secure(next http.Handler, csp string, authOn bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
if !isSafeMethod(r.Method) && crossSite(r) {
|
||||
writeErr(w, http.StatusForbidden, &codedError{code: "cross_site", msg: "cross-site request refused"})
|
||||
return
|
||||
}
|
||||
if !authOn && r.URL.Path == "/auth/me" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "none", "role": "admin"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var inlineScript = regexp.MustCompile(`(?s)<script>(.*?)</script>`)
|
||||
|
||||
// contentSecurityPolicy allows the UI's own files, the inline scripts of the
|
||||
// embedded pages (by hash) and the optional Bunny Fonts.
|
||||
func contentSecurityPolicy(static fs.FS) string {
|
||||
scripts := []string{"'self'"}
|
||||
for _, page := range []string{"index.html", "login.html"} {
|
||||
b, err := fs.ReadFile(static, page)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range inlineScript.FindAllSubmatch(b, -1) {
|
||||
sum := sha256.Sum256(m[1])
|
||||
scripts = append(scripts, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
|
||||
}
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"script-src " + strings.Join(scripts, " "),
|
||||
// Inline style attributes carry the tag and project colors.
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
|
||||
"font-src 'self' https://fonts.bunny.net",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}, "; ")
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var echo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) })
|
||||
|
||||
func TestSecureHeadersAndCrossSite(t *testing.T) {
|
||||
h := secure(echo, "default-src 'self'", false)
|
||||
cases := []struct {
|
||||
method string
|
||||
hdr map[string]string
|
||||
want int
|
||||
}{
|
||||
{"GET", map[string]string{"Sec-Fetch-Site": "cross-site"}, 200}, // reading is fine
|
||||
{"POST", nil, 200}, // curl, scripts
|
||||
{"POST", map[string]string{"Sec-Fetch-Site": "same-origin"}, 200},
|
||||
{"POST", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
|
||||
{"DELETE", map[string]string{"Sec-Fetch-Site": "same-site"}, 403},
|
||||
{"POST", map[string]string{"Origin": "http://logs.lan:8080"}, 200},
|
||||
{"POST", map[string]string{"Origin": "https://evil.example"}, 403},
|
||||
{"PUT", map[string]string{"Origin": "null"}, 403},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(c.method, "http://logs.lan:8080/api/purge", nil)
|
||||
for k, v := range c.hdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("%s %v: %d, want %d", c.method, c.hdr, rec.Code, c.want)
|
||||
}
|
||||
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("%s %v: security headers missing", c.method, c.hdr)
|
||||
}
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
|
||||
if !strings.Contains(rec.Body.String(), `"mode":"none"`) || !strings.Contains(rec.Body.String(), `"role":"admin"`) {
|
||||
t.Errorf("/auth/me without auth: %s", rec.Body)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
secure(echo, "", true).ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
|
||||
if rec.Body.String() != "app /auth/me" {
|
||||
t.Errorf("/auth/me with auth answered by the guard: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSecurityPolicyHashesInlineScripts(t *testing.T) {
|
||||
static, _ := fs.Sub(webFS, "web")
|
||||
csp := contentSecurityPolicy(static)
|
||||
// index.html and login.html each have inline scripts.
|
||||
if n := strings.Count(csp, "'sha256-"); n < 3 {
|
||||
t.Errorf("%d script hashes in %q", n, csp)
|
||||
}
|
||||
for _, want := range []string{"frame-ancestors 'none'", "connect-src 'self'", "https://fonts.bunny.net"} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalViewerIsReadOnly(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
h, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", viewerUser: "guest", viewerPass: "ro", dataDir: t.TempDir()}, readOnly(echo))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := &http.Client{Jar: jar, Transport: hosts{"app.test": h}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
app := "http://app.test"
|
||||
|
||||
login(t, c, app, "guest", "ro", "/")
|
||||
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"role":"viewer"`) {
|
||||
t.Fatalf("me: %d %s", code, body)
|
||||
}
|
||||
if code, _ := get(t, c, app+"/api/logs"); code != 200 {
|
||||
t.Errorf("viewer reading logs: %d", code)
|
||||
}
|
||||
res, err := c.Post(app+"/api/purge", "application/json", strings.NewReader(`{"confirm":"PURGE"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("viewer purge: %d, want 403", res.StatusCode)
|
||||
}
|
||||
|
||||
// The admin account still changes things, also through Basic auth.
|
||||
req, _ := http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
|
||||
req.SetBasicAuth("admin", "pw")
|
||||
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != 200 {
|
||||
t.Errorf("admin change: %d", res.StatusCode)
|
||||
}
|
||||
req, _ = http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
|
||||
req.SetBasicAuth("guest", "ro")
|
||||
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("viewer change through Basic auth: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAdminGroup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
groups any
|
||||
role string
|
||||
}{
|
||||
{[]any{"staff", "/logstream-admins"}, "admin"},
|
||||
{[]any{"staff"}, "viewer"},
|
||||
{nil, "viewer"},
|
||||
} {
|
||||
idp := newFakeIdP(t)
|
||||
idp.claims = func(c map[string]any) {
|
||||
if tc.groups != nil {
|
||||
c["groups"] = tc.groups
|
||||
}
|
||||
}
|
||||
h, err := newAuth(authConfig{
|
||||
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
|
||||
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(), adminGroup: "logstream-admins",
|
||||
}, readOnly(echo))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
netw := hosts{"app.test": h, "idp.test": idp.mux}
|
||||
h.(*OIDC).client.Transport = netw
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := &http.Client{Jar: jar, Transport: netw}
|
||||
get(t, c, "http://app.test/")
|
||||
if _, body := get(t, c, "http://app.test/auth/me"); !strings.Contains(body, `"role":"`+tc.role+`"`) {
|
||||
t.Errorf("groups %v: %s, want role %s", tc.groups, body, tc.role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasGroup(t *testing.T) {
|
||||
if !hasGroup("ops logstream-admins", "/logstream-admins") || !hasGroup([]any{"a", "b"}, "b") || hasGroup(42, "b") {
|
||||
t.Error("hasGroup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPIdleTimeout(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer b.Close()
|
||||
c := idleConn{a, 30 * time.Millisecond}
|
||||
go func() { _, _ = b.Write([]byte("x")) }()
|
||||
buf := make([]byte, 1)
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := c.Read(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("silent connection: %v, want a deadline error", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Error("deadline not applied")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -153,7 +153,7 @@ func TestHistogramHandler(t *testing.T) {
|
||||
{"_time":"2000-01-01T00:00:00Z","severity":"info","hits":"9"}
|
||||
`, recent.Format(time.RFC3339), recent.Format(time.RFC3339), older.Format(time.RFC3339))
|
||||
}}
|
||||
store := NewStore("http://vl", 10, 10, time.Second)
|
||||
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||
store.streamClient.Transport = vl
|
||||
a := &API{store: store}
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestHistogramDayInParis(t *testing.T) {
|
||||
{"_time":"2026-09-21T20:00:00Z","severity":"info","hits":"100"}
|
||||
`
|
||||
}}
|
||||
store := NewStore("http://vl", 10, 10, time.Second)
|
||||
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||
store.streamClient.Transport = vl
|
||||
a := &API{store: store}
|
||||
from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
+7
-2
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -116,14 +115,18 @@ func (h *HostLogs) saveState() {
|
||||
}
|
||||
h.dirty = false
|
||||
if err := writeJSONFile(h.statePath, h.pos); err != nil {
|
||||
log.Printf("host logs: saving positions: %v", err)
|
||||
logEvent("host", 4, "saving positions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HostLogs) setStatus(mode string, files int, code, detail string) {
|
||||
h.mu.Lock()
|
||||
changed := code != h.errCode
|
||||
h.mode, h.files, h.errCode, h.errDetail = mode, files, code, detail
|
||||
h.mu.Unlock()
|
||||
if changed && code != "" && code != "empty" {
|
||||
logEvent("host", 4, "cannot read the host system logs (%s): %s", code, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// scan reads what was added since the previous poll.
|
||||
@@ -327,6 +330,7 @@ func (h *HostLogs) readFile(path, name string, off int64) (int64, error) {
|
||||
}
|
||||
e.SourceType = "host"
|
||||
e.Extra = map[string]string{"log_file": "/var/log/" + name}
|
||||
e.Wait = true
|
||||
h.sink(e)
|
||||
h.count(1, 0)
|
||||
}
|
||||
@@ -400,6 +404,7 @@ func (h *HostLogs) emitJournal(je *journalEntry) {
|
||||
Proto: "journal",
|
||||
SourceType: "host",
|
||||
Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]},
|
||||
Wait: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,15 @@ type config struct {
|
||||
backfill time.Duration
|
||||
hostRoot string
|
||||
hostFill time.Duration
|
||||
kmsgDevice string
|
||||
kmsgFill time.Duration
|
||||
auditLog string
|
||||
auditHost string
|
||||
auditFill time.Duration
|
||||
batchSize int
|
||||
queueSize int
|
||||
flushEvery time.Duration
|
||||
spoolMax int64
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
@@ -57,6 +63,14 @@ func getenvInt(key string, def int) int {
|
||||
return def
|
||||
}
|
||||
|
||||
// getenvIntZero is getenvInt that also accepts 0 (to turn a feature off).
|
||||
func getenvIntZero(key string, def int) int {
|
||||
if v, err := strconv.Atoi(os.Getenv(key)); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getenvBool(key string, def bool) bool {
|
||||
switch strings.ToLower(os.Getenv(key)) {
|
||||
case "1", "true", "yes", "on":
|
||||
@@ -84,6 +98,10 @@ func main() {
|
||||
mode: getenv("AUTH_MODE", "local"),
|
||||
user: os.Getenv("AUTH_USER"),
|
||||
pass: os.Getenv("AUTH_PASS"),
|
||||
viewerUser: os.Getenv("AUTH_VIEWER_USER"),
|
||||
viewerPass: os.Getenv("AUTH_VIEWER_PASS"),
|
||||
adminGroup: os.Getenv("OIDC_ADMIN_GROUP"),
|
||||
groupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
|
||||
issuer: os.Getenv("OIDC_ISSUER"),
|
||||
clientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
@@ -95,16 +113,22 @@ func main() {
|
||||
},
|
||||
rdns: getenvBool("RDNS", true),
|
||||
dnsServer: os.Getenv("DNS_SERVER"),
|
||||
allowPurge: getenvBool("ALLOW_PURGE", true),
|
||||
allowPurge: getenvBool("ALLOW_PURGE", false),
|
||||
exportMax: getenvInt("EXPORT_MAX", 100000),
|
||||
dockerLogs: getenvBool("DOCKER_LOGS", false),
|
||||
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
|
||||
hostRoot: getenv("HOST_LOGS_ROOT", "/host"),
|
||||
hostFill: getenvDuration("HOST_LOGS_BACKFILL", time.Hour),
|
||||
kmsgDevice: getenv("DMESG_DEVICE", "/dev/kmsg"),
|
||||
kmsgFill: getenvDuration("DMESG_BACKFILL", time.Hour),
|
||||
auditLog: getenv("AUDIT_LOG", "/var/log/audit/audit.log"),
|
||||
auditHost: os.Getenv("AUDIT_HOST"),
|
||||
auditFill: getenvDuration("AUDIT_BACKFILL", time.Hour),
|
||||
batchSize: getenvInt("BATCH_SIZE", 1000),
|
||||
queueSize: getenvInt("QUEUE_SIZE", 100000),
|
||||
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
||||
spoolMax: int64(getenvIntZero("SPOOL_MAX_MB", 1024)) << 20,
|
||||
}
|
||||
|
||||
cfg.auth.dataDir = cfg.dataDir
|
||||
@@ -117,10 +141,19 @@ func main() {
|
||||
log.Fatalf("tags: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery)
|
||||
var spool *Spool
|
||||
if cfg.spoolMax > 0 {
|
||||
if spool, err = OpenSpool(filepath.Join(cfg.dataDir, "spool"), cfg.spoolMax); err != nil {
|
||||
log.Printf("disk buffer disabled: %v", err) // before the self-logs exist
|
||||
spool = nil
|
||||
}
|
||||
}
|
||||
store := NewStore(cfg.vlogsURL, cfg.batchSize, cfg.queueSize, cfg.flushEvery, spool)
|
||||
// The store stops after everything else, so the stop event is still stored.
|
||||
storeCtx, stopStore := context.WithCancel(context.Background())
|
||||
storeDone := make(chan struct{})
|
||||
go func() {
|
||||
store.Run(ctx)
|
||||
store.Run(storeCtx)
|
||||
close(storeDone)
|
||||
}()
|
||||
|
||||
@@ -128,12 +161,33 @@ func main() {
|
||||
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
|
||||
sink := func(e *Entry) {
|
||||
// Host sent as an IP (or no host in the header): replace it with its DNS name.
|
||||
// A new IP waits at most 300 ms; slower lookups finish in the background.
|
||||
if name := rdns.Lookup(e.Host, 300*time.Millisecond); name != "" {
|
||||
e.HostIP, e.Host = e.Host, name
|
||||
}
|
||||
// A new IP waits at most 300 ms, without holding up the listener; slower
|
||||
// lookups finish in the background.
|
||||
rdns.Resolve(e.Host, 300*time.Millisecond, func(name string) {
|
||||
if name != "" {
|
||||
e.HostIP, e.Host = e.Host, name
|
||||
}
|
||||
store.Enqueue(e)
|
||||
hub.Publish(e)
|
||||
})
|
||||
}
|
||||
// Internal events of LogStream (host=logstream), WARNING and above unless
|
||||
// changed in Settings > Sources.
|
||||
selfLog = NewSelfLog(cfg.dataDir, func(e *Entry) {
|
||||
store.Enqueue(e)
|
||||
hub.Publish(e)
|
||||
})
|
||||
selfCtx, stopSelf := context.WithCancel(context.Background())
|
||||
selfDone := make(chan struct{})
|
||||
go func() {
|
||||
selfLog.Run(selfCtx)
|
||||
close(selfDone)
|
||||
}()
|
||||
logEvent("main", 5, "LogStream started")
|
||||
|
||||
tcpMaxConns = getenvInt("SYSLOG_TCP_MAX_CONNS", tcpMaxConns)
|
||||
if d := getenvDuration("SYSLOG_TCP_IDLE", tcpIdle); d > 0 {
|
||||
tcpIdle = d
|
||||
}
|
||||
// Listening errors (port already used…) are shown in Settings > Sources.
|
||||
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
|
||||
@@ -148,7 +202,7 @@ func main() {
|
||||
if cfg.dockerLogs {
|
||||
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
||||
if err != nil {
|
||||
log.Printf("docker logs disabled: %v", err)
|
||||
logEvent("docker", 3, "Docker logs disabled: %v", err)
|
||||
} else {
|
||||
go dm.Run(ctx)
|
||||
api.docker = dm
|
||||
@@ -158,20 +212,35 @@ func main() {
|
||||
// System logs of the host (journal or /var/log), off until enabled in Settings > Sources.
|
||||
api.host = NewHostLogs(cfg.hostRoot, cfg.dataDir, cfg.hostFill, sink)
|
||||
go api.host.Run(ctx)
|
||||
// Kernel messages of the host (/dev/kmsg), off until enabled in Settings > Sources.
|
||||
api.dmesg = NewDmesg(cfg.kmsgDevice, cfg.dataDir, cfg.kmsgFill, sink)
|
||||
go api.dmesg.Run(ctx)
|
||||
// Linux audit log of the host (auditd), off until enabled in Settings > Sources.
|
||||
api.audit = NewAudit(cfg.auditLog, cfg.hostRoot, cfg.auditHost, cfg.dataDir, cfg.auditFill, sink)
|
||||
go api.audit.Run(ctx)
|
||||
api.selfLog = selfLog
|
||||
api.Routes(mux)
|
||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||
|
||||
handler, err := newAuth(cfg.auth, mux)
|
||||
handler, err := newAuth(cfg.auth, readOnly(mux))
|
||||
if err != nil {
|
||||
log.Fatalf("auth: %v", err)
|
||||
}
|
||||
if o, ok := handler.(*OIDC); ok {
|
||||
go o.checkProvider()
|
||||
}
|
||||
_, local := handler.(*Local)
|
||||
_, oidc := handler.(*OIDC)
|
||||
authOn := local || oidc
|
||||
if !authOn {
|
||||
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
|
||||
}
|
||||
handler = secure(handler, contentSecurityPolicy(static), authOn)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
// Requests inherit the global context so SSE streams end on shutdown.
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
}
|
||||
@@ -186,6 +255,10 @@ func main() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http: %v", err)
|
||||
}
|
||||
logEvent("main", 5, "LogStream stopping")
|
||||
stopSelf()
|
||||
<-selfDone
|
||||
stopStore()
|
||||
<-storeDone
|
||||
log.Println("shutdown complete")
|
||||
}
|
||||
@@ -119,10 +119,10 @@ func (f Filter) filterExpr() string {
|
||||
parts = append(parts, "app:="+strconv.Quote(f.App))
|
||||
}
|
||||
switch f.Source {
|
||||
case "docker", "host":
|
||||
case "docker", "host", "kernel", "audit", "logstream":
|
||||
parts = append(parts, `source_type:=`+strconv.Quote(f.Source))
|
||||
case "syslog": // also matches logs stored before source_type existed
|
||||
parts = append(parts, `!(source_type:in("docker","host"))`)
|
||||
parts = append(parts, `!(source_type:in("docker","host","kernel","audit","logstream"))`)
|
||||
}
|
||||
if f.Severity >= 0 && f.Severity < 7 {
|
||||
names := make([]string, 0, 8)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -15,17 +16,26 @@ type ReverseDNS struct {
|
||||
posTTL time.Duration // cache duration of a found name
|
||||
negTTL time.Duration // cache duration of "no name"
|
||||
|
||||
lookups chan struct{} // limits the lookups running at once
|
||||
waiters chan struct{} // limits the messages waiting for a lookup (Resolve)
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*rdnsEntry
|
||||
cache map[string]*list.Element // ip -> element of lru
|
||||
lru *list.List // *rdnsEntry, most recently used first
|
||||
}
|
||||
|
||||
type rdnsEntry struct {
|
||||
ip string
|
||||
name string
|
||||
expires time.Time
|
||||
done chan struct{} // closed once the lookup has finished
|
||||
}
|
||||
|
||||
const rdnsMaxEntries = 10000
|
||||
const (
|
||||
rdnsMaxEntries = 10000
|
||||
rdnsMaxLookups = 64
|
||||
rdnsMaxWaiters = 1024
|
||||
)
|
||||
|
||||
// NewReverseDNS uses the system resolver, or `server` ("ip" or "ip:port") when set.
|
||||
func NewReverseDNS(enabled bool, server string) *ReverseDNS {
|
||||
@@ -47,7 +57,10 @@ func NewReverseDNS(enabled bool, server string) *ReverseDNS {
|
||||
r: r,
|
||||
posTTL: time.Hour,
|
||||
negTTL: 10 * time.Minute,
|
||||
cache: make(map[string]*rdnsEntry),
|
||||
lookups: make(chan struct{}, rdnsMaxLookups),
|
||||
waiters: make(chan struct{}, rdnsMaxWaiters),
|
||||
cache: make(map[string]*list.Element),
|
||||
lru: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +73,38 @@ func isClosed(ch chan struct{}) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// entry returns the cache entry of ip, starting its lookup when it is missing
|
||||
// or expired, or nil when too many lookups are already running (a flood of
|
||||
// unknown addresses). The least recently used entry makes room for a new one.
|
||||
func (d *ReverseDNS) entry(ip string) *rdnsEntry {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if el := d.cache[ip]; el != nil {
|
||||
e := el.Value.(*rdnsEntry)
|
||||
if !isClosed(e.done) || time.Now().Before(e.expires) {
|
||||
d.lru.MoveToFront(el)
|
||||
return e
|
||||
}
|
||||
}
|
||||
select {
|
||||
case d.lookups <- struct{}{}:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if el := d.cache[ip]; el != nil {
|
||||
d.lru.Remove(el)
|
||||
}
|
||||
for d.lru.Len() >= rdnsMaxEntries {
|
||||
old := d.lru.Back()
|
||||
d.lru.Remove(old)
|
||||
delete(d.cache, old.Value.(*rdnsEntry).ip)
|
||||
}
|
||||
e := &rdnsEntry{ip: ip, done: make(chan struct{})}
|
||||
d.cache[ip] = d.lru.PushFront(e)
|
||||
go d.resolve(ip, e)
|
||||
return e
|
||||
}
|
||||
|
||||
// Lookup returns the name of ip, or "" when ip is not an IP address, has no
|
||||
// PTR record, or is not resolved within `wait`. A lookup that takes longer
|
||||
// keeps running in the background and fills the cache for later calls.
|
||||
@@ -67,18 +112,10 @@ func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
|
||||
if !d.enabled || net.ParseIP(ip) == nil {
|
||||
return ""
|
||||
}
|
||||
d.mu.Lock()
|
||||
e := d.cache[ip]
|
||||
if e == nil || (isClosed(e.done) && time.Now().After(e.expires)) {
|
||||
if len(d.cache) >= rdnsMaxEntries {
|
||||
d.cache = make(map[string]*rdnsEntry)
|
||||
}
|
||||
e = &rdnsEntry{done: make(chan struct{})}
|
||||
d.cache[ip] = e
|
||||
go d.resolve(ip, e)
|
||||
e := d.entry(ip)
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if !isClosed(e.done) {
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
@@ -91,6 +128,43 @@ func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
|
||||
return e.name
|
||||
}
|
||||
|
||||
// Resolve is Lookup without blocking the caller: fn gets the name (or "") at
|
||||
// once when it is known, otherwise from a goroutine after at most `wait`. The
|
||||
// syslog listeners use it so that a slow DNS server never delays the reading
|
||||
// of the next messages.
|
||||
func (d *ReverseDNS) Resolve(ip string, wait time.Duration, fn func(name string)) {
|
||||
if !d.enabled || net.ParseIP(ip) == nil {
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
e := d.entry(ip)
|
||||
switch {
|
||||
case e == nil:
|
||||
fn("")
|
||||
return
|
||||
case isClosed(e.done):
|
||||
fn(e.name)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case d.waiters <- struct{}{}:
|
||||
default:
|
||||
fn("") // too many messages waiting already
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-d.waiters }()
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-e.done:
|
||||
fn(e.name)
|
||||
case <-timer.C:
|
||||
fn("")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// LookupMany resolves several addresses in parallel; unresolved ones are absent.
|
||||
func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string {
|
||||
out := make(map[string]string)
|
||||
@@ -112,6 +186,7 @@ func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]str
|
||||
}
|
||||
|
||||
func (d *ReverseDNS) resolve(ip string, e *rdnsEntry) {
|
||||
defer func() { <-d.lookups }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
ttl := d.negTTL
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// slowDNS never answers before the context ends.
|
||||
func slowDNS() *ReverseDNS {
|
||||
d := NewReverseDNS(true, "")
|
||||
d.r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
<-ctx.Done()
|
||||
return nil, errors.New("timeout")
|
||||
}}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestResolveDoesNotBlock(t *testing.T) {
|
||||
d := slowDNS()
|
||||
got := make(chan string, 1)
|
||||
start := time.Now()
|
||||
d.Resolve("192.0.2.1", 50*time.Millisecond, func(name string) { got <- name })
|
||||
if time.Since(start) > 20*time.Millisecond {
|
||||
t.Fatal("Resolve waited for the DNS server")
|
||||
}
|
||||
if name := <-got; name != "" {
|
||||
t.Errorf("name %q, want none", name)
|
||||
}
|
||||
|
||||
// Not an IP address: the callback runs at once.
|
||||
called := false
|
||||
d.Resolve("router", time.Second, func(name string) { called = name == "" })
|
||||
if !called {
|
||||
t.Error("callback not called synchronously for a host name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseDNSLimits(t *testing.T) {
|
||||
d := slowDNS()
|
||||
// Lookups beyond the limit are not started (and not cached).
|
||||
for i := 0; i < rdnsMaxLookups+10; i++ {
|
||||
d.Lookup(net.IPv4(10, 0, byte(i>>8), byte(i)).String(), 0)
|
||||
}
|
||||
if n := d.lru.Len(); n != rdnsMaxLookups {
|
||||
t.Errorf("%d entries, want %d", n, rdnsMaxLookups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseDNSEvictsLeastRecentlyUsed(t *testing.T) {
|
||||
d := slowDNS()
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
add := func(ip string) {
|
||||
e := &rdnsEntry{ip: ip, name: ip + ".lan", expires: time.Now().Add(time.Hour), done: done}
|
||||
d.cache[ip] = d.lru.PushFront(e)
|
||||
}
|
||||
for i := 0; i < rdnsMaxEntries; i++ {
|
||||
add(net.IPv4(10, 1, byte(i>>8), byte(i)).String())
|
||||
}
|
||||
first := net.IPv4(10, 1, 0, 0).String()
|
||||
if name := d.Lookup(first, 0); name != first+".lan" { // now the most recent
|
||||
t.Fatalf("cached name %q", name)
|
||||
}
|
||||
d.entry("192.0.2.9")
|
||||
if d.lru.Len() != rdnsMaxEntries {
|
||||
t.Errorf("%d entries, want %d", d.lru.Len(), rdnsMaxEntries)
|
||||
}
|
||||
if d.cache[first] == nil {
|
||||
t.Error("recently used entry evicted")
|
||||
}
|
||||
if d.cache[net.IPv4(10, 1, 0, 1).String()] != nil {
|
||||
t.Error("least recently used entry kept")
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// selfLogConfig is saved in /data/selflog.json and edited in Settings > Sources.
|
||||
type selfLogConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLevel string `json:"min_level"` // severity name: "err", "warning", "notice", "info"…
|
||||
}
|
||||
|
||||
// SelfLog turns the internal events of LogStream (storage errors, listener
|
||||
// failures, sources that stop…) into log entries, with host=logstream and
|
||||
// app=component. Events go through a bounded queue and a per-message
|
||||
// throttle: an event about a failing Store is itself stored through that
|
||||
// Store, so it must never be able to loop or pile up.
|
||||
type SelfLog struct {
|
||||
cfgPath string
|
||||
sink func(*Entry)
|
||||
queue chan *Entry
|
||||
interval time.Duration // one event per component and message format per interval
|
||||
|
||||
mu sync.Mutex
|
||||
cfg selfLogConfig
|
||||
last map[string]time.Time
|
||||
suppressed map[string]int
|
||||
emitted uint64
|
||||
dropped uint64
|
||||
throttled uint64
|
||||
}
|
||||
|
||||
// selfLog receives the events of logEvent; nil (tests) only writes them to
|
||||
// the process log.
|
||||
var selfLog *SelfLog
|
||||
|
||||
func NewSelfLog(dataDir string, sink func(*Entry)) *SelfLog {
|
||||
s := &SelfLog{
|
||||
cfgPath: filepath.Join(dataDir, "selflog.json"),
|
||||
sink: sink,
|
||||
queue: make(chan *Entry, 256),
|
||||
interval: 30 * time.Second,
|
||||
cfg: selfLogConfig{Enabled: true, MinLevel: "warning"},
|
||||
last: map[string]time.Time{},
|
||||
suppressed: map[string]int{},
|
||||
}
|
||||
if b, err := os.ReadFile(s.cfgPath); err == nil {
|
||||
var cfg selfLogConfig
|
||||
if json.Unmarshal(b, &cfg) == nil {
|
||||
s.cfg = normSelfLogConfig(cfg)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normSelfLogConfig(cfg selfLogConfig) selfLogConfig {
|
||||
if sevNum(cfg.MinLevel) < 0 {
|
||||
cfg.MinLevel = "warning"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func sevNum(name string) int {
|
||||
for i, n := range severityNames {
|
||||
if n == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// logEvent writes an internal event to the process log and, when the
|
||||
// self-logs source is on and the level is high enough, emits it as an entry.
|
||||
// sev is a syslog severity (3 err, 4 warning, 5 notice, 6 info).
|
||||
func logEvent(component string, sev int, format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Printf("%s: %s", component, msg)
|
||||
if s := selfLog; s != nil {
|
||||
s.Emit(component, sev, format, msg, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// Emit queues an event without ever blocking. The same component and message
|
||||
// format are let through once per interval; the next one says how many were
|
||||
// left out in between.
|
||||
func (s *SelfLog) Emit(component string, sev int, format, msg string, now time.Time) {
|
||||
sev = min(max(sev, 0), 7)
|
||||
key := component + "\x00" + format
|
||||
s.mu.Lock()
|
||||
if !s.cfg.Enabled || sev > sevNum(s.cfg.MinLevel) {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if t, ok := s.last[key]; ok && now.Sub(t) < s.interval {
|
||||
s.suppressed[key]++
|
||||
s.throttled++
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.last[key] = now
|
||||
if n := s.suppressed[key]; n > 0 {
|
||||
msg += fmt.Sprintf(" (%d similar events not shown)", n)
|
||||
delete(s.suppressed, key)
|
||||
}
|
||||
if len(s.last) > 1000 { // formats are constants: only a bug could get here
|
||||
clear(s.last)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
e := &Entry{
|
||||
Time: now,
|
||||
Received: now,
|
||||
Host: "logstream",
|
||||
App: component,
|
||||
Facility: "syslog",
|
||||
Severity: severityNames[sev],
|
||||
SevNum: sev,
|
||||
Message: msg,
|
||||
Proto: "internal",
|
||||
SourceType: "logstream",
|
||||
// Wait stays false: when the Store queue is full, the event is dropped
|
||||
// instead of blocking the component that reports it.
|
||||
}
|
||||
select {
|
||||
case s.queue <- e:
|
||||
default:
|
||||
s.mu.Lock()
|
||||
s.dropped++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Run hands the queued events to the sink until the context ends, then
|
||||
// delivers what is left (the stop event among them).
|
||||
func (s *SelfLog) Run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case e := <-s.queue:
|
||||
s.deliver(e)
|
||||
case <-ctx.Done():
|
||||
for {
|
||||
select {
|
||||
case e := <-s.queue:
|
||||
s.deliver(e)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SelfLog) deliver(e *Entry) {
|
||||
s.sink(e)
|
||||
s.mu.Lock()
|
||||
s.emitted++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Configure saves and applies the configuration.
|
||||
func (s *SelfLog) Configure(cfg selfLogConfig) error {
|
||||
cfg = normSelfLogConfig(cfg)
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.mu.Unlock()
|
||||
return writeJSONFile(s.cfgPath, cfg)
|
||||
}
|
||||
|
||||
// Status is the state shown in Settings > Sources.
|
||||
func (s *SelfLog) Status() map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return map[string]any{
|
||||
"enabled": s.cfg.Enabled,
|
||||
"min_level": s.cfg.MinLevel,
|
||||
"emitted": s.emitted,
|
||||
"dropped": s.dropped,
|
||||
"throttled": s.throttled,
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelfLogEmit(t *testing.T) {
|
||||
now := time.Unix(1759572000, 0)
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg selfLogConfig
|
||||
events []int // severities, one second apart, same message format
|
||||
want int // entries queued
|
||||
suffix string
|
||||
}{
|
||||
{"warning and above", selfLogConfig{Enabled: true, MinLevel: "warning"}, []int{3}, 1, ""},
|
||||
{"below the level", selfLogConfig{Enabled: true, MinLevel: "warning"}, []int{5, 6}, 0, ""},
|
||||
{"info level", selfLogConfig{Enabled: true, MinLevel: "info"}, []int{6}, 1, ""},
|
||||
{"disabled", selfLogConfig{Enabled: false, MinLevel: "debug"}, []int{0}, 0, ""},
|
||||
{"bad level falls back to warning", normSelfLogConfig(selfLogConfig{Enabled: true, MinLevel: "loud"}), []int{4, 5}, 1, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := NewSelfLog(t.TempDir(), func(*Entry) {})
|
||||
s.cfg = tt.cfg
|
||||
for i, sev := range tt.events {
|
||||
s.Emit("store", sev, "fmt %d", "msg", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
if len(s.queue) != tt.want {
|
||||
t.Fatalf("%d queued, want %d", len(s.queue), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Same component and format: once per interval, then a count of the others.
|
||||
s := NewSelfLog(t.TempDir(), func(*Entry) {})
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Emit("store", 3, "%d messages lost", "x", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
s.Emit("syslog", 3, "%d messages lost", "y", now) // another component
|
||||
s.Emit("store", 3, "%d messages lost", "z", now.Add(s.interval))
|
||||
if len(s.queue) != 3 {
|
||||
t.Fatalf("%d queued", len(s.queue))
|
||||
}
|
||||
<-s.queue
|
||||
if e := <-s.queue; e.App != "syslog" || e.Host != "logstream" || e.SourceType != "logstream" || e.Wait {
|
||||
t.Fatalf("entry %+v", e)
|
||||
}
|
||||
if e := <-s.queue; e.Message != "z (4 similar events not shown)" || e.Severity != "err" {
|
||||
t.Fatalf("message %q", e.Message)
|
||||
}
|
||||
if st := s.Status(); st["throttled"] != uint64(4) {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
|
||||
// A full queue drops events instead of blocking.
|
||||
s = NewSelfLog(t.TempDir(), func(*Entry) {})
|
||||
for i := 0; i < cap(s.queue)+10; i++ {
|
||||
s.Emit("store", 3, "event %d", "x", now.Add(time.Duration(i)*s.interval))
|
||||
}
|
||||
if st := s.Status(); st["dropped"] != uint64(10) {
|
||||
t.Fatalf("status %v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfLogRunAndConfigure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sink := &entrySink{}
|
||||
s := NewSelfLog(dir, sink.add)
|
||||
if err := s.Configure(selfLogConfig{Enabled: true, MinLevel: "notice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, "selflog.json")); !strings.Contains(string(b), `"notice"`) {
|
||||
t.Fatalf("saved %s", b)
|
||||
}
|
||||
if s2 := NewSelfLog(dir, nil); s2.cfg.MinLevel != "notice" {
|
||||
t.Fatalf("reloaded %+v", s2.cfg)
|
||||
}
|
||||
old := selfLog
|
||||
selfLog = s
|
||||
defer func() { selfLog = old }()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { s.Run(ctx); close(done) }()
|
||||
logEvent("main", 5, "LogStream started")
|
||||
logEvent("main", 6, "too verbose")
|
||||
cancel()
|
||||
<-done
|
||||
logEvent("main", 5, "LogStream stopping") // queued after Run: not lost if Run drains it
|
||||
sink.mu.Lock()
|
||||
n := len(sink.es)
|
||||
sink.mu.Unlock()
|
||||
if n != 1 || s.Status()["emitted"] != uint64(1) {
|
||||
t.Fatalf("%d entries, status %v", n, s.Status())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Spool keeps on disk the batches VictoriaLogs could not take, so that they
|
||||
// are sent later instead of being lost. Each batch is one NDJSON file named
|
||||
// <unix nanoseconds>-<lines>.ndjson; files are sent back oldest first.
|
||||
type Spool struct {
|
||||
dir string
|
||||
max int64 // maximum total size in bytes
|
||||
|
||||
mu sync.Mutex
|
||||
size int64 // bytes on disk
|
||||
lines int64 // messages on disk
|
||||
seq int64
|
||||
}
|
||||
|
||||
// errSpoolFull is returned when a batch does not fit within SPOOL_MAX_MB.
|
||||
var errSpoolFull = fmt.Errorf("disk buffer full")
|
||||
|
||||
// OpenSpool creates the directory if needed and counts the batches already
|
||||
// there (left by a previous run).
|
||||
func OpenSpool(dir string, max int64) (*Spool, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Spool{dir: dir, max: max}
|
||||
files, err := s.files()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range files {
|
||||
s.size += f.size
|
||||
s.lines += f.lines
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type spoolFile struct {
|
||||
path string
|
||||
size int64
|
||||
lines int64
|
||||
}
|
||||
|
||||
// files lists the batches on disk, oldest first. Unfinished writes (.tmp)
|
||||
// are removed.
|
||||
func (s *Spool) files() ([]spoolFile, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []spoolFile
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".tmp") {
|
||||
_ = os.Remove(filepath.Join(s.dir, name))
|
||||
continue
|
||||
}
|
||||
base, ok := strings.CutSuffix(name, ".ndjson")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, n, _ := strings.Cut(base, "-")
|
||||
lines, _ := strconv.ParseInt(n, 10, 64)
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, spoolFile{path: filepath.Join(s.dir, name), size: info.Size(), lines: lines})
|
||||
}
|
||||
// The names start with a fixed-width timestamp: string order is time order.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Write saves one batch of `lines` messages.
|
||||
func (s *Spool) Write(body []byte, lines int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.size+int64(len(body)) > s.max {
|
||||
return errSpoolFull
|
||||
}
|
||||
s.seq++
|
||||
name := fmt.Sprintf("%020d%04d-%d.ndjson", time.Now().UnixNano(), s.seq%10000, lines)
|
||||
path := filepath.Join(s.dir, name)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
s.size += int64(len(body))
|
||||
s.lines += int64(lines)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Oldest returns the oldest batch, or ok=false when the spool is empty.
|
||||
func (s *Spool) Oldest() (f spoolFile, body []byte, ok bool, err error) {
|
||||
files, err := s.files()
|
||||
if err != nil || len(files) == 0 {
|
||||
return f, nil, false, err
|
||||
}
|
||||
f = files[0]
|
||||
body, err = os.ReadFile(f.path)
|
||||
return f, body, err == nil, err
|
||||
}
|
||||
|
||||
// Remove deletes a batch once VictoriaLogs has taken it.
|
||||
func (s *Spool) Remove(f spoolFile) {
|
||||
if err := os.Remove(f.path); err != nil && !os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.size -= f.size
|
||||
s.lines -= f.lines
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Pending returns the number of messages and bytes waiting on disk.
|
||||
func (s *Spool) Pending() (lines, size int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lines, s.size
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -17,43 +16,62 @@ import (
|
||||
)
|
||||
|
||||
// Store sends messages to VictoriaLogs in batches and queries it with LogsQL.
|
||||
// Batches VictoriaLogs cannot take go to the disk spool (when enabled) and
|
||||
// are sent again, oldest first, once it answers.
|
||||
type Store struct {
|
||||
base string
|
||||
client *http.Client
|
||||
streamClient *http.Client
|
||||
in chan *Entry
|
||||
batchSize int
|
||||
flushEvery time.Duration
|
||||
in chan *Entry
|
||||
quit chan struct{} // closed on shutdown: unblocks waiting producers
|
||||
batchSize int
|
||||
flushEvery time.Duration
|
||||
spool *Spool // nil: no disk buffer
|
||||
spooled chan struct{} // wakes the replay loop up after a write to the spool
|
||||
|
||||
received atomic.Int64
|
||||
ingested atomic.Int64
|
||||
dropped atomic.Int64
|
||||
lastErr atomic.Value // string
|
||||
failing atomic.Bool // VictoriaLogs refused or missed the last send
|
||||
}
|
||||
|
||||
func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration) *Store {
|
||||
func NewStore(base string, batchSize, queueSize int, flushEvery time.Duration, spool *Spool) *Store {
|
||||
s := &Store{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
base: strings.TrimRight(base, "/"),
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
// No global timeout: a large export can take longer than a minute. The
|
||||
// request context still cancels it when the browser goes away.
|
||||
streamClient: &http.Client{},
|
||||
in: make(chan *Entry, queueSize),
|
||||
batchSize: batchSize,
|
||||
flushEvery: flushEvery,
|
||||
in: make(chan *Entry, queueSize),
|
||||
quit: make(chan struct{}),
|
||||
batchSize: batchSize,
|
||||
flushEvery: flushEvery,
|
||||
spool: spool,
|
||||
spooled: make(chan struct{}, 1),
|
||||
}
|
||||
s.lastErr.Store("")
|
||||
return s
|
||||
}
|
||||
|
||||
// Enqueue never blocks: when the queue is full, the message is counted as dropped.
|
||||
// Enqueue adds a message to the queue. When the queue is full, a message
|
||||
// whose producer can wait (Entry.Wait: Docker, host logs) blocks until there
|
||||
// is room; any other one (syslog) is counted as dropped.
|
||||
func (s *Store) Enqueue(e *Entry) {
|
||||
s.received.Add(1)
|
||||
select {
|
||||
case s.in <- e:
|
||||
return
|
||||
default:
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
if e.Wait {
|
||||
select {
|
||||
case s.in <- e:
|
||||
return
|
||||
case <-s.quit:
|
||||
}
|
||||
}
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
|
||||
// Run drains the queue into VictoriaLogs until the context is cancelled,
|
||||
@@ -62,20 +80,16 @@ func (s *Store) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.flushEvery)
|
||||
defer ticker.Stop()
|
||||
batch := make([]*Entry, 0, s.batchSize)
|
||||
if s.spool != nil {
|
||||
go s.replay(ctx)
|
||||
}
|
||||
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
flush := func(ctx context.Context) {
|
||||
if len(batch) > 0 {
|
||||
s.store(ctx, batch)
|
||||
clear(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
if err := s.insert(batch); err != nil {
|
||||
s.dropped.Add(int64(len(batch)))
|
||||
s.lastErr.Store(err.Error())
|
||||
log.Printf("victorialogs: %d messages dropped: %v", len(batch), err)
|
||||
} else {
|
||||
s.ingested.Add(int64(len(batch)))
|
||||
s.lastErr.Store("")
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -83,20 +97,24 @@ func (s *Store) Run(ctx context.Context) {
|
||||
case e := <-s.in:
|
||||
batch = append(batch, e)
|
||||
if len(batch) >= s.batchSize {
|
||||
flush()
|
||||
flush(ctx)
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
flush(ctx)
|
||||
case <-ctx.Done():
|
||||
close(s.quit)
|
||||
// The last batches get one short attempt, then go to the spool.
|
||||
end, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
select {
|
||||
case e := <-s.in:
|
||||
batch = append(batch, e)
|
||||
if len(batch) >= s.batchSize {
|
||||
flush()
|
||||
flush(end)
|
||||
}
|
||||
default:
|
||||
flush()
|
||||
flush(end)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -104,38 +122,179 @@ func (s *Store) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) insert(batch []*Entry) error {
|
||||
// store sends one batch to VictoriaLogs, or to the spool when VictoriaLogs
|
||||
// fails or older batches are still waiting there (to keep their order).
|
||||
// Entry.Done is called once the batch is stored or spooled.
|
||||
func (s *Store) store(ctx context.Context, batch []*Entry) {
|
||||
body, err := encodeBatch(batch)
|
||||
if err == nil {
|
||||
err = s.save(ctx, body, len(batch))
|
||||
}
|
||||
if err != nil {
|
||||
s.dropped.Add(int64(len(batch)))
|
||||
s.lastErr.Store(err.Error())
|
||||
logEvent("store", 3, "%d messages lost: %v", len(batch), err)
|
||||
return
|
||||
}
|
||||
for _, e := range batch {
|
||||
if e.Done != nil {
|
||||
e.Done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) save(ctx context.Context, body []byte, lines int) error {
|
||||
if s.spool == nil {
|
||||
// No disk buffer: retry a few times, then give up.
|
||||
var err error
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if attempt > 0 && !sleepCtx(ctx, time.Duration(1<<attempt)*500*time.Millisecond) { // 1s, 2s, 4s, 8s
|
||||
break
|
||||
}
|
||||
if err = s.post(ctx, body); err == nil {
|
||||
s.ingested.Add(int64(lines))
|
||||
s.sent()
|
||||
return nil
|
||||
}
|
||||
s.sendFailed(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var postErr error
|
||||
if waiting, _ := s.spool.Pending(); waiting == 0 {
|
||||
if postErr = s.post(ctx, body); postErr == nil {
|
||||
s.ingested.Add(int64(lines))
|
||||
s.sent()
|
||||
return nil
|
||||
}
|
||||
s.sendFailed(postErr)
|
||||
}
|
||||
err := s.spool.Write(body, lines)
|
||||
if err == nil {
|
||||
select {
|
||||
case s.spooled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if postErr != nil {
|
||||
return fmt.Errorf("%v; %v", postErr, err)
|
||||
}
|
||||
// Spool full while older batches wait: one direct attempt before dropping.
|
||||
if postErr = s.post(ctx, body); postErr == nil {
|
||||
s.ingested.Add(int64(lines))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%v; %v", err, postErr)
|
||||
}
|
||||
|
||||
// replay sends the spooled batches back to VictoriaLogs, oldest first, with
|
||||
// an increasing pause (up to 30 s) while it keeps failing.
|
||||
func (s *Store) replay(ctx context.Context) {
|
||||
if n, size := s.spool.Pending(); n > 0 {
|
||||
logEvent("store", 5, "disk spool: %d messages (%d bytes) waiting from a previous run", n, size)
|
||||
}
|
||||
backoff := time.Second
|
||||
for {
|
||||
f, body, ok, err := s.spool.Oldest()
|
||||
if err != nil {
|
||||
logEvent("store", 4, "disk spool: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.spooled:
|
||||
case <-time.After(time.Minute):
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = s.post(ctx, body)
|
||||
switch {
|
||||
case err == nil:
|
||||
s.spool.Remove(f)
|
||||
s.ingested.Add(f.lines)
|
||||
s.sent()
|
||||
backoff = time.Second
|
||||
continue
|
||||
case isRejected(err):
|
||||
// VictoriaLogs refuses the data itself: sending it again would not help.
|
||||
s.spool.Remove(f)
|
||||
s.dropped.Add(f.lines)
|
||||
logEvent("store", 3, "disk spool: %d messages refused by VictoriaLogs and lost: %v", f.lines, err)
|
||||
continue
|
||||
}
|
||||
s.sendFailed(err)
|
||||
if !sleepCtx(ctx, backoff) {
|
||||
return
|
||||
}
|
||||
backoff = min(2*backoff, 30*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// sendFailed and sent report when VictoriaLogs stops and starts taking the
|
||||
// batches again, once per change.
|
||||
func (s *Store) sendFailed(err error) {
|
||||
s.lastErr.Store(err.Error())
|
||||
if !s.failing.Swap(true) {
|
||||
if s.spool != nil {
|
||||
logEvent("store", 4, "VictoriaLogs unreachable, batches are kept in the disk spool: %v", err)
|
||||
} else {
|
||||
logEvent("store", 4, "VictoriaLogs unreachable, retrying: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) sent() {
|
||||
s.lastErr.Store("")
|
||||
if s.failing.Swap(false) {
|
||||
logEvent("store", 5, "VictoriaLogs reachable again")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeBatch(batch []*Entry) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
for _, e := range batch {
|
||||
if err := enc.Encode(e.Record()); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
u := s.base + "/insert/jsonline?_stream_fields=host,app&_msg_field=_msg&_time_field=_time"
|
||||
|
||||
var err error
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(1<<attempt) * 500 * time.Millisecond) // 1s, 2s, 4s, 8s
|
||||
}
|
||||
if err = s.post(u, buf.Bytes()); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *Store) post(u string, body []byte) error {
|
||||
resp, err := s.client.Post(u, "application/stream+json", bytes.NewReader(body))
|
||||
// insertError is an error status of VictoriaLogs to an insert.
|
||||
type insertError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *insertError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.status, e.msg) }
|
||||
|
||||
// isRejected tells whether VictoriaLogs refused the data itself (4xx other
|
||||
// than 429), as opposed to being unreachable or overloaded.
|
||||
func isRejected(err error) bool {
|
||||
var ie *insertError
|
||||
return errors.As(err, &ie) && ie.status >= 400 && ie.status < 500 && ie.status != http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
func (s *Store) post(ctx context.Context, body []byte) error {
|
||||
u := s.base + "/insert/jsonline?_stream_fields=host,app&_msg_field=_msg&_time_field=_time"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/stream+json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
return &insertError{status: resp.StatusCode, msg: strings.TrimSpace(string(msg))}
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
@@ -265,11 +424,15 @@ func queryStatus(err error) int {
|
||||
}
|
||||
|
||||
func (s *Store) Stats() map[string]any {
|
||||
return map[string]any{
|
||||
st := map[string]any{
|
||||
"received": s.received.Load(),
|
||||
"ingested": s.ingested.Load(),
|
||||
"dropped": s.dropped.Load(),
|
||||
"queue": len(s.in),
|
||||
"lastError": s.lastErr.Load(),
|
||||
}
|
||||
if s.spool != nil {
|
||||
st["spooled"], st["spoolBytes"] = s.spool.Pending()
|
||||
}
|
||||
return st
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// insertVL plays VictoriaLogs' insert endpoint: it answers `status` (0 = the
|
||||
// connection fails) and keeps the lines it accepted.
|
||||
type insertVL struct {
|
||||
mu sync.Mutex
|
||||
status int
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (v *insertVL) set(status int) {
|
||||
v.mu.Lock()
|
||||
v.status = status
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
func (v *insertVL) got() []string {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return append([]string(nil), v.lines...)
|
||||
}
|
||||
|
||||
func (v *insertVL) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.status == 0 {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
if v.status == http.StatusOK {
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
v.lines = append(v.lines, l)
|
||||
}
|
||||
}
|
||||
return &http.Response{StatusCode: v.status, Body: io.NopCloser(strings.NewReader("")), Header: http.Header{}}, nil
|
||||
}
|
||||
|
||||
func testEntry(msg string, done *atomic.Int64) *Entry {
|
||||
e := &Entry{Received: time.Now(), Time: time.Now(), Host: "h", App: "a", Message: msg}
|
||||
if done != nil {
|
||||
e.Done = func() { done.Add(1) }
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func TestSpoolFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp, err := OpenSpool(dir, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sp.Write([]byte("a\nb\n"), 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sp.Write([]byte("c\n"), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sp.Write(bytes.Repeat([]byte("x"), 100), 1); !errors.Is(err, errSpoolFull) {
|
||||
t.Fatalf("write over the limit: %v, want errSpoolFull", err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, "broken.ndjson.tmp"), []byte("z"), 0o644)
|
||||
|
||||
// A new run finds the batches left on disk and drops unfinished writes.
|
||||
sp, err = OpenSpool(dir, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, size := sp.Pending(); n != 3 || size != 6 {
|
||||
t.Fatalf("pending %d lines %d bytes, want 3 and 6", n, size)
|
||||
}
|
||||
f, body, ok, err := sp.Oldest()
|
||||
if !ok || err != nil || string(body) != "a\nb\n" || f.lines != 2 {
|
||||
t.Fatalf("oldest: %q %+v %v %v", body, f, ok, err)
|
||||
}
|
||||
sp.Remove(f)
|
||||
if _, body, _, _ := sp.Oldest(); string(body) != "c\n" {
|
||||
t.Fatalf("next oldest %q", body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "broken.ndjson.tmp")); !os.IsNotExist(err) {
|
||||
t.Error("unfinished write left in the spool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSpoolsWhileVictoriaLogsIsDown(t *testing.T) {
|
||||
sp, err := OpenSpool(t.TempDir(), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vl := &insertVL{status: 0}
|
||||
s := NewStore("http://vl", 2, 100, 20*time.Millisecond, sp)
|
||||
s.client.Transport = vl
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go s.Run(ctx)
|
||||
|
||||
var done atomic.Int64
|
||||
for _, m := range []string{"one", "two", "three"} {
|
||||
s.Enqueue(testEntry(m, &done))
|
||||
}
|
||||
// VictoriaLogs is down: the messages are kept on disk, and acknowledged.
|
||||
waitFor(t, "3 spooled messages", func() bool { n, _ := sp.Pending(); return n == 3 })
|
||||
if done.Load() != 3 || s.dropped.Load() != 0 {
|
||||
t.Fatalf("done %d dropped %d, want 3 and 0", done.Load(), s.dropped.Load())
|
||||
}
|
||||
// While batches wait on disk, new ones queue behind them.
|
||||
vl.set(http.StatusServiceUnavailable)
|
||||
s.Enqueue(testEntry("four", &done))
|
||||
waitFor(t, "4 spooled messages", func() bool { n, _ := sp.Pending(); return n == 4 })
|
||||
|
||||
vl.set(http.StatusOK)
|
||||
waitFor(t, "the spool to drain", func() bool { n, _ := sp.Pending(); return n == 0 })
|
||||
got := strings.Join(vl.got(), "\n")
|
||||
for i, m := range []string{"one", "two", "three", "four"} {
|
||||
if !strings.Contains(got, `"_msg":"`+m+`"`) {
|
||||
t.Errorf("message %d %q not sent: %s", i, m, got)
|
||||
}
|
||||
}
|
||||
if strings.Index(got, `"one"`) > strings.Index(got, `"four"`) {
|
||||
t.Error("spooled batches sent out of order")
|
||||
}
|
||||
if s.ingested.Load() != 4 || s.lastErr.Load() != "" {
|
||||
t.Errorf("ingested %d lastErr %q", s.ingested.Load(), s.lastErr.Load())
|
||||
}
|
||||
|
||||
// Once the spool is empty, batches go straight to VictoriaLogs again.
|
||||
s.Enqueue(testEntry("five", &done))
|
||||
waitFor(t, "a direct insert", func() bool { return s.ingested.Load() == 5 })
|
||||
}
|
||||
|
||||
func TestStoreDropsRefusedSpooledBatch(t *testing.T) {
|
||||
sp, _ := OpenSpool(t.TempDir(), 1<<20)
|
||||
_ = sp.Write([]byte("{bad json\n"), 1)
|
||||
vl := &insertVL{status: http.StatusBadRequest}
|
||||
s := NewStore("http://vl", 10, 10, time.Second, sp)
|
||||
s.client.Transport = vl
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go s.Run(ctx)
|
||||
waitFor(t, "the refused batch to be dropped", func() bool { return s.dropped.Load() == 1 })
|
||||
if n, _ := sp.Pending(); n != 0 {
|
||||
t.Errorf("%d messages left in the spool", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreWithoutSpoolDrops(t *testing.T) {
|
||||
vl := &insertVL{status: 0}
|
||||
s := NewStore("http://vl", 10, 10, time.Hour, nil)
|
||||
s.client.Transport = vl
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // shutdown: one attempt, no retry pauses
|
||||
var done atomic.Int64
|
||||
s.store(ctx, []*Entry{testEntry("x", &done)})
|
||||
if done.Load() != 0 || s.dropped.Load() != 1 {
|
||||
t.Errorf("done %d dropped %d, want 0 and 1", done.Load(), s.dropped.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueWait(t *testing.T) {
|
||||
s := NewStore("http://vl", 10, 1, time.Hour, nil)
|
||||
s.Enqueue(testEntry("fills the queue", nil))
|
||||
s.Enqueue(testEntry("syslog: dropped", nil))
|
||||
if s.dropped.Load() != 1 {
|
||||
t.Fatalf("dropped %d, want 1", s.dropped.Load())
|
||||
}
|
||||
// A producer that can wait blocks until there is room…
|
||||
queued := make(chan struct{})
|
||||
go func() {
|
||||
e := testEntry("docker: waits", nil)
|
||||
e.Wait = true
|
||||
s.Enqueue(e)
|
||||
close(queued)
|
||||
}()
|
||||
select {
|
||||
case <-queued:
|
||||
t.Fatal("did not wait for room in the queue")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
<-s.in
|
||||
<-queued
|
||||
if s.dropped.Load() != 1 {
|
||||
t.Errorf("dropped %d, want 1", s.dropped.Load())
|
||||
}
|
||||
// …or until shutdown.
|
||||
close(s.quit)
|
||||
e := testEntry("docker: shutdown", nil)
|
||||
e.Wait = true
|
||||
s.Enqueue(e)
|
||||
if s.dropped.Load() != 2 {
|
||||
t.Errorf("dropped %d after shutdown, want 2", s.dropped.Load())
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,14 @@ type Entry struct {
|
||||
Proto string // udp or tcp
|
||||
HostIP string // original host value when it was an IP resolved through DNS
|
||||
|
||||
SourceType string // "syslog" or "docker"
|
||||
SourceType string // "syslog", "docker", "host", "kernel", "audit" or "logstream"
|
||||
Extra map[string]string // additional fields (docker: container, image, …)
|
||||
|
||||
// Not stored. Wait: the producer can be slowed down when the queue is full
|
||||
// (Docker, host logs) instead of losing the message. Done: called once the
|
||||
// message is stored in VictoriaLogs or in the disk spool.
|
||||
Wait bool
|
||||
Done func()
|
||||
}
|
||||
|
||||
// Record returns the entry in the shape stored in VictoriaLogs and returned by the API.
|
||||
@@ -233,7 +239,7 @@ func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("syslog udp: %v", err)
|
||||
logEvent("syslog", 4, "UDP read error: %v", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@@ -241,21 +247,51 @@ func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) {
|
||||
}
|
||||
}
|
||||
|
||||
// TCP limits: connections open at once, and how long a connection may stay
|
||||
// silent before it is closed (senders reconnect on their own).
|
||||
var (
|
||||
tcpMaxConns = 512
|
||||
tcpIdle = 30 * time.Minute
|
||||
)
|
||||
|
||||
func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
|
||||
slots := make(chan struct{}, tcpMaxConns)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("syslog tcp: %v", err)
|
||||
logEvent("syslog", 4, "TCP accept error: %v", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
go handleTCP(ctx, conn, sink)
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
default:
|
||||
logEvent("syslog", 4, "%d TCP connections already open, refusing %s", tcpMaxConns, conn.RemoteAddr())
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-slots }()
|
||||
handleTCP(ctx, conn, sink)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// idleConn pushes the read deadline back before each read, so only a
|
||||
// connection that stays silent for tcpIdle is closed.
|
||||
type idleConn struct {
|
||||
net.Conn
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func (c idleConn) Read(p []byte) (int, error) {
|
||||
_ = c.Conn.SetReadDeadline(time.Now().Add(c.idle))
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
const maxFrame = 1 << 20
|
||||
|
||||
// handleTCP supports both RFC 6587 framings: octet counting
|
||||
@@ -266,7 +302,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) {
|
||||
defer stop()
|
||||
|
||||
src := hostOf(conn.RemoteAddr())
|
||||
r := bufio.NewReaderSize(conn, 64*1024)
|
||||
r := bufio.NewReaderSize(idleConn{conn, tcpIdle}, 64*1024)
|
||||
for {
|
||||
c, err := r.ReadByte()
|
||||
if err != nil {
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ func (s *SyslogServer) apply() {
|
||||
pc, err := net.ListenPacket("udp", s.addr)
|
||||
if err != nil {
|
||||
s.errUDP = err.Error()
|
||||
log.Printf("syslog udp: %v", err)
|
||||
logEvent("syslog", 3, "UDP listener failed: %v", err)
|
||||
} else {
|
||||
if uc, ok := pc.(*net.UDPConn); ok {
|
||||
_ = uc.SetReadBuffer(8 << 20)
|
||||
@@ -108,7 +108,7 @@ func (s *SyslogServer) apply() {
|
||||
ln, err := net.Listen("tcp", s.addr)
|
||||
if err != nil {
|
||||
s.errTCP = err.Error()
|
||||
log.Printf("syslog tcp: %v", err)
|
||||
logEvent("syslog", 3, "TCP listener failed: %v", err)
|
||||
} else {
|
||||
s.ln = ln
|
||||
go serveTCP(ctx, ln, s.sink)
|
||||
|
||||
+464
-34
@@ -33,6 +33,7 @@ const I18N = {
|
||||
skipped: (n) => `${n} messages not shown (rate too high)`,
|
||||
stats: (s) => `received ${s.received} · stored ${s.ingested} · dropped ${s.dropped} · queue ${s.queue}`,
|
||||
storageErr: 'storage: ',
|
||||
spooled: (n) => `${n} waiting on disk`,
|
||||
unreachable: 'server unreachable',
|
||||
fTime: 'timestamp', fMsg: 'message', fPid: 'pid', fSd: 'structured data',
|
||||
filterHost: 'Filter on this host', filterApp: 'Filter on this app',
|
||||
@@ -49,6 +50,9 @@ const I18N = {
|
||||
confirmDelete: (p) => `Delete tag "${p}"?`,
|
||||
confirmReset: 'Replace all tags with the defaults (warning, error)?',
|
||||
presetAria: 'Add a preset', presetPick: '+ Preset…',
|
||||
tagFilter: 'Filter tags (keyword, label or code)',
|
||||
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total === 1 ? '' : 's'}` : `${n.shown} / ${n.total} tags`),
|
||||
noTagMatch: 'No tag matches the filter.',
|
||||
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: ',
|
||||
@@ -73,6 +77,23 @@ const I18N = {
|
||||
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
|
||||
sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources',
|
||||
srcHost: 'Host system',
|
||||
srcKernel: 'Kernel (dmesg)',
|
||||
auditTitle: 'Linux audit (auditd)',
|
||||
auditEnabled: 'Collect the audit events of this machine',
|
||||
auditPath: 'File',
|
||||
auditOff: 'Off: the audit log of the machine hosting LogStream is not read.',
|
||||
auditOk: ({ p, r }) => `Following ${p}: ${r} events since startup.`,
|
||||
audit_not_found: 'Audit log not found: mount /var/log/audit in the container (see docker-compose.yml) and check the path. ',
|
||||
audit_permission: 'Permission denied on the audit log: set log_group in /etc/audit/auditd.conf, or add an ACL for UID 10001 (see the README). ',
|
||||
audit_read: 'Cannot read the audit log: ',
|
||||
auditHelp: 'Reads the log of the Linux audit daemon (auditd): logins, sudo, watched files, syscalls matched by your rules… The records of one event are grouped in one line, with a summary (who, what, result) and the raw record below it; failures are warnings. When turned on, the last hour is read first (AUDIT_BACKFILL); rotations are followed and reading resumes where it stopped.',
|
||||
selfTitle: 'LogStream internal logs',
|
||||
selfEnabled: 'Record the events of LogStream itself',
|
||||
selfLevel: 'Minimum level',
|
||||
selfOff: 'Off: the events of LogStream only go to its container output.',
|
||||
selfOk: ({ n }) => `${n} events recorded since startup (host logstream).`,
|
||||
selfDropped: (n) => ` ${n} left out (repeated or too many at once).`,
|
||||
selfHelp: 'Turns the events of LogStream into logs (host logstream, app = component): VictoriaLogs unreachable and back, lost batches, syslog listener errors, Docker streams lost, failing sources, start and stop. The same event is recorded at most once every 30 s, with the number of repeats.',
|
||||
hostTitle: 'Host system logs',
|
||||
hostEnabled: 'Collect the system logs of this machine',
|
||||
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
|
||||
@@ -85,6 +106,15 @@ const I18N = {
|
||||
host_empty: 'No systemd journal and no log file found in /var/log.',
|
||||
host_read: 'Cannot read the host logs: ',
|
||||
hostHelp: 'Reads the systemd journal of the host (/var/log/journal, /run/log/journal), or the text files of /var/log (syslog, messages, *.log) when there is no journal. Directories are mounted read-only. When turned on, the last hour is read first (HOST_LOGS_BACKFILL).',
|
||||
dmesgTitle: 'Kernel messages (dmesg)',
|
||||
dmesgEnabled: 'Collect the kernel messages of this machine',
|
||||
dmesgOff: 'Off: the kernel messages of the machine hosting LogStream are not collected.',
|
||||
dmesgOk: ({ d, r }) => `Reading ${d}: ${r} messages since startup.`,
|
||||
dmesgLost: (n) => ` ${n} messages were overwritten in the kernel buffer before being read.`,
|
||||
dmesg_not_found: 'Device /dev/kmsg not found in the container: uncomment its devices section in docker-compose.yml (see the README).',
|
||||
dmesg_permission: 'Permission denied on /dev/kmsg: set kernel.dmesg_restrict=0 on the host, or run the container as root with the SYSLOG capability (see the README). ',
|
||||
dmesg_read: 'Cannot read the kernel messages: ',
|
||||
dmesgHelp: 'Reads the kernel ring buffer of the host through /dev/kmsg, like dmesg: hardware, drivers, OOM killer, firewall… When turned on, the last hour is read first (DMESG_BACKFILL); after a restart, reading resumes where it stopped.',
|
||||
syslogEnabled: 'Receive syslog messages', syslogProtocols: 'Protocols', syslogPort: 'Port',
|
||||
syslogListening: ({ port, protos }) => `Listening on port ${port} (${protos}).`,
|
||||
syslogOff: 'Syslog reception is off: syslog messages are ignored (Docker logs are still collected).',
|
||||
@@ -122,6 +152,16 @@ const I18N = {
|
||||
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
|
||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||
dangerZone: 'Danger zone',
|
||||
dbTitle: 'Database', dbRefresh: 'Refresh', dbLoading: 'Loading…',
|
||||
dbUnits: ['B', 'KB', 'MB', 'GB', 'TB'],
|
||||
dbRows: 'Stored lines', dbDisk: 'Size on disk', dbIndex: (x) => `including ${x} of index`,
|
||||
dbRaw: 'Raw size', dbRatio: (x) => `compressed ×${x}`,
|
||||
dbPeriod: 'Period', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} day${n > 1 ? 's' : ''}`,
|
||||
dbRetention: (r) => `retention ${r}`,
|
||||
dbRecent: 'Last 24 h', dbLastHour: (n) => `${n} in the last hour`,
|
||||
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} host${p.hn > 1 ? 's' : ''} · ${p.a} app${p.an > 1 ? 's' : ''}`,
|
||||
dbFree: 'Free disk space', dbEmpty: 'The database is empty.',
|
||||
dbQueryErr: 'Some figures could not be computed: ',
|
||||
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
|
||||
purgeBtn: 'Delete all logs…',
|
||||
purgePrompt: 'This permanently deletes ALL stored logs.\n\nType PURGE to confirm:',
|
||||
@@ -131,21 +171,30 @@ const I18N = {
|
||||
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
|
||||
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
|
||||
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
|
||||
err_read_only: 'Read-only account: changes are reserved to administrators',
|
||||
err_cross_site: 'Request refused: it comes from another site',
|
||||
authOff: 'No authentication: anyone who can reach this page can read the logs and change the settings. Set AUTH_USER / AUTH_PASS or AUTH_MODE=oidc.',
|
||||
readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
|
||||
err_purge_confirm: 'Type PURGE to confirm',
|
||||
liveZoomed: 'Live view is not available on a zoomed range',
|
||||
connZoom: 'live paused (zoom)',
|
||||
liveZoomed: 'Live view is only available in Stream mode',
|
||||
connZoom: 'live paused (time range)',
|
||||
viewAria: 'Display mode', viewStream: 'Stream', viewPeriod: 'Time range',
|
||||
viewStreamTitle: 'Stream: the latest logs over a sliding duration, updated live',
|
||||
viewPeriodTitle: 'Time range: the logs between a start and an end date',
|
||||
perFrom: 'Start', perTo: 'End', perPrev: 'Previous time range', perNext: 'Next time range',
|
||||
perOut: 'Zoom out (twice as long)',
|
||||
histoTitle: 'Timeline',
|
||||
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
|
||||
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
|
||||
hRender: 'Display', hBars: 'Bars', hArea: 'Area',
|
||||
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval',
|
||||
histoHelp: 'Click a bar to zoom on its interval, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
|
||||
histoHelp: 'Click a bar to show its interval in Time range mode, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
|
||||
hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
|
||||
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
|
||||
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
|
||||
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
|
||||
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
|
||||
hUnzoom: '× Reset zoom', unitDay: 'd',
|
||||
hUnzoom: '× Back to stream', unitDay: 'd',
|
||||
toTop: 'Back to top',
|
||||
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
|
||||
codesTitle: 'Codes of the color tags found in the message',
|
||||
@@ -184,6 +233,7 @@ const I18N = {
|
||||
skipped: (n) => `${n} messages non affichés (débit trop élevé)`,
|
||||
stats: (s) => `reçus ${s.received} · stockés ${s.ingested} · perdus ${s.dropped} · file ${s.queue}`,
|
||||
storageErr: 'stockage : ',
|
||||
spooled: (n) => `${n} en attente sur disque`,
|
||||
unreachable: 'serveur injoignable',
|
||||
fTime: 'horodatage', fMsg: 'message', fPid: 'pid', fSd: 'données structurées',
|
||||
filterHost: 'Filtrer sur cet hôte', filterApp: 'Filtrer sur cette appli',
|
||||
@@ -200,6 +250,9 @@ const I18N = {
|
||||
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
|
||||
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?',
|
||||
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
|
||||
tagFilter: 'Filtrer les tags (mot-clé, libellé ou code)',
|
||||
tagCount: (n) => (n.shown === n.total ? `${n.total} tag${n.total > 1 ? 's' : ''}` : `${n.shown} / ${n.total} tags`),
|
||||
noTagMatch: 'Aucun tag ne correspond au filtre.',
|
||||
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 : ',
|
||||
@@ -224,6 +277,23 @@ const I18N = {
|
||||
clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli',
|
||||
sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources',
|
||||
srcHost: 'Système hôte',
|
||||
srcKernel: 'Noyau (dmesg)',
|
||||
auditTitle: 'Audit Linux (auditd)',
|
||||
auditEnabled: 'Collecter les événements d\'audit de cette machine',
|
||||
auditPath: 'Fichier',
|
||||
auditOff: 'Désactivé : le journal d\'audit de la machine qui héberge LogStream n\'est pas lu.',
|
||||
auditOk: ({ p, r }) => `Suivi de ${p} : ${r} événements depuis le démarrage.`,
|
||||
audit_not_found: 'Journal d\'audit introuvable : montez /var/log/audit dans le conteneur (voir docker-compose.yml) et vérifiez le chemin. ',
|
||||
audit_permission: 'Accès refusé au journal d\'audit : réglez log_group dans /etc/audit/auditd.conf, ou ajoutez une ACL pour l\'UID 10001 (voir le README). ',
|
||||
audit_read: 'Lecture du journal d\'audit impossible : ',
|
||||
auditHelp: 'Lit le journal du démon d\'audit Linux (auditd) : connexions, sudo, fichiers surveillés, appels système visés par vos règles… Les enregistrements d\'un même événement sont regroupés sur une ligne, avec un résumé (qui, quoi, résultat) et l\'enregistrement brut en dessous ; les échecs sont des warnings. À l\'activation, la dernière heure est lue d\'abord (AUDIT_BACKFILL) ; les rotations sont suivies et la lecture reprend où elle s\'était arrêtée.',
|
||||
selfTitle: 'Logs internes de LogStream',
|
||||
selfEnabled: 'Enregistrer les événements de LogStream lui-même',
|
||||
selfLevel: 'Niveau minimal',
|
||||
selfOff: 'Désactivé : les événements de LogStream ne vont que dans la sortie de son conteneur.',
|
||||
selfOk: ({ n }) => `${n} événements enregistrés depuis le démarrage (hôte logstream).`,
|
||||
selfDropped: (n) => ` ${n} écartés (répétés ou trop nombreux d\'un coup).`,
|
||||
selfHelp: 'Transforme les événements de LogStream en logs (hôte logstream, app = composant) : VictoriaLogs injoignable puis de retour, lots perdus, erreurs du listener syslog, flux Docker décrochés, sources en échec, démarrage et arrêt. Un même événement est enregistré au plus une fois toutes les 30 s, avec le nombre de répétitions.',
|
||||
hostTitle: 'Logs système de l\'hôte',
|
||||
hostEnabled: 'Collecter les logs système de cette machine',
|
||||
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
|
||||
@@ -236,6 +306,15 @@ const I18N = {
|
||||
host_empty: 'Ni journal systemd ni fichier de log trouvé dans /var/log.',
|
||||
host_read: 'Lecture des logs de l\'hôte impossible : ',
|
||||
hostHelp: 'Lit le journal systemd de l\'hôte (/var/log/journal, /run/log/journal), ou les fichiers texte de /var/log (syslog, messages, *.log) s\'il n\'y a pas de journal. Les répertoires sont montés en lecture seule. À l\'activation, la dernière heure est lue d\'abord (HOST_LOGS_BACKFILL).',
|
||||
dmesgTitle: 'Messages du noyau (dmesg)',
|
||||
dmesgEnabled: 'Collecter les messages du noyau de cette machine',
|
||||
dmesgOff: 'Désactivé : les messages du noyau de la machine qui héberge LogStream ne sont pas collectés.',
|
||||
dmesgOk: ({ d, r }) => `Lecture de ${d} : ${r} messages depuis le démarrage.`,
|
||||
dmesgLost: (n) => ` ${n} messages ont été écrasés dans le buffer du noyau avant d'être lus.`,
|
||||
dmesg_not_found: 'Périphérique /dev/kmsg absent du conteneur : décommentez sa section devices dans docker-compose.yml (voir le README).',
|
||||
dmesg_permission: 'Accès refusé à /dev/kmsg : réglez kernel.dmesg_restrict=0 sur l\'hôte, ou lancez le conteneur en root avec la capacité SYSLOG (voir le README). ',
|
||||
dmesg_read: 'Lecture des messages du noyau impossible : ',
|
||||
dmesgHelp: 'Lit le buffer circulaire du noyau de l\'hôte via /dev/kmsg, comme dmesg : matériel, pilotes, OOM killer, pare-feu… À l\'activation, la dernière heure est lue d\'abord (DMESG_BACKFILL) ; après un redémarrage, la lecture reprend où elle s\'était arrêtée.',
|
||||
syslogEnabled: 'Recevoir les messages syslog', syslogProtocols: 'Protocoles', syslogPort: 'Port',
|
||||
syslogListening: ({ port, protos }) => `Écoute sur le port ${port} (${protos}).`,
|
||||
syslogOff: 'Réception syslog désactivée : les messages syslog sont ignorés (les logs Docker sont toujours collectés).',
|
||||
@@ -273,6 +352,16 @@ const I18N = {
|
||||
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
|
||||
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
|
||||
dangerZone: 'Zone de danger',
|
||||
dbTitle: 'Base de données', dbRefresh: 'Actualiser', dbLoading: 'Chargement…',
|
||||
dbUnits: ['o', 'Ko', 'Mo', 'Go', 'To'],
|
||||
dbRows: 'Lignes stockées', dbDisk: 'Taille sur disque', dbIndex: (x) => `dont ${x} d'index`,
|
||||
dbRaw: 'Taille brute', dbRatio: (x) => `compression ×${x}`,
|
||||
dbPeriod: 'Période', dbFromTo: (p) => `${p.from} → ${p.to}`, dbDays: (n) => `${n} jour${n > 1 ? 's' : ''}`,
|
||||
dbRetention: (r) => `rétention ${r}`,
|
||||
dbRecent: 'Dernières 24 h', dbLastHour: (n) => `${n} sur la dernière heure`,
|
||||
dbSources: 'Sources', dbHostsApps: (p) => `${p.h} hôte${p.hn > 1 ? 's' : ''} · ${p.a} application${p.an > 1 ? 's' : ''}`,
|
||||
dbFree: 'Espace disque libre', dbEmpty: 'La base est vide.',
|
||||
dbQueryErr: 'Certains chiffres n\'ont pas pu être calculés : ',
|
||||
purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.',
|
||||
purgeBtn: 'Supprimer tous les logs…',
|
||||
purgePrompt: 'Cette action supprime définitivement TOUS les logs stockés.\n\nTapez PURGE pour confirmer :',
|
||||
@@ -282,21 +371,30 @@ const I18N = {
|
||||
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
|
||||
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
|
||||
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
|
||||
err_read_only: 'Compte en lecture seule : les modifications sont réservées aux administrateurs',
|
||||
err_cross_site: 'Requête refusée : elle vient d\'un autre site',
|
||||
authOff: 'Aucune authentification : toute personne qui atteint cette page peut lire les logs et changer les réglages. Définissez AUTH_USER / AUTH_PASS ou AUTH_MODE=oidc.',
|
||||
readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
|
||||
err_purge_confirm: 'Tapez PURGE pour confirmer',
|
||||
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée',
|
||||
connZoom: 'direct en pause (zoom)',
|
||||
liveZoomed: 'Le direct n\'est disponible qu\'en mode Flux',
|
||||
connZoom: 'direct en pause (période)',
|
||||
viewAria: 'Mode d\'affichage', viewStream: 'Flux', viewPeriod: 'Période',
|
||||
viewStreamTitle: 'Flux : les derniers logs sur une durée glissante, mis à jour en direct',
|
||||
viewPeriodTitle: 'Période : les logs entre une date de début et une date de fin',
|
||||
perFrom: 'Début', perTo: 'Fin', perPrev: 'Période précédente', perNext: 'Période suivante',
|
||||
perOut: 'Élargir (deux fois plus long)',
|
||||
histoTitle: 'Frise',
|
||||
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
|
||||
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
|
||||
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
|
||||
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle',
|
||||
histoHelp: 'Cliquez sur une barre pour zoomer sur son intervalle, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
|
||||
histoHelp: 'Cliquez sur une barre pour afficher son intervalle en mode Période, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
|
||||
hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
|
||||
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×',
|
||||
hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
|
||||
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
|
||||
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
|
||||
hUnzoom: '× Annuler le zoom', unitDay: 'j',
|
||||
hUnzoom: '× Revenir au flux', unitDay: 'j',
|
||||
toTop: 'Revenir en haut',
|
||||
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
|
||||
codesTitle: 'Codes des tags de couleur trouvés dans le message',
|
||||
@@ -457,7 +555,7 @@ const state = {
|
||||
live: store.get('live', '1') === '1',
|
||||
es: null,
|
||||
reqId: 0,
|
||||
zoom: null, // {from, to} in ms when the timeline is zoomed (range "custom")
|
||||
zoom: null, // {from, to} in ms in Time range mode (range "custom"), null in Stream mode
|
||||
tookMs: null,
|
||||
conn: ['', 'connPaused'],
|
||||
stats: null,
|
||||
@@ -486,16 +584,20 @@ function setLang(next) {
|
||||
setConn(...state.conn);
|
||||
renderList();
|
||||
updateCount(state.tookMs);
|
||||
setZoomOption();
|
||||
renderPeriod();
|
||||
renderHisto();
|
||||
renderStats();
|
||||
renderTagList();
|
||||
renderPresets();
|
||||
renderTimeSettings();
|
||||
renderPurge();
|
||||
renderDbStats();
|
||||
renderInterface();
|
||||
renderSyslog();
|
||||
renderHost();
|
||||
renderDmesg();
|
||||
renderAudit();
|
||||
renderSelfLog();
|
||||
renderDocker();
|
||||
}
|
||||
|
||||
@@ -803,10 +905,15 @@ function fmtTime(s) {
|
||||
return `${day} ${p.h}:${p.m}:${p.s}<span class="ms">.${p.ms}</span>`;
|
||||
}
|
||||
|
||||
// Sources with their own look in the list: a colored edge and a badge
|
||||
// before the app name (the severity badge stays the same).
|
||||
const SRC_BADGE = { audit: 'AUDIT', logstream: 'LS' };
|
||||
|
||||
function rowHTML(r, isNew) {
|
||||
const sev = r.severity || '';
|
||||
const cls = SEV_CLASS[sev] || 'info';
|
||||
return `<article class="row sev-${cls}${isNew ? ' new' : ''}" tabindex="0">`
|
||||
const badge = SRC_BADGE[r.source_type];
|
||||
return `<article class="row sev-${cls}${badge ? ' src-' + r.source_type : ''}${isNew ? ' new' : ''}" tabindex="0">`
|
||||
+ `<time class="rcv" title="${esc(t('rcvTitle'))}">${r.received ? esc(fmtFull(r.received)) : '—'}</time>`
|
||||
+ `<time class="mt" title="${esc(t('msgTimeTitle'))}">${fmtTime(r.msg_time || r._time)}</time>`
|
||||
+ `<span class="sev">${esc(sev || '—')}</span>`
|
||||
@@ -814,13 +921,20 @@ function rowHTML(r, isNew) {
|
||||
? `<span class="host" data-act="host" title="${esc(hostTitle(r) + ' · ' + t('clickHost'))}">${esc(r.host_name || r.host)}</span>`
|
||||
: '<span class="host"></span>')
|
||||
+ (r.app
|
||||
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
|
||||
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${badge ? `<b class="src-badge">${badge}</b>` : ''}${esc(r.app)}</span>`
|
||||
: '<span class="app"></span>')
|
||||
+ `<span class="codes">${codesHTML(r._msg)}</span>`
|
||||
+ `<div class="msg">${highlight(r._msg)}</div>`
|
||||
+ `<div class="msg">${msgHTML(r)}</div>`
|
||||
+ '</article>';
|
||||
}
|
||||
|
||||
// Message cell; audit events also show their raw record (hidden in compact
|
||||
// density until the row is opened).
|
||||
function msgHTML(r) {
|
||||
return highlight(r._msg)
|
||||
+ (r.source_type === 'audit' && r.audit_raw ? `<div class="raw">${esc(r.audit_raw)}</div>` : '');
|
||||
}
|
||||
|
||||
// Stable hue (0-359) for a name: the compose project color, shared by the
|
||||
// Sources labels and the Docker app names in the log list.
|
||||
function hueOf(name) {
|
||||
@@ -884,7 +998,7 @@ function rehighlight() {
|
||||
for (const row of list.children) {
|
||||
const r = recOf.get(row);
|
||||
if (!r) continue;
|
||||
row.querySelector('.msg').innerHTML = highlight(r._msg);
|
||||
row.querySelector('.msg').innerHTML = msgHTML(r);
|
||||
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
|
||||
}
|
||||
}
|
||||
@@ -911,8 +1025,8 @@ function updateCount(tookMs) {
|
||||
}
|
||||
|
||||
function detailsHTML(r) {
|
||||
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'unit', 'log_file', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
|
||||
const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
|
||||
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'audit_type', 'audit_serial', 'auid', 'user', 'exe', 'res', 'key', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'unit', 'log_file', 'subsystem', 'device', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
|
||||
const hidden = new Set(['_stream_id', '_stream', 'sevnum', 'audit_raw']); // audit_raw: shown under the message
|
||||
if (r.msg_time) hidden.add('_time'); // same as the reception time
|
||||
const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k))
|
||||
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
|
||||
@@ -1398,33 +1512,115 @@ function showHistoSel() {
|
||||
sel.style.width = ((b - a + 1) / d.count) * 100 + '%';
|
||||
}
|
||||
|
||||
function setZoomOption() {
|
||||
const sel = $('#range');
|
||||
let opt = sel.querySelector('option[value="custom"]');
|
||||
if (!state.zoom) {
|
||||
if (opt) opt.remove();
|
||||
return;
|
||||
/* ---- Display mode: Stream or Time range ---- */
|
||||
|
||||
// datetime-local fields hold a wall time in the chosen time zone.
|
||||
function toWall(ms, sec) {
|
||||
const p = zoneParts(new Date(ms));
|
||||
return `${p.Y}-${p.M}-${p.D}T${p.h}:${p.m}${sec ? ':' + p.s : ''}`;
|
||||
}
|
||||
|
||||
function zoneOffsetMs(ms) {
|
||||
const m = /^([+-])(\d\d):(\d\d)$/.exec(zoneParts(new Date(ms)).off);
|
||||
return m ? (m[1] === '-' ? -1 : 1) * (m[2] * 36e5 + m[3] * 6e4) : 0;
|
||||
}
|
||||
|
||||
function fromWall(v) {
|
||||
const m = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d))?/.exec(v || '');
|
||||
if (!m) return NaN;
|
||||
const wall = Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5], m[6] || 0);
|
||||
const guess = wall - zoneOffsetMs(wall);
|
||||
return wall - zoneOffsetMs(guess); // second pass: right offset around DST changes
|
||||
}
|
||||
|
||||
const serverNow = () => Date.now() + (histo.data ? histo.data.offset : 0);
|
||||
|
||||
function renderPeriod() {
|
||||
const on = !!state.zoom;
|
||||
for (const b of $('#viewMode').querySelectorAll('[data-view]')) {
|
||||
b.setAttribute('aria-checked', String((b.dataset.view === 'period') === on));
|
||||
}
|
||||
if (!opt) { opt = new Option('', 'custom'); sel.add(opt); }
|
||||
opt.textContent = fmtSpan(state.zoom.from, state.zoom.to, state.zoom.to - state.zoom.from < 3e5);
|
||||
sel.value = 'custom';
|
||||
$('#range').hidden = on;
|
||||
$('#period').hidden = !on;
|
||||
if (!on) return;
|
||||
const { from, to } = state.zoom;
|
||||
const sec = from % 6e4 !== 0 || to % 6e4 !== 0;
|
||||
for (const [el, v] of [[$('#perFrom'), from], [$('#perTo'), to]]) {
|
||||
el.step = sec ? 1 : 60;
|
||||
el.classList.remove('bad');
|
||||
if (document.activeElement !== el) el.value = toWall(v, sec);
|
||||
}
|
||||
$('#perNext').disabled = to >= serverNow();
|
||||
}
|
||||
|
||||
function zoomTo(from, to) {
|
||||
state.zoom = { from: Math.round(from), to: Math.round(to) };
|
||||
setZoomOption();
|
||||
store.set('period', JSON.stringify(state.zoom));
|
||||
renderPeriod();
|
||||
hideHistoTip();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function unzoom() {
|
||||
state.zoom = null;
|
||||
setZoomOption();
|
||||
store.set('period', '');
|
||||
renderPeriod();
|
||||
$('#range').value = store.get('range', '1h');
|
||||
if (!$('#range').value) $('#range').value = '1h';
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Entering Time range mode starts from what the stream was showing.
|
||||
function enterPeriod() {
|
||||
if (state.zoom) return;
|
||||
const d = histo.data;
|
||||
if (d) return zoomTo(d.start, d.start + d.count * d.step);
|
||||
const to = Math.ceil(serverNow() / 6e4) * 6e4;
|
||||
zoomTo(to - (RANGE_MS[$('#range').value] || 36e5), to);
|
||||
}
|
||||
|
||||
$('#viewMode').addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('[data-view]');
|
||||
if (!b) return;
|
||||
if (b.dataset.view === 'period') enterPeriod();
|
||||
else if (state.zoom) unzoom();
|
||||
});
|
||||
|
||||
const onPeriodInput = debounce(() => {
|
||||
if (!state.zoom) return;
|
||||
const from = fromWall($('#perFrom').value);
|
||||
const to = fromWall($('#perTo').value);
|
||||
const ok = !isNaN(from) && !isNaN(to) && to > from;
|
||||
$('#perFrom').classList.toggle('bad', isNaN(from) || (!isNaN(to) && !ok));
|
||||
$('#perTo').classList.toggle('bad', isNaN(to) || (!isNaN(from) && !ok));
|
||||
if (ok && (from !== state.zoom.from || to !== state.zoom.to)) zoomTo(from, to);
|
||||
}, 500);
|
||||
for (const id of ['perFrom', 'perTo']) {
|
||||
$('#' + id).addEventListener('input', onPeriodInput);
|
||||
$('#' + id).addEventListener('keydown', (ev) => { if (ev.key === 'Enter') ev.target.blur(); });
|
||||
$('#' + id).addEventListener('blur', () => { if (!$('#' + id).classList.contains('bad')) renderPeriod(); });
|
||||
}
|
||||
|
||||
// ◀ ▶ move by the width of the range, − doubles it around its middle.
|
||||
function shiftPeriod(dir) {
|
||||
const { from, to } = state.zoom;
|
||||
const w = to - from;
|
||||
zoomTo(from + dir * w, to + dir * w);
|
||||
}
|
||||
$('#perPrev').addEventListener('click', () => shiftPeriod(-1));
|
||||
$('#perNext').addEventListener('click', () => shiftPeriod(1));
|
||||
$('#perOut').addEventListener('click', () => {
|
||||
const { from, to } = state.zoom;
|
||||
const w = to - from;
|
||||
let a = from - w / 2;
|
||||
let b = to + w / 2;
|
||||
const now = serverNow();
|
||||
const end = Math.max(to, now); // widen into the past rather than the future
|
||||
if (b > end) { a -= b - end; b = end; }
|
||||
const r = w >= 12e4 ? 6e4 : 1e3; // whole minutes, or seconds for short ranges
|
||||
zoomTo(Math.round(a / r) * r, Math.round(b / r) * r);
|
||||
});
|
||||
|
||||
{
|
||||
const el = $('#histo');
|
||||
el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>'
|
||||
@@ -1546,10 +1742,6 @@ async function refresh() {
|
||||
}
|
||||
|
||||
const onFilterChange = (ev) => {
|
||||
if (ev && ev.target.id === 'range' && state.zoom && ev.target.value !== 'custom') {
|
||||
state.zoom = null; // another range chosen: leave the zoom
|
||||
setZoomOption();
|
||||
}
|
||||
if (!state.zoom) store.set('range', $('#range').value);
|
||||
store.set('severity', $('#severity').value);
|
||||
refresh();
|
||||
@@ -1664,6 +1856,7 @@ function renderStats() {
|
||||
const n = { received: fmtNum(s.received), ingested: fmtNum(s.ingested), dropped: '%DROPPED%', queue: fmtNum(s.queue) };
|
||||
const dropped = s.dropped ? `<span class="bad">${fmtNum(s.dropped)}</span>` : fmtNum(0);
|
||||
let html = esc(t('stats', n)).replace('%DROPPED%', dropped);
|
||||
if (s.spooled) html += ` · <span class="warn">${esc(t('spooled', fmtNum(s.spooled)))}</span>`;
|
||||
if (s.lastError) html += ` · <span class="bad">${esc(t('storageErr') + s.lastError)}</span>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
@@ -1712,7 +1905,7 @@ function onTimePrefsChange() {
|
||||
store.set('timefmt', timePrefs.fmt);
|
||||
updateTimePreview();
|
||||
renderList();
|
||||
setZoomOption();
|
||||
renderPeriod();
|
||||
refreshHisto(); // intervals are aligned on the local time
|
||||
}
|
||||
$('#tzSelect').addEventListener('change', onTimePrefsChange);
|
||||
@@ -1874,6 +2067,121 @@ async function configureHost(enabled) {
|
||||
|
||||
$('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked));
|
||||
|
||||
/* ================= Kernel messages (dmesg) ================= */
|
||||
|
||||
let dmesgState = null;
|
||||
|
||||
async function loadDmesg() {
|
||||
try { dmesgState = await api('/api/dmesg'); } catch { dmesgState = null; }
|
||||
renderDmesg();
|
||||
}
|
||||
|
||||
function renderDmesg() {
|
||||
const s = dmesgState;
|
||||
const status = $('#dmesgStatus');
|
||||
if (!s) { status.textContent = ''; return; }
|
||||
$('#dmesgEnabled').checked = s.enabled;
|
||||
let text = t('hostWaiting');
|
||||
let cls = 'muted';
|
||||
if (!s.enabled) {
|
||||
text = t('dmesgOff');
|
||||
} else if (s.code) {
|
||||
text = t('dmesg_' + s.code) + (s.code === 'read' || s.code === 'permission' ? s.error || '' : '');
|
||||
cls = 'bad';
|
||||
} else if (s.open) {
|
||||
text = t('dmesgOk', { d: s.device, r: fmtNum(s.read) }) + (s.lost ? t('dmesgLost', fmtNum(s.lost)) : '');
|
||||
cls = 'good';
|
||||
}
|
||||
status.textContent = text;
|
||||
status.className = 'docker-status ' + cls;
|
||||
}
|
||||
|
||||
async function configureDmesg(enabled) {
|
||||
dmesgState = { ...(dmesgState || {}), enabled };
|
||||
renderDmesg();
|
||||
try { dmesgState = await api('/api/dmesg', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); }
|
||||
renderDmesg();
|
||||
setTimeout(loadDmesg, 1500); // the device is opened in the background
|
||||
}
|
||||
|
||||
$('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.checked));
|
||||
|
||||
/* ================= Linux audit (auditd) ================= */
|
||||
|
||||
let auditState = null;
|
||||
|
||||
async function loadAudit() {
|
||||
try { auditState = await api('/api/audit'); } catch { auditState = null; }
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const s = auditState;
|
||||
const status = $('#auditStatus');
|
||||
if (!s) { status.textContent = ''; return; }
|
||||
$('#auditEnabled').checked = s.enabled;
|
||||
const input = $('#auditPath');
|
||||
input.placeholder = s.defaultPath || '';
|
||||
if (document.activeElement !== input) input.value = s.path || '';
|
||||
let text = t('hostWaiting');
|
||||
let cls = 'muted';
|
||||
if (!s.enabled) {
|
||||
text = t('auditOff');
|
||||
} else if (s.code) {
|
||||
text = t('audit_' + s.code) + (s.error || '');
|
||||
cls = 'bad';
|
||||
} else if (s.open) {
|
||||
text = t('auditOk', { p: s.path || s.defaultPath, r: fmtNum(s.read) });
|
||||
cls = 'good';
|
||||
}
|
||||
status.textContent = text;
|
||||
status.className = 'docker-status ' + cls;
|
||||
}
|
||||
|
||||
async function configureAudit(change) {
|
||||
const body = { enabled: auditState?.enabled || false, path: auditState?.path || '', ...change };
|
||||
auditState = { ...(auditState || {}), ...body };
|
||||
renderAudit();
|
||||
try { auditState = await api('/api/audit', { method: 'PUT', body }); } catch (e) { toast(e.message); }
|
||||
renderAudit();
|
||||
setTimeout(loadAudit, 1500); // the file is read in the background
|
||||
}
|
||||
|
||||
$('#auditEnabled').addEventListener('change', (ev) => configureAudit({ enabled: ev.target.checked }));
|
||||
$('#auditPath').addEventListener('change', (ev) => configureAudit({ path: ev.target.value.trim() }));
|
||||
|
||||
/* ================= LogStream internal logs ================= */
|
||||
|
||||
let selfState = null;
|
||||
|
||||
async function loadSelfLog() {
|
||||
try { selfState = await api('/api/selflog'); } catch { selfState = null; }
|
||||
renderSelfLog();
|
||||
}
|
||||
|
||||
function renderSelfLog() {
|
||||
const s = selfState;
|
||||
const status = $('#selfStatus');
|
||||
if (!s) { status.textContent = ''; return; }
|
||||
$('#selfEnabled').checked = s.enabled;
|
||||
$('#selfLevel').value = s.min_level || 'warning';
|
||||
$('#selfLevel').disabled = !s.enabled;
|
||||
const left = (s.dropped || 0) + (s.throttled || 0);
|
||||
status.textContent = s.enabled ? t('selfOk', { n: fmtNum(s.emitted || 0) }) + (left ? t('selfDropped', fmtNum(left)) : '') : t('selfOff');
|
||||
status.className = 'docker-status ' + (s.enabled ? 'good' : 'muted');
|
||||
}
|
||||
|
||||
async function configureSelfLog(change) {
|
||||
const body = { enabled: selfState?.enabled ?? true, min_level: selfState?.min_level || 'warning', ...change };
|
||||
selfState = { ...(selfState || {}), ...body };
|
||||
renderSelfLog();
|
||||
try { selfState = await api('/api/selflog', { method: 'PUT', body }); } catch (e) { toast(e.message); }
|
||||
renderSelfLog();
|
||||
}
|
||||
|
||||
$('#selfEnabled').addEventListener('change', (ev) => configureSelfLog({ enabled: ev.target.checked }));
|
||||
$('#selfLevel').addEventListener('change', (ev) => configureSelfLog({ min_level: ev.target.value }));
|
||||
|
||||
/* ================= Docker source ================= */
|
||||
|
||||
let dockerState = null;
|
||||
@@ -1990,9 +2298,75 @@ setInterval(() => {
|
||||
if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) {
|
||||
loadDocker();
|
||||
loadHost();
|
||||
loadDmesg();
|
||||
loadAudit();
|
||||
loadSelfLog();
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
/* ================= Database statistics ================= */
|
||||
|
||||
// 1536 -> "1,5 Ko" / "1.5 KB" (binary multiples, the usual reading for disk sizes).
|
||||
function fmtBytes(n) {
|
||||
const units = t('dbUnits');
|
||||
let i = 0;
|
||||
n = Number(n || 0);
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
const digits = i === 0 || n >= 100 ? 0 : 1;
|
||||
return `${n.toLocaleString(t('locale'), { maximumFractionDigits: digits })} ${units[i]}`;
|
||||
}
|
||||
|
||||
const db = { stats: null, error: null, loading: false };
|
||||
|
||||
function renderDbStats() {
|
||||
const el = $('#dbStats');
|
||||
const s = db.stats;
|
||||
if (!s) {
|
||||
el.innerHTML = `<div class="db-msg${db.error ? ' bad' : ''}">${esc(db.error || t('dbLoading'))}</div>`;
|
||||
return;
|
||||
}
|
||||
const row = (k, v, sub) => `<dt>${esc(k)}</dt><dd><b>${esc(v)}</b>${sub ? ` <span class="muted">${esc(sub)}</span>` : ''}</dd>`;
|
||||
const day = (x) => fmtFull(x).replace(/[.,]\d{3}(?=\D*$)/, '');
|
||||
let html = row(t('dbRows'), fmtNum(s.rows));
|
||||
html += row(t('dbDisk'), fmtBytes(s.diskBytes), s.indexBytes ? t('dbIndex', fmtBytes(s.indexBytes)) : '');
|
||||
if (s.rawBytes) {
|
||||
const ratio = s.diskBytes ? (s.rawBytes / s.diskBytes).toLocaleString(t('locale'), { maximumFractionDigits: 1 }) : '';
|
||||
html += row(t('dbRaw'), fmtBytes(s.rawBytes), ratio ? t('dbRatio', ratio) : '');
|
||||
}
|
||||
if (s.oldest && s.newest) {
|
||||
const sub = [s.days ? t('dbDays', s.days) : '', s.retention ? t('dbRetention', s.retention) : ''].filter(Boolean).join(' · ');
|
||||
html += row(t('dbPeriod'), t('dbFromTo', { from: day(s.oldest), to: day(s.newest) }), sub);
|
||||
} else if (s.retention) {
|
||||
html += row(t('dbPeriod'), '—', t('dbRetention', s.retention));
|
||||
}
|
||||
if (s.rows) {
|
||||
html += row(t('dbRecent'), fmtNum(s.last24h), t('dbLastHour', fmtNum(s.last1h)));
|
||||
html += row(t('dbSources'), t('dbHostsApps', { h: fmtNum(s.hosts), hn: s.hosts, a: fmtNum(s.apps), an: s.apps }));
|
||||
}
|
||||
if (s.freeBytes) html += row(t('dbFree'), fmtBytes(s.freeBytes));
|
||||
if (!s.rows) html += `<div class="db-msg">${esc(t('dbEmpty'))}</div>`;
|
||||
if (s.queryError) html += `<div class="db-msg bad">${esc(t('dbQueryErr') + s.queryError)}</div>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadDbStats(refresh = false) {
|
||||
if (db.loading) return;
|
||||
db.loading = true;
|
||||
$('#dbRefresh').disabled = true;
|
||||
try {
|
||||
db.stats = await api('/api/dbstats' + (refresh ? '?refresh=1' : ''));
|
||||
db.error = null;
|
||||
} catch (e) {
|
||||
db.stats = null;
|
||||
db.error = e.message;
|
||||
} finally {
|
||||
db.loading = false;
|
||||
$('#dbRefresh').disabled = false;
|
||||
}
|
||||
renderDbStats();
|
||||
}
|
||||
$('#dbRefresh').addEventListener('click', () => loadDbStats(true));
|
||||
|
||||
/* ================= Purge ================= */
|
||||
|
||||
const purge = { allowed: true, running: 0, error: null, code: null, wasRunning: false, timer: null };
|
||||
@@ -2022,6 +2396,7 @@ async function loadPurgeStatus() {
|
||||
refresh();
|
||||
loadFacets();
|
||||
loadStats();
|
||||
loadDbStats(true);
|
||||
}
|
||||
purge.running = s.running || 0;
|
||||
} catch { /* shown on the next attempt */ }
|
||||
@@ -2098,8 +2473,32 @@ function tagRowHTML(tag) {
|
||||
|
||||
function renderTagList() {
|
||||
$('#tagList').innerHTML = state.tags.map(tagRowHTML).join('') || `<p class="muted small">${esc(t('noTags'))}</p>`;
|
||||
filterTagList();
|
||||
}
|
||||
|
||||
// Settings > Filters: shows only the tags whose keyword, label or code contains the filter.
|
||||
function filterTagList() {
|
||||
const q = $('#tagFilter').value.trim().toLowerCase();
|
||||
let shown = 0;
|
||||
for (const row of $('#tagList').querySelectorAll('.tag-row')) {
|
||||
const tag = state.tags.find((x) => x.id === row.dataset.id);
|
||||
const hit = !q || !tag || [tag.pattern, tag.label, tag.code].some((v) => String(v || '').toLowerCase().includes(q));
|
||||
row.hidden = !hit;
|
||||
if (hit) shown++;
|
||||
}
|
||||
let none = $('#tagList').querySelector('.tag-none');
|
||||
if (q && !shown && state.tags.length) {
|
||||
if (!none) {
|
||||
none = document.createElement('p');
|
||||
none.className = 'muted small tag-none';
|
||||
$('#tagList').append(none);
|
||||
}
|
||||
none.textContent = t('noTagMatch');
|
||||
} else if (none) none.remove();
|
||||
$('#tagCount').textContent = state.tags.length ? t('tagCount', { shown, total: state.tags.length }) : '';
|
||||
}
|
||||
$('#tagFilter').addEventListener('input', filterTagList);
|
||||
|
||||
const saveTimers = {};
|
||||
function scheduleSave(tag, rowEl) {
|
||||
clearTimeout(saveTimers[tag.id]);
|
||||
@@ -2150,6 +2549,7 @@ $('#addTag').addEventListener('click', async () => {
|
||||
try {
|
||||
const tag = await api('/api/tags', { method: 'POST', body: { pattern: t('newTag'), color, wholeWord: true, enabled: true } });
|
||||
state.tags.push(tag);
|
||||
$('#tagFilter').value = '';
|
||||
renderTagList();
|
||||
applyTags();
|
||||
const input = $('#tagList').lastElementChild.querySelector('input[type="text"]');
|
||||
@@ -2194,7 +2594,12 @@ $('#settingsBtn').addEventListener('click', () => {
|
||||
loadPurgeStatus();
|
||||
loadSyslog();
|
||||
loadHost();
|
||||
loadDmesg();
|
||||
loadAudit();
|
||||
loadSelfLog();
|
||||
loadDocker();
|
||||
renderDbStats();
|
||||
loadDbStats();
|
||||
showSettingsTab(store.get('settingsTab', 'locale'));
|
||||
$('#settingsDlg').showModal();
|
||||
});
|
||||
@@ -2212,14 +2617,39 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
|
||||
applyLogFont(store.get('logFont', 'system'));
|
||||
applyLogSize(store.get('logSize', 'medium'));
|
||||
applyLogDensity(store.get('logDensity', 'normal'));
|
||||
$('#authWarnClose').addEventListener('click', () => {
|
||||
$('#authWarn').hidden = true;
|
||||
store.set('authWarnHidden', '1');
|
||||
});
|
||||
|
||||
applyLang();
|
||||
$('#range').value = store.get('range', '1h');
|
||||
if (!$('#range').value) $('#range').value = '1h';
|
||||
$('#severity').value = store.get('severity', '');
|
||||
try { // Time range mode is kept across reloads
|
||||
const z = JSON.parse(store.get('period', '') || 'null');
|
||||
if (z && z.to > z.from) state.zoom = { from: z.from, to: z.to };
|
||||
} catch { /* stream mode */ }
|
||||
renderPeriod();
|
||||
|
||||
// With a login (local or OIDC), show who is logged in and the log out button.
|
||||
// Without a login, warn that the UI is open to everyone. With a login (local or OIDC),
|
||||
// show who is logged in and the log out button, and lock the admin settings of a
|
||||
// read-only account.
|
||||
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
|
||||
if (!me || !me.user) return;
|
||||
if (!me) return;
|
||||
if (me.mode === 'none' && store.get('authWarnHidden') !== '1') $('#authWarn').hidden = false;
|
||||
if (me.role === 'viewer') {
|
||||
document.body.classList.add('read-only');
|
||||
for (const p of document.querySelectorAll('[data-panel="filters"], [data-panel="sources"], [data-panel="data"]')) {
|
||||
for (const s of p.querySelectorAll('.set-section')) s.inert = true;
|
||||
const note = document.createElement('p');
|
||||
note.className = 'ro-note';
|
||||
note.dataset.i18n = 'readOnlyNote';
|
||||
note.textContent = t('readOnlyNote');
|
||||
p.prepend(note);
|
||||
}
|
||||
}
|
||||
if (!me.user) return;
|
||||
const btn = $('#logoutBtn');
|
||||
btn.hidden = false;
|
||||
btn.dataset.user = me.user;
|
||||
|
||||
+100
-14
@@ -50,6 +50,33 @@
|
||||
</header>
|
||||
|
||||
<section class="filters">
|
||||
<div id="viewMode" class="seg view-mode" role="radiogroup" data-i18n-aria="viewAria">
|
||||
<button type="button" role="radio" data-view="stream" data-i18n-title="viewStreamTitle">
|
||||
<!-- Lucide "radio" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
|
||||
<span data-i18n="viewStream">Stream</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-view="period" data-i18n-title="viewPeriodTitle">
|
||||
<!-- Lucide "calendar-range" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M3 10h18M8 2v4M17 14h-6M13 18H7M7 14h.01M17 18h.01"/></svg>
|
||||
<span data-i18n="viewPeriod">Time range</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="period" class="period" hidden>
|
||||
<button id="perPrev" class="btn tool" type="button" data-i18n-title="perPrev" data-i18n-aria="perPrev">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<input id="perFrom" type="datetime-local" data-i18n-title="perFrom" data-i18n-aria="perFrom">
|
||||
<span class="muted" aria-hidden="true">→</span>
|
||||
<input id="perTo" type="datetime-local" data-i18n-title="perTo" data-i18n-aria="perTo">
|
||||
<button id="perNext" class="btn tool" type="button" data-i18n-title="perNext" data-i18n-aria="perNext">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
|
||||
</button>
|
||||
<button id="perOut" class="btn tool" type="button" data-i18n-title="perOut" data-i18n-aria="perOut">
|
||||
<!-- Lucide "zoom-out" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3M8 11h6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<select id="range" data-i18n-aria="rangeAria">
|
||||
<option value="5m" data-i18n="r5m">5 min</option>
|
||||
<option value="15m" data-i18n="r15m">15 min</option>
|
||||
@@ -75,6 +102,9 @@
|
||||
<option value="syslog">Syslog</option>
|
||||
<option value="docker">Docker</option>
|
||||
<option value="host" data-i18n="srcHost">Host system</option>
|
||||
<option value="kernel" data-i18n="srcKernel">Kernel (dmesg)</option>
|
||||
<option value="audit">Audit</option>
|
||||
<option value="logstream">LogStream</option>
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
@@ -97,6 +127,12 @@
|
||||
|
||||
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section>
|
||||
|
||||
<div id="authWarn" class="auth-warn" role="status" hidden>
|
||||
<span data-i18n="authOff"></span>
|
||||
<button id="authWarnClose" class="icon-btn" type="button" data-i18n-aria="close">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="error" class="error-banner" hidden></div>
|
||||
<button id="newPill" class="pill" type="button" hidden></button>
|
||||
|
||||
@@ -183,20 +219,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="filters" hidden>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="tagsTitle">Color tags</h3>
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="filters" hidden>
|
||||
<section class="set-section tags-section">
|
||||
<div class="sec-head">
|
||||
<h3 data-i18n="tagsTitle">Color tags</h3>
|
||||
<span id="tagCount" class="muted small"></span>
|
||||
</div>
|
||||
<p class="muted small" data-i18n="tagsHelp"></p>
|
||||
<div id="tagList" class="tag-list"></div>
|
||||
<footer>
|
||||
<span class="tag-add">
|
||||
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
||||
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
||||
<option value="" data-i18n="presetPick">+ Preset…</option>
|
||||
</select>
|
||||
</span>
|
||||
<div class="tag-toolbar">
|
||||
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
||||
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
||||
<option value="" data-i18n="presetPick">+ Preset…</option>
|
||||
</select>
|
||||
<input id="tagFilter" type="search" class="dk-filter" autocomplete="off" spellcheck="false" data-i18n-ph="tagFilter" data-i18n-aria="tagFilter">
|
||||
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
|
||||
</footer>
|
||||
</div>
|
||||
<div id="tagList" class="tag-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +269,49 @@
|
||||
</section>
|
||||
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="dmesgTitle">Kernel messages (dmesg)</h3>
|
||||
<p id="dmesgStatus" class="docker-status"></p>
|
||||
<label class="switch-row">
|
||||
<input id="dmesgEnabled" type="checkbox" class="switch">
|
||||
<span data-i18n="dmesgEnabled">Collect the kernel messages of this machine</span>
|
||||
</label>
|
||||
<p class="muted small" data-i18n="dmesgHelp"></p>
|
||||
</section>
|
||||
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="auditTitle">Linux audit (auditd)</h3>
|
||||
<p id="auditStatus" class="docker-status"></p>
|
||||
<label class="switch-row">
|
||||
<input id="auditEnabled" type="checkbox" class="switch">
|
||||
<span data-i18n="auditEnabled">Collect the audit events of this machine</span>
|
||||
</label>
|
||||
<div class="sl-grid">
|
||||
<label class="lbl" for="auditPath" data-i18n="auditPath">File</label>
|
||||
<input id="auditPath" type="text" class="dk-filter" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<p class="muted small src-help" data-i18n="auditHelp"></p>
|
||||
</section>
|
||||
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="selfTitle">LogStream internal logs</h3>
|
||||
<p id="selfStatus" class="docker-status"></p>
|
||||
<label class="switch-row">
|
||||
<input id="selfEnabled" type="checkbox" class="switch">
|
||||
<span data-i18n="selfEnabled">Record the events of LogStream itself</span>
|
||||
</label>
|
||||
<div class="sl-grid">
|
||||
<label class="lbl" for="selfLevel" data-i18n="selfLevel">Minimum level</label>
|
||||
<select id="selfLevel" class="field self-level">
|
||||
<option value="err" data-i18n="sev3">Errors and above</option>
|
||||
<option value="warning" data-i18n="sev4">Warnings and above</option>
|
||||
<option value="notice" data-i18n="sev5">Notice and above</option>
|
||||
<option value="info" data-i18n="sev6">Info and above</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="muted small src-help" data-i18n="selfHelp"></p>
|
||||
</section>
|
||||
|
||||
<section class="set-section span">
|
||||
<h3 data-i18n="dockerTitle">Docker containers</h3>
|
||||
<p id="dockerStatus" class="docker-status"></p>
|
||||
<div id="dockerBody" hidden>
|
||||
@@ -267,7 +348,7 @@
|
||||
</div>
|
||||
<p class="muted small hint" data-i18n="themeHelp"></p>
|
||||
</section>
|
||||
<section class="set-section">
|
||||
<section class="set-section span">
|
||||
<h3 data-i18n="logDisplay">Log display</h3>
|
||||
<div class="field-grid">
|
||||
<span class="lbl" data-i18n="fontSize">Font size</span>
|
||||
@@ -328,9 +409,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Data -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="data" hidden>
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="data" hidden>
|
||||
<section class="set-section danger">
|
||||
<h3 data-i18n="dangerZone">Danger zone</h3>
|
||||
<div class="db-head">
|
||||
<span class="lbl" data-i18n="dbTitle">Database</span>
|
||||
<button id="dbRefresh" class="link-btn" type="button" data-i18n="dbRefresh">Refresh</button>
|
||||
</div>
|
||||
<dl id="dbStats" class="db-stats"></dl>
|
||||
<p class="muted small" data-i18n="purgeHelp"></p>
|
||||
<div class="purge-row">
|
||||
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
|
||||
|
||||
+132
-27
@@ -22,6 +22,9 @@
|
||||
--hit: rgba(99, 102, 241, .18);
|
||||
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
|
||||
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
|
||||
--src-audit: #7c4dbd; /* audit events (auditd) */
|
||||
--src-ls: #1f9aa3; /* LogStream internal logs */
|
||||
--src-tint: 6%; /* background tint of their rows */
|
||||
|
||||
--sev-crit: #be123c;
|
||||
--sev-err: #dc2626;
|
||||
@@ -70,6 +73,9 @@
|
||||
--hit: rgba(129, 140, 248, .28);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||
--chip-l: 70%;
|
||||
--src-audit: #a983e6;
|
||||
--src-ls: #3cc3cc;
|
||||
--src-tint: 9%;
|
||||
--sev-crit: #fb7185;
|
||||
--sev-err: #f87171;
|
||||
--sev-warning: #fbbf24;
|
||||
@@ -102,6 +108,9 @@
|
||||
--hit: rgba(129, 140, 248, .28);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||
--chip-l: 70%;
|
||||
--src-audit: #a983e6;
|
||||
--src-ls: #3cc3cc;
|
||||
--src-tint: 9%;
|
||||
--sev-crit: #fb7185;
|
||||
--sev-err: #f87171;
|
||||
--sev-warning: #fbbf24;
|
||||
@@ -240,6 +249,19 @@ body.busy .progress::after {
|
||||
font-size: 13px; max-width: 220px;
|
||||
}
|
||||
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); }
|
||||
/* Display mode: Stream or Time range, then the start → end fields of the range */
|
||||
.view-mode { padding: 2px; gap: 2px; border-radius: 9px; }
|
||||
.view-mode button { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; }
|
||||
.view-mode svg { width: 15px; height: 15px; }
|
||||
.period { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.period .btn.tool { width: 32px; padding: 0; justify-content: center; }
|
||||
.period input {
|
||||
height: 32px; padding: 0 8px;
|
||||
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
|
||||
font-size: 13px; font-variant-numeric: tabular-nums; color-scheme: light dark;
|
||||
}
|
||||
.period input:focus { outline: 0; box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.period input.bad { border-color: var(--sev-err); background: color-mix(in srgb, var(--sev-err) 10%, transparent); }
|
||||
.spacer { flex: 1; }
|
||||
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -388,6 +410,25 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
|
||||
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
|
||||
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
|
||||
/* Audit events and LogStream internal logs: colored edge (over the severity
|
||||
one), light tint, badge before the app name. */
|
||||
.row.src-audit { --src: var(--src-audit); }
|
||||
.row.src-logstream { --src: var(--src-ls); }
|
||||
.row.src-audit, .row.src-logstream { border-left-color: var(--src); background: color-mix(in srgb, var(--src) var(--src-tint), transparent); }
|
||||
.row.src-audit:hover, .row.src-logstream:hover { background: color-mix(in srgb, var(--src) 13%, var(--row-hover)); }
|
||||
.src-badge {
|
||||
display: inline-block; margin-right: 5px; padding: 0 4px; border-radius: 4px; vertical-align: 1px;
|
||||
font: 700 9.5px/1.45 var(--sans); letter-spacing: .04em;
|
||||
color: var(--src); background: color-mix(in srgb, var(--src) 16%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--src) 40%, transparent);
|
||||
}
|
||||
.row .raw {
|
||||
margin-top: 2px; font-size: .85em; line-height: 1.35; color: var(--muted);
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
:root[data-density="compact"] .row:not(.open) .raw { display: none; }
|
||||
.src-help { margin-top: 10px !important; }
|
||||
.self-level { width: auto; justify-self: start; }
|
||||
/* Codes of the tags found in the message: grey badges, fixed size */
|
||||
.row .codes { white-space: nowrap; overflow: hidden; }
|
||||
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
|
||||
@@ -433,6 +474,22 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
||||
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.auth-warn {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 6px 20px 0; padding: 6px 8px 6px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--sev-warning) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--sev-warning) 12%, transparent);
|
||||
color: var(--text); border-radius: var(--radius); font-size: 12.5px;
|
||||
}
|
||||
.auth-warn[hidden] { display: none; }
|
||||
.auth-warn span { flex: 1; }
|
||||
.auth-warn .icon-btn { width: 26px; height: 26px; flex: none; }
|
||||
.ro-note {
|
||||
margin: 0 0 12px; padding: 8px 12px; border-radius: var(--radius); font-size: 12.5px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text);
|
||||
}
|
||||
.read-only .set-panel .set-section[inert] { opacity: .55; }
|
||||
|
||||
.pill {
|
||||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
|
||||
border: 0; border-radius: 999px; padding: 7px 16px;
|
||||
@@ -452,6 +509,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
||||
.conn.ok::before { color: var(--sev-info); }
|
||||
.conn.ko::before { color: var(--sev-err); }
|
||||
#stats .bad { color: var(--sev-err); }
|
||||
#stats .warn { color: var(--sev-warning); }
|
||||
/* "Back to top", at the right end of the status bar: never over a log row */
|
||||
.to-top { width: 24px; height: 24px; margin: -5px -6px -5px auto; flex: none; } /* no taller bar */
|
||||
.to-top svg { width: 16px; height: 16px; }
|
||||
@@ -465,12 +523,12 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
||||
|
||||
/* ---------- Settings dialog ---------- */
|
||||
dialog.settings {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
height: min(660px, calc(100vh - 64px)); max-height: none;
|
||||
width: min(1280px, calc(100vw - 32px));
|
||||
height: calc(100vh - 48px); max-height: 960px;
|
||||
}
|
||||
dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; }
|
||||
dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); }
|
||||
.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px minmax(0, 1fr); }
|
||||
.set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 176px minmax(0, 1fr); }
|
||||
.set-nav {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 12px 10px; border-right: 1px solid var(--border);
|
||||
@@ -485,8 +543,19 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
|
||||
.set-nav button:hover { background: var(--panel-2); color: var(--text); }
|
||||
.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.set-nav svg { width: 18px; height: 18px; flex: none; }
|
||||
.set-panels { overflow-y: auto; padding: 2px 24px 22px; }
|
||||
.set-panel > .set-section:first-child { margin-top: 16px; }
|
||||
.set-panels { overflow-y: auto; padding: 16px 20px 20px; }
|
||||
/* Sections are cards laid out in columns when there is room; .span ones take the full width. */
|
||||
.set-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 14px; align-items: start; grid-auto-flow: row dense; }
|
||||
.set-panel[hidden] { display: none; }
|
||||
.set-panel.wide { grid-template-columns: minmax(0, 1fr); }
|
||||
.set-panel > .set-section, .set-panel > .set-section + .set-section {
|
||||
margin: 0; padding: 14px 16px; min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--panel-2) 35%, var(--panel));
|
||||
}
|
||||
.set-panel > .set-section.span { grid-column: 1 / -1; }
|
||||
.set-panel > .ro-note { grid-column: 1 / -1; margin: 0; }
|
||||
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.hint { margin-top: 8px !important; }
|
||||
.lbl { font-size: 13px; font-weight: 550; }
|
||||
.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; }
|
||||
@@ -531,6 +600,17 @@ select.field:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0
|
||||
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
|
||||
}
|
||||
.set-section.danger h3 { color: var(--sev-err); }
|
||||
.db-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 4px 0 6px; }
|
||||
.db-stats {
|
||||
display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px;
|
||||
margin: 0 0 14px; padding: 10px 12px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
|
||||
}
|
||||
.db-stats dt { color: var(--muted); }
|
||||
.db-stats dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.db-stats dd b { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.db-msg { grid-column: 1 / -1; color: var(--muted); }
|
||||
.db-msg.bad { color: var(--sev-err); }
|
||||
.link-btn:disabled { opacity: .5; cursor: default; text-decoration: none; }
|
||||
.purge-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 10px; }
|
||||
.btn.danger { color: var(--sev-err); border-color: color-mix(in srgb, var(--sev-err) 45%, var(--border)); }
|
||||
.btn.danger:hover:not(:disabled) { background: var(--sev-err); border-color: var(--sev-err); color: #fff; }
|
||||
@@ -605,9 +685,19 @@ input.switch:checked::after { transform: translateX(14px); }
|
||||
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; }
|
||||
/* Settings > Filters: toolbar kept in view while the list scrolls, dense rows. */
|
||||
.tags-section { padding-top: 12px !important; }
|
||||
.tag-toolbar {
|
||||
position: sticky; top: -16px; z-index: 2;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
margin: 10px -16px 0; padding: 8px 16px;
|
||||
background: color-mix(in srgb, var(--panel-2) 35%, var(--panel));
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tag-toolbar .btn { height: 30px; }
|
||||
.tag-toolbar select.field { width: auto; height: 30px; }
|
||||
.tag-toolbar .dk-filter { flex: 1 1 220px; height: 30px; }
|
||||
.tag-toolbar #resetTags { margin-left: auto; }
|
||||
|
||||
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.seg button {
|
||||
@@ -617,34 +707,38 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.seg button:hover { color: var(--text); }
|
||||
.seg button[aria-checked="true"] { background: var(--panel); color: var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .12); }
|
||||
|
||||
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
/* One wide column: long regular expressions stay readable. */
|
||||
.tag-list { display: flex; flex-direction: column; gap: 3px; margin-top: 10px; }
|
||||
.tag-row {
|
||||
display: grid; align-items: center; gap: 8px 10px;
|
||||
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
||||
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
display: grid; align-items: center; gap: 4px 6px;
|
||||
grid-template-columns: 2.4em 30px minmax(7rem, 1fr) 10rem auto 26px;
|
||||
padding: 3px 4px 3px 6px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.tag-row[hidden] { display: none; }
|
||||
.tag-row.off { opacity: .55; }
|
||||
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
|
||||
.tag-row .tag-code { font-size: 11px; padding: 2px 4px; cursor: default; text-align: center; }
|
||||
.tag-row input[type="color"] {
|
||||
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
|
||||
width: 30px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: none; cursor: pointer;
|
||||
}
|
||||
.tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 3px; }
|
||||
.tag-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.tag-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; }
|
||||
.tag-row input[type="text"] {
|
||||
height: 32px; padding: 0 10px; min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
|
||||
font-family: var(--mono); font-size: 13px; outline: 0;
|
||||
height: 26px; padding: 0 8px; min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
|
||||
font-family: var(--mono); font-size: 12.5px; outline: 0;
|
||||
}
|
||||
.tag-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.tag-row .preview { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
|
||||
.tag-row .opts { display: flex; gap: 4px; }
|
||||
.tag-row .preview { font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
|
||||
.tag-row .opts { display: flex; gap: 3px; }
|
||||
.opt {
|
||||
display: inline-flex; align-items: center; height: 28px; padding: 0 8px;
|
||||
border: 1px solid var(--border); border-radius: 7px; background: var(--panel);
|
||||
font-size: 11.5px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
|
||||
display: inline-flex; align-items: center; height: 24px; padding: 0 6px;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
|
||||
font-size: 11px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
|
||||
}
|
||||
.tag-row .icon-btn { width: 26px; height: 26px; border-radius: 6px; }
|
||||
.tag-row .icon-btn svg { width: 15px; height: 15px; }
|
||||
.opt input { display: none; }
|
||||
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
|
||||
@@ -658,11 +752,18 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
.live .lbl { display: none; }
|
||||
.filters { padding: 10px 16px 6px; }
|
||||
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
|
||||
.view-mode { flex: 1 1 100%; }
|
||||
.view-mode button { flex: 1; justify-content: center; }
|
||||
/* Phones: the two dates on one line, the ◀ ▶ − buttons below */
|
||||
.period { flex: 1 1 100%; flex-wrap: wrap; }
|
||||
.period > span { display: none; }
|
||||
.period input { flex: 1 1 calc(50% - 3px); min-width: 0; }
|
||||
.period .btn.tool { order: 1; flex: 1; }
|
||||
.spacer { display: none; }
|
||||
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.histo { padding: 0 16px 6px; }
|
||||
.h-leg { display: none; }
|
||||
.list, .error-banner, .table { margin-left: 16px; margin-right: 16px; }
|
||||
.list, .error-banner, .auth-warn, .table { margin-left: 16px; margin-right: 16px; }
|
||||
/* Phones: no columns, two lines per log (layout below); the widths do not apply */
|
||||
.table { display: block; }
|
||||
.table .list { display: block; margin: 0; }
|
||||
@@ -694,13 +795,17 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
||||
font-size: 11.5px; text-align: center;
|
||||
}
|
||||
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.set-panels { padding: 0 16px 18px; }
|
||||
.set-panels { padding: 12px 12px 18px; }
|
||||
.set-panel { grid-template-columns: minmax(0, 1fr); gap: 10px; }
|
||||
.set-panel > .set-section, .set-panel > .set-section + .set-section { padding: 12px; }
|
||||
.tag-toolbar { position: static; margin: 10px -12px 0; padding: 8px 12px; }
|
||||
.tag-toolbar #resetTags { margin-left: 0; }
|
||||
#sizeSwitch, #densitySwitch { display: flex; }
|
||||
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
|
||||
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
|
||||
.field-grid select { margin-bottom: 6px; }
|
||||
.status { padding: 6px 16px; }
|
||||
.tag-row { grid-template-columns: auto 38px 1fr 36px; }
|
||||
.tag-row { grid-template-columns: auto 30px 1fr 26px; }
|
||||
.tag-row .preview { display: none; }
|
||||
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
||||
.tag-row [data-del] { grid-column: 4; grid-row: 1; }
|
||||
|
||||
Reference in new issue
Block a user