Author SHA1 Message Date
cedric 392fc645ce wip schema: arrows between containers 2026-10-02 09:17:30 +02:00
cedric 81806a6991 wip schema: straight polyline arrows 2026-10-02 09:16:59 +02:00
cedric 6abf1f47fd wip schema source 2026-10-02 09:16:02 +02:00
39 changed files with 3600 additions and 7863 deletions

No files matched your search

+1 -28
View File
@@ -4,30 +4,9 @@ HTTP_PORT=8080
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
# Web UI authentication: local (login page with the account below, or none) or oidc (OpenID Connect provider) # Web UI authentication (empty = disabled)
AUTH_MODE=local
# local mode: user and password (empty = no authentication)
AUTH_USER= AUTH_USER=
AUTH_PASS= AUTH_PASS=
# local mode: PNG logo shown on the login page, path inside the container (empty = no logo).
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
LOGIN_LOGO=
# Ready-made color tags offered in Settings > Filters (see docs/presets.md).
# Empty: /data/presets.json if present, else the built-in list. To use your own file,
# mount it in docker-compose.yml, e.g. ./presets.json:/config/presets.json:ro
PRESETS_FILE=
# Session lifetime, both modes (e.g. 8h, 24h)
SESSION_TTL=12h
# oidc mode: issuer URL exactly as the provider announces it
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added)
OIDC_SCOPES=openid profile email
# Reverse DNS: show host names instead of IP addresses (on/off) # Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver # DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
@@ -40,9 +19,3 @@ EXPORT_MAX=100000
DOCKER_LOGS=on DOCKER_LOGS=on
# History read from a container seen for the first time (e.g. 30m, 1h, 24h; 0 = only new lines) # History read from a container seen for the first time (e.g. 30m, 1h, 24h; 0 = only new lines)
DOCKER_BACKFILL=1h DOCKER_BACKFILL=1h
# Host system logs (enable them in Settings > Sources)
# Group allowed to read the host logs: 4 = adm on Debian/Ubuntu; or the systemd-journal group
# (getent group systemd-journal | cut -d: -f3)
HOST_LOGS_GID=4
# History read when the source is turned on (e.g. 30m, 1h, 24h; 0 = only new entries)
HOST_LOGS_BACKFILL=1h
+1 -1
View File
@@ -4,7 +4,7 @@
FROM golang:1.27.1-alpine3.24 AS build FROM golang:1.27.1-alpine3.24 AS build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod ./
COPY *.go presets.json ./ COPY *.go ./
COPY web ./web COPY web ./web
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/logstream .
-430
View File
@@ -1,430 +0,0 @@
[English](README.md) | Français
# Logstream
Un collecteur syslog simple : il reçoit les logs en UDP/TCP sur le port 514, les stocke dans
[VictoriaLogs](https://docs.victoriametrics.com/victorialogs/) et sert une interface web
soignée (thème clair/sombre, recherche instantanée, direct, tags de couleur, interface en
anglais et en français).
```
devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ VictoriaLogs
▲ └── SSE (live) ──▶ browser
└──── API / UI ◀─────────┘
```
## Architecture
![Schéma logique de Logstream](docs/architecture.png)
- **Ingestion** : les messages syslog (UDP/TCP) et les logs des conteneurs Docker passent tous
par `sink()` (résolution DNS inverse des hôtes donnés par leur IP), puis par la file du
`Store`, qui les envoie par lots à VictoriaLogs.
- **Direct** : `sink()` publie aussi chaque message dans le `Hub`, qui le diffuse aux
navigateurs en SSE.
- **Recherche** : l'API HTTP traduit les filtres de l'interface en requêtes LogsQL envoyées à
VictoriaLogs.
- **Frise** : `/api/histogram` (`histogram.go`) compte les logs par intervalle et par sévérité,
alignés sur l'heure locale ; les bornes du zoom (`from`/`to`) s'appliquent aussi à la liste
et à l'export.
- **État** : les tags et les réglages des sources sont dans `/data` (volume `logstream-data`) ;
les logs eux-mêmes dans le volume `vlogs-data`.
La source modifiable du schéma est
[`docs/architecture.excalidraw`](docs/architecture.excalidraw)
(à ouvrir sur [excalidraw.com](https://excalidraw.com)).
## Démarrage
```bash
cp .env.example .env # optional
docker compose up -d --build
./tools/send-test-logs.sh # sends 100 test messages
```
Ouvrez ensuite <http://localhost:8080>.
## Envoyer des logs
- **rsyslog** (Linux) : ajoutez `*.* @SERVER_IP:514` (UDP) ou `*.* @@SERVER_IP:514` (TCP)
dans `/etc/rsyslog.d/90-logstream.conf`, puis lancez `systemctl restart rsyslog`.
- **Équipements réseau, NAS, pare-feu** : indiquez l'IP du serveur et le port 514 dans leurs
réglages « syslog distant » (remote syslog).
- **Test manuel** : `logger -n 127.0.0.1 -P 514 -d "hello error"` (util-linux) ou
`echo "<14>test ok" | nc -u -w1 127.0.0.1 514`.
**Paramètres > Sources > Syslog** active ou désactive la réception syslog et choisit les
protocoles (UDP, TCP) sans redémarrage ; le choix est enregistré dans `/data/syslog.json`. Cet
onglet affiche aussi l'état d'écoute (et l'erreur si le port est déjà utilisé). Le port
lui-même est publié par docker-compose : modifiez `SYSLOG_PORT` dans `.env`, puis lancez
`docker compose up -d`.
Formats pris en charge : RFC 3164 (BSD) et RFC 5424. En TCP, les deux découpages sont acceptés :
« un message par ligne » et « octet counting » (RFC 6587).
## Recherche
**Mode simple** (par défaut) : chaque mot est cherché comme sous-chaîne, sans tenir compte de
la casse, dans le message, l'hôte et l'application. Les mots sont combinés avec ET.
| Saisie | Signification |
|---|---|
| `error disk` | contient « error » **et** « disk » |
| `"disk full"` | contient la phrase exacte |
| `error -timeout` | contient « error » mais pas « timeout » |
**Mode LogsQL** (bouton `Simple` / `LogsQL`) : le langage de requête complet de VictoriaLogs,
par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host) count()`.
Le direct est désactivé dans ce mode.
Chaque ligne affiche, de gauche à droite : l'**heure de réception** (horloge du serveur),
l'horodatage trouvé dans le message lui-même (`msg_time`), la sévérité, l'hôte, l'application,
les codes des tags trouvés et le message. Un clic sur un hôte ou une application filtre dessus.
Les logs sont indexés, recherchés et triés par **heure de réception** : les équipements dont
l'horloge est fausse (par exemple des points d'accès dont le NTP échoue) apparaissent quand même
dans la bonne plage de temps. Les logs stockés par les versions antérieures à ce changement sont
indexés par l'horodatage de leur message ; purgez-les depuis les Paramètres pour repartir sur
une base propre.
Les badges de sévérité `err`/`crit` et `warning` reprennent les couleurs des tags `error` et
`warning`.
Raccourcis : `/` place le curseur dans la recherche, `Esc` la vide. Un clic sur une ligne
affiche tous ses champs.
Les en-têtes de colonnes restent visibles pendant le défilement. Faites glisser le bord d'un
en-tête (réception, heure message, sévérité, hôte, app) pour redimensionner la colonne, et
double-cliquez dessus pour revenir à la largeur automatique ; le message occupe l'espace
restant. Les largeurs sont mémorisées par le navigateur (**Paramètres > Interface >
Réinitialiser les colonnes** les rétablit toutes). Sur téléphone, la liste garde sa
présentation sur deux lignes, sans colonnes.
## Frise
La frise au-dessus de la liste montre le volume de logs par intervalle, compté selon l'**heure
de réception** sur l'horloge du serveur (elle correspond donc aux heures affichées dans les
lignes).
- Au survol d'un intervalle : ses bornes, son total et le détail par sévérité.
- Un clic sur une barre zoome sur cet intervalle ; un glisser sur plusieurs barres zoome sur la
sélection. La plage de temps affiche alors la période zoomée (« × Annuler le zoom » ou le
choix d'une autre plage en sort) ; la liste, les compteurs et l'export CSV suivent le zoom,
et le direct se met en pause.
- En direct, le dernier intervalle grandit à l'arrivée des messages, et la frise se recharge à
chaque nouvel intervalle. Rien n'est rafraîchi tant que l'onglet du navigateur est masqué ;
la frise se met à jour dès qu'il redevient visible.
**Paramètres > Interface > Frise** (mémorisé par navigateur) : échelle (linéaire, √ par défaut,
log), hauteur (S/M/L : 40/80/120 px), couleur (empilement par sévérité, intensité comparée à
la médiane de la fenêtre : calme, rafale au-delà de 3×, anomalie au-delà de 10×, ou aucune),
barres ou aire, division (automatique, environ 100 intervalles, ou fixe : 1 s, 10 s, 1 min,
5 min, 1 h, 1 jour) et rafraîchissement (désactivé, 5 s, 15 s, 30 s, 1 min, ou à chaque nouvel
intervalle). Une division fixe qui dépasserait 300 intervalles sur la plage est élargie
(signalé par « élargi »). Les intervalles sont alignés sur l'heure locale du fuseau horaire
choisi dans les Paramètres (les jours commencent à minuit, heure locale).
API : `curl 'localhost:8080/api/histogram?range=24h&step=auto&tz=Europe/Paris'` renvoie
`step` (ms), `start` (ms), `count`, `now` (horloge du serveur) et les `buckets` non vides
(`i` = numéro d'intervalle, `n` = total, `sev` = nombre par sévérité). Toutes les routes de
logs acceptent aussi `from` / `to` (millisecondes Unix) à la place de `range`.
## Logs des conteneurs Docker
Logstream collecte aussi les logs des conteneurs Docker qui tournent sur la machine où il est
installé (`DOCKER_LOGS=on`, le défaut dans `docker-compose.yml`). Ils sont recherchés, filtrés,
colorés et exportés comme les messages syslog :
- **host** est le nom de l'hôte Docker, **app** le service compose (ou le nom du conteneur), et
chaque log porte aussi `container`, `container_id`, `image`, `compose_project`,
`compose_service` et `stream` (stdout/stderr), visibles dans le détail de la ligne.
- Le filtre **Source** n'affiche que les logs syslog ou que les logs Docker ; les lignes Docker
ont un petit cube devant le nom de l'application, de la couleur de son projet compose.
- La sévérité vient de la ligne elle-même quand l'application l'écrit : JSON
(`"level":"error"`), logfmt (`level=warn`), `[ERROR]`, ou un niveau en majuscules au début de
la ligne (`ERROR`, `WARN`…). Sinon, elle vaut `info`. Les codes de couleur du terminal sont
supprimés.
- **Paramètres > Sources** affiche une étiquette par conteneur (`project/service`) dans un seul
champ : les conteneurs suivis en couleur, puis les non suivis en gris ; un clic bascule une
étiquette. La couleur identifie le projet compose, et les noms d'applications Docker de la
liste utilisent la même couleur. Les conteneurs arrêtés sont masqués par défaut (« Afficher
les conteneurs arrêtés » les montre, en pointillés, et ils restent modifiables). Une zone de
filtre et « Tout activer » / « Tout désactiver » (appliqués aux étiquettes affichées) aident
quand les conteneurs sont nombreux. Les nouveaux conteneurs sont suivis automatiquement, sauf
si cette option est désactivée. Les choix sont enregistrés par service compose (ou nom de
conteneur) dans `/data/docker.json` : ils survivent aux recréations.
- Logstream mémorise la position lue dans chaque conteneur (`/data/docker-state.json`) : après
un redémarrage, il reprend sans perdre ni dupliquer de lignes. Un conteneur vu pour la
première fois est lu à partir de `DOCKER_BACKFILL` en arrière (1 heure par défaut).
- Logstream lui-même et le proxy ci-dessous ne sont jamais collectés ; ajoutez l'étiquette
`logstream.exclude=true` à tout autre conteneur pour l'exclure définitivement.
**Sécurité** : l'accès au socket Docker équivaut à un accès root sur la machine. Logstream passe
donc par [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy), qui ne laisse
passer que la liste des conteneurs, la lecture des logs, les événements et les informations du
moteur (`GET` uniquement).
## Logs système de l'hôte
Logstream peut aussi collecter les logs système de la machine qui héberge la stack, sans rien
configurer sur l'hôte. La source est **désactivée par défaut** : activez-la dans
**Paramètres > Sources > Logs système de l'hôte** (enregistré dans `/data/hostlogs.json`).
- `docker-compose.yml` monte `/var/log` et `/run/log/journal` en lecture seule sous `/host`.
- Si l'hôte utilise systemd, Logstream lit directement les fichiers du **journal systemd**
(pas besoin de `journalctl` dans l'image) : **host** est le nom de la machine, **app** le
programme (`SYSLOG_IDENTIFIER`), la sévérité et la facility viennent du journal, et l'unité
systemd est conservée dans `unit`. Sinon, il suit les fichiers texte de `/var/log` (`syslog`,
`messages`, `*.log`), analysés comme des lignes syslog, avec le nom du fichier dans `log_file`.
- Ces logs ont la source `host` : le filtre **Source** permet de les afficher seuls.
- À l'activation, la dernière heure est lue d'abord (`HOST_LOGS_BACKFILL`) ; la position
atteinte est enregistrée dans `/data/hostlogs-state.json`, un redémarrage ne perd donc ni ne
duplique d'entrées.
- **Droits** : le conteneur tourne avec un utilisateur sans privilège et reçoit le groupe `adm`
(gid 4), qui peut lire le journal et `/var/log` sur Debian et Ubuntu. Sur d'autres systèmes,
réglez `HOST_LOGS_GID` dans `.env` sur le gid de `systemd-journal`
(`getent group systemd-journal | cut -d: -f3`). Paramètres > Sources affiche un message clair
si l'accès est refusé.
- Limites : les champs du journal compressés par journald (messages de plus de 512 octets,
compressés en zstd/lz4/xz) ne peuvent pas être décodés sans bibliothèque supplémentaire ; ils
sont comptés dans les Paramètres et affichés comme « (compressed journal entry) ». Les fichiers
texte tournés ou compressés (`*.1`, `*.gz`) ne sont pas lus.
## Export CSV
Le bouton **Exporter** (à côté du nombre de logs) télécharge tous les logs stockés qui
correspondent aux filtres en cours (recherche, plage de temps, sévérité, hôte, application),
du plus récent au plus ancien, jusqu'à `EXPORT_MAX` lignes (100 000 par défaut), et pas
seulement les lignes à l'écran. Deux variantes :
- **CSV** : séparateur virgule, UTF-8.
- **CSV pour Excel** : séparateur point-virgule avec un BOM UTF-8, pour qu'Excel en français
l'ouvre directement avec les accents. Les cellules qui commencent par `=`, `+`, `-` ou `@`
sont préfixées par `'` pour qu'un message de log forgé ne puisse pas s'exécuter comme une
formule.
Colonnes : `received`, `message_time` (toutes deux au format `YYYY-MM-DD HH:MM:SS.mmm` dans le
fuseau horaire choisi dans les Paramètres), `severity`, `facility`, `host`, `host_ip`, `app`,
`pid`, `source_ip`, `proto`, `message`. En mode LogsQL, seule la partie filtre est prise en
charge (pas de `| pipes`).
En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error&range=24h&tz=Europe/Paris'`.
## Paramètres
L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Tout, sauf les tags de
couleur, est mémorisé par navigateur.
- **Localisation**
- *Langue* : anglais ou français.
- *Date et heure* : fuseau horaire (celui du navigateur, UTC ou environ 80 fuseaux courants)
et format d'affichage de l'heure de réception : `DD/MM/YYYY HH:MM:SS` (par défaut,
l'affichage français habituel), avec millisecondes, `YYYY-MM-DD`, horloge sur 12 heures,
ISO 8601 ou epoch Unix. Le fuseau horaire s'applique à toutes les dates affichées.
- **Filtres** : tags de couleur. Chaque tag a un mot-clé, une couleur de fond (le texte passe
automatiquement en noir ou en blanc pour rester lisible) et des options : mot entier, respect
de la casse, expression régulière, actif. Les tags sont stockés sur le serveur dans
`/data/tags.json` (volume `logstream-data`) : ils sont donc partagés par tous les navigateurs.
Tags par défaut (pastel) : `warning` (orange) et `error` (rouge) ; `ok` (vert) est dans le
préréglage *Niveaux de log*. Les tags par
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
couleurs pastel.
Le menu *+ Préréglage…* ajoute des tags tout faits : logs d'accès HTTP/HTTPS (codes de statut,
méthodes, sondes, robots, erreurs TLS et proxy), logs système (SSH, sudo, noyau, systemd,
pare-feu), applications (Docker, bases de données) et motifs généraux (niveaux de log,
adresses IPv4). Les tags déjà présents ne sont pas ajoutés en double, et les tags ajoutés se
modifient comme les autres. La liste vient d'un fichier texte modifiable (`PRESETS_FILE`) :
voir [docs/presets.fr.md](docs/presets.fr.md) pour le détail de chaque préréglage et le
format du fichier. Dans une expression
régulière, un groupe nommé `hl` (`(?<hl>…)`) ne colore que cette partie de la correspondance :
les préréglages s'en servent pour colorer le code de statut ou la méthode, pas le texte autour.
Chaque tag reçoit un code à deux chiffres (`01`, `02`…) attribué par le serveur : il reste
attaché au tag jusqu'à sa suppression (les tags créés par les versions précédentes en reçoivent
un aussi). La colonne *Filtres* de la liste affiche, en badges gris, les codes des tags actifs
trouvés dans chaque message ; elle a la place pour 3, au-delà elle en affiche 2 et `+N`, et
l'infobulle les liste tous.
- **Interface**
- *Thème* : Système (suit la préférence de l'ordinateur ou du téléphone), Clair ou Sombre. Le
bouton soleil/lune de l'en-tête bascule entre clair et sombre.
- *Affichage des logs* : taille du texte (très petite, petite, moyenne, grande), densité
(normale, ou compacte pour afficher environ 50 % de lignes en plus à l'écran) et police :
la police monospace du système, l'une des 3 polices étroites intégrées, servies par
LogStream lui-même et utilisables hors ligne (Inconsolata Condensed, la plus étroite,
Iosevka et Ubuntu Mono), ou l'une des 11 polices libres conçues pour le texte dense
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Ces 11 polices sont
chargées par le navigateur depuis [Bunny Fonts](https://fonts.bunny.net), un service
européen de polices respectueux de la vie privée ; sans accès à internet, la police du
système est utilisée. Les ligatures sont désactivées pour que `->` ou `!=` s'affichent tels
quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface
peut purger : activez l'[authentification](#authentification) si l'interface est accessible
à d'autres.
## Authentification
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
- **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà).
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE.
En mode `local`, la page de connexion suit le thème et la langue de l'interface. La session dure
`SESSION_TTL` (12 h par défaut), survit aux redémarrages (sa clé de signature est dans
`/data/session.key`) et se termine quand `AUTH_USER` ou `AUTH_PASS` change ; le bouton de
déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrits dans les logs avec
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
son chemin dans `LOGIN_LOGO` :
```yaml
# docker-compose.yml, service logstream
volumes:
- ./logo.png:/config/logo.png:ro
```
```bash
# .env
LOGIN_LOGO=/config/logo.png
```
Pour utiliser OIDC :
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
l'URL de retour `https://logs.example.org/auth/callback` (votre adresse).
2. Dans `.env` :
```bash
AUTH_MODE=oidc
OIDC_ISSUER=https://sso.example.org/realms/maison # exactement l'« issuer » du fournisseur
OIDC_CLIENT_ID=logstream
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
```
3. `docker compose up -d`. Les logs affichent `oidc authentication enabled`, ou la raison pour
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
La session dure `SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
déconnexion du fournisseur s'il en a une.
Tout utilisateur accepté par le fournisseur pour ce client peut se connecter : restreignez l'accès
dans le fournisseur (Keycloak : rôles du client ou realm dédié ; Authentik : liaisons de
l'application). Les connexions sont écrites dans les logs de logstream (`oidc: alice logged in`).
Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être
joint à travers un reverse proxy TLS.
## Noms d'hôtes (DNS inverse)
Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout),
Logstream cherche son nom DNS (enregistrement PTR) et stocke le nom dans `host` et l'IP dans
`host_ip`. Les résultats sont mis en cache (1 heure, 10 minutes quand il n'y a pas de nom). Les
logs stockés auparavant avec une IP sont résolus à l'affichage, et le filtre d'hôte affiche
`name (IP)`.
Le conteneur utilise le DNS de Docker, qui relaie vers les résolveurs de l'hôte. Si vos noms
locaux ne sont connus que de votre routeur ou d'un DNS local (Pi-hole, AdGuard, Unbound…),
définissez `DNS_SERVER=192.168.1.1` (son adresse). Mettez `RDNS=off` pour désactiver les
résolutions.
## Configuration
| Variable | Défaut | Rôle |
|---|---|---|
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
| `HTTP_PORT` | `8080` | port de l'interface web |
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur |
| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés |
| `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS |
| `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) |
| `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres |
| `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV |
| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
| `DOCKER_HOST` | `tcp://docker-proxy:2375` dans compose | adresse de l'API Docker (`unix:///var/run/docker.sock` hors compose) |
| `DOCKER_BACKFILL` | `1h` | historique lu pour un conteneur vu pour la première fois |
| `HOST_LOGS_GID` | `4` (adm) dans compose | groupe donné au conteneur pour lire les logs de l'hôte |
| `HOST_LOGS_BACKFILL` | `1h` | historique lu à l'activation de la source « logs système de l'hôte » |
| `HOST_LOGS_ROOT` | `/host` | emplacement de montage des répertoires de l'hôte |
| `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 |
## Débogage
- `docker compose logs -f logstream` : erreurs de réception et erreurs d'envoi vers
VictoriaLogs.
- La barre du bas affiche les compteurs reçus / stockés / perdus et la dernière erreur de
stockage.
- <http://localhost:9428/select/vmui> : l'interface de VictoriaLogs, pour essayer des requêtes
LogsQL.
- API :
```bash
curl 'localhost:8080/api/logs?q=error&range=1h&limit=5' # the response includes the generated LogsQL query
curl localhost:8080/api/stats
curl localhost:8080/api/tags
```
- Lancement hors Docker (Go 1.22+) : `VLOGS_URL=http://localhost:9428 DATA_DIR=./data SYSLOG_ADDR=:5514 go run .`
## Mise à jour
Toutes les versions d'images sont épinglées : un `docker compose pull` ou une reconstruction ne
change jamais un composant à votre insu.
| Emplacement | Image | Version |
|---|---|---|
| `docker-compose.yml` | `victoriametrics/victoria-logs` | `v1.52.0` |
| `docker-compose.yml` | `tecnativa/docker-socket-proxy` | `v0.5.0` |
| `Dockerfile` (build) | `golang` | `1.27.1-alpine3.24` |
| `Dockerfile` (exécution) | `alpine` | `3.24.2` |
Pour mettre à jour l'une d'elles :
1. Lisez les notes de version : [VictoriaLogs](https://docs.victoriametrics.com/victorialogs/changelog/),
[docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy/releases),
[Go](https://go.dev/doc/devel/release), [Alpine](https://alpinelinux.org/releases/).
VictoriaLogs conserve son format de stockage entre versions mineures ; lisez le changelog
avant un changement de version majeure.
2. Modifiez la version dans le fichier indiqué ci-dessus, puis lancez `docker compose up -d --build`.
3. Vérifiez la barre du bas (reçus / stockés / perdus) et **Paramètres > Sources**. Pour revenir
en arrière, remettez la version précédente et lancez la même commande.
## Organisation du code
| Fichier | Contenu |
|---|---|
| `main.go` | configuration, démarrage |
| `auth.go` | authentification : OpenID Connect (découverte, PKCE, contrôle de l'ID token) et cookie de session signé |
| `auth_local.go` | mode `local` : page de connexion (`web/login.html`), cookie de session, `LOGIN_LOGO` |
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
| `histogram.go` | frise : division, alignement des intervalles, `/api/histogram` |
| `hub.go` | envoie les nouveaux messages aux navigateurs (SSE) |
| `rdns.go` | résolutions DNS inverses avec cache |
| `export.go` | export CSV en flux |
| `docker.go` | logs des conteneurs Docker (API, lecteurs, positions, détection du niveau) |
| `syslogserver.go` | écoutes syslog ouvertes et fermées depuis Paramètres > Sources |
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
| `tags.go` | stockage des tags de couleur |
| `presets.go`, `presets.json` | préréglages de tags (`/api/presets`), liste intégrée |
| `api.go` | routes HTTP `/api/*` |
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
## Remarque
Avec Docker Desktop (macOS/Windows), l'IP source vue par le conteneur pour les paquets UDP est
la passerelle Docker. Le champ `host` vient toujours de l'en-tête syslog, qui porte normalement
le vrai nom de l'émetteur.
+20 -129
View File
@@ -1,5 +1,3 @@
English | [Français](README.fr.md)
# Logstream # Logstream
A simple syslog sink: receives logs over UDP/TCP on port 514, stores them in A simple syslog sink: receives logs over UDP/TCP on port 514, stores them in
@@ -14,20 +12,18 @@ devices ──514 udp/tcp──▶ logstream (Go) ──HTTP batches──▶ Vi
## Architecture ## Architecture
![Schéma logique de Logstream](docs/architecture.png) ![Schéma logique de Logstream](docs/logstream-schema-logique.png)
- **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS - **Ingestion**: syslog (UDP/TCP) and Docker container logs both go through `sink()` (reverse DNS
on IP hosts), then the `Store` queue, which sends them in batches to VictoriaLogs. on IP hosts), then the `Store` queue, which sends them in batches to VictoriaLogs.
- **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the - **Live view**: `sink()` also publishes each message to the `Hub`, which streams it to the
browsers over SSE. browsers over SSE.
- **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs. - **Search**: the HTTP API turns the UI filters into LogsQL queries sent to VictoriaLogs.
- **Timeline**: `/api/histogram` (`histogram.go`) counts the logs per interval and severity,
aligned on the local time; the zoom bounds (`from`/`to`) also apply to the list and the export.
- **State**: tags and source settings live in `/data` (`logstream-data` volume); the logs - **State**: tags and source settings live in `/data` (`logstream-data` volume); the logs
themselves in the `vlogs-data` volume. themselves in the `vlogs-data` volume.
The editable source of the diagram is The editable source of the diagram is
[`docs/architecture.excalidraw`](docs/architecture.excalidraw) [`docs/logstream-schema-logique.excalidraw`](docs/logstream-schema-logique.excalidraw)
(open it on [excalidraw.com](https://excalidraw.com)). (open it on [excalidraw.com](https://excalidraw.com)).
## Getting started ## Getting started
@@ -72,7 +68,7 @@ message, host and app. Words are combined with AND.
The live view is disabled in this mode. The live view is disabled in this mode.
Each row shows, from left to right: the **reception time** (server clock), the timestamp Each row shows, from left to right: the **reception time** (server clock), the timestamp
found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an found in the message itself (`msg_time`), severity, host, app and message. Click a host or an
app to filter on it. app to filter on it.
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
@@ -147,32 +143,6 @@ colored and exported like syslog messages:
therefore goes through [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy), therefore goes through [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy),
which only lets through listing containers, reading logs, events and engine info (`GET` only). which only lets through listing containers, reading logs, events and engine info (`GET` only).
## Host system logs
Logstream can also collect the system logs of the machine hosting the stack, without
configuring anything on the host. The source is **off by default**: turn it on in
**Settings > Sources > Host system logs** (saved in `/data/hostlogs.json`).
- `docker-compose.yml` mounts `/var/log` and `/run/log/journal` read-only under `/host`.
- When the host runs systemd, Logstream reads the **systemd journal** files directly (no
`journalctl` needed in the image): **host** is the machine name, **app** the program
(`SYSLOG_IDENTIFIER`), severity and facility come from the journal, and the systemd unit is
kept in `unit`. Otherwise it follows the text files of `/var/log` (`syslog`, `messages`,
`*.log`), parsed like syslog lines, with the file name in `log_file`.
- These logs have the `host` source: the **Source** filter shows them alone.
- When the source is turned on, the last hour is read first (`HOST_LOGS_BACKFILL`); the
position reached is saved in `/data/hostlogs-state.json`, so a restart neither loses nor
duplicates entries.
- **Permissions**: the container runs as an unprivileged user and gets the `adm` group
(gid 4), which can read the journal and `/var/log` on Debian and Ubuntu. On other systems,
set `HOST_LOGS_GID` in `.env` to the gid of `systemd-journal`
(`getent group systemd-journal | cut -d: -f3`). Settings > Sources shows a clear message when
access is denied.
- Limits: journal fields compressed by journald (messages longer than 512 bytes, compressed
with zstd/lz4/xz) cannot be decoded without extra libraries; they are counted in Settings and
shown as "(compressed journal entry)". Rotated or compressed text files (`*.1`, `*.gz`) are
not read.
## CSV export ## CSV export
The **Export** button (next to the log count) downloads every stored log matching the The **Export** button (next to the log count) downloads every stored log matching the
@@ -205,91 +175,22 @@ remembered per browser.
switches to black or white to stay readable) and options: whole word, match case, switches to black or white to stay readable) and options: whole word, match case,
regular expression, active. Tags are stored on the server in `/data/tags.json` regular expression, active. Tags are stored on the server in `/data/tags.json`
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel): (`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
`warning` (orange) and `error` (red); `ok` (green) is in the *Log levels* preset. Default `warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of
tags still using the colors of earlier versions are switched to the pastel ones earlier versions are switched to the pastel ones automatically.
automatically.
The *+ Preset…* menu adds ready-made tags: HTTP/HTTPS access logs (status codes, methods,
probes, bots, TLS and proxy errors), system logs (SSH, sudo, kernel, systemd, firewall),
applications (Docker, databases) and general patterns (log levels, IPv4 addresses). Tags
already in the list are skipped, and the added tags can be edited like any other. The list
comes from a text file you can edit (`PRESETS_FILE`): see [docs/presets.md](docs/presets.md)
for each preset and the file format. In a regular expression, a group named `hl`
(`(?<hl>…)`) colors only that part of the match: the presets use it to color the status code
or the method, not the text around it.
Each tag gets a two-digit code (`01`, `02`…) assigned by the server: it stays with the tag
until the tag is deleted (codes are also given to tags created by earlier versions). The
*Filters* column of the log list shows, as grey badges, the codes of the active tags found in
each message; it has room for 3, beyond that it shows 2 and `+N`, and the tooltip lists them
all.
- **Interface** - **Interface**
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon - *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
button in the header switches between light and dark. button in the header switches between light and dark.
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to - *Log display*: font size (tiny, small, medium, large) and font: the system monospace
fit about 50% more lines on screen) and font: the system monospace font, one of 3 narrow font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source
built-in fonts served by LogStream itself, which work offline (Inconsolata Condensed, the Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat
narrowest, Iosevka and Ubuntu Mono), or one of 11 free fonts made for dense text (JetBrains Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from
Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Inconsolata,
Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). These 11 are loaded by the browser from
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without [Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed.
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
- **Data**: "Delete all logs" permanently erases every stored log (you must type - **Data**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` `PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature. (already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable
is reachable by others. by others.
## Authentication
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
- **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them
empty to have no authentication (for instance behind a reverse proxy that already checks).
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE.
In `local` mode the login page follows the theme and language of the UI. The session lasts
`SESSION_TTL` (12 h by default), survives restarts (its signing key is in `/data/session.key`) and
ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) ends it. Failed logins
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
```yaml
# docker-compose.yml, logstream service
volumes:
- ./logo.png:/config/logo.png:ro
```
```bash
# .env
LOGIN_LOGO=/config/logo.png
```
To use OIDC:
1. In the provider, create a **confidential** client (with a secret) for logstream and register
the redirect URL `https://logs.example.org/auth/callback` (your address).
2. In `.env`:
```bash
AUTH_MODE=oidc
OIDC_ISSUER=https://sso.example.org/realms/home # exactly the "issuer" of the provider
OIDC_CLIENT_ID=logstream
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
```
3. `docker compose up -d`. The logs show `oidc authentication enabled`, or the reason the
provider could not be read (wrong issuer, unreachable…).
Opening the UI sends you to the provider's login page, then back to logstream. The session
lasts `SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
`/data/session.key`); when it ends, the page goes through the login again. The log out button
(top right) ends the logstream session, then opens the provider's log out page if it has one.
Every user the provider accepts for this client can log in: restrict access in the provider
(Keycloak: client roles or a dedicated realm; Authentik: application bindings). Logins are written
in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are
only sent over HTTPS: logstream must be reached through a TLS reverse proxy.
## Host names (reverse DNS) ## Host names (reverse DNS)
@@ -301,6 +202,11 @@ on display, and the host filter shows `name (IP)`.
The container uses Docker's DNS, which forwards to the host's resolvers. If your local names The container uses Docker's DNS, which forwards to the host's resolvers. If your local names
are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
`DNS_SERVER=192.168.1.1` (its address). Set `RDNS=off` to disable lookups. `DNS_SERVER=192.168.1.1` (its address). Set `RDNS=off` to disable lookups.
- **Color tags**: each tag has a keyword, a background color (the text automatically
switches to black or white to stay readable) and options: whole word, match case,
regular expression, active. Tags are stored in `/data/tags.json` (`logstream-data` volume).
Default tags (pastel): `warning` (orange), `error` (red), `ok` (green). Default tags
still using the colors of earlier versions are switched to the pastel ones automatically.
## Configuration ## Configuration
@@ -309,25 +215,14 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `SYSLOG_PORT` | `514` | syslog port published on the host | | `SYSLOG_PORT` | `514` | syslog port published on the host |
| `HTTP_PORT` | `8080` | web UI port | | `HTTP_PORT` | `8080` | web UI port |
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs | | `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) | | `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI |
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes |
| `RDNS` | `on` | resolve IP hosts to DNS names | | `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) | | `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings | | `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export | | `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers | | `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
| `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) | | `DOCKER_HOST` | `tcp://docker-proxy:2375` in compose | Docker API address (`unix:///var/run/docker.sock` outside compose) |
| `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time | | `DOCKER_BACKFILL` | `1h` | history read from a container seen for the first time |
| `HOST_LOGS_GID` | `4` (adm) in compose | group given to the container to read the host logs |
| `HOST_LOGS_BACKFILL` | `1h` | history read when the host system logs source is turned on |
| `HOST_LOGS_ROOT` | `/host` | where the host directories are mounted |
| `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 |
@@ -371,9 +266,7 @@ To update one of them:
| File | Contents | | File | Contents |
|---|---| |---|---|
| `main.go` | configuration, startup | | `main.go` | configuration, startup, authentication |
| `auth.go` | authentication: OpenID Connect (discovery, PKCE, ID token checks) and the signed session cookie |
| `auth_local.go` | `local` mode: login page (`web/login.html`), session cookie, `LOGIN_LOGO` |
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing | | `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries | | `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
| `query.go` | turns UI filters into LogsQL; live-view filter | | `query.go` | turns UI filters into LogsQL; live-view filter |
@@ -383,11 +276,9 @@ To update one of them:
| `export.go` | streamed CSV export | | `export.go` | streamed CSV export |
| `docker.go` | Docker container logs (API, followers, positions, level detection) | | `docker.go` | Docker container logs (API, followers, positions, level detection) |
| `syslogserver.go` | syslog listeners opened and closed from Settings > Sources | | `syslogserver.go` | syslog listeners opened and closed from Settings > Sources |
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
| `tags.go` | color tag storage | | `tags.go` | color tag storage |
| `presets.go`, `presets.json` | color tag presets (`/api/presets`), built-in list |
| `api.go` | `/api/*` HTTP routes | | `api.go` | `/api/*` HTTP routes |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) |
## Note ## Note
-29
View File
@@ -16,13 +16,11 @@ type API struct {
store *Store store *Store
hub *Hub hub *Hub
tags *TagStore tags *TagStore
presets string // presets file, built-in presets when missing
rdns *ReverseDNS rdns *ReverseDNS
allowPurge bool allowPurge bool
exportMax int exportMax int
docker *DockerManager // nil when DOCKER_LOGS is off docker *DockerManager // nil when DOCKER_LOGS is off
syslog *SyslogServer syslog *SyslogServer
host *HostLogs
} }
func (a *API) Routes(mux *http.ServeMux) { func (a *API) Routes(mux *http.ServeMux) {
@@ -35,7 +33,6 @@ func (a *API) Routes(mux *http.ServeMux) {
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("POST /api/tags/reset", a.resetTags) mux.HandleFunc("POST /api/tags/reset", a.resetTags)
mux.HandleFunc("GET /api/presets", a.listPresets)
mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) mux.HandleFunc("PUT /api/tags/{id}", a.updateTag)
mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag) mux.HandleFunc("DELETE /api/tags/{id}", a.deleteTag)
mux.HandleFunc("GET /api/purge", a.purgeStatus) mux.HandleFunc("GET /api/purge", a.purgeStatus)
@@ -45,28 +42,6 @@ func (a *API) Routes(mux *http.ServeMux) {
mux.HandleFunc("PUT /api/syslog", a.syslogConfigure) mux.HandleFunc("PUT /api/syslog", a.syslogConfigure)
mux.HandleFunc("GET /api/docker", a.dockerStatus) mux.HandleFunc("GET /api/docker", a.dockerStatus)
mux.HandleFunc("PUT /api/docker", a.dockerConfigure) mux.HandleFunc("PUT /api/docker", a.dockerConfigure)
mux.HandleFunc("GET /api/hostlogs", a.hostLogsStatus)
mux.HandleFunc("PUT /api/hostlogs", a.hostLogsConfigure)
}
// GET /api/hostlogs: state of the host system logs source (Settings > Sources).
func (a *API) hostLogsStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.host.Status())
}
// PUT /api/hostlogs {"enabled": bool}
func (a *API) hostLogsConfigure(w http.ResponseWriter, r *http.Request) {
var cfg hostLogsConfig
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&cfg); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.host.Configure(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
log.Printf("host system logs changed from %s: %+v", r.RemoteAddr, cfg)
writeJSON(w, http.StatusOK, a.host.Status())
} }
// GET /api/syslog: syslog reception state (Settings > Sources). // GET /api/syslog: syslog reception state (Settings > Sources).
@@ -318,10 +293,6 @@ func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.tags.List()) writeJSON(w, http.StatusOK, a.tags.List())
} }
func (a *API) listPresets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, loadPresets(a.presets))
}
func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) { func decodeTag(w http.ResponseWriter, r *http.Request) (Tag, error) {
var t Tag var t Tag
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t) err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&t)
-622
View File
@@ -1,622 +0,0 @@
package main
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
_ "crypto/sha512" // SHA-384/512 for RS384, ES384, RS512…
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
// AUTH_PASS are set (auth_local.go); AUTH_MODE=oidc delegates the login to an
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
// flow and PKCE. Only the standard library is used.
const (
sessionCookie = "logstream_session"
loginCookie = "logstream_login_" // + state: one cookie per login in progress
loginTTL = 10 * time.Minute
clockSkew = time.Minute
)
type authConfig struct {
mode string
user, pass string // local mode
issuer string
clientID string
clientSecret string
redirectURL string
scopes string
sessionTTL time.Duration
dataDir string
loginLogo string // local mode: PNG shown on the login page
}
// newAuth returns the middleware that protects the UI and the API (except /healthz).
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
if c.sessionTTL <= 0 {
c.sessionTTL = 12 * time.Hour
}
switch strings.ToLower(c.mode) {
case "", "local":
if c.user == "" {
return next, nil
}
l := newLocal(c)
l.next = next
return l, nil
case "oidc":
o, err := newOIDC(c)
if err != nil {
return nil, err
}
o.next = next
log.Printf("oidc authentication enabled (issuer %s)", c.issuer)
return o, nil
}
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
}
type oidcMeta struct {
Issuer string `json:"issuer"`
AuthEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
EndSession string `json:"end_session_endpoint"`
TokenAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
}
type OIDC struct {
cfg authConfig
callback string // path of OIDC_REDIRECT_URL
secure bool // cookies only sent over HTTPS
key []byte // signs the session and login cookies
client *http.Client
next http.Handler
mu sync.Mutex
meta *oidcMeta
keys map[string]crypto.PublicKey
keysAt time.Time
}
func newOIDC(c authConfig) (*OIDC, error) {
var missing []string
for _, v := range [][2]string{
{"OIDC_ISSUER", c.issuer}, {"OIDC_CLIENT_ID", c.clientID},
{"OIDC_CLIENT_SECRET", c.clientSecret}, {"OIDC_REDIRECT_URL", c.redirectURL},
} {
if v[1] == "" {
missing = append(missing, v[0])
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("AUTH_MODE=oidc: missing %s", strings.Join(missing, ", "))
}
ru, err := url.Parse(c.redirectURL)
if err != nil || ru.Host == "" || ru.Path == "" || ru.Path == "/" {
return nil, fmt.Errorf("OIDC_REDIRECT_URL=%q: expected a full URL such as https://logs.example.org/auth/callback", c.redirectURL)
}
if c.scopes == "" {
c.scopes = "openid profile email"
}
if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes
}
return &OIDC{
cfg: c,
callback: ru.Path,
secure: ru.Scheme == "https",
key: sessionKey(c.dataDir),
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
// checkProvider reads the provider configuration at startup so a mistake shows in the logs.
func (o *OIDC) checkProvider() {
if _, err := o.discover(); err != nil {
log.Printf("oidc: %v", err)
}
}
// sessionKey is kept in DATA_DIR so sessions survive a restart.
func sessionKey(dir string) []byte {
path := filepath.Join(dir, "session.key")
if k, err := os.ReadFile(path); err == nil && len(k) >= 32 {
return k
}
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
log.Fatalf("session key: %v", err)
}
if err := os.WriteFile(path, k, 0o600); err != nil {
log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
}
return k
}
type session struct {
User string `json:"u"`
Exp int64 `json:"e"`
}
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
// (and so into a new login).
func writeAuthRequired(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
}
type loginState struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
Exp int64 `json:"e"`
}
func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
o.next.ServeHTTP(w, r)
return
case o.callback:
o.handleCallback(w, r)
return
case "/auth/logout":
o.handleLogout(w, r)
return
}
var s session
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
if r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
return
}
o.next.ServeHTTP(w, r)
return
}
// Not logged in: pages go to the provider, API calls get a 401 that the UI turns
// into a reload (and so into a new login).
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me" {
o.startLogin(w, r)
return
}
writeAuthRequired(w)
}
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
meta, err := o.discover()
if err != nil {
log.Printf("oidc: %v", err)
http.Error(w, "identity provider unreachable, try again later", http.StatusBadGateway)
return
}
state, nonce, verifier := randomString(), randomString(), randomString()+randomString()
ret := r.URL.RequestURI()
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") {
ret = "/"
}
http.SetCookie(w, &http.Cookie{
Name: loginCookie + state,
Value: signCookie(o.key, loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
Path: "/",
MaxAge: int(loginTTL.Seconds()),
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode, // sent back on the redirect from the provider
})
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"response_type": {"code"},
"client_id": {o.cfg.clientID},
"redirect_uri": {o.cfg.redirectURL},
"scope": {o.cfg.scopes},
"state": {state},
"nonce": {nonce},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, addQuery(meta.AuthEndpoint, q), http.StatusFound)
}
func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if e := q.Get("error"); e != "" {
log.Printf("oidc: login refused by the provider: %s %s", e, q.Get("error_description"))
http.Error(w, "login refused by the identity provider: "+e, http.StatusForbidden)
return
}
state := q.Get("state")
var ls loginState
c, err := r.Cookie(loginCookie + state)
if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
http.Error(w, "login expired or started in another browser: open LogStream again", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
user, err := o.exchange(r, q.Get("code"), ls)
if err != nil {
log.Printf("oidc: login failed: %v", err)
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
return
}
log.Printf("oidc: %s logged in", user)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true,
Secure: o.secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, ls.Return, http.StatusFound)
}
// The session ends here; the provider's own session ends on its logout page if it has one.
func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
if meta, err := o.discover(); err == nil && meta.EndSession != "" {
http.Redirect(w, r, addQuery(meta.EndSession, url.Values{"client_id": {o.cfg.clientID}}), http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
// exchange trades the code for tokens and returns the user name from the verified ID token.
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) {
if code == "" {
return "", errors.New("no code in the callback")
}
meta, err := o.discover()
if err != nil {
return "", err
}
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {o.cfg.redirectURL},
"code_verifier": {ls.Verifier},
}
// client_secret_basic is the default; some providers only accept client_secret_post.
post := len(meta.TokenAuthMethods) > 0 && !contains(meta.TokenAuthMethods, "client_secret_basic") && contains(meta.TokenAuthMethods, "client_secret_post")
if post {
form.Set("client_id", o.cfg.clientID)
form.Set("client_secret", o.cfg.clientSecret)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if !post {
req.SetBasicAuth(url.QueryEscape(o.cfg.clientID), url.QueryEscape(o.cfg.clientSecret))
}
res, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("token endpoint: %w", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
}
var tok struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
return "", errors.New("token endpoint: no id_token in the response")
}
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
if err != nil {
return "", err
}
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
if v, _ := claims[k].(string); v != "" {
return v, nil
}
}
return "", errors.New("id_token: no sub")
}
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
func (o *OIDC) verifyIDToken(raw, nonce string) (map[string]any, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, errors.New("id_token: not a JWT")
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := decodeSegment(parts[0], &hdr); err != nil {
return nil, fmt.Errorf("id_token header: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errors.New("id_token: bad signature encoding")
}
key, err := o.keyFor(hdr.Kid)
if err != nil {
return nil, err
}
if err := verifySignature(hdr.Alg, key, []byte(parts[0]+"."+parts[1]), sig); err != nil {
return nil, fmt.Errorf("id_token: %w", err)
}
var claims map[string]any
if err := decodeSegment(parts[1], &claims); err != nil {
return nil, fmt.Errorf("id_token claims: %w", err)
}
if iss, _ := claims["iss"].(string); iss != o.cfg.issuer {
return nil, fmt.Errorf("id_token: issuer %q, expected %q", iss, o.cfg.issuer)
}
var aud []string
switch v := claims["aud"].(type) {
case string:
aud = []string{v}
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
aud = append(aud, s)
}
}
}
if !contains(aud, o.cfg.clientID) {
return nil, fmt.Errorf("id_token: audience %v does not include %q", aud, o.cfg.clientID)
}
if azp, ok := claims["azp"].(string); ok && len(aud) > 1 && azp != o.cfg.clientID {
return nil, fmt.Errorf("id_token: azp %q", azp)
}
now := time.Now()
exp, _ := claims["exp"].(float64)
if exp == 0 || now.After(time.Unix(int64(exp), 0).Add(clockSkew)) {
return nil, errors.New("id_token: expired (check the clocks)")
}
if iat, ok := claims["iat"].(float64); ok && time.Unix(int64(iat), 0).After(now.Add(clockSkew)) {
return nil, errors.New("id_token: issued in the future (check the clocks)")
}
if n, _ := claims["nonce"].(string); subtle.ConstantTimeCompare([]byte(n), []byte(nonce)) != 1 {
return nil, errors.New("id_token: wrong nonce")
}
return claims, nil
}
func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error {
if len(alg) != 5 {
return fmt.Errorf("unsupported algorithm %q", alg)
}
var h crypto.Hash
switch alg[2:] {
case "256":
h = crypto.SHA256
case "384":
h = crypto.SHA384
case "512":
h = crypto.SHA512
}
if h == 0 {
return fmt.Errorf("unsupported algorithm %q", alg)
}
hh := h.New()
hh.Write(signed)
digest := hh.Sum(nil)
switch k := key.(type) {
case *rsa.PublicKey:
switch alg[:2] {
case "RS":
return rsa.VerifyPKCS1v15(k, h, digest, sig)
case "PS":
return rsa.VerifyPSS(k, h, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
}
case *ecdsa.PublicKey:
size := (k.Curve.Params().BitSize + 7) / 8
if alg[:2] != "ES" || len(sig) != 2*size {
break
}
r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:])
if ecdsa.Verify(k, digest, r, s) {
return nil
}
return errors.New("bad signature")
}
return fmt.Errorf("algorithm %q does not match the key", alg)
}
// discover reads the provider configuration once (and again after a failure).
func (o *OIDC) discover() (*oidcMeta, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.meta != nil {
return o.meta, nil
}
u := strings.TrimSuffix(o.cfg.issuer, "/") + "/.well-known/openid-configuration"
var m oidcMeta
if err := o.getJSON(u, &m); err != nil {
return nil, fmt.Errorf("discovery: %w", err)
}
if m.Issuer != o.cfg.issuer {
return nil, fmt.Errorf("discovery: the provider says its issuer is %q, set OIDC_ISSUER to that exact value", m.Issuer)
}
if m.AuthEndpoint == "" || m.TokenEndpoint == "" || m.JWKSURI == "" {
return nil, errors.New("discovery: incomplete provider configuration")
}
o.meta = &m
return o.meta, nil
}
// keyFor returns the signing key kid; the key set is reloaded when the provider rotates its keys.
func (o *OIDC) keyFor(kid string) (crypto.PublicKey, error) {
meta, err := o.discover()
if err != nil {
return nil, err
}
o.mu.Lock()
defer o.mu.Unlock()
pick := func() crypto.PublicKey {
if k, ok := o.keys[kid]; ok {
return k
}
if kid == "" && len(o.keys) == 1 {
for _, k := range o.keys {
return k
}
}
return nil
}
if k := pick(); k != nil {
return k, nil
}
if time.Since(o.keysAt) < 10*time.Second {
return nil, fmt.Errorf("id_token: unknown key %q", kid)
}
var set struct {
Keys []struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
} `json:"keys"`
}
if err := o.getJSON(meta.JWKSURI, &set); err != nil {
return nil, fmt.Errorf("jwks: %w", err)
}
keys := map[string]crypto.PublicKey{}
for _, k := range set.Keys {
if k.Use != "" && k.Use != "sig" {
continue
}
switch k.Kty {
case "RSA":
n, e := decodeBig(k.N), decodeBig(k.E)
if n != nil && e != nil && e.IsInt64() {
keys[k.Kid] = &rsa.PublicKey{N: n, E: int(e.Int64())}
}
case "EC":
var c elliptic.Curve
switch k.Crv {
case "P-256":
c = elliptic.P256()
case "P-384":
c = elliptic.P384()
case "P-521":
c = elliptic.P521()
}
x, y := decodeBig(k.X), decodeBig(k.Y)
if c != nil && x != nil && y != nil && c.IsOnCurve(x, y) {
keys[k.Kid] = &ecdsa.PublicKey{Curve: c, X: x, Y: y}
}
}
}
o.keys, o.keysAt = keys, time.Now()
if k := pick(); k != nil {
return k, nil
}
return nil, fmt.Errorf("id_token: unknown key %q", kid)
}
func (o *OIDC) getJSON(u string, v any) error {
res, err := o.client.Get(u)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", u, res.Status)
}
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
}
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
func signCookie(key []byte, v any) string {
b, _ := json.Marshal(v)
p := base64.RawURLEncoding.EncodeToString(b)
m := hmac.New(sha256.New, key)
m.Write([]byte(p))
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
func verifyCookie(key []byte, s string, v any) bool {
p, sig, ok := strings.Cut(s, ".")
if !ok {
return false
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil {
return false
}
m := hmac.New(sha256.New, key)
m.Write([]byte(p))
if !hmac.Equal(got, m.Sum(nil)) {
return false
}
return decodeSegment(p, v) == nil
}
func decodeSegment(s string, v any) error {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
func decodeBig(s string) *big.Int {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(b) == 0 {
return nil
}
return new(big.Int).SetBytes(b)
}
func randomString() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
func addQuery(endpoint string, q url.Values) string {
sep := "?"
if strings.Contains(endpoint, "?") {
sep = "&"
}
return endpoint + sep + q.Encode()
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
-167
View File
@@ -1,167 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
// accepted so scripts calling the API keep working, but the browser popup is gone.
const loginPage = "/login.html"
var loginFailDelay = time.Second // slows down password guessing
type Local struct {
user, pass string
ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo
key []byte
next http.Handler
}
func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
if c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err)
}
}
log.Printf("local authentication enabled (user %s)", c.user)
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
}
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz", "/style.css":
l.next.ServeHTTP(w, r)
return
case "/auth/logo":
l.serveLogo(w, r)
return
case "/auth/login":
l.handleLogin(w, r)
return
case "/auth/logout":
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, ok := l.sessionUser(r)
if !ok {
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
user, ok = u, true
}
}
switch {
case r.URL.Path == loginPage:
if ok {
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
return
}
w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me":
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
case ok:
l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
target := loginPage
if ret := r.URL.RequestURI(); ret != "/" {
target += "?" + url.Values{"r": {ret}}.Encode()
}
http.Redirect(w, r, target, http.StatusFound)
default:
writeAuthRequired(w)
}
}
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r"))
if !l.check(user, pass) {
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}}
if ret != "/" {
q.Set("r", ret)
}
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return
}
log.Printf("auth: %s logged in from %s", user, clientIP(r))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
Path: "/",
MaxAge: int(l.ttl.Seconds()),
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, ret, http.StatusSeeOther)
}
func (l *Local) sessionUser(r *http.Request) (string, bool) {
var s session
c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
return "", false
}
return s.User, true
}
func (l *Local) check(user, pass string) bool {
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
return u&p == 1
}
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
if l.logo == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, l.logo)
}
// safeReturn keeps the page to open after login inside logstream.
func safeReturn(ret string) string {
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
return "/"
}
return ret
}
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func clientIP(r *http.Request) string {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
-142
View File
@@ -1,142 +0,0 @@
package main
import (
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// newLocalApp puts the local login in front of a handler that echoes "app" and returns a
// browser (client with cookies) that does not follow redirects.
func newLocalApp(t *testing.T, c authConfig) (string, *http.Client) {
t.Helper()
loginFailDelay = 0
c.mode, c.dataDir = "local", t.TempDir()
h, err := newAuth(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
if err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
return "http://app.test", &http.Client{
Jar: jar,
Transport: hosts{"app.test": h},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
func login(t *testing.T, c *http.Client, app, user, pass, ret string) *http.Response {
t.Helper()
res, err := c.PostForm(app+"/auth/login", url.Values{"user": {user}, "pass": {pass}, "r": {ret}})
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
func TestLocalLoginFlow(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
res, _ := c.Get(app + "/api/logs?q=x")
if res.StatusCode != http.StatusUnauthorized || res.Header.Get("WWW-Authenticate") != "" {
t.Fatalf("API without session: %d %q", res.StatusCode, res.Header.Get("WWW-Authenticate"))
}
res, _ = c.Get(app + "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusFound || loc != "/login.html?r=%2F%3Fq%3Ddisk" {
t.Fatalf("page without session: %d %q", res.StatusCode, loc)
}
for _, p := range []string{"/login.html", "/style.css", "/healthz"} {
if code, body := get(t, c, app+p); code != http.StatusOK || body != "app "+p {
t.Errorf("%s without session: %d %q", p, code, body)
}
}
res = login(t, c, app, "admin", "wrong", "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || !strings.Contains(loc, "e=1") {
t.Fatalf("wrong password: %d %q", res.StatusCode, loc)
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatal("session created by a wrong password")
}
res = login(t, c, app, "admin", "pw", "//evil.example/")
if loc := res.Header.Get("Location"); loc != "/" {
t.Fatalf("open redirect: %q", loc)
}
res = login(t, c, app, "admin", "pw", "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || loc != "/?q=disk" {
t.Fatalf("login: %d %q", res.StatusCode, loc)
}
if code, body := get(t, c, app+"/api/logs"); code != http.StatusOK || body != "app /api/logs" {
t.Fatalf("API with session: %d %q", code, body)
}
if code, body := get(t, c, app+"/auth/me"); code != http.StatusOK || !strings.Contains(body, `"user":"admin"`) || !strings.Contains(body, `"mode":"local"`) {
t.Fatalf("/auth/me: %d %s", code, body)
}
if res, _ := c.Get(app + "/login.html"); res.StatusCode != http.StatusFound {
t.Errorf("login page while logged in: %d", res.StatusCode)
}
if res, _ := c.Get(app + "/auth/logout"); res.Header.Get("Location") != "/login.html" {
t.Fatalf("logout: %q", res.Header.Get("Location"))
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatal("session still valid after logout")
}
}
func TestLocalBasicAuthForScripts(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
req, _ := http.NewRequest(http.MethodGet, app+"/api/logs", nil)
req.SetBasicAuth("admin", "pw")
if res, _ := c.Do(req); res.StatusCode != http.StatusOK {
t.Fatalf("basic auth: %d", res.StatusCode)
}
req.SetBasicAuth("admin", "nope")
if res, _ := c.Do(req); res.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong basic auth: %d", res.StatusCode)
}
}
func TestLocalPasswordChangeEndsSessions(t *testing.T) {
dir := t.TempDir()
loginFailDelay = 0
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h1, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "old", dataDir: dir}, echo)
h2, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "new", dataDir: dir}, echo)
cookie := signCookie(h1.(*Local).key, session{User: "admin", Exp: 9999999999})
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
if _, ok := h1.(*Local).sessionUser(r); !ok {
t.Fatal("session refused with the same password")
}
if _, ok := h2.(*Local).sessionUser(r); ok {
t.Fatal("session kept after a password change")
}
}
func TestLocalLogo(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
if code, _ := get(t, c, app+"/auth/logo"); code != http.StatusNotFound {
t.Errorf("no LOGIN_LOGO: %d", code)
}
png := filepath.Join(t.TempDir(), "logo.png")
_ = os.WriteFile(png, []byte("\x89PNG\r\n\x1a\nfake"), 0o644)
app, c = newLocalApp(t, authConfig{user: "admin", pass: "pw", loginLogo: png})
res, _ := c.Get(app + "/auth/logo")
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "image/png" {
t.Errorf("LOGIN_LOGO: %d %q", res.StatusCode, res.Header.Get("Content-Type"))
}
}
func TestLocalWithoutUserIsOpen(t *testing.T) {
if h, _ := newAuth(authConfig{mode: "local"}, http.NotFoundHandler()); h == nil || isLocal(h) {
t.Error("local mode without AUTH_USER should not protect anything")
}
}
func isLocal(h http.Handler) bool { _, ok := h.(*Local); return ok }
-230
View File
@@ -1,230 +0,0 @@
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// hosts routes requests to in-memory handlers (no listening socket needed).
type hosts map[string]http.Handler
func (h hosts) RoundTrip(r *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
h[r.URL.Host].ServeHTTP(rec, r)
res := rec.Result()
res.Request = r
return res, nil
}
// fakeIdP is a minimal OpenID provider: it logs in "alice" without asking.
type fakeIdP struct {
mux *http.ServeMux
rsaKey *rsa.PrivateKey
ecKey *ecdsa.PrivateKey
useEC bool
codes map[string]url.Values // code -> authorize request
claims func(map[string]any) // last-minute changes to the ID token
tokenErr bool
}
func newFakeIdP(t *testing.T) *fakeIdP {
rk, _ := rsa.GenerateKey(rand.Reader, 2048)
ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
p := &fakeIdP{rsaKey: rk, ecKey: ek, codes: map[string]url.Values{}}
mux := http.NewServeMux()
p.mux = mux
iss := "http://idp.test/realm"
mux.HandleFunc("/realm/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": iss, "authorization_endpoint": iss + "/auth", "token_endpoint": iss + "/token",
"jwks_uri": iss + "/jwks", "end_session_endpoint": iss + "/logout",
})
})
mux.HandleFunc("/realm/jwks", func(w http.ResponseWriter, r *http.Request) {
b := func(i *big.Int) string { return base64.RawURLEncoding.EncodeToString(i.Bytes()) }
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{
map[string]string{"kty": "RSA", "kid": "r1", "use": "sig", "n": b(rk.N), "e": "AQAB"},
map[string]string{"kty": "EC", "kid": "e1", "crv": "P-256", "x": b(ek.X), "y": b(ek.Y)},
}})
})
mux.HandleFunc("/realm/auth", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
code := randomString()
p.codes[code] = q
http.Redirect(w, r, q.Get("redirect_uri")+"?code="+code+"&state="+q.Get("state"), http.StatusFound)
})
mux.HandleFunc("/realm/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
id, secret, _ := r.BasicAuth()
authz, ok := p.codes[r.Form.Get("code")]
sum := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
if p.tokenErr || !ok || id != "logstream" || secret != "s3cret" ||
base64.RawURLEncoding.EncodeToString(sum[:]) != authz.Get("code_challenge") ||
r.Form.Get("redirect_uri") != authz.Get("redirect_uri") {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
return
}
delete(p.codes, r.Form.Get("code"))
c := map[string]any{
"iss": iss, "aud": "logstream", "sub": "123", "preferred_username": "alice",
"exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), "nonce": authz.Get("nonce"),
}
if p.claims != nil {
p.claims(c)
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "x", "id_token": p.sign(c)})
})
return p
}
func (p *fakeIdP) sign(claims map[string]any) string {
alg, kid := "RS256", "r1"
if p.useEC {
alg, kid = "ES256", "e1"
}
h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"})
c, _ := json.Marshal(claims)
in := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(c)
d := sha256.Sum256([]byte(in))
var sig []byte
if p.useEC {
r, s, _ := ecdsa.Sign(rand.Reader, p.ecKey, d[:])
sig = make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
} else {
sig, _ = rsa.SignPKCS1v15(rand.Reader, p.rsaKey, crypto.SHA256, d[:])
}
return in + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// newOIDCApp puts logstream's auth in front of a handler that echoes "app" and returns
// a browser (client with cookies) that reaches both the app and the provider.
func newOIDCApp(t *testing.T, idp *fakeIdP) (string, *http.Client) {
h, err := newAuth(authConfig{
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(),
}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
if err != nil {
t.Fatal(err)
}
net := hosts{"app.test": h, "idp.test": idp.mux}
h.(*OIDC).client.Transport = net
jar, _ := cookiejar.New(nil)
return "http://app.test", &http.Client{Jar: jar, Transport: net}
}
func get(t *testing.T, c *http.Client, u string) (int, string) {
t.Helper()
res, err := c.Get(u)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := res.Body.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
return res.StatusCode, b.String()
}
func TestOIDCLoginFlow(t *testing.T) {
for _, ec := range []bool{false, true} {
idp := newFakeIdP(t)
idp.useEC = ec
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("api without session: %d", code)
}
if code, body := get(t, c, app+"/healthz"); code != 200 || body != "app /healthz" {
t.Fatalf("healthz: %d %q", code, body)
}
// A page goes through the provider and comes back to the page asked for.
if code, body := get(t, c, app+"/index.html?x=1"); code != 200 || body != "app /index.html" {
t.Fatalf("login (ec=%v): %d %q", ec, code, body)
}
if code, body := get(t, c, app+"/api/logs"); code != 200 || body != "app /api/logs" {
t.Fatalf("api with session: %d %q", code, body)
}
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"user":"alice"`) {
t.Fatalf("me: %d %q", code, body)
}
// Logout drops the session (the fake provider has no logout page: 404).
get(t, c, app+"/auth/logout")
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("api after logout: %d", code)
}
}
}
func TestOIDCRejectsBadTokens(t *testing.T) {
cases := map[string]func(map[string]any){
"wrong nonce": func(c map[string]any) { c["nonce"] = "x" },
"wrong audience": func(c map[string]any) { c["aud"] = "other" },
"wrong issuer": func(c map[string]any) { c["iss"] = "https://evil" },
"expired": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Hour).Unix() },
}
for name, change := range cases {
idp := newFakeIdP(t)
idp.claims = change
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/"); code != http.StatusForbidden {
t.Errorf("%s: login gave %d, expected 403", name, code)
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Errorf("%s: session created", name)
}
}
}
func TestOIDCCallbackNeedsLoginCookie(t *testing.T) {
idp := newFakeIdP(t)
app, c := newOIDCApp(t, idp)
if code, _ := get(t, c, app+"/auth/callback?code=abc&state=forged"); code != http.StatusBadRequest {
t.Fatalf("forged callback: %d", code)
}
}
func TestOIDCForgedSessionCookie(t *testing.T) {
idp := newFakeIdP(t)
app, c := newOIDCApp(t, idp)
u, _ := url.Parse(app)
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"u":"mallory","e":9999999999}`))
c.Jar.SetCookies(u, []*http.Cookie{{Name: sessionCookie, Value: payload + ".AAAA"}})
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatalf("forged session accepted: %d", code)
}
}
func TestAuthModeConfig(t *testing.T) {
next := http.NotFoundHandler()
if _, err := newAuth(authConfig{mode: "oidc"}, next); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") {
t.Errorf("missing variables not reported: %v", err)
}
if _, err := newAuth(authConfig{mode: "ldap"}, next); err == nil {
t.Error("unknown mode accepted")
}
if h, err := newAuth(authConfig{mode: "local"}, next); err != nil || h == nil {
t.Errorf("local mode: %v", err)
}
}
+15 -31
View File
@@ -12,42 +12,24 @@ services:
- "${HTTP_PORT:-8080}:8080" - "${HTTP_PORT:-8080}: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} # shown in Settings > Sources (the mapping is set above)
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-} AUTH_PASS: ${AUTH_PASS:-}
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes) RDNS: ${RDNS:-on} # replace IP hosts with their DNS name (PTR)
PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md) DNS_SERVER: ${DNS_SERVER:-} # e.g. 192.168.1.1 to query your LAN DNS; empty = system resolver
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
RDNS: ${RDNS:-on} # resol dns
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
ALLOW_PURGE: ${ALLOW_PURGE:-true} ALLOW_PURGE: ${ALLOW_PURGE:-true}
EXPORT_MAX: ${EXPORT_MAX:-100000} EXPORT_MAX: ${EXPORT_MAX:-100000}
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker DOCKER_LOGS: ${DOCKER_LOGS:-on} # collect the logs of this machine's containers
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker DOCKER_HOST: tcp://docker-proxy:2375 # read-only Docker API gateway (below)
DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h} DOCKER_BACKFILL: ${DOCKER_BACKFILL:-1h} # history read from a container seen for the first time
HOST_LOGS_BACKFILL: ${HOST_LOGS_BACKFILL:-1h} # historique lu a l'activation des logs systeme de l'hote
group_add:
- "${HOST_LOGS_GID:-4}" # groupe autorise a lire les logs de l'hote (4 = adm sur Debian/Ubuntu)
volumes: volumes:
- logstream-data:/data # tags.json, docker.json (les choix des conteneurs) - logstream-data:/data # tags.json, docker.json (container choices)
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
# - ./logo.png:/config/logo.png:ro
# prereglages de tags personnalises, avec PRESETS_FILE=/config/presets.json dans .env
# - ./presets.json:/config/presets.json:ro
labels: labels:
logstream.exclude: "true" # pas de collect des logs logstream logstream.exclude: "true" # never collect Logstream's own logs
victorialogs: victorialogs:
# Pinned version: see "Updating" in the README before changing it
image: victoriametrics/victoria-logs:v1.52.0 image: victoriametrics/victoria-logs:v1.52.0
container_name: logstream-victorialogs container_name: logstream-victorialogs
restart: unless-stopped restart: unless-stopped
@@ -55,7 +37,7 @@ services:
- -storageDataPath=/vlogs - -storageDataPath=/vlogs
- -retentionPeriod=${RETENTION:-30d} - -retentionPeriod=${RETENTION:-30d}
- -httpListenAddr=:9428 - -httpListenAddr=:9428
- -delete.enable # obliger pour autoriser la purge de la base via l'interface - -delete.enable # required by "Delete all logs" in Settings
volumes: volumes:
- vlogs-data:/vlogs - vlogs-data:/vlogs
ports: ports:
@@ -63,7 +45,9 @@ services:
- "127.0.0.1:9428:9428" - "127.0.0.1:9428:9428"
docker-proxy: docker-proxy:
# Docker proxy pour eviter de solliciter directement l'API docker # Read-only gateway to the Docker API: Logstream can only list containers,
# read their logs, receive events and engine info. Anything else (start,
# stop, exec, images, volumes…) is refused.
image: tecnativa/docker-socket-proxy:v0.5.0 image: tecnativa/docker-socket-proxy:v0.5.0
container_name: logstream-docker-proxy container_name: logstream-docker-proxy
restart: unless-stopped restart: unless-stopped
@@ -75,7 +59,7 @@ services:
volumes: volumes:
- /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" # its access log would only echo Logstream's own requests
volumes: volumes:
logstream-data: logstream-data:
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

-114
View File
@@ -1,114 +0,0 @@
[English](presets.md) · **Français**
# Préréglages de tags de couleur
Dans **Paramètres › Filtres**, le menu **+ Préréglage…** ajoute d'un clic un groupe de tags de
couleur tout faits. Les tags ajoutés sont des tags ordinaires : vous pouvez changer leur couleur,
leur motif ou leurs options, ou les supprimer. Un tag dont le motif est déjà dans la liste n'est
pas ajouté en double.
La liste vient d'un fichier texte, [`presets.json`](../presets.json), intégré à LogStream. Vous
pouvez le remplacer par votre propre fichier (voir [Utiliser votre propre fichier](#utiliser-votre-propre-fichier)).
## Préréglages intégrés
### HTTP/HTTPS
Ces préréglages lisent les logs d'accès de nginx et Apache (formats common et combined), Traefik
(CLF et JSON), Caddy (JSON) et HAProxy (`option httplog`).
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Codes HTTP | `HTTP 2xx` vert, `HTTP 3xx` bleu, `HTTP 4xx` orange, `HTTP 5xx` rouge | seulement le code de statut, par exemple `404` dans `"GET /x HTTP/1.1" 404 153`, `"status":404` ou `"DownstreamStatus":404`. Les autres nombres de la ligne (taille, chemin) ne sont pas touchés. |
| Méthodes HTTP | `GET/HEAD/OPTIONS` gris, `POST/PUT/PATCH` violet, `DELETE` rose | seulement la méthode dans `"GET /chemin` ou `"method":"GET"` (en majuscules uniquement) |
| Sondes et attaques | `sondes / attaques` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Robots et scripts | `robots / scripts` | les mots finissant par `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| Erreurs TLS/HTTPS et proxy | `erreurs TLS`, `erreurs proxy` | échecs de handshake TLS, certificats expirés ou refusés, `x509:` ; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### Système
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| SSH et connexions | `échecs de connexion` rouge, `connexions` vert | sshd/PAM : `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`… ; `Accepted publickey`, `session opened for user`, `New session … of user` |
| Commandes sudo | `commandes sudo` | la commande lancée, par exemple `COMMAND=/usr/bin/apt` |
| Noyau : OOM, plantages, disques | `mémoire épuisée`, `erreurs noyau` | `Out of memory`, `oom-killer`, `Killed process 4242` ; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| Services systemd | `services en échec` rouge, `démarrage/arrêt de service` vert | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly` ; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Pare-feu et fail2ban | `pare-feu` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Docker et conteneurs | `problèmes de conteneur` | `exited with code 137` (codes non nuls seulement), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Bases de données | `erreurs base de données` | PostgreSQL et MySQL/MariaDB : `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### Général
| Préréglage | Tags | Ce qui est coloré |
| --- | --- | --- |
| Niveaux de log | `fatal / critique` rouge, `info / notice` bleu, `debug / trace` gris, `ok` vert | ces mots en mots entiers, quelle que soit la casse (les tags par défaut `warning` et `error` couvrent le reste) |
| Adresses IPv4 | `adresses IPv4` | `192.168.1.20`, `203.0.113.9`… Les numéros de version à quatre parties comme `1.2.3.4` sont aussi colorés. |
Quand des tags se chevauchent, celui placé le plus haut dans la liste l'emporte : les
préréglages ajoutés après les tags par défaut ne les masquent donc jamais.
## Utiliser votre propre fichier
LogStream lit le fichier indiqué par `PRESETS_FILE`, `/data/presets.json` par défaut (dans le
volume `logstream-data`). S'il n'existe pas, la liste intégrée est utilisée. Le fichier est relu
à chaque ouverture des Paramètres : pas besoin de redémarrer après une modification.
Avec docker-compose, le plus simple est de garder le fichier à côté de `docker-compose.yml` :
1. Copiez [`presets.json`](../presets.json) depuis ce dépôt et modifiez-le.
2. Dans `docker-compose.yml`, décommentez la ligne `- ./presets.json:/config/presets.json:ro`.
3. Dans `.env`, mettez `PRESETS_FILE=/config/presets.json`, puis lancez `docker compose up -d`.
Si le fichier est invalide (erreur JSON, expression régulière ou couleur incorrecte, id en
double), les Paramètres affichent l'erreur et la liste intégrée est utilisée jusqu'à correction.
## Format du fichier
Le fichier est une liste JSON de groupes. Chaque groupe a un nom et une liste de préréglages ;
chaque préréglage a un `id`, un nom et ses tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "monappli",
"name": "Mon appli",
"tags": [
{ "label": "paiement refusé", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "commande", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "lent", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Champ | Obligatoire | Signification |
| --- | --- | --- |
| `group` | oui | nom du groupe dans le menu |
| `id` | oui | identifiant unique du préréglage |
| `name` | oui | nom du préréglage dans le menu |
| `tags[].pattern` | oui | ce qu'il faut colorer : une expression régulière, ou du texte simple avec `"regex": false` |
| `tags[].color` | oui | couleur de fond, `#rrggbb` |
| `tags[].label` | non | nom affiché dans la liste des tags à la place du motif |
| `tags[].regex` | non | `true` par défaut |
| `tags[].wholeWord` | non | mots entiers seulement, `false` par défaut |
| `tags[].caseSensitive` | non | respecter la casse, `false` par défaut |
Les noms et libellés sont soit un seul texte pour toutes les langues (`"Mon appli"`), soit un
texte par langue (`{ "en": "My app", "fr": "Mon appli" }`) ; une langue absente se rabat sur
l'anglais.
Les expressions régulières doivent fonctionner à la fois dans le navigateur (JavaScript) et en
Go, qui les vérifie : évitez les assertions arrière `(?<=…)`, avant `(?=…)` et les références
arrière `\1`. En JSON, chaque barre oblique inverse s'écrit deux fois : `\d` devient `"\\d"`. Un
groupe nommé `hl`, `(?<hl>…)`, ne colore que cette partie de la correspondance, comme le font
les préréglages HTTP avec `(?<hl>5\\d\\d)`.
-111
View File
@@ -1,111 +0,0 @@
**English** · [Français](presets.fr.md)
# Color tag presets
In **Settings › Filters**, the **+ Preset…** menu adds a group of ready-made color tags in one
click. Added tags are ordinary tags: you can change their color, pattern or options, or delete
them. A tag whose pattern is already in the list is not added twice.
The list comes from a text file, [`presets.json`](../presets.json), built into LogStream. You
can replace it with your own file (see [Using your own file](#using-your-own-file)).
## Built-in presets
### HTTP/HTTPS
These presets read access logs from nginx and Apache (common and combined formats), Traefik
(CLF and JSON), Caddy (JSON) and HAProxy (`option httplog`).
| Preset | Tags | What gets colored |
| --- | --- | --- |
| HTTP status codes | `HTTP 2xx` green, `HTTP 3xx` blue, `HTTP 4xx` orange, `HTTP 5xx` red | only the status code, e.g. `404` in `"GET /x HTTP/1.1" 404 153`, `"status":404` or `"DownstreamStatus":404`. Other numbers on the line (size, path) are left alone. |
| HTTP methods | `GET/HEAD/OPTIONS` grey, `POST/PUT/PATCH` purple, `DELETE` pink | only the method in `"GET /path` or `"method":"GET"` (upper case only) |
| Probes and attacks | `probes / attacks` | `wp-login.php`, `xmlrpc.php`, `wp-admin`, `phpmyadmin`, `/.env`, `/.git`, `/.aws`, `/cgi-bin/`, `../`, `%2e%2e`, `/etc/passwd`, `<script`, `union select` |
| Bots and scripts | `bots / scripts` | words ending in `bot` (`Googlebot`, `bingbot`…), `crawler`, `spider`, `curl`, `wget`, `python-requests`, `Go-http-client`, `zgrab`, `masscan`, `nmap`, `sqlmap`, `nikto` |
| TLS/HTTPS and proxy errors | `TLS errors`, `proxy errors` | TLS handshake failures, expired or rejected certificates, `x509:`; `upstream timed out`, `no live upstreams`, `connect() failed`, `connection refused`, `bad gateway`, `gateway timeout`, `service unavailable` |
### System
| Preset | Tags | What gets colored |
| --- | --- | --- |
| SSH and logins | `login failures` red, `logins` green | sshd/PAM: `Failed password`, `Invalid user`, `authentication failure`, `incorrect password attempts`, `NOT in sudoers`…; `Accepted publickey`, `session opened for user`, `New session … of user` |
| sudo commands | `sudo commands` | the command run, e.g. `COMMAND=/usr/bin/apt` |
| Kernel: OOM, crashes, disks | `out of memory`, `kernel errors` | `Out of memory`, `oom-killer`, `Killed process 4242`; `Kernel panic`, `BUG:`, `Oops`, `Call Trace`, `segfault at`, `I/O error`, `EXT4-fs error`, `blocked for more than 120 seconds`, `soft lockup` |
| systemd services | `failed services` red, `service start/stop` green | `Failed to start`, `Failed with result`, `Main process exited, code=killed`, `Start request repeated too quickly`; `Started`, `Stopping`, `Reloaded`, `Reached target` |
| Firewall and fail2ban | `firewall` | `[UFW BLOCK]`, `[UFW ALLOW]`, `DROP`, `REJECT`, `Ban 203.0.113.9`, `Unban …`, `Found …` |
### Applications
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Docker and containers | `container problems` | `exited with code 137` (non-zero codes only), `OOMKilled`, `unhealthy`, `Back-off restarting`, `CrashLoopBackOff`, `container die/kill/oom` |
| Databases | `database errors` | PostgreSQL and MySQL/MariaDB: `deadlock detected`, `duplicate key`, `too many connections`, `lock wait timeout`, `slow query`, `server has gone away`, `Access denied for user`, `password authentication failed`… |
### General
| Preset | Tags | What gets colored |
| --- | --- | --- |
| Log levels | `fatal / critical` red, `info / notice` blue, `debug / trace` grey, `ok` green | these words as whole words, any case (the default `warning` and `error` tags cover the rest) |
| IPv4 addresses | `IPv4 addresses` | `192.168.1.20`, `203.0.113.9`… Four-part version numbers such as `1.2.3.4` are colored too. |
When tags overlap, the one highest in the tag list wins, so presets added after the default
tags never hide them.
## Using your own file
LogStream reads the file named by `PRESETS_FILE`, `/data/presets.json` by default (in the
`logstream-data` volume). When the file does not exist, the built-in list is used. The file is
read again each time Settings is opened: no restart is needed after an edit.
With docker-compose, the simplest is to keep the file next to `docker-compose.yml`:
1. Copy [`presets.json`](../presets.json) from this repository and edit it.
2. In `docker-compose.yml`, uncomment the line `- ./presets.json:/config/presets.json:ro`.
3. In `.env`, set `PRESETS_FILE=/config/presets.json`, then run `docker compose up -d`.
If the file is invalid (JSON error, bad regular expression or color, duplicate id), Settings
shows the error and the built-in list is used until the file is fixed.
## File format
The file is a JSON list of groups. Each group has a name and a list of presets; each preset has
an `id`, a name and its tags.
```json
[
{
"group": { "en": "My apps", "fr": "Mes applis" },
"presets": [
{
"id": "myapp",
"name": "My app",
"tags": [
{ "label": "payment failed", "color": "#fca5a5", "pattern": "payment (?:failed|refused)" },
{ "label": "order", "color": "#86efac", "pattern": "order #\\d+" },
{ "label": "slow", "color": "#fde68a", "pattern": "SLOW", "regex": false, "caseSensitive": true }
]
}
]
}
]
```
| Field | Required | Meaning |
| --- | --- | --- |
| `group` | yes | name of the group in the menu |
| `id` | yes | unique identifier of the preset |
| `name` | yes | name of the preset in the menu |
| `tags[].pattern` | yes | what to color: a regular expression, or plain text with `"regex": false` |
| `tags[].color` | yes | background color, `#rrggbb` |
| `tags[].label` | no | name shown in the tag list instead of the pattern |
| `tags[].regex` | no | `true` by default |
| `tags[].wholeWord` | no | only match whole words, `false` by default |
| `tags[].caseSensitive` | no | match case, `false` by default |
Names and labels are either one text for every language (`"My app"`) or one text per language
(`{ "en": "My app", "fr": "Mon appli" }`); a missing language falls back to English.
Regular expressions must work both in the browser (JavaScript) and in Go, which checks them:
avoid look-behind `(?<=…)`, look-ahead `(?=…)` and back-references `\1`. In JSON, every
backslash is written twice: `\d` becomes `"\\d"`. A group named `hl`, `(?<hl>…)`, colors only
that part of the match, as the HTTP presets do with `(?<hl>5\\d\\d)`.
-452
View File
@@ -1,452 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
)
// hostLogsConfig is saved in /data/hostlogs.json and edited in Settings > Sources.
type hostLogsConfig struct {
Enabled bool `json:"enabled"`
}
// hostPos is where reading resumes in a journal file (by file ID) or a text
// file (by name, with its inode to detect rotations). Off -1: archived file
// read to the end.
type hostPos struct {
Off int64 `json:"off"`
Ino uint64 `json:"ino,omitempty"`
}
// HostLogs collects the system logs of the machine hosting the stack: the
// systemd journal when there is one, else the text files of /var/log. The
// host directories are mounted read-only under root (/host by default).
type HostLogs struct {
root string
sink func(*Entry)
backfill time.Duration
cfgPath string
statePath string
wake chan struct{}
// Owned by the Run goroutine.
pos map[string]hostPos
dirty bool
started bool // a first scan was done since enabling: new files are read from their start
mu sync.Mutex
cfg hostLogsConfig
reset bool
mode string // "journal", "files" or "" (nothing found)
files int
read uint64
compressed uint64
errCode string
errDetail string
}
func NewHostLogs(root, dataDir string, backfill time.Duration, sink func(*Entry)) *HostLogs {
h := &HostLogs{
root: root,
sink: sink,
backfill: backfill,
cfgPath: filepath.Join(dataDir, "hostlogs.json"),
statePath: filepath.Join(dataDir, "hostlogs-state.json"),
wake: make(chan struct{}, 1),
pos: map[string]hostPos{},
}
if b, err := os.ReadFile(h.cfgPath); err == nil {
_ = json.Unmarshal(b, &h.cfg)
}
if b, err := os.ReadFile(h.statePath); err == nil {
_ = json.Unmarshal(b, &h.pos)
h.started = len(h.pos) > 0
}
return h
}
// Run polls the host logs every second while the source is enabled.
func (h *HostLogs) Run(ctx context.Context) {
tick := time.NewTicker(time.Second)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
h.saveState()
return
case <-tick.C:
case <-h.wake:
}
h.mu.Lock()
enabled, reset := h.cfg.Enabled, h.reset
h.reset = false
h.mu.Unlock()
if reset {
// Disabled then enabled again: start over from HOST_LOGS_BACKFILL ago
// rather than reading everything written in between.
h.pos, h.started, h.dirty = map[string]hostPos{}, false, true
}
if enabled {
h.scan(time.Now())
}
if n++; n%5 == 0 || reset {
h.saveState()
}
}
}
func (h *HostLogs) saveState() {
if !h.dirty {
return
}
h.dirty = false
if err := writeJSONFile(h.statePath, h.pos); err != nil {
log.Printf("host logs: saving positions: %v", err)
}
}
func (h *HostLogs) setStatus(mode string, files int, code, detail string) {
h.mu.Lock()
h.mode, h.files, h.errCode, h.errDetail = mode, files, code, detail
h.mu.Unlock()
}
// scan reads what was added since the previous poll.
func (h *HostLogs) scan(now time.Time) {
notBefore := time.Time{}
if !h.started {
notBefore = now.Add(-h.backfill)
}
defer func() { h.started = true }()
var journals []string
for _, dir := range []string{"var/log/journal", "run/log/journal"} {
base := filepath.Join(h.root, dir)
for _, pat := range []string{"*.journal", "*/*.journal"} {
m, _ := filepath.Glob(filepath.Join(base, pat))
journals = append(journals, m...)
}
}
if len(journals) > 0 {
h.scanJournals(journals, notBefore)
return
}
h.scanFiles(notBefore.IsZero())
}
func (h *HostLogs) scanJournals(paths []string, notBefore time.Time) {
seen := map[string]bool{}
var firstErr error
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
firstErr = keepFirst(firstErr, err)
continue
}
hdr, err := readJournalHeader(f)
f.Close()
if err != nil {
continue // file being created, or not a journal
}
key := "j:" + hdr.fileID
seen[key] = true
pos, known := h.pos[key]
if known && pos.Off < 0 {
continue
}
from, nb := pos.Off, time.Time{}
if !known {
if hdr.archived && !notBefore.IsZero() && time.UnixMicro(int64(hdr.tailRealtimeUS)).Before(notBefore) {
h.pos[key], h.dirty = hostPos{Off: -1}, true // archived before the backfill window
continue
}
nb = notBefore
}
next, hdr, err := readJournal(p, from, nb, h.emitJournal)
if err != nil {
firstErr = keepFirst(firstErr, err)
continue
}
if hdr.archived {
next = -1
}
if !known || next != pos.Off {
h.pos[key], h.dirty = hostPos{Off: next}, true
}
}
h.prune("j:", seen)
code, detail := "", ""
if firstErr != nil && len(seen) == 0 {
code, detail = errCode(firstErr), firstErr.Error()
}
h.setStatus("journal", len(seen), code, detail)
}
func keepFirst(first, err error) error {
if first != nil {
return first
}
return err
}
func errCode(err error) string {
if errors.Is(err, fs.ErrPermission) {
return "permission"
}
return "read"
}
func (h *HostLogs) prune(prefix string, seen map[string]bool) {
for k := range h.pos {
if strings.HasPrefix(k, prefix) && !seen[k] {
delete(h.pos, k)
h.dirty = true
}
}
}
// hostLogFile reports the classic text logs of /var/log (rotated and
// compressed copies are left out).
func hostLogFile(name string) bool {
return name == "syslog" || name == "messages" || strings.HasSuffix(name, ".log")
}
const maxHostRead = 4 << 20 // per file and per poll
// scanFiles follows the text files of /var/log, for hosts without journald.
// Files present at the first scan are read from their end (only new lines).
func (h *HostLogs) scanFiles(fromStart bool) {
dir := filepath.Join(h.root, "var/log")
ents, err := os.ReadDir(dir)
if err != nil {
code := errCode(err)
if errors.Is(err, fs.ErrNotExist) {
code = "not_mounted"
}
h.setStatus("", 0, code, err.Error())
return
}
seen := map[string]bool{}
var firstErr error
for _, de := range ents {
if !de.Type().IsRegular() || !hostLogFile(de.Name()) {
continue
}
name := de.Name()
key := "f:" + name
fi, err := de.Info()
if err != nil {
continue
}
var ino uint64
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
ino = uint64(st.Ino)
}
pos, known := h.pos[key]
switch {
case !known && !fromStart:
pos = hostPos{Off: fi.Size(), Ino: ino}
case !known || pos.Ino != ino || fi.Size() < pos.Off:
pos = hostPos{Off: 0, Ino: ino} // new, rotated or truncated file
}
if fi.Size() > pos.Off {
off, err := h.readFile(filepath.Join(dir, name), name, pos.Off)
if err != nil {
firstErr = keepFirst(firstErr, err)
continue // not readable: not counted as followed
}
pos.Off = off
}
seen[key] = true
if old, ok := h.pos[key]; !ok || old != pos {
h.pos[key], h.dirty = pos, true
}
}
h.prune("f:", seen)
code, detail := "", ""
if firstErr != nil && len(seen) == 0 {
code, detail = errCode(firstErr), firstErr.Error()
}
mode := "files"
if len(seen) == 0 && code == "" {
mode, code = "", "empty"
}
h.setStatus(mode, len(seen), code, detail)
}
// readFile sends the complete lines written after off and returns the new offset.
func (h *HostLogs) readFile(path, name string, off int64) (int64, error) {
f, err := os.Open(path)
if err != nil {
return off, err
}
defer f.Close()
buf, err := io.ReadAll(io.NewSectionReader(f, off, maxHostRead))
if err != nil {
return off, err
}
end := bytes.LastIndexByte(buf, '\n')
if end < 0 {
if len(buf) < maxHostRead {
return off, nil // partial line: wait for its end
}
end = len(buf) - 1 // line longer than the read window: cut it
}
now := time.Now()
fac := fileFacility(name)
for _, line := range bytes.Split(buf[:end+1], []byte{'\n'}) {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
e := ParseSyslog(line, "", "file", now)
if e.Host == "" {
e.Host = "localhost"
}
if n, ok := detectLevel(e.Message); ok {
e.SevNum, e.Severity = n, severityNames[n]
} else {
e.SevNum, e.Severity = 6, "info"
}
if fac >= 0 {
e.Facility = facilityNames[fac]
}
e.SourceType = "host"
e.Extra = map[string]string{"log_file": "/var/log/" + name}
h.sink(e)
h.count(1, 0)
}
return off + int64(end) + 1, nil
}
// fileFacility guesses the facility from the usual Debian/RHEL file names.
func fileFacility(name string) int {
switch strings.TrimSuffix(name, ".log") {
case "kern":
return 0
case "mail", "maillog":
return 2
case "daemon":
return 3
case "auth", "secure":
return 4
case "cron":
return 9
}
return -1
}
func (h *HostLogs) count(read, compressed uint64) {
h.mu.Lock()
h.read += read
h.compressed += compressed
h.mu.Unlock()
}
// emitJournal turns a journal entry into an Entry.
func (h *HostLogs) emitJournal(je *journalEntry) {
f := je.Fields
msg := f["MESSAGE"]
if msg == "" && je.Compressed > 0 {
msg = "(compressed journal entry: read it with journalctl)"
}
h.count(1, uint64(je.Compressed))
if strings.TrimSpace(msg) == "" {
return
}
if !utf8.ValidString(msg) {
msg = strings.ToValidUTF8(msg, "�")
}
sev := 6
if n, err := strconv.Atoi(f["PRIORITY"]); err == nil && n >= 0 && n <= 7 {
sev = n
}
fac := 1 // user
switch {
case f["_TRANSPORT"] == "kernel":
fac = 0
case f["_SYSTEMD_UNIT"] != "":
fac = 3 // daemon
}
if n, err := strconv.Atoi(f["SYSLOG_FACILITY"]); err == nil && n >= 0 && n < len(facilityNames) {
fac = n
}
app := firstNonEmpty(f["SYSLOG_IDENTIFIER"], f["_COMM"], strings.TrimSuffix(f["_SYSTEMD_UNIT"], ".service"))
host := firstNonEmpty(f["_HOSTNAME"], "localhost")
h.sink(&Entry{
Time: je.Realtime,
Received: time.Now(),
Host: host,
App: app,
ProcID: firstNonEmpty(f["SYSLOG_PID"], f["_PID"]),
Facility: facilityNames[fac],
Severity: severityNames[sev],
SevNum: sev,
Message: strings.TrimRight(msg, "\n"),
Proto: "journal",
SourceType: "host",
Extra: map[string]string{"unit": f["_SYSTEMD_UNIT"]},
})
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// Configure saves and applies the configuration.
func (h *HostLogs) Configure(cfg hostLogsConfig) error {
h.mu.Lock()
if h.cfg.Enabled && !cfg.Enabled {
h.reset = true
h.mode, h.files, h.errCode, h.errDetail = "", 0, "", ""
}
h.cfg = cfg
h.mu.Unlock()
select {
case h.wake <- struct{}{}:
default:
}
return writeJSONFile(h.cfgPath, cfg)
}
// Status is the state shown in Settings > Sources.
func (h *HostLogs) Status() map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
dirs := []string{}
for _, d := range []string{"var/log/journal", "run/log/journal", "var/log"} {
if _, err := os.Stat(filepath.Join(h.root, d)); err == nil {
dirs = append(dirs, "/"+d)
}
}
sort.Strings(dirs)
return map[string]any{
"enabled": h.cfg.Enabled,
"mode": h.mode,
"files": h.files,
"read": h.read,
"compressed": h.compressed,
"code": h.errCode,
"error": h.errDetail,
"mounted": dirs,
}
}
-206
View File
@@ -1,206 +0,0 @@
package main
import (
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeJournal builds a minimal journal file: header, data objects, entries.
type fakeJournal struct {
compact bool
buf []byte
seq uint64
tail uint64
}
func newFakeJournal(compact bool) *fakeJournal {
j := &fakeJournal{compact: compact, buf: make([]byte, 272)}
copy(j.buf, jSignature)
if compact {
binary.LittleEndian.PutUint32(j.buf[12:], jIncompatCompact)
}
j.buf[16] = 1 // online
copy(j.buf[24:40], "0123456789abcdef")
binary.LittleEndian.PutUint64(j.buf[88:], 272)
return j
}
func (j *fakeJournal) object(typ, flags byte, body []byte) uint64 {
for len(j.buf)%8 != 0 {
j.buf = append(j.buf, 0)
}
off := uint64(len(j.buf))
h := make([]byte, jObjHeaderSize)
h[0], h[1] = typ, flags
binary.LittleEndian.PutUint64(h[8:], uint64(jObjHeaderSize+len(body)))
j.buf = append(append(j.buf, h...), body...)
j.tail = off
binary.LittleEndian.PutUint64(j.buf[136:], off)
return off
}
func (j *fakeJournal) data(field string, flags byte) uint64 {
n := 48
if j.compact {
n = 56
}
return j.object(jObjData, flags, append(make([]byte, n), field...))
}
// entry appends an entry; linked=false leaves it unfinished (tail seqnum not updated).
func (j *fakeJournal) entry(t time.Time, linked bool, fields ...string) {
var items []byte
for _, f := range fields {
flags := byte(0)
if strings.HasPrefix(f, "!") { // compressed data object
f, flags = f[1:], 4
}
off := j.data(f, flags)
if j.compact {
items = binary.LittleEndian.AppendUint32(items, uint32(off))
} else {
items = binary.LittleEndian.AppendUint64(items, off)
items = binary.LittleEndian.AppendUint64(items, 0)
}
}
j.seq++
body := make([]byte, 48)
binary.LittleEndian.PutUint64(body[0:], j.seq)
binary.LittleEndian.PutUint64(body[8:], uint64(t.UnixMicro()))
j.object(jObjEntry, 0, append(body, items...))
if linked {
binary.LittleEndian.PutUint64(j.buf[160:], j.seq)
binary.LittleEndian.PutUint64(j.buf[192:], uint64(t.UnixMicro()))
}
}
func (j *fakeJournal) write(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, j.buf, 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadJournal(t *testing.T) {
for _, compact := range []bool{false, true} {
path := filepath.Join(t.TempDir(), "system.journal")
now := time.Now()
j := newFakeJournal(compact)
j.entry(now.Add(-2*time.Hour), true, "MESSAGE=too old", "PRIORITY=6")
j.entry(now.Add(-time.Minute), true, "MESSAGE=Started cron.", "PRIORITY=5", "SYSLOG_IDENTIFIER=systemd", "_HOSTNAME=srv1", "_PID=1")
j.write(t, path)
var got []*journalEntry
emit := func(e *journalEntry) { got = append(got, e) }
next, _, err := readJournal(path, 0, now.Add(-time.Hour), emit)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Fields["MESSAGE"] != "Started cron." || got[0].Fields["_HOSTNAME"] != "srv1" {
t.Fatalf("compact=%v: got %+v", compact, got)
}
// A new complete entry and one still being written.
j.entry(now, true, "MESSAGE=second", "!MESSAGE=big")
j.entry(now, false, "MESSAGE=unfinished")
j.write(t, path)
got = nil
next2, _, err := readJournal(path, next, time.Time{}, emit)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Fields["MESSAGE"] != "second" || got[0].Compressed != 1 {
t.Fatalf("compact=%v: resumed read got %d entries", compact, len(got))
}
// Once linked, the unfinished entry is read from where reading stopped.
binary.LittleEndian.PutUint64(j.buf[160:], j.seq)
j.write(t, path)
got = nil
if _, _, err := readJournal(path, next2, time.Time{}, emit); err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Fields["MESSAGE"] != "unfinished" {
t.Fatalf("compact=%v: unfinished entry got %+v", compact, got)
}
}
}
func TestHostLogsJournal(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "var/log/journal/machine")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
j := newFakeJournal(true)
j.entry(time.Now(), true, "MESSAGE=Accepted publickey", "PRIORITY=6", "SYSLOG_FACILITY=10", "SYSLOG_IDENTIFIER=sshd", "_PID=42", "_HOSTNAME=srv1", "_SYSTEMD_UNIT=ssh.service")
j.entry(time.Now(), true, "MESSAGE=oops", "PRIORITY=3", "_TRANSPORT=kernel", "_HOSTNAME=srv1")
j.write(t, filepath.Join(dir, "system.journal"))
var got []*Entry
h := NewHostLogs(root, t.TempDir(), time.Hour, func(e *Entry) { got = append(got, e) })
h.scan(time.Now())
if len(got) != 2 {
t.Fatalf("got %d entries", len(got))
}
e := got[0]
if e.App != "sshd" || e.ProcID != "42" || e.Facility != "authpriv" || e.Severity != "info" || e.Host != "srv1" || e.SourceType != "host" || e.Extra["unit"] != "ssh.service" {
t.Fatalf("entry %+v", e)
}
if got[1].Facility != "kern" || got[1].Severity != "err" {
t.Fatalf("kernel entry %+v", got[1])
}
h.scan(time.Now()) // nothing new
if len(got) != 2 {
t.Fatalf("re-read: %d entries", len(got))
}
if st := h.Status(); st["mode"] != "journal" || st["files"] != 1 {
t.Fatalf("status %+v", st)
}
}
func TestHostLogsFiles(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "var/log")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
auth := filepath.Join(dir, "auth.log")
if err := os.WriteFile(auth, []byte("Oct 3 07:00:00 srv1 sshd[1]: old line\n"), 0o644); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "auth.log.1"), []byte("rotated\n"), 0o644)
var got []*Entry
h := NewHostLogs(root, t.TempDir(), time.Hour, func(e *Entry) { got = append(got, e) })
h.scan(time.Now()) // existing content is skipped
if len(got) != 0 {
t.Fatalf("first scan read %d lines", len(got))
}
f, _ := os.OpenFile(auth, os.O_APPEND|os.O_WRONLY, 0)
_, _ = f.WriteString("Oct 3 08:00:00 srv1 sshd[7]: Failed password for root\nOct 3 08:00:01 srv1 sshd[7]: partial")
f.Close()
h.scan(time.Now())
if len(got) != 1 {
t.Fatalf("got %d lines", len(got))
}
e := got[0]
if e.Host != "srv1" || e.App != "sshd" || e.ProcID != "7" || e.Facility != "auth" || e.Extra["log_file"] != "/var/log/auth.log" || e.Proto != "file" {
t.Fatalf("entry %+v", e)
}
if st := h.Status(); st["mode"] != "files" || st["files"] != 1 {
t.Fatalf("status %+v", st)
}
}
func TestHostLogsNotMounted(t *testing.T) {
h := NewHostLogs(filepath.Join(t.TempDir(), "none"), t.TempDir(), time.Hour, func(*Entry) {})
h.scan(time.Now())
if st := h.Status(); st["code"] != "not_mounted" {
t.Fatalf("status %+v", st)
}
}
-174
View File
@@ -1,174 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"os"
"time"
)
// Minimal reader of systemd journal files (*.journal), enough to follow them
// without journalctl: objects are walked in file order, which is the order
// entries were written. Format: https://systemd.io/JOURNAL_FILE_FORMAT/
const (
jHeaderMin = 208 // header fields used below end at offset 208
jObjHeaderSize = 16
jObjData = 1
jObjEntry = 3
jIncompatCompact = 1 << 4
jStateArchived = 2
jObjCompressed = 1<<0 | 1<<1 | 1<<2 // XZ, LZ4, ZSTD: not decoded (no stdlib codec)
)
var jSignature = []byte("LPKSHHRH")
// jHeader holds the header fields the reader needs.
type jHeader struct {
compact bool
archived bool
fileID string
headerSize uint64
tailObject uint64 // offset of the last object
tailSeqnum uint64 // seqnum of the last complete entry
tailRealtimeUS uint64 // realtime of the last entry (µs since the epoch)
}
func readJournalHeader(f io.ReaderAt) (jHeader, error) {
b := make([]byte, jHeaderMin)
if _, err := f.ReadAt(b, 0); err != nil {
return jHeader{}, err
}
if !bytes.Equal(b[:8], jSignature) {
return jHeader{}, errors.New("not a journal file")
}
le := binary.LittleEndian
h := jHeader{
compact: le.Uint32(b[12:])&jIncompatCompact != 0,
archived: b[16] == jStateArchived,
fileID: hex.EncodeToString(b[24:40]),
headerSize: le.Uint64(b[88:]),
tailObject: le.Uint64(b[136:]),
tailSeqnum: le.Uint64(b[160:]),
tailRealtimeUS: le.Uint64(b[192:]),
}
if h.headerSize < jHeaderMin {
return h, errors.New("journal header too small")
}
return h, nil
}
// journalEntry is one entry: its time and its "FIELD=value" pairs.
type journalEntry struct {
Realtime time.Time
Fields map[string]string
Compressed int // fields that could not be read (compressed data)
}
// readJournal reads the entries complete after offset `from` (0 = start of
// the file) and returns the offset to resume from. Entries older than
// `notBefore` are skipped without decoding their fields.
func readJournal(path string, from int64, notBefore time.Time, emit func(*journalEntry)) (next int64, h jHeader, err error) {
f, err := os.Open(path)
if err != nil {
return from, h, err
}
defer f.Close()
if h, err = readJournalHeader(f); err != nil {
return from, h, err
}
if from < int64(h.headerSize) {
from = int64(h.headerSize)
}
end := int64(h.tailObject)
r := bufio.NewReaderSize(io.NewSectionReader(f, from, 1<<62), 64*1024)
le := binary.LittleEndian
hdr := make([]byte, jObjHeaderSize)
off := from
for off <= end {
if _, err := io.ReadFull(r, hdr); err != nil {
return off, h, nil // object still being written
}
typ, size := hdr[0], int64(le.Uint64(hdr[8:]))
if size < jObjHeaderSize {
return off, h, nil
}
body := size - jObjHeaderSize
if typ == jObjEntry && body >= 48 && body < 1<<20 {
obj := make([]byte, body)
if _, err := io.ReadFull(r, obj); err != nil {
return off, h, nil
}
seq := le.Uint64(obj[0:])
if seq == 0 || seq > h.tailSeqnum {
return off, h, nil // not linked yet: retry at the next poll
}
rt := time.UnixMicro(int64(le.Uint64(obj[8:])))
if !rt.Before(notBefore) {
emit(readEntryFields(f, h.compact, rt, obj[48:]))
}
} else if _, err := r.Discard(int(body)); err != nil {
return off, h, nil
}
next := (off + size + 7) &^ 7 // objects are 8-byte aligned
if pad := next - off - size; pad > 0 {
if _, err := r.Discard(int(pad)); err != nil {
return next, h, nil // the object is complete: resume after it
}
}
off = next
}
return off, h, nil
}
// readEntryFields resolves the data objects referenced by an entry.
func readEntryFields(f io.ReaderAt, compact bool, rt time.Time, items []byte) *journalEntry {
le := binary.LittleEndian
e := &journalEntry{Realtime: rt, Fields: make(map[string]string, 16)}
step := 16
if compact {
step = 4
}
payloadAt := int64(64)
if compact {
payloadAt = 72
}
hdr := make([]byte, jObjHeaderSize)
for i := 0; i+step <= len(items); i += step {
var off int64
if compact {
off = int64(le.Uint32(items[i:]))
} else {
off = int64(le.Uint64(items[i:]))
}
if off == 0 {
continue
}
if _, err := f.ReadAt(hdr, off); err != nil || hdr[0] != jObjData {
continue
}
if hdr[1]&jObjCompressed != 0 {
e.Compressed++
continue
}
size := int64(le.Uint64(hdr[8:]))
n := size - payloadAt
if n <= 0 || n > 1<<20 {
continue
}
p := make([]byte, n)
if _, err := f.ReadAt(p, off+payloadAt); err != nil {
continue
}
if k := bytes.IndexByte(p, '='); k > 0 {
e.Fields[string(p[:k])] = string(p[k+1:])
}
}
return e
}
+30 -34
View File
@@ -1,9 +1,10 @@
// Logstream: a syslog (UDP/TCP), Docker and host system logs sink stored in VictoriaLogs, // Logstream: a syslog (UDP/TCP) sink stored in VictoriaLogs,
// with a web interface to browse and search the logs. // with a web interface to browse and search the logs.
package main package main
import ( import (
"context" "context"
"crypto/subtle"
"embed" "embed"
"errors" "errors"
"io/fs" "io/fs"
@@ -28,7 +29,8 @@ type config struct {
httpAddr string httpAddr string
vlogsURL string vlogsURL string
dataDir string dataDir string
auth authConfig authUser string
authPass string
rdns bool rdns bool
dnsServer string dnsServer string
allowPurge bool allowPurge bool
@@ -36,8 +38,6 @@ type config struct {
dockerLogs bool dockerLogs bool
dockerHost string dockerHost string
backfill time.Duration backfill time.Duration
hostRoot string
hostFill time.Duration
batchSize int batchSize int
queueSize int queueSize int
flushEvery time.Duration flushEvery time.Duration
@@ -80,19 +80,8 @@ func main() {
httpAddr: getenv("HTTP_ADDR", ":8080"), httpAddr: getenv("HTTP_ADDR", ":8080"),
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"), vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
dataDir: getenv("DATA_DIR", "/data"), dataDir: getenv("DATA_DIR", "/data"),
auth: authConfig{ authUser: os.Getenv("AUTH_USER"),
mode: getenv("AUTH_MODE", "local"), authPass: os.Getenv("AUTH_PASS"),
user: os.Getenv("AUTH_USER"),
pass: os.Getenv("AUTH_PASS"),
issuer: os.Getenv("OIDC_ISSUER"),
clientID: os.Getenv("OIDC_CLIENT_ID"),
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
scopes: os.Getenv("OIDC_SCOPES"),
// SESSION_TTL applies to both modes; OIDC_SESSION_TTL is its former name.
sessionTTL: getenvDuration("SESSION_TTL", getenvDuration("OIDC_SESSION_TTL", 12*time.Hour)),
loginLogo: os.Getenv("LOGIN_LOGO"),
},
rdns: getenvBool("RDNS", true), rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"), dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true), allowPurge: getenvBool("ALLOW_PURGE", true),
@@ -100,15 +89,11 @@ func main() {
dockerLogs: getenvBool("DOCKER_LOGS", false), dockerLogs: getenvBool("DOCKER_LOGS", false),
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"), dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
backfill: getenvDuration("DOCKER_BACKFILL", time.Hour), backfill: getenvDuration("DOCKER_BACKFILL", time.Hour),
hostRoot: getenv("HOST_LOGS_ROOT", "/host"),
hostFill: getenvDuration("HOST_LOGS_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,
} }
cfg.auth.dataDir = cfg.dataDir
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -143,8 +128,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
presets := getenv("PRESETS_FILE", filepath.Join(cfg.dataDir, "presets.json")) api := &API{store: store, hub: hub, tags: tags, 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}
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 {
@@ -155,22 +139,12 @@ func main() {
log.Printf("docker logs enabled through %s", cfg.dockerHost) log.Printf("docker logs enabled through %s", cfg.dockerHost)
} }
} }
// System logs of the host (journal or /var/log), off until enabled in Settings > Sources.
api.host = NewHostLogs(cfg.hostRoot, cfg.dataDir, cfg.hostFill, sink)
go api.host.Run(ctx)
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, mux)
if err != nil {
log.Fatalf("auth: %v", err)
}
if o, ok := handler.(*OIDC); ok {
go o.checkProvider()
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.httpAddr, Addr: cfg.httpAddr,
Handler: handler, Handler: basicAuth(cfg.authUser, cfg.authPass, mux),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
// Requests inherit the global context so SSE streams end on shutdown. // Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx }, BaseContext: func(net.Listener) context.Context { return ctx },
@@ -189,3 +163,25 @@ func main() {
<-storeDone <-storeDone
log.Println("shutdown complete") log.Println("shutdown complete")
} }
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
func basicAuth(user, pass string, next http.Handler) http.Handler {
if user == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
u, p, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"os"
)
// Built-in tag presets, used when no presets file is found (PRESETS_FILE,
// /data/presets.json by default). See docs/presets.md.
//
//go:embed presets.json
var builtinPresets []byte
// i18nText is a text per language ({"en": "...", "fr": "..."}) or one plain
// string for every language.
type i18nText map[string]string
func (t *i18nText) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*t = i18nText{"en": s}
return nil
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
return errors.New("expected a string or an object of strings per language")
}
*t = m
return nil
}
type PresetTag struct {
Label i18nText `json:"label,omitempty"`
Pattern string `json:"pattern"`
Color string `json:"color"`
Regex *bool `json:"regex,omitempty"` // regular expression unless false
WholeWord bool `json:"wholeWord,omitempty"`
CaseSensitive bool `json:"caseSensitive,omitempty"`
}
type Preset struct {
ID string `json:"id"`
Name i18nText `json:"name"`
Tags []PresetTag `json:"tags"`
}
type PresetGroup struct {
Group i18nText `json:"group"`
Presets []Preset `json:"presets"`
}
// parsePresets decodes and checks a presets file with the same rules as the
// tags, so that every preset can be added as is.
func parsePresets(b []byte) ([]PresetGroup, error) {
var groups []PresetGroup
if err := json.Unmarshal(b, &groups); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, g := range groups {
for _, p := range g.Presets {
if p.ID == "" || seen[p.ID] {
return nil, fmt.Errorf("preset %q: missing or duplicate id", p.ID)
}
seen[p.ID] = true
if len(p.Tags) == 0 {
return nil, fmt.Errorf("preset %q: no tags", p.ID)
}
for i, pt := range p.Tags {
t := Tag{Pattern: pt.Pattern, Color: pt.Color, Regex: pt.Regex == nil || *pt.Regex}
if err := t.validate(); err != nil {
return nil, fmt.Errorf("preset %q, tag %d: %w", p.ID, i+1, err)
}
}
}
}
return groups, nil
}
// presetsResponse is what GET /api/presets returns.
type presetsResponse struct {
Source string `json:"source"` // "file" or "builtin"
File string `json:"file"`
Error string `json:"error,omitempty"` // the file is invalid: built-in presets are used
Groups []PresetGroup `json:"groups"`
}
// loadPresets reads the presets file on every call, so that edits apply
// without a restart, and falls back to the built-in presets.
func loadPresets(path string) presetsResponse {
res := presetsResponse{Source: "builtin", File: path}
if path != "" {
b, err := os.ReadFile(path)
switch {
case err == nil:
if res.Groups, err = parsePresets(b); err == nil {
res.Source = "file"
return res
}
res.Error = err.Error()
case !errors.Is(err, os.ErrNotExist):
res.Error = err.Error()
}
}
res.Groups, _ = parsePresets(builtinPresets) // checked by the tests
return res
}
-244
View File
@@ -1,244 +0,0 @@
[
{
"group": "HTTP/HTTPS",
"presets": [
{
"id": "http_status",
"name": { "en": "HTTP status codes", "fr": "Codes HTTP" },
"tags": [
{
"label": "HTTP 2xx",
"color": "#86efac",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>2\\d\\d)\\b"
},
{
"label": "HTTP 3xx",
"color": "#93c5fd",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>3\\d\\d)\\b"
},
{
"label": "HTTP 4xx",
"color": "#fdba74",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>4\\d\\d)\\b"
},
{
"label": "HTTP 5xx",
"color": "#f87171",
"pattern": "(?:\" |\"(?:status|DownstreamStatus|OriginStatus|status_code)\": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )(?<hl>5\\d\\d)\\b"
}
]
},
{
"id": "http_methods",
"name": { "en": "HTTP methods", "fr": "Méthodes HTTP" },
"tags": [
{
"label": "GET/HEAD/OPTIONS",
"color": "#cbd5e1",
"caseSensitive": true,
"pattern": "\"(?<hl>GET|HEAD|OPTIONS)[ \"]"
},
{
"label": "POST/PUT/PATCH",
"color": "#c4b5fd",
"caseSensitive": true,
"pattern": "\"(?<hl>POST|PUT|PATCH)[ \"]"
},
{
"label": "DELETE",
"color": "#f9a8d4",
"caseSensitive": true,
"pattern": "\"(?<hl>DELETE)[ \"]"
}
]
},
{
"id": "http_probes",
"name": { "en": "Probes and attacks", "fr": "Sondes et attaques" },
"tags": [
{
"label": { "en": "probes / attacks", "fr": "sondes / attaques" },
"color": "#fda4af",
"pattern": "(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)"
}
]
},
{
"id": "http_bots",
"name": { "en": "Bots and scripts", "fr": "Robots et scripts" },
"tags": [
{
"label": { "en": "bots / scripts", "fr": "robots / scripts" },
"color": "#fde68a",
"pattern": "\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b"
}
]
},
{
"id": "http_errors",
"name": { "en": "TLS/HTTPS and proxy errors", "fr": "Erreurs TLS/HTTPS et proxy" },
"tags": [
{
"label": { "en": "TLS errors", "fr": "erreurs TLS" },
"color": "#f0abfc",
"pattern": "(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)"
},
{
"label": { "en": "proxy errors", "fr": "erreurs proxy" },
"color": "#fdba74",
"pattern": "(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)"
}
]
}
]
},
{
"group": { "en": "System", "fr": "Système" },
"presets": [
{
"id": "sys_auth",
"name": { "en": "SSH and logins", "fr": "SSH et connexions" },
"tags": [
{
"label": { "en": "login failures", "fr": "échecs de connexion" },
"color": "#fca5a5",
"pattern": "(?:Failed (?:password|publickey|none)|Invalid user|authentication failures?|Connection closed by (?:invalid|authenticating) user|maximum authentication attempts exceeded|FAILED (?:LOGIN|SU)|incorrect password attempts?|NOT in sudoers)"
},
{
"label": { "en": "logins", "fr": "connexions" },
"color": "#86efac",
"pattern": "(?:Accepted (?:password|publickey|keyboard-interactive(?:/pam)?)|session opened for user|New session \\S+ of user)"
}
]
},
{
"id": "sys_sudo",
"name": { "en": "sudo commands", "fr": "Commandes sudo" },
"tags": [
{
"label": { "en": "sudo commands", "fr": "commandes sudo" },
"color": "#fde68a",
"caseSensitive": true,
"pattern": "\\bCOMMAND=\\S+"
}
]
},
{
"id": "sys_kernel",
"name": { "en": "Kernel: OOM, crashes, disks", "fr": "Noyau : OOM, plantages, disques" },
"tags": [
{
"label": { "en": "out of memory", "fr": "mémoire épuisée" },
"color": "#f87171",
"pattern": "(?:Out of memory|oom-kill(?:er)?|oom_reaper|Killed process \\d+|invoked oom-killer)"
},
{
"label": { "en": "kernel errors", "fr": "erreurs noyau" },
"color": "#fda4af",
"pattern": "(?:Kernel panic|\\bBUG: |\\bOops\\b|Call Trace|segfault at|general protection fault|I/O error|EXT4-fs error|Buffer I/O error|blocked for more than \\d+ seconds|Hardware Error|soft lockup|hard LOCKUP)"
}
]
},
{
"id": "sys_systemd",
"name": { "en": "systemd services", "fr": "Services systemd" },
"tags": [
{
"label": { "en": "failed services", "fr": "services en échec" },
"color": "#fca5a5",
"pattern": "(?:Failed to start|failed with result|Main process exited, code=(?:exited|killed|dumped)|entered failed state|Start request repeated too quickly|Dependency failed)"
},
{
"label": { "en": "service start/stop", "fr": "démarrage/arrêt de service" },
"color": "#bbf7d0",
"caseSensitive": true,
"pattern": "\\b(?:Started|Starting|Stopped|Stopping|Reloaded|Reloading|Reached target)\\b"
}
]
},
{
"id": "sys_firewall",
"name": { "en": "Firewall and fail2ban", "fr": "Pare-feu et fail2ban" },
"tags": [
{
"label": { "en": "firewall", "fr": "pare-feu" },
"color": "#fdba74",
"caseSensitive": true,
"pattern": "(?:\\[UFW (?:BLOCK|ALLOW|AUDIT|LIMIT BLOCK)\\]|\\b(?:DROP|REJECT)\\b|\\b(?:Ban|Unban|Found) \\d{1,3}(?:\\.\\d{1,3}){3}\\b)"
}
]
}
]
},
{
"group": "Applications",
"presets": [
{
"id": "app_docker",
"name": { "en": "Docker and containers", "fr": "Docker et conteneurs" },
"tags": [
{
"label": { "en": "container problems", "fr": "problèmes de conteneur" },
"color": "#fcd34d",
"pattern": "(?:\\bOOMKilled\\b|exited with code [1-9]\\d*|exit code: [1-9]\\d*|health_status: unhealthy|\\bunhealthy\\b|Back-off restarting|CrashLoopBackOff|container (?:die|kill|oom)\\b|restarting \\(\\d+\\))"
}
]
},
{
"id": "app_db",
"name": { "en": "Databases", "fr": "Bases de données" },
"tags": [
{
"label": { "en": "database errors", "fr": "erreurs base de données" },
"color": "#c4b5fd",
"pattern": "(?:\\bdeadlock(?: detected| found)?\\b|duplicate key|too many (?:connections|clients)|lock wait timeout|slow query|could not connect to server|server has gone away|out of shared memory|terminating connection|Access denied for user|password authentication failed)"
}
]
}
]
},
{
"group": { "en": "General", "fr": "Général" },
"presets": [
{
"id": "gen_levels",
"name": { "en": "Log levels", "fr": "Niveaux de log" },
"tags": [
{
"label": { "en": "fatal / critical", "fr": "fatal / critique" },
"color": "#ef4444",
"pattern": "\\b(?:fatal|crit(?:ical)?|panic|emerg(?:ency)?)\\b"
},
{
"label": "info / notice",
"color": "#bfdbfe",
"pattern": "\\b(?:info|notice)\\b"
},
{
"label": "debug / trace",
"color": "#e5e7eb",
"pattern": "\\b(?:debug|trace)\\b"
},
{
"label": "ok",
"color": "#86efac",
"regex": false,
"wholeWord": true,
"pattern": "ok"
}
]
},
{
"id": "gen_ip",
"name": { "en": "IPv4 addresses", "fr": "Adresses IPv4" },
"tags": [
{
"label": { "en": "IPv4 addresses", "fr": "adresses IPv4" },
"color": "#a5f3fc",
"pattern": "\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b"
}
]
}
]
}
]
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestBuiltinPresets(t *testing.T) {
groups, err := parsePresets(builtinPresets)
if err != nil {
t.Fatal(err)
}
if len(groups) == 0 {
t.Fatal("no built-in presets")
}
}
func TestLoadPresetsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "presets.json")
if res := loadPresets(path); res.Source != "builtin" || res.Error != "" {
t.Fatalf("missing file: got %q, error %q", res.Source, res.Error)
}
os.WriteFile(path, []byte(`[{"group":"Mine","presets":[{"id":"a","name":{"en":"A","fr":"A fr"},
"tags":[{"label":"x","pattern":"foo|bar","color":"#112233"},{"pattern":"a.b","color":"#445566","regex":false}]}]}]`), 0o644)
res := loadPresets(path)
if res.Source != "file" || res.Error != "" || res.Groups[0].Presets[0].Name["fr"] != "A fr" || res.Groups[0].Group["en"] != "Mine" {
t.Fatalf("valid file: %+v", res)
}
for _, bad := range []string{
`not json`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"(","color":"#112233"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"red"}]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[]}]}]`,
`[{"group":"G","presets":[{"id":"a","name":"A","tags":[{"pattern":"x","color":"#112233"}]},{"id":"a","name":"B","tags":[{"pattern":"y","color":"#112233"}]}]}]`,
} {
os.WriteFile(path, []byte(bad), 0o644)
if res := loadPresets(path); res.Source != "builtin" || res.Error == "" || len(res.Groups) == 0 {
t.Errorf("%s: got %q, error %q", bad, res.Source, res.Error)
}
}
}
+5 -5
View File
@@ -17,7 +17,7 @@ type Filter struct {
Host string Host string
App string App string
Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all
Source string // "syslog", "docker", "host" or "" for all Source string // "syslog", "docker" or "" for all
} }
var rangeDurations = map[string]time.Duration{ var rangeDurations = map[string]time.Duration{
@@ -119,10 +119,10 @@ func (f Filter) filterExpr() string {
parts = append(parts, "app:="+strconv.Quote(f.App)) parts = append(parts, "app:="+strconv.Quote(f.App))
} }
switch f.Source { switch f.Source {
case "docker", "host": case "docker":
parts = append(parts, `source_type:=`+strconv.Quote(f.Source)) parts = append(parts, `source_type:="docker"`)
case "syslog": // also matches logs stored before source_type existed case "syslog": // also matches logs stored before source_type existed
parts = append(parts, `!(source_type:in("docker","host"))`) parts = append(parts, `!(source_type:="docker")`)
} }
if f.Severity >= 0 && f.Severity < 7 { if f.Severity >= 0 && f.Severity < 7 {
names := make([]string, 0, 8) names := make([]string, 0, 8)
@@ -209,7 +209,7 @@ func (m *Matcher) Match(e *Entry) bool {
if m.f.App != "" && e.App != m.f.App { if m.f.App != "" && e.App != m.f.App {
return false return false
} }
if m.f.Source != "" && m.f.Source != e.SourceType { if (m.f.Source == "docker") != (e.SourceType == "docker") && m.f.Source != "" {
return false return false
} }
if m.f.Severity >= 0 && e.SevNum > m.f.Severity { if m.f.Severity >= 0 && e.SevNum > m.f.Severity {
+4 -46
View File
@@ -16,9 +16,7 @@ import (
// Tag highlights a keyword in displayed messages. // Tag highlights a keyword in displayed messages.
type Tag struct { type Tag struct {
ID string `json:"id"` ID string `json:"id"`
Code string `json:"code"` // two digits, shown on matching log lines
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
Color string `json:"color"` Color string `json:"color"`
WholeWord bool `json:"wholeWord"` WholeWord bool `json:"wholeWord"`
CaseSensitive bool `json:"caseSensitive"` CaseSensitive bool `json:"caseSensitive"`
@@ -28,46 +26,12 @@ type Tag struct {
func defaultTags() []Tag { func defaultTags() []Tag {
return []Tag{ return []Tag{
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true}, {ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true}, {ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
} }
} }
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
// all are taken. A code stays with its tag until the tag is deleted.
func freeCode(tags []Tag) string {
used := map[string]bool{}
for _, t := range tags {
used[t.Code] = true
}
for n := 1; n <= 99; n++ {
if c := fmt.Sprintf("%02d", n); !used[c] {
return c
}
}
return ""
}
// assignCodes gives a code to the tags that have none (files written before
// codes existed) or share one with an earlier tag.
func assignCodes(tags []Tag) bool {
changed := false
seen := map[string]bool{}
for i := range tags {
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
seen[tags[i].Code] = true
continue
}
tags[i].Code = ""
tags[i].Code = freeCode(tags)
seen[tags[i].Code] = true
changed = true
}
return changed
}
// Colors of the default tags in earlier versions: still unchanged, they are // Colors of the default tags in earlier versions: still unchanged, they are
// switched to the new pastel defaults when the file is loaded. // switched to the new pastel defaults when the file is loaded.
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"} var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
@@ -103,13 +67,11 @@ func (e *codedError) Error() string {
var ( var (
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"} errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
) )
func (t *Tag) validate() error { func (t *Tag) validate() error {
t.Pattern = strings.TrimSpace(t.Pattern) t.Pattern = strings.TrimSpace(t.Pattern)
t.Label = strings.TrimSpace(t.Label)
if t.Pattern == "" { if t.Pattern == "" {
return &codedError{code: "pattern_required", msg: "the keyword is required"} return &codedError{code: "pattern_required", msg: "the keyword is required"}
} }
@@ -144,7 +106,7 @@ func LoadTagStore(path string) (*TagStore, error) {
if err := json.Unmarshal(b, &s.tags); err != nil { if err := json.Unmarshal(b, &s.tags); err != nil {
return nil, fmt.Errorf("%s: %w", path, err) return nil, fmt.Errorf("%s: %w", path, err)
} }
if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 { if migrateDefaultColors(s.tags) {
if err := s.save(); err != nil { if err := s.save(); err != nil {
return nil, err return nil, err
} }
@@ -181,9 +143,6 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
t.ID = newID() t.ID = newID()
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if t.Code = freeCode(s.tags); t.Code == "" {
return t, errTooManyTags
}
s.tags = append(s.tags, t) s.tags = append(s.tags, t)
return t, s.save() return t, s.save()
} }
@@ -197,7 +156,6 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
defer s.mu.Unlock() defer s.mu.Unlock()
for i := range s.tags { for i := range s.tags {
if s.tags[i].ID == id { if s.tags[i].ID == id {
t.Code = s.tags[i].Code // assigned by the server, never changed
s.tags[i] = t s.tags[i] = t
return t, s.save() return t, s.save()
} }
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestTagCodes(t *testing.T) {
path := filepath.Join(t.TempDir(), "tags.json")
// File written before codes existed, with a duplicate code.
old := `[{"id":"a","pattern":"x","color":"#000000"},{"id":"b","code":"07","pattern":"y","color":"#000000"},{"id":"c","code":"07","pattern":"z","color":"#000000"}]`
if err := os.WriteFile(path, []byte(old), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
got := []string{}
for _, tg := range s.List() {
got = append(got, tg.Code)
}
if want := []string{"01", "07", "02"}; !slices.Equal(got, want) {
t.Fatalf("migrated codes = %v, want %v", got, want)
}
n, err := s.Create(Tag{Pattern: "w", Color: "#000000"})
if err != nil || n.Code != "03" {
t.Fatalf("Create code = %q, %v; want 03", n.Code, err)
}
// The client cannot change a code.
u, err := s.Update("b", Tag{Code: "42", Pattern: "y2", Color: "#000000"})
if err != nil || u.Code != "07" {
t.Fatalf("Update code = %q, %v; want 07", u.Code, err)
}
// A deleted tag frees its code; the others keep theirs.
if err := s.Delete("a"); err != nil {
t.Fatal(err)
}
if n, _ := s.Create(Tag{Pattern: "v", Color: "#000000"}); n.Code != "01" {
t.Fatalf("code after delete = %q, want 01", n.Code)
}
// Codes are saved in the file.
s2, err := LoadTagStore(path)
if err != nil {
t.Fatal(err)
}
if got := s2.List()[0].Code; got != "07" {
t.Fatalf("reloaded code = %q, want 07", got)
}
}
+24 -226
View File
@@ -14,7 +14,6 @@ const I18N = {
liveUnavailable: 'Live view is not available in LogsQL mode', liveUnavailable: 'Live view is not available in LogsQL mode',
settings: 'Settings', settings: 'Settings',
theme: 'Light / dark theme', theme: 'Light / dark theme',
logout: 'Log out',
close: 'Close', close: 'Close',
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application', rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
histoAria: 'Log volume over time', histoAria: 'Log volume over time',
@@ -47,16 +46,12 @@ const I18N = {
colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.', colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.',
newTag: 'new', newTag: 'new',
confirmDelete: (p) => `Delete tag "${p}"?`, confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error)?', confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
presetAria: 'Add a preset', presetPick: '+ Preset…',
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
presetsFileErr: (f) => `Invalid presets file ${f}, built-in presets used: `,
tagsLoadErr: 'Tags: ', tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required', err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)', err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression', err_invalid_regex: 'Invalid regular expression',
err_tag_not_found: 'Tag not found', err_tag_not_found: 'Tag not found',
err_too_many_tags: 'Too many tags (99 at most)',
err_live_logsql: 'Live view is not available in LogsQL mode', err_live_logsql: 'Live view is not available in LogsQL mode',
dateTime: 'Date & time', dateTime: 'Date & time',
tzLabel: 'Time zone', tzLabel: 'Time zone',
@@ -72,19 +67,6 @@ const I18N = {
fHostName: 'host name (DNS)', fHostIp: 'host IP', fHostName: 'host name (DNS)', fHostIp: 'host IP',
clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app', clickHost: 'Click to filter on this host', clickApp: 'Click to filter on this app',
sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources', sourceAria: 'Source', srcAll: 'All sources', tabSources: 'Sources',
srcHost: 'Host system',
hostTitle: 'Host system logs',
hostEnabled: 'Collect the system logs of this machine',
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
hostWaiting: 'Starting…',
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
hostCompressed: (n) => ` ${n} compressed fields skipped (long messages, readable with journalctl).`,
host_not_mounted: 'No host log directory found: mount /var/log and /run/log/journal under /host (see docker-compose.yml).',
host_permission: 'Permission denied: give the container a group allowed to read the logs (HOST_LOGS_GID in .env, see the README). ',
host_empty: 'No systemd journal and no log file found in /var/log.',
host_read: 'Cannot read the host logs: ',
hostHelp: 'Reads the systemd journal of the host (/var/log/journal, /run/log/journal), or the text files of /var/log (syslog, messages, *.log) when there is no journal. Directories are mounted read-only. When turned on, the last hour is read first (HOST_LOGS_BACKFILL).',
syslogEnabled: 'Receive syslog messages', syslogProtocols: 'Protocols', syslogPort: 'Port', syslogEnabled: 'Receive syslog messages', syslogProtocols: 'Protocols', syslogPort: 'Port',
syslogListening: ({ port, protos }) => `Listening on port ${port} (${protos}).`, syslogListening: ({ port, protos }) => `Listening on port ${port} (${protos}).`,
syslogOff: 'Syslog reception is off: syslog messages are ignored (Docker logs are still collected).', syslogOff: 'Syslog reception is off: syslog messages are ignored (Docker logs are still collected).',
@@ -102,11 +84,10 @@ const I18N = {
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.', dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
dockerNoMatch: 'No container', dockerNoMatch: 'No container',
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped', stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
stLocked: 'excluded', stLockedTitle: 'LogStream itself, or label logstream.exclude=true', stLocked: 'excluded', stLockedTitle: 'Logstream itself, or label logstream.exclude=true',
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: LogStream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.', dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: Logstream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project', fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
fService: 'compose service', fStream: 'stream', fSourceType: 'source', fService: 'compose service', fStream: 'stream', fSourceType: 'source',
fUnit: 'systemd unit', fLogFile: 'log file',
export: 'Export', exportCsvHint: 'Comma separated, UTF-8', export: 'Export', exportCsvHint: 'Comma separated, UTF-8',
exportExcel: 'CSV for Excel', exportExcelHint: 'Semicolon separated, for Excel in French', exportExcel: 'CSV for Excel', exportExcelHint: 'Semicolon separated, for Excel in French',
exportNote: (n) => `Up to ${n} logs matching the current filters, newest first. Dates use the time zone chosen in Settings.`, exportNote: (n) => `Up to ${n} logs matching the current filters, newest first. Dates use the time zone chosen in Settings.`,
@@ -117,9 +98,8 @@ const I18N = {
themeHelp: 'System follows the light/dark preference of your computer or phone.', themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size', logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large', sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact', fontLabel: 'Font', fontSystem: 'System monospace (no download)',
fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow', fontHelp: 'Free fonts (SIL Open Font License) loaded by your browser from Bunny Fonts, a privacy-friendly European font service. Without internet access, the system font is used.',
fontHelp: 'Free fonts. The built-in ones (Inconsolata Condensed, the narrowest, Iosevka and Ubuntu Mono) are served by LogStream itself and work offline; they are narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Danger zone', dangerZone: 'Danger zone',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.', purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
@@ -147,9 +127,7 @@ const I18N = {
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.', hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd', hUnzoom: '× Reset zoom', unitDay: 'd',
toTop: 'Back to top', toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message', colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
tagCodeTitle: 'Code shown on the log lines this tag matches',
colGrip: 'Drag to resize, double-click for the automatic width', colGrip: 'Drag to resize, double-click for the automatic width',
resetCols: 'Reset column widths', colsReset: 'Column widths reset', resetCols: 'Reset column widths', colsReset: 'Column widths reset',
colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).', colsHelp: 'Drag the edge of a column header in the log list to resize it (remembered by this browser).',
@@ -165,7 +143,6 @@ const I18N = {
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL', liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
settings: 'Paramètres', settings: 'Paramètres',
theme: 'Thème clair / sombre', theme: 'Thème clair / sombre',
logout: 'Se déconnecter',
close: 'Fermer', close: 'Fermer',
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application', rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
histoAria: 'Volume de logs dans le temps', histoAria: 'Volume de logs dans le temps',
@@ -198,16 +175,12 @@ const I18N = {
colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.', colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.',
newTag: 'nouveau', newTag: 'nouveau',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`, confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?', confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
presetsFileErr: (f) => `Fichier de préréglages ${f} invalide, préréglages intégrés utilisés : `,
tagsLoadErr: 'Tags : ', tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire', err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)', err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide', err_invalid_regex: 'Expression régulière invalide',
err_tag_not_found: 'Tag introuvable', err_tag_not_found: 'Tag introuvable',
err_too_many_tags: 'Trop de tags (99 au maximum)',
err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL', err_live_logsql: 'Le direct n\'est pas disponible en mode LogsQL',
dateTime: 'Date et heure', dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire', tzLabel: 'Fuseau horaire',
@@ -223,19 +196,6 @@ const I18N = {
fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte', fHostName: 'nom d\'hôte (DNS)', fHostIp: 'IP de l\'hôte',
clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli', clickHost: 'Cliquer pour filtrer sur cet hôte', clickApp: 'Cliquer pour filtrer sur cette appli',
sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources', sourceAria: 'Source', srcAll: 'Toutes les sources', tabSources: 'Sources',
srcHost: 'Système hôte',
hostTitle: 'Logs système de l\'hôte',
hostEnabled: 'Collecter les logs système de cette machine',
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
hostWaiting: 'Démarrage…',
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
hostCompressed: (n) => ` ${n} champs compressés ignorés (messages longs, lisibles avec journalctl).`,
host_not_mounted: 'Aucun répertoire de logs de l\'hôte trouvé : montez /var/log et /run/log/journal sous /host (voir docker-compose.yml).',
host_permission: 'Accès refusé : donnez au conteneur un groupe autorisé à lire les logs (HOST_LOGS_GID dans .env, voir le README). ',
host_empty: 'Ni journal systemd ni fichier de log trouvé dans /var/log.',
host_read: 'Lecture des logs de l\'hôte impossible : ',
hostHelp: 'Lit le journal systemd de l\'hôte (/var/log/journal, /run/log/journal), ou les fichiers texte de /var/log (syslog, messages, *.log) s\'il n\'y a pas de journal. Les répertoires sont montés en lecture seule. À l\'activation, la dernière heure est lue d\'abord (HOST_LOGS_BACKFILL).',
syslogEnabled: 'Recevoir les messages syslog', syslogProtocols: 'Protocoles', syslogPort: 'Port', syslogEnabled: 'Recevoir les messages syslog', syslogProtocols: 'Protocoles', syslogPort: 'Port',
syslogListening: ({ port, protos }) => `Écoute sur le port ${port} (${protos}).`, syslogListening: ({ port, protos }) => `Écoute sur le port ${port} (${protos}).`,
syslogOff: 'Réception syslog désactivée : les messages syslog sont ignorés (les logs Docker sont toujours collectés).', syslogOff: 'Réception syslog désactivée : les messages syslog sont ignorés (les logs Docker sont toujours collectés).',
@@ -253,11 +213,10 @@ const I18N = {
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.', dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
dockerNoMatch: 'Aucun conteneur', dockerNoMatch: 'Aucun conteneur',
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté', stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
stLocked: 'exclu', stLockedTitle: 'LogStream lui-même, ou étiquette logstream.exclude=true', stLocked: 'exclu', stLockedTitle: 'Logstream lui-même, ou étiquette logstream.exclude=true',
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : LogStream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.', dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : Logstream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose', fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
fService: 'service compose', fStream: 'flux', fSourceType: 'source', fService: 'service compose', fStream: 'flux', fSourceType: 'source',
fUnit: 'unité systemd', fLogFile: 'fichier de log',
export: 'Exporter', exportCsvHint: 'Séparateur virgule, UTF-8', export: 'Exporter', exportCsvHint: 'Séparateur virgule, UTF-8',
exportExcel: 'CSV pour Excel', exportExcelHint: 'Séparateur point-virgule, pour Excel en français', exportExcel: 'CSV pour Excel', exportExcelHint: 'Séparateur point-virgule, pour Excel en français',
exportNote: (n) => `Jusqu'à ${n} logs correspondant aux filtres, du plus récent au plus ancien. Dates dans le fuseau choisi dans Paramètres.`, exportNote: (n) => `Jusqu'à ${n} logs correspondant aux filtres, du plus récent au plus ancien. Dates dans le fuseau choisi dans Paramètres.`,
@@ -268,9 +227,8 @@ const I18N = {
themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.', themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou de votre téléphone.',
logDisplay: 'Affichage des logs', fontSize: 'Taille du texte', logDisplay: 'Affichage des logs', fontSize: 'Taille du texte',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande', sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte', fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite', fontHelp: 'Polices libres (licence SIL Open Font) chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée. Sans accès à internet, la police du système est utilisée.',
fontHelp: 'Polices libres. Les polices intégrées (Inconsolata Condensed, la plus étroite, Iosevka et Ubuntu Mono) sont servies par LogStream lui-même et fonctionnent hors ligne ; elles sont étroites, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'], previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Zone de danger', dangerZone: 'Zone de danger',
purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.', purgeHelp: 'Supprime définitivement tous les logs stockés pour repartir de zéro. Les tags de couleur et les réglages sont conservés.',
@@ -298,9 +256,7 @@ const I18N = {
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.', hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j', hUnzoom: '× Annuler le zoom', unitDay: 'j',
toTop: 'Revenir en haut', toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message', colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique', colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées', resetCols: 'Réinitialiser les colonnes', colsReset: 'Largeurs de colonnes réinitialisées',
colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).', colsHelp: 'Glissez le bord d\'un en-tête de colonne de la liste pour la redimensionner (mémorisé par ce navigateur).',
@@ -425,8 +381,6 @@ async function api(url, opts = {}) {
// Known error codes are translated; otherwise the server message is shown. // Known error codes are translated; otherwise the server message is shown.
function apiError(res, text, data) { function apiError(res, text, data) {
// OIDC session expired: reloading the page goes through the login again.
if (res.status === 401 && data && data.code === 'auth') location.reload();
let msg = (data && data.error) || text || res.statusText; let msg = (data && data.error) || text || res.statusText;
if (data && data.code && I18N[lang]['err_' + data.code]) { if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
@@ -451,7 +405,6 @@ const state = {
rows: [], // displayed records, newest first rows: [], // displayed records, newest first
pending: [], // live messages received while scrolled down pending: [], // live messages received while scrolled down
tags: [], tags: [],
presets: [], // preset groups from /api/presets
matchers: [], // compiled tags + search terms matchers: [], // compiled tags + search terms
mode: store.get('mode', 'simple'), mode: store.get('mode', 'simple'),
live: store.get('live', '1') === '1', live: store.get('live', '1') === '1',
@@ -471,7 +424,7 @@ const list = $('#list');
function applyLang() { function applyLang() {
document.documentElement.lang = lang; document.documentElement.lang = lang;
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : ''); for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh); for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
@@ -490,12 +443,10 @@ function setLang(next) {
renderHisto(); renderHisto();
renderStats(); renderStats();
renderTagList(); renderTagList();
renderPresets();
renderTimeSettings(); renderTimeSettings();
renderPurge(); renderPurge();
renderInterface(); renderInterface();
renderSyslog(); renderSyslog();
renderHost();
renderDocker(); renderDocker();
} }
@@ -535,19 +486,16 @@ $('#themeSwitch').addEventListener('click', (ev) => {
/* ================= Log font and size ================= */ /* ================= Log font and size ================= */
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the // Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net).
// built-in ones (web/fonts, declared in style.css), which also work offline.
const LOG_FONTS = [ const LOG_FONTS = [
{ id: 'system' }, { id: 'system' },
{ id: 'inconsolata-condensed', family: 'Inconsolata Condensed', builtin: true },
{ id: 'iosevka', family: 'Iosevka', builtin: true },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono', builtin: true },
{ id: 'jetbrains-mono', family: 'JetBrains Mono' }, { id: 'jetbrains-mono', family: 'JetBrains Mono' },
{ id: 'fira-code', family: 'Fira Code' }, { id: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' }, { id: 'source-code-pro', family: 'Source Code Pro' },
{ id: 'ibm-plex-mono', family: 'IBM Plex Mono' }, { id: 'ibm-plex-mono', family: 'IBM Plex Mono' },
{ id: 'cascadia-code', family: 'Cascadia Code' }, { id: 'cascadia-code', family: 'Cascadia Code' },
{ id: 'roboto-mono', family: 'Roboto Mono' }, { id: 'roboto-mono', family: 'Roboto Mono' },
{ id: 'ubuntu-mono', family: 'Ubuntu Mono' },
{ id: 'inconsolata', family: 'Inconsolata' }, { id: 'inconsolata', family: 'Inconsolata' },
{ id: 'red-hat-mono', family: 'Red Hat Mono' }, { id: 'red-hat-mono', family: 'Red Hat Mono' },
{ id: 'noto-sans-mono', family: 'Noto Sans Mono' }, { id: 'noto-sans-mono', family: 'Noto Sans Mono' },
@@ -555,8 +503,7 @@ const LOG_FONTS = [
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' }, { id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
]; ];
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' }; const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
const LOG_DENSITIES = ['normal', 'compact']; const ui = { font: 'system', size: 'medium' };
const ui = { font: 'system', size: 'medium', density: 'normal' };
function applyLogFont(id) { function applyLogFont(id) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0]; const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
@@ -566,10 +513,6 @@ function applyLogFont(id) {
root.removeProperty('--log-font'); root.removeProperty('--log-font');
return; return;
} }
if (f.builtin) {
root.setProperty('--log-font', `'${f.family}', var(--mono)`);
return;
}
const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`; const href = `https://fonts.bunny.net/css?family=${f.id}:${f.weights || '400,700'}&display=swap`;
let link = document.getElementById('logFontCss'); let link = document.getElementById('logFontCss');
if (!link) { if (!link) {
@@ -585,22 +528,13 @@ function applyLogSize(id) {
document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]); document.documentElement.style.setProperty('--log-size', LOG_SIZES[ui.size]);
} }
function applyLogDensity(id) {
ui.density = LOG_DENSITIES.includes(id) ? id : 'normal';
document.documentElement.dataset.density = ui.density;
}
function renderInterface() { function renderInterface() {
renderThemeSwitch(); renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) { for (const b of document.querySelectorAll('#sizeSwitch [data-size]')) {
b.setAttribute('aria-checked', String(b.dataset.size === ui.size)); b.setAttribute('aria-checked', String(b.dataset.size === ui.size));
} }
for (const b of document.querySelectorAll('#densitySwitch [data-density]')) {
b.setAttribute('aria-checked', String(b.dataset.density === ui.density));
}
const sel = $('#fontSelect'); const sel = $('#fontSelect');
const fontName = (f) => (!f.family ? t('fontSystem') : f.builtin ? `${f.family} (${t('fontBuiltin')})` : f.family); sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join('');
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(fontName(f))}</option>`).join('');
sel.value = ui.font; sel.value = ui.font;
renderFontPreview(); renderFontPreview();
renderHistoSettings(); renderHistoSettings();
@@ -624,13 +558,6 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
store.set('logSize', ui.size); store.set('logSize', ui.size);
renderInterface(); renderInterface();
}); });
$('#densitySwitch').addEventListener('click', (ev) => {
const b = ev.target.closest('[data-density]');
if (!b) return;
applyLogDensity(b.dataset.density);
store.set('logDensity', ui.density);
renderInterface();
});
$('#fontSelect').addEventListener('change', (ev) => { $('#fontSelect').addEventListener('change', (ev) => {
applyLogFont(ev.target.value); applyLogFont(ev.target.value);
store.set('logFont', ui.font); store.set('logFont', ui.font);
@@ -721,11 +648,7 @@ function compileMatchers() {
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern); let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`; if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
try { try {
// A regex may name a group "hl" to color only that part of the match. out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
const hl = tag.regex && /\(\?P?<hl>/.test(tag.pattern);
if (hl) src = src.replace(/\(\?P<hl>/g, '(?<hl>');
out.push({ re: new RegExp(src, 'gu' + (hl ? 'd' : '') + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">`,
code: tag.code, name: tag.label || tag.pattern });
} catch (e) { } catch (e) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message); console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
} }
@@ -750,8 +673,6 @@ function highlight(text) {
let x; let x;
while ((x = m.re.exec(text)) !== null) { while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; } if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (g) { if (g[1] > g[0]) spans.push({ s: g[0], e: g[1], prio, html: m.html }); continue; }
spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html }); spans.push({ s: x.index, e: x.index + x[0].length, prio, html: m.html });
} }
}); });
@@ -767,30 +688,6 @@ function highlight(text) {
return out + esc(text.slice(pos)); return out + esc(text.slice(pos));
} }
// Badges with the codes of the tags found in a message, in list order. The
// column has room for 3: beyond that, 2 badges and "+N" (all in the tooltip).
function codesHTML(text) {
text = String(text ?? '');
const found = [];
for (const m of state.matchers) {
if (!m.code || !text) continue;
m.re.lastIndex = 0;
let x;
while ((x = m.re.exec(text)) !== null) {
if (x[0] === '') { m.re.lastIndex++; continue; }
const g = x.indices?.groups?.hl;
if (!x.indices?.groups || g) { found.push(m); break; }
}
}
if (!found.length) return '';
const shown = found.length > 3 ? found.slice(0, 2) : found;
const title = found.map((m) => `${m.code} ${m.name}`).join('\n');
return `<span title="${esc(title)}">`
+ shown.map((m) => `<b>${esc(m.code)}</b>`).join('')
+ (found.length > 3 ? `<b class="more">+${found.length - 2}</b>` : '')
+ '</span>';
}
/* ================= List rendering ================= */ /* ================= List rendering ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' }; const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
@@ -816,7 +713,6 @@ function rowHTML(r, isNew) {
+ (r.app + (r.app
? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>` ? `<span class="app${r.source_type === 'docker' ? ' proj' : ''}" data-act="app"${r.source_type === 'docker' ? ` style="--h:${hueOf(r.compose_project || r.container || r.app)}"` : ''} title="${esc((r.compose_project ? r.compose_project + '/' : '') + (r.container ? r.container + ' · ' : '') + r.app + ' · ' + t('clickApp'))}">${r.source_type === 'docker' ? DOCKER_ICON : ''}${esc(r.app)}</span>`
: '<span class="app"></span>') : '<span class="app"></span>')
+ `<span class="codes">${codesHTML(r._msg)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>` + `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>'; + '</article>';
} }
@@ -883,9 +779,7 @@ function renderList() {
function rehighlight() { function rehighlight() {
for (const row of list.children) { for (const row of list.children) {
const r = recOf.get(row); const r = recOf.get(row);
if (!r) continue; if (r) row.querySelector('.msg').innerHTML = highlight(r._msg);
row.querySelector('.msg').innerHTML = highlight(r._msg);
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
} }
} }
@@ -911,14 +805,14 @@ function updateCount(tookMs) {
} }
function detailsHTML(r) { function detailsHTML(r) {
const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'unit', 'log_file', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg']; const order = ['received', 'msg_time', '_time', 'host', 'host_name', 'host_ip', 'source_type', 'container', 'image', 'compose_project', 'compose_service', 'stream', 'app', 'procid', 'severity', 'facility', 'source', 'proto', 'sd', '_msg'];
const hidden = new Set(['_stream_id', '_stream', 'sevnum']); const hidden = new Set(['_stream_id', '_stream', 'sevnum']);
if (r.msg_time) hidden.add('_time'); // same as the reception time if (r.msg_time) hidden.add('_time'); // same as the reception time
const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k)) const keys = order.filter((k) => r[k] != null && r[k] !== '' && !hidden.has(k))
.concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort()); .concat(Object.keys(r).filter((k) => !order.includes(k) && !hidden.has(k)).sort());
const label = { const label = {
container: t('fContainer'), container_id: t('fContainerId'), image: t('fImage'), compose_project: t('fProject'), container: t('fContainer'), container_id: t('fContainerId'), image: t('fImage'), compose_project: t('fProject'),
compose_service: t('fService'), stream: t('fStream'), source_type: t('fSourceType'), unit: t('fUnit'), log_file: t('fLogFile'), compose_service: t('fService'), stream: t('fStream'), source_type: t('fSourceType'),
msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') }; msg_time: t('fTime'), host_name: t('fHostName'), host_ip: t('fHostIp'), received: t('fReceived'), _time: t('fTime'), _msg: t('fMsg'), procid: t('fPid'), sd: t('fSd') };
const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time' const val = (k) => (k === '_time' || k === 'received' || k === 'msg_time'
? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>` ? `${esc(fmtFull(r[k]))} <span class="muted">(${esc(r[k])})</span>`
@@ -1832,48 +1726,6 @@ $('#syslogProtos').addEventListener('click', (ev) => {
configureSyslog({ [b.dataset.proto]: !syslogState[b.dataset.proto] }); configureSyslog({ [b.dataset.proto]: !syslogState[b.dataset.proto] });
}); });
/* ================= Host system logs ================= */
let hostState = null;
async function loadHost() {
try { hostState = await api('/api/hostlogs'); } catch { hostState = null; }
renderHost();
}
function renderHost() {
const s = hostState;
const status = $('#hostStatus');
if (!s) { status.textContent = ''; return; }
$('#hostEnabled').checked = s.enabled;
let text = t('hostWaiting');
let cls = 'muted';
if (!s.enabled) {
text = t('hostOff');
} else if (s.code) {
text = t('host_' + s.code) + (s.code === 'read' || s.code === 'permission' ? s.error || '' : '');
cls = 'bad';
} else if (s.mode === 'journal') {
text = t('hostJournal', { n: s.files, r: fmtNum(s.read) }) + (s.compressed ? t('hostCompressed', fmtNum(s.compressed)) : '');
cls = 'good';
} else if (s.mode === 'files') {
text = t('hostFiles', { n: s.files, r: fmtNum(s.read) });
cls = 'good';
}
status.textContent = text;
status.className = 'docker-status ' + cls;
}
async function configureHost(enabled) {
hostState = { ...(hostState || {}), enabled };
renderHost();
try { hostState = await api('/api/hostlogs', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); }
renderHost();
setTimeout(loadHost, 1500); // the first scan runs in the background
}
$('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked));
/* ================= Docker source ================= */ /* ================= Docker source ================= */
let dockerState = null; let dockerState = null;
@@ -1987,10 +1839,7 @@ for (const [id, on] of [['#dockerAll', true], ['#dockerNone', false]]) {
} }
// Keep the list fresh while the Sources tab is open. // Keep the list fresh while the Sources tab is open.
setInterval(() => { setInterval(() => {
if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) { if ($('#settingsDlg').open && !document.querySelector('[data-panel="sources"]').hidden) loadDocker();
loadDocker();
loadHost();
}
}, 4000); }, 4000);
/* ================= Purge ================= */ /* ================= Purge ================= */
@@ -2051,25 +1900,6 @@ $('#purgeBtn').addEventListener('click', async () => {
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316']; const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
// Ready-made tags, from the presets file (or the built-in presets.json):
// see docs/presets.md. A text is a string or an object per language.
const pickText = (x) => (typeof x === 'string' ? x : (x?.[lang] ?? x?.en ?? Object.values(x || {})[0] ?? ''));
async function loadPresets() {
try {
const res = await api('/api/presets');
state.presets = res.groups || [];
if (res.error) toast(t('presetsFileErr', res.file) + res.error);
} catch (e) { toast(e.message); }
renderPresets();
}
function renderPresets() {
$('#presetTags').innerHTML = `<option value="">${esc(t('presetPick'))}</option>`
+ state.presets.map((g) => `<optgroup label="${esc(pickText(g.group))}">`
+ g.presets.map((p) => `<option value="${esc(p.id)}">${esc(pickText(p.name))}</option>`).join('') + '</optgroup>').join('');
}
async function loadTags() { async function loadTags() {
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); } try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
compileMatchers(); compileMatchers();
@@ -2079,10 +1909,9 @@ function tagRowHTML(tag) {
const opt = (field, label, title) => const opt = (field, label, title) =>
`<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`; `<label class="opt" title="${esc(title)}"><input type="checkbox" data-f="${field}"${tag[field] ? ' checked' : ''}>${esc(label)}</label>`;
return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}"> return `<div class="tag-row${tag.enabled ? '' : ' off'}" data-id="${esc(tag.id)}">
<span class="tag-code" title="${esc(t('tagCodeTitle'))}">${esc(tag.code || '··')}</span>
<input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}"> <input type="color" value="${esc(tag.color)}" data-f="color" aria-label="${esc(t('colorAria'))}">
<input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}"> <input type="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span> <span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</mark></span>
<div class="opts"> <div class="opts">
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))} ${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
${opt('caseSensitive', 'Aa', t('optCaseTitle'))} ${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
@@ -2125,7 +1954,7 @@ $('#tagList').addEventListener('input', (ev) => {
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value; tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
rowEl.classList.toggle('off', !tag.enabled); rowEl.classList.toggle('off', !tag.enabled);
const mark = rowEl.querySelector('.preview mark'); const mark = rowEl.querySelector('.preview mark');
mark.textContent = tag.label || tag.pattern || t('preview'); mark.textContent = tag.pattern || t('preview');
mark.setAttribute('style', tagStyle(tag.color)); mark.setAttribute('style', tagStyle(tag.color));
scheduleSave(tag, rowEl); scheduleSave(tag, rowEl);
applyTags(); applyTags();
@@ -2158,25 +1987,6 @@ $('#addTag').addEventListener('click', async () => {
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
}); });
// Adds a preset group, skipping tags whose pattern is already in the list.
$('#presetTags').addEventListener('change', async (ev) => {
const preset = state.presets.flatMap((g) => g.presets).find((p) => p.id === ev.target.value);
ev.target.value = '';
if (!preset) return;
let added = 0;
try {
for (const p of preset.tags) {
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
const tag = { ...p, label: pickText(p.label), regex: p.regex ?? true, enabled: true };
state.tags.push(await api('/api/tags', { method: 'POST', body: tag }));
added++;
}
} catch (e) { toast(e.message); }
renderTagList();
applyTags();
toast(t('presetAdded', added));
});
$('#resetTags').addEventListener('click', async () => { $('#resetTags').addEventListener('click', async () => {
if (!confirm(t('confirmReset'))) return; if (!confirm(t('confirmReset'))) return;
try { try {
@@ -2189,11 +1999,9 @@ $('#resetTags').addEventListener('click', async () => {
$('#settingsBtn').addEventListener('click', () => { $('#settingsBtn').addEventListener('click', () => {
renderTimeSettings(); renderTimeSettings();
renderTagList(); renderTagList();
loadPresets();
renderInterface(); renderInterface();
loadPurgeStatus(); loadPurgeStatus();
loadSyslog(); loadSyslog();
loadHost();
loadDocker(); loadDocker();
showSettingsTab(store.get('settingsTab', 'locale')); showSettingsTab(store.get('settingsTab', 'locale'));
$('#settingsDlg').showModal(); $('#settingsDlg').showModal();
@@ -2211,21 +2019,11 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
} }
applyLogFont(store.get('logFont', 'system')); applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium')); applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
applyLang(); applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', ''); $('#severity').value = store.get('severity', '');
// With a login (local or OIDC), show who is logged in and the log out button.
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
if (!me || !me.user) return;
const btn = $('#logoutBtn');
btn.hidden = false;
btn.dataset.user = me.user;
btn.title = `${t('logout')} (${me.user})`;
}).catch(() => {});
(async () => { (async () => {
await loadTags(); await loadTags();
loadFacets(); loadFacets();
-93
View File
@@ -1,93 +0,0 @@
Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-110
View File
@@ -1,110 +0,0 @@
Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-96
View File
@@ -1,96 +0,0 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -28
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogStream</title> <title>Logstream</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<script> <script>
@@ -20,7 +20,7 @@
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg> <svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
<span>LogStream</span> <span>Logstream</span>
</div> </div>
<div class="search"> <div class="search">
@@ -41,10 +41,6 @@
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg> <svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg> <svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button> </button>
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
<!-- Log out icon (Lucide "log-out", ISC license) -->
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
</a>
</div> </div>
<div class="progress" aria-hidden="true"></div> <div class="progress" aria-hidden="true"></div>
</header> </header>
@@ -74,7 +70,6 @@
<option value="" data-i18n="srcAll">All sources</option> <option value="" data-i18n="srcAll">All sources</option>
<option value="syslog">Syslog</option> <option value="syslog">Syslog</option>
<option value="docker">Docker</option> <option value="docker">Docker</option>
<option value="host" data-i18n="srcHost">Host system</option>
</select> </select>
<span class="spacer"></span> <span class="spacer"></span>
<span id="count" class="muted"></span> <span id="count" class="muted"></span>
@@ -107,7 +102,6 @@
<span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="sev"><span class="lbl" data-i18n="colSev">Severity</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="host"><span class="lbl" data-i18n="colHost">Host</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span> <span data-col="app"><span class="lbl" data-i18n="colApp">App</span><i class="grip" data-i18n-title="colGrip"></i></span>
<span data-col="codes"><span class="lbl" data-i18n="colCodes" data-i18n-title="codesTitle">Filters</span></span>
<span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span> <span data-col="msg"><span class="lbl" data-i18n="colMsg">Message</span></span>
</div> </div>
<main id="list" class="list"></main> <main id="list" class="list"></main>
@@ -189,12 +183,7 @@
<p class="muted small" data-i18n="tagsHelp"></p> <p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div> <div id="tagList" class="tag-list"></div>
<footer> <footer>
<span class="tag-add">
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button> <button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
<option value="" data-i18n="presetPick">+ Preset…</option>
</select>
</span>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button> <button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer> </footer>
</section> </section>
@@ -220,16 +209,6 @@
</div> </div>
</section> </section>
<section class="set-section">
<h3 data-i18n="hostTitle">Host system logs</h3>
<p id="hostStatus" class="docker-status"></p>
<label class="switch-row">
<input id="hostEnabled" type="checkbox" class="switch">
<span data-i18n="hostEnabled">Collect the system logs of this machine</span>
</label>
<p class="muted small" data-i18n="hostHelp"></p>
</section>
<section class="set-section"> <section class="set-section">
<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>
@@ -277,11 +256,6 @@
<button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button> <button type="button" role="radio" data-size="medium" data-i18n="sizeMedium">Medium</button>
<button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button> <button type="button" role="radio" data-size="large" data-i18n="sizeLarge">Large</button>
</div> </div>
<span class="lbl" data-i18n="density">Density</span>
<div id="densitySwitch" class="seg" role="radiogroup" data-i18n-aria="density">
<button type="button" role="radio" data-density="normal" data-i18n="densityNormal">Normal</button>
<button type="button" role="radio" data-density="compact" data-i18n="densityCompact">Compact</button>
</div>
<label for="fontSelect" data-i18n="fontLabel">Font</label> <label for="fontSelect" data-i18n="fontLabel">Font</label>
<select id="fontSelect" class="field"></select> <select id="fontSelect" class="field"></select>
</div> </div>
-95
View File
@@ -1,95 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogStream</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
<script>
// Same saved theme and language as the UI, applied before first paint.
try {
var t = localStorage.getItem('logstream.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
var l = localStorage.getItem('logstream.lang');
if (l) document.documentElement.lang = l;
} catch (e) {}
</script>
</head>
<body class="login-page">
<div class="login-tools">
<div class="seg" role="radiogroup" id="langSwitch">
<button type="button" role="radio" data-lang="fr">FR</button>
<button type="button" role="radio" data-lang="en">EN</button>
</div>
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
<main class="login-card">
<img id="logo" class="login-logo" src="auth/logo" alt="" hidden>
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
<span>LogStream</span>
</div>
<p class="muted" data-i18n="intro">Sign in to view the logs.</p>
<form method="post" action="auth/login">
<input type="hidden" name="r" id="ret">
<label for="user" data-i18n="user">User</label>
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<label for="pass" data-i18n="pass">Password</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Wrong user or password.</p>
<button class="btn primary" type="submit" data-i18n="submit">Sign in</button>
</form>
</main>
<script>
(function () {
var I18N = {
en: { intro: 'Sign in to view the logs.', user: 'User', pass: 'Password', submit: 'Sign in',
error: 'Wrong user or password.', theme: 'Light / dark theme' },
fr: { intro: 'Connectez-vous pour consulter les logs.', user: 'Utilisateur', pass: 'Mot de passe',
submit: 'Se connecter', error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre' },
};
function get(k) { try { return localStorage.getItem('logstream.' + k); } catch (e) { return null; } }
function set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch (e) {} }
var lang = get('lang');
if (!I18N[lang]) lang = /^fr\b/i.test(navigator.language || '') ? 'fr' : 'en';
function applyLang() {
var d = I18N[lang];
document.documentElement.lang = lang;
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = d[el.dataset.i18n]; });
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = d[el.dataset.i18nTitle]; });
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', d[el.dataset.i18nAria]); });
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
}
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
});
document.getElementById('themeBtn').addEventListener('click', function () {
var root = document.documentElement;
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
set('theme', root.dataset.theme);
});
var q = new URLSearchParams(location.search);
document.getElementById('ret').value = q.get('r') || '/';
document.getElementById('err').hidden = q.get('e') !== '1';
// LOGIN_LOGO: shown only when the server has one.
var logo = document.getElementById('logo');
logo.addEventListener('load', function () { logo.hidden = false; });
if (logo.complete && logo.naturalWidth) logo.hidden = false;
applyLang();
})();
</script>
</body>
</html>
+10 -69
View File
@@ -1,13 +1,3 @@
/* Narrow monospace fonts served by LogStream itself, so they also work offline (licences in
web/fonts). Latin subset only; other scripts fall back to --mono. Inconsolata Condensed is
Inconsolata's variable font pinned at width 75 (0.4em per character, others 0.5em). */
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Iosevka"; src: url("fonts/iosevka-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Ubuntu Mono"; src: url("fonts/ubuntu-mono-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-400.woff2") format("woff2"); font-weight: 400 500; font-display: swap; }
@font-face { font-family: "Inconsolata Condensed"; src: url("fonts/inconsolata-condensed-700.woff2") format("woff2"); font-weight: 600 800; font-display: swap; }
/* ---------- Theme: everything goes through these variables ---------- */ /* ---------- Theme: everything goes through these variables ---------- */
:root { :root {
--bg: #f6f7f9; --bg: #f6f7f9;
@@ -48,8 +38,6 @@
--tag-warning-text: #111827; --tag-warning-text: #111827;
--log-size: 12.5px; /* Settings > Interface > Font size */ --log-size: 12.5px; /* Settings > Interface > Font size */
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */
--code-bg: #858c97; /* tag code badges */
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */ /* --log-font is set by Settings > Interface > Font (defaults to --mono) */
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -308,13 +296,13 @@ 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: received, message time, severity, host, app, 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: 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)) 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);
overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */ overflow: clip; /* rounded corners without breaking the sticky header (hidden would) */
@@ -343,7 +331,7 @@ 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 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
gap: 0 12px; align-items: baseline; gap: 0 12px; align-items: baseline;
padding: 5px 14px 5px 11px; padding: 5px 14px 5px 11px;
border-left: 3px solid transparent; border-left: 3px solid transparent;
@@ -353,10 +341,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
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 */
:root[data-density="compact"] .row { padding-top: 1px; padding-bottom: 1px; line-height: 1.25; }
:root[data-density="compact"] .row .sev { padding-top: 0; padding-bottom: 0; line-height: 1.3; }
: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; }
@@ -388,17 +372,6 @@ 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; }
/* Codes of the tags found in the message: grey badges, fixed size */
.row .codes { white-space: nowrap; overflow: hidden; }
.row .codes > span { display: inline-flex; gap: 3px; vertical-align: 1px; }
.row .codes b, .tag-code {
min-width: 2.2ch; padding: 1px 4px; border-radius: 5px; text-align: center;
/* 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;
line-height: 1.35; font-variant-numeric: tabular-nums;
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 .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
mark.tag { mark.tag {
@@ -502,7 +475,6 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
.preview-list .row .host { grid-area: host; } .preview-list .row .host { grid-area: host; }
.preview-list .row .app { grid-area: app; display: block; } .preview-list .row .app { grid-area: app; display: block; }
.preview-list .row .msg { grid-area: msg; } .preview-list .row .msg { grid-area: msg; }
.preview-list .row .codes { display: none; }
dialog { dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px); width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
@@ -606,8 +578,6 @@ input.switch:disabled { cursor: not-allowed; }
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; } .set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-add select.field { width: auto; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; } .seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
.seg button { .seg button {
@@ -620,12 +590,11 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } .tag-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.tag-row { .tag-row {
display: grid; align-items: center; gap: 8px 10px; display: grid; align-items: center; gap: 8px 10px;
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px; grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px;
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel-2); background: var(--panel-2);
} }
.tag-row.off { opacity: .55; } .tag-row.off { opacity: .55; }
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
.tag-row input[type="color"] { .tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px; width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
background: none; cursor: pointer; background: none; cursor: pointer;
@@ -668,8 +637,8 @@ 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 1fr;
grid-template-areas: "rcv sev host codes" "msg msg msg msg"; grid-template-areas: "rcv sev host" "msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px; gap: 3px 8px; padding: 8px 12px 8px 10px;
} }
.row time.rcv { grid-area: rcv; } .row time.rcv { grid-area: rcv; }
@@ -677,7 +646,6 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.row .sev { grid-area: sev; } .row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; } .row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; } .row .app { display: none; }
.row .codes { grid-area: codes; }
.row .msg { grid-area: msg; } .row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; } .details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; } .details dl { grid-template-columns: 1fr; }
@@ -695,39 +663,12 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
} }
.set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } .set-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.set-panels { padding: 0 16px 18px; } .set-panels { padding: 0 16px 18px; }
#sizeSwitch, #densitySwitch { display: flex; } #sizeSwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; } #sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
.field-grid select { margin-bottom: 6px; } .field-grid select { margin-bottom: 6px; }
.status { padding: 6px 16px; } .status { padding: 6px 16px; }
.tag-row { grid-template-columns: auto 38px 1fr 36px; } .tag-row { grid-template-columns: 38px 1fr 36px; }
.tag-row .preview { display: none; } .tag-row .preview { display: none; }
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; } .tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 4; grid-row: 1; } .tag-row [data-del] { grid-column: 3; grid-row: 1; }
} }
/* ---------- Login page (AUTH_MODE=local) ---------- */
body.login-page {
min-height: 100vh; padding: 16px;
display: grid; place-items: center;
}
.login-tools { position: fixed; top: 12px; right: 16px; display: flex; align-items: center; gap: 8px; }
.login-card {
width: 100%; max-width: 360px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 32px 28px 28px;
background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow);
}
.login-logo { max-width: 200px; max-height: 96px; object-fit: contain; margin-bottom: 6px; }
.login-card .brand { font-size: 20px; }
.login-card .brand svg { width: 32px; height: 32px; }
.login-card > p { margin: 0 0 8px; text-align: center; }
.login-card form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
.login-card label { font-size: 13px; font-weight: 550; }
.login-card input {
height: 38px; padding: 0 11px; margin-bottom: 6px;
border: 1px solid var(--border); border-radius: 9px; background: var(--bg);
}
.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-err { margin: 0; color: var(--sev-err); font-size: 13px; }