Author SHA1 Message Date
cedricandClaude Opus 5.5 9ea1371696 Compact density and built-in Iosevka font for denser log display
Settings > Interface > Log display gets a Density switch (Normal/Compact)
that tightens row padding and line height, and the font list gets Iosevka,
a narrow SIL OFL monospace font served from web/fonts (Latin subset,
13 KB per weight) so it works offline, unlike the Bunny Fonts ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:39:33 +02:00
claude Bot 974c20e45c Merge pull request 'Codes à 2 chiffres pour les tags de couleur, affichés sur les lignes de log' (#11) from feat/codes-filtres into main 2026-10-03 15:37:04 +02:00
cedricandClaude Opus 5.5 c664f1eaaf Two-digit code per color tag, shown as badges on matching log lines
Each tag gets a stable code (01-99) assigned by the server and stored in
tags.json; existing tags get one when the file is loaded. A fixed-width
Filters column shows the codes of the active tags found in each message
(room for 3, then 2 + "+N", all listed in the tooltip).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:35:20 +02:00
claude Bot 1a21656546 Merge pull request 'Préréglages de tags couleur pour les logs HTTP/HTTPS' (#10) from feat/filtres-http into main 2026-10-03 15:17:58 +02:00
cedricandClaude Opus 5.5 be58284916 Ready-made color tag presets for HTTP/HTTPS access logs
A '+ Preset…' menu in Settings > Filters adds groups of tags for status
codes, methods, probes, bots and TLS/proxy errors (nginx, Apache, Traefik,
Caddy, HAProxy). A regex group named hl colors only that part of a match,
and tags get an optional label shown in place of the pattern.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 15:16:44 +02:00
cedricandClaude Opus 5.5 7b139e8931 Show the name as LogStream in the interface
Titles, header, login page, help texts (FR/EN) and auth error pages.
Technical identifiers (Go module, binary, compose services, cookies,
localStorage keys, logstream.exclude label) are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 11:17:40 +02:00
claude Bot 19ed16ac12 Merge pull request 'Page de connexion pour AUTH_MODE=local' (#8) from feat/login-page into main 2026-10-03 11:12:28 +02:00
cedricandClaude Opus 5.5 7aebb1120f Login page for AUTH_MODE=local instead of the Basic Auth popup
The local mode now shows a login page in the colors of the UI (light/dark theme, FR/EN)
with a signed session cookie and the log out button, like the OIDC mode. LOGIN_LOGO shows
a PNG mounted in the container on that page. SESSION_TTL applies to both modes
(OIDC_SESSION_TTL still works). HTTP Basic credentials are still accepted for scripts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 11:11:11 +02:00
claude Bot ab38a54d54 Merge pull request 'Connexion OpenID Connect (AUTH_MODE=oidc)' (#7) from feat/oidc into main 2026-10-03 10:41:37 +02:00
cedricandClaude Opus 5.5 f30c353b46 OpenID Connect login (AUTH_MODE=oidc)
AUTH_MODE=local keeps the HTTP Basic authentication (unchanged default);
AUTH_MODE=oidc logs in through an OpenID Connect provider with the
authorization code flow and PKCE, standard library only: discovery,
ID token signature (RS/PS/ES) and claims checks, signed session cookie
whose key is kept in DATA_DIR. The UI gets a log out button and reloads
into the login when the session ends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 10:39:54 +02:00
claude Bot 84f8b9f9ad Merge pull request 'Source « logs système de l'hôte » (journal systemd ou /var/log)' (#6) from feat/logs-systeme into main 2026-10-03 10:20:31 +02:00
18 changed files with 1935 additions and 92 deletions

No files matched your search

+17 -1
View File
@@ -4,9 +4,25 @@ HTTP_PORT=8080
TZ=Europe/Paris
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
RETENTION=30d
# Web UI authentication (empty = disabled)
# Web UI authentication: local (login page with the account below, or none) or oidc (OpenID Connect provider)
AUTH_MODE=local
# local mode: user and password (empty = no authentication)
AUTH_USER=
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=
# 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)
RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
+95 -14
View File
@@ -78,8 +78,8 @@ par exemple `error AND host:web-01`, `app:~"ssh|nginx"` ou `* | stats by (host)
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
et le message. Un clic sur un hôte ou une application filtre dessus.
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
@@ -228,22 +228,94 @@ couleur, est mémorisé par navigateur.
Tags par défaut (pastel) : `warning` (orange), `error` (rouge), `ok` (vert). Les tags par
défaut qui utilisent encore les couleurs des versions précédentes passent automatiquement aux
couleurs pastel.
Le menu *+ Préréglage…* ajoute des tags tout faits pour les logs d'accès HTTP/HTTPS (nginx et
Apache common/combined, Traefik CLF et JSON, Caddy JSON, HAProxy httplog) : codes de statut
(2xx vert, 3xx bleu, 4xx orange, 5xx rouge), méthodes, sondes et attaques (`wp-login.php`,
`/.env`, `../`…), robots et scripts, erreurs TLS et proxy. Les tags déjà présents ne sont pas
ajoutés en double, et les tags ajoutés se modifient comme les autres. Dans une expression
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) et police :
la police monospace du système, ou l'une des 12 polices libres conçues pour le texte dense
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). Elles 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.
- *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, [Iosevka](https://github.com/be5invis/Iosevka) (étroite,
donc plus de texte par ligne ; servie par LogStream lui-même, fonctionne hors ligne), ou
l'une des 12 polices libres conçues pour le texte dense (JetBrains Mono, Fira Code, Source
Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat Mono,
Noto Sans Mono, Victor Mono, DM Mono). Ces 12 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 + Iosevka.
- **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 : définissez `AUTH_USER` / `AUTH_PASS` si l'interface est accessible à d'autres.
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)
@@ -265,7 +337,14 @@ résolutions.
| `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_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface |
| `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 |
@@ -322,7 +401,9 @@ Pour mettre à jour l'une d'elles :
| Fichier | Contenu |
|---|---|
| `main.go` | configuration, démarrage, authentification |
| `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 |
@@ -335,7 +416,7 @@ Pour mettre à jour l'une d'elles :
| `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 |
| `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`) |
| `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
+88 -12
View File
@@ -72,7 +72,7 @@ message, host and app. Words are combined with AND.
The live view is disabled in this mode.
Each row shows, from left to right: the **reception time** (server clock), the timestamp
found in the message itself (`msg_time`), severity, host, app and message. Click a host or an
found in the message itself (`msg_time`), severity, host, app, codes of the tags found and message. Click a host or an
app to filter on it.
Logs are indexed, searched and sorted by **reception time**: devices with a wrong clock
@@ -207,20 +207,87 @@ remembered per browser.
(`logstream-data` volume), so they are shared by every browser. Default tags (pastel):
`warning` (orange), `error` (red), `ok` (green). Default tags still using the colors of
earlier versions are switched to the pastel ones automatically.
The *+ Preset…* menu adds ready-made tags for HTTP/HTTPS access logs (nginx and Apache
common/combined, Traefik CLF and JSON, Caddy JSON, HAProxy httplog): status codes (2xx green,
3xx blue, 4xx orange, 5xx red), methods, probes and attacks (`wp-login.php`, `/.env`,
`../`…), bots and scripts, TLS and proxy errors. Tags already in the list are skipped, and the
added tags can be edited like any other. In a regular expression, a group named `hl`
(`(?<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**
- *Theme*: System (follows the computer/phone preference), Light or Dark. The sun/moon
button in the header switches between light and dark.
- *Log display*: font size (tiny, small, medium, large) and font: the system monospace
font, or one of 12 free fonts made for dense text (JetBrains Mono, Fira Code, Source
Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono, Ubuntu Mono, Inconsolata, Red Hat
Mono, Noto Sans Mono, Victor Mono, DM Mono). They are loaded by the browser from
[Bunny Fonts](https://fonts.bunny.net), a privacy-friendly European font service; without
internet access, the system font is used. Ligatures are disabled so `->` or `!=` show as typed.
- *Log display*: font size (tiny, small, medium, large), density (normal, or compact to
fit about 50% more lines on screen) and font: the system monospace font,
[Iosevka](https://github.com/be5invis/Iosevka) (narrow, so more text fits on each line;
served by LogStream itself, works offline), or one of 12 free fonts made for dense text
(JetBrains Mono, Fira Code, Source Code Pro, IBM Plex Mono, Cascadia Code, Roboto Mono,
Ubuntu Mono, Inconsolata, Red Hat Mono, Noto Sans Mono, Victor Mono, DM Mono). These 12 are
loaded by the browser from [Bunny Fonts](https://fonts.bunny.net), a privacy-friendly
European font service; without internet access, the system font is used. Ligatures are
disabled so `->` or `!=` show as typed. The densest setting is Tiny + Compact + Iosevka.
- **Data**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature.
Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable
by others.
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI
is reachable 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)
@@ -240,7 +307,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 |
| `HTTP_PORT` | `8080` | web UI port |
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI |
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
| `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 |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
@@ -294,7 +368,9 @@ To update one of them:
| File | Contents |
|---|---|
| `main.go` | configuration, startup, authentication |
| `main.go` | configuration, startup |
| `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 |
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
| `query.go` | turns UI filters into LogsQL; live-view filter |
@@ -307,7 +383,7 @@ To update one of them:
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
| `tags.go` | color tag storage |
| `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`) |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
## Note
+622
View File
@@ -0,0 +1,622 @@
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
@@ -0,0 +1,167 @@
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
@@ -0,0 +1,142 @@
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
@@ -0,0 +1,230 @@
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)
}
}
+10
View File
@@ -14,8 +14,16 @@ services:
VLOGS_URL: http://victorialogs:9428
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
TZ: ${TZ:-Europe/Paris}
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-}
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
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}
@@ -31,6 +39,8 @@ services:
# 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
labels:
logstream.exclude: "true" # pas de collect des logs logstream
+24 -28
View File
@@ -4,7 +4,6 @@ package main
import (
"context"
"crypto/subtle"
"embed"
"errors"
"io/fs"
@@ -29,8 +28,7 @@ type config struct {
httpAddr string
vlogsURL string
dataDir string
authUser string
authPass string
auth authConfig
rdns bool
dnsServer string
allowPurge bool
@@ -82,8 +80,19 @@ func main() {
httpAddr: getenv("HTTP_ADDR", ":8080"),
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
dataDir: getenv("DATA_DIR", "/data"),
authUser: os.Getenv("AUTH_USER"),
authPass: os.Getenv("AUTH_PASS"),
auth: authConfig{
mode: getenv("AUTH_MODE", "local"),
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),
dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true),
@@ -98,6 +107,8 @@ func main() {
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
}
cfg.auth.dataDir = cfg.dataDir
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -149,9 +160,16 @@ func main() {
api.Routes(mux)
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{
Addr: cfg.httpAddr,
Handler: basicAuth(cfg.authUser, cfg.authPass, mux),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
// Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx },
@@ -170,25 +188,3 @@ func main() {
<-storeDone
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)
})
}
+47 -4
View File
@@ -16,7 +16,9 @@ import (
// Tag highlights a keyword in displayed messages.
type Tag struct {
ID string `json:"id"`
Code string `json:"code"` // two digits, shown on matching log lines
Pattern string `json:"pattern"`
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
Color string `json:"color"`
WholeWord bool `json:"wholeWord"`
CaseSensitive bool `json:"caseSensitive"`
@@ -26,12 +28,47 @@ type Tag struct {
func defaultTags() []Tag {
return []Tag{
{ID: "warning", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Pattern: "ok", Color: "#86efac", WholeWord: true, Enabled: true},
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
{ID: "ok", Code: "03", 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
// switched to the new pastel defaults when the file is loaded.
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
@@ -67,11 +104,13 @@ func (e *codedError) Error() string {
var (
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}$`)
)
func (t *Tag) validate() error {
t.Pattern = strings.TrimSpace(t.Pattern)
t.Label = strings.TrimSpace(t.Label)
if t.Pattern == "" {
return &codedError{code: "pattern_required", msg: "the keyword is required"}
}
@@ -106,7 +145,7 @@ func LoadTagStore(path string) (*TagStore, error) {
if err := json.Unmarshal(b, &s.tags); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if migrateDefaultColors(s.tags) {
if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
if err := s.save(); err != nil {
return nil, err
}
@@ -143,6 +182,9 @@ func (s *TagStore) Create(t Tag) (Tag, error) {
t.ID = newID()
s.mu.Lock()
defer s.mu.Unlock()
if t.Code = freeCode(s.tags); t.Code == "" {
return t, errTooManyTags
}
s.tags = append(s.tags, t)
return t, s.save()
}
@@ -156,6 +198,7 @@ func (s *TagStore) Update(id string, t Tag) (Tag, error) {
defer s.mu.Unlock()
for i := range s.tags {
if s.tags[i].ID == id {
t.Code = s.tags[i].Code // assigned by the server, never changed
s.tags[i] = t
return t, s.save()
}
+54
View File
@@ -0,0 +1,54 @@
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)
}
}
+151 -20
View File
@@ -14,6 +14,7 @@ const I18N = {
liveUnavailable: 'Live view is not available in LogsQL mode',
settings: 'Settings',
theme: 'Light / dark theme',
logout: 'Log out',
close: 'Close',
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
histoAria: 'Log volume over time',
@@ -47,11 +48,18 @@ const I18N = {
newTag: 'new',
confirmDelete: (p) => `Delete tag "${p}"?`,
confirmReset: 'Replace all tags with the defaults (warning, error, ok)?',
presetAria: 'Add a preset', presetPick: '+ Preset…',
preset_http_status: 'HTTP status codes', preset_http_methods: 'HTTP methods',
preset_http_probes: 'Probes and attacks', preset_http_bots: 'Bots and scripts',
preset_http_errors: 'TLS/HTTPS and proxy errors',
pl_probes: 'probes / attacks', pl_bots: 'bots / scripts', pl_tls: 'TLS errors', pl_proxy: 'proxy errors',
presetAdded: (n) => (n ? `${n} tag(s) added` : 'These tags are already in the list'),
tagsLoadErr: 'Tags: ',
err_pattern_required: 'The keyword is required',
err_invalid_color: 'Invalid color (expected #rrggbb)',
err_invalid_regex: 'Invalid regular expression',
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',
dateTime: 'Date & time',
tzLabel: 'Time zone',
@@ -70,7 +78,7 @@ const I18N = {
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.',
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.`,
@@ -97,8 +105,8 @@ const I18N = {
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
dockerNoMatch: 'No container',
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
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.',
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.',
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
fUnit: 'systemd unit', fLogFile: 'log file',
@@ -112,8 +120,9 @@ const I18N = {
themeHelp: 'System follows the light/dark preference of your computer or phone.',
logDisplay: 'Log display', fontSize: 'Font size',
sizeTiny: 'Tiny', sizeSmall: 'Small', sizeMedium: 'Medium', sizeLarge: 'Large',
fontLabel: 'Font', fontSystem: 'System monospace (no download)',
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.',
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
fontLabel: 'Font', fontSystem: 'System monospace (no download)', fontBuiltin: 'built in, narrow',
fontHelp: 'Free fonts (SIL Open Font License). Iosevka is served by LogStream itself and works offline; it is narrow, so more text fits on each line. The others are loaded by your browser from Bunny Fonts, a privacy-friendly European font service; without internet access, the system font is used.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Danger zone',
purgeHelp: 'Permanently delete every stored log to start from scratch. Color tags and settings are kept.',
@@ -141,7 +150,9 @@ const I18N = {
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd',
toTop: 'Back to top',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colMsg: 'Message',
colRcv: 'Received', colMt: 'Message time', colSev: 'Severity', colHost: 'Host', colApp: 'App', colCodes: 'Filters', colMsg: 'Message',
codesTitle: 'Codes of the color tags found in the message',
tagCodeTitle: 'Code shown on the log lines this tag matches',
colGrip: 'Drag to resize, double-click for the automatic width',
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).',
@@ -157,6 +168,7 @@ const I18N = {
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
settings: 'Paramètres',
theme: 'Thème clair / sombre',
logout: 'Se déconnecter',
close: 'Fermer',
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
histoAria: 'Volume de logs dans le temps',
@@ -190,11 +202,18 @@ const I18N = {
newTag: 'nouveau',
confirmDelete: (p) => `Supprimer le tag « ${p} » ?`,
confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error, ok) ?',
presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…',
preset_http_status: 'Codes HTTP', preset_http_methods: 'Méthodes HTTP',
preset_http_probes: 'Sondes et attaques', preset_http_bots: 'Robots et scripts',
preset_http_errors: 'Erreurs TLS/HTTPS et proxy',
pl_probes: 'sondes / attaques', pl_bots: 'robots / scripts', pl_tls: 'erreurs TLS', pl_proxy: 'erreurs proxy',
presetAdded: (n) => (n ? `${n} tag(s) ajouté(s)` : 'Ces tags sont déjà dans la liste'),
tagsLoadErr: 'Tags : ',
err_pattern_required: 'Le mot-clé est obligatoire',
err_invalid_color: 'Couleur invalide (format #rrggbb attendu)',
err_invalid_regex: 'Expression régulière invalide',
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',
dateTime: 'Date et heure',
tzLabel: 'Fuseau horaire',
@@ -213,7 +232,7 @@ const I18N = {
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.',
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.`,
@@ -240,8 +259,8 @@ 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.',
dockerNoMatch: 'Aucun conteneur',
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',
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.',
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.',
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
fUnit: 'unité systemd', fLogFile: 'fichier de log',
@@ -255,8 +274,9 @@ const I18N = {
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',
sizeTiny: 'Très petite', sizeSmall: 'Petite', sizeMedium: 'Moyenne', sizeLarge: 'Grande',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)',
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.',
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
fontLabel: 'Police', fontSystem: 'Monospace du système (aucun téléchargement)', fontBuiltin: 'intégrée, étroite',
fontHelp: 'Polices libres (licence SIL Open Font). Iosevka est servie par LogStream lui-même et fonctionne hors ligne ; elle est étroite, donc chaque ligne affiche plus de texte. Les autres sont chargées par votre navigateur depuis Bunny Fonts, un service européen respectueux de la vie privée ; sans accès à internet, la police du système est utilisée.',
previewMsgs: ['error: link down on eth1, carrier lost', 'warning: disk /dev/sda temperature 58°C', 'backup finished: ok (12.4 GB in 00:14:32)'],
dangerZone: 'Zone de danger',
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.',
@@ -284,7 +304,9 @@ const I18N = {
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j',
toTop: 'Revenir en haut',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colMsg: 'Message',
colRcv: 'Réception', colMt: 'Heure message', colSev: 'Sévérité', colHost: 'Hôte', colApp: 'App', colCodes: 'Filtres', colMsg: 'Message',
codesTitle: 'Codes des tags de couleur trouvés dans le message',
tagCodeTitle: 'Code affiché sur les lignes de log où ce tag est trouvé',
colGrip: 'Glisser pour redimensionner, double-clic pour la largeur automatique',
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).',
@@ -409,6 +431,8 @@ async function api(url, opts = {}) {
// Known error codes are translated; otherwise the server message is shown.
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;
if (data && data.code && I18N[lang]['err_' + data.code]) {
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
@@ -452,7 +476,7 @@ const list = $('#list');
function applyLang() {
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-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : '');
for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria));
for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh);
for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang));
@@ -515,9 +539,11 @@ $('#themeSwitch').addEventListener('click', (ev) => {
/* ================= Log font and size ================= */
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net).
// Free monospace fonts, served by Bunny Fonts (https://fonts.bunny.net), except the
// built-in ones (web/fonts, declared in style.css), which also work offline.
const LOG_FONTS = [
{ id: 'system' },
{ id: 'iosevka', family: 'Iosevka', builtin: true },
{ id: 'jetbrains-mono', family: 'JetBrains Mono' },
{ id: 'fira-code', family: 'Fira Code' },
{ id: 'source-code-pro', family: 'Source Code Pro' },
@@ -532,7 +558,8 @@ const LOG_FONTS = [
{ id: 'dm-mono', family: 'DM Mono', weights: '400,500' },
];
const LOG_SIZES = { tiny: '10.5px', small: '11.5px', medium: '12.5px', large: '14px' };
const ui = { font: 'system', size: 'medium' };
const LOG_DENSITIES = ['normal', 'compact'];
const ui = { font: 'system', size: 'medium', density: 'normal' };
function applyLogFont(id) {
const f = LOG_FONTS.find((x) => x.id === id) || LOG_FONTS[0];
@@ -542,6 +569,10 @@ function applyLogFont(id) {
root.removeProperty('--log-font');
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`;
let link = document.getElementById('logFontCss');
if (!link) {
@@ -557,13 +588,22 @@ function applyLogSize(id) {
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() {
renderThemeSwitch();
for (const b of document.querySelectorAll('#sizeSwitch [data-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');
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join('');
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(fontName(f))}</option>`).join('');
sel.value = ui.font;
renderFontPreview();
renderHistoSettings();
@@ -587,6 +627,13 @@ $('#sizeSwitch').addEventListener('click', (ev) => {
store.set('logSize', ui.size);
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) => {
applyLogFont(ev.target.value);
store.set('logFont', ui.font);
@@ -677,7 +724,11 @@ function compileMatchers() {
let src = tag.regex ? tag.pattern : escapeRe(tag.pattern);
if (tag.wholeWord) src = `(?<![\\p{L}\\p{N}_])(?:${src})(?![\\p{L}\\p{N}_])`;
try {
out.push({ re: new RegExp(src, 'gu' + (tag.caseSensitive ? '' : 'i')), html: `<mark class="tag" style="${tagStyle(tag.color)}">` });
// A regex may name a group "hl" to color only that part of the match.
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) {
console.warn('Tag skipped (invalid pattern):', tag.pattern, e.message);
}
@@ -702,6 +753,8 @@ function highlight(text) {
let x;
while ((x = m.re.exec(text)) !== null) {
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 });
}
});
@@ -717,6 +770,30 @@ function highlight(text) {
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 ================= */
const SEV_CLASS = { emerg: 'crit', alert: 'crit', crit: 'crit', err: 'err', warning: 'warning', notice: 'notice', info: 'info', debug: 'debug' };
@@ -742,6 +819,7 @@ function rowHTML(r, isNew) {
+ (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"></span>')
+ `<span class="codes">${codesHTML(r._msg)}</span>`
+ `<div class="msg">${highlight(r._msg)}</div>`
+ '</article>';
}
@@ -808,7 +886,9 @@ function renderList() {
function rehighlight() {
for (const row of list.children) {
const r = recOf.get(row);
if (r) row.querySelector('.msg').innerHTML = highlight(r._msg);
if (!r) continue;
row.querySelector('.msg').innerHTML = highlight(r._msg);
row.querySelector('.codes').innerHTML = codesHTML(r._msg);
}
}
@@ -1974,6 +2054,28 @@ $('#purgeBtn').addEventListener('click', async () => {
const PALETTE = ['#6366f1', '#0ea5e9', '#14b8a6', '#a855f7', '#ec4899', '#eab308', '#64748b', '#f97316'];
// Ready-made tags for HTTP/HTTPS access logs. The patterns are valid in both
// JavaScript and Go (RE2) and cover nginx/Apache (common, combined), Traefik
// (CLF, JSON), Caddy (JSON) and HAProxy (httplog). For status codes and
// methods only the "hl" group is colored, not the context around it.
const HTTP_STATUS_CTX = '(?:" |"(?:status|DownstreamStatus|OriginStatus|status_code)": ?|(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/(?:-1|\\d+)/\\+?\\d+ )';
const httpStatus = (d, color) => ({ label: `HTTP ${d}xx`, pattern: `${HTTP_STATUS_CTX}(?<hl>${d}\\d\\d)\\b`, color });
const httpMethod = (m, color) => ({ label: m.replace(/\|/g, '/'), pattern: `"(?<hl>${m})[ "]`, color, caseSensitive: true });
const PRESETS = {
http_status: () => [httpStatus(2, '#86efac'), httpStatus(3, '#93c5fd'), httpStatus(4, '#fdba74'), httpStatus(5, '#f87171')],
http_methods: () => [httpMethod('GET|HEAD|OPTIONS', '#cbd5e1'), httpMethod('POST|PUT|PATCH', '#c4b5fd'), httpMethod('DELETE', '#f9a8d4')],
http_probes: () => [{ label: t('pl_probes'), color: '#fda4af',
pattern: '(?:wp-login\\.php|xmlrpc\\.php|wp-admin|phpmyadmin|/\\.env|/\\.git|/\\.aws|/cgi-bin/|\\.\\./|%2e%2e|/etc/passwd|<script|union(?:\\s|%20|\\+)+select)' }],
http_bots: () => [{ label: t('pl_bots'), color: '#fde68a',
pattern: '\\b(?:[a-z]*bot|crawler|spider|curl|wget|python-requests|Go-http-client|zgrab|masscan|nmap|sqlmap|nikto)\\b' }],
http_errors: () => [
{ label: t('pl_tls'), color: '#f0abfc',
pattern: '(?:TLS handshake error|SSL_do_handshake\\(\\) failed|SSL handshake|SSL routines|certificate (?:has )?expired|certificate verify failed|bad certificate|unknown certificate|x509:)' },
{ label: t('pl_proxy'), color: '#fdba74',
pattern: '(?:upstream timed out|upstream prematurely closed|no live upstreams|connect\\(\\) failed|connection refused|bad gateway|gateway time-?out|service unavailable)' },
],
};
async function loadTags() {
try { state.tags = await api('/api/tags'); } catch (e) { toast(t('tagsLoadErr') + e.message); }
compileMatchers();
@@ -1983,9 +2085,10 @@ function tagRowHTML(tag) {
const opt = (field, label, title) =>
`<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)}">
<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="text" value="${esc(tag.pattern)}" data-f="pattern" placeholder="${esc(t('keyword'))}" spellcheck="false" aria-label="${esc(t('keyword'))}">
<span class="preview"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.pattern || t('preview'))}</mark></span>
<span class="preview" title="${esc(tag.pattern)}"><mark class="tag" style="${tagStyle(tag.color)}">${esc(tag.label || tag.pattern || t('preview'))}</mark></span>
<div class="opts">
${opt('wholeWord', t('optWhole'), t('optWholeTitle'))}
${opt('caseSensitive', 'Aa', t('optCaseTitle'))}
@@ -2028,7 +2131,7 @@ $('#tagList').addEventListener('input', (ev) => {
tag[field] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
rowEl.classList.toggle('off', !tag.enabled);
const mark = rowEl.querySelector('.preview mark');
mark.textContent = tag.pattern || t('preview');
mark.textContent = tag.label || tag.pattern || t('preview');
mark.setAttribute('style', tagStyle(tag.color));
scheduleSave(tag, rowEl);
applyTags();
@@ -2061,6 +2164,24 @@ $('#addTag').addEventListener('click', async () => {
} catch (e) { toast(e.message); }
});
// Adds a preset group, skipping tags whose pattern is already in the list.
$('#presetTags').addEventListener('change', async (ev) => {
const make = PRESETS[ev.target.value];
ev.target.value = '';
if (!make) return;
let added = 0;
try {
for (const p of make()) {
if (state.tags.some((x) => x.pattern === p.pattern)) continue;
state.tags.push(await api('/api/tags', { method: 'POST', body: { regex: true, enabled: true, ...p } }));
added++;
}
} catch (e) { toast(e.message); }
renderTagList();
applyTags();
toast(t('presetAdded', added));
});
$('#resetTags').addEventListener('click', async () => {
if (!confirm(t('confirmReset'))) return;
try {
@@ -2094,11 +2215,21 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
}
applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
applyLang();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
$('#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 () => {
await loadTags();
loadFacets();
+110
View File
@@ -0,0 +1,110 @@
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.
Binary file not shown.
Binary file not shown.
+22 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<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="stylesheet" href="style.css">
<script>
@@ -20,7 +20,7 @@
<header class="topbar">
<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>
<span>LogStream</span>
</div>
<div class="search">
@@ -41,6 +41,10 @@
<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>
<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 class="progress" aria-hidden="true"></div>
</header>
@@ -103,6 +107,7 @@
<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="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>
</div>
<main id="list" class="list"></main>
@@ -184,7 +189,17 @@
<p class="muted small" data-i18n="tagsHelp"></p>
<div id="tagList" class="tag-list"></div>
<footer>
<span class="tag-add">
<button id="addTag" class="btn primary" type="button" data-i18n="addTag">+ Add tag</button>
<select id="presetTags" class="field" aria-label="Presets" data-i18n-aria="presetAria">
<option value="" data-i18n="presetPick">+ Preset…</option>
<option value="http_status" data-i18n="preset_http_status">HTTP status codes</option>
<option value="http_methods" data-i18n="preset_http_methods">HTTP methods</option>
<option value="http_probes" data-i18n="preset_http_probes">Probes and attacks</option>
<option value="http_bots" data-i18n="preset_http_bots">Bots and scripts</option>
<option value="http_errors" data-i18n="preset_http_errors">TLS/HTTPS and proxy errors</option>
</select>
</span>
<button id="resetTags" class="btn ghost" type="button" data-i18n="resetTags">Restore default tags</button>
</footer>
</section>
@@ -267,6 +282,11 @@
<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>
</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>
<select id="fontSelect" class="field"></select>
</div>
+95
View File
@@ -0,0 +1,95 @@
<!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>
+61 -11
View File
@@ -1,3 +1,8 @@
/* Iosevka (SIL OFL, web/fonts/OFL-Iosevka.txt): a narrow monospace font served by LogStream
itself, so it also works offline. Latin subset only; other scripts fall back to --mono. */
@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; }
/* ---------- Theme: everything goes through these variables ---------- */
:root {
--bg: #f6f7f9;
@@ -37,7 +42,8 @@
--tag-warning: #fdba74;
--tag-warning-text: #111827;
--log-size: 12.5px; /* Settings > Interface > Font size */
--log-size: 12.5px;
--codes-w: 4.9rem; /* tag codes column: room for 3 badges */ /* Settings > Interface > Font size */
/* --log-font is set by Settings > Interface > Font (defaults to --mono) */
--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;
@@ -296,13 +302,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:empty { display: none; }
/* Columns: received, message time, severity, host, app, message. Widths come from
/* Columns: received, message time, severity, host, app, tag codes, message. Widths come from
--col-* (set on #table when a column has been resized, see app.js), shared by
the header and every row through subgrid so that the columns line up. */
.table {
display: grid;
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)) minmax(0, 1fr);
var(--col-host, minmax(5rem, 9rem)) var(--col-app, minmax(4rem, 8rem)) var(--codes-w) minmax(0, 1fr);
column-gap: 12px;
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) */
@@ -331,7 +337,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row {
display: grid;
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) 1fr;
grid-template-columns: max-content max-content 4.6rem minmax(5rem, 9rem) minmax(4rem, 8rem) var(--codes-w) 1fr;
gap: 0 12px; align-items: baseline;
padding: 5px 14px 5px 11px;
border-left: 3px solid transparent;
@@ -341,6 +347,9 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
cursor: pointer;
}
.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; }
.row:hover { background: var(--row-hover); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.row.new { animation: flash 1.2s ease-out; }
@@ -372,6 +381,15 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.row.sev-crit, .row.sev-err, .row.sev-warning { border-left-color: var(--sev); }
.row.sev-crit, .row.sev-err { background: color-mix(in srgb, var(--sev) 7%, transparent); }
.row .host[data-act], .row .app[data-act] { cursor: pointer; }
/* 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: 4px; text-align: center;
font-family: var(--mono); font-size: 10.5px; font-weight: 700; line-height: 1.35; font-variant-numeric: tabular-nums;
background: #b4bac3; color: #111827;
}
.row .codes b.more { background: none; box-shadow: inset 0 0 0 1px #b4bac3; color: var(--muted); }
.row .host[data-act]:hover, .row .app[data-act]:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
mark.tag {
@@ -475,6 +493,7 @@ dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px
.preview-list .row .host { grid-area: host; }
.preview-list .row .app { grid-area: app; display: block; }
.preview-list .row .msg { grid-area: msg; }
.preview-list .row .codes { display: none; }
dialog {
width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 64px);
@@ -578,6 +597,8 @@ input.switch:disabled { cursor: not-allowed; }
input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.set-section footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-top: 14px; }
.tag-add { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-add select.field { width: auto; }
.seg { display: inline-flex; padding: 3px; gap: 3px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; }
.seg button {
@@ -590,11 +611,12 @@ 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-row {
display: grid; align-items: center; gap: 8px 10px;
grid-template-columns: 38px minmax(8rem, 1fr) 7.5rem auto 36px;
grid-template-columns: auto 38px minmax(8rem, 1fr) 7.5rem auto 36px;
padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel-2);
}
.tag-row.off { opacity: .55; }
.tag-row .tag-code { font-size: 12px; padding: 3px 6px; cursor: default; }
.tag-row input[type="color"] {
width: 38px; height: 32px; padding: 0; border: 1px solid var(--border); border-radius: 8px;
background: none; cursor: pointer;
@@ -637,8 +659,8 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.table .list { display: block; margin: 0; }
.list-head { display: none; }
.row, .table .row {
grid-template-columns: auto auto 1fr;
grid-template-areas: "rcv sev host" "msg msg msg";
grid-template-columns: auto auto 1fr auto;
grid-template-areas: "rcv sev host codes" "msg msg msg msg";
gap: 3px 8px; padding: 8px 12px 8px 10px;
}
.row time.rcv { grid-area: rcv; }
@@ -646,6 +668,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.row .sev { grid-area: sev; }
.row .host { grid-area: host; justify-self: end; max-width: 100%; }
.row .app { display: none; }
.row .codes { grid-area: codes; }
.row .msg { grid-area: msg; }
.details { grid-column: 1 / -1; }
.details dl { grid-template-columns: 1fr; }
@@ -663,12 +686,39 @@ 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-panels { padding: 0 16px 18px; }
#sizeSwitch { display: flex; }
#sizeSwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
#sizeSwitch, #densitySwitch { display: flex; }
#sizeSwitch button, #densitySwitch button { flex: 1; padding: 0 4px; white-space: nowrap; font-size: 12px; }
:root[data-density="compact"] .row { padding-top: 3px; padding-bottom: 3px; }
.field-grid select { margin-bottom: 6px; }
.status { padding: 6px 16px; }
.tag-row { grid-template-columns: 38px 1fr 36px; }
.tag-row { grid-template-columns: auto 38px 1fr 36px; }
.tag-row .preview { display: none; }
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
.tag-row [data-del] { grid-column: 4; 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; }