Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bfea7cd93 | ||
|
|
3e703c8792 | ||
|
|
367420961e | ||
|
|
7ee27bb1ab | ||
|
|
e77fcb326d | ||
|
|
c8f45044a3 | ||
|
|
d61384ed2c | ||
|
|
dd8ca74cad | ||
|
|
2ca89b1c6d | ||
|
|
1a3b3ee23a | ||
|
|
194edee6de | ||
|
|
dcb09e619e | ||
|
|
087d9e7b30 | ||
|
|
e87186e476 | ||
|
|
7e9aefa755 | ||
|
|
78e363652b | ||
|
|
a57531c95f | ||
|
|
7f053a0fe9 | ||
|
|
5b28bff48b | ||
|
|
c2c3926135 | ||
|
|
076c01aac4 | ||
|
|
f3373e1f66 | ||
|
|
0beaa24f82 | ||
|
|
b626b3b8db | ||
|
|
aff7cf8839 | ||
|
|
389a679c9f | ||
|
|
8b5ee73ee1 | ||
|
|
e901de442c | ||
|
|
2f84bc8deb | ||
|
|
524f5dc0fd | ||
|
|
4f4cb3e02b | ||
|
|
42f6137391 | ||
|
|
3504263992 | ||
|
|
3466a29692 |
No files matched your search
+28
-3
@@ -1,6 +1,6 @@
|
|||||||
# Copy this file to .env and adjust it.
|
# Copy this file to .env and adjust it.
|
||||||
SYSLOG_PORT=514
|
SYSLOG_PORT=5514
|
||||||
HTTP_PORT=8080
|
HTTP_PORT=8989
|
||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||||
RETENTION=30d
|
RETENTION=30d
|
||||||
@@ -9,6 +9,9 @@ AUTH_MODE=local
|
|||||||
# local mode: user and password (empty = no authentication)
|
# local mode: user and password (empty = no authentication)
|
||||||
AUTH_USER=
|
AUTH_USER=
|
||||||
AUTH_PASS=
|
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).
|
# 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
|
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
|
||||||
LOGIN_LOGO=
|
LOGIN_LOGO=
|
||||||
@@ -28,12 +31,16 @@ OIDC_CLIENT_SECRET=
|
|||||||
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
||||||
# Requested scopes (openid is always added)
|
# Requested scopes (openid is always added)
|
||||||
OIDC_SCOPES=openid profile email
|
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)
|
# Reverse DNS: show host names instead of IP addresses (on/off)
|
||||||
RDNS=on
|
RDNS=on
|
||||||
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
|
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
|
||||||
DNS_SERVER=
|
DNS_SERVER=
|
||||||
# Allow "Delete all logs" in Settings (true/false)
|
# Allow "Delete all logs" in Settings (true/false)
|
||||||
ALLOW_PURGE=true
|
ALLOW_PURGE=false
|
||||||
# Maximum number of rows in a CSV export
|
# Maximum number of rows in a CSV export
|
||||||
EXPORT_MAX=100000
|
EXPORT_MAX=100000
|
||||||
# Collect the logs of the Docker containers of this machine (on/off)
|
# Collect the logs of the Docker containers of this machine (on/off)
|
||||||
@@ -46,3 +53,21 @@ DOCKER_BACKFILL=1h
|
|||||||
HOST_LOGS_GID=4
|
HOST_LOGS_GID=4
|
||||||
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new entries)
|
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new entries)
|
||||||
HOST_LOGS_BACKFILL=1h
|
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=
|
||||||
|
# Log files of other programs (Traefik, nginx, applications…), declared in Settings > Log sources >
|
||||||
|
# Log files: mount their directory read-only in docker-compose.yml (e.g. /var/log/traefik:/logs/traefik:ro).
|
||||||
|
# History read when a file is turned on (e.g. 30m, 1h, 24h; 0 = only new lines)
|
||||||
|
FILES_BACKFILL=1h
|
||||||
+335
-29
@@ -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
|
- **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
|
par `sink()` (résolution DNS inverse des hôtes donnés par leur IP, sans bloquer la
|
||||||
`Store`, qui les envoie par lots à VictoriaLogs.
|
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
|
- **Direct** : `sink()` publie aussi chaque message dans le `Hub`, qui le diffuse aux
|
||||||
navigateurs en SSE.
|
navigateurs en SSE.
|
||||||
- **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à
|
- **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à
|
||||||
@@ -53,7 +55,7 @@ Ouvrez ensuite <http://localhost:8080>.
|
|||||||
- **Test manuel** : `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) ou
|
- **Test manuel** : `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) ou
|
||||||
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
||||||
|
|
||||||
**Paramètres > Sources > Syslog** active ou désactive la réception syslog et choisit les
|
**Paramètres > Sources de logs > Syslog** active ou désactive la réception syslog et choisit les
|
||||||
protocoles (UDP, TCP) sans redémarrage ; le choix est enregistré dans `/data/syslog.json`. Cet
|
protocoles (UDP, TCP) sans redémarrage ; le choix est enregistré dans `/data/syslog.json`. Cet
|
||||||
onglet affiche aussi l'état d'écoute (et l'erreur si le port est déjà utilisé). Le port
|
onglet affiche aussi l'état d'écoute (et l'erreur si le port est déjà utilisé). Le port
|
||||||
lui-même est publié par docker-compose : modifiez `SYSLOG_PORT` dans `.env`, puis lancez
|
lui-même est publié par docker-compose : modifiez `SYSLOG_PORT` dans `.env`, puis lancez
|
||||||
@@ -90,16 +92,30 @@ une base propre.
|
|||||||
Les badges de sévérité `err`/`crit` et `warning` reprennent les couleurs des tags `error` et
|
Les badges de sévérité `err`/`crit` et `warning` reprennent les couleurs des tags `error` et
|
||||||
`warning`.
|
`warning`.
|
||||||
|
|
||||||
|
Chaque ligne commence par un badge de largeur fixe qui indique sa source, avec une couleur par
|
||||||
|
source : **SYSLOG**, **DOCKER**, **KERNEL** (dmesg), **HOST** (fichiers de `/var/log`), **AUDIT**,
|
||||||
|
**FILE** et **LS** (logs de LogStream lui-même).
|
||||||
|
|
||||||
Raccourcis : `/` place le curseur dans la recherche, `Esc` la vide. Un clic sur une ligne
|
Raccourcis : `/` place le curseur dans la recherche, `Esc` la vide. Un clic sur une ligne
|
||||||
affiche tous ses champs.
|
affiche tous ses champs.
|
||||||
|
|
||||||
Les en-têtes de colonnes restent visibles pendant le défilement. Faites glisser le bord d'un
|
Les en-têtes de colonnes restent visibles pendant le défilement. Faites glisser le bord d'un
|
||||||
en-tête (réception, heure message, sévérité, hôte, app) pour redimensionner la colonne, et
|
en-tête (réception, heure message, sévérité, hôte, app) pour redimensionner la colonne, et
|
||||||
double-cliquez dessus pour revenir à la largeur automatique ; le message occupe l'espace
|
double-cliquez dessus pour revenir à la largeur automatique ; le message occupe l'espace
|
||||||
restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Interface >
|
restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Affichage >
|
||||||
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
|
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
|
||||||
présentation sur deux lignes, sans colonnes.
|
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
|
## Frise
|
||||||
|
|
||||||
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
|
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
|
||||||
@@ -107,15 +123,14 @@ de réception** sur l'horloge du serveur (elle correspond donc aux heures affich
|
|||||||
lignes).
|
lignes).
|
||||||
|
|
||||||
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité.
|
- 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
|
- Un clic sur une barre affiche cet intervalle en mode Période ; un glisser sur plusieurs barres
|
||||||
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le
|
affiche la sélection. « × Revenir au flux » repasse en mode Flux. La liste, les compteurs et
|
||||||
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom,
|
l'export CSV suivent la période.
|
||||||
et le direct se met en pause.
|
|
||||||
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à
|
- 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é ;
|
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.
|
la frise se met à jour dès qu'il redevient visible.
|
||||||
|
|
||||||
**Paramètres > Interface > Frise** (mémorisé par navigateur) : échelle (linéaire, √ par défaut,
|
**Paramètres > Affichage > Frise** (mémorisé par navigateur) : échelle (linéaire, √ par défaut,
|
||||||
log), hauteur (S/M/L : 40/80/120 px), couleur (empilement par sévérité, intensité comparée à
|
log), hauteur (S/M/L : 40/80/120 px), couleur (empilement par sévérité, intensité comparée à
|
||||||
la médiane de la fenêtre : calme, rafale au-delà de 3×, anomalie au-delà de 10×, ou aucune),
|
la médiane de la fenêtre : calme, rafale au-delà de 3×, anomalie au-delà de 10×, ou aucune),
|
||||||
barres ou aire, division (automatique, environ 100 intervalles, ou fixe : 1 s, 10 s, 1 min,
|
barres ou aire, division (automatique, environ 100 intervalles, ou fixe : 1 s, 10 s, 1 min,
|
||||||
@@ -144,7 +159,7 @@ colorés et exportés comme les messages syslog :
|
|||||||
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, ou un niveau en majuscules au début de
|
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, ou un niveau en majuscules au début de
|
||||||
la ligne (`ERROR`, `WARN`…). Sinon, elle vaut `info`. Les codes de couleur du terminal sont
|
la ligne (`ERROR`, `WARN`…). Sinon, elle vaut `info`. Les codes de couleur du terminal sont
|
||||||
supprimés.
|
supprimés.
|
||||||
- **Paramètres > Sources** affiche une étiquette par conteneur (`project/service`) dans un seul
|
- **Paramètres > Sources de logs** affiche une étiquette par conteneur (`project/service`) dans un seul
|
||||||
champ : les conteneurs suivis en couleur, puis les non suivis en gris ; un clic bascule une
|
champ : les conteneurs suivis en couleur, puis les non suivis en gris ; un clic bascule une
|
||||||
étiquette. La couleur identifie le projet compose, et les noms d'applications Docker de la
|
étiquette. La couleur identifie le projet compose, et les noms d'applications Docker de la
|
||||||
liste utilisent la même couleur. Les conteneurs arrêtés sont masqués par défaut (« Afficher
|
liste utilisent la même couleur. Les conteneurs arrêtés sont masqués par défaut (« Afficher
|
||||||
@@ -153,8 +168,10 @@ colorés et exportés comme les messages syslog :
|
|||||||
quand les conteneurs sont nombreux. Les nouveaux conteneurs sont suivis automatiquement, sauf
|
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
|
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.
|
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
|
- Logstream mémorise la position de la dernière ligne stockée pour chaque conteneur
|
||||||
un redémarrage, il reprend sans perdre ni dupliquer de lignes. Un conteneur vu pour la
|
(`/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).
|
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 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.
|
`logstream.exclude=true` à tout autre conteneur pour l'exclure définitivement.
|
||||||
@@ -168,7 +185,7 @@ moteur (`GET` uniquement).
|
|||||||
|
|
||||||
Logstream peut aussi collecter les logs système de la machine qui héberge la stack, sans rien
|
Logstream peut aussi collecter les logs système de la machine qui héberge la stack, sans rien
|
||||||
configurer sur l'hôte. La source est **désactivée par défaut** : activez-la dans
|
configurer sur l'hôte. La source est **désactivée par défaut** : activez-la dans
|
||||||
**Paramètres > Sources > Logs système de l'hôte** (enregistré dans `/data/hostlogs.json`).
|
**Paramètres > Sources de logs > Logs système de l'hôte** (enregistré dans `/data/hostlogs.json`).
|
||||||
|
|
||||||
- `docker-compose.yml` monte `/var/log` et `/run/log/journal` en lecture seule sous `/host`.
|
- `docker-compose.yml` monte `/var/log` et `/run/log/journal` en lecture seule sous `/host`.
|
||||||
- Si l'hôte utilise systemd, Logstream lit directement les fichiers du **journal systemd**
|
- Si l'hôte utilise systemd, Logstream lit directement les fichiers du **journal systemd**
|
||||||
@@ -183,13 +200,176 @@ configurer sur l'hôte. La source est **désactivée par défaut** : activez-la
|
|||||||
- **Droits** : le conteneur tourne avec un utilisateur sans privilège et reçoit le groupe `adm`
|
- **Droits** : le conteneur tourne avec un utilisateur sans privilège et reçoit le groupe `adm`
|
||||||
(gid 4), qui peut lire le journal et `/var/log` sur Debian et Ubuntu. Sur d'autres systèmes,
|
(gid 4), qui peut lire le journal et `/var/log` sur Debian et Ubuntu. Sur d'autres systèmes,
|
||||||
réglez `HOST_LOGS_GID` dans `.env` sur le gid de `systemd-journal`
|
réglez `HOST_LOGS_GID` dans `.env` sur le gid de `systemd-journal`
|
||||||
(`getent group systemd-journal | cut -d: -f3`). Paramètres > Sources affiche un message clair
|
(`getent group systemd-journal | cut -d: -f3`). Paramètres > Sources de logs affiche un message clair
|
||||||
si l'accès est refusé.
|
si l'accès est refusé.
|
||||||
- Limites : les champs du journal compressés par journald (messages de plus de 512 octets,
|
- Limites : les champs du journal compressés par journald (messages de plus de 512 octets,
|
||||||
compressés en zstd/lz4/xz) ne peuvent pas être décodés sans bibliothèque supplémentaire ; ils
|
compressés en zstd/lz4/xz) ne peuvent pas être décodés sans bibliothèque supplémentaire ; ils
|
||||||
sont comptés dans les Paramètres et affichés comme « (compressed journal entry) ». Les fichiers
|
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.
|
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 de logs > 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 de logs 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 le badge de source **AUDIT** ; 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.
|
||||||
|
|
||||||
|
## Fichiers de logs (Traefik, nginx, applications…)
|
||||||
|
|
||||||
|
Logstream peut suivre les fichiers de logs d'autres programmes : Traefik, nginx, Apache, ou
|
||||||
|
toute application qui écrit dans un fichier. Déclarez chaque fichier dans **Paramètres > Sources
|
||||||
|
de logs > Fichiers de logs** (enregistré dans `/data/files.json`, 50 au plus) : un **nom** (l'**app**
|
||||||
|
de ses entrées, par défaut le nom du fichier), le **chemin** du fichier tel que monté dans le
|
||||||
|
conteneur, un **format** (*Automatique* par défaut), un **hôte** facultatif (par défaut
|
||||||
|
`/host/etc/hostname`, sinon `localhost`) et un interrupteur.
|
||||||
|
|
||||||
|
- **Montage** du dossier en lecture seule, par ex. dans `docker-compose.yml` :
|
||||||
|
`- /var/log/traefik:/logs/traefik:ro`, puis `docker compose up -d`. Les fichiers de `/var/log`
|
||||||
|
sont déjà accessibles sous `/host/var/log/…`. Les fichiers doivent être lisibles par l'UID
|
||||||
|
`10001` ou le groupe de `HOST_LOGS_GID` (`adm` par défaut) ; Paramètres > Sources de logs
|
||||||
|
indique quand aucun fichier ne correspond ou que l'accès est refusé.
|
||||||
|
- **Chemin** : un fichier, par ex. `/logs/traefik/access.log` (pas de jokers). Dès la saisie, le
|
||||||
|
fichier est vérifié : trouvé ou non, lisible ou non, son format détecté et sa dernière ligne.
|
||||||
|
- **Détection du format** (*Automatique*) : à partir des dernières lignes du fichier (50 au
|
||||||
|
plus), le format structuré que suivent au moins 80 % d'entre elles — accès Traefik JSON, JSON
|
||||||
|
lines, journal d'accès CLF, logfmt — sinon texte brut. Elle est refaite quand le format est
|
||||||
|
changé ; le format trouvé s'affiche dans l'état du fichier. Choisissez un autre format pour
|
||||||
|
le forcer (une expression régulière n'est jamais détectée).
|
||||||
|
- **Formats** :
|
||||||
|
- *Texte brut* : une entrée par ligne ; le niveau est détecté comme pour les logs Docker, un
|
||||||
|
horodatage en début de ligne (ISO 8601, `2006/01/02 15:04:05`, `[02/Jan/2006:15:04:05 +0200]`,
|
||||||
|
`Jan 2 15:04:05`) devient l'heure du message et est retiré du message. Les lignes indentées
|
||||||
|
(stack traces) sont rattachées à l'entrée précédente.
|
||||||
|
- *JSON lines* : `msg` / `message`, `time` / `ts` / `@timestamp` (texte ou heure Unix) et
|
||||||
|
`level` (mot ou nombre, pino/bunyan compris) sont reconnus ; les autres clés deviennent des
|
||||||
|
champs, les objets imbriqués avec des noms pointés (`req.id`).
|
||||||
|
- *logfmt* : paires `clé=valeur`, comme les logs par défaut de Traefik ou des programmes Go ;
|
||||||
|
mêmes clés que le JSON.
|
||||||
|
- *Journal d'accès (CLF)* : Common / Combined Log Format de nginx et Apache, et le journal
|
||||||
|
d'accès de Traefik dans ce format (routeur, URL du serveur et durée compris). Champs
|
||||||
|
`client_ip`, `http_method`, `http_path`, `http_status`, `http_size`, `user_agent`,
|
||||||
|
`http_referer`, `router`, `backend`, `duration_ms`.
|
||||||
|
- *Accès Traefik (JSON)* : le journal d'accès JSON de Traefik (`accessLog.format: json`),
|
||||||
|
résumé en `GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9`, toutes les clés
|
||||||
|
dans les champs. Les logs de Traefik lui-même dans le même fichier sont lus comme du JSON.
|
||||||
|
- *Expression régulière* (syntaxe RE2 / Go) : les groupes nommés `time`, `level`, `msg`,
|
||||||
|
`host` et `app` remplissent ces champs, `status` donne le niveau d'un journal d'accès, les
|
||||||
|
autres groupes deviennent des champs, par ex.
|
||||||
|
`^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>\S+): (?P<msg>.*)$`. Une ligne qui ne
|
||||||
|
correspond pas est rattachée à l'entrée précédente.
|
||||||
|
|
||||||
|
Pour les journaux d'accès, les statuts 5xx sont en `err`, les 4xx en `warning`, les autres en
|
||||||
|
`info`. Une ligne qui ne suit pas le format choisi est gardée comme du texte brut. Les champs
|
||||||
|
qui portent le nom d'un champ de LogStream (`host`, `app`…) sont renommés `field_host`,
|
||||||
|
`field_app`…
|
||||||
|
- **Affichage** : source `file`, le chemin dans `log_file`. Dans la liste, ces lignes ont un
|
||||||
|
liseré gauche vert, une légère teinte et le badge de source **FILE** ; le filtre **Source**
|
||||||
|
les affiche seules.
|
||||||
|
- À l'activation d'un fichier, les lignes de la dernière heure déjà présentes sont lues d'abord
|
||||||
|
(`FILES_BACKFILL`, `0` = seulement les nouvelles lignes ; les lignes sans horodatage sont alors
|
||||||
|
ignorées). Le début de cette heure est trouvé par dichotomie sur les dates des lignes : un gros
|
||||||
|
fichier qui contient des mois de logs n'est pas relu depuis son début ; un fichier créé ensuite
|
||||||
|
est lu depuis son début, jusqu'à 64 Mo par seconde, l'état indiquant ce qu'il reste à lire. L'inode et une
|
||||||
|
position sont enregistrés par fichier dans `/data/files-state.json` : après un redémarrage la
|
||||||
|
lecture reprend là ; après une rotation (`access.log` renommé `access.log.1`), la fin de
|
||||||
|
l'ancien fichier est lue avant le nouveau ; un fichier vidé sur place (`copytruncate`) est
|
||||||
|
relu depuis son début.
|
||||||
|
|
||||||
|
## 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`, `files`, `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
|
## Export CSV
|
||||||
|
|
||||||
Le bouton **Exporter** (à côté du nombre de logs) télécharge tous les logs stockés qui
|
Le bouton **Exporter** (à côté du nombre de logs) télécharge tous les logs stockés qui
|
||||||
@@ -210,18 +390,96 @@ charge (pas de `| pipes`).
|
|||||||
|
|
||||||
En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
||||||
|
|
||||||
|
## API de lecture (n8n, scripts)
|
||||||
|
|
||||||
|
`GET /api/v1/logs` renvoie les logs stockés qui suivent un curseur, du plus ancien au plus
|
||||||
|
récent : un outil appelé à intervalle régulier (n8n, un script, un connecteur SIEM) reçoit ainsi
|
||||||
|
chaque nouveau log une fois, sans trou ni doublon, même quand beaucoup de logs ont le même
|
||||||
|
horodatage.
|
||||||
|
|
||||||
|
L'accès passe par un **jeton d'API**, créé dans *Paramètres > API* par un administrateur et
|
||||||
|
envoyé dans `Authorization: Bearer lst_…`. Un jeton est en lecture seule et n'ouvre que
|
||||||
|
`/api/v1/` ; il fonctionne quel que soit le mode d'[authentification](#authentification)
|
||||||
|
(`local`, `oidc` ou aucune). Il n'est affiché qu'une fois, à sa création : LogStream n'en garde
|
||||||
|
que l'empreinte SHA-256, dans `/data/api_tokens.json`. Le révoquer (puis Valider) coupe l'accès immédiatement ;
|
||||||
|
la liste indique quand et d'où chaque jeton a servi pour la dernière fois. Une session ouverte
|
||||||
|
dans le navigateur peut aussi appeler la route.
|
||||||
|
|
||||||
|
| Paramètre | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `query` | filtre LogsQL (par défaut `*`), par exemple `severity:in(err,crit) app:sshd`. Les pipes (`\| ...`) sont refusés. |
|
||||||
|
| `since` | point de départ : le `next_cursor` de la réponse précédente, une date RFC 3339 (`2026-11-01T08:00:00Z`) ou une durée avant maintenant (`15m`, `6h`, `7d`). Par défaut : `1h`. |
|
||||||
|
| `limit` | lignes par réponse, de 1 à 10 000 (1 000 par défaut). |
|
||||||
|
| `lag` | seuls les logs reçus depuis au moins cette durée sont renvoyés (`30s` par défaut, `1h` au plus), pour ne pas sauter les logs encore dans la file ou en attente de leur nom DNS. |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": [{"_time": "2026-11-01T14:02:11.52Z", "_msg": "…", "host": "fw01", "app": "sshd", "severity": "err", …}],
|
||||||
|
"next_cursor": "eyJ0IjoiMjAyNi0xMS0wMVQxNDowMjoxMS41MjAwMDAwMDFaIiwicSI6IjJlYjE4YzNhIn0",
|
||||||
|
"has_more": false,
|
||||||
|
"until": "2026-11-01T14:05:30.1Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les lignes contiennent les champs stockés (comme la vue JSON de l'interface), triées par
|
||||||
|
`_time`, l'heure de réception. Renvoyez toujours `next_cursor`, même quand `rows` est vide.
|
||||||
|
`has_more: true` signifie que d'autres logs sont prêts : rappelez tout de suite.
|
||||||
|
`waiting: true` signifie que des logs du [tampon disque](#configuration) sont encore en cours
|
||||||
|
d'envoi à VictoriaLogs : rien n'est renvoyé tant qu'ils ne sont pas stockés, pour n'en manquer
|
||||||
|
aucun. Le curseur est lié à sa `query` : changer la requête garde la position dans le temps. Les
|
||||||
|
erreurs arrivent sous la forme `{"error", "code"}` avec le statut 400 (paramètre ou LogsQL
|
||||||
|
invalide), 401 (jeton invalide ou révoqué) ou 502 (VictoriaLogs injoignable).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer lst_…" \
|
||||||
|
"https://logs.example.org/api/v1/logs?query=severity:err&since=15m&limit=1000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemple : workflow n8n
|
||||||
|
|
||||||
|
Quatre nœuds, le curseur gardé dans les données statiques du workflow d'une exécution à l'autre :
|
||||||
|
|
||||||
|
1. **Schedule Trigger** : toutes les minutes.
|
||||||
|
2. **Code** (*Run Once for All Items*), lit le curseur :
|
||||||
|
```js
|
||||||
|
const data = $getWorkflowStaticData('global');
|
||||||
|
return [{ json: { since: data.cursor || '15m' } }];
|
||||||
|
```
|
||||||
|
3. **HTTP Request** : méthode `GET`, URL `https://logs.example.org/api/v1/logs`.
|
||||||
|
*Authentication* : Generic Credential Type > Header Auth, avec le nom `Authorization` et la
|
||||||
|
valeur `Bearer lst_…`. *Send Query Parameters* : `query` = `severity:in(emerg,alert,crit,err)`,
|
||||||
|
`since` = `{{ $json.since }}`, `limit` = `1000`.
|
||||||
|
4. **Code** (*Run Once for All Items*), enregistre le curseur et fait de chaque log un item :
|
||||||
|
```js
|
||||||
|
const data = $getWorkflowStaticData('global');
|
||||||
|
const page = $input.first().json;
|
||||||
|
data.cursor = page.next_cursor;
|
||||||
|
return page.rows.map((row) => ({ json: row }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Branchez ensuite ce que les logs doivent alimenter (Slack, mail, ticket, base de données…). n8n ne
|
||||||
|
garde les données statiques que pour les exécutions du workflow actif, pas pour les tests
|
||||||
|
manuels : pendant les essais, chaque exécution repart de `15m`. Au-delà de 1 000 logs par minute,
|
||||||
|
`has_more` vaut true et les exécutions suivantes rattrapent le retard ; augmentez `limit` ou la
|
||||||
|
fréquence du déclencheur si besoin.
|
||||||
|
|
||||||
## Paramètres
|
## Paramètres
|
||||||
|
|
||||||
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Tout, sauf les tags de
|
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Les modifications
|
||||||
couleur, est mémorisé par navigateur.
|
s'affichent tout de suite mais ne sont conservées qu'avec le bouton **Valider**, sous les libellés
|
||||||
|
des onglets : il prend la couleur d'accent tant que des modifications sont en attente. **Annuler**
|
||||||
|
revient aux paramètres enregistrés, et fermer la fenêtre avec des modifications non validées
|
||||||
|
demande confirmation avant de les abandonner. Localisation et Affichage sont mémorisés par
|
||||||
|
navigateur ; les tags de couleur et les sources de logs sont enregistrés sur le serveur. La purge
|
||||||
|
et la réinitialisation des colonnes agissent immédiatement.
|
||||||
|
|
||||||
- **Localisation**
|
- **Localisation et langue**
|
||||||
- *Langue* : anglais ou français.
|
- *Langue* : anglais ou français.
|
||||||
- *Date et heure* : fuseau horaire (celui du navigateur, UTC ou environ 80 fuseaux courants)
|
- *Date et heure* : fuseau horaire (celui du navigateur, UTC ou environ 80 fuseaux courants)
|
||||||
et format d'affichage de l'heure de réception : `DD/MM/YYYY HH:MM:SS` (par défaut,
|
et format d'affichage de l'heure de réception : `DD/MM/YYYY HH:MM:SS` (par défaut,
|
||||||
l'affichage français habituel), avec millisecondes, `YYYY-MM-DD`, horloge sur 12 heures,
|
l'affichage français habituel), avec millisecondes, `YYYY-MM-DD`, horloge sur 12 heures,
|
||||||
ISO 8601 ou epoch Unix. Le fuseau horaire s'applique à toutes les dates affichées.
|
ISO 8601 ou epoch Unix. Le fuseau horaire s'applique à toutes les dates affichées.
|
||||||
- **Filtres** : tags de couleur. Chaque tag a un mot-clé, une couleur de fond (le texte passe
|
- **Motifs de recherche** : tags de couleur. Chaque tag a un mot-clé, une couleur de fond (le texte passe
|
||||||
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
|
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
|
||||||
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
|
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
|
||||||
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
|
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
|
||||||
@@ -240,10 +498,10 @@ couleur, est mémorisé par navigateur.
|
|||||||
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
|
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
|
||||||
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
|
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
|
||||||
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
|
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
|
||||||
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
|
un aussi). La colonne *Motif de recherche* de la liste affiche, en badges gris, les codes des tags actifs
|
||||||
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
|
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
|
||||||
l'infobulle les liste tous.
|
l'infobulle les liste tous.
|
||||||
- **Interface**
|
- **Affichage**
|
||||||
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
|
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
|
||||||
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
|
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
|
||||||
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
|
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
|
||||||
@@ -257,12 +515,21 @@ couleur, est mémorisé par navigateur.
|
|||||||
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
|
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
|
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.
|
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
|
- **Base de 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
|
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
|
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
|
||||||
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface
|
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
||||||
peut purger : activez l'[authentification](#authentification) si l'interface est accessible
|
alors purger : activez l'[authentification](#authentification) si l'interface est accessible
|
||||||
à d'autres.
|
à d'autres.
|
||||||
|
- **API** : jetons de l'[API de lecture](#api-de-lecture-n8n-scripts) : en ajouter un avec un
|
||||||
|
nom, en révoquer un, voir quand et d'où chacun a servi pour la dernière fois. Ajouts et
|
||||||
|
révocations attendent **Valider** comme les autres réglages (**Annuler** les abandonne). Après
|
||||||
|
Valider, chaque nouveau jeton est affiché une seule fois avec un bouton Copier, et l'exemple
|
||||||
|
curl en dessous l'utilise, surligné, avec son propre bouton Copier. Réservé aux administrateurs.
|
||||||
|
|
||||||
## Authentification
|
## Authentification
|
||||||
|
|
||||||
@@ -270,6 +537,7 @@ couleur, est mémorisé par navigateur.
|
|||||||
|
|
||||||
- **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
|
- **`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à).
|
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…),
|
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
|
||||||
flux « authorization code » avec PKCE.
|
flux « authorization code » avec PKCE.
|
||||||
|
|
||||||
@@ -280,6 +548,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
|
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`).
|
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
|
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
|
||||||
son chemin dans `LOGIN_LOGO` :
|
son chemin dans `LOGIN_LOGO` :
|
||||||
|
|
||||||
@@ -320,6 +592,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
|
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.
|
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)
|
## 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),
|
Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout),
|
||||||
@@ -342,15 +624,20 @@ résolutions.
|
|||||||
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
||||||
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
|
| `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_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`) |
|
| `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) |
|
| `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_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_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_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
|
||||||
| `OIDC_SCOPES` | `openid profile email` | scopes demandés |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `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)) |
|
| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
|
||||||
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
|
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
|
||||||
@@ -359,15 +646,22 @@ résolutions.
|
|||||||
| `HOST_LOGS_GID` | `4` (adm) dans compose | groupe donné au conteneur pour lire les logs de l'hôte |
|
| `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_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 |
|
| `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=` |
|
||||||
|
| `FILES_BACKFILL` | `1h` | historique lu à l'activation d'un fichier de logs (Paramètres > Sources de logs > Fichiers de logs) |
|
||||||
| `TZ` | `Europe/Paris` | fuseau horaire des horodatages RFC 3164 (qui n'en portent pas) |
|
| `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 |
|
| `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
|
## Débogage
|
||||||
|
|
||||||
- `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers
|
- `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers
|
||||||
VictoriaLogs.
|
VictoriaLogs.
|
||||||
- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de
|
- La barre du bas affiche les compteurs reçus / stockés / perdus, les messages en attente dans
|
||||||
stockage.
|
le tampon disque et la dernière erreur de stockage.
|
||||||
- <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes
|
- <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes
|
||||||
LogsQL.
|
LogsQL.
|
||||||
- API :
|
- API :
|
||||||
@@ -398,7 +692,7 @@ Pour mettre à jour l'une d'elles :
|
|||||||
VictoriaLogs conserve son format de stockage entre versions mineures ; lisez le changelog
|
VictoriaLogs conserve son format de stockage entre versions mineures ; lisez le changelog
|
||||||
avant un changement de version majeure.
|
avant un changement de version majeure.
|
||||||
2. Modifiez la version dans le fichier indiqué ci-dessus, puis lancez `docker compose up -d --build`.
|
2. Modifiez la version dans le fichier indiqué ci-dessus, puis lancez `docker compose up -d --build`.
|
||||||
3. Vérifiez la barre du bas (reçus / stockés / perdus) et **Paramètres > Sources**. Pour revenir
|
3. Vérifiez la barre du bas (reçus / stockés / perdus) et **Paramètres > Sources de logs**. Pour revenir
|
||||||
en arrière, remettez la version précédente et lancez la même commande.
|
en arrière, remettez la version précédente et lancez la même commande.
|
||||||
|
|
||||||
## Organisation du code
|
## Organisation du code
|
||||||
@@ -415,12 +709,19 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| `hub.go` | envoie les nouveaux messages aux navigateurs (SSE) |
|
| `hub.go` | envoie les nouveaux messages aux navigateurs (SSE) |
|
||||||
| `rdns.go` | résolutions DNS inverses avec cache |
|
| `rdns.go` | résolutions DNS inverses avec cache |
|
||||||
| `export.go` | export CSV en flux |
|
| `export.go` | export CSV en flux |
|
||||||
|
| `logsapi.go` | API de lecture `/api/v1/logs` : curseur, groupes de logs de même heure |
|
||||||
|
| `apitokens.go` | jetons d'API : stockage (haché), contrôle Bearer, `/api/tokens` |
|
||||||
| `docker.go` | logs des conteneurs Docker (API, lecteurs, positions, détection du niveau) |
|
| `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 |
|
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources de logs |
|
||||||
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
| `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é |
|
||||||
|
| `filesource.go`, `fileformats.go` | fichiers de logs : suivi (rotation, positions enregistrées, lignes de suite) et formats (détection, texte, JSON, logfmt, CLF, Traefik, expression régulière) |
|
||||||
|
| `selflog.go` | logs internes de LogStream : `logEvent`, niveau minimal, limitation des répétitions, file bornée |
|
||||||
| `tags.go` | stockage des tags de couleur |
|
| `tags.go` | stockage des tags de couleur |
|
||||||
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
|
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
|
||||||
| `api.go` | routes HTTP `/api/*` |
|
| `api.go` | routes HTTP `/api/*` |
|
||||||
|
| `static.go` | fichiers de l'interface embarqués : ETag par fichier, revérifiés à chaque chargement (`no-cache`, 304 s'ils n'ont pas changé) pour toujours prendre une mise à jour ; polices en cache 30 jours |
|
||||||
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
|
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
|
||||||
|
|
||||||
## Remarque
|
## Remarque
|
||||||
@@ -428,3 +729,8 @@ Pour mettre à jour l'une d'elles :
|
|||||||
Avec Docker Desktop (macOS/Windows), l'IP source vue par le conteneur pour les paquets UDP est
|
Avec Docker Desktop (macOS/Windows), l'IP source vue par le conteneur pour les paquets UDP est
|
||||||
la passerelle Docker. Le champ `host` vient toujours de l'en-tête syslog, qui porte normalement
|
la passerelle Docker. Le champ `host` vient toujours de l'en-tête syslog, qui porte normalement
|
||||||
le vrai nom de l'émetteur.
|
le vrai nom de l'émetteur.
|
||||||
|
|
||||||
|
## Composants tiers
|
||||||
|
|
||||||
|
Les icônes de l'interface viennent de [Papicons](https://github.com/PapillonApp/papicons) (MIT) et, pour quelques-unes,
|
||||||
|
de [Lucide](https://lucide.dev) (ISC). Licences et notices : [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
@@ -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
|
- **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
|
- **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the
|
||||||
browsers over SSE.
|
browsers over SSE.
|
||||||
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
|
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
|
||||||
@@ -48,7 +50,7 @@ Then open <http://localhost:8080>.
|
|||||||
- **Manual test**: `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) or
|
- **Manual test**: `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) or
|
||||||
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
|
||||||
|
|
||||||
**Settings > Sources > Syslog** turns syslog reception on or off and chooses the protocols
|
**Settings > Log sources > Syslog** turns syslog reception on or off and chooses the protocols
|
||||||
(UDP, TCP) without restarting; the choice is saved in `/data/syslog.json`. It also shows the
|
(UDP, TCP) without restarting; the choice is saved in `/data/syslog.json`. It also shows the
|
||||||
listening state (and the error if the port is already in use). The port itself is published
|
listening state (and the error if the port is already in use). The port itself is published
|
||||||
by docker-compose: change `SYSLOG_PORT` in `.env`, then run `docker compose up -d`.
|
by docker-compose: change `SYSLOG_PORT` in `.env`, then run `docker compose up -d`.
|
||||||
@@ -81,28 +83,41 @@ versions before this change are indexed by their message timestamp; purge them f
|
|||||||
Settings to start clean.
|
Settings to start clean.
|
||||||
|
|
||||||
Severity badges `err`/`crit` and `warning` use the colors of the `error` and `warning` tags.
|
Severity badges `err`/`crit` and `warning` use the colors of the `error` and `warning` tags.
|
||||||
|
Each row starts with a fixed-width badge naming its source, one color per source: **SYSLOG**,
|
||||||
|
**DOCKER**, **KERNEL** (dmesg), **HOST** (files of `/var/log`), **AUDIT**, **FILE** and **LS**
|
||||||
|
(LogStream's own logs).
|
||||||
|
|
||||||
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
|
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
|
||||||
|
|
||||||
The column headers stay visible while scrolling. Drag the edge of a header (received, message
|
The column headers stay visible while scrolling. Drag the edge of a header (received, message
|
||||||
time, severity, host, app) to resize the column, double-click it to go back to the automatic
|
time, severity, host, app) to resize the column, double-click it to go back to the automatic
|
||||||
width; the message takes the remaining space. Widths are remembered by the browser
|
width; the message takes the remaining space. Widths are remembered by the browser
|
||||||
(**Settings > Interface > Reset column widths** restores them all). On phones the list keeps its
|
(**Settings > Appearance > Reset column widths** restores them all). On phones the list keeps its
|
||||||
two-line layout without columns.
|
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
|
## Timeline
|
||||||
|
|
||||||
The timeline above the list shows the volume of logs per interval, counted by **reception
|
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).
|
time** on the server clock (so it matches the times shown in the rows).
|
||||||
|
|
||||||
- Hover an interval: its bounds, total and detail per severity.
|
- 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.
|
- Click a bar to show that interval in Time range mode, or drag across several bars to show the
|
||||||
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it);
|
selection; "× Back to stream" returns to Stream mode. The list, the counters and the CSV export
|
||||||
the list, the counters and the CSV export follow the zoom, and the live view pauses.
|
follow the range.
|
||||||
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new
|
- 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.
|
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
|
||||||
|
|
||||||
**Settings > Interface > Timeline** (remembered per browser): scale (linear, √ by default, log),
|
**Settings > Appearance > Timeline** (remembered per browser): scale (linear, √ by default, log),
|
||||||
height (S/M/L: 40/80/120 px), color (stacked by severity, intensity compared with the median of
|
height (S/M/L: 40/80/120 px), color (stacked by severity, intensity compared with the median of
|
||||||
the window: calm, burst above 3×, anomaly above 10×, or none), bars or area, division
|
the window: calm, burst above 3×, anomaly above 10×, or none), bars or area, division
|
||||||
(automatic, about 100 intervals, or fixed: 1 s, 10 s, 1 min, 5 min, 1 h, 1 day) and refresh
|
(automatic, about 100 intervals, or fixed: 1 s, 10 s, 1 min, 5 min, 1 h, 1 day) and refresh
|
||||||
@@ -129,7 +144,7 @@ colored and exported like syslog messages:
|
|||||||
- The severity comes from the line itself when the application writes it: JSON
|
- The severity comes from the line itself when the application writes it: JSON
|
||||||
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, or an upper-case level word at the
|
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, or an upper-case level word at the
|
||||||
start of the line (`ERROR`, `WARN`…). Otherwise it is `info`. Terminal color codes are removed.
|
start of the line (`ERROR`, `WARN`…). Otherwise it is `info`. Terminal color codes are removed.
|
||||||
- **Settings > Sources** shows one label per container (`project/service`) in a single field:
|
- **Settings > Log sources** shows one label per container (`project/service`) in a single field:
|
||||||
followed containers in color, then not followed ones in grey; a click switches a label.
|
followed containers in color, then not followed ones in grey; a click switches a label.
|
||||||
The color identifies the compose project, and Docker app names in the log list use the
|
The color identifies the compose project, and Docker app names in the log list use the
|
||||||
same color. Stopped containers are hidden by default ("Show stopped containers" displays
|
same color. Stopped containers are hidden by default ("Show stopped containers" displays
|
||||||
@@ -137,8 +152,10 @@ colored and exported like syslog messages:
|
|||||||
to the labels shown) help with many containers. New containers are followed automatically
|
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)
|
unless that option is turned off. Choices are saved per compose service (or container name)
|
||||||
in `/data/docker.json`, so they survive re-creations.
|
in `/data/docker.json`, so they survive re-creations.
|
||||||
- Logstream remembers the position read in each container (`/data/docker-state.json`): after a
|
- Logstream remembers the position of the last line stored for each container
|
||||||
restart it resumes without losing or duplicating lines. A container seen for the first time
|
(`/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).
|
is read from `DOCKER_BACKFILL` ago (1 hour by default).
|
||||||
- Logstream itself and the proxy below are never collected; add the label
|
- Logstream itself and the proxy below are never collected; add the label
|
||||||
`logstream.exclude=true` to any other container to exclude it for good.
|
`logstream.exclude=true` to any other container to exclude it for good.
|
||||||
@@ -151,7 +168,7 @@ which only lets through listing containers, reading logs, events and engine info
|
|||||||
|
|
||||||
Logstream can also collect the system logs of the machine hosting the stack, without
|
Logstream can also collect the system logs of the machine hosting the stack, without
|
||||||
configuring anything on the host. The source is **off by default**: turn it on in
|
configuring anything on the host. The source is **off by default**: turn it on in
|
||||||
**Settings > Sources > Host system logs** (saved in `/data/hostlogs.json`).
|
**Settings > Log sources > Host system logs** (saved in `/data/hostlogs.json`).
|
||||||
|
|
||||||
- `docker-compose.yml` mounts `/var/log` and `/run/log/journal` read-only under `/host`.
|
- `docker-compose.yml` mounts `/var/log` and `/run/log/journal` read-only under `/host`.
|
||||||
- When the host runs systemd, Logstream reads the **systemd journal** files directly (no
|
- When the host runs systemd, Logstream reads the **systemd journal** files directly (no
|
||||||
@@ -166,13 +183,160 @@ configuring anything on the host. The source is **off by default**: turn it on i
|
|||||||
- **Permissions**: the container runs as an unprivileged user and gets the `adm` group
|
- **Permissions**: the container runs as an unprivileged user and gets the `adm` group
|
||||||
(gid 4), which can read the journal and `/var/log` on Debian and Ubuntu. On other systems,
|
(gid 4), which can read the journal and `/var/log` on Debian and Ubuntu. On other systems,
|
||||||
set `HOST_LOGS_GID` in `.env` to the gid of `systemd-journal`
|
set `HOST_LOGS_GID` in `.env` to the gid of `systemd-journal`
|
||||||
(`getent group systemd-journal | cut -d: -f3`). Settings > Sources shows a clear message when
|
(`getent group systemd-journal | cut -d: -f3`). Settings > Log sources shows a clear message when
|
||||||
access is denied.
|
access is denied.
|
||||||
- Limits: journal fields compressed by journald (messages longer than 512 bytes, compressed
|
- Limits: journal fields compressed by journald (messages longer than 512 bytes, compressed
|
||||||
with zstd/lz4/xz) cannot be decoded without extra libraries; they are counted in Settings and
|
with zstd/lz4/xz) cannot be decoded without extra libraries; they are counted in Settings and
|
||||||
shown as "(compressed journal entry)". Rotated or compressed text files (`*.1`, `*.gz`) are
|
shown as "(compressed journal entry)". Rotated or compressed text files (`*.1`, `*.gz`) are
|
||||||
not read.
|
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 > Log 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 > Log 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 > Log 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 > Log 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 the **AUDIT** source badge;
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Log files (Traefik, nginx, applications…)
|
||||||
|
|
||||||
|
Logstream can follow the log files of other programs: Traefik, nginx, Apache, or any application
|
||||||
|
that writes to a file. Declare each file in **Settings > Log sources > Log files** (saved in
|
||||||
|
`/data/files.json`, up to 50): a **name** (the **app** of its entries, by default the file name),
|
||||||
|
the **path** of the file as mounted in the container, a **format** (*Automatic* by default), an
|
||||||
|
optional **host** (default: `/host/etc/hostname`, else `localhost`) and a switch.
|
||||||
|
|
||||||
|
- **Mount** the directory read-only, e.g. in `docker-compose.yml`:
|
||||||
|
`- /var/log/traefik:/logs/traefik:ro`, then `docker compose up -d`. Files under `/var/log`
|
||||||
|
are already reachable as `/host/var/log/…`. The files must be readable by UID `10001` or the
|
||||||
|
group of `HOST_LOGS_GID` (`adm` by default); Settings > Log sources says when no file matches
|
||||||
|
or access is denied.
|
||||||
|
- **Path**: one file, e.g. `/logs/traefik/access.log` (no wildcards). As soon as it is typed, the
|
||||||
|
file is checked: found or not, readable or not, its detected format and its last line.
|
||||||
|
- **Format detection** (*Automatic*): from the last lines of the file (50 at most), the
|
||||||
|
structured format that at least 80 % of them follow — Traefik access JSON, JSON lines, access
|
||||||
|
log CLF, logfmt — else plain text. It is done again when the format is changed; the format
|
||||||
|
found is shown in the status of the file. Choose another format to force it (a regular
|
||||||
|
expression is never detected).
|
||||||
|
- **Formats**:
|
||||||
|
- *Plain text*: one entry per line; the level is detected as for Docker logs, a timestamp at
|
||||||
|
the start of the line (ISO 8601, `2006/01/02 15:04:05`, `[02/Jan/2006:15:04:05 +0200]`,
|
||||||
|
`Jan 2 15:04:05`) becomes the message time and is removed from the message. Indented
|
||||||
|
lines (stack traces) are added to the previous entry.
|
||||||
|
- *JSON lines*: `msg` / `message`, `time` / `ts` / `@timestamp` (text or Unix time) and
|
||||||
|
`level` (word or number, pino/bunyan included) are recognized; the other keys become
|
||||||
|
fields, nested objects with dotted names (`req.id`).
|
||||||
|
- *logfmt*: `key=value` pairs, as in Traefik's default logs or Go programs; same keys as JSON.
|
||||||
|
- *Access log (CLF)*: Common / Combined Log Format of nginx and Apache, and Traefik's access
|
||||||
|
log in this format (router, server URL and duration included). Fields `client_ip`,
|
||||||
|
`http_method`, `http_path`, `http_status`, `http_size`, `user_agent`, `http_referer`,
|
||||||
|
`router`, `backend`, `duration_ms`.
|
||||||
|
- *Traefik access (JSON)*: the JSON access log of Traefik (`accessLog.format: json`),
|
||||||
|
summarized as `GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9`, every key in
|
||||||
|
the fields. Traefik's own logs in the same file are read as JSON lines.
|
||||||
|
- *Regular expression* (RE2 / Go syntax): the named groups `time`, `level`, `msg`, `host`
|
||||||
|
and `app` fill these fields, `status` gives the level of an access log, the other groups
|
||||||
|
become fields, e.g. `^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>\S+): (?P<msg>.*)$`.
|
||||||
|
A line that does not match is added to the previous entry.
|
||||||
|
|
||||||
|
For access logs, 5xx statuses are `err`, 4xx `warning`, the others `info`. A line that does
|
||||||
|
not fit the chosen format is kept as plain text. Fields named like LogStream's own ones
|
||||||
|
(`host`, `app`…) are renamed `field_host`, `field_app`…
|
||||||
|
- **Display**: source `file`, the path in `log_file`. In the list these lines have a green left
|
||||||
|
edge, a light tint and the **FILE** source badge; the **Source** filter shows them alone.
|
||||||
|
- When a file is turned on, the lines of the last hour already in it are read first
|
||||||
|
(`FILES_BACKFILL`, `0` = only new lines; lines without a timestamp are then left out). The
|
||||||
|
start of that hour is found by bisection on the dates of the lines, so a large file that holds
|
||||||
|
months of logs is not read from its start; a file created later is read from its start, up to
|
||||||
|
64 MB per second, the status showing what is left to read. The inode and an offset are saved per file in
|
||||||
|
`/data/files-state.json`: after a restart reading resumes there; after a rotation
|
||||||
|
(`access.log` renamed `access.log.1`), the end of the old file is read before the new one;
|
||||||
|
a file truncated in place (`copytruncate`) is read again from its start.
|
||||||
|
|
||||||
|
## LogStream internal logs
|
||||||
|
|
||||||
|
Logstream records its own events as logs, with **host** `logstream`, **app** the component
|
||||||
|
(`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `files`, `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 > Log 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
|
## CSV export
|
||||||
|
|
||||||
The **Export** button (next to the log count) downloads every stored log matching the
|
The **Export** button (next to the log count) downloads every stored log matching the
|
||||||
@@ -190,10 +354,82 @@ chosen in Settings), `severity`, `facility`, `host`, `host_ip`, `app`, `pid`, `s
|
|||||||
|
|
||||||
From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
|
||||||
|
|
||||||
|
## Reading API (n8n, scripts)
|
||||||
|
|
||||||
|
`GET /api/v1/logs` returns the stored logs that follow a cursor, oldest first, so that a tool
|
||||||
|
called on a schedule (n8n, a script, a SIEM connector) gets every new log once, without gaps
|
||||||
|
or duplicates, even when many logs share the same timestamp.
|
||||||
|
|
||||||
|
Access goes through an **API token**, created in *Settings > API* by an administrator and sent
|
||||||
|
as `Authorization: Bearer lst_…`. A token is read-only and only opens `/api/v1/`; it works
|
||||||
|
whatever the [authentication](#authentication) mode (`local`, `oidc`, or none). It is shown once,
|
||||||
|
when it is created: LogStream only keeps its SHA-256 hash, in `/data/api_tokens.json`.
|
||||||
|
Revoking it (then Save) cuts off access at once; the list shows when and from where each token was last
|
||||||
|
used. A logged-in browser session can call the route too.
|
||||||
|
|
||||||
|
| Parameter | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `query` | LogsQL filter (default `*`), e.g. `severity:in(err,crit) app:sshd`. Pipes (`\| ...`) are refused. |
|
||||||
|
| `since` | where to start: the `next_cursor` of the previous answer, an RFC 3339 time (`2026-11-01T08:00:00Z`) or a duration back from now (`15m`, `6h`, `7d`). Default: `1h`. |
|
||||||
|
| `limit` | rows per answer, 1 to 10,000 (default 1,000). |
|
||||||
|
| `lag` | only logs received at least this long ago are returned (default `30s`, at most `1h`), so that the logs still in the queue or waiting for their DNS name are not skipped. |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": [{"_time": "2026-11-01T14:02:11.52Z", "_msg": "…", "host": "fw01", "app": "sshd", "severity": "err", …}],
|
||||||
|
"next_cursor": "eyJ0IjoiMjAyNi0xMS0wMVQxNDowMjoxMS41MjAwMDAwMDFaIiwicSI6IjJlYjE4YzNhIn0",
|
||||||
|
"has_more": false,
|
||||||
|
"until": "2026-11-01T14:05:30.1Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rows have the stored fields (as in the UI's JSON view), sorted by `_time`, the reception time.
|
||||||
|
Always send back `next_cursor`, even when `rows` is empty. `has_more: true` means more logs are
|
||||||
|
ready: call again at once. `waiting: true` means logs from the [disk buffer](#configuration) are
|
||||||
|
still being sent to VictoriaLogs: nothing is returned until they are stored, so none is missed.
|
||||||
|
The cursor belongs to its `query`: changing the query keeps the position in time. Errors come
|
||||||
|
as `{"error", "code"}` with status 400 (bad parameter or LogsQL), 401 (invalid or revoked token)
|
||||||
|
or 502 (VictoriaLogs unreachable).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer lst_…" \
|
||||||
|
"https://logs.example.org/api/v1/logs?query=severity:err&since=15m&limit=1000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: n8n workflow
|
||||||
|
|
||||||
|
Four nodes, the cursor kept in the workflow's static data between runs:
|
||||||
|
|
||||||
|
1. **Schedule Trigger**: every minute.
|
||||||
|
2. **Code** (*Run Once for All Items*), reads the cursor:
|
||||||
|
```js
|
||||||
|
const data = $getWorkflowStaticData('global');
|
||||||
|
return [{ json: { since: data.cursor || '15m' } }];
|
||||||
|
```
|
||||||
|
3. **HTTP Request**: method `GET`, URL `https://logs.example.org/api/v1/logs`.
|
||||||
|
*Authentication*: Generic Credential Type > Header Auth, with name `Authorization` and value
|
||||||
|
`Bearer lst_…`. *Send Query Parameters*: `query` = `severity:in(emerg,alert,crit,err)`,
|
||||||
|
`since` = `{{ $json.since }}`, `limit` = `1000`.
|
||||||
|
4. **Code** (*Run Once for All Items*), saves the cursor and turns each log into an item:
|
||||||
|
```js
|
||||||
|
const data = $getWorkflowStaticData('global');
|
||||||
|
const page = $input.first().json;
|
||||||
|
data.cursor = page.next_cursor;
|
||||||
|
return page.rows.map((row) => ({ json: row }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then connect what the logs should feed (Slack, mail, ticket, database…). n8n only keeps static
|
||||||
|
data for runs of the active workflow, not for manual test runs: while testing, each run starts
|
||||||
|
again from `15m`. When more than 1,000 logs arrive per minute, `has_more` is true and the
|
||||||
|
following runs catch up; raise `limit` or run the trigger more often if needed.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
The gear icon opens the settings, organized in tabs. Everything except color tags is
|
The gear icon opens the settings, organized in tabs. Changes show at once but are only kept
|
||||||
remembered per browser.
|
with the **Save** button, under the tab labels: it takes the accent color while changes are
|
||||||
|
waiting. **Cancel** goes back to the saved settings, and closing the window with unsaved changes
|
||||||
|
asks before dropping them. Localization and Appearance are remembered per browser; color tags
|
||||||
|
and log sources are saved on the server. Purging and resetting column widths act at once.
|
||||||
|
|
||||||
- **Localization**
|
- **Localization**
|
||||||
- *Language*: English or French.
|
- *Language*: English or French.
|
||||||
@@ -201,7 +437,7 @@ remembered per browser.
|
|||||||
format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display),
|
format of the reception time: `DD/MM/YYYY HH:MM:SS` (default, the usual French display),
|
||||||
with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone
|
with milliseconds, `YYYY-MM-DD`, 12-hour clock, ISO 8601 or Unix epoch. The time zone
|
||||||
applies to every date shown.
|
applies to every date shown.
|
||||||
- **Filters**: color tags. Each tag has a keyword, a background color (the text automatically
|
- **Patterns**: color tags. Each tag has a keyword, a background color (the text automatically
|
||||||
switches to black or white to stay readable) and options: whole word, match case,
|
switches to black or white to stay readable) and options: whole word, match case,
|
||||||
regular expression, active. Tags are stored on the server in `/data/tags.json`
|
regular expression, active. Tags are stored on the server in `/data/tags.json`
|
||||||
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
|
||||||
@@ -218,10 +454,10 @@ remembered per browser.
|
|||||||
or the method, not the text around it.
|
or the method, not the text around it.
|
||||||
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
|
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
|
||||||
until the tag is deleted (codes are also given to tags created by earlier versions). The
|
until the tag is deleted (codes are also given to tags created by earlier versions). The
|
||||||
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
|
*Pattern* column of the log list shows, as grey badges, the codes of the active tags found in
|
||||||
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
|
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
|
||||||
all.
|
all.
|
||||||
- **Interface**
|
- **Appearance**
|
||||||
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
|
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
|
||||||
button in the header switches between light and dark.
|
button in the header switches between light and dark.
|
||||||
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
|
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
|
||||||
@@ -233,18 +469,27 @@ remembered per browser.
|
|||||||
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
|
[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
|
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as
|
||||||
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
|
||||||
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
- **Database**: 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`
|
`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.
|
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
|
||||||
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI
|
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
|
||||||
is reachable by others.
|
is reachable by others.
|
||||||
|
- **API**: API tokens for the [reading API](#reading-api-n8n-scripts): add one with a name,
|
||||||
|
revoke one, see when and from where each was last used. Additions and revocations wait for
|
||||||
|
**Save** like the other settings (**Cancel** drops them). After Save, each new token is shown
|
||||||
|
once with a copy button, and the curl example below uses it, highlighted, with its own copy
|
||||||
|
button. Reserved to administrators.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
|
`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
|
- **`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…),
|
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
|
||||||
authorization code flow with PKCE.
|
authorization code flow with PKCE.
|
||||||
|
|
||||||
@@ -254,6 +499,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`).
|
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`).
|
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:
|
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -291,6 +540,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
|
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.
|
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)
|
## Host names (reverse DNS)
|
||||||
|
|
||||||
When a device sends its IP address as host name (or no host name at all), Logstream looks up
|
When a device sends its IP address as host name (or no host name at all), Logstream looks up
|
||||||
@@ -311,15 +568,20 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
|||||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
||||||
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
|
| `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_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) |
|
| `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) |
|
| `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_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
|
||||||
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
|
| `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_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
|
||||||
| `OIDC_SCOPES` | `openid profile email` | requested scopes |
|
| `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 |
|
| `RDNS` | `on` | resolve IP hosts to DNS names |
|
||||||
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
| `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 |
|
| `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)) |
|
| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
|
||||||
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
|
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
|
||||||
@@ -328,13 +590,21 @@ 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_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_BACKFILL` | `1h` | history read when the host system logs source is turned on |
|
||||||
| `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted |
|
| `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 > Log 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=` |
|
||||||
|
| `FILES_BACKFILL` | `1h` | history read when a log file is turned on (Settings > Log sources > Log files) |
|
||||||
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
|
| `TZ` | `Europe/Paris` | time zone for RFC 3164 timestamps (which carry none) |
|
||||||
| `BATCH_SIZE`, `FLUSH_MS`, `QUEUE_SIZE` | `1000`, `1000`, `100000` | ingestion tuning |
|
| `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
|
## Debugging
|
||||||
|
|
||||||
- `docker compose logs -f logstream`: receive errors and errors sending to VictoriaLogs.
|
- `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.
|
- <http://localhost:9428/select/vmui>: VictoriaLogs' own UI to try LogsQL queries.
|
||||||
- API:
|
- API:
|
||||||
```bash
|
```bash
|
||||||
@@ -364,7 +634,7 @@ To update one of them:
|
|||||||
VictoriaLogs keeps its storage format across minor versions; read the changelog before a
|
VictoriaLogs keeps its storage format across minor versions; read the changelog before a
|
||||||
major version change.
|
major version change.
|
||||||
2. Change the version in the file above, then run `docker compose up -d --build`.
|
2. Change the version in the file above, then run `docker compose up -d --build`.
|
||||||
3. Check the bottom bar (received / stored / dropped) and **Settings > Sources**. To go back,
|
3. Check the bottom bar (received / stored / dropped) and **Settings > Log sources**. To go back,
|
||||||
restore the previous version and run the same command.
|
restore the previous version and run the same command.
|
||||||
|
|
||||||
## Code layout
|
## Code layout
|
||||||
@@ -381,12 +651,19 @@ To update one of them:
|
|||||||
| `hub.go` | pushes new messages to browsers (SSE) |
|
| `hub.go` | pushes new messages to browsers (SSE) |
|
||||||
| `rdns.go` | cached reverse DNS lookups |
|
| `rdns.go` | cached reverse DNS lookups |
|
||||||
| `export.go` | streamed CSV export |
|
| `export.go` | streamed CSV export |
|
||||||
|
| `logsapi.go` | reading API `/api/v1/logs`: cursor, groups of logs with the same time |
|
||||||
|
| `apitokens.go` | API tokens: storage (hashed), Bearer check, `/api/tokens` |
|
||||||
| `docker.go` | Docker container logs (API, followers, positions, level detection) |
|
| `docker.go` | Docker container logs (API, followers, positions, level detection) |
|
||||||
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
|
| `syslogserver.go` | syslog listeners opened and closed from Settings > Log sources |
|
||||||
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
| `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 |
|
||||||
|
| `filesource.go`, `fileformats.go` | log files: follower (rotation, saved offsets, continuation lines) and formats (detection, text, JSON, logfmt, CLF, Traefik, regular expression) |
|
||||||
|
| `selflog.go` | LogStream internal logs: `logEvent`, minimum level, throttling, bounded queue |
|
||||||
| `tags.go` | color tag storage |
|
| `tags.go` | color tag storage |
|
||||||
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
|
||||||
| `api.go` | `/api/*` HTTP routes |
|
| `api.go` | `/api/*` HTTP routes |
|
||||||
|
| `static.go` | embedded UI files: ETag per file, revalidated on each load (`no-cache`, 304 when unchanged) so an update is always picked up; fonts cached 30 days |
|
||||||
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
|
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
@@ -394,3 +671,8 @@ To update one of them:
|
|||||||
With Docker Desktop (macOS/Windows), the source IP seen by the container for UDP packets is
|
With Docker Desktop (macOS/Windows), the source IP seen by the container for UDP packets is
|
||||||
the Docker gateway. The `host` field still comes from the syslog header, which normally
|
the Docker gateway. The `host` field still comes from the syslog header, which normally
|
||||||
carries the sender's real name.
|
carries the sender's real name.
|
||||||
|
|
||||||
|
## Third-party assets
|
||||||
|
|
||||||
|
UI icons come from [Papicons](https://github.com/PapillonApp/papicons) (MIT) and, for a few of them,
|
||||||
|
[Lucide](https://lucide.dev) (ISC). Licenses and notices: [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
## Papicons
|
||||||
|
|
||||||
|
Most UI icons (inline SVG with `class="pi"` in `web/index.html`, `web/login.html` and
|
||||||
|
`web/app.js`) come from Papicons: https://github.com/PapillonApp/papicons
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Association Papillon
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lucide
|
||||||
|
|
||||||
|
The few icons Papicons has no equivalent for (zoom out in the time range bar, Docker source
|
||||||
|
badge) come from Lucide (ISC license): https://github.com/lucide-icons/lucide/blob/main/LICENSE
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
The bundled fonts are covered by the license files next to them in `web/fonts/`.
|
||||||
@@ -23,17 +23,30 @@ type API struct {
|
|||||||
docker *DockerManager // nil when DOCKER_LOGS is off
|
docker *DockerManager // nil when DOCKER_LOGS is off
|
||||||
syslog *SyslogServer
|
syslog *SyslogServer
|
||||||
host *HostLogs
|
host *HostLogs
|
||||||
|
dmesg *Dmesg
|
||||||
|
audit *Audit
|
||||||
|
files *Files
|
||||||
|
selfLog *SelfLog
|
||||||
|
dbstats dbStatsCache
|
||||||
|
tokens *TokenStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Routes(mux *http.ServeMux) {
|
func (a *API) Routes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /healthz", a.health)
|
mux.HandleFunc("GET /healthz", a.health)
|
||||||
mux.HandleFunc("GET /api/logs", a.logs)
|
mux.HandleFunc("GET /api/logs", a.logs)
|
||||||
|
mux.HandleFunc("GET /api/v1/logs", a.logsV1)
|
||||||
|
mux.HandleFunc("GET /api/tokens", a.listTokens)
|
||||||
|
mux.HandleFunc("POST /api/tokens", a.createToken)
|
||||||
|
mux.HandleFunc("DELETE /api/tokens/{id}", a.deleteToken)
|
||||||
mux.HandleFunc("GET /api/histogram", a.histogram)
|
mux.HandleFunc("GET /api/histogram", a.histogram)
|
||||||
mux.HandleFunc("GET /api/facets", a.facets)
|
mux.HandleFunc("GET /api/facets", a.facets)
|
||||||
mux.HandleFunc("GET /api/stream", a.stream)
|
mux.HandleFunc("GET /api/stream", a.stream)
|
||||||
mux.HandleFunc("GET /api/stats", a.stats)
|
mux.HandleFunc("GET /api/stats", a.stats)
|
||||||
|
mux.HandleFunc("GET /api/dbstats", a.dbStats)
|
||||||
mux.HandleFunc("GET /api/tags", a.listTags)
|
mux.HandleFunc("GET /api/tags", a.listTags)
|
||||||
mux.HandleFunc("POST /api/tags", a.createTag)
|
mux.HandleFunc("POST /api/tags", a.createTag)
|
||||||
|
mux.HandleFunc("PUT /api/tags", a.replaceTags)
|
||||||
|
mux.HandleFunc("GET /api/tags/defaults", a.defaultTags)
|
||||||
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
mux.HandleFunc("POST /api/tags/reset", a.resetTags)
|
||||||
mux.HandleFunc("GET /api/presets", a.listPresets)
|
mux.HandleFunc("GET /api/presets", a.listPresets)
|
||||||
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
|
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
|
||||||
@@ -47,6 +60,110 @@ func (a *API) Routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
|
mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
|
||||||
mux.HandleFunc("GET /api/hostlogs", a.hostLogsStatus)
|
mux.HandleFunc("GET /api/hostlogs", a.hostLogsStatus)
|
||||||
mux.HandleFunc("PUT /api/hostlogs", a.hostLogsConfigure)
|
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/files", a.filesStatus)
|
||||||
|
mux.HandleFunc("PUT /api/files", a.filesConfigure)
|
||||||
|
mux.HandleFunc("POST /api/files/detect", a.filesDetect)
|
||||||
|
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/files: the log files of the "Files" source (Settings > Sources).
|
||||||
|
func (a *API) filesStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, a.files.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/files {"sources": [{"id", "name", "path", "format" (auto or a format), "regex", "host", "enabled"}]}
|
||||||
|
// (no id: a new file).
|
||||||
|
func (a *API) filesConfigure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var cfg filesConfig
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&cfg); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.files.Configure(cfg); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("file sources changed from %s: %d files", r.RemoteAddr, len(cfg.Sources))
|
||||||
|
writeJSON(w, http.StatusOK, a.files.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/files/detect {"path": "/logs/traefik/access.log"}: whether the file
|
||||||
|
// can be read, its detected format and its last line (Settings > Sources). A
|
||||||
|
// POST, so that read-only accounts cannot read files through it.
|
||||||
|
func (a *API) filesDetect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, DetectFile(req.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
// GET /api/hostlogs: state of the host system logs source (Settings > Sources).
|
||||||
@@ -314,6 +431,17 @@ func (a *API) stats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, s)
|
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) {
|
func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, a.tags.List())
|
writeJSON(w, http.StatusOK, a.tags.List())
|
||||||
}
|
}
|
||||||
@@ -410,6 +538,41 @@ func (a *API) purge(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"task_id": id})
|
writeJSON(w, http.StatusOK, map[string]string{"task_id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT /api/tags [tags]: replaces the whole list. On an invalid tag, "index"
|
||||||
|
// gives its position and nothing is saved.
|
||||||
|
func (a *API) replaceTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in []Tag
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := a.tags.Replace(in)
|
||||||
|
var le *tagListError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &le):
|
||||||
|
body := map[string]any{"error": err.Error(), "index": le.index}
|
||||||
|
var ce *codedError
|
||||||
|
if errors.As(err, &ce) {
|
||||||
|
body["code"] = ce.code
|
||||||
|
if ce.detail != "" {
|
||||||
|
body["detail"] = ce.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusBadRequest, body)
|
||||||
|
case errors.Is(err, errTooManyTags):
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
case err != nil:
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusOK, tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags/defaults: the tags "Restore default tags" shows before saving.
|
||||||
|
func (a *API) defaultTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, defaultTags())
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
|
func (a *API) resetTags(w http.ResponseWriter, r *http.Request) {
|
||||||
tags, err := a.tags.Reset()
|
tags, err := a.tags.Reset()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API tokens: read-only access to /api/v1/ for scripts and automation tools
|
||||||
|
// (n8n…), sent as "Authorization: Bearer lst_…". They are created and revoked
|
||||||
|
// in Settings > API by an administrator, and work whatever the AUTH_MODE,
|
||||||
|
// including without authentication. Only a SHA-256 of each token is kept, in
|
||||||
|
// DATA_DIR/api_tokens.json: a token is shown once, when it is created.
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenPrefix = "lst_"
|
||||||
|
maxTokens = 100
|
||||||
|
lastUsedPeriod = time.Minute // last use of a token is written to disk at most this often (or on a new IP)
|
||||||
|
)
|
||||||
|
|
||||||
|
var errTokenNotFound = errors.New("token not found")
|
||||||
|
|
||||||
|
type apiToken struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hint string `json:"hint"` // start of the token, to recognize it
|
||||||
|
Hash string `json:"hash"` // hex SHA-256 of the whole token
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastUsed time.Time `json:"last_used,omitzero"`
|
||||||
|
LastIP string `json:"last_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTokenInfo is what the UI sees of a token (never its hash).
|
||||||
|
type apiTokenInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastUsed time.Time `json:"last_used,omitzero"`
|
||||||
|
LastIP string `json:"last_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenStore struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens []apiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTokenStore(path string) (*TokenStore, error) {
|
||||||
|
s := &TokenStore{path: path}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &s.tokens); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes the file atomically, readable by its owner only; the caller holds the lock.
|
||||||
|
func (s *TokenStore) save() error {
|
||||||
|
b, err := json.MarshalIndent(s.tokens, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TokenStore) List() []apiTokenInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]apiTokenInfo, len(s.tokens))
|
||||||
|
for i, t := range s.tokens {
|
||||||
|
out[i] = apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created, LastUsed: t.LastUsed, LastIP: t.LastIP}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create makes a new token and returns it in clear, the only time it exists so.
|
||||||
|
func (s *TokenStore) Create(name string) (apiTokenInfo, string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || utf8.RuneCountInString(name) > 80 {
|
||||||
|
return apiTokenInfo{}, "", &codedError{code: "token_name", msg: "give the token a name of 1 to 80 characters"}
|
||||||
|
}
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return apiTokenInfo{}, "", err
|
||||||
|
}
|
||||||
|
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
t := apiToken{ID: newID(), Name: name, Hint: secret[:len(tokenPrefix)+6], Hash: hashToken(secret), Created: time.Now().UTC()}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if len(s.tokens) >= maxTokens {
|
||||||
|
return apiTokenInfo{}, "", &codedError{code: "token_max", msg: fmt.Sprintf("at most %d API tokens", maxTokens)}
|
||||||
|
}
|
||||||
|
s.tokens = append(s.tokens, t)
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
s.tokens = s.tokens[:len(s.tokens)-1]
|
||||||
|
return apiTokenInfo{}, "", err
|
||||||
|
}
|
||||||
|
return apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created}, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TokenStore) Delete(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i, t := range s.tokens {
|
||||||
|
if t.ID == id {
|
||||||
|
s.tokens = append(s.tokens[:i:i], s.tokens[i+1:]...)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tells whether token is a valid API token, and notes its use.
|
||||||
|
func (s *TokenStore) Check(token, ip string) (name string, ok bool) {
|
||||||
|
if !strings.HasPrefix(token, tokenPrefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
h := []byte(hashToken(token))
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.tokens {
|
||||||
|
t := &s.tokens[i]
|
||||||
|
if subtle.ConstantTimeCompare(h, []byte(t.Hash)) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stale := now.Sub(t.LastUsed) >= lastUsedPeriod || t.LastIP != ip
|
||||||
|
t.LastUsed, t.LastIP = now, ip
|
||||||
|
if stale {
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
log.Printf("api tokens: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Name, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken returns the token of an "Authorization: Bearer …" header.
|
||||||
|
func bearerToken(r *http.Request) (string, bool) {
|
||||||
|
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||||
|
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(token), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenAuth serves the requests carrying an API token itself, read-only and
|
||||||
|
// limited to /api/v1/, before the session login (auth); api is the handler
|
||||||
|
// behind the login.
|
||||||
|
func tokenAuth(tokens *TokenStore, auth, api http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := bearerToken(r)
|
||||||
|
if !ok {
|
||||||
|
auth.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := clientIP(r)
|
||||||
|
if _, valid := tokens.Check(token, ip); !valid {
|
||||||
|
log.Printf("api: invalid or revoked token from %s", ip)
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer realm="logstream"`)
|
||||||
|
writeErr(w, http.StatusUnauthorized, &codedError{code: "token", msg: "invalid or revoked API token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || !isSafeMethod(r.Method) {
|
||||||
|
writeErr(w, http.StatusForbidden, &codedError{code: "token_scope", msg: "API tokens only give read access to /api/v1/"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.ServeHTTP(w, asViewer(r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tokens: the API tokens (Settings > API), for administrators.
|
||||||
|
func (a *API) listTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isViewer(r) {
|
||||||
|
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, a.tokens.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tokens {"name": "n8n"}: returns the new token, with "token" in clear.
|
||||||
|
func (a *API) createToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, secret, err := a.tokens.Create(body.Name)
|
||||||
|
var ce *codedError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &ce):
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("api token %q created from %s", info.Name, clientIP(r))
|
||||||
|
writeJSON(w, http.StatusCreated, struct {
|
||||||
|
apiTokenInfo
|
||||||
|
Token string `json:"token"`
|
||||||
|
}{info, secret})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/tokens/{id}: revokes a token at once.
|
||||||
|
func (a *API) deleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := a.tokens.Delete(r.PathValue("id"))
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errTokenNotFound):
|
||||||
|
writeErr(w, http.StatusNotFound, err)
|
||||||
|
case err != nil:
|
||||||
|
writeErr(w, http.StatusInternalServerError, err)
|
||||||
|
default:
|
||||||
|
log.Printf("api token %s revoked from %s", r.PathValue("id"), clientIP(r))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenStore(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "api_tokens.json")
|
||||||
|
s, err := LoadTokenStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.Create(" "); err == nil {
|
||||||
|
t.Fatal("a token without a name should be refused")
|
||||||
|
}
|
||||||
|
info, secret, err := s.Create("n8n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(secret, tokenPrefix) || len(secret) < 40 || !strings.HasPrefix(secret, info.Hint) {
|
||||||
|
t.Fatalf("token %q, hint %q", secret, info.Hint)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(path)
|
||||||
|
if strings.Contains(string(b), secret) || !strings.Contains(string(b), hashToken(secret)) {
|
||||||
|
t.Fatal("the file must hold the hash of the token, not the token")
|
||||||
|
}
|
||||||
|
if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("file mode %v, want 0600", st.Mode().Perm())
|
||||||
|
}
|
||||||
|
if _, ok := s.Check(secret+"x", "10.0.0.1"); ok {
|
||||||
|
t.Fatal("a wrong token was accepted")
|
||||||
|
}
|
||||||
|
if name, ok := s.Check(secret, "10.0.0.1"); !ok || name != "n8n" {
|
||||||
|
t.Fatal("the token was refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reloaded from disk (restart): still valid, last use kept.
|
||||||
|
s2, err := LoadTokenStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l := s2.List(); len(l) != 1 || l[0].LastIP != "10.0.0.1" || l[0].LastUsed.IsZero() {
|
||||||
|
t.Fatalf("after reload: %+v", l)
|
||||||
|
}
|
||||||
|
if _, ok := s2.Check(secret, "10.0.0.2"); !ok {
|
||||||
|
t.Fatal("token refused after reload")
|
||||||
|
}
|
||||||
|
if err := s2.Delete(info.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := s2.Check(secret, "10.0.0.2"); ok {
|
||||||
|
t.Fatal("a revoked token was accepted")
|
||||||
|
}
|
||||||
|
if err := s2.Delete(info.ID); err != errTokenNotFound {
|
||||||
|
t.Fatalf("second delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenAuth(t *testing.T) {
|
||||||
|
loginFailDelay = 0
|
||||||
|
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||||
|
_, secret, _ := tokens.Create("n8n")
|
||||||
|
|
||||||
|
var reached string
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = r.URL.Path
|
||||||
|
if isViewer(r) {
|
||||||
|
reached += " viewer"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app := readOnly(mux)
|
||||||
|
login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAuthRequired(w) })
|
||||||
|
h := tokenAuth(tokens, login, app)
|
||||||
|
|
||||||
|
call := func(method, path, auth string) int {
|
||||||
|
reached = ""
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
if auth != "" {
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
if code := call("GET", "/api/v1/logs", "Bearer "+secret); code != http.StatusOK || reached != "/api/v1/logs viewer" {
|
||||||
|
t.Fatalf("valid token: %d, reached %q", code, reached)
|
||||||
|
}
|
||||||
|
if code := call("GET", "/api/v1/logs", "bearer "+secret); code != http.StatusOK {
|
||||||
|
t.Fatalf("lower-case scheme: %d", code)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path, auth string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"GET", "/api/v1/logs", "Bearer lst_wrong", http.StatusUnauthorized},
|
||||||
|
{"GET", "/api/v1/logs", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"GET", "/api/tags", "Bearer " + secret, http.StatusForbidden},
|
||||||
|
{"GET", "/api/tokens", "Bearer " + secret, http.StatusForbidden},
|
||||||
|
{"POST", "/api/v1/logs", "Bearer " + secret, http.StatusForbidden},
|
||||||
|
{"GET", "/api/v1/logs", "", http.StatusUnauthorized}, // no token: the login decides
|
||||||
|
{"GET", "/api/v1/logs", "Basic dTpw", http.StatusUnauthorized}, // Basic goes to the login
|
||||||
|
} {
|
||||||
|
if code := call(tc.method, tc.path, tc.auth); code != tc.want || reached != "" {
|
||||||
|
t.Errorf("%s %s %q: %d (reached %q), want %d", tc.method, tc.path, tc.auth, code, reached, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With local authentication, a token opens /api/v1/ without a session.
|
||||||
|
func TestTokenWithLocalAuth(t *testing.T) {
|
||||||
|
loginFailDelay = 0
|
||||||
|
dir := t.TempDir()
|
||||||
|
tokens, _ := LoadTokenStore(filepath.Join(dir, "api_tokens.json"))
|
||||||
|
_, secret, _ := tokens.Create("script")
|
||||||
|
app := readOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
|
||||||
|
auth, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", dataDir: dir, sessionTTL: time.Hour}, app)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := tokenAuth(tokens, auth, app)
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/logs", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("without token: %d", rec.Code)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
||||||
|
t.Fatalf("with token: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandlers(t *testing.T) {
|
||||||
|
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||||
|
a := &API{tokens: tokens}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
a.Routes(mux)
|
||||||
|
do := func(r *http.Request) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
readOnly(mux).ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
rec := do(httptest.NewRequest("POST", "/api/tokens", strings.NewReader(`{"name":"n8n"}`)))
|
||||||
|
var created struct {
|
||||||
|
ID, Token, Hint string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); rec.Code != http.StatusCreated || err != nil || created.Token == "" {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
rec = do(httptest.NewRequest("GET", "/api/tokens", nil))
|
||||||
|
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), created.Token) || strings.Contains(rec.Body.String(), "hash") {
|
||||||
|
t.Fatalf("list must not show the token or its hash: %s", rec.Body)
|
||||||
|
}
|
||||||
|
if rec := do(asViewer(httptest.NewRequest("GET", "/api/tokens", nil))); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("viewer list: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(asViewer(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil))); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("viewer delete: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("delete again: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
type authConfig struct {
|
||||||
mode string
|
mode string
|
||||||
user, pass string // local mode
|
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
|
issuer string
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
@@ -124,6 +128,9 @@ func newOIDC(c authConfig) (*OIDC, error) {
|
|||||||
if !strings.Contains(" "+c.scopes+" ", " openid ") {
|
if !strings.Contains(" "+c.scopes+" ", " openid ") {
|
||||||
c.scopes = "openid " + c.scopes
|
c.scopes = "openid " + c.scopes
|
||||||
}
|
}
|
||||||
|
if c.groupsClaim == "" {
|
||||||
|
c.groupsClaim = "groups"
|
||||||
|
}
|
||||||
return &OIDC{
|
return &OIDC{
|
||||||
cfg: c,
|
cfg: c,
|
||||||
callback: ru.Path,
|
callback: ru.Path,
|
||||||
@@ -157,8 +164,9 @@ func sessionKey(dir string) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type session struct {
|
type session struct {
|
||||||
User string `json:"u"`
|
User string `json:"u"`
|
||||||
Exp int64 `json:"e"`
|
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
|
// 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
|
var s session
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
|
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" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
if s.Viewer {
|
||||||
|
r = asViewer(r)
|
||||||
|
}
|
||||||
o.next.ServeHTTP(w, r)
|
o.next.ServeHTTP(w, r)
|
||||||
return
|
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})
|
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 {
|
if err != nil {
|
||||||
log.Printf("oidc: login failed: %v", err)
|
log.Printf("oidc: login failed: %v", err)
|
||||||
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
|
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("oidc: %s logged in", user)
|
log.Printf("oidc: %s logged in (%s)", user, roleName(viewer))
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
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: "/",
|
Path: "/",
|
||||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -286,14 +297,15 @@ func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exchange trades the code for tokens and returns the user name from the verified ID token.
|
// exchange trades the code for tokens and returns the user name from the verified ID
|
||||||
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) {
|
// 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 == "" {
|
if code == "" {
|
||||||
return "", errors.New("no code in the callback")
|
return "", false, errors.New("no code in the callback")
|
||||||
}
|
}
|
||||||
meta, err := o.discover()
|
meta, err := o.discover()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
form := url.Values{
|
form := url.Values{
|
||||||
"grant_type": {"authorization_code"},
|
"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()))
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Accept", "application/json")
|
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)
|
res, err := o.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("token endpoint: %w", err)
|
return "", false, fmt.Errorf("token endpoint: %w", err)
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
if res.StatusCode != http.StatusOK {
|
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 {
|
var tok struct {
|
||||||
IDToken string `json:"id_token"`
|
IDToken string `json:"id_token"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
|
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)
|
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
|
||||||
if err != nil {
|
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"} {
|
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
|
||||||
if v, _ := claims[k].(string); v != "" {
|
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.
|
// 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 {
|
type Local struct {
|
||||||
user, pass string
|
user, pass string
|
||||||
|
viewerUser string // optional read-only account
|
||||||
|
viewerPass string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
logo string // LOGIN_LOGO, served at /auth/logo
|
logo string // LOGIN_LOGO, served at /auth/logo
|
||||||
key []byte
|
key []byte
|
||||||
@@ -32,14 +34,17 @@ type Local struct {
|
|||||||
func newLocal(c authConfig) *Local {
|
func newLocal(c authConfig) *Local {
|
||||||
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||||
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
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 c.loginLogo != "" {
|
||||||
if _, err := os.Stat(c.loginLogo); err != nil {
|
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||||
log.Printf("auth: LOGIN_LOGO: %v", err)
|
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("local authentication enabled (user %s)", c.user)
|
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) {
|
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)
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, ok := l.sessionUser(r)
|
s, ok := l.sessionUser(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
if u, p, basic := r.BasicAuth(); basic {
|
||||||
user, ok = u, true
|
if viewer, valid := l.check(u, p); valid {
|
||||||
|
s, ok = session{User: u, Viewer: viewer}, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ok && s.Viewer {
|
||||||
|
r = asViewer(r)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case r.URL.Path == loginPage:
|
case r.URL.Path == loginPage:
|
||||||
if ok {
|
if ok {
|
||||||
@@ -73,7 +83,7 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
l.next.ServeHTTP(w, r)
|
l.next.ServeHTTP(w, r)
|
||||||
case ok && r.URL.Path == "/auth/me":
|
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:
|
case ok:
|
||||||
l.next.ServeHTTP(w, r)
|
l.next.ServeHTTP(w, r)
|
||||||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
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")
|
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||||
ret := safeReturn(r.PostFormValue("r"))
|
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))
|
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||||
time.Sleep(loginFailDelay)
|
time.Sleep(loginFailDelay)
|
||||||
q := url.Values{"e": {"1"}}
|
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)
|
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
return
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
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: "/",
|
Path: "/",
|
||||||
MaxAge: int(l.ttl.Seconds()),
|
MaxAge: int(l.ttl.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -117,19 +128,30 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, ret, http.StatusSeeOther)
|
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
|
var s session
|
||||||
c, err := r.Cookie(sessionCookie)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
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 {
|
// check validates a user and password: the admin account, or the read-only one
|
||||||
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
// (viewer=true) when AUTH_VIEWER_USER is set.
|
||||||
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
func (l *Local) check(user, pass string) (viewer, ok bool) {
|
||||||
return u&p == 1
|
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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-5
@@ -6,10 +6,10 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- victorialogs
|
- victorialogs
|
||||||
- docker-proxy
|
- docker-proxy
|
||||||
ports:
|
# ports:
|
||||||
- "${SYSLOG_PORT:-514}:5514/udp"
|
# - "${SYSLOG_PORT:-5514}:5514/udp"
|
||||||
- "${SYSLOG_PORT:-514}:5514/tcp"
|
# - "${SYSLOG_PORT:-5514}:5514/tcp"
|
||||||
- "${HTTP_PORT:-8080}:8080"
|
# - "${HTTP_PORT:-8989}:8080"
|
||||||
environment:
|
environment:
|
||||||
VLOGS_URL: http://victorialogs:9428
|
VLOGS_URL: http://victorialogs:9428
|
||||||
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc
|
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_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
|
||||||
AUTH_PASS: ${AUTH_PASS:-}
|
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)
|
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)
|
PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
|
||||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
@@ -24,22 +26,46 @@ services:
|
|||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||||
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
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)
|
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
|
||||||
RDNS: ${RDNS:-on} # resol dns
|
RDNS: ${RDNS:-on} # resol dns
|
||||||
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
|
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}
|
EXPORT_MAX: ${EXPORT_MAX:-100000}
|
||||||
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
|
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
|
||||||
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker
|
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker
|
||||||
DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h}
|
DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h}
|
||||||
HOST_LOGS_BACKFILL: ${HOST_LOGS_BACKFILL:-1h} # historique lu a l'activation des logs systeme de l'hote
|
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)
|
||||||
|
FILES_BACKFILL: ${FILES_BACKFILL:-1h} # historique lu a l'activation d'un fichier de logs (Reglages > Sources)
|
||||||
|
AUDIT_HOST: ${AUDIT_HOST:-} # nom d'hote des evenements d'audit (vide : node= des enregistrements, sinon /host/etc/hostname)
|
||||||
group_add:
|
group_add:
|
||||||
- "${HOST_LOGS_GID:-4}" # groupe autorise a lire les logs de l'hote (4 = adm sur Debian/Ubuntu)
|
- "${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:
|
volumes:
|
||||||
- logstream-data:/data # tags.json, docker.json (les choix des conteneurs)
|
- 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)
|
# 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
|
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
||||||
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
- /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
|
||||||
|
# fichiers de logs d'autres programmes (Traefik, nginx...), a declarer dans Reglages > Sources > Fichiers de logs
|
||||||
|
- /home/traefik/log:/home/logs/traefik:ro
|
||||||
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
|
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
|
||||||
# - ./logo.png:/config/logo.png:ro
|
# - ./logo.png:/config/logo.png:ro
|
||||||
# prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env
|
# prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env
|
||||||
@@ -76,7 +102,12 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
labels:
|
labels:
|
||||||
logstream.exclude: "true" # pour exclure les logs propres de logstream
|
logstream.exclude: "true" # pour exclure les logs propres de logstream
|
||||||
|
networks:
|
||||||
|
- traefik-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
volumes:
|
volumes:
|
||||||
logstream-data:
|
logstream-data:
|
||||||
vlogs-data:
|
vlogs-data:
|
||||||
@@ -61,8 +61,8 @@ type DockerManager struct {
|
|||||||
cfg dockerConfig
|
cfg dockerConfig
|
||||||
hostName string
|
hostName string
|
||||||
containers []DockerContainer
|
containers []DockerContainer
|
||||||
followers map[string]*follower // container ID -> running follower
|
followers map[string]*follower // container ID -> running follower
|
||||||
checkpoint map[string]time.Time // container ID -> timestamp of the last line read
|
checkpoint map[string]time.Time // container ID -> timestamp of the last line stored
|
||||||
dirty bool
|
dirty bool
|
||||||
connected bool
|
connected bool
|
||||||
lastErr string
|
lastErr string
|
||||||
@@ -242,7 +242,7 @@ func (m *DockerManager) setError(err error) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.lastErr != err.Error() {
|
if m.lastErr != err.Error() {
|
||||||
log.Printf("docker: %v", err)
|
logEvent("docker", 3, "Docker API unreachable: %v", err)
|
||||||
}
|
}
|
||||||
m.connected = false
|
m.connected = false
|
||||||
m.lastErr = err.Error()
|
m.lastErr = err.Error()
|
||||||
@@ -290,7 +290,7 @@ func (m *DockerManager) sync(ctx context.Context) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if !m.connected {
|
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, ""
|
m.connected, m.lastErr = true, ""
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -371,7 +371,7 @@ func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
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)
|
sleepCtx(ctx, 5*time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -383,22 +383,25 @@ func (m *DockerManager) follow(ctx context.Context, c DockerContainer) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
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)
|
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)
|
t := time.NewTimer(d)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
case <-t.C:
|
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.
|
// DOCKER_BACKFILL ago for a container seen for the first time.
|
||||||
func (m *DockerManager) since(id string) time.Time {
|
func (m *DockerManager) since(id string) time.Time {
|
||||||
m.mu.Lock()
|
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 {
|
} else if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
ts, s = t, ""
|
ts, s = t, ""
|
||||||
}
|
}
|
||||||
defer m.setCheckpoint(c.ID, ts)
|
|
||||||
|
|
||||||
s = ansiRe.ReplaceAllString(s, "")
|
s = ansiRe.ReplaceAllString(s, "")
|
||||||
if !utf8.ValidString(s) {
|
if !utf8.ValidString(s) {
|
||||||
s = strings.ToValidUTF8(s, string(utf8.RuneError))
|
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,
|
"compose_service": c.Service,
|
||||||
"stream": stream,
|
"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) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Préréglages de tags de couleur
|
# Préréglages de tags de couleur
|
||||||
|
|
||||||
Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
|
Dans **Paramètres › Motifs de recherche**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
|
||||||
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
|
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
|
||||||
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
|
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
|
||||||
pas ajouté en double.
|
pas ajouté en double.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Color tag presets
|
# Color tag presets
|
||||||
|
|
||||||
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
|
In **Settings › Patterns**, the **+ Preset…** menu adds a group of ready-made color tags in one
|
||||||
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
|
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
|
||||||
them. A tag whose pattern is already in the list is not added twice.
|
them. A tag whose pattern is already in the list is not added twice.
|
||||||
|
|
||||||
|
|||||||
+689
@@ -0,0 +1,689 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Formats of the "Files" source (Settings > Sources), one per declared file.
|
||||||
|
const (
|
||||||
|
fmtAuto = "auto" // detected from the last lines of the file
|
||||||
|
fmtText = "text" // one line per entry, level and leading timestamp detected
|
||||||
|
fmtJSON = "json" // JSON lines: msg/time/level recognized, other keys as fields
|
||||||
|
fmtLogfmt = "logfmt" // key=value pairs (Traefik "common" logs, Go programs…)
|
||||||
|
fmtRegex = "regex" // regular expression with named groups
|
||||||
|
fmtCLF = "clf" // access logs in Common/Combined Log Format (nginx, Apache, Traefik)
|
||||||
|
fmtTraefik = "traefik" // Traefik access log in JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
var fileFormats = map[string]bool{fmtText: true, fmtJSON: true, fmtLogfmt: true, fmtRegex: true, fmtCLF: true, fmtTraefik: true}
|
||||||
|
|
||||||
|
// fileRec is one parsed line (or several, for continuation lines).
|
||||||
|
type fileRec struct {
|
||||||
|
t time.Time
|
||||||
|
dated bool // t comes from the line
|
||||||
|
sev int
|
||||||
|
msg string
|
||||||
|
host string
|
||||||
|
app string
|
||||||
|
fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFileFields = 64
|
||||||
|
maxFieldValue = 4096
|
||||||
|
maxFileMsg = 64 << 10 // message with its continuation lines
|
||||||
|
maxContinuation = 500 // continuation lines added to one entry
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileParser turns a line of a file into an entry, for one format.
|
||||||
|
type fileParser struct {
|
||||||
|
format string
|
||||||
|
re *regexp.Regexp // fmtRegex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileParser(format, expr string) (*fileParser, error) {
|
||||||
|
if !fileFormats[format] {
|
||||||
|
return nil, fmt.Errorf("unknown format %q", format)
|
||||||
|
}
|
||||||
|
p := &fileParser{format: format}
|
||||||
|
if format == fmtRegex {
|
||||||
|
if strings.TrimSpace(expr) == "" {
|
||||||
|
return nil, fmt.Errorf("the regex format needs a regular expression")
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(expr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid regular expression: %v", err)
|
||||||
|
}
|
||||||
|
p.re = re
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiline tells whether lines that do not start an entry are added to the
|
||||||
|
// previous one (stack traces…). Structured formats are one entry per line.
|
||||||
|
func (p *fileParser) multiline() bool {
|
||||||
|
return p.format == fmtText || p.format == fmtRegex
|
||||||
|
}
|
||||||
|
|
||||||
|
// continuation reports a line that belongs to the previous entry: indented,
|
||||||
|
// or not matching the regular expression.
|
||||||
|
func (p *fileParser) continuation(line string) bool {
|
||||||
|
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return p.format == fmtRegex && !p.re.MatchString(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse decodes one line; it never fails (a line in another format is kept as text).
|
||||||
|
func (p *fileParser) parse(line string, now time.Time) *fileRec {
|
||||||
|
line = cleanLine(line)
|
||||||
|
switch p.format {
|
||||||
|
case fmtJSON, fmtTraefik:
|
||||||
|
if obj, ok := decodeJSONLine(line); ok {
|
||||||
|
if p.format == fmtTraefik {
|
||||||
|
if r := traefikRec(obj, line, now); r != nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonRec(obj, line, now)
|
||||||
|
}
|
||||||
|
case fmtLogfmt:
|
||||||
|
if kv := parseLogfmt(line); len(kv) > 0 {
|
||||||
|
return logfmtRec(kv, line, now)
|
||||||
|
}
|
||||||
|
case fmtRegex:
|
||||||
|
if m := p.re.FindStringSubmatch(line); m != nil {
|
||||||
|
return p.regexRec(m, line, now)
|
||||||
|
}
|
||||||
|
case fmtCLF:
|
||||||
|
if r := clfRec(line, now); r != nil {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return textRec(line, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanLine(s string) string {
|
||||||
|
s = strings.TrimRight(s, "\r")
|
||||||
|
s = ansiRe.ReplaceAllString(s, "")
|
||||||
|
if !utf8.ValidString(s) {
|
||||||
|
s = strings.ToValidUTF8(s, "�")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// textRec: the line itself, without its leading timestamp when there is one.
|
||||||
|
func textRec(line string, now time.Time) *fileRec {
|
||||||
|
r := &fileRec{t: now, sev: 6, msg: line}
|
||||||
|
if m := leadTimeRe.FindStringSubmatchIndex(line); m != nil {
|
||||||
|
if t, ok := parseLogTime(line[m[2]:m[3]], now); ok {
|
||||||
|
r.t, r.dated = t, true
|
||||||
|
if rest := line[m[1]:]; strings.TrimSpace(rest) != "" {
|
||||||
|
r.msg = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, ok := detectLevel(r.msg); ok {
|
||||||
|
r.sev = n
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Timestamps ---------- */
|
||||||
|
|
||||||
|
// leadTimeRe: a timestamp at the start of a line, possibly in brackets
|
||||||
|
// (ISO 8601 / RFC 3339, 2006/01/02 15:04:05, CLF, syslog "Jan _2 15:04:05").
|
||||||
|
var leadTimeRe = regexp.MustCompile(`^\[?(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?: ?(?:Z|[+-]\d{2}:?\d{2}))?|\d{2}/[A-Z][a-z]{2}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}|[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2})\]?[ \t]*(?:[-|:][ \t]+)?`)
|
||||||
|
|
||||||
|
var timeLayouts = []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02T15:04:05Z0700",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"02/Jan/2006:15:04:05 -0700",
|
||||||
|
"Jan _2 15:04:05",
|
||||||
|
time.RFC1123Z,
|
||||||
|
time.RFC1123,
|
||||||
|
time.UnixDate,
|
||||||
|
time.ANSIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLogTime reads the usual log timestamps; times without a zone are local.
|
||||||
|
func parseLogTime(s string, now time.Time) (time.Time, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if s[0] >= '0' && s[0] <= '9' && !strings.ContainsAny(s, ":/-") {
|
||||||
|
if whole, _, _ := strings.Cut(s, "."); len(whole) < 9 {
|
||||||
|
return time.Time{}, false // not a Unix time of this century
|
||||||
|
}
|
||||||
|
return epochTime(s)
|
||||||
|
}
|
||||||
|
// 2006/01/02 15:04:05,123 +02:00 -> 2006-01-02T15:04:05.123+02:00
|
||||||
|
if len(s) >= 19 && s[4] == '/' && s[7] == '/' {
|
||||||
|
s = s[:4] + "-" + s[5:7] + "-" + s[8:]
|
||||||
|
}
|
||||||
|
if len(s) >= 19 && s[4] == '-' && s[10] == ' ' {
|
||||||
|
s = s[:10] + "T" + s[11:]
|
||||||
|
}
|
||||||
|
if len(s) > 19 && s[4] == '-' {
|
||||||
|
s = s[:19] + strings.Replace(strings.Replace(s[19:], ",", ".", 1), " ", "", 1)
|
||||||
|
}
|
||||||
|
s = strings.Join(strings.Fields(s), " ") // "Jan 5" -> "Jan 5"
|
||||||
|
for _, layout := range timeLayouts {
|
||||||
|
if layout == "Jan _2 15:04:05" {
|
||||||
|
layout = "Jan 2 15:04:05"
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation(layout, s, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Year() == 0 { // syslog timestamps have no year
|
||||||
|
t = t.AddDate(now.Year(), 0, 0)
|
||||||
|
if t.After(now.Add(24 * time.Hour)) {
|
||||||
|
t = t.AddDate(-1, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// epochTime reads a Unix time in seconds, milliseconds, microseconds or
|
||||||
|
// nanoseconds (guessed from its size), with an optional fraction.
|
||||||
|
func epochTime(s string) (time.Time, bool) {
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || f <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case f < 1e11:
|
||||||
|
return time.Unix(0, int64(f*1e9)), true
|
||||||
|
case f < 1e14:
|
||||||
|
return time.UnixMilli(int64(f)), true
|
||||||
|
case f < 1e17:
|
||||||
|
return time.UnixMicro(int64(f)), true
|
||||||
|
}
|
||||||
|
return time.Unix(0, int64(f)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Fields ---------- */
|
||||||
|
|
||||||
|
// reservedFields are stored by LogStream itself: fields of the logs with the
|
||||||
|
// same name are renamed field_<name>.
|
||||||
|
var reservedFields = map[string]bool{
|
||||||
|
"_time": true, "_msg": true, "_stream": true, "_stream_id": true, "received": true, "msg_time": true,
|
||||||
|
"host": true, "host_name": true, "host_ip": true, "app": true, "facility": true, "severity": true,
|
||||||
|
"sevnum": true, "source": true, "proto": true, "procid": true, "sd": true, "source_type": true, "log_file": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldName(k string) string {
|
||||||
|
k = strings.Map(func(r rune) rune {
|
||||||
|
if r == '_' || r == '.' || r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, k)
|
||||||
|
if k == "" || reservedFields[k] || k[0] == '_' {
|
||||||
|
k = "field_" + strings.TrimLeft(k, "_")
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fileRec) set(k, v string) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.fields == nil {
|
||||||
|
r.fields = map[string]string{}
|
||||||
|
}
|
||||||
|
k = fieldName(k)
|
||||||
|
if _, ok := r.fields[k]; !ok && len(r.fields) >= maxFileFields {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(v) > maxFieldValue {
|
||||||
|
v = strings.ToValidUTF8(v[:maxFieldValue], "") + "…"
|
||||||
|
}
|
||||||
|
r.fields[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- JSON lines ---------- */
|
||||||
|
|
||||||
|
func decodeJSONLine(line string) (map[string]any, bool) {
|
||||||
|
s := strings.TrimSpace(line)
|
||||||
|
if len(s) < 2 || s[0] != '{' {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(s))
|
||||||
|
dec.UseNumber()
|
||||||
|
var obj map[string]any
|
||||||
|
if dec.Decode(&obj) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return obj, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
jsonMsgKeys = []string{"msg", "message", "@message", "log", "text"}
|
||||||
|
jsonTimeKeys = []string{"time", "ts", "timestamp", "@timestamp", "@t", "t", "datetime", "date"}
|
||||||
|
jsonLevelKeys = []string{"level", "lvl", "severity", "log.level", "@level", "@l", "loglevel", "levelname"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// takeString removes the first of keys found in obj and returns it as text.
|
||||||
|
func takeString(obj map[string]any, keys []string) (string, bool) {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := obj[k]; ok {
|
||||||
|
if s := scalarString(v); s != "" {
|
||||||
|
delete(obj, k)
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarString(v any) string {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case json.Number:
|
||||||
|
return v.String()
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRec(obj map[string]any, line string, now time.Time) *fileRec {
|
||||||
|
r := &fileRec{t: now, sev: 6, msg: line}
|
||||||
|
for _, k := range jsonTimeKeys {
|
||||||
|
if t, ok := parseLogTime(scalarString(obj[k]), now); ok {
|
||||||
|
r.t, r.dated = t, true
|
||||||
|
delete(obj, k)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lvl, hasLevel := takeString(obj, jsonLevelKeys)
|
||||||
|
if hasLevel {
|
||||||
|
if n, ok := levelNum(lvl); ok {
|
||||||
|
r.sev = n
|
||||||
|
} else if n, err := strconv.Atoi(lvl); err == nil {
|
||||||
|
r.sev = numericLevel(n)
|
||||||
|
} else {
|
||||||
|
r.set("level", lvl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s, ok := takeString(obj, jsonMsgKeys); ok {
|
||||||
|
r.msg = strings.TrimRight(s, "\n")
|
||||||
|
}
|
||||||
|
flattenJSON("", obj, r)
|
||||||
|
if !hasLevel {
|
||||||
|
if n, ok := detectLevel(r.msg); ok {
|
||||||
|
r.sev = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericLevel maps numeric levels: syslog (0-7) or pino/bunyan (10-60).
|
||||||
|
func numericLevel(n int) int {
|
||||||
|
switch {
|
||||||
|
case n >= 0 && n <= 7:
|
||||||
|
return n
|
||||||
|
case n >= 60:
|
||||||
|
return 2
|
||||||
|
case n >= 50:
|
||||||
|
return 3
|
||||||
|
case n >= 40:
|
||||||
|
return 4
|
||||||
|
case n >= 30:
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenJSON stores the remaining keys as fields; nested objects give
|
||||||
|
// dotted names, arrays stay in JSON.
|
||||||
|
func flattenJSON(prefix string, obj map[string]any, r *fileRec) {
|
||||||
|
for k, v := range obj {
|
||||||
|
name := prefix + k
|
||||||
|
switch v := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
flattenJSON(name+".", v, r)
|
||||||
|
case []any:
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
r.set(name, string(b))
|
||||||
|
case nil:
|
||||||
|
default:
|
||||||
|
r.set(name, scalarString(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- logfmt ---------- */
|
||||||
|
|
||||||
|
// parseLogfmt splits `k=v k2="v 2"` (with \" escapes); words without a value
|
||||||
|
// are skipped. Nil when the line has no pair.
|
||||||
|
func parseLogfmt(s string) [][2]string {
|
||||||
|
var out [][2]string
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
for i < len(s) && s[i] != '=' && s[i] != ' ' && s[i] != '\t' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(s) || s[i] != '=' || i == start {
|
||||||
|
for i < len(s) && s[i] != ' ' && s[i] != '\t' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := s[start:i]
|
||||||
|
i++
|
||||||
|
var v string
|
||||||
|
if i < len(s) && s[i] == '"' {
|
||||||
|
var b strings.Builder
|
||||||
|
i++
|
||||||
|
for i < len(s) && s[i] != '"' {
|
||||||
|
if s[i] == '\\' && i+1 < len(s) {
|
||||||
|
i++
|
||||||
|
switch s[i] {
|
||||||
|
case 'n':
|
||||||
|
b.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
b.WriteByte('\t')
|
||||||
|
default:
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i++ // closing quote
|
||||||
|
v = b.String()
|
||||||
|
} else {
|
||||||
|
end := i
|
||||||
|
for end < len(s) && s[end] != ' ' && s[end] != '\t' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
v, i = s[i:end], end
|
||||||
|
}
|
||||||
|
out = append(out, [2]string{k, v})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func logfmtRec(kv [][2]string, line string, now time.Time) *fileRec {
|
||||||
|
obj := make(map[string]any, len(kv))
|
||||||
|
for _, p := range kv {
|
||||||
|
obj[p[0]] = p[1]
|
||||||
|
}
|
||||||
|
return jsonRec(obj, line, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Regular expression ---------- */
|
||||||
|
|
||||||
|
func (p *fileParser) regexRec(m []string, line string, now time.Time) *fileRec {
|
||||||
|
r := &fileRec{t: now, sev: 6, msg: line}
|
||||||
|
hasLevel := false
|
||||||
|
for i, name := range p.re.SubexpNames() {
|
||||||
|
if i == 0 || name == "" || m[i] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := m[i]
|
||||||
|
switch strings.ToLower(name) {
|
||||||
|
case "time", "timestamp", "ts":
|
||||||
|
if t, ok := parseLogTime(v, now); ok {
|
||||||
|
r.t, r.dated = t, true
|
||||||
|
}
|
||||||
|
case "level", "severity", "lvl":
|
||||||
|
if n, ok := levelNum(v); ok {
|
||||||
|
r.sev, hasLevel = n, true
|
||||||
|
}
|
||||||
|
case "msg", "message":
|
||||||
|
r.msg = v
|
||||||
|
case "host":
|
||||||
|
r.host = v
|
||||||
|
case "app":
|
||||||
|
r.app = v
|
||||||
|
case "status":
|
||||||
|
r.set(name, v)
|
||||||
|
if n, ok := statusLevel(v); ok && !hasLevel {
|
||||||
|
r.sev, hasLevel = n, true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
r.set(name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasLevel {
|
||||||
|
if n, ok := detectLevel(r.msg); ok {
|
||||||
|
r.sev = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Access logs ---------- */
|
||||||
|
|
||||||
|
// statusLevel: 5xx are errors, 4xx warnings, the rest info.
|
||||||
|
func statusLevel(s string) (int, bool) {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n < 100 || n > 999 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case n >= 500:
|
||||||
|
return 3, true
|
||||||
|
case n >= 400:
|
||||||
|
return 4, true
|
||||||
|
}
|
||||||
|
return 6, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clfRe: Common Log Format, the combined extension (referer, user agent) and
|
||||||
|
// the Traefik one (request count, router, server URL, duration).
|
||||||
|
var clfRe = regexp.MustCompile(`^(\S+) (\S+) (\S+) \[([^\]]+)\] "((?:[^"\\]|\\.)*)" (\d{3}) (\d+|-)(?: "((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)")?(?: (\d+) "([^"]*)" "([^"]*)" (\d+)ms)?`)
|
||||||
|
|
||||||
|
func clfRec(line string, now time.Time) *fileRec {
|
||||||
|
m := clfRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := &fileRec{t: now, sev: 6, msg: line}
|
||||||
|
if t, ok := parseLogTime(m[4], now); ok {
|
||||||
|
r.t, r.dated = t, true
|
||||||
|
}
|
||||||
|
dash := func(s string) string {
|
||||||
|
if s == "-" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r.set("client_ip", m[1])
|
||||||
|
r.set("http_user", dash(m[3]))
|
||||||
|
if req := strings.Fields(m[5]); len(req) == 3 {
|
||||||
|
r.set("http_method", req[0])
|
||||||
|
r.set("http_path", req[1])
|
||||||
|
r.set("http_proto", req[2])
|
||||||
|
} else {
|
||||||
|
r.set("http_request", m[5])
|
||||||
|
}
|
||||||
|
r.set("http_status", m[6])
|
||||||
|
r.set("http_size", dash(m[7]))
|
||||||
|
r.set("http_referer", dash(m[8]))
|
||||||
|
r.set("user_agent", dash(m[9]))
|
||||||
|
r.set("router", dash(m[11]))
|
||||||
|
r.set("backend", dash(m[12]))
|
||||||
|
r.set("duration_ms", m[13])
|
||||||
|
r.sev, _ = statusLevel(m[6])
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// traefikRec reads a Traefik access log line in JSON; nil for the other
|
||||||
|
// lines (Traefik's own logs), read as generic JSON.
|
||||||
|
func traefikRec(obj map[string]any, line string, now time.Time) *fileRec {
|
||||||
|
status := scalarString(obj["DownstreamStatus"])
|
||||||
|
if status == "" {
|
||||||
|
status = scalarString(obj["OriginStatus"])
|
||||||
|
}
|
||||||
|
method := scalarString(obj["RequestMethod"])
|
||||||
|
if status == "" && method == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := &fileRec{t: now, sev: 6}
|
||||||
|
if t, ok := parseLogTime(firstNonEmpty(scalarString(obj["StartUTC"]), scalarString(obj["time"])), now); ok {
|
||||||
|
r.t, r.dated = t, true
|
||||||
|
}
|
||||||
|
if n, ok := statusLevel(status); ok {
|
||||||
|
r.sev = n
|
||||||
|
}
|
||||||
|
rename := map[string]string{
|
||||||
|
"ClientHost": "client_ip", "ClientUsername": "http_user", "RequestMethod": "http_method",
|
||||||
|
"RequestHost": "http_host", "RequestPath": "http_path", "RequestProtocol": "http_proto",
|
||||||
|
"DownstreamStatus": "http_status", "DownstreamContentSize": "http_size", "OriginStatus": "origin_status",
|
||||||
|
"RouterName": "router", "ServiceName": "service", "ServiceURL": "backend", "entryPointName": "entrypoint",
|
||||||
|
"request_User-Agent": "user_agent", "request_Referer": "http_referer",
|
||||||
|
}
|
||||||
|
dur := ""
|
||||||
|
if d, err := strconv.ParseFloat(scalarString(obj["Duration"]), 64); err == nil {
|
||||||
|
dur = strconv.FormatFloat(d/1e6, 'f', -1, 64)
|
||||||
|
r.set("duration_ms", dur)
|
||||||
|
}
|
||||||
|
for _, k := range []string{"Duration", "StartUTC", "StartLocal", "time", "level", "msg"} {
|
||||||
|
delete(obj, k)
|
||||||
|
}
|
||||||
|
for k, v := range obj {
|
||||||
|
if to, ok := rename[k]; ok {
|
||||||
|
r.set(to, scalarString(v))
|
||||||
|
delete(obj, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flattenJSON("", obj, r)
|
||||||
|
// "GET example.com/path 404 · 12.5 ms · router@docker"
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(firstNonEmpty(method, "-"))
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(r.fields["http_host"])
|
||||||
|
b.WriteString(r.fields["http_path"])
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(firstNonEmpty(status, "-"))
|
||||||
|
if dur != "" {
|
||||||
|
b.WriteString(" · " + dur + " ms")
|
||||||
|
}
|
||||||
|
if rt := r.fields["router"]; rt != "" {
|
||||||
|
b.WriteString(" · " + rt)
|
||||||
|
}
|
||||||
|
if ip := r.fields["client_ip"]; ip != "" {
|
||||||
|
b.WriteString(" · " + ip)
|
||||||
|
}
|
||||||
|
r.msg = b.String()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Detection ---------- */
|
||||||
|
|
||||||
|
const detectWindow = 64 << 10
|
||||||
|
|
||||||
|
// sampleLines returns the last complete lines of a file (50 at most).
|
||||||
|
func sampleLines(path string) ([]string, error) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
fi, err := fh.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
off := fi.Size() - detectWindow
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(io.NewSectionReader(fh, off, detectWindow))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
end := bytes.LastIndexByte(buf, '\n')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
buf = buf[:end]
|
||||||
|
if off > 0 { // first line cut by the window
|
||||||
|
if nl := bytes.IndexByte(buf, '\n'); nl >= 0 {
|
||||||
|
buf = buf[nl+1:]
|
||||||
|
} else {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, b := range bytes.Split(buf, []byte{'\n'}) {
|
||||||
|
if len(bytes.TrimSpace(b)) > 0 {
|
||||||
|
lines = append(lines, string(bytes.TrimRight(b, "\r")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > 50 {
|
||||||
|
lines = lines[len(lines)-50:]
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logfmtKeys: the pairs have one of the usual logfmt keys.
|
||||||
|
func logfmtKeys(kv [][2]string) bool {
|
||||||
|
for _, p := range kv {
|
||||||
|
switch strings.ToLower(p[0]) {
|
||||||
|
case "level", "lvl", "msg", "message", "time", "ts", "t":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFormat guesses the format of a file from some of its lines: the
|
||||||
|
// structured format most lines follow, else plain text (a regular
|
||||||
|
// expression is never guessed).
|
||||||
|
func detectFormat(lines []string) string {
|
||||||
|
var js, traefik, clf, logfmt int
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if obj, ok := decodeJSONLine(l); ok {
|
||||||
|
js++
|
||||||
|
if _, ok := obj["RequestMethod"]; ok {
|
||||||
|
traefik++
|
||||||
|
} else if _, ok := obj["DownstreamStatus"]; ok {
|
||||||
|
traefik++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clfRe.MatchString(l) {
|
||||||
|
clf++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kv := parseLogfmt(l); len(kv) >= 3 && logfmtKeys(kv) {
|
||||||
|
logfmt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
most := func(n int) bool { return n > 0 && n*5 >= len(lines)*4 } // 80 %
|
||||||
|
switch {
|
||||||
|
case most(js) && traefik*2 >= js:
|
||||||
|
return fmtTraefik
|
||||||
|
case most(js):
|
||||||
|
return fmtJSON
|
||||||
|
case most(clf):
|
||||||
|
return fmtCLF
|
||||||
|
case most(logfmt):
|
||||||
|
return fmtLogfmt
|
||||||
|
}
|
||||||
|
return fmtText
|
||||||
|
}
|
||||||
+711
@@ -0,0 +1,711 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileSourceDef is one log file declared in Settings > Sources.
|
||||||
|
type fileSourceDef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // app of the entries
|
||||||
|
Path string `json:"path"` // the file, as mounted in the container
|
||||||
|
Format string `json:"format"` // auto (detected), or text, json, logfmt, regex, clf, traefik
|
||||||
|
Regex string `json:"regex,omitempty"` // format regex: named groups time, level, msg, host, app…
|
||||||
|
Host string `json:"host,omitempty"` // host of the entries (empty: the host name of the machine)
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesConfig is saved in /data/files.json.
|
||||||
|
type filesConfig struct {
|
||||||
|
Sources []fileSourceDef `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// filePos is where reading resumes in a file: its inode and an offset before
|
||||||
|
// which every entry was sent. Until: end of the history found when the
|
||||||
|
// source was turned on; entries before it are kept only when dated within
|
||||||
|
// FILES_BACKFILL.
|
||||||
|
type filePos struct {
|
||||||
|
Ino uint64 `json:"ino"`
|
||||||
|
Off int64 `json:"off"`
|
||||||
|
Until int64 `json:"until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type filesState struct {
|
||||||
|
Pos map[string]filePos `json:"pos"` // by source id
|
||||||
|
Started map[string]time.Time `json:"started"` // when each source was turned on
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSourceStatus struct {
|
||||||
|
Open bool
|
||||||
|
Read uint64
|
||||||
|
Detected string // format found when the source is in auto
|
||||||
|
Behind int64 // bytes left to read (history being read)
|
||||||
|
Code string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSource is a definition with its parser (nil in auto: detected from
|
||||||
|
// the file).
|
||||||
|
type fileSource struct {
|
||||||
|
fileSourceDef
|
||||||
|
parser *fileParser
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingEntry is an entry waiting for its continuation lines.
|
||||||
|
type pendingEntry struct {
|
||||||
|
rec *fileRec
|
||||||
|
off int64 // offset of its first line
|
||||||
|
seen time.Time // when its last line was read
|
||||||
|
history bool // read from the history found when the source was turned on
|
||||||
|
lines int
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileTail is the reading state of one file, owned by the Run goroutine.
|
||||||
|
type fileTail struct {
|
||||||
|
readOff int64 // where the next read starts (after the lines of the pending entry)
|
||||||
|
known bool // readOff is set
|
||||||
|
pending *pendingEntry
|
||||||
|
auto *fileParser // format detected from the file, in auto
|
||||||
|
behind int64 // bytes left to read after the last poll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files follows log files of other programs (Traefik, nginx, applications…)
|
||||||
|
// mounted in the container, one entry per line in the format detected or
|
||||||
|
// chosen for each file. Source type "file".
|
||||||
|
type Files struct {
|
||||||
|
sink func(*Entry)
|
||||||
|
hostName string
|
||||||
|
backfill time.Duration
|
||||||
|
cfgPath string
|
||||||
|
statePath string
|
||||||
|
wake chan struct{}
|
||||||
|
|
||||||
|
// Owned by the Run goroutine.
|
||||||
|
st filesState
|
||||||
|
tails map[string]*fileTail
|
||||||
|
dirty bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg []*fileSource
|
||||||
|
resets map[string]bool // sources to read again from FILES_BACKFILL ago
|
||||||
|
redetect map[string]bool // sources whose format changed
|
||||||
|
status map[string]*fileSourceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFiles(hostRoot, dataDir string, backfill time.Duration, sink func(*Entry)) *Files {
|
||||||
|
f := &Files{
|
||||||
|
sink: sink,
|
||||||
|
hostName: readTrim(filepath.Join(hostRoot, "etc/hostname")),
|
||||||
|
backfill: backfill,
|
||||||
|
cfgPath: filepath.Join(dataDir, "files.json"),
|
||||||
|
statePath: filepath.Join(dataDir, "files-state.json"),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
tails: map[string]*fileTail{},
|
||||||
|
resets: map[string]bool{},
|
||||||
|
redetect: map[string]bool{},
|
||||||
|
status: map[string]*fileSourceStatus{},
|
||||||
|
}
|
||||||
|
if f.hostName == "" {
|
||||||
|
f.hostName = "localhost"
|
||||||
|
}
|
||||||
|
var cfg filesConfig
|
||||||
|
if b, err := os.ReadFile(f.cfgPath); err == nil {
|
||||||
|
_ = json.Unmarshal(b, &cfg)
|
||||||
|
}
|
||||||
|
for _, d := range cfg.Sources {
|
||||||
|
src, err := compileFileSource(d)
|
||||||
|
if err != nil {
|
||||||
|
logEvent("files", 4, "file %q ignored: %v", d.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.cfg = append(f.cfg, src)
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(f.statePath); err == nil {
|
||||||
|
_ = json.Unmarshal(b, &f.st)
|
||||||
|
}
|
||||||
|
if f.st.Pos == nil {
|
||||||
|
f.st.Pos = map[string]filePos{}
|
||||||
|
}
|
||||||
|
if f.st.Started == nil {
|
||||||
|
f.st.Started = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFileSources = 50
|
||||||
|
|
||||||
|
var fileIDRe = regexp.MustCompile(`^[0-9a-f]{1,32}$`)
|
||||||
|
|
||||||
|
// checkFilePath: one file, absolute, without wildcards.
|
||||||
|
func checkFilePath(p string) (string, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
switch {
|
||||||
|
case !filepath.IsAbs(p):
|
||||||
|
return "", errors.New("the path must be absolute (as mounted in the container)")
|
||||||
|
case strings.ContainsAny(p, "*?["):
|
||||||
|
return "", errors.New("give the path of one file (no wildcards)")
|
||||||
|
}
|
||||||
|
return filepath.Clean(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileFileSource checks a definition and builds its parser.
|
||||||
|
func compileFileSource(d fileSourceDef) (*fileSource, error) {
|
||||||
|
d.Name = strings.TrimSpace(d.Name)
|
||||||
|
d.Host = strings.TrimSpace(d.Host)
|
||||||
|
if d.Format == "" {
|
||||||
|
d.Format = fmtAuto
|
||||||
|
}
|
||||||
|
if d.Format != fmtRegex {
|
||||||
|
d.Regex = ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !fileIDRe.MatchString(d.ID):
|
||||||
|
return nil, errors.New("invalid id")
|
||||||
|
case d.Name == "" || len(d.Name) > 64:
|
||||||
|
return nil, errors.New("the name is required (64 characters at most)")
|
||||||
|
case len(d.Host) > 253:
|
||||||
|
return nil, fmt.Errorf("%s: host name too long", d.Name)
|
||||||
|
}
|
||||||
|
p, err := checkFilePath(d.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %v", d.Name, err)
|
||||||
|
}
|
||||||
|
d.Path = p
|
||||||
|
src := &fileSource{fileSourceDef: d}
|
||||||
|
if d.Format != fmtAuto {
|
||||||
|
if src.parser, err = newFileParser(d.Format, d.Regex); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %v", d.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure checks, saves and applies the list of files. Files turned off,
|
||||||
|
// removed or given another path are read again from FILES_BACKFILL ago the
|
||||||
|
// next time they are on.
|
||||||
|
func (f *Files) Configure(cfg filesConfig) error {
|
||||||
|
if len(cfg.Sources) > maxFileSources {
|
||||||
|
return fmt.Errorf("%d files at most", maxFileSources)
|
||||||
|
}
|
||||||
|
var srcs []*fileSource
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, d := range cfg.Sources {
|
||||||
|
if d.ID == "" {
|
||||||
|
d.ID = newFileID()
|
||||||
|
}
|
||||||
|
if ids[d.ID] {
|
||||||
|
return fmt.Errorf("duplicate id %s", d.ID)
|
||||||
|
}
|
||||||
|
ids[d.ID] = true
|
||||||
|
src, err := compileFileSource(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcs = append(srcs, src)
|
||||||
|
}
|
||||||
|
saved := filesConfig{Sources: []fileSourceDef{}}
|
||||||
|
for _, s := range srcs {
|
||||||
|
saved.Sources = append(saved.Sources, s.fileSourceDef)
|
||||||
|
}
|
||||||
|
if err := writeJSONFile(f.cfgPath, saved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
old := map[string]*fileSource{}
|
||||||
|
for _, s := range f.cfg {
|
||||||
|
old[s.ID] = s
|
||||||
|
}
|
||||||
|
for _, s := range srcs {
|
||||||
|
o := old[s.ID]
|
||||||
|
if o != nil && ((o.Enabled && !s.Enabled) || o.Path != s.Path) {
|
||||||
|
f.resets[s.ID] = true
|
||||||
|
delete(f.status, s.ID)
|
||||||
|
}
|
||||||
|
if o != nil && (o.Format != s.Format || o.Regex != s.Regex) {
|
||||||
|
f.redetect[s.ID] = true
|
||||||
|
}
|
||||||
|
delete(old, s.ID)
|
||||||
|
}
|
||||||
|
for id := range old { // removed
|
||||||
|
f.resets[id] = true
|
||||||
|
delete(f.status, id)
|
||||||
|
}
|
||||||
|
f.cfg = srcs
|
||||||
|
f.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case f.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the state shown in Settings > Sources.
|
||||||
|
func (f *Files) Status() map[string]any {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
list := make([]map[string]any, 0, len(f.cfg))
|
||||||
|
for _, s := range f.cfg {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": s.ID, "name": s.Name, "path": s.Path, "format": s.Format, "regex": s.Regex,
|
||||||
|
"host": s.Host, "enabled": s.Enabled, "open": false, "read": 0, "detected": "", "behind": 0, "code": "", "error": "",
|
||||||
|
}
|
||||||
|
if st := f.status[s.ID]; st != nil && s.Enabled {
|
||||||
|
m["open"], m["read"], m["detected"], m["code"], m["error"] = st.Open, st.Read, st.Detected, st.Code, st.Error
|
||||||
|
m["behind"] = st.Behind
|
||||||
|
}
|
||||||
|
list = append(list, m)
|
||||||
|
}
|
||||||
|
return map[string]any{"sources": list, "defaultHost": f.hostName}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run polls the files every second.
|
||||||
|
func (f *Files) Run(ctx context.Context) {
|
||||||
|
tick := time.NewTicker(time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
now := time.Now()
|
||||||
|
for id := range f.tails {
|
||||||
|
f.flushPending(id, now)
|
||||||
|
}
|
||||||
|
f.checkpointAll()
|
||||||
|
f.saveState()
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
case <-f.wake:
|
||||||
|
}
|
||||||
|
f.poll(time.Now())
|
||||||
|
if n++; n%5 == 0 {
|
||||||
|
f.saveState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Files) saveState() {
|
||||||
|
if !f.dirty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.dirty = false
|
||||||
|
if err := writeJSONFile(f.statePath, f.st); err != nil {
|
||||||
|
logEvent("files", 4, "saving positions: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll reads what was added to every file that is on.
|
||||||
|
func (f *Files) poll(now time.Time) {
|
||||||
|
f.mu.Lock()
|
||||||
|
srcs := f.cfg
|
||||||
|
resets, redetect := f.resets, f.redetect
|
||||||
|
f.resets, f.redetect = map[string]bool{}, map[string]bool{}
|
||||||
|
f.mu.Unlock()
|
||||||
|
for id := range resets {
|
||||||
|
delete(f.st.Pos, id)
|
||||||
|
delete(f.st.Started, id)
|
||||||
|
delete(f.tails, id)
|
||||||
|
f.dirty = true
|
||||||
|
}
|
||||||
|
for id := range redetect {
|
||||||
|
if tl := f.tails[id]; tl != nil {
|
||||||
|
tl.auto = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active := map[string]bool{}
|
||||||
|
for _, s := range srcs {
|
||||||
|
if !s.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active[s.ID] = true
|
||||||
|
f.setStatus(s.ID, f.pollFile(s, now))
|
||||||
|
}
|
||||||
|
// Files turned off: their position is kept until the next poll forgets it.
|
||||||
|
for id := range f.tails {
|
||||||
|
if !active[id] {
|
||||||
|
delete(f.tails, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.checkpointAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxFileRead = 4 << 20 // per read
|
||||||
|
maxFileReads = 16 // reads per file and per poll (64 MB)
|
||||||
|
fileContinueDelay = 2 * time.Second // an entry waits this long for continuation lines
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *Files) setStatus(id string, err error) {
|
||||||
|
code, detail := "", ""
|
||||||
|
if err != nil {
|
||||||
|
code, detail = errCode(err), err.Error()
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
code = "not_found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detected := ""
|
||||||
|
if tl := f.tails[id]; tl != nil && tl.auto != nil {
|
||||||
|
detected = tl.auto.format
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
st := f.status[id]
|
||||||
|
if st == nil {
|
||||||
|
st = &fileSourceStatus{}
|
||||||
|
f.status[id] = st
|
||||||
|
}
|
||||||
|
changed := code != st.Code
|
||||||
|
st.Open, st.Detected, st.Code, st.Error = err == nil, detected, code, detail
|
||||||
|
st.Behind = 0
|
||||||
|
if tl := f.tails[id]; tl != nil && err == nil {
|
||||||
|
st.Behind = tl.behind
|
||||||
|
}
|
||||||
|
name := id
|
||||||
|
for _, s := range f.cfg {
|
||||||
|
if s.ID == id {
|
||||||
|
name = s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if changed && code != "" && code != "not_found" {
|
||||||
|
logEvent("files", 4, "cannot read the file %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Files) count(id string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
st := f.status[id]
|
||||||
|
if st == nil { // first poll of the file: its status comes after
|
||||||
|
st = &fileSourceStatus{}
|
||||||
|
f.status[id] = st
|
||||||
|
}
|
||||||
|
st.Read++
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parserOf is the parser of a file: the chosen format, or the one detected
|
||||||
|
// from its last lines (nil while the file has none).
|
||||||
|
func (f *Files) parserOf(s *fileSource, tl *fileTail, path string) *fileParser {
|
||||||
|
if s.parser != nil {
|
||||||
|
return s.parser
|
||||||
|
}
|
||||||
|
if tl.auto == nil {
|
||||||
|
lines, err := sampleLines(path)
|
||||||
|
if err != nil || len(lines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tl.auto, _ = newFileParser(detectFormat(lines), "")
|
||||||
|
}
|
||||||
|
return tl.auto
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Files) pollFile(s *fileSource, now time.Time) error {
|
||||||
|
path := s.Path
|
||||||
|
first := false
|
||||||
|
if _, ok := f.st.Started[s.ID]; !ok {
|
||||||
|
f.st.Started[s.ID], f.dirty, first = now, true, true
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%s is not a regular file", path)
|
||||||
|
}
|
||||||
|
tl := f.tails[s.ID]
|
||||||
|
if tl == nil {
|
||||||
|
tl = &fileTail{}
|
||||||
|
f.tails[s.ID] = tl
|
||||||
|
}
|
||||||
|
ino := inodeOf(fi)
|
||||||
|
pos, known := f.st.Pos[s.ID]
|
||||||
|
if !known {
|
||||||
|
pos = filePos{Ino: ino}
|
||||||
|
if first {
|
||||||
|
// Turned on: the last FILES_BACKFILL of what is already there
|
||||||
|
// (0: only new lines), found by date rather than read from the
|
||||||
|
// start of a file that can hold months of logs.
|
||||||
|
pos.Off = fi.Size()
|
||||||
|
if p := f.parserOf(s, tl, path); f.backfill > 0 && p != nil {
|
||||||
|
pos.Off = backfillStart(path, p, fi.Size(), f.st.Started[s.ID].Add(-f.backfill), now)
|
||||||
|
pos.Until = fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.st.Pos[s.ID], f.dirty = pos, true
|
||||||
|
}
|
||||||
|
if !tl.known {
|
||||||
|
tl.readOff, tl.known = pos.Off, true
|
||||||
|
}
|
||||||
|
if pos.Ino != ino {
|
||||||
|
// Rotated (access.log -> access.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) == pos.Ino {
|
||||||
|
if p := f.parserOf(s, tl, path+".1"); p != nil {
|
||||||
|
for {
|
||||||
|
next, more, err := f.readFrom(s, tl, p, path+".1", tl.readOff, pos.Until, now)
|
||||||
|
if err != nil || !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tl.readOff = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.flushPending(s.ID, now)
|
||||||
|
pos, tl.readOff = filePos{Ino: ino}, 0
|
||||||
|
f.st.Pos[s.ID], f.dirty = pos, true
|
||||||
|
} else if fi.Size() < tl.readOff {
|
||||||
|
f.flushPending(s.ID, now) // truncated (copytruncate)
|
||||||
|
pos.Until, tl.readOff = 0, 0
|
||||||
|
f.st.Pos[s.ID], f.dirty = pos, true
|
||||||
|
}
|
||||||
|
if fi.Size() > tl.readOff {
|
||||||
|
p := f.parserOf(s, tl, path)
|
||||||
|
if p == nil {
|
||||||
|
return nil // nothing complete to detect the format from yet
|
||||||
|
}
|
||||||
|
for i := 0; i < maxFileReads; i++ {
|
||||||
|
next, more, err := f.readFrom(s, tl, p, path, tl.readOff, pos.Until, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tl.readOff = next
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tl.behind = max(fi.Size()-tl.readOff, 0)
|
||||||
|
if pe := tl.pending; pe != nil && now.Sub(pe.seen) >= fileContinueDelay {
|
||||||
|
f.flushPending(s.ID, now)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillStart finds where the lines dated notBefore or later start, by
|
||||||
|
// bisection on the dates of the lines (logs are written in order). Undated
|
||||||
|
// lines are left out of the history anyway: then only new lines are read.
|
||||||
|
func backfillStart(path string, p *fileParser, size int64, notBefore, now time.Time) int64 {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
// lineAt reads the first line starting at or after off (off itself when
|
||||||
|
// it is 0): its start and its date.
|
||||||
|
lineAt := func(off int64) (int64, time.Time, bool) {
|
||||||
|
n, _ := fh.ReadAt(buf, off)
|
||||||
|
b := buf[:n]
|
||||||
|
start := off
|
||||||
|
if off > 0 {
|
||||||
|
nl := bytes.IndexByte(b, '\n')
|
||||||
|
if nl < 0 {
|
||||||
|
return size, time.Time{}, false
|
||||||
|
}
|
||||||
|
b, start = b[nl+1:], off+int64(nl)+1
|
||||||
|
}
|
||||||
|
end := bytes.IndexByte(b, '\n')
|
||||||
|
if end < 0 {
|
||||||
|
return size, time.Time{}, false
|
||||||
|
}
|
||||||
|
r := p.parse(string(b[:end]), now)
|
||||||
|
return start, r.t, r.dated
|
||||||
|
}
|
||||||
|
if _, t, ok := lineAt(0); !ok || !t.Before(notBefore) {
|
||||||
|
if !ok {
|
||||||
|
// No date on the first line: try the last ones.
|
||||||
|
if _, _, ok := lineAt(max(size-int64(len(buf)), 0)); !ok {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return 0 // all of it is recent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo, hi := int64(0), size // line at lo: before notBefore; at hi: not
|
||||||
|
for hi-lo > int64(len(buf)) {
|
||||||
|
mid := lo + (hi-lo)/2
|
||||||
|
_, t, ok := lineAt(mid)
|
||||||
|
if ok && t.Before(notBefore) {
|
||||||
|
lo = mid
|
||||||
|
} else {
|
||||||
|
hi = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start, _, _ := lineAt(lo)
|
||||||
|
return min(start, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrom parses the complete lines written after off and returns the new
|
||||||
|
// offset; more tells whether the read window was full.
|
||||||
|
func (f *Files) readFrom(s *fileSource, tl *fileTail, p *fileParser, path string, off, until int64, now time.Time) (int64, bool, error) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return off, false, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
buf, err := io.ReadAll(io.NewSectionReader(fh, off, maxFileRead))
|
||||||
|
if err != nil {
|
||||||
|
return off, false, err
|
||||||
|
}
|
||||||
|
full := len(buf) == maxFileRead
|
||||||
|
end := bytes.LastIndexByte(buf, '\n')
|
||||||
|
if end < 0 {
|
||||||
|
if !full {
|
||||||
|
return off, false, nil // partial line: wait for its end
|
||||||
|
}
|
||||||
|
end = len(buf) - 1 // line longer than the read window: cut it
|
||||||
|
}
|
||||||
|
start := off
|
||||||
|
for _, b := range bytes.Split(buf[:end], []byte{'\n'}) {
|
||||||
|
f.line(s, tl, p, string(b), start, start < until, now)
|
||||||
|
start += int64(len(b)) + 1
|
||||||
|
}
|
||||||
|
return off + int64(end) + 1, full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// line handles one line: a continuation of the pending entry, or a new one.
|
||||||
|
func (f *Files) line(s *fileSource, tl *fileTail, p *fileParser, line string, off int64, history bool, now time.Time) {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pe := tl.pending; p.multiline() && pe != nil && p.continuation(line) {
|
||||||
|
if pe.lines < maxContinuation && len(pe.rec.msg) < maxFileMsg {
|
||||||
|
pe.rec.msg += "\n" + strings.TrimRight(cleanLine(line), " \t")
|
||||||
|
pe.lines++
|
||||||
|
}
|
||||||
|
pe.seen = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.flushPending(s.ID, now)
|
||||||
|
pe := &pendingEntry{rec: p.parse(line, now), off: off, seen: now, history: history}
|
||||||
|
if p.multiline() {
|
||||||
|
tl.pending = pe
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.emit(s, pe, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Files) flushPending(id string, now time.Time) {
|
||||||
|
tl := f.tails[id]
|
||||||
|
if tl == nil || tl.pending == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pe := tl.pending
|
||||||
|
tl.pending = nil
|
||||||
|
f.mu.Lock()
|
||||||
|
var src *fileSource
|
||||||
|
for _, s := range f.cfg {
|
||||||
|
if s.ID == id {
|
||||||
|
src = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if src != nil {
|
||||||
|
f.emit(src, pe, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit sends an entry; history entries are kept only when dated within
|
||||||
|
// FILES_BACKFILL of when the file was turned on.
|
||||||
|
func (f *Files) emit(s *fileSource, pe *pendingEntry, now time.Time) {
|
||||||
|
r := pe.rec
|
||||||
|
if pe.history {
|
||||||
|
notBefore := f.st.Started[s.ID].Add(-f.backfill)
|
||||||
|
if !r.dated || r.t.Before(notBefore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.msg) > maxFileMsg {
|
||||||
|
r.msg = strings.ToValidUTF8(r.msg[:maxFileMsg], "") + "…"
|
||||||
|
}
|
||||||
|
extra := map[string]string{"log_file": s.Path}
|
||||||
|
for k, v := range r.fields {
|
||||||
|
extra[k] = v
|
||||||
|
}
|
||||||
|
f.sink(&Entry{
|
||||||
|
Time: r.t,
|
||||||
|
Received: now,
|
||||||
|
Host: firstNonEmpty(r.host, s.Host, f.hostName),
|
||||||
|
App: firstNonEmpty(r.app, s.Name),
|
||||||
|
SevNum: r.sev,
|
||||||
|
Severity: severityNames[r.sev],
|
||||||
|
Message: r.msg,
|
||||||
|
Proto: "file",
|
||||||
|
SourceType: "file",
|
||||||
|
Extra: extra,
|
||||||
|
Wait: true, // the file keeps the lines: wait for room in the queue
|
||||||
|
})
|
||||||
|
f.count(s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkpointAll saves, for each file, an offset before which every entry is
|
||||||
|
// sent: the start of its pending entry, or the read offset.
|
||||||
|
func (f *Files) checkpointAll() {
|
||||||
|
for id, tl := range f.tails {
|
||||||
|
pos, ok := f.st.Pos[id]
|
||||||
|
if !ok || !tl.known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
off := tl.readOff
|
||||||
|
if pe := tl.pending; pe != nil && pe.off < off {
|
||||||
|
off = pe.off
|
||||||
|
}
|
||||||
|
if off != pos.Off {
|
||||||
|
pos.Off, f.dirty = off, true
|
||||||
|
f.st.Pos[id] = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectFile tells, for the settings, whether a file can be read and its
|
||||||
|
// format, with a line as example.
|
||||||
|
func DetectFile(path string) map[string]any {
|
||||||
|
res := map[string]any{"format": "", "sample": "", "code": "", "error": ""}
|
||||||
|
p, err := checkFilePath(path)
|
||||||
|
if err != nil {
|
||||||
|
res["code"], res["error"] = "invalid", err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
if fi, err2 := os.Stat(p); err2 != nil {
|
||||||
|
err = err2
|
||||||
|
} else if !fi.Mode().IsRegular() {
|
||||||
|
err = fmt.Errorf("%s is not a regular file", p)
|
||||||
|
} else {
|
||||||
|
lines, err = sampleLines(p)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
res["code"], res["error"] = "not_found", err.Error()
|
||||||
|
case err != nil:
|
||||||
|
res["code"], res["error"] = errCode(err), err.Error()
|
||||||
|
case len(lines) == 0:
|
||||||
|
res["code"] = "empty"
|
||||||
|
default:
|
||||||
|
res["format"] = detectFormat(lines)
|
||||||
|
sample := lines[len(lines)-1]
|
||||||
|
if len(sample) > 300 {
|
||||||
|
sample = strings.ToValidUTF8(sample[:300], "") + "…"
|
||||||
|
}
|
||||||
|
res["sample"] = cleanLine(sample)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustParser(t *testing.T, format, expr string) *fileParser {
|
||||||
|
t.Helper()
|
||||||
|
p, err := newFileParser(format, expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLogTime(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
utc := func(s string) time.Time { t, _ := time.Parse(time.RFC3339Nano, s); return t }
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{"2026-10-05T10:00:00.123Z", utc("2026-10-05T10:00:00.123Z")},
|
||||||
|
{"2026-10-05T10:00:00+02:00", utc("2026-10-05T08:00:00Z")},
|
||||||
|
{"2026-10-05 10:00:00,250 +0200", utc("2026-10-05T08:00:00.25Z")},
|
||||||
|
{"2026/10/05 10:00:00Z", utc("2026-10-05T10:00:00Z")},
|
||||||
|
{"05/Oct/2026:10:00:00 +0200", utc("2026-10-05T08:00:00Z")},
|
||||||
|
{"1759572000", time.Unix(1759572000, 0)},
|
||||||
|
{"1759572000123", time.UnixMilli(1759572000123)},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := parseLogTime(tt.in, now)
|
||||||
|
if !ok || !got.Equal(tt.want) {
|
||||||
|
t.Errorf("%q: got %v %v, want %v", tt.in, got, ok, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Syslog timestamps: no year, the last one before tomorrow.
|
||||||
|
if got, ok := parseLogTime("Dec 31 23:00:00", now); !ok || got.Year() != 2025 {
|
||||||
|
t.Errorf("Dec 31: %v %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := parseLogTime("Oct 5 11:00:00", now); !ok || got.Year() != 2026 || got.Day() != 5 {
|
||||||
|
t.Errorf("Oct 5: %v %v", got, ok)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "hello", "200", "12345"} {
|
||||||
|
if _, ok := parseLogTime(bad, now); ok {
|
||||||
|
t.Errorf("%q parsed", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseText(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
p := mustParser(t, fmtText, "")
|
||||||
|
r := p.parse("2026-10-05 10:00:00.5 ERROR db: connection refused", now)
|
||||||
|
if !r.dated || r.msg != "ERROR db: connection refused" || r.sev != 3 {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
r = p.parse("[2026-10-05T10:00:00Z] - started", now)
|
||||||
|
if !r.dated || r.msg != "started" || r.sev != 6 {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
r = p.parse("\x1b[33mWARN\x1b[0m no timestamp here", now)
|
||||||
|
if r.dated || r.msg != "WARN no timestamp here" || r.sev != 4 || !r.t.Equal(now) {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJSON(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
p := mustParser(t, fmtJSON, "")
|
||||||
|
r := p.parse(`{"time":"2026-10-05T10:00:00Z","level":"warn","msg":"slow query","host":"db.example","req":{"id":7,"tags":["a"]},"ok":true}`, now)
|
||||||
|
if !r.dated || r.sev != 4 || r.msg != "slow query" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{"field_host": "db.example", "req.id": "7", "req.tags": `["a"]`, "ok": "true"} {
|
||||||
|
if r.fields[k] != want {
|
||||||
|
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pino: numeric level, epoch in ms.
|
||||||
|
r = p.parse(`{"level":50,"time":1759572000123,"msg":"boom"}`, now)
|
||||||
|
if r.sev != 3 || !r.t.Equal(time.UnixMilli(1759572000123)) {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
// No message key: the whole line; not JSON: text.
|
||||||
|
line := `{"event":"login","user":"bob"}`
|
||||||
|
if r = p.parse(line, now); r.msg != line || r.fields["user"] != "bob" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
if r = p.parse("plain ERROR line", now); r.msg != "plain ERROR line" || r.sev != 3 {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLogfmt(t *testing.T) {
|
||||||
|
p := mustParser(t, fmtLogfmt, "")
|
||||||
|
r := p.parse(`time="2026-10-05T10:00:00Z" level=error msg="Cannot connect: \"docker\" down" providerName=docker flag`, time.Now())
|
||||||
|
if !r.dated || r.sev != 3 || r.msg != `Cannot connect: "docker" down` || r.fields["providerName"] != "docker" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRegex(t *testing.T) {
|
||||||
|
if _, err := newFileParser(fmtRegex, "("); err == nil {
|
||||||
|
t.Error("invalid regex accepted")
|
||||||
|
}
|
||||||
|
if _, err := newFileParser(fmtRegex, ""); err == nil {
|
||||||
|
t.Error("empty regex accepted")
|
||||||
|
}
|
||||||
|
p := mustParser(t, fmtRegex, `^(?P<time>\S+ \S+) \[(?P<level>\w+)\] (?P<module>[\w.]+): (?P<msg>.*)$`)
|
||||||
|
r := p.parse("2026-10-05 10:00:00 [warning] app.db: pool exhausted", time.Now())
|
||||||
|
if !r.dated || r.sev != 4 || r.msg != "pool exhausted" || r.fields["module"] != "app.db" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
if !p.continuation("Traceback (most recent call last):") || p.continuation("2026-10-05 10:00:01 [info] a: b") {
|
||||||
|
t.Error("continuation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCLF(t *testing.T) {
|
||||||
|
p := mustParser(t, fmtCLF, "")
|
||||||
|
r := p.parse(`203.0.113.9 - - [05/Oct/2026:10:00:00 +0200] "GET /api/x?y=1 HTTP/1.1" 502 157 "-" "curl/8.0" 4211 "api@docker" "http://172.18.0.5:8080" 31ms`, time.Now())
|
||||||
|
if !r.dated || r.sev != 3 || !strings.HasPrefix(r.msg, "203.0.113.9 - - [") {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{"client_ip": "203.0.113.9", "http_method": "GET", "http_path": "/api/x?y=1", "http_status": "502",
|
||||||
|
"user_agent": "curl/8.0", "router": "api@docker", "backend": "http://172.18.0.5:8080", "duration_ms": "31"} {
|
||||||
|
if r.fields[k] != want {
|
||||||
|
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := r.fields["http_referer"]; ok {
|
||||||
|
t.Error("empty referer kept")
|
||||||
|
}
|
||||||
|
// nginx combined, 404.
|
||||||
|
r = p.parse(`10.0.0.1 - alice [05/Oct/2026:10:00:00 +0000] "POST /login HTTP/2.0" 404 0 "https://example.com/" "Mozilla/5.0"`, time.Now())
|
||||||
|
if r.sev != 4 || r.fields["http_user"] != "alice" || r.fields["http_referer"] != "https://example.com/" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTraefikJSON(t *testing.T) {
|
||||||
|
p := mustParser(t, fmtTraefik, "")
|
||||||
|
r := p.parse(`{"ClientHost":"203.0.113.9","DownstreamStatus":200,"Duration":12500000,"RequestHost":"app.example.com","RequestMethod":"GET","RequestPath":"/","RequestProtocol":"HTTP/2.0","RouterName":"app@docker","StartUTC":"2026-10-05T10:00:00.1Z","entryPointName":"websecure","level":"info","msg":"","time":"2026-10-05T10:00:00Z","TLSVersion":"1.3"}`, time.Now())
|
||||||
|
if !r.dated || r.sev != 6 || r.msg != "GET app.example.com/ 200 · 12.5 ms · app@docker · 203.0.113.9" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{"entrypoint": "websecure", "http_status": "200", "TLSVersion": "1.3", "duration_ms": "12.5"} {
|
||||||
|
if r.fields[k] != want {
|
||||||
|
t.Errorf("%s = %q, want %q", k, r.fields[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Traefik's own logs in the same file: generic JSON.
|
||||||
|
r = p.parse(`{"level":"error","msg":"Provider error","time":"2026-10-05T10:00:00Z"}`, time.Now())
|
||||||
|
if r.sev != 3 || r.msg != "Provider error" {
|
||||||
|
t.Errorf("got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFormat(t *testing.T) {
|
||||||
|
tests := map[string][]string{
|
||||||
|
fmtJSON: {`{"level":"info","msg":"a"}`, `{"level":"warn","msg":"b"}`},
|
||||||
|
fmtTraefik: {`{"RequestMethod":"GET","DownstreamStatus":200}`, `{"level":"info","msg":"Configuration loaded"}`},
|
||||||
|
fmtCLF: {`10.0.0.1 - - [05/Oct/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 12 "-" "curl"`},
|
||||||
|
fmtLogfmt: {`time="2026-10-05T10:00:00Z" level=info msg="Starting provider" providerName=docker`},
|
||||||
|
fmtText: {`2026-10-05 10:00:00 INFO started`, `user=bob logged in`},
|
||||||
|
}
|
||||||
|
for want, lines := range tests {
|
||||||
|
if got := detectFormat(lines); got != want {
|
||||||
|
t.Errorf("%q: got %s, want %s", lines, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A few lines of another format do not change the answer (80 %).
|
||||||
|
lines := []string{`{"a":1}`, `{"a":2}`, `{"a":3}`, `{"a":4}`, `not json`}
|
||||||
|
if got := detectFormat(lines); got != fmtJSON {
|
||||||
|
t.Errorf("got %s", got)
|
||||||
|
}
|
||||||
|
lines = append(lines, `still not json`)
|
||||||
|
if got := detectFormat(lines); got != fmtText {
|
||||||
|
t.Errorf("got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "access.log")
|
||||||
|
appendFile(t, path, `10.0.0.1 - - [05/Oct/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 12 "-" "curl"`+"\n"+`partial`)
|
||||||
|
res := DetectFile(path)
|
||||||
|
if res["format"] != fmtCLF || !strings.HasPrefix(res["sample"].(string), "10.0.0.1 - -") || res["code"] != "" {
|
||||||
|
t.Errorf("got %+v", res)
|
||||||
|
}
|
||||||
|
for p, code := range map[string]string{
|
||||||
|
filepath.Join(dir, "nope.log"): "not_found", "relative.log": "invalid", filepath.Join(dir, "*.log"): "invalid", dir: "read",
|
||||||
|
} {
|
||||||
|
if res := DetectFile(p); res["code"] != code {
|
||||||
|
t.Errorf("%s: got %+v, want %s", p, res, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
empty := filepath.Join(dir, "empty.log")
|
||||||
|
appendFile(t, empty, "")
|
||||||
|
if res := DetectFile(empty); res["code"] != "empty" {
|
||||||
|
t.Errorf("got %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileSourceValidation(t *testing.T) {
|
||||||
|
f := NewFiles(t.TempDir(), t.TempDir(), time.Hour, func(*Entry) {})
|
||||||
|
bad := []fileSourceDef{
|
||||||
|
{Name: "", Path: "/logs/a.log"},
|
||||||
|
{Name: "a", Path: "relative.log"},
|
||||||
|
{Name: "a", Path: "/logs/*.log"},
|
||||||
|
{Name: "a", Path: "/logs/a.log", Format: "xml"},
|
||||||
|
{Name: "a", Path: "/logs/a.log", Format: fmtRegex, Regex: "(?P<x"},
|
||||||
|
}
|
||||||
|
for _, d := range bad {
|
||||||
|
if err := f.Configure(filesConfig{Sources: []fileSourceDef{d}}); err == nil {
|
||||||
|
t.Errorf("%+v accepted", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.Configure(filesConfig{Sources: []fileSourceDef{{Name: " traefik ", Path: "/logs/traefik/access.log"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := f.Status()["sources"].([]map[string]any)
|
||||||
|
if len(src) != 1 || src[0]["name"] != "traefik" || src[0]["id"] == "" || src[0]["format"] != fmtAuto {
|
||||||
|
t.Errorf("got %+v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
e []*Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileSink) add(e *Entry) { s.mu.Lock(); s.e = append(s.e, e); s.mu.Unlock() }
|
||||||
|
func (s *fileSink) take() []*Entry {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e := s.e
|
||||||
|
s.e = nil
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFile(t *testing.T, path, s string) {
|
||||||
|
t.Helper()
|
||||||
|
fh, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fh.WriteString(s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fh.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgs(es []*Entry) []string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range es {
|
||||||
|
out = append(out, e.Message)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesFollow(t *testing.T) {
|
||||||
|
dir, data := t.TempDir(), t.TempDir()
|
||||||
|
now := time.Now()
|
||||||
|
old := now.Add(-3 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
recent := now.Add(-10 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
app := filepath.Join(dir, "app.log")
|
||||||
|
appendFile(t, app, old+" INFO too old\n"+recent+" ERROR recent\n at line 1\nundated history line\n")
|
||||||
|
|
||||||
|
sink := &fileSink{}
|
||||||
|
f := NewFiles(dir, data, time.Hour, sink.add)
|
||||||
|
if err := f.Configure(filesConfig{Sources: []fileSourceDef{{Name: "myapp", Path: app, Host: "web1", Enabled: true}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.poll(now)
|
||||||
|
f.poll(now.Add(3 * time.Second)) // the pending entry waited for continuation lines
|
||||||
|
got := sink.take()
|
||||||
|
if len(got) != 1 || got[0].Message != "ERROR recent\n at line 1" || got[0].SevNum != 3 || got[0].App != "myapp" ||
|
||||||
|
got[0].Host != "web1" || got[0].SourceType != "file" || got[0].Extra["log_file"] != app {
|
||||||
|
t.Fatalf("history: got %q %+v", msgs(got), got)
|
||||||
|
}
|
||||||
|
if st := f.Status()["sources"].([]map[string]any)[0]; st["detected"] != fmtText || st["open"] != true {
|
||||||
|
t.Errorf("status %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFile(t, app, "plain new line\n\tcontinued\n")
|
||||||
|
f.poll(now.Add(4 * time.Second))
|
||||||
|
appendFile(t, app, "after\n")
|
||||||
|
f.poll(now.Add(5 * time.Second))
|
||||||
|
f.poll(now.Add(8 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != "plain new line\n\tcontinued|after" {
|
||||||
|
t.Fatalf("new lines: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFile(t, app, "before rotation\n")
|
||||||
|
if err := os.Rename(app, app+".1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appendFile(t, app, "after rotation\n")
|
||||||
|
f.poll(now.Add(9 * time.Second))
|
||||||
|
f.poll(now.Add(12 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != "before rotation|after rotation" {
|
||||||
|
t.Fatalf("rotation: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart: resumes where it stopped, nothing sent twice.
|
||||||
|
f.checkpointAll()
|
||||||
|
f.dirty = true
|
||||||
|
f.saveState()
|
||||||
|
appendFile(t, app, "while stopped\n")
|
||||||
|
f2 := NewFiles(dir, data, time.Hour, sink.add)
|
||||||
|
f2.poll(now.Add(13 * time.Second))
|
||||||
|
f2.poll(now.Add(16 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != "while stopped" {
|
||||||
|
t.Fatalf("restart: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncated in place (copytruncate).
|
||||||
|
os.Truncate(app, 0)
|
||||||
|
appendFile(t, app, "x\n")
|
||||||
|
f2.poll(now.Add(17 * time.Second))
|
||||||
|
f2.poll(now.Add(20 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != "x" {
|
||||||
|
t.Fatalf("truncate: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesAutoAndErrors(t *testing.T) {
|
||||||
|
dir, data := t.TempDir(), t.TempDir()
|
||||||
|
path := filepath.Join(dir, "access.json")
|
||||||
|
sink := &fileSink{}
|
||||||
|
f := NewFiles(dir, data, 0, sink.add) // FILES_BACKFILL=0: only new lines
|
||||||
|
cfg := filesConfig{Sources: []fileSourceDef{
|
||||||
|
{ID: "a1", Name: "api", Path: path, Enabled: true}, // auto
|
||||||
|
{ID: "b2", Name: "missing", Path: filepath.Join(dir, "nope.log"), Format: fmtText, Enabled: true},
|
||||||
|
}}
|
||||||
|
if err := f.Configure(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
appendFile(t, path, "")
|
||||||
|
f.poll(now) // empty file: format not known yet
|
||||||
|
appendFile(t, path, `{"msg":"one","level":"error"}`+"\n"+`{"msg":"two"}`+"\n"+`{"msg":"partial`)
|
||||||
|
f.poll(now.Add(time.Second))
|
||||||
|
got := sink.take()
|
||||||
|
if strings.Join(msgs(got), "|") != "one|two" || got[0].SevNum != 3 {
|
||||||
|
t.Fatalf("got %q", msgs(got))
|
||||||
|
}
|
||||||
|
src := f.Status()["sources"].([]map[string]any)
|
||||||
|
if src[0]["detected"] != fmtJSON || src[0]["open"] != true || src[1]["code"] != "not_found" {
|
||||||
|
t.Errorf("status %+v", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced format: the JSON lines are read as text.
|
||||||
|
cfg.Sources[0].Format = fmtText
|
||||||
|
f.Configure(cfg)
|
||||||
|
appendFile(t, path, `"}`+"\n")
|
||||||
|
f.poll(now.Add(2 * time.Second))
|
||||||
|
f.poll(now.Add(5 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != `{"msg":"partial"}` {
|
||||||
|
t.Fatalf("forced: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turned off then on: starts over (only new lines here).
|
||||||
|
cfg.Sources[0].Enabled = false
|
||||||
|
f.Configure(cfg)
|
||||||
|
f.poll(now.Add(6 * time.Second))
|
||||||
|
appendFile(t, path, "while off\n")
|
||||||
|
cfg.Sources[0].Enabled = true
|
||||||
|
f.Configure(cfg)
|
||||||
|
f.poll(now.Add(7 * time.Second))
|
||||||
|
appendFile(t, path, "three\n")
|
||||||
|
f.poll(now.Add(8 * time.Second))
|
||||||
|
f.poll(now.Add(11 * time.Second))
|
||||||
|
if got = sink.take(); strings.Join(msgs(got), "|") != "three" {
|
||||||
|
t.Fatalf("re-enabled: got %q", msgs(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large access log: the last hour is found by date, not read from the
|
||||||
|
// start, and every entry is counted from the first poll.
|
||||||
|
func TestFilesBackfillLargeFile(t *testing.T) {
|
||||||
|
dir, data := t.TempDir(), t.TempDir()
|
||||||
|
path := filepath.Join(dir, "access.log")
|
||||||
|
now := time.Now()
|
||||||
|
line := func(at time.Time, n int) string {
|
||||||
|
return fmt.Sprintf(`86.246.223.62 - - [%s] "GET /wp-content/x.png HTTP/2.0" 200 640365 "-" "-" %d "vblog-io-router@file" "http://wordpress" 3ms`+"\n",
|
||||||
|
at.UTC().Format("02/Jan/2006:15:04:05 -0700"), n)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
const n = 200000 // one line every 3 s for a week: ~32 MB
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
b.WriteString(line(now.Add(-time.Duration(n-i)*3*time.Second), i))
|
||||||
|
}
|
||||||
|
appendFile(t, path, b.String())
|
||||||
|
sink := &fileSink{}
|
||||||
|
f := NewFiles(dir, data, time.Hour, sink.add)
|
||||||
|
f.Configure(filesConfig{Sources: []fileSourceDef{{ID: "a1", Name: "traefik-log", Path: path, Enabled: true}}})
|
||||||
|
f.poll(now)
|
||||||
|
if pos := f.st.Pos["a1"]; pos.Off < int64(b.Len())*99/100 {
|
||||||
|
t.Errorf("history read from offset %d of %d", pos.Off, b.Len())
|
||||||
|
}
|
||||||
|
appendFile(t, path, line(now, n))
|
||||||
|
f.poll(now.Add(time.Second))
|
||||||
|
got := sink.take()
|
||||||
|
// The last hour (1200 lines, the oldest one on the edge) and the new line.
|
||||||
|
if len(got) < 1200 || len(got) > 1201 || got[0].App != "traefik-log" || got[len(got)-1].Extra["http_status"] != "200" {
|
||||||
|
t.Fatalf("got %d entries", len(got))
|
||||||
|
}
|
||||||
|
if st := f.Status()["sources"].([]map[string]any)[0]; st["read"] != uint64(len(got)) || st["behind"] != int64(0) || st["detected"] != fmtCLF {
|
||||||
|
t.Errorf("status %+v", st)
|
||||||
|
}
|
||||||
|
// Undated plain text: only new lines.
|
||||||
|
txt := filepath.Join(dir, "app.log")
|
||||||
|
appendFile(t, txt, strings.Repeat("no date here\n", 1000))
|
||||||
|
p := mustParser(t, fmtText, "")
|
||||||
|
if off := backfillStart(txt, p, 13000, now.Add(-time.Hour), now); off != 13000 {
|
||||||
|
t.Errorf("undated: %d", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
{"_time":"2000-01-01T00:00:00Z","severity":"info","hits":"9"}
|
||||||
`, recent.Format(time.RFC3339), recent.Format(time.RFC3339), older.Format(time.RFC3339))
|
`, 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
|
store.streamClient.Transport = vl
|
||||||
a := &API{store: store}
|
a := &API{store: store}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ func TestHistogramDayInParis(t *testing.T) {
|
|||||||
{"_time":"2026-09-21T20:00:00Z","severity":"info","hits":"100"}
|
{"_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
|
store.streamClient.Transport = vl
|
||||||
a := &API{store: store}
|
a := &API{store: store}
|
||||||
from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
|
from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
+7
-2
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -116,14 +115,18 @@ func (h *HostLogs) saveState() {
|
|||||||
}
|
}
|
||||||
h.dirty = false
|
h.dirty = false
|
||||||
if err := writeJSONFile(h.statePath, h.pos); err != nil {
|
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) {
|
func (h *HostLogs) setStatus(mode string, files int, code, detail string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
changed := code != h.errCode
|
||||||
h.mode, h.files, h.errCode, h.errDetail = mode, files, code, detail
|
h.mode, h.files, h.errCode, h.errDetail = mode, files, code, detail
|
||||||
h.mu.Unlock()
|
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.
|
// 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.SourceType = "host"
|
||||||
e.Extra = map[string]string{"log_file": "/var/log/" + name}
|
e.Extra = map[string]string{"log_file": "/var/log/" + name}
|
||||||
|
e.Wait = true
|
||||||
h.sink(e)
|
h.sink(e)
|
||||||
h.count(1, 0)
|
h.count(1, 0)
|
||||||
}
|
}
|
||||||
@@ -400,6 +404,7 @@ func (h *HostLogs) emitJournal(je *journalEntry) {
|
|||||||
Proto: "journal",
|
Proto: "journal",
|
||||||
SourceType: "host",
|
SourceType: "host",
|
||||||
Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]},
|
Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]},
|
||||||
|
Wait: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/logs: incremental reading of the stored logs for automation
|
||||||
|
// tools (n8n, scripts). Each answer gives the rows that follow a cursor,
|
||||||
|
// oldest first, and the cursor to send next time; following the cursors
|
||||||
|
// returns every row once, without gaps or duplicates.
|
||||||
|
//
|
||||||
|
// The cursor is a position on _time, which is the reception time given by
|
||||||
|
// LogStream (see Entry.Record), so new rows always come after it. Rows only
|
||||||
|
// count once they are older than the lag (30 s by default): a message can
|
||||||
|
// wait a little in the queue or for its DNS name before VictoriaLogs makes it
|
||||||
|
// visible. Several rows can share the same _time to the nanosecond; inside
|
||||||
|
// such a group the rows are ordered by a hash of their content, and the
|
||||||
|
// cursor remembers how many of them were already returned.
|
||||||
|
|
||||||
|
const (
|
||||||
|
logsAPIDefaultLimit = 1000
|
||||||
|
logsAPIMaxLimit = 10000
|
||||||
|
logsAPIDefaultLag = 30 * time.Second
|
||||||
|
logsAPIMaxLag = time.Hour
|
||||||
|
logsAPIDefaultSince = time.Hour
|
||||||
|
logsAPIMaxGroup = 100000 // rows with the very same _time
|
||||||
|
)
|
||||||
|
|
||||||
|
// logsCursor: every row with _time < T has been returned, plus the first K
|
||||||
|
// rows (in hash order) of those with _time == T. Q is a hash of the query:
|
||||||
|
// K only means something for the same query.
|
||||||
|
type logsCursor struct {
|
||||||
|
T time.Time `json:"t"`
|
||||||
|
K int `json:"k,omitempty"`
|
||||||
|
Q string `json:"q,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c logsCursor) encode() string {
|
||||||
|
b, _ := json.Marshal(c)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCursor(s string) (logsCursor, bool) {
|
||||||
|
var c logsCursor
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil || json.Unmarshal(b, &c) != nil || c.T.IsZero() || c.K < 0 {
|
||||||
|
return logsCursor{}, false
|
||||||
|
}
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSince reads the since parameter: a cursor from a previous answer, an
|
||||||
|
// RFC 3339 time, or a duration back from now (90s, 15m, 6h, 7d). Empty: the
|
||||||
|
// last hour.
|
||||||
|
func parseSince(s string, now time.Time) (logsCursor, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return logsCursor{T: now.Add(-logsAPIDefaultSince)}, nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
|
return logsCursor{T: t}, nil
|
||||||
|
}
|
||||||
|
if d, ok := parseAge(s); ok {
|
||||||
|
return logsCursor{T: now.Add(-d)}, nil
|
||||||
|
}
|
||||||
|
if c, ok := decodeCursor(s); ok {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
return logsCursor{}, &codedError{code: "bad_since", msg: "since: expected a next_cursor, an RFC 3339 time or a duration such as 15m, 6h or 7d"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAge reads a Go duration, plus "Nd" for days.
|
||||||
|
func parseAge(s string) (time.Duration, bool) {
|
||||||
|
if n, ok := strings.CutSuffix(s, "d"); ok {
|
||||||
|
days, err := strconv.Atoi(n)
|
||||||
|
if err != nil || days < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return time.Duration(days) * 24 * time.Hour, true
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
return d, err == nil && d >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type logsPage struct {
|
||||||
|
Rows []map[string]any `json:"rows"`
|
||||||
|
NextCursor string `json:"next_cursor"`
|
||||||
|
HasMore bool `json:"has_more"` // call again right away: more rows are ready
|
||||||
|
Waiting bool `json:"waiting,omitempty"` // the disk buffer is still being sent to VictoriaLogs
|
||||||
|
Until string `json:"until"` // rows are returned up to this time (now - lag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/logs?query=<LogsQL filter>&since=<cursor|time|duration>&limit=<n>&lag=<duration>
|
||||||
|
func (a *API) logsV1(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
filter := strings.TrimSpace(q.Get("query"))
|
||||||
|
if f, pipes := splitPipes(filter); strings.TrimSpace(pipes) != "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, &codedError{code: "api_pipes", msg: "query: LogsQL pipes (| ...) are not supported, keep only the filter part"})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
filter = strings.TrimSpace(f)
|
||||||
|
}
|
||||||
|
if filter == "" {
|
||||||
|
filter = "*"
|
||||||
|
}
|
||||||
|
limit := logsAPIDefaultLimit
|
||||||
|
if v := q.Get("limit"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 || n > logsAPIMaxLimit {
|
||||||
|
writeErr(w, http.StatusBadRequest, &codedError{code: "bad_limit", msg: fmt.Sprintf("limit: expected 1 to %d", logsAPIMaxLimit)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
lag := logsAPIDefaultLag
|
||||||
|
if v := q.Get("lag"); v != "" {
|
||||||
|
d, ok := parseAge(v)
|
||||||
|
if !ok || d > logsAPIMaxLag {
|
||||||
|
writeErr(w, http.StatusBadRequest, &codedError{code: "bad_lag", msg: "lag: expected a duration from 0s to 1h"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lag = d
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
cur, err := parseSince(q.Get("since"), now)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qh := queryHash(filter)
|
||||||
|
if cur.Q != qh {
|
||||||
|
cur.K = 0 // the query changed: the rank inside the group no longer applies
|
||||||
|
}
|
||||||
|
cur.Q = qh
|
||||||
|
|
||||||
|
// Logs waiting in the disk buffer were received earlier: returning newer
|
||||||
|
// rows now would move the cursor past them.
|
||||||
|
if a.store.PendingSpool() > 0 {
|
||||||
|
writeJSON(w, http.StatusOK, logsPage{Rows: []map[string]any{}, NextCursor: cur.encode(), Waiting: true, Until: logsqlTime(cur.T)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until := now.Add(-lag)
|
||||||
|
page, err := readLogsPage(r.Context(), a.store.Query, filter, cur, until, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, queryStatus(err), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.annotateHosts(page.Rows)
|
||||||
|
writeJSON(w, http.StatusOK, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryHash(filter string) string {
|
||||||
|
sum := sha256.Sum256([]byte(filter))
|
||||||
|
return hex.EncodeToString(sum[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryFunc func(ctx context.Context, query string) ([]map[string]any, error)
|
||||||
|
|
||||||
|
// readLogsPage returns at most limit rows after cur, up to until included.
|
||||||
|
func readLogsPage(ctx context.Context, run queryFunc, filter string, cur logsCursor, until time.Time, limit int) (logsPage, error) {
|
||||||
|
page := logsPage{Rows: []map[string]any{}, Until: logsqlTime(until)}
|
||||||
|
next := cur
|
||||||
|
start := cur.T
|
||||||
|
if cur.K > 0 {
|
||||||
|
// Rest of the group the previous page stopped in.
|
||||||
|
group, err := readGroup(ctx, run, filter, cur.T)
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
rest := group[min(cur.K, len(group)):]
|
||||||
|
if len(rest) >= limit {
|
||||||
|
page.Rows = rest[:limit]
|
||||||
|
next.K = cur.K + limit
|
||||||
|
page.HasMore = true
|
||||||
|
page.NextCursor = next.encode()
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
page.Rows = append(page.Rows, rest...)
|
||||||
|
start = cur.T.Add(time.Nanosecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
left := limit - len(page.Rows)
|
||||||
|
var rows []map[string]any
|
||||||
|
if !start.After(until) {
|
||||||
|
q := fmt.Sprintf("_time:[%s, %s] (%s) | sort by (_time) | limit %d", logsqlTime(start), logsqlTime(until), filter, left+1)
|
||||||
|
var err error
|
||||||
|
if rows, err = run(ctx, q); err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) <= left {
|
||||||
|
// Everything up to until has been read.
|
||||||
|
page.Rows = append(page.Rows, rows...)
|
||||||
|
next = logsCursor{T: until.Add(time.Nanosecond), Q: cur.Q}
|
||||||
|
if next.T.Before(start) {
|
||||||
|
next.T = start
|
||||||
|
}
|
||||||
|
page.NextCursor = next.encode()
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row too many: the group of the last time may be cut. Whole groups
|
||||||
|
// before it are returned; that last group starts the next page.
|
||||||
|
page.HasMore = true
|
||||||
|
last, err := rowTime(rows[left])
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for n < left {
|
||||||
|
t, err := rowTime(rows[n])
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
if !t.Before(last) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n > 0 || len(page.Rows) > 0 {
|
||||||
|
page.Rows = append(page.Rows, rows[:n]...)
|
||||||
|
page.NextCursor = logsCursor{T: last, Q: cur.Q}.encode()
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
// The whole page is a single group: read it all to cut it in a stable order.
|
||||||
|
group, err := readGroup(ctx, run, filter, last)
|
||||||
|
if err != nil {
|
||||||
|
return page, err
|
||||||
|
}
|
||||||
|
page.Rows = group[:min(left, len(group))]
|
||||||
|
page.NextCursor = logsCursor{T: last, K: len(page.Rows), Q: cur.Q}.encode()
|
||||||
|
return page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGroup returns the rows whose _time is exactly t, in a stable order.
|
||||||
|
func readGroup(ctx context.Context, run queryFunc, filter string, t time.Time) ([]map[string]any, error) {
|
||||||
|
ts := logsqlTime(t)
|
||||||
|
rows, err := run(ctx, fmt.Sprintf("_time:[%s, %s] (%s) | limit %d", ts, ts, filter, logsAPIMaxGroup+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) > logsAPIMaxGroup {
|
||||||
|
return nil, fmt.Errorf("more than %d logs share the time %s", logsAPIMaxGroup, ts)
|
||||||
|
}
|
||||||
|
keys := make([]string, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
b, _ := json.Marshal(row) // map keys are sorted: same row, same bytes
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
keys[i] = string(sum[:])
|
||||||
|
}
|
||||||
|
idx := make([]int, len(rows))
|
||||||
|
for i := range idx {
|
||||||
|
idx[i] = i
|
||||||
|
}
|
||||||
|
sort.SliceStable(idx, func(i, j int) bool { return keys[idx[i]] < keys[idx[j]] })
|
||||||
|
out := make([]map[string]any, len(rows))
|
||||||
|
for i, k := range idx {
|
||||||
|
out[i] = rows[k]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowTime(row map[string]any) (time.Time, error) {
|
||||||
|
s, _ := row["_time"].(string)
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, errors.New("VictoriaLogs returned a row without a valid _time")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cursorVL plays VictoriaLogs for the two queries of /api/v1/logs: a time
|
||||||
|
// range sorted by _time, and an exact time. Like VictoriaLogs, it gives no
|
||||||
|
// order among rows of the same _time: they are shuffled on every query.
|
||||||
|
type cursorVL struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rows []map[string]any
|
||||||
|
rnd *rand.Rand
|
||||||
|
queries []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var cursorQuery = regexp.MustCompile(`^_time:\[([^,]+), ([^\]]+)] \((.*?)\)( \| sort by \(_time\))? \| limit (\d+)$`)
|
||||||
|
|
||||||
|
func (v *cursorVL) add(t time.Time, msg string) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.rows = append(v.rows, map[string]any{"_time": t.UTC().Format(time.RFC3339Nano), "_msg": msg, "host": "h1"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *cursorVL) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
q := r.PostForm.Get("query")
|
||||||
|
m := cursorQuery.FindStringSubmatch(q)
|
||||||
|
if m == nil {
|
||||||
|
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader("unexpected query " + q)), Header: http.Header{}}, nil
|
||||||
|
}
|
||||||
|
from, _ := time.Parse(time.RFC3339Nano, m[1])
|
||||||
|
to, _ := time.Parse(time.RFC3339Nano, m[2])
|
||||||
|
limit, _ := strconv.Atoi(m[5])
|
||||||
|
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.queries = append(v.queries, q)
|
||||||
|
var out []map[string]any
|
||||||
|
for _, row := range v.rows {
|
||||||
|
t, _ := time.Parse(time.RFC3339Nano, row["_time"].(string))
|
||||||
|
if !t.Before(from) && !t.After(to) {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.rnd.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
|
||||||
|
if m[4] != "" {
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, _ := time.Parse(time.RFC3339Nano, out[i]["_time"].(string))
|
||||||
|
b, _ := time.Parse(time.RFC3339Nano, out[j]["_time"].(string))
|
||||||
|
return a.Before(b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, row := range out {
|
||||||
|
line, _ := json.Marshal(row)
|
||||||
|
b.Write(line)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(b.String())), Header: http.Header{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCursorVL(seed int64) (*cursorVL, *Store) {
|
||||||
|
vl := &cursorVL{rnd: rand.New(rand.NewSource(seed))}
|
||||||
|
store := NewStore("http://vl", 10, 10, time.Second, nil)
|
||||||
|
store.client.Transport = vl
|
||||||
|
store.streamClient.Transport = vl
|
||||||
|
return vl, store
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill adds rows by groups of the same _time, some groups larger than a
|
||||||
|
// page, some with identical rows, and returns the multiset of the rows.
|
||||||
|
func fill(vl *cursorVL, start time.Time, groups []int, tag string) map[string]int {
|
||||||
|
want := map[string]int{}
|
||||||
|
t := start
|
||||||
|
for g, size := range groups {
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
msg := fmt.Sprintf("%s g%d r%d", tag, g, i%4) // r0..r3 repeat: identical rows
|
||||||
|
vl.add(t, msg)
|
||||||
|
want[rowKey(t, msg)]++
|
||||||
|
}
|
||||||
|
t = t.Add(time.Duration(1+g%3) * time.Microsecond)
|
||||||
|
}
|
||||||
|
return want
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowKey(t time.Time, msg string) string { return t.UTC().Format(time.RFC3339Nano) + " " + msg }
|
||||||
|
|
||||||
|
func TestLogsCursorNoLossNoDuplicate(t *testing.T) {
|
||||||
|
base := time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
groups := []int{1, 3, 1, 1, 7, 2, 1, 12, 1, 4, 4, 1, 25, 1, 2}
|
||||||
|
for _, limit := range []int{1, 2, 3, 5, 8, 1000} {
|
||||||
|
for seed := int64(1); seed <= 5; seed++ {
|
||||||
|
vl, store := newCursorVL(seed)
|
||||||
|
want := fill(vl, base, groups, "a")
|
||||||
|
got := map[string]int{}
|
||||||
|
cur := logsCursor{T: base.Add(-time.Minute)}
|
||||||
|
until := base.Add(time.Second)
|
||||||
|
var last time.Time
|
||||||
|
for calls := 0; ; calls++ {
|
||||||
|
if calls > 500 {
|
||||||
|
t.Fatalf("limit %d: no end", limit)
|
||||||
|
}
|
||||||
|
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, limit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) > limit {
|
||||||
|
t.Fatalf("limit %d: page of %d rows", limit, len(page.Rows))
|
||||||
|
}
|
||||||
|
for _, row := range page.Rows {
|
||||||
|
rt, _ := rowTime(row)
|
||||||
|
if rt.Before(last) {
|
||||||
|
t.Fatalf("limit %d: rows out of order (%s after %s)", limit, rt, last)
|
||||||
|
}
|
||||||
|
last = rt
|
||||||
|
got[rowKey(rt, row["_msg"].(string))]++
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
if cur, ok = decodeCursor(page.NextCursor); !ok {
|
||||||
|
t.Fatalf("bad cursor %q", page.NextCursor)
|
||||||
|
}
|
||||||
|
if !page.HasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(page.Rows) == 0 {
|
||||||
|
t.Fatalf("limit %d: has_more with an empty page", limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("limit %d seed %d:\n got %v\nwant %v", limit, seed, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New logs arrive between the calls, as with a scheduled n8n workflow.
|
||||||
|
func TestLogsCursorFollowsNewLogs(t *testing.T) {
|
||||||
|
vl, store := newCursorVL(7)
|
||||||
|
base := time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
want := map[string]int{}
|
||||||
|
got := map[string]int{}
|
||||||
|
cur := logsCursor{T: base}
|
||||||
|
for round := 0; round < 6; round++ {
|
||||||
|
start := base.Add(time.Duration(round) * time.Second)
|
||||||
|
for k, n := range fill(vl, start, []int{2, 5, 1, 3}, fmt.Sprintf("round%d", round)) {
|
||||||
|
want[k] += n
|
||||||
|
}
|
||||||
|
until := start.Add(500 * time.Millisecond) // after this round's rows
|
||||||
|
for {
|
||||||
|
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, row := range page.Rows {
|
||||||
|
rt, _ := rowTime(row)
|
||||||
|
got[rowKey(rt, row["_msg"].(string))]++
|
||||||
|
}
|
||||||
|
cur, _ = decodeCursor(page.NextCursor)
|
||||||
|
if !page.HasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Polling again with nothing new returns nothing.
|
||||||
|
page, err := readLogsPage(context.Background(), store.Query, "*", cur, until, 4)
|
||||||
|
if err != nil || len(page.Rows) != 0 || page.HasMore {
|
||||||
|
t.Fatalf("round %d: second poll returned %d rows (has_more %v, err %v)", round, len(page.Rows), page.HasMore, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSince(t *testing.T) {
|
||||||
|
now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
c := logsCursor{T: now.Add(-time.Minute), K: 3, Q: "abcd"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
want logsCursor
|
||||||
|
}{
|
||||||
|
{"", logsCursor{T: now.Add(-time.Hour)}},
|
||||||
|
{"15m", logsCursor{T: now.Add(-15 * time.Minute)}},
|
||||||
|
{"7d", logsCursor{T: now.Add(-7 * 24 * time.Hour)}},
|
||||||
|
{"2026-11-01T08:30:00.5Z", logsCursor{T: time.Date(2026, 11, 1, 8, 30, 0, 5e8, time.UTC)}},
|
||||||
|
{c.encode(), c},
|
||||||
|
} {
|
||||||
|
got, err := parseSince(tc.in, now)
|
||||||
|
if err != nil || !got.T.Equal(tc.want.T) || got.K != tc.want.K || got.Q != tc.want.Q {
|
||||||
|
t.Errorf("parseSince(%q) = %+v, %v; want %+v", tc.in, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"yesterday", "-5m", "e30"} {
|
||||||
|
if _, err := parseSince(bad, now); err == nil {
|
||||||
|
t.Errorf("parseSince(%q): expected an error", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsV1Handler(t *testing.T) {
|
||||||
|
vl, store := newCursorVL(3)
|
||||||
|
a := &API{store: store, rdns: NewReverseDNS(false, "")}
|
||||||
|
old := time.Now().Add(-10 * time.Minute)
|
||||||
|
fill(vl, old, []int{2, 1}, "x")
|
||||||
|
vl.add(time.Now().Add(time.Hour), "in the future: after until")
|
||||||
|
|
||||||
|
get := func(url string) (int, logsPage, map[string]string) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.logsV1(rec, httptest.NewRequest("GET", url, nil))
|
||||||
|
var page logsPage
|
||||||
|
var e map[string]string
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &e)
|
||||||
|
return rec.Code, page, e
|
||||||
|
}
|
||||||
|
code, page, _ := get("/api/v1/logs?query=host:h1&since=1h")
|
||||||
|
if code != http.StatusOK || len(page.Rows) != 3 || page.HasMore || page.NextCursor == "" {
|
||||||
|
t.Fatalf("status %d, %d rows, has_more %v", code, len(page.Rows), page.HasMore)
|
||||||
|
}
|
||||||
|
if q := vl.queries[len(vl.queries)-1]; !strings.Contains(q, "(host:h1)") {
|
||||||
|
t.Fatalf("query filter not applied: %s", q)
|
||||||
|
}
|
||||||
|
code, page, _ = get("/api/v1/logs?query=host:h1&since=" + page.NextCursor)
|
||||||
|
if code != http.StatusOK || len(page.Rows) != 0 {
|
||||||
|
t.Fatalf("second call: status %d, %d rows", code, len(page.Rows))
|
||||||
|
}
|
||||||
|
for url, wantCode := range map[string]string{
|
||||||
|
"/api/v1/logs?query=" + "*%20|%20stats%20count()": "api_pipes",
|
||||||
|
"/api/v1/logs?limit=0": "bad_limit",
|
||||||
|
"/api/v1/logs?limit=20000": "bad_limit",
|
||||||
|
"/api/v1/logs?lag=2h": "bad_lag",
|
||||||
|
"/api/v1/logs?since=nope": "bad_since",
|
||||||
|
} {
|
||||||
|
if code, _, e := get(url); code != http.StatusBadRequest || e["code"] != wantCode {
|
||||||
|
t.Errorf("%s: status %d, %v; want 400 %s", url, code, e, wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsV1WaitsForSpool(t *testing.T) {
|
||||||
|
spool, err := OpenSpool(t.TempDir(), 1<<20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := spool.Write([]byte(`{"_msg":"x"}`+"\n"), 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vl, _ := newCursorVL(1)
|
||||||
|
store := NewStore("http://vl", 10, 10, time.Second, spool)
|
||||||
|
store.streamClient.Transport = vl
|
||||||
|
a := &API{store: store}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.logsV1(rec, httptest.NewRequest("GET", "/api/v1/logs?since=2026-11-01T10:00:00Z", nil))
|
||||||
|
var page logsPage
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
|
c, _ := decodeCursor(page.NextCursor)
|
||||||
|
if rec.Code != http.StatusOK || !page.Waiting || len(page.Rows) != 0 || !c.T.Equal(time.Date(2026, 11, 1, 10, 0, 0, 0, time.UTC)) || len(vl.queries) != 0 {
|
||||||
|
t.Fatalf("status %d, waiting %v, %d rows, cursor %v, %d queries", rec.Code, page.Waiting, len(page.Rows), c.T, len(vl.queries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,9 +38,16 @@ type config struct {
|
|||||||
backfill time.Duration
|
backfill time.Duration
|
||||||
hostRoot string
|
hostRoot string
|
||||||
hostFill time.Duration
|
hostFill time.Duration
|
||||||
|
kmsgDevice string
|
||||||
|
kmsgFill time.Duration
|
||||||
|
auditLog string
|
||||||
|
auditHost string
|
||||||
|
auditFill time.Duration
|
||||||
|
filesFill time.Duration
|
||||||
batchSize int
|
batchSize int
|
||||||
queueSize int
|
queueSize int
|
||||||
flushEvery time.Duration
|
flushEvery time.Duration
|
||||||
|
spoolMax int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func getenv(key, def string) string {
|
func getenv(key, def string) string {
|
||||||
@@ -57,6 +64,14 @@ func getenvInt(key string, def int) int {
|
|||||||
return def
|
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 {
|
func getenvBool(key string, def bool) bool {
|
||||||
switch strings.ToLower(os.Getenv(key)) {
|
switch strings.ToLower(os.Getenv(key)) {
|
||||||
case "1", "true", "yes", "on":
|
case "1", "true", "yes", "on":
|
||||||
@@ -84,6 +99,10 @@ func main() {
|
|||||||
mode: getenv("AUTH_MODE", "local"),
|
mode: getenv("AUTH_MODE", "local"),
|
||||||
user: os.Getenv("AUTH_USER"),
|
user: os.Getenv("AUTH_USER"),
|
||||||
pass: os.Getenv("AUTH_PASS"),
|
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"),
|
issuer: os.Getenv("OIDC_ISSUER"),
|
||||||
clientID: os.Getenv("OIDC_CLIENT_ID"),
|
clientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
@@ -95,16 +114,23 @@ func main() {
|
|||||||
},
|
},
|
||||||
rdns: getenvBool("RDNS", true),
|
rdns: getenvBool("RDNS", true),
|
||||||
dnsServer: os.Getenv("DNS_SERVER"),
|
dnsServer: os.Getenv("DNS_SERVER"),
|
||||||
allowPurge: getenvBool("ALLOW_PURGE", true),
|
allowPurge: getenvBool("ALLOW_PURGE", false),
|
||||||
exportMax: getenvInt("EXPORT_MAX", 100000),
|
exportMax: getenvInt("EXPORT_MAX", 100000),
|
||||||
dockerLogs: getenvBool("DOCKER_LOGS", false),
|
dockerLogs: getenvBool("DOCKER_LOGS", false),
|
||||||
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||||
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
|
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
|
||||||
hostRoot: getenv("HOST_LOGS_ROOT", "/host"),
|
hostRoot: getenv("HOST_LOGS_ROOT", "/host"),
|
||||||
hostFill: getenvDuration("HOST_LOGS_BACKFILL", time.Hour),
|
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),
|
||||||
|
filesFill: getenvDuration("FILES_BACKFILL", time.Hour),
|
||||||
batchSize: getenvInt("BATCH_SIZE", 1000),
|
batchSize: getenvInt("BATCH_SIZE", 1000),
|
||||||
queueSize: getenvInt("QUEUE_SIZE", 100000),
|
queueSize: getenvInt("QUEUE_SIZE", 100000),
|
||||||
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
||||||
|
spoolMax: int64(getenvIntZero("SPOOL_MAX_MB", 1024)) << 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.auth.dataDir = cfg.dataDir
|
cfg.auth.dataDir = cfg.dataDir
|
||||||
@@ -116,11 +142,24 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("tags: %v", err)
|
log.Fatalf("tags: %v", err)
|
||||||
}
|
}
|
||||||
|
tokens, err := LoadTokenStore(filepath.Join(cfg.dataDir, "api_tokens.json"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("api tokens: %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{})
|
storeDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
store.Run(ctx)
|
store.Run(storeCtx)
|
||||||
close(storeDone)
|
close(storeDone)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -128,12 +167,33 @@ func main() {
|
|||||||
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
|
rdns := NewReverseDNS(cfg.rdns, cfg.dnsServer)
|
||||||
sink := func(e *Entry) {
|
sink := func(e *Entry) {
|
||||||
// Host sent as an IP (or no host in the header): replace it with its DNS name.
|
// 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.
|
// A new IP waits at most 300 ms, without holding up the listener; slower
|
||||||
if name := rdns.Lookup(e.Host, 300*time.Millisecond); name != "" {
|
// lookups finish in the background.
|
||||||
e.HostIP, e.Host = e.Host, name
|
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)
|
store.Enqueue(e)
|
||||||
hub.Publish(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.
|
// Listening errors (port already used…) are shown in Settings > Sources.
|
||||||
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
|
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
|
||||||
@@ -144,11 +204,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json"))
|
presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json"))
|
||||||
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv}
|
api := &API{store: store, hub: hub, tags: tags, presets: presets, rdns: rdns, allowPurge: cfg.allowPurge, exportMax: cfg.exportMax, syslog: syslogSrv, tokens: tokens}
|
||||||
if cfg.dockerLogs {
|
if cfg.dockerLogs {
|
||||||
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
dm, err := NewDockerManager(cfg.dockerHost, cfg.dataDir, cfg.backfill, sink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("docker logs disabled: %v", err)
|
logEvent("docker", 3, "Docker logs disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
go dm.Run(ctx)
|
go dm.Run(ctx)
|
||||||
api.docker = dm
|
api.docker = dm
|
||||||
@@ -158,20 +218,41 @@ func main() {
|
|||||||
// System logs of the host (journal or /var/log), off until enabled in Settings > Sources.
|
// 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)
|
api.host = NewHostLogs(cfg.hostRoot, cfg.dataDir, cfg.hostFill, sink)
|
||||||
go api.host.Run(ctx)
|
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)
|
||||||
|
// Log files of other programs (Traefik, nginx…) mounted in the container,
|
||||||
|
// declared in Settings > Sources.
|
||||||
|
api.files = NewFiles(cfg.hostRoot, cfg.dataDir, cfg.filesFill, sink)
|
||||||
|
go api.files.Run(ctx)
|
||||||
|
api.selfLog = selfLog
|
||||||
api.Routes(mux)
|
api.Routes(mux)
|
||||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
mux.Handle("GET /", staticFiles(static))
|
||||||
|
|
||||||
handler, err := newAuth(cfg.auth, mux)
|
app := readOnly(mux)
|
||||||
|
handler, err := newAuth(cfg.auth, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("auth: %v", err)
|
log.Fatalf("auth: %v", err)
|
||||||
}
|
}
|
||||||
if o, ok := handler.(*OIDC); ok {
|
if o, ok := handler.(*OIDC); ok {
|
||||||
go o.checkProvider()
|
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)
|
||||||
|
}
|
||||||
|
// API tokens (Settings > API) are checked before the login, in every auth mode.
|
||||||
|
handler = secure(tokenAuth(tokens, handler, app), contentSecurityPolicy(static), authOn)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.httpAddr,
|
Addr: cfg.httpAddr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 2 * time.Minute,
|
||||||
// Requests inherit the global context so SSE streams end on shutdown.
|
// Requests inherit the global context so SSE streams end on shutdown.
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
}
|
}
|
||||||
@@ -186,6 +267,10 @@ func main() {
|
|||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("http: %v", err)
|
log.Fatalf("http: %v", err)
|
||||||
}
|
}
|
||||||
|
logEvent("main", 5, "LogStream stopping")
|
||||||
|
stopSelf()
|
||||||
|
<-selfDone
|
||||||
|
stopStore()
|
||||||
<-storeDone
|
<-storeDone
|
||||||
log.Println("shutdown complete")
|
log.Println("shutdown complete")
|
||||||
}
|
}
|
||||||
@@ -119,10 +119,10 @@ func (f Filter) filterExpr() string {
|
|||||||
parts = append(parts, "app:="+strconv.Quote(f.App))
|
parts = append(parts, "app:="+strconv.Quote(f.App))
|
||||||
}
|
}
|
||||||
switch f.Source {
|
switch f.Source {
|
||||||
case "docker", "host":
|
case "docker", "host", "kernel", "audit", "logstream", "file":
|
||||||
parts = append(parts, `source_type:=`+strconv.Quote(f.Source))
|
parts = append(parts, `source_type:=`+strconv.Quote(f.Source))
|
||||||
case "syslog": // also matches logs stored before source_type existed
|
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","file"))`)
|
||||||
}
|
}
|
||||||
if f.Severity >= 0 && f.Severity < 7 {
|
if f.Severity >= 0 && f.Severity < 7 {
|
||||||
names := make([]string, 0, 8)
|
names := make([]string, 0, 8)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,17 +16,26 @@ type ReverseDNS struct {
|
|||||||
posTTL time.Duration // cache duration of a found name
|
posTTL time.Duration // cache duration of a found name
|
||||||
negTTL time.Duration // cache duration of "no 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
|
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 {
|
type rdnsEntry struct {
|
||||||
|
ip string
|
||||||
name string
|
name string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
done chan struct{} // closed once the lookup has finished
|
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.
|
// NewReverseDNS uses the system resolver, or `server` ("ip" or "ip:port") when set.
|
||||||
func NewReverseDNS(enabled bool, server string) *ReverseDNS {
|
func NewReverseDNS(enabled bool, server string) *ReverseDNS {
|
||||||
@@ -47,7 +57,10 @@ func NewReverseDNS(enabled bool, server string) *ReverseDNS {
|
|||||||
r: r,
|
r: r,
|
||||||
posTTL: time.Hour,
|
posTTL: time.Hour,
|
||||||
negTTL: 10 * time.Minute,
|
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
|
// 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
|
// 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.
|
// 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 {
|
if !d.enabled || net.ParseIP(ip) == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
d.mu.Lock()
|
e := d.entry(ip)
|
||||||
e := d.cache[ip]
|
if e == nil {
|
||||||
if e == nil || (isClosed(e.done) && time.Now().After(e.expires)) {
|
return ""
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
|
||||||
|
|
||||||
if !isClosed(e.done) {
|
if !isClosed(e.done) {
|
||||||
timer := time.NewTimer(wait)
|
timer := time.NewTimer(wait)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
@@ -91,6 +128,43 @@ func (d *ReverseDNS) Lookup(ip string, wait time.Duration) string {
|
|||||||
return e.name
|
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.
|
// LookupMany resolves several addresses in parallel; unresolved ones are absent.
|
||||||
func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string {
|
func (d *ReverseDNS) LookupMany(ips []string, wait time.Duration) map[string]string {
|
||||||
out := make(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) {
|
func (d *ReverseDNS) resolve(ip string, e *rdnsEntry) {
|
||||||
|
defer func() { <-d.lookups }()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ttl := d.negTTL
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticFiles serves the embedded UI with an ETag per file (a hash of its
|
||||||
|
// content, computed at startup). Pages, scripts and styles are revalidated on
|
||||||
|
// every load ("no-cache"): an unchanged file costs a 304 without body, and the
|
||||||
|
// files of a new version are always taken after an update. Fonts are kept for
|
||||||
|
// 30 days.
|
||||||
|
func staticFiles(static fs.FS) http.Handler {
|
||||||
|
etags := map[string]string{}
|
||||||
|
_ = fs.WalkDir(static, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(static, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
etags["/"+path] = `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
etags["/"] = etags["/index.html"]
|
||||||
|
files := http.FileServer(http.FS(static))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if tag, ok := etags[r.URL.Path]; ok {
|
||||||
|
// http.FileServer answers If-None-Match with 304 from this header.
|
||||||
|
w.Header().Set("ETag", tag)
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/fonts/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=2592000")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaticFilesCache(t *testing.T) {
|
||||||
|
h := staticFiles(fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte("<p>v1</p>")},
|
||||||
|
"app.js": {Data: []byte("v1")},
|
||||||
|
"fonts/mono.woff2": {Data: []byte("font")},
|
||||||
|
})
|
||||||
|
get := func(path, etag string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("GET", path, nil)
|
||||||
|
if etag != "" {
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
rec := get("/app.js", "")
|
||||||
|
tag := rec.Header().Get("ETag")
|
||||||
|
if rec.Code != http.StatusOK || tag == "" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||||
|
t.Fatalf("app.js: %d, ETag %q, Cache-Control %q", rec.Code, tag, rec.Header().Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
if rec := get("/app.js", tag); rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("revalidation: %d with %d bytes, want 304 without body", rec.Code, rec.Body.Len())
|
||||||
|
}
|
||||||
|
if rec := get("/app.js", `"old"`); rec.Code != http.StatusOK || rec.Body.String() != "v1" {
|
||||||
|
t.Fatalf("stale ETag: %d %q", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if rec := get("/", ""); rec.Code != http.StatusOK || rec.Header().Get("ETag") == "" || rec.Header().Get("Cache-Control") != "no-cache" {
|
||||||
|
t.Fatalf("index: %d %v", rec.Code, rec.Header())
|
||||||
|
}
|
||||||
|
if rec := get("/fonts/mono.woff2", ""); rec.Header().Get("Cache-Control") != "public, max-age=2592000" {
|
||||||
|
t.Fatalf("font Cache-Control %q", rec.Header().Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,43 +16,62 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Store sends messages to VictoriaLogs in batches and queries it with LogsQL.
|
// 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 {
|
type Store struct {
|
||||||
base string
|
base string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
streamClient *http.Client
|
streamClient *http.Client
|
||||||
in chan *Entry
|
in chan *Entry
|
||||||
batchSize int
|
quit chan struct{} // closed on shutdown: unblocks waiting producers
|
||||||
flushEvery time.Duration
|
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
|
received atomic.Int64
|
||||||
ingested atomic.Int64
|
ingested atomic.Int64
|
||||||
dropped atomic.Int64
|
dropped atomic.Int64
|
||||||
lastErr atomic.Value // string
|
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{
|
s := &Store{
|
||||||
base: strings.TrimRight(base, "/"),
|
base: strings.TrimRight(base, "/"),
|
||||||
client: &http.Client{Timeout: 60 * time.Second},
|
client: &http.Client{Timeout: 60 * time.Second},
|
||||||
// No global timeout: a large export can take longer than a minute. The
|
// No global timeout: a large export can take longer than a minute. The
|
||||||
// request context still cancels it when the browser goes away.
|
// request context still cancels it when the browser goes away.
|
||||||
streamClient: &http.Client{},
|
streamClient: &http.Client{},
|
||||||
in: make(chan *Entry, queueSize),
|
in: make(chan *Entry, queueSize),
|
||||||
batchSize: batchSize,
|
quit: make(chan struct{}),
|
||||||
flushEvery: flushEvery,
|
batchSize: batchSize,
|
||||||
|
flushEvery: flushEvery,
|
||||||
|
spool: spool,
|
||||||
|
spooled: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
s.lastErr.Store("")
|
s.lastErr.Store("")
|
||||||
return s
|
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) {
|
func (s *Store) Enqueue(e *Entry) {
|
||||||
s.received.Add(1)
|
s.received.Add(1)
|
||||||
select {
|
select {
|
||||||
case s.in <- e:
|
case s.in <- e:
|
||||||
|
return
|
||||||
default:
|
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,
|
// 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)
|
ticker := time.NewTicker(s.flushEvery)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
batch := make([]*Entry, 0, s.batchSize)
|
batch := make([]*Entry, 0, s.batchSize)
|
||||||
|
if s.spool != nil {
|
||||||
|
go s.replay(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
flush := func() {
|
flush := func(ctx context.Context) {
|
||||||
if len(batch) == 0 {
|
if len(batch) > 0 {
|
||||||
return
|
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 {
|
for {
|
||||||
@@ -83,20 +97,24 @@ func (s *Store) Run(ctx context.Context) {
|
|||||||
case e := <-s.in:
|
case e := <-s.in:
|
||||||
batch = append(batch, e)
|
batch = append(batch, e)
|
||||||
if len(batch) >= s.batchSize {
|
if len(batch) >= s.batchSize {
|
||||||
flush()
|
flush(ctx)
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
flush()
|
flush(ctx)
|
||||||
case <-ctx.Done():
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case e := <-s.in:
|
case e := <-s.in:
|
||||||
batch = append(batch, e)
|
batch = append(batch, e)
|
||||||
if len(batch) >= s.batchSize {
|
if len(batch) >= s.batchSize {
|
||||||
flush()
|
flush(end)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
flush()
|
flush(end)
|
||||||
return
|
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
|
var buf bytes.Buffer
|
||||||
enc := json.NewEncoder(&buf)
|
enc := json.NewEncoder(&buf)
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
for _, e := range batch {
|
for _, e := range batch {
|
||||||
if err := enc.Encode(e.Record()); err != nil {
|
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"
|
return buf.Bytes(), nil
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) post(u string, body []byte) error {
|
// insertError is an error status of VictoriaLogs to an insert.
|
||||||
resp, err := s.client.Post(u, "application/stream+json", bytes.NewReader(body))
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
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)
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
return nil
|
return nil
|
||||||
@@ -264,12 +423,25 @@ func queryStatus(err error) int {
|
|||||||
return http.StatusBadGateway
|
return http.StatusBadGateway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PendingSpool is the number of logs waiting in the disk spool.
|
||||||
|
func (s *Store) PendingSpool() int64 {
|
||||||
|
if s.spool == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lines, _ := s.spool.Pending()
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) Stats() map[string]any {
|
func (s *Store) Stats() map[string]any {
|
||||||
return map[string]any{
|
st := map[string]any{
|
||||||
"received": s.received.Load(),
|
"received": s.received.Load(),
|
||||||
"ingested": s.ingested.Load(),
|
"ingested": s.ingested.Load(),
|
||||||
"dropped": s.dropped.Load(),
|
"dropped": s.dropped.Load(),
|
||||||
"queue": len(s.in),
|
"queue": len(s.in),
|
||||||
"lastError": s.lastErr.Load(),
|
"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
|
Proto string // udp or tcp
|
||||||
HostIP string // original host value when it was an IP resolved through DNS
|
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", "file" or "logstream"
|
||||||
Extra map[string]string // additional fields (docker: container, image, …)
|
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.
|
// 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 {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("syslog udp: %v", err)
|
logEvent("syslog", 4, "UDP read error: %v", err)
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
continue
|
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)) {
|
func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
|
||||||
|
slots := make(chan struct{}, tcpMaxConns)
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("syslog tcp: %v", err)
|
logEvent("syslog", 4, "TCP accept error: %v", err)
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
continue
|
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
|
const maxFrame = 1 << 20
|
||||||
|
|
||||||
// handleTCP supports both RFC 6587 framings: octet counting
|
// 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()
|
defer stop()
|
||||||
|
|
||||||
src := hostOf(conn.RemoteAddr())
|
src := hostOf(conn.RemoteAddr())
|
||||||
r := bufio.NewReaderSize(conn, 64*1024)
|
r := bufio.NewReaderSize(idleConn{conn, tcpIdle}, 64*1024)
|
||||||
for {
|
for {
|
||||||
c, err := r.ReadByte()
|
c, err := r.ReadByte()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-2
@@ -94,7 +94,7 @@ func (s *SyslogServer) apply() {
|
|||||||
pc, err := net.ListenPacket("udp", s.addr)
|
pc, err := net.ListenPacket("udp", s.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.errUDP = err.Error()
|
s.errUDP = err.Error()
|
||||||
log.Printf("syslog udp: %v", err)
|
logEvent("syslog", 3, "UDP listener failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
if uc, ok := pc.(*net.UDPConn); ok {
|
if uc, ok := pc.(*net.UDPConn); ok {
|
||||||
_ = uc.SetReadBuffer(8 << 20)
|
_ = uc.SetReadBuffer(8 << 20)
|
||||||
@@ -108,7 +108,7 @@ func (s *SyslogServer) apply() {
|
|||||||
ln, err := net.Listen("tcp", s.addr)
|
ln, err := net.Listen("tcp", s.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.errTCP = err.Error()
|
s.errTCP = err.Error()
|
||||||
log.Printf("syslog tcp: %v", err)
|
logEvent("syslog", 3, "TCP listener failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
s.ln = ln
|
s.ln = ln
|
||||||
go serveTCP(ctx, ln, s.sink)
|
go serveTCP(ctx, ln, s.sink)
|
||||||
|
|||||||
@@ -217,6 +217,51 @@ func (s *TagStore) Delete(id string) error {
|
|||||||
return errTagNotFound
|
return errTagNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagListError tells which tag of a list (PUT /api/tags) is invalid.
|
||||||
|
type tagListError struct {
|
||||||
|
index int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *tagListError) Error() string { return e.err.Error() }
|
||||||
|
func (e *tagListError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// Replace saves a whole list at once (the Save button of the settings). A tag
|
||||||
|
// whose id is known keeps its code; the others get a new id and a free code.
|
||||||
|
// Nothing is saved when a tag is invalid: the error gives its position.
|
||||||
|
func (s *TagStore) Replace(in []Tag) ([]Tag, error) {
|
||||||
|
if len(in) > 99 {
|
||||||
|
return nil, errTooManyTags
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
codes := map[string]string{}
|
||||||
|
for _, t := range s.tags {
|
||||||
|
codes[t.ID] = t.Code
|
||||||
|
}
|
||||||
|
out := make([]Tag, len(in))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i, t := range in {
|
||||||
|
if err := t.validate(); err != nil {
|
||||||
|
return nil, &tagListError{index: i, err: err}
|
||||||
|
}
|
||||||
|
if c, ok := codes[t.ID]; ok && !seen[t.ID] {
|
||||||
|
t.Code = c
|
||||||
|
} else {
|
||||||
|
t.ID, t.Code = newID(), ""
|
||||||
|
}
|
||||||
|
seen[t.ID] = true
|
||||||
|
out[i] = t
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
if out[i].Code == "" {
|
||||||
|
out[i].Code = freeCode(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.tags = out
|
||||||
|
return append([]Tag(nil), out...), s.save()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TagStore) Reset() ([]Tag, error) {
|
func (s *TagStore) Reset() ([]Tag, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -52,3 +53,37 @@ func TestTagCodes(t *testing.T) {
|
|||||||
t.Fatalf("reloaded code = %q, want 07", got)
|
t.Fatalf("reloaded code = %q, want 07", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagReplace(t *testing.T) {
|
||||||
|
s, err := LoadTagStore(filepath.Join(t.TempDir(), "tags.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Defaults: warning 01, error 02. Keep error, drop warning, add two tags
|
||||||
|
// (one with an unknown id, one repeating a known id).
|
||||||
|
got, err := s.Replace([]Tag{
|
||||||
|
{ID: "new1", Pattern: "a", Color: "#000000"},
|
||||||
|
{ID: "error", Code: "42", Pattern: "error", Color: "#111111", Enabled: true},
|
||||||
|
{ID: "error", Pattern: "b", Color: "#000000"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codes := []string{got[0].Code, got[1].Code, got[2].Code}
|
||||||
|
if want := []string{"01", "02", "03"}; !slices.Equal(codes, want) {
|
||||||
|
t.Fatalf("codes = %v, want %v", codes, want)
|
||||||
|
}
|
||||||
|
if got[1].ID != "error" || got[0].ID == "new1" || got[2].ID == "error" || got[1].Color != "#111111" {
|
||||||
|
t.Fatalf("ids/fields = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid tag saves nothing and gives its position.
|
||||||
|
_, err = s.Replace([]Tag{{Pattern: "ok", Color: "#000000"}, {Pattern: "(", Regex: true, Color: "#000000"}})
|
||||||
|
var le *tagListError
|
||||||
|
if !errors.As(err, &le) || le.index != 1 {
|
||||||
|
t.Fatalf("err = %v, want tagListError at 1", err)
|
||||||
|
}
|
||||||
|
if n := len(s.List()); n != 3 {
|
||||||
|
t.Fatalf("list after a failed Replace has %d tags, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1111
-123
File diff suppressed because it is too large.
Load diff
+173
-36
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
<svg class="pi ico" viewBox="0 0 24 24" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 3C13.866 3 17 6.13401 17 10C17 11.591 16.4674 13.0568 15.5732 14.2324L19.6514 17.7803C20.9315 18.8943 20.1433 21 18.4463 21C17.8908 20.9999 17.3649 20.7481 17.0166 20.3154L13.5625 16.0244C12.5186 16.643 11.3013 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3ZM10 6C7.79086 6 6 7.79086 6 10C6 12.2091 7.79086 14 10 14C12.2091 14 14 12.2091 14 10C14 7.79086 12.2091 6 10 6Z"/></svg>
|
||||||
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria">
|
<input id="q" type="search" autocomplete="off" spellcheck="false" data-i18n-aria="searchAria">
|
||||||
<button id="mode" class="chip" type="button" data-i18n-title="modeTitle">Simple</button>
|
<button id="mode" class="chip" type="button" data-i18n-title="modeTitle">Simple</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,22 +34,50 @@
|
|||||||
<span class="dot"></span><span class="lbl" data-i18n="live">Live</span>
|
<span class="dot"></span><span class="lbl" data-i18n="live">Live</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="settingsBtn" class="icon-btn" type="button" data-i18n-title="settings" data-i18n-aria="settings">
|
<button id="settingsBtn" class="icon-btn" type="button" data-i18n-title="settings" data-i18n-aria="settings">
|
||||||
<!-- Gear icon (Lucide "settings", ISC license) -->
|
<!-- Papicons "gears" (MIT license, see THIRD_PARTY_NOTICES.md) -->
|
||||||
<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.9995 3C12.5927 3.00002 13.3086 3.20228 13.8921 3.41211C14.5527 3.64979 14.8812 4.36002 14.7143 5.04199L14.5307 5.78809C14.4327 6.18858 14.6447 6.59689 15.0014 6.80371C15.3585 7.01063 15.8146 6.98753 16.1128 6.70215L16.6684 6.1709C17.1756 5.68518 17.9554 5.61392 18.4917 6.06738C18.9651 6.46772 19.4979 6.98637 19.7944 7.5C20.091 8.01372 20.2733 8.7346 20.3833 9.34473C20.5078 10.0358 20.0565 10.6757 19.3823 10.8721L18.643 11.0869C18.2471 11.2022 18.0005 11.5876 18.0005 12C18.0005 12.4123 18.2463 12.7975 18.6421 12.9131L19.3833 13.1289C20.0571 13.3255 20.5077 13.9644 20.3833 14.6553C20.2733 15.2654 20.0909 15.9863 19.7944 16.5C19.4979 17.0136 18.965 17.5323 18.4917 17.9326C17.9555 18.386 17.1756 18.3156 16.6684 17.8301L16.1128 17.2979C15.8148 17.0124 15.3587 16.9888 15.0014 17.1953C14.6445 17.4017 14.4325 17.8105 14.5307 18.2109L14.7143 18.958C14.8813 19.6401 14.5528 20.3512 13.8921 20.5889C13.3087 20.7987 12.5926 21 11.9995 21C11.4065 20.9999 10.6911 20.7986 10.1079 20.5889C9.4471 20.3512 9.11864 19.6401 9.28562 18.958L9.46824 18.21C9.56604 17.8097 9.3543 17.4015 8.99754 17.1953C8.64021 16.989 8.18412 17.0124 7.88621 17.2979L7.33152 17.8301C6.82436 18.3157 6.04449 18.3859 5.50828 17.9326C5.03495 17.5323 4.50211 17.0136 4.20555 16.5C3.90903 15.9863 3.7257 15.2654 3.6157 14.6553C3.49135 13.9644 3.94281 13.3254 4.61668 13.1289L5.35594 12.9131C5.7518 12.7976 6.00041 12.4124 6.00047 12C6.00048 11.5876 5.75186 11.2022 5.35594 11.0869L4.61668 10.8721C3.94264 10.6756 3.49219 10.0357 3.61668 9.34473C3.72668 8.73456 3.90897 8.01373 4.20555 7.5C4.50212 6.98632 5.03487 6.46774 5.50828 6.06738C6.04452 5.61392 6.82432 5.68518 7.33152 6.1709L7.88621 6.70215C8.18437 6.98742 8.64039 7.01053 8.99754 6.80371C9.35438 6.59702 9.56625 6.18865 9.46824 5.78809L9.28562 5.04199C9.1187 4.35995 9.44713 3.64975 10.1079 3.41211C10.6912 3.20234 11.4064 3.00008 11.9995 3ZM12.0005 9C10.3436 9 9.00047 10.3431 9.00047 12C9.00047 13.6569 10.3436 15 12.0005 15C13.6571 14.9997 15.0005 13.6567 15.0005 12C15.0005 10.3433 13.6571 9.00026 12.0005 9Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
||||||
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
<svg class="pi sun" viewBox="0 0 24 24"><path d="M15 12.8572C15 14.514 13.6569 15.8572 12 15.8572C10.3431 15.8572 9 14.514 9 12.8572C9 11.2003 10.3431 9.85718 12 9.85718C13.6569 9.85718 15 11.2003 15 12.8572Z"/><path d="M11.9994 9.85732C13.6563 9.85732 14.9994 11.2005 14.9994 12.8573C14.9993 14.514 13.6562 15.8573 11.9994 15.8573C10.3429 15.8571 8.99959 14.5139 8.99943 12.8573C8.99943 11.2006 10.3428 9.85754 11.9994 9.85732Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.224 2.71279C13.1976 2.82269 13.8745 3.93342 15.2279 6.1542C17.808 5.82774 19.0985 5.66373 19.7914 6.35635C19.8975 6.46241 19.9909 6.58086 20.0707 6.70791C20.5916 7.53758 20.1456 8.75872 19.2533 11.2011C21.1171 13.0146 22.0492 13.9219 21.9399 14.8954C21.9231 15.0444 21.8898 15.1914 21.8403 15.3329C21.5165 16.2576 20.283 16.6707 17.8168 17.496C17.561 20.084 17.4324 21.3779 16.6029 21.8993C16.4762 21.979 16.3409 22.0442 16.1996 22.0937C15.2748 22.4171 14.1824 21.7104 11.9994 20.2968C9.81658 21.7103 8.72506 22.4171 7.80021 22.0937C7.65874 22.0441 7.52282 21.9791 7.39592 21.8993C6.56669 21.3778 6.43883 20.0837 6.18303 17.496C3.71676 16.6707 2.48338 16.2576 2.15959 15.3329C2.11005 15.1914 2.07674 15.0444 2.05998 14.8954C1.95064 13.9218 2.88271 13.0146 4.7465 11.2011C3.8542 8.75861 3.40816 7.53759 3.92912 6.70791C4.00891 6.58086 4.10231 6.46241 4.20842 6.35635C4.9014 5.66383 6.19193 5.82775 8.77189 6.1542C10.1253 3.93342 10.8022 2.82269 11.7758 2.71279C11.9247 2.696 12.0751 2.69599 12.224 2.71279ZM11.9994 7.85732C9.23819 7.85754 6.99943 10.096 6.99943 12.8573C6.99959 15.6185 9.23829 17.8571 11.9994 17.8573C14.7608 17.8573 16.9993 15.6186 16.9994 12.8573C16.9994 10.0959 14.7609 7.85732 11.9994 7.85732Z"/></svg>
|
||||||
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
<svg class="pi moon" viewBox="0 0 24 24"><path d="M11 3.85718C11.7891 3.85718 12.0004 5.04248 11.4641 5.62132C10.5555 6.60206 10 7.91462 10 9.35718C10 12.3947 12.4624 14.8572 15.5 14.8572C16.9423 14.8572 18.2542 14.3018 19.2346 13.3934C19.8136 12.8568 20.9996 13.0677 21 13.8572C21.0027 18.8277 16.9706 21.8572 12 21.8572C7.02944 21.8572 3 17.8277 3 12.8572C3 7.88661 6.02944 3.85718 11 3.85718Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<span id="whoami" class="whoami" hidden><span class="who-name"></span><span class="who-badge"></span></span>
|
||||||
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
|
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
|
||||||
<!-- Log out icon (Lucide "log-out", ISC license) -->
|
<!-- Papicons "logout" (MIT license) -->
|
||||||
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path d="M7.5 3C8.77204 3 9.7815 3.14324 10.5449 3.32129C11.7792 3.60916 12.5491 4.68832 12.6953 5.94727C12.7404 6.33543 12.7854 6.78351 12.8262 7.29297C12.891 8.10446 12.1921 8.76206 11.3828 8.67383C11.2557 8.65998 11.128 8.64666 11.001 8.63281C10.1171 8.53511 9.26865 8.80021 8.64355 9.42676C8.0185 10.0502 7.66803 10.9842 7.66797 11.9658C7.66803 12.9475 8.0185 13.8815 8.64355 14.5049C9.26864 15.1314 10.1171 15.3965 11.001 15.2988C11.128 15.285 11.2557 15.2717 11.3828 15.2578C12.1939 15.1694 12.8945 15.8263 12.8311 16.6396C12.789 17.177 12.7423 17.6476 12.6953 18.0527C12.5491 19.3117 11.7792 20.3908 10.5449 20.6787C9.7815 20.8568 8.77204 21 7.5 21C6.22796 21 5.2185 20.8568 4.45508 20.6787C3.22079 20.3908 2.45087 19.3117 2.30469 18.0527C2.15229 16.7401 2 14.7432 2 12C2 9.25683 2.15229 7.25991 2.30469 5.94727C2.45087 4.68832 3.22079 3.60916 4.45508 3.32129C5.2185 3.14324 6.22795 3 7.5 3ZM15.8955 7.38379C16.286 6.99352 16.9191 6.99349 17.3096 7.38379L21.083 11.1572C21.473 11.5477 21.4732 12.1809 21.083 12.5713L17.3096 16.3438C16.919 16.734 16.2859 16.7342 15.8955 16.3438C15.5052 15.9533 15.5053 15.3202 15.8955 14.9297L17.8242 13.001C15.677 13.0946 13.5289 13.1886 11.3818 13.2822C11.2547 13.2878 11.1272 13.2933 11 13.2988C10.6465 13.3144 10.3066 13.1877 10.0566 12.9375C9.80697 12.6878 9.66705 12.3347 9.66699 11.9658C9.66704 11.5968 9.80779 11.2439 10.0576 10.9941C10.3076 10.7441 10.6466 10.6173 11 10.6328C11.1272 10.6384 11.2547 10.6439 11.3818 10.6494C13.5999 10.7461 15.819 10.8418 18.0371 10.9385L15.8955 8.79785C15.5052 8.40741 15.5053 7.77428 15.8955 7.38379Z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" aria-hidden="true"></div>
|
<div class="progress" aria-hidden="true"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="filters">
|
<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">
|
||||||
|
<!-- Papicons "play" (MIT license) -->
|
||||||
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 14.7702V9.22986C6 5.65215 6 3.86329 7.12572 3.31615C8.25145 2.769 9.65806 3.8742 12.4713 6.08459L14.9962 8.06842C17.3011 9.87942 18.4535 10.7849 18.4535 12C18.4535 13.2151 17.3011 14.1206 14.9962 15.9316L12.4713 17.9154C9.65806 20.1258 8.25145 21.231 7.12572 20.6839C6 20.1367 6 18.3479 6 14.7702Z"/></svg>
|
||||||
|
<span data-i18n="viewStream">Stream</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="radio" data-view="period" data-i18n-title="viewPeriodTitle">
|
||||||
|
<!-- Papicons "calendar" (MIT license) -->
|
||||||
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6.125C3 4.39911 4.39911 3 6.125 3H17.875C19.6009 3 21 4.39911 21 6.125C21 7.16053 20.1605 8 19.125 8H4.875C3.83947 8 3 7.16053 3 6.125Z"/><path d="M18.1738 10.0049C19.6939 10.0931 20.9069 11.3061 20.9951 12.8262C20.9979 12.8742 20.9965 12.9274 20.9971 13H17C14.7909 13 13 14.7909 13 17V20.9893C12.6268 20.9985 12.1964 21 11.6865 21H9.28613C7.15368 21 6.08721 20.9996 5.26465 20.6035C4.44822 20.2103 3.78966 19.5518 3.39648 18.7354C3.00036 17.9128 3 16.8463 3 14.7139C3 13.1148 2.99981 12.3151 3.29688 11.6982C3.59175 11.0859 4.08592 10.5918 4.69824 10.2969C5.31511 9.99981 6.1148 10 7.71387 10H17.7793C17.9843 10 18.0872 9.99986 18.1738 10.0049Z"/><path d="M17 15H20.6396C20.4588 15.3973 20.2137 15.7662 19.9092 16.0889C19.8695 16.1309 19.8269 16.1731 19.7422 16.2578L17.3428 18.6572C16.2546 19.7454 15.677 20.318 15 20.6357V17C15 15.9643 15.7872 15.113 16.7959 15.0107L17 15Z"/></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 class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.8037 4.43946C13.3895 3.85368 14.339 3.85367 14.9248 4.43946C15.5105 5.02525 15.5106 5.97481 14.9248 6.56055L9.6211 11.8643L14.9248 17.167C15.5105 17.7527 15.5104 18.7023 14.9248 19.2881C14.339 19.8739 13.3895 19.8739 12.8037 19.2881L6.43946 12.9248C5.85368 12.339 5.85368 11.3885 6.43946 10.8027L12.8037 4.43946Z"/></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 class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M10.5605 4.43946C9.97469 3.85368 9.02517 3.85367 8.43938 4.43946C7.85372 5.02525 7.85365 5.97481 8.43938 6.56055L13.7431 11.8643L8.43938 17.167C7.85366 17.7527 7.8538 18.7023 8.43938 19.2881C9.02517 19.8739 9.97469 19.8739 10.5605 19.2881L16.9247 12.9248C17.5105 12.339 17.5105 11.3885 16.9247 10.8027L10.5605 4.43946Z"/></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">
|
<select id="range" data-i18n-aria="rangeAria">
|
||||||
<option value="5m" data-i18n="r5m">5 min</option>
|
<option value="5m" data-i18n="r5m">5 min</option>
|
||||||
<option value="15m" data-i18n="r15m">15 min</option>
|
<option value="15m" data-i18n="r15m">15 min</option>
|
||||||
@@ -75,12 +103,16 @@
|
|||||||
<option value="syslog">Syslog</option>
|
<option value="syslog">Syslog</option>
|
||||||
<option value="docker">Docker</option>
|
<option value="docker">Docker</option>
|
||||||
<option value="host" data-i18n="srcHost">Host system</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="file" data-i18n="srcFile">Files</option>
|
||||||
|
<option value="logstream">LogStream</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span id="count" class="muted"></span>
|
<span id="count" class="muted"></span>
|
||||||
<div class="export">
|
<div class="export">
|
||||||
<button id="exportBtn" class="btn tool" type="button" aria-haspopup="menu" aria-expanded="false">
|
<button id="exportBtn" class="btn tool" type="button" aria-haspopup="menu" aria-expanded="false">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M21.0002 12.0001C21.0002 13.4832 20.9277 14.7482 20.8215 15.8048C20.5477 18.5277 18.5287 20.5475 15.8058 20.8214C14.7491 20.9277 13.4835 21.0001 12.0002 21.0001C10.5171 21.0001 9.25211 20.9276 8.1955 20.8214C5.47245 20.5476 3.45266 18.5279 3.17889 15.8048C3.07268 14.7482 3.00019 13.4832 3.00018 12.0001C3.00018 10.5168 3.07265 9.25118 3.17889 8.19446C3.45277 5.47156 5.47254 3.45259 8.1955 3.17883C9.25211 3.07262 10.5171 3.00013 12.0002 3.00012C13.4835 3.00012 14.7491 3.07259 15.8058 3.17883C18.5286 3.45273 20.5476 5.47169 20.8215 8.19446C20.9277 9.25118 21.0002 10.5168 21.0002 12.0001ZM16.8781 12.8282C17.2686 12.4377 17.2686 11.8047 16.8781 11.4142C16.4875 11.0242 15.8544 11.0239 15.4641 11.4142L13.0978 13.7814C13.1877 12.0389 13.2765 10.2953 13.3664 8.55286C13.3719 8.44571 13.3775 8.33773 13.383 8.23059C13.4013 7.87733 13.2776 7.53707 13.0275 7.28723C12.7778 7.0377 12.4217 6.89661 12.05 6.89661C11.6782 6.8968 11.322 7.03739 11.0724 7.28723C10.8224 7.53705 10.6988 7.87736 10.717 8.23059C10.7225 8.33771 10.7281 8.44574 10.7336 8.55286C10.8203 10.2344 10.9076 11.9171 10.9943 13.5988L8.80878 11.4142C8.41822 11.0241 7.78508 11.0238 7.39471 11.4142C7.00435 11.8045 7.00467 12.4377 7.39471 12.8282L11.4289 16.8634C11.8194 17.2539 12.4534 17.2539 12.8439 16.8634L16.8781 12.8282Z"/></svg>
|
||||||
<span class="lbl" data-i18n="export">Export</span>
|
<span class="lbl" data-i18n="export">Export</span>
|
||||||
</button>
|
</button>
|
||||||
<div id="exportMenu" class="menu" role="menu" hidden>
|
<div id="exportMenu" class="menu" role="menu" hidden>
|
||||||
@@ -97,23 +129,30 @@
|
|||||||
|
|
||||||
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section>
|
<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 class="pi" viewBox="0 0 24 24"><path d="M17.958 3.95801C18.4881 3.95763 19.0166 4.18846 19.4141 4.58594C19.8115 4.98342 20.0424 5.51194 20.042 6.04199C20.0423 6.57267 19.8115 7.06147 19.4141 7.41406C19.3082 7.50811 19.2025 7.60225 19.0967 7.69629C17.4824 9.13092 15.8673 10.5653 14.2529 12C15.8673 13.4347 17.4824 14.8691 19.0967 16.3037C19.2025 16.3978 19.3082 16.4919 19.4141 16.5859C19.8115 16.9385 20.0423 17.4273 20.042 17.958C20.0424 18.4881 19.8115 19.0166 19.4141 19.4141C19.0166 19.8115 18.4881 20.0424 17.958 20.042C17.4273 20.0423 16.9385 19.8115 16.5859 19.4141C16.4919 19.3082 16.3978 19.2025 16.3037 19.0967C14.8691 17.4824 13.4347 15.8673 12 14.2529C10.5653 15.8673 9.13092 17.4824 7.69629 19.0967C7.60225 19.2025 7.50811 19.3082 7.41406 19.4141C7.06147 19.8115 6.57267 20.0423 6.04199 20.042C5.51194 20.0424 4.98342 19.8115 4.58594 19.4141C4.18846 19.0166 3.95763 18.4881 3.95801 17.958C3.95773 17.4273 4.18854 16.9385 4.58594 16.5859C4.69176 16.4919 4.7975 16.3978 4.90332 16.3037C6.51743 14.8692 8.13186 13.4345 9.74609 12C8.13186 10.5655 6.51743 9.13076 4.90332 7.69629C4.7975 7.60225 4.69176 7.50811 4.58594 7.41406C4.18854 7.06147 3.95773 6.57267 3.95801 6.04199C3.95763 5.51194 4.18846 4.98342 4.58594 4.58594C4.98342 4.18846 5.51194 3.95763 6.04199 3.95801C6.57267 3.95773 7.06147 4.18854 7.41406 4.58594C7.50811 4.69176 7.60225 4.7975 7.69629 4.90332C9.13076 6.51743 10.5655 8.13186 12 9.74609C13.4345 8.13186 14.8692 6.51743 16.3037 4.90332C16.3978 4.7975 16.4919 4.69176 16.5859 4.58594C16.9385 4.18854 17.4273 3.95773 17.958 3.95801Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="error" class="error-banner" hidden></div>
|
<div id="error" class="error-banner" hidden></div>
|
||||||
<button id="newPill" class="pill" type="button" hidden></button>
|
<button id="newPill" class="pill" type="button" hidden></button>
|
||||||
|
|
||||||
<div id="table" class="table" hidden>
|
<div id="table" class="table" hidden>
|
||||||
<div id="listHead" class="list-head" role="presentation">
|
<div id="listHead" class="list-head" role="presentation">
|
||||||
|
<span data-col="src"><span class="lbl" data-i18n="colSrc">Source</span></span>
|
||||||
<span data-col="rcv"><span class="lbl" data-i18n="colRcv">Received</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="rcv"><span class="lbl" data-i18n="colRcv">Received</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="mt"><span class="lbl" data-i18n="colMt">Message time</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="mt"><span class="lbl" data-i18n="colMt">Message time</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
|
||||||
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
|
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Pattern</span></span>
|
||||||
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
|
||||||
</div>
|
</div>
|
||||||
<main id="list" class="list"></main>
|
<main id="list" class="list"></main>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty" class="empty" hidden>
|
<div id="empty" class="empty" hidden>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7"/></svg>
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3C13.483 3.00001 14.7481 3.07251 15.8047 3.17871C18.5276 3.45246 20.5473 5.47153 20.8213 8.19434C20.9275 9.25103 21 10.5167 21 12C21 13.483 20.9275 14.7481 20.8213 15.8047C20.5475 18.5277 18.5277 20.5475 15.8047 20.8213C14.7481 20.9275 13.483 21 12 21C10.5168 21 9.251 20.9275 8.19434 20.8213C5.47156 20.5473 3.45246 18.5276 3.17871 15.8047C3.0725 14.7481 3 13.483 3 12C3 10.5167 3.07248 9.25103 3.17871 8.19434C3.45264 5.4717 5.47172 3.45267 8.19434 3.17871C9.251 3.07248 10.5168 3.00001 12 3ZM11.5 15C10.1892 15 9.21877 15.0253 8.5625 15.0518C7.86067 15.0801 7 15.2977 7 16C7 16.7023 7.86067 16.9199 8.5625 16.9482C9.21877 16.9747 10.1892 17 11.5 17C12.8108 17 13.7812 16.9747 14.4375 16.9482C15.1393 16.9199 16 16.7023 16 16C16 15.2977 15.1393 15.0801 14.4375 15.0518C13.7812 15.0253 12.8108 15 11.5 15ZM10.5 11C9.59007 11 8.89128 11.0199 8.38867 11.043C7.73172 11.0732 7 11.3424 7 12C7 12.6576 7.73172 12.9268 8.38867 12.957C8.89128 12.9801 9.59007 13 10.5 13C11.4099 13 12.1087 12.9801 12.6113 12.957C13.2683 12.9268 14 12.6576 14 12C14 11.3424 13.2683 11.0732 12.6113 11.043C12.1087 11.0199 11.4099 11 10.5 11ZM12 7C10.4872 7 9.38276 7.02718 8.65332 7.05469C7.92733 7.08208 7 7.27351 7 8C7 8.72649 7.92733 8.91792 8.65332 8.94531C9.38276 8.97282 10.4872 9 12 9C13.5128 9 14.6172 8.97282 15.3467 8.94531C16.0727 8.91792 17 8.72649 17 8C17 7.27351 16.0727 7.08208 15.3467 7.05469C14.6172 7.02718 13.5128 7 12 7Z"/></svg>
|
||||||
<p><strong data-i18n="emptyTitle">No matching logs.</strong></p>
|
<p><strong data-i18n="emptyTitle">No matching logs.</strong></p>
|
||||||
<p class="muted" data-i18n="emptyHint">Widen the time range or send syslog messages to port 514 (UDP or TCP).</p>
|
<p class="muted" data-i18n="emptyHint">Widen the time range or send syslog messages to port 514 (UDP or TCP).</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +161,7 @@
|
|||||||
<span id="conn" class="conn"></span>
|
<span id="conn" class="conn"></span>
|
||||||
<span id="stats" class="muted"></span>
|
<span id="stats" class="muted"></span>
|
||||||
<button id="toTop" class="icon-btn to-top" type="button" data-i18n-title="toTop" data-i18n-aria="toTop" hidden>
|
<button id="toTop" class="icon-btn to-top" type="button" data-i18n-title="toTop" data-i18n-aria="toTop" hidden>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.4243 10.6673C20.01 11.2531 20.01 12.2026 19.4243 12.7884C18.8385 13.3741 17.8889 13.3741 17.3032 12.7884L13.4252 8.90949C13.5632 11.7245 13.7002 14.5399 13.8383 17.3548C13.8466 17.5243 13.8554 17.6941 13.8637 17.8636C13.8901 18.394 13.7021 18.9036 13.3266 19.2786C12.952 19.6535 12.4203 19.8636 11.8637 19.8636C11.3072 19.8635 10.7754 19.6534 10.4008 19.2786C10.0254 18.9036 9.83733 18.3939 9.8637 17.8636C9.87202 17.6941 9.88078 17.5243 9.88909 17.3548C10.0225 14.6352 10.1562 11.9153 10.2895 9.19563L6.69671 12.7884C6.11099 13.3741 5.16142 13.374 4.57562 12.7884C3.98983 12.2026 3.98983 11.2531 4.57562 10.6673L10.9389 4.30305C11.5247 3.71726 12.4752 3.71726 13.061 4.30305L19.4243 10.6673Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -131,33 +170,47 @@
|
|||||||
<header>
|
<header>
|
||||||
<h2 id="settingsTitle" data-i18n="settings">Settings</h2>
|
<h2 id="settingsTitle" data-i18n="settings">Settings</h2>
|
||||||
<button id="closeSettings" class="icon-btn" type="button" data-i18n-aria="close">
|
<button id="closeSettings" class="icon-btn" type="button" data-i18n-aria="close">
|
||||||
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path d="M17.958 3.95801C18.4881 3.95763 19.0166 4.18846 19.4141 4.58594C19.8115 4.98342 20.0424 5.51194 20.042 6.04199C20.0423 6.57267 19.8115 7.06147 19.4141 7.41406C19.3082 7.50811 19.2025 7.60225 19.0967 7.69629C17.4824 9.13092 15.8673 10.5653 14.2529 12C15.8673 13.4347 17.4824 14.8691 19.0967 16.3037C19.2025 16.3978 19.3082 16.4919 19.4141 16.5859C19.8115 16.9385 20.0423 17.4273 20.042 17.958C20.0424 18.4881 19.8115 19.0166 19.4141 19.4141C19.0166 19.8115 18.4881 20.0424 17.958 20.042C17.4273 20.0423 16.9385 19.8115 16.5859 19.4141C16.4919 19.3082 16.3978 19.2025 16.3037 19.0967C14.8691 17.4824 13.4347 15.8673 12 14.2529C10.5653 15.8673 9.13092 17.4824 7.69629 19.0967C7.60225 19.2025 7.50811 19.3082 7.41406 19.4141C7.06147 19.8115 6.57267 20.0423 6.04199 20.042C5.51194 20.0424 4.98342 19.8115 4.58594 19.4141C4.18846 19.0166 3.95763 18.4881 3.95801 17.958C3.95773 17.4273 4.18854 16.9385 4.58594 16.5859C4.69176 16.4919 4.7975 16.3978 4.90332 16.3037C6.51743 14.8692 8.13186 13.4345 9.74609 12C8.13186 10.5655 6.51743 9.13076 4.90332 7.69629C4.7975 7.60225 4.69176 7.50811 4.58594 7.41406C4.18854 7.06147 3.95773 6.57267 3.95801 6.04199C3.95763 5.51194 4.18846 4.98342 4.58594 4.58594C4.98342 4.18846 5.51194 3.95763 6.04199 3.95801C6.57267 3.95773 7.06147 4.18854 7.41406 4.58594C7.50811 4.69176 7.60225 4.7975 7.69629 4.90332C9.13076 6.51743 10.5655 8.13186 12 9.74609C13.4345 8.13186 14.8692 6.51743 16.3037 4.90332C16.3978 4.7975 16.4919 4.69176 16.5859 4.58594C16.9385 4.18854 17.4273 3.95773 17.958 3.95801Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="set-body">
|
<div class="set-body">
|
||||||
|
<div class="set-side">
|
||||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||||
<button type="button" role="tab" data-tab="locale">
|
<button type="button" role="tab" data-tab="locale">
|
||||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM4.73926 9C4.6407 9 4.55559 9.06709 4.52056 9.15921C4.18489 10.0421 4 10.9994 4 12C4 16.4183 7.58172 20 12 20C12.5088 20 13 19.6719 13 19.1631C12.9998 18.149 12.1772 17.3271 11.1631 17.3271H8.82617C8.24987 17.3271 7.78223 16.8595 7.78223 16.2832C7.78238 15.707 8.24997 15.2402 8.82617 15.2402H8.86914C9.46936 15.2402 9.9559 14.7535 9.95605 14.1533V13.6436C9.95605 12.7619 9.24192 12.0471 8.36035 12.0469H8.00195C7.16056 12.0469 6.47852 11.3648 6.47852 10.5234C6.47849 9.68216 5.79632 9.00016 4.95508 9H4.73926ZM12.5176 7.22559C12.1868 7.54639 12 7.98745 12 8.44824C12 9.38888 12.7625 10.1513 13.7031 10.1514H15.5762C16.9149 10.1516 18 11.2374 18 12.5762V13.5C18 13.7937 18.0844 14.0672 18.2303 14.2981C18.687 15.021 19.6577 14.4662 19.8323 13.629C19.942 13.1032 20 12.5584 20 12C20 9.1841 18.5448 6.7087 16.3457 5.28326C15.667 4.84332 14.7905 5.02052 14.2099 5.58373L12.5176 7.22559Z"/></svg>
|
||||||
<span data-i18n="tabLocale">Localization</span>
|
<span data-i18n="tabLocale">Localization</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="tab" data-tab="filters">
|
<button type="button" role="tab" data-tab="filters">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z"/><circle cx="8" cy="8" r="1.5"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path d="M12 16.7647C12.548 16.7647 15.4122 18.8669 17.0571 20.1092C17.6198 20.5343 18.404 20.2599 18.5179 19.5639C18.7378 18.2207 19 15.9104 19 12.5294C19 10.7935 18.9309 9.3398 18.8339 8.15435C18.6173 5.50766 16.8501 3.35738 14.2069 3.10193C13.5553 3.03896 12.8206 3 12 3C11.1794 3 10.4447 3.03896 9.79312 3.10193C7.14989 3.35738 5.38274 5.50766 5.16613 8.15435C5.06911 9.3398 5 10.7935 5 12.5294C5 15.9104 5.26216 18.2207 5.48208 19.5639C5.59602 20.2599 6.38016 20.5343 6.94294 20.1092C8.58775 18.8669 11.452 16.7647 12 16.7647Z"/></svg>
|
||||||
<span data-i18n="tabFilters">Filters</span>
|
<span data-i18n="tabFilters">Patterns</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="tab" data-tab="sources">
|
<button type="button" role="tab" data-tab="sources">
|
||||||
<svg viewBox="0 0 24 24"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path d="M18.2951 12C19.7572 12 20.4886 12.0001 20.9132 12.373C21.1023 12.5392 21.247 12.7507 21.3331 12.9873C21.5261 13.5183 21.2604 14.1996 20.7296 15.5615C20.0195 17.3837 19.6643 18.2954 19.005 18.915C18.7039 19.198 18.3604 19.4325 17.9874 19.6104C17.1707 19.9998 16.1923 20 14.2365 20H9.83216C7.88742 20 6.91479 20.0001 6.10462 19.6182C5.68919 19.4222 5.31062 19.1555 4.98548 18.8311C4.35134 18.1983 4.02084 17.2832 3.36146 15.4531C2.89275 14.1522 2.65852 13.5012 2.83997 12.9941C2.93109 12.7397 3.0895 12.5147 3.29798 12.3428C3.71355 12.0002 4.40527 12 5.78822 12H18.2951Z"/><path d="M8.70033 4C9.18415 4 9.42625 4.00031 9.64661 4.05078C10.0597 4.14544 10.4322 4.36852 10.7101 4.68848C10.8584 4.8592 10.9725 5.07288 11.2003 5.5C11.428 5.92693 11.5414 6.14083 11.6896 6.31152C11.9674 6.63143 12.3401 6.85454 12.7531 6.94922C12.9735 6.99971 13.2162 7 13.7003 7H17.0001C18.6569 7.00011 20.0001 8.34321 20.0001 10H4.00013V8.57129C4.00013 7.10682 4.00001 6.37442 4.25013 5.80078C4.55276 5.10714 5.10631 4.55267 5.79994 4.25C6.37366 3.99969 7.1065 4 8.57142 4H8.70033Z"/></svg>
|
||||||
<span data-i18n="tabSources">Sources</span>
|
<span data-i18n="tabSources">Log sources</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="tab" data-tab="interface">
|
<button type="button" role="tab" data-tab="interface">
|
||||||
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.2432 3.85718C17.3475 3.85745 22.5648 8.07142 20.5615 12.8572C19.1729 16.1744 14.1433 14.7708 12.7051 16.3572C11.0115 18.2256 15.4772 21.8572 9.93164 21.8572C5.10708 21.857 3 17.8276 3 12.8572C3 7.88661 7.13857 3.85718 12.2432 3.85718ZM8 13.8572C6.89543 13.8572 6 14.7526 6 15.8572C6 16.9617 6.89543 17.8572 8 17.8572C9.10457 17.8572 10 16.9617 10 15.8572C10 14.7526 9.10457 13.8572 8 13.8572ZM9 7.85718C7.89543 7.85718 7 8.75261 7 9.85718C7 10.9617 7.89543 11.8572 9 11.8572C10.1046 11.8572 11 10.9617 11 9.85718C11 8.75261 10.1046 7.85718 9 7.85718ZM15 6.85718C13.8954 6.85718 13 7.75261 13 8.85718C13 9.96175 13.8954 10.8572 15 10.8572C16.1046 10.8572 17 9.96175 17 8.85718C17 7.75261 16.1046 6.85718 15 6.85718Z"/></svg>
|
||||||
<span data-i18n="tabInterface">Interface</span>
|
<span data-i18n="tabInterface">Appearance</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="tab" data-tab="data">
|
<button type="button" role="tab" data-tab="data">
|
||||||
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
|
<svg class="pi" viewBox="0 0 24 24"><path d="M12.9346 9.07617C16.1274 8.97649 17.7241 8.92669 18.3164 9.44434C18.3817 9.50143 18.4048 9.52447 18.4639 9.58789C18.9997 10.1637 19 11.5073 19 14.1934C19 16.8343 18.9998 18.155 18.2256 19.0117C18.1579 19.0866 18.0866 19.1579 18.0117 19.2256C17.155 19.9998 15.8343 20 13.1934 20H10.8066C8.16574 20 6.84497 19.9998 5.98828 19.2256C5.91344 19.1579 5.84206 19.0866 5.77441 19.0117C5.00023 18.155 5 16.8343 5 14.1934C5 11.5073 5.00025 10.1637 5.53613 9.58789C5.59516 9.52447 5.61826 9.50143 5.68359 9.44434C6.27589 8.92669 7.8726 8.97649 11.0654 9.07617C11.3654 9.08554 11.6769 9.09082 12 9.09082C12.3231 9.09082 12.6346 9.08554 12.9346 9.07617ZM12 12C11.2869 12 10.725 12.0166 10.3047 12.0371C9.66842 12.0682 9 12.363 9 13C9 13.637 9.66842 13.9318 10.3047 13.9629C10.725 13.9834 11.2869 14 12 14C12.7131 14 13.275 13.9834 13.6953 13.9629C14.3316 13.9318 15 13.637 15 13C15 12.363 14.3316 12.0682 13.6953 12.0371C13.275 12.0166 12.7131 12 12 12ZM18.6494 4C19.3953 4.00014 19.9999 4.60474 20 5.35059C20 6.01305 19.5206 6.57368 18.8623 6.64746C17.5465 6.79493 15.1509 7 12 7C8.8491 7 6.4535 6.79493 5.1377 6.64746C4.47935 6.57368 4 6.01305 4 5.35059C4.00014 4.60474 4.60474 4.00014 5.35059 4H18.6494Z"/></svg>
|
||||||
<span data-i18n="tabData">Data</span>
|
<span data-i18n="tabData">Database</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" data-tab="api">
|
||||||
|
<svg class="pi" viewBox="0 0 24 24"><path d="M7.89844 4.00888C8.41797 3.90461 8.98572 4.02996 9.45801 4.34482C9.93011 4.65974 10.2637 5.13577 10.3672 5.65537C10.4715 6.17558 10.3378 6.6978 10.0127 7.11826C9.93916 7.21355 9.86552 7.3091 9.79199 7.40439C8.60774 8.93905 7.42354 10.4742 6.23926 12.0089C7.42347 13.5434 8.60781 15.0788 9.79199 16.6134C9.86545 16.7086 9.93924 16.8043 10.0127 16.8995C10.3376 17.32 10.4716 17.8422 10.3672 18.3624C10.2636 18.882 9.93018 19.3581 9.45801 19.6729C8.9858 19.9877 8.41788 20.1131 7.89844 20.0089C7.37808 19.9052 6.94777 19.5799 6.68457 19.1183C6.62496 19.0138 6.5645 18.9092 6.50488 18.8048C5.42979 16.9218 4.35439 15.0374 3.2793 13.1544C3.21976 13.0501 3.16012 12.9452 3.10059 12.8409C2.99425 12.6543 2.93402 12.4428 2.91895 12.2257V11.7901C2.9341 11.5735 2.99449 11.3632 3.10059 11.1769C3.16018 11.0725 3.21971 10.9678 3.2793 10.8634C4.35444 8.98025 5.42974 7.09611 6.50488 5.21298C6.56456 5.10847 6.6249 5.00402 6.68457 4.89951C6.94774 4.43754 7.37788 4.1126 7.89844 4.00888Z"/><path d="M13.9395 4.34482C14.4118 4.02991 14.9794 3.90451 15.499 4.00888C16.0195 4.11266 16.4498 4.43761 16.7129 4.89951C16.7725 5.0039 16.833 5.10859 16.8926 5.21298C17.9677 7.09599 19.0431 8.98037 20.1182 10.8634C20.1777 10.9676 20.2374 11.0726 20.2969 11.1769C20.4381 11.4251 20.4979 11.7166 20.4785 12.0079C20.4981 12.2997 20.4385 12.5923 20.2969 12.8409C20.2373 12.9452 20.1777 13.0501 20.1182 13.1544C19.0431 15.0374 17.9677 16.9217 16.8926 18.8048C16.833 18.9092 16.7725 19.0138 16.7129 19.1183C16.4497 19.5799 16.0193 19.9052 15.499 20.0089C14.9796 20.1132 14.4117 19.9876 13.9395 19.6729C13.4673 19.3582 13.1339 18.8819 13.0303 18.3624C12.9258 17.8421 13.0598 17.3201 13.3848 16.8995C13.4583 16.8043 13.532 16.7086 13.6055 16.6134C14.7897 15.0788 15.974 13.5435 17.1582 12.0089C15.9739 10.4743 14.7897 8.93902 13.6055 7.40439C13.532 7.3092 13.4582 7.21345 13.3848 7.11826C13.0597 6.69783 12.9261 6.17556 13.0303 5.65537C13.1338 5.13567 13.4672 4.65974 13.9395 4.34482Z"/></svg>
|
||||||
|
<span data-i18n="tabApi">API</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div id="setActions" class="set-actions">
|
||||||
|
<button id="setSave" class="btn save" type="button" disabled>
|
||||||
|
<svg class="pi" viewBox="0 0 24 24" aria-hidden="true"><path d="M18.9531 3.99225C19.48 3.9335 20.0214 4.09638 20.4492 4.43854C20.8769 4.78081 21.1545 5.27298 21.2129 5.79987C21.2716 6.32695 21.1064 6.84596 20.7617 7.24908C20.5953 7.44382 20.4282 7.63931 20.2617 7.83405C17.2647 11.3402 14.2675 14.8465 11.2705 18.3526C11.1041 18.5473 10.937 18.7428 10.7705 18.9376C10.52 19.23 10.1569 19.4123 9.76758 19.4493C9.50945 19.4735 9.25416 19.4313 9.02344 19.3311C8.84537 19.272 8.67957 19.1816 8.53906 19.0606C8.45329 18.9866 8.36702 18.9129 8.28125 18.8389C6.73535 17.5051 5.18946 16.1708 3.64356 14.837C3.55775 14.7629 3.47155 14.6884 3.38574 14.6143C2.98278 14.2673 2.74666 13.7828 2.74707 13.252C2.7467 12.7223 2.98292 12.1892 3.38574 11.7862C3.78869 11.3833 4.32181 11.1482 4.85156 11.1485C5.38243 11.148 5.86681 11.3832 6.21387 11.7862C6.28792 11.872 6.36247 11.9582 6.43652 12.044C7.4622 13.2328 8.48806 14.4216 9.51367 15.6104C12.0681 12.1955 14.6234 8.78117 17.1777 5.36627C17.3311 5.16129 17.4844 4.95602 17.6377 4.75104C17.9553 4.32607 18.426 4.05079 18.9531 3.99225Z"/></svg>
|
||||||
|
<span data-i18n="saveSettings">Save</span>
|
||||||
|
</button>
|
||||||
|
<button id="setCancel" class="btn ghost" type="button" disabled data-i18n="cancelSettings">Cancel</button>
|
||||||
|
<p id="setState" class="set-state" role="status"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="set-panels">
|
<div class="set-panels">
|
||||||
<!-- Localization -->
|
<!-- Localization -->
|
||||||
@@ -182,21 +235,23 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Pattern (color tags; the "filters" tab id is kept for saved settings) -->
|
||||||
<div class="set-panel" role="tabpanel" data-panel="filters" hidden>
|
<div class="set-panel wide" role="tabpanel" data-panel="filters" hidden>
|
||||||
<section class="set-section">
|
<section class="set-section tags-section">
|
||||||
<h3 data-i18n="tagsTitle">Color tags</h3>
|
<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>
|
<p class="muted small" data-i18n="tagsHelp"></p>
|
||||||
<div id="tagList" class="tag-list"></div>
|
<div class="tag-toolbar">
|
||||||
<footer>
|
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
||||||
<span class="tag-add">
|
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
||||||
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
|
<option value="" data-i18n="presetPick">+ Preset…</option>
|
||||||
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
|
</select>
|
||||||
<option value="" data-i18n="presetPick">+ Preset…</option>
|
<input id="tagFilter" type="search" class="dk-filter" autocomplete="off" spellcheck="false" data-i18n-ph="tagFilter" data-i18n-aria="tagFilter">
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,6 +286,58 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="set-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="filesTitle">Log files</h3>
|
||||||
|
<div id="fileList" class="file-list"></div>
|
||||||
|
<div class="file-toolbar">
|
||||||
|
<button id="addFile" class="btn primary" type="button" data-i18n="addFile">+ Add a file</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted small src-help" data-i18n="filesHelp"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="set-section span">
|
||||||
<h3 data-i18n="dockerTitle">Docker containers</h3>
|
<h3 data-i18n="dockerTitle">Docker containers</h3>
|
||||||
<p id="dockerStatus" class="docker-status"></p>
|
<p id="dockerStatus" class="docker-status"></p>
|
||||||
<div id="dockerBody" hidden>
|
<div id="dockerBody" hidden>
|
||||||
@@ -267,7 +374,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted small hint" data-i18n="themeHelp"></p>
|
<p class="muted small hint" data-i18n="themeHelp"></p>
|
||||||
</section>
|
</section>
|
||||||
<section class="set-section">
|
<section class="set-section span">
|
||||||
<h3 data-i18n="logDisplay">Log display</h3>
|
<h3 data-i18n="logDisplay">Log display</h3>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<span class="lbl" data-i18n="fontSize">Font size</span>
|
<span class="lbl" data-i18n="fontSize">Font size</span>
|
||||||
@@ -328,9 +435,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data -->
|
<!-- 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">
|
<section class="set-section danger">
|
||||||
<h3 data-i18n="dangerZone">Danger zone</h3>
|
<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>
|
<p class="muted small" data-i18n="purgeHelp"></p>
|
||||||
<div class="purge-row">
|
<div class="purge-row">
|
||||||
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
|
<button id="purgeBtn" class="btn danger" type="button" data-i18n="purgeBtn">Delete all logs…</button>
|
||||||
@@ -338,6 +450,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API tokens -->
|
||||||
|
<div class="set-panel wide" role="tabpanel" data-panel="api" hidden>
|
||||||
|
<section class="set-section">
|
||||||
|
<div class="sec-head">
|
||||||
|
<h3 data-i18n="tokensTitle">API tokens</h3>
|
||||||
|
<span id="tokenCount" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" data-i18n="tokensHelp"></p>
|
||||||
|
<div id="tokenList" class="token-list"></div>
|
||||||
|
<form id="tokenForm" class="token-form">
|
||||||
|
<input id="tokenName" type="text" class="dk-filter" maxlength="80" autocomplete="off" spellcheck="false" data-i18n-ph="tokenNamePh" data-i18n-aria="tokenNamePh">
|
||||||
|
<button id="tokenCreate" class="btn" type="submit" data-i18n="tokenCreate">Add a token</button>
|
||||||
|
</form>
|
||||||
|
<div id="tokenNew" class="token-new" hidden></div>
|
||||||
|
</section>
|
||||||
|
<section class="set-section">
|
||||||
|
<h3 data-i18n="tokensUse">Usage</h3>
|
||||||
|
<p class="muted small" data-i18n="tokensUseHelp"></p>
|
||||||
|
<div class="token-example-box">
|
||||||
|
<pre id="tokenExample" class="token-example"></pre>
|
||||||
|
<button id="tokenExampleCopy" class="btn" type="button" data-i18n="tokenExampleCopy">Copy the command</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -23,8 +23,8 @@
|
|||||||
<button type="button" role="radio" data-lang="en">EN</button>
|
<button type="button" role="radio" data-lang="en">EN</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
||||||
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
<svg class="pi sun" viewBox="0 0 24 24"><path d="M15 12.8572C15 14.514 13.6569 15.8572 12 15.8572C10.3431 15.8572 9 14.514 9 12.8572C9 11.2003 10.3431 9.85718 12 9.85718C13.6569 9.85718 15 11.2003 15 12.8572Z"/><path d="M11.9994 9.85732C13.6563 9.85732 14.9994 11.2005 14.9994 12.8573C14.9993 14.514 13.6562 15.8573 11.9994 15.8573C10.3429 15.8571 8.99959 14.5139 8.99943 12.8573C8.99943 11.2006 10.3428 9.85754 11.9994 9.85732Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12.224 2.71279C13.1976 2.82269 13.8745 3.93342 15.2279 6.1542C17.808 5.82774 19.0985 5.66373 19.7914 6.35635C19.8975 6.46241 19.9909 6.58086 20.0707 6.70791C20.5916 7.53758 20.1456 8.75872 19.2533 11.2011C21.1171 13.0146 22.0492 13.9219 21.9399 14.8954C21.9231 15.0444 21.8898 15.1914 21.8403 15.3329C21.5165 16.2576 20.283 16.6707 17.8168 17.496C17.561 20.084 17.4324 21.3779 16.6029 21.8993C16.4762 21.979 16.3409 22.0442 16.1996 22.0937C15.2748 22.4171 14.1824 21.7104 11.9994 20.2968C9.81658 21.7103 8.72506 22.4171 7.80021 22.0937C7.65874 22.0441 7.52282 21.9791 7.39592 21.8993C6.56669 21.3778 6.43883 20.0837 6.18303 17.496C3.71676 16.6707 2.48338 16.2576 2.15959 15.3329C2.11005 15.1914 2.07674 15.0444 2.05998 14.8954C1.95064 13.9218 2.88271 13.0146 4.7465 11.2011C3.8542 8.75861 3.40816 7.53759 3.92912 6.70791C4.00891 6.58086 4.10231 6.46241 4.20842 6.35635C4.9014 5.66383 6.19193 5.82775 8.77189 6.1542C10.1253 3.93342 10.8022 2.82269 11.7758 2.71279C11.9247 2.696 12.0751 2.69599 12.224 2.71279ZM11.9994 7.85732C9.23819 7.85754 6.99943 10.096 6.99943 12.8573C6.99959 15.6185 9.23829 17.8571 11.9994 17.8573C14.7608 17.8573 16.9993 15.6186 16.9994 12.8573C16.9994 10.0959 14.7609 7.85732 11.9994 7.85732Z"/></svg>
|
||||||
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
<svg class="pi moon" viewBox="0 0 24 24"><path d="M11 3.85718C11.7891 3.85718 12.0004 5.04248 11.4641 5.62132C10.5555 6.60206 10 7.91462 10 9.35718C10 12.3947 12.4624 14.8572 15.5 14.8572C16.9423 14.8572 18.2542 14.3018 19.2346 13.3934C19.8136 12.8568 20.9996 13.0677 21 13.8572C21.0027 18.8277 16.9706 21.8572 12 21.8572C7.02944 21.8572 3 17.8277 3 12.8572C3 7.88661 6.02944 3.85718 11 3.85718Z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+261
-50
@@ -22,6 +22,14 @@
|
|||||||
--hit: rgba(99, 102, 241, .18);
|
--hit: rgba(99, 102, 241, .18);
|
||||||
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
|
--shadow: 0 10px 30px rgba(15, 23, 42, .12);
|
||||||
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
|
--chip-l: 38%; /* lightness of project-colored text (labels, Docker apps) */
|
||||||
|
--src-syslog: #64748b; /* source badges: syslog received over the network */
|
||||||
|
--src-docker: #1d6fd6; /* Docker containers */
|
||||||
|
--src-kernel: #b4540a; /* kernel ring buffer (dmesg) */
|
||||||
|
--src-host: #b0407a; /* /var/log files of the host system */
|
||||||
|
--src-audit: #7c4dbd; /* audit events (auditd) */
|
||||||
|
--src-ls: #1f9aa3; /* LogStream internal logs */
|
||||||
|
--src-file: #5f8a1f; /* log files (Settings > Sources > Log files) */
|
||||||
|
--src-tint: 6%; /* background tint of their rows */
|
||||||
|
|
||||||
--sev-crit: #be123c;
|
--sev-crit: #be123c;
|
||||||
--sev-err: #dc2626;
|
--sev-err: #dc2626;
|
||||||
@@ -70,6 +78,14 @@
|
|||||||
--hit: rgba(129, 140, 248, .28);
|
--hit: rgba(129, 140, 248, .28);
|
||||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||||
--chip-l: 70%;
|
--chip-l: 70%;
|
||||||
|
--src-syslog: #94a3b8;
|
||||||
|
--src-docker: #5aa2f5;
|
||||||
|
--src-kernel: #e7a25a;
|
||||||
|
--src-host: #e58bbb;
|
||||||
|
--src-audit: #a983e6;
|
||||||
|
--src-ls: #3cc3cc;
|
||||||
|
--src-file: #9ccc54;
|
||||||
|
--src-tint: 9%;
|
||||||
--sev-crit: #fb7185;
|
--sev-crit: #fb7185;
|
||||||
--sev-err: #f87171;
|
--sev-err: #f87171;
|
||||||
--sev-warning: #fbbf24;
|
--sev-warning: #fbbf24;
|
||||||
@@ -102,6 +118,14 @@
|
|||||||
--hit: rgba(129, 140, 248, .28);
|
--hit: rgba(129, 140, 248, .28);
|
||||||
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
--shadow: 0 10px 30px rgba(0, 0, 0, .5);
|
||||||
--chip-l: 70%;
|
--chip-l: 70%;
|
||||||
|
--src-syslog: #94a3b8;
|
||||||
|
--src-docker: #5aa2f5;
|
||||||
|
--src-kernel: #e7a25a;
|
||||||
|
--src-host: #e58bbb;
|
||||||
|
--src-audit: #a983e6;
|
||||||
|
--src-ls: #3cc3cc;
|
||||||
|
--src-file: #9ccc54;
|
||||||
|
--src-tint: 9%;
|
||||||
--sev-crit: #fb7185;
|
--sev-crit: #fb7185;
|
||||||
--sev-err: #f87171;
|
--sev-err: #f87171;
|
||||||
--sev-warning: #fbbf24;
|
--sev-warning: #fbbf24;
|
||||||
@@ -134,6 +158,7 @@ body {
|
|||||||
button, input, select { font: inherit; color: inherit; }
|
button, input, select { font: inherit; color: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
svg.pi { fill: currentColor; stroke: none; } /* Papicons: filled shapes, no outline */
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.small { font-size: 12.5px; }
|
.small { font-size: 12.5px; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
@@ -180,6 +205,16 @@ svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
|
|||||||
.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
.chip.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||||
|
/* Logged-in user, with how they logged in (oidc / local) */
|
||||||
|
.whoami { display: flex; align-items: center; gap: 6px; min-width: 0; margin-left: 6px; font-size: 13px; color: var(--muted); }
|
||||||
|
.whoami .who-name { font-weight: 600; color: var(--text); max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.whoami .who-badge {
|
||||||
|
flex: none; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1px 6px; font-size: 10.5px; font-weight: 650; letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.whoami .who-badge.oidc { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.whoami .who-badge:empty { display: none; }
|
||||||
|
.whoami.none .who-name { font-weight: 400; color: var(--muted); }
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 36px; height: 36px; display: grid; place-items: center;
|
width: 36px; height: 36px; display: grid; place-items: center;
|
||||||
border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted);
|
border: 1px solid transparent; background: transparent; border-radius: 9px; color: var(--muted);
|
||||||
@@ -240,6 +275,19 @@ body.busy .progress::after {
|
|||||||
font-size: 13px; max-width: 220px;
|
font-size: 13px; max-width: 220px;
|
||||||
}
|
}
|
||||||
.filters select.set { border-color: var(--accent); background-color: var(--accent-soft); }
|
.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; }
|
.spacer { flex: 1; }
|
||||||
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
#count { font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
@@ -308,12 +356,12 @@ body.busy .progress::after {
|
|||||||
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||||
.list:empty { display: none; }
|
.list:empty { display: none; }
|
||||||
|
|
||||||
/* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
|
/* Columns: source, received, message time, severity, host, app, tag codes, message. Widths come from
|
||||||
--col-* (set on #table when a column has been resized, see app.js), shared by
|
--col-* (set on #table when a column has been resized, see app.js), shared by
|
||||||
the header and every row through subgrid so that the columns line up. */
|
the header and every row through subgrid so that the columns line up. */
|
||||||
.table {
|
.table {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
grid-template-columns: minmax(3.9rem, max-content) var(--col-rcv, max-content) var(--col-mt, max-content) var(--col-sev, 4.6rem)
|
||||||
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
|
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
|
||||||
column-gap: 12px;
|
column-gap: 12px;
|
||||||
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
@@ -343,20 +391,19 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
|
grid-template-columns: max-content max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
|
||||||
gap: 0 12px; align-items: baseline;
|
gap: 0 12px; align-items: baseline;
|
||||||
padding: 5px 14px 5px 11px;
|
padding: 3px 14px 3px 11px;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-family: var(--log-font, var(--mono)); font-size: var(--log-size);
|
font-family: var(--log-font, var(--mono)); font-size: var(--log-size); line-height: 1.35;
|
||||||
font-variant-ligatures: none; /* keep -> != etc. as typed */
|
font-variant-ligatures: none; /* keep -> != etc. as typed */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.row:last-child { border-bottom: 0; }
|
.row:last-child { border-bottom: 0; }
|
||||||
/* Settings > Interface > Density: compact fits about 50% more lines on screen */
|
/* Settings > Interface > Density: compact fits about 50% more lines on screen */
|
||||||
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.25; }
|
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.2; }
|
||||||
:root[data-density="compact"] .row .sev { padding-top: 0; padding-bottom: 0; line-height: 1.3; }
|
:root[data-density="compact"] .row :is(.src-badge, .sev, .codes b) { line-height: 1.15; }
|
||||||
:root[data-density="compact"] .row .codes b { padding-top: 0; padding-bottom: 0; line-height: 1.2; }
|
|
||||||
.row:hover { background: var(--row-hover); }
|
.row:hover { background: var(--row-hover); }
|
||||||
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.row.new { animation: flash 1.2s ease-out; }
|
.row.new { animation: flash 1.2s ease-out; }
|
||||||
@@ -374,7 +421,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
.sev {
|
.sev {
|
||||||
justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
|
||||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||||
padding: 1px 6px; border-radius: 5px;
|
padding: 0 6px; border-radius: 5px; line-height: 1.3;
|
||||||
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent);
|
||||||
}
|
}
|
||||||
.sev-crit { --sev: var(--tag-error); --sev-text: var(--tag-error-text); }
|
.sev-crit { --sev: var(--tag-error); --sev-text: var(--tag-error-text); }
|
||||||
@@ -388,14 +435,39 @@ 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, .row.sev-warning { border-left-color: var(--sev); }
|
||||||
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
|
.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; }
|
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
|
||||||
|
/* Source badge at the start of every row, fixed width, one color per source.
|
||||||
|
Audit events, log files and LogStream internal logs also get a colored edge
|
||||||
|
(over the severity one) and a light tint. */
|
||||||
|
.row.src-syslog { --src: var(--src-syslog); }
|
||||||
|
.row.src-docker { --src: var(--src-docker); }
|
||||||
|
.row.src-kernel { --src: var(--src-kernel); }
|
||||||
|
.row.src-host { --src: var(--src-host); }
|
||||||
|
.row.src-audit { --src: var(--src-audit); }
|
||||||
|
.row.src-logstream { --src: var(--src-ls); }
|
||||||
|
.row.src-file { --src: var(--src-file); }
|
||||||
|
.row.src-audit, .row.src-logstream, .row.src-file { border-left-color: var(--src); background: color-mix(in srgb, var(--src) var(--src-tint), transparent); }
|
||||||
|
.row.src-audit:hover, .row.src-logstream:hover, .row.src-file:hover { background: color-mix(in srgb, var(--src) 13%, var(--row-hover)); }
|
||||||
|
.src-badge {
|
||||||
|
justify-self: start; box-sizing: content-box; width: 6ch; padding: 0 4px; border-radius: 4px; text-align: center;
|
||||||
|
font-family: var(--log-font, var(--mono)); font-size: 10px; font-weight: 700; letter-spacing: .02em; line-height: 1.3;
|
||||||
|
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 */
|
/* Codes of the tags found in the message: grey badges, fixed size */
|
||||||
.row .codes { white-space: nowrap; overflow: hidden; }
|
.row .codes { white-space: nowrap; overflow: hidden; }
|
||||||
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
|
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
|
||||||
.row .codes b, .tag-code {
|
.row .codes b, .tag-code {
|
||||||
min-width: 2.2ch; padding: 1px 4px; border-radius: 5px; text-align: center;
|
min-width: 2.2ch; padding: 0 4px; border-radius: 5px; text-align: center;
|
||||||
/* same font as the severity badges (the log font) */
|
/* same font as the severity badges (the log font) */
|
||||||
font-family: var(--log-font, var(--mono)); font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
|
font-family: var(--log-font, var(--mono)); font-size: 10.5px; font-weight: 700; letter-spacing: .03em;
|
||||||
line-height: 1.35; font-variant-numeric: tabular-nums;
|
line-height: 1.3; font-variant-numeric: tabular-nums;
|
||||||
background: var(--code-bg); color: #0b0f17;
|
background: var(--code-bg); color: #0b0f17;
|
||||||
}
|
}
|
||||||
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px var(--code-bg); color: var(--muted); }
|
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px var(--code-bg); color: var(--muted); }
|
||||||
@@ -433,6 +505,22 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
|||||||
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap;
|
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 {
|
.pill {
|
||||||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
|
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
|
||||||
border: 0; border-radius: 999px; padding: 7px 16px;
|
border: 0; border-radius: 999px; padding: 7px 16px;
|
||||||
@@ -452,6 +540,7 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
|||||||
.conn.ok::before { color: var(--sev-info); }
|
.conn.ok::before { color: var(--sev-info); }
|
||||||
.conn.ko::before { color: var(--sev-err); }
|
.conn.ko::before { color: var(--sev-err); }
|
||||||
#stats .bad { 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 */
|
/* "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 { width: 24px; height: 24px; margin: -5px -6px -5px auto; flex: none; } /* no taller bar */
|
||||||
.to-top svg { width: 16px; height: 16px; }
|
.to-top svg { width: 16px; height: 16px; }
|
||||||
@@ -465,17 +554,30 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
|
|||||||
|
|
||||||
/* ---------- Settings dialog ---------- */
|
/* ---------- Settings dialog ---------- */
|
||||||
dialog.settings {
|
dialog.settings {
|
||||||
width: min(900px, calc(100vw - 32px));
|
width: min(1280px, calc(100vw - 32px));
|
||||||
height: min(660px, calc(100vh - 64px)); max-height: none;
|
height: calc(100vh - 48px); max-height: 960px;
|
||||||
}
|
}
|
||||||
dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; }
|
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); }
|
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: minmax(176px, max-content) minmax(0, 1fr); }
|
||||||
.set-nav {
|
.set-side {
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
|
||||||
padding: 12px 10px; border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: color-mix(in srgb, var(--panel-2) 50%, transparent);
|
background: color-mix(in srgb, var(--panel-2) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
.set-nav { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px; }
|
||||||
|
/* Save / Cancel, under the section labels: Save takes the accent color while changes wait. */
|
||||||
|
.set-actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 10px 12px; padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.set-actions .btn { flex: 1; justify-content: center; }
|
||||||
|
.set-actions .btn.save { color: var(--muted); }
|
||||||
|
.set-actions .btn svg { width: 16px; height: 16px; }
|
||||||
|
.set-actions.dirty .btn.save { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.set-actions.dirty .btn.save:hover { filter: brightness(1.08); }
|
||||||
|
.set-state { flex-basis: 100%; margin: 2px 2px 0; font-size: 12px; color: var(--muted); }
|
||||||
|
.set-actions.dirty .set-state { color: var(--accent); font-weight: 550; }
|
||||||
.set-nav button {
|
.set-nav button {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
height: 38px; padding: 0 12px; border: 0; border-radius: 9px;
|
height: 38px; padding: 0 12px; border: 0; border-radius: 9px;
|
||||||
@@ -485,17 +587,29 @@ 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:hover { background: var(--panel-2); color: var(--text); }
|
||||||
.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
|
.set-nav button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||||
.set-nav svg { width: 18px; height: 18px; flex: none; }
|
.set-nav svg { width: 18px; height: 18px; flex: none; }
|
||||||
.set-panels { overflow-y: auto; padding: 2px 24px 22px; }
|
.set-panels { overflow-y: auto; padding: 16px 20px 20px; }
|
||||||
.set-panel > .set-section:first-child { margin-top: 16px; }
|
/* 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; }
|
.hint { margin-top: 8px !important; }
|
||||||
.lbl { font-size: 13px; font-weight: 550; }
|
.lbl { font-size: 13px; font-weight: 550; }
|
||||||
.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; }
|
.set-panel .preview-list { margin: 14px 0 10px; pointer-events: none; }
|
||||||
.preview-list .row {
|
.preview-list .row {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
grid-template-columns: max-content max-content minmax(0, max-content) minmax(0, 1fr);
|
grid-template-columns: max-content max-content max-content minmax(0, max-content) minmax(0, 1fr);
|
||||||
grid-template-areas: "rcv sev host app" "msg msg msg msg";
|
grid-template-areas: "src rcv sev host app" "msg msg msg msg msg";
|
||||||
gap: 3px 10px;
|
gap: 3px 10px;
|
||||||
}
|
}
|
||||||
|
.preview-list .row .src-badge { grid-area: src; }
|
||||||
.preview-list .row time.rcv { grid-area: rcv; }
|
.preview-list .row time.rcv { grid-area: rcv; }
|
||||||
.preview-list .row time.mt { display: none; }
|
.preview-list .row time.mt { display: none; }
|
||||||
.preview-list .row .sev { grid-area: sev; }
|
.preview-list .row .sev { grid-area: sev; }
|
||||||
@@ -531,6 +645,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;
|
background: var(--panel-2); font-family: var(--mono); font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.set-section.danger h3 { color: var(--sev-err); }
|
.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; }
|
.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 { 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; }
|
.btn.danger:hover:not(:disabled) { background: var(--sev-err); border-color: var(--sev-err); color: #fff; }
|
||||||
@@ -605,9 +730,19 @@ input.switch:checked::after { transform: translateX(14px); }
|
|||||||
input.switch:disabled { cursor: not-allowed; }
|
input.switch:disabled { cursor: not-allowed; }
|
||||||
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
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; }
|
/* Settings > Pattern: toolbar kept in view while the list scrolls, dense rows. */
|
||||||
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; }
|
.tags-section { padding-top: 12px !important; }
|
||||||
.tag-add select.field { width: auto; }
|
.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 { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||||
.seg button {
|
.seg button {
|
||||||
@@ -617,61 +752,103 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.seg button:hover { color: var(--text); }
|
.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); }
|
.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 {
|
.tag-row {
|
||||||
display: grid; align-items: center; gap: 8px 10px;
|
display: grid; align-items: center; gap: 4px 6px;
|
||||||
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
|
grid-template-columns: 2.4em 30px minmax(7rem, 1fr) 10rem auto 26px;
|
||||||
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
|
padding: 3px 4px 3px 6px; border: 1px solid var(--border); border-radius: 8px;
|
||||||
background: var(--panel-2);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
.tag-row[hidden] { display: none; }
|
||||||
.tag-row.off { opacity: .55; }
|
.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"] {
|
.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;
|
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="color"]::-webkit-color-swatch { border: 0; border-radius: 5px; }
|
||||||
.tag-row input[type="text"] {
|
.tag-row input[type="text"] {
|
||||||
height: 32px; padding: 0 10px; min-width: 0;
|
height: 26px; padding: 0 8px; min-width: 0;
|
||||||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
|
border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
|
||||||
font-family: var(--mono); font-size: 13px; outline: 0;
|
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 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 .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: 4px; }
|
.tag-row .opts { display: flex; gap: 3px; }
|
||||||
.opt {
|
.opt {
|
||||||
display: inline-flex; align-items: center; height: 28px; padding: 0 8px;
|
display: inline-flex; align-items: center; height: 24px; padding: 0 6px;
|
||||||
border: 1px solid var(--border); border-radius: 7px; background: var(--panel);
|
border: 1px solid var(--border); border-radius: 6px; background: var(--panel);
|
||||||
font-size: 11.5px; font-weight: 600; color: var(--muted); cursor: pointer; user-select: none;
|
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 input { display: none; }
|
||||||
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
.opt:has(input:checked) { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
/* Log files (Settings > Sources) */
|
||||||
|
.file-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.file-row {
|
||||||
|
display: grid; align-items: center; gap: 6px;
|
||||||
|
grid-template-columns: auto minmax(7rem, 1fr) minmax(10rem, 2fr) minmax(9rem, 1.4fr) minmax(6rem, .8fr) 26px;
|
||||||
|
padding: 6px 6px 6px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
|
||||||
|
}
|
||||||
|
.file-row.off > input[type="text"], .file-row.off > select { opacity: .6; }
|
||||||
|
.file-row input[type="text"] {
|
||||||
|
height: 28px; padding: 0 8px; min-width: 0;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); font-size: 12.5px; outline: 0;
|
||||||
|
}
|
||||||
|
.file-row input.mono { font-family: var(--mono); }
|
||||||
|
.file-row input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.file-row select.field { height: 28px; }
|
||||||
|
.file-row .icon-btn { width: 26px; height: 26px; border-radius: 6px; }
|
||||||
|
.file-row .icon-btn svg { width: 15px; height: 15px; }
|
||||||
|
.file-row .fr-regex, .file-row .fr-status, .file-row .fr-sample { grid-column: 2 / -1; }
|
||||||
|
.file-row .fr-sample {
|
||||||
|
margin: 0; font: 11.5px var(--mono); color: var(--muted);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.file-row .fr-sample[hidden] { display: none; }
|
||||||
|
.file-row .fr-regex[hidden] { display: none; }
|
||||||
|
.file-row .fr-status { margin: 0 !important; font-size: 12px; }
|
||||||
|
.file-toolbar { margin-top: 10px; }
|
||||||
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
|
.tag-row .err { grid-column: 1 / -1; color: var(--sev-err); font-size: 12px; }
|
||||||
.tag-row .err:empty { display: none; }
|
.tag-row .err:empty { display: none; }
|
||||||
|
|
||||||
/* ---------- Mobile ---------- */
|
/* ---------- Mobile ---------- */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.file-row { grid-template-columns: auto minmax(0, 1fr) 26px; }
|
||||||
|
.file-row [data-del] { grid-column: 3; grid-row: 1; }
|
||||||
|
.file-row > [data-f="path"], .file-row > [data-f="format"], .file-row > [data-f="host"] { grid-column: 2 / -1; }
|
||||||
body { font-size: 13.5px; }
|
body { font-size: 13.5px; }
|
||||||
.topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; }
|
.topbar { flex-wrap: wrap; padding: 8px 16px; gap: 8px 10px; }
|
||||||
.search { order: 3; flex-basis: 100%; max-width: none; }
|
.search { order: 3; flex-basis: 100%; max-width: none; }
|
||||||
.live .lbl { display: none; }
|
.live .lbl { display: none; }
|
||||||
|
.whoami .who-name { max-width: 12ch; }
|
||||||
.filters { padding: 10px 16px 6px; }
|
.filters { padding: 10px 16px 6px; }
|
||||||
.filters select { flex: 1 1 calc(50% - 8px); max-width: none; min-width: 0; }
|
.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; }
|
.spacer { display: none; }
|
||||||
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.histo { padding: 0 16px 6px; }
|
.histo { padding: 0 16px 6px; }
|
||||||
.h-leg { display: none; }
|
.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 */
|
/* Phones: no columns, two lines per log (layout below); the widths do not apply */
|
||||||
.table { display: block; }
|
.table { display: block; }
|
||||||
.table .list { display: block; margin: 0; }
|
.table .list { display: block; margin: 0; }
|
||||||
.list-head { display: none; }
|
.list-head { display: none; }
|
||||||
.row, .table .row {
|
.row, .table .row {
|
||||||
grid-template-columns: auto auto 1fr auto;
|
grid-template-columns: auto auto auto 1fr auto;
|
||||||
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
|
grid-template-areas: "src rcv sev host codes" "msg msg msg msg msg";
|
||||||
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
gap: 3px 8px; padding: 8px 12px 8px 10px;
|
||||||
}
|
}
|
||||||
|
.row .src-badge { grid-area: src; }
|
||||||
.row time.rcv { grid-area: rcv; }
|
.row time.rcv { grid-area: rcv; }
|
||||||
.row time.mt { display: none; } /* still shown in the details panel */
|
.row time.mt { display: none; } /* still shown in the details panel */
|
||||||
.row .sev { grid-area: sev; }
|
.row .sev { grid-area: sev; }
|
||||||
@@ -685,22 +862,27 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.field-grid { grid-template-columns: 1fr; gap: 4px; }
|
.field-grid { grid-template-columns: 1fr; gap: 4px; }
|
||||||
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); }
|
||||||
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
.set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||||
.set-nav {
|
.set-side { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; }
|
||||||
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px;
|
.set-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; }
|
||||||
border-right: 0; border-bottom: 1px solid var(--border);
|
.set-actions { margin: 0 6px 6px; padding-top: 6px; align-items: center; }
|
||||||
}
|
.set-actions .btn { flex: 0 0 auto; height: 30px; }
|
||||||
|
.set-state { flex: 1; flex-basis: auto; margin: 0; text-align: right; }
|
||||||
.set-nav button {
|
.set-nav button {
|
||||||
flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px;
|
flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px;
|
||||||
font-size: 11.5px; text-align: center;
|
font-size: 11.5px; text-align: center;
|
||||||
}
|
}
|
||||||
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
.set-nav button span { max-width: 100%; white-space: normal; line-height: 1.15; 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, #densitySwitch { display: flex; }
|
||||||
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
|
#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; }
|
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
|
||||||
.field-grid select { margin-bottom: 6px; }
|
.field-grid select { margin-bottom: 6px; }
|
||||||
.status { padding: 6px 16px; }
|
.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 .preview { display: none; }
|
||||||
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
||||||
.tag-row [data-del] { grid-column: 4; grid-row: 1; }
|
.tag-row [data-del] { grid-column: 4; grid-row: 1; }
|
||||||
@@ -731,3 +913,32 @@ body.login-page {
|
|||||||
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
|
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
|
||||||
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }
|
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }
|
||||||
|
|
||||||
|
/* API tokens (Settings > API) */
|
||||||
|
.token-list { display: flex; flex-direction: column; gap: 4px; margin: 12px 0 10px; }
|
||||||
|
.token-row {
|
||||||
|
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center;
|
||||||
|
padding: 8px 10px; border-radius: 8px; background: var(--panel-2); font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.token-row b { font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.token-row code { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
|
||||||
|
.token-row .muted { grid-column: 1; font-size: 11.5px; }
|
||||||
|
.token-row .btn { grid-row: 1 / span 2; grid-column: 2; height: 28px; font-size: 12px; }
|
||||||
|
.token-empty { color: var(--muted); font-size: 12.5px; }
|
||||||
|
.token-form { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.token-form .btn { height: 28px; font-size: 12px; }
|
||||||
|
.token-new { margin-top: 12px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent-soft); }
|
||||||
|
.token-secret { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
|
||||||
|
.token-label { font-size: 12.5px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.token-row.revoked b, .token-row.revoked code { text-decoration: line-through; opacity: .6; }
|
||||||
|
.token-row.pending { border: 1px dashed var(--accent); }
|
||||||
|
.token-example-box { display: flex; gap: 10px; align-items: flex-start; margin-top: 8px; }
|
||||||
|
.token-example-box .token-example { flex: 1; min-width: 0; margin: 0; }
|
||||||
|
.token-example-box .btn { height: 28px; font-size: 12px; }
|
||||||
|
.token-example mark { background: var(--accent-soft); color: var(--accent); border-radius: 3px; padding: 0 2px; font-weight: 600; }
|
||||||
|
.token-secret code { flex: 1; min-width: 0; font: 12px var(--mono); overflow-wrap: anywhere; user-select: all; }
|
||||||
|
.token-secret .btn { height: 28px; font-size: 12px; }
|
||||||
|
.token-example {
|
||||||
|
margin: 8px 0 0; padding: 8px 12px; border-radius: 8px; background: var(--panel-2);
|
||||||
|
font: 12px var(--mono); white-space: pre-wrap; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user