Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f45044a3 | ||
|
|
d61384ed2c | ||
|
|
dd8ca74cad | ||
|
|
2ca89b1c6d | ||
|
|
1a3b3ee23a | ||
|
|
194edee6de | ||
|
|
dcb09e619e | ||
|
|
087d9e7b30 | ||
|
|
e87186e476 | ||
|
|
7e9aefa755 |
No files matched your search
+6
-2
@@ -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
|
||||||
@@ -67,3 +67,7 @@ DMESG_BACKFILL=1h
|
|||||||
AUDIT_BACKFILL=1h
|
AUDIT_BACKFILL=1h
|
||||||
# Host name of the audit events (empty: node= of the records, else /host/etc/hostname, else localhost)
|
# Host name of the audit events (empty: node= of the records, else /host/etc/hostname, else localhost)
|
||||||
AUDIT_HOST=
|
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
|
||||||
+154
-4
@@ -92,6 +92,10 @@ 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.
|
||||||
|
|
||||||
@@ -278,7 +282,7 @@ source est **désactivée par défaut** : activez-la dans **Réglages > Sources
|
|||||||
ne peut pas être résolu), `exe`, `res` (`success` / `failed`, d'après `res=` ou `success=`) et
|
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
|
`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,
|
`authpriv` ; source `audit`. Dans la liste, les lignes d'audit ont un liseré gauche violet,
|
||||||
un fond légèrement teinté et un badge **AUDIT** devant l'app ; le filtre **Source** permet de
|
un fond légèrement teinté et le badge de source **AUDIT** ; le filtre **Source** permet de
|
||||||
les afficher seules.
|
les afficher seules.
|
||||||
- **Noms** : `user` vient du format enrichi (`log_format = ENRICHED`, le défaut des versions
|
- **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`.
|
récentes d'auditd), sinon du `/etc/passwd` de l'hôte s'il est monté en `/host/etc/passwd`.
|
||||||
@@ -289,10 +293,71 @@ source est **désactivée par défaut** : activez-la dans **Réglages > Sources
|
|||||||
enregistrés dans `/data/audit-state.json` : après un redémarrage, la lecture reprend là ;
|
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.
|
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
|
## Logs internes de LogStream
|
||||||
|
|
||||||
Logstream enregistre ses propres événements comme des logs, avec l'**hôte** `logstream`,
|
Logstream enregistre ses propres événements comme des logs, avec l'**hôte** `logstream`,
|
||||||
l'**app** du composant (`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) et la
|
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
|
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,
|
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
|
sources qui ne peuvent pas lire, démarrage et arrêt. Ils sont actifs par défaut à partir du
|
||||||
@@ -325,10 +390,88 @@ 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 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,
|
||||||
|
les jetons d'API et la réinitialisation des colonnes agissent immédiatement.
|
||||||
|
|
||||||
- **Localisation et langue**
|
- **Localisation et langue**
|
||||||
- *Langue* : anglais ou français.
|
- *Langue* : anglais ou français.
|
||||||
@@ -382,6 +525,9 @@ couleur, est mémorisé par navigateur.
|
|||||||
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
|
||||||
alors purger : activez l'[authentification](#authentification) si l'interface est accessible
|
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 créer un avec un nom
|
||||||
|
(affiché une seule fois, à copier tout de suite), voir quand et d'où chacun a servi pour la
|
||||||
|
dernière fois, le révoquer. Réservé aux administrateurs.
|
||||||
|
|
||||||
## Authentification
|
## Authentification
|
||||||
|
|
||||||
@@ -503,6 +649,7 @@ résolutions.
|
|||||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | chemin par défaut du journal d'audit Linux (modifiable dans Réglages > Sources) |
|
| `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_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=` |
|
| `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) |
|
| `SPOOL_MAX_MB` | `1024` | taille du tampon disque des lots refusés par VictoriaLogs (`0` = pas de tampon : 15 s de tentatives, puis perte) |
|
||||||
@@ -560,11 +707,14 @@ 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 de logs |
|
| `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 |
|
| `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é |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ 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.
|
||||||
|
|
||||||
@@ -254,7 +257,7 @@ also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path)
|
|||||||
`auid` (login user ID), `user` (its name, or the uid when it cannot be resolved), `exe`, `res`
|
`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`
|
(`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
|
when the result is a failure, `info` otherwise; facility `authpriv`; source `audit`. In the
|
||||||
list, audit lines have a violet left edge, a light tint and an **AUDIT** badge before the app;
|
list, audit lines have a violet left edge, a light tint and the **AUDIT** source badge;
|
||||||
the **Source** filter shows them alone.
|
the **Source** filter shows them alone.
|
||||||
- **Names**: `user` comes from the enriched format (`log_format = ENRICHED`, the default of
|
- **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
|
recent auditd versions), else from the host's `/etc/passwd` when it is mounted as
|
||||||
@@ -265,10 +268,65 @@ also be changed (saved in `/data/audit.json`; `AUDIT_LOG` sets the default path)
|
|||||||
`/data/audit-state.json`: after a restart, reading resumes there; after a rotation by auditd,
|
`/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.
|
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 internal logs
|
||||||
|
|
||||||
Logstream records its own events as logs, with **host** `logstream`, **app** the component
|
Logstream records its own events as logs, with **host** `logstream`, **app** the component
|
||||||
(`store`, `syslog`, `docker`, `host`, `dmesg`, `audit`, `main`) and the `logstream` source:
|
(`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
|
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
|
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
|
stop. They are on by default from level **warning**; **Settings > Log sources > LogStream internal
|
||||||
@@ -296,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 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, API tokens and resetting column widths act at once.
|
||||||
|
|
||||||
- **Localization**
|
- **Localization**
|
||||||
- *Language*: English or French.
|
- *Language*: English or French.
|
||||||
@@ -347,6 +477,9 @@ remembered per browser.
|
|||||||
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
|
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
|
||||||
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
|
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): create one with a name
|
||||||
|
(shown once, copy it then), see when and from where each was last used, revoke it. Reserved
|
||||||
|
to administrators.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -460,6 +593,7 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
|||||||
| `AUDIT_LOG` | `/var/log/audit/audit.log` | default path of the Linux audit log (can be changed in Settings > Log sources) |
|
| `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_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=` |
|
| `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) |
|
| `SPOOL_MAX_MB` | `1024` | disk buffer size for batches VictoriaLogs could not take (`0` = no buffer: retried for 15 s, then dropped) |
|
||||||
@@ -515,11 +649,14 @@ 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 > Log 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -25,13 +25,19 @@ type API struct {
|
|||||||
host *HostLogs
|
host *HostLogs
|
||||||
dmesg *Dmesg
|
dmesg *Dmesg
|
||||||
audit *Audit
|
audit *Audit
|
||||||
|
files *Files
|
||||||
selfLog *SelfLog
|
selfLog *SelfLog
|
||||||
dbstats dbStatsCache
|
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)
|
||||||
@@ -39,6 +45,8 @@ func (a *API) Routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/dbstats", a.dbStats)
|
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)
|
||||||
@@ -56,6 +64,9 @@ func (a *API) Routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("PUT /api/dmesg", a.dmesgConfigure)
|
mux.HandleFunc("PUT /api/dmesg", a.dmesgConfigure)
|
||||||
mux.HandleFunc("GET /api/audit", a.auditStatus)
|
mux.HandleFunc("GET /api/audit", a.auditStatus)
|
||||||
mux.HandleFunc("PUT /api/audit", a.auditConfigure)
|
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("GET /api/selflog", a.selfLogStatus)
|
||||||
mux.HandleFunc("PUT /api/selflog", a.selfLogConfigure)
|
mux.HandleFunc("PUT /api/selflog", a.selfLogConfigure)
|
||||||
}
|
}
|
||||||
@@ -80,6 +91,41 @@ func (a *API) auditConfigure(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, a.audit.Status())
|
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).
|
// GET /api/selflog: state of the LogStream internal logs (Settings > Sources).
|
||||||
func (a *API) selfLogStatus(w http.ResponseWriter, r *http.Request) {
|
func (a *API) selfLogStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, a.selfLog.Status())
|
writeJSON(w, http.StatusOK, a.selfLog.Status())
|
||||||
@@ -492,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-7
@@ -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)
|
||||||
@@ -39,6 +39,7 @@ services:
|
|||||||
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)
|
DMESG_BACKFILL: ${DMESG_BACKFILL:-1h} # historique lu a l'activation des messages du noyau (dmesg)
|
||||||
AUDIT_BACKFILL: ${AUDIT_BACKFILL:-1h} # historique lu a l'activation de l'audit Linux (auditd)
|
AUDIT_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)
|
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)
|
||||||
@@ -59,10 +60,12 @@ services:
|
|||||||
- /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
|
# 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)
|
# lisible par l'UID 10001 : log_group = adm dans /etc/audit/auditd.conf (voir le README)
|
||||||
# - /var/log/audit:/var/log/audit:ro
|
- /var/log/audit:/var/log/audit:ro
|
||||||
# noms des utilisateurs et de l'hote pour les evenements d'audit (optionnel)
|
# noms des utilisateurs et de l'hote pour les evenements d'audit (optionnel)
|
||||||
# - /etc/passwd:/host/etc/passwd:ro
|
- /etc/passwd:/host/etc/passwd:ro
|
||||||
# - /etc/hostname:/host/etc/hostname: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
|
||||||
@@ -99,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:
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ type config struct {
|
|||||||
auditLog string
|
auditLog string
|
||||||
auditHost string
|
auditHost string
|
||||||
auditFill time.Duration
|
auditFill time.Duration
|
||||||
|
filesFill time.Duration
|
||||||
batchSize int
|
batchSize int
|
||||||
queueSize int
|
queueSize int
|
||||||
flushEvery time.Duration
|
flushEvery time.Duration
|
||||||
@@ -125,6 +126,7 @@ func main() {
|
|||||||
auditLog: getenv("AUDIT_LOG", "/var/log/audit/audit.log"),
|
auditLog: getenv("AUDIT_LOG", "/var/log/audit/audit.log"),
|
||||||
auditHost: os.Getenv("AUDIT_HOST"),
|
auditHost: os.Getenv("AUDIT_HOST"),
|
||||||
auditFill: getenvDuration("AUDIT_BACKFILL", time.Hour),
|
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,
|
||||||
@@ -140,6 +142,10 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
var spool *Spool
|
var spool *Spool
|
||||||
if cfg.spoolMax > 0 {
|
if cfg.spoolMax > 0 {
|
||||||
@@ -198,7 +204,7 @@ 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 {
|
||||||
@@ -218,11 +224,16 @@ func main() {
|
|||||||
// Linux audit log of the host (auditd), off until enabled in Settings > Sources.
|
// 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)
|
api.audit = NewAudit(cfg.auditLog, cfg.hostRoot, cfg.auditHost, cfg.dataDir, cfg.auditFill, sink)
|
||||||
go api.audit.Run(ctx)
|
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.selfLog = selfLog
|
||||||
api.Routes(mux)
|
api.Routes(mux)
|
||||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||||
|
|
||||||
handler, err := newAuth(cfg.auth, readOnly(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)
|
||||||
}
|
}
|
||||||
@@ -235,7 +246,8 @@ func main() {
|
|||||||
if !authOn {
|
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)
|
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
|
||||||
}
|
}
|
||||||
handler = secure(handler, contentSecurityPolicy(static), authOn)
|
// 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,
|
||||||
|
|||||||
@@ -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", "kernel", "audit", "logstream":
|
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","kernel","audit","logstream"))`)
|
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)
|
||||||
|
|||||||
@@ -423,6 +423,15 @@ 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 {
|
||||||
st := map[string]any{
|
st := map[string]any{
|
||||||
"received": s.received.Load(),
|
"received": s.received.Load(),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ 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", "docker", "host", "kernel", "audit" or "logstream"
|
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
|
// Not stored. Wait: the producer can be slowed down when the queue is full
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+596
-113
File diff suppressed because it is too large.
Load diff
@@ -105,6 +105,7 @@
|
|||||||
<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="kernel" data-i18n="srcKernel">Kernel (dmesg)</option>
|
||||||
<option value="audit">Audit</option>
|
<option value="audit">Audit</option>
|
||||||
|
<option value="file" data-i18n="srcFile">Files</option>
|
||||||
<option value="logstream">LogStream</option>
|
<option value="logstream">LogStream</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -173,6 +175,7 @@
|
|||||||
</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 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>
|
||||||
@@ -194,7 +197,20 @@
|
|||||||
<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 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>
|
||||||
<span data-i18n="tabData">Database</span>
|
<span data-i18n="tabData">Database</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="tab" data-tab="api">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 7l3 3M18 5l2 2"/></svg>
|
||||||
|
<span data-i18n="tabApi">API</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div id="setActions" class="set-actions">
|
||||||
|
<button id="setSave" class="btn save" type="button" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></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 -->
|
||||||
@@ -312,6 +328,15 @@
|
|||||||
<p class="muted small src-help" data-i18n="selfHelp"></p>
|
<p class="muted small src-help" data-i18n="selfHelp"></p>
|
||||||
</section>
|
</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">
|
<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>
|
||||||
@@ -425,6 +450,34 @@
|
|||||||
</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">Create a token</button>
|
||||||
|
</form>
|
||||||
|
<div id="tokenNew" class="token-new" hidden>
|
||||||
|
<p class="small" data-i18n="tokenOnce"></p>
|
||||||
|
<div class="token-secret">
|
||||||
|
<code id="tokenSecret"></code>
|
||||||
|
<button id="tokenCopy" class="btn" type="button" data-i18n="tokenCopy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="set-section">
|
||||||
|
<h3 data-i18n="tokensUse">Usage</h3>
|
||||||
|
<p class="muted small" data-i18n="tokensUseHelp"></p>
|
||||||
|
<pre id="tokenExample" class="token-example"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+115
-28
@@ -22,8 +22,13 @@
|
|||||||
--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-audit: #7c4dbd; /* audit events (auditd) */
|
||||||
--src-ls: #1f9aa3; /* LogStream internal logs */
|
--src-ls: #1f9aa3; /* LogStream internal logs */
|
||||||
|
--src-file: #5f8a1f; /* log files (Settings > Sources > Log files) */
|
||||||
--src-tint: 6%; /* background tint of their rows */
|
--src-tint: 6%; /* background tint of their rows */
|
||||||
|
|
||||||
--sev-crit: #be123c;
|
--sev-crit: #be123c;
|
||||||
@@ -73,8 +78,13 @@
|
|||||||
--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-audit: #a983e6;
|
||||||
--src-ls: #3cc3cc;
|
--src-ls: #3cc3cc;
|
||||||
|
--src-file: #9ccc54;
|
||||||
--src-tint: 9%;
|
--src-tint: 9%;
|
||||||
--sev-crit: #fb7185;
|
--sev-crit: #fb7185;
|
||||||
--sev-err: #f87171;
|
--sev-err: #f87171;
|
||||||
@@ -108,8 +118,13 @@
|
|||||||
--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-audit: #a983e6;
|
||||||
--src-ls: #3cc3cc;
|
--src-ls: #3cc3cc;
|
||||||
|
--src-file: #9ccc54;
|
||||||
--src-tint: 9%;
|
--src-tint: 9%;
|
||||||
--sev-crit: #fb7185;
|
--sev-crit: #fb7185;
|
||||||
--sev-err: #f87171;
|
--sev-err: #f87171;
|
||||||
@@ -340,12 +355,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);
|
||||||
@@ -375,20 +390,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; }
|
||||||
@@ -406,7 +420,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); }
|
||||||
@@ -420,15 +434,21 @@ 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; }
|
||||||
/* Audit events and LogStream internal logs: colored edge (over the severity
|
/* Source badge at the start of every row, fixed width, one color per source.
|
||||||
one), light tint, badge before the app name. */
|
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-audit { --src: var(--src-audit); }
|
||||||
.row.src-logstream { --src: var(--src-ls); }
|
.row.src-logstream { --src: var(--src-ls); }
|
||||||
.row.src-audit, .row.src-logstream { border-left-color: var(--src); background: color-mix(in srgb, var(--src) var(--src-tint), transparent); }
|
.row.src-file { --src: var(--src-file); }
|
||||||
.row.src-audit:hover, .row.src-logstream:hover { background: color-mix(in srgb, var(--src) 13%, var(--row-hover)); }
|
.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 {
|
.src-badge {
|
||||||
display: inline-block; margin-right: 5px; padding: 0 4px; border-radius: 4px; vertical-align: 1px;
|
justify-self: start; box-sizing: content-box; width: 6ch; padding: 0 4px; border-radius: 4px; text-align: center;
|
||||||
font: 700 9.5px/1.45 var(--sans); letter-spacing: .04em;
|
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);
|
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);
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--src) 40%, transparent);
|
||||||
}
|
}
|
||||||
@@ -443,10 +463,10 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
.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); }
|
||||||
@@ -539,11 +559,24 @@ dialog.settings {
|
|||||||
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: minmax(176px, max-content) 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;
|
||||||
@@ -571,10 +604,11 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
|
|||||||
.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; }
|
||||||
@@ -751,11 +785,40 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.tag-row .icon-btn svg { width: 15px; height: 15px; }
|
.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; }
|
||||||
@@ -780,10 +843,11 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.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; }
|
||||||
@@ -797,10 +861,11 @@ 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;
|
||||||
@@ -847,3 +912,25 @@ 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-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