Safer defaults, read-only role, security headers and syslog TCP limits

- ALLOW_PURGE is now false by default; the UI shows a banner when there is
  no authentication.
- Read-only role: AUTH_VIEWER_USER/AUTH_VIEWER_PASS in local mode, or
  OIDC_ADMIN_GROUP in OIDC mode; changes get 403 and the admin settings
  are greyed out.
- Content-Security-Policy (inline scripts allowed by hash) and other
  security headers; cross-site changes are refused.
- Syslog TCP: at most SYSLOG_TCP_MAX_CONNS connections, closed after
  SYSLOG_TCP_IDLE of silence; HTTP idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-10-03 16:30:40 +02:00
1 parent 3466a29692
commit 42f6137391
13 files changed
+543 -50

No files matched your search

+8 -1
View File
@@ -9,6 +9,9 @@ AUTH_MODE=local
# local mode: user and password (empty = no authentication)
AUTH_USER=
AUTH_PASS=
# local mode: optional read-only account (can search and export, cannot change tags, sources or purge)
AUTH_VIEWER_USER=
AUTH_VIEWER_PASS=
# local mode: PNG logo shown on the login page, path inside the container (empty = no logo).
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
LOGIN_LOGO=
@@ -28,12 +31,16 @@ OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added)
OIDC_SCOPES=openid profile email
# oidc mode: only members of this group are admins, the others are read-only (empty = everyone is admin).
# The groups come from the ID token claim OIDC_GROUPS_CLAIM (default groups)
OIDC_ADMIN_GROUP=
OIDC_GROUPS_CLAIM=groups
# Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
DNS_SERVER=
# Allow "Delete all logs" in Settings (true/false)
ALLOW_PURGE=true
ALLOW_PURGE=false
# Maximum number of rows in a CSV export
EXPORT_MAX=100000
# Collect the logs of the Docker containers of this machine (on/off)
+24 -4
View File
@@ -259,9 +259,9 @@ couleur, est mémorisé par navigateur.
quels. Le réglage le plus dense est Très petite + Compacte + Inconsolata Condensed.
- **Données** : « Supprimer tous les logs » efface définitivement tous les logs stockés (il faut
taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface
peut purger : activez l'[authentification](#authentification) si l'interface est accessible
être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
alors purger : activez l'[authentification](#authentification) si l'interface est accessible
à d'autres.
## Authentification
@@ -270,6 +270,7 @@ couleur, est mémorisé par navigateur.
- **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà).
L'interface affiche alors un bandeau d'avertissement, que l'on peut fermer.
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE.
@@ -280,6 +281,10 @@ déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrit
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
Un **compte en lecture seule** optionnel, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, peut chercher,
suivre le direct et exporter, mais pas modifier les tags, les sources ni purger : ces réglages
sont grisés dans son interface et l'API répond `403`.
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
son chemin dans `LOGIN_LOGO` :
@@ -320,6 +325,16 @@ l'application). Les connexions sont écrites dans les logs de logstream (`oidc:
Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être
joint à travers un reverse proxy TLS.
Pour donner un accès en lecture seule à certains utilisateurs, définissez `OIDC_ADMIN_GROUP` (par
exemple `logstream-admins`) : seuls ses membres sont administrateurs, les autres sont en lecture
seule. Les groupes sont lus dans la revendication `groups` du jeton d'identité
(`OIDC_GROUPS_CLAIM` pour en utiliser une autre) ; dans Keycloak, ajoutez au client un mapper
« Group Membership » (le `/` initial est ignoré).
Quel que soit le mode, chaque réponse porte des en-têtes de sécurité (Content-Security-Policy,
X-Frame-Options…), et l'API refuse les modifications envoyées depuis un autre site (requêtes
intersites).
## Noms d'hôtes (DNS inverse)
Quand un équipement envoie son adresse IP comme nom d'hôte (ou pas de nom d'hôte du tout),
@@ -342,15 +357,20 @@ résolutions.
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | vide | compte optionnel en lecture seule (mode `local`) |
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur |
| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés |
| `OIDC_ADMIN_GROUP` | vide | seuls les membres de ce groupe sont administrateurs, les autres en lecture seule (vide = tout le monde est administrateur) |
| `OIDC_GROUPS_CLAIM` | `groups` | revendication du jeton d'identité qui liste les groupes |
| `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS |
| `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) |
| `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres |
| `ALLOW_PURGE` | `false` | autoriser « Supprimer tous les logs » dans les Paramètres |
| `SYSLOG_TCP_MAX_CONNS` | `512` | connexions syslog TCP ouvertes en même temps ; au-delà, elles sont refusées |
| `SYSLOG_TCP_IDLE` | `30m` | une connexion syslog TCP silencieuse pendant cette durée est fermée (les émetteurs se reconnectent) |
| `EXPORT_MAX` | `100000` | nombre maximal de lignes dans un export CSV |
| `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux |
+22 -4
View File
@@ -235,8 +235,8 @@ remembered per browser.
typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
- **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: turn on [authentication](#authentication) if the UI
(already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
is reachable by others.
## Authentication
@@ -244,7 +244,8 @@ remembered per browser.
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open):
- **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them
empty to have no authentication (for instance behind a reverse proxy that already checks).
empty to have no authentication (for instance behind a reverse proxy that already checks). The
UI then shows a warning banner, which can be closed.
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE.
@@ -254,6 +255,10 @@ ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) end
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
An optional **read-only account**, `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS`, can search, follow
the live view and export, but cannot change tags, sources or purge: those settings are greyed
out in its UI and the API answers `403`.
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
```yaml
@@ -291,6 +296,14 @@ Every user the provider accepts for this client can log in: restrict access in t
in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are
only sent over HTTPS: logstream must be reached through a TLS reverse proxy.
To give read-only access to some users, set `OIDC_ADMIN_GROUP` (for instance
`logstream-admins`): only its members are admins, the others are read-only. The groups are read
from the `groups` claim of the ID token (`OIDC_GROUPS_CLAIM` to use another one); in Keycloak,
add a "Group Membership" mapper to the client (a leading `/` is ignored).
Whatever the mode, every answer carries security headers (Content-Security-Policy,
X-Frame-Options…), and the API refuses changes sent from another site (cross-site requests).
## Host names (reverse DNS)
When a device sends its IP address as host name (or no host name at all), Logstream looks up
@@ -311,15 +324,20 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
| `AUTH_VIEWER_USER` / `AUTH_VIEWER_PASS` | empty | optional read-only account (`local` mode) |
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider |
| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes |
| `OIDC_ADMIN_GROUP` | empty | only members of this group are admins, the others read-only (empty = everyone is admin) |
| `OIDC_GROUPS_CLAIM` | `groups` | ID token claim that lists the groups |
| `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
| `ALLOW_PURGE` | `false` | allow "Delete all logs" in Settings |
| `SYSLOG_TCP_MAX_CONNS` | `512` | syslog TCP connections open at once; more are refused |
| `SYSLOG_TCP_IDLE` | `30m` | a syslog TCP connection silent this long is closed (senders reconnect) |
| `EXPORT_MAX` | `100000` | maximum number of rows in a CSV export |
| `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
+53 -17
View File
@@ -42,6 +42,10 @@ const (
type authConfig struct {
mode string
user, pass string // local mode
viewerUser string // local mode: optional read-only account
viewerPass string
adminGroup string // oidc: only members of this group are admins (empty: everyone)
groupsClaim string // oidc: ID token claim listing the groups
issuer string
clientID string
clientSecret string
@@ -124,6 +128,9 @@ func newOIDC(c authConfig) (*OIDC, error) {
if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes
}
if c.groupsClaim == "" {
c.groupsClaim = "groups"
}
return &OIDC{
cfg: c,
callback: ru.Path,
@@ -157,8 +164,9 @@ func sessionKey(dir string) []byte {
}
type session struct {
User string `json:"u"`
Exp int64 `json:"e"`
User string `json:"u"`
Exp int64 `json:"e"`
Viewer bool `json:"v,omitempty"` // read-only user
}
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
@@ -191,9 +199,12 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var s session
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
if r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User, "role": roleName(s.Viewer)})
return
}
if s.Viewer {
r = asViewer(r)
}
o.next.ServeHTTP(w, r)
return
}
@@ -257,16 +268,16 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure})
user, err := o.exchange(r, q.Get("code"), ls)
user, viewer, err := o.exchange(r, q.Get("code"), ls)
if err != nil {
log.Printf("oidc: login failed: %v", err)
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
return
}
log.Printf("oidc: %s logged in", user)
log.Printf("oidc: %s logged in (%s)", user, roleName(viewer))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix(), Viewer: viewer}),
Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true,
@@ -286,14 +297,15 @@ func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
// exchange trades the code for tokens and returns the user name from the verified ID token.
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) {
// exchange trades the code for tokens and returns the user name from the verified ID
// token, and whether the user is read-only (not in OIDC_ADMIN_GROUP).
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, bool, error) {
if code == "" {
return "", errors.New("no code in the callback")
return "", false, errors.New("no code in the callback")
}
meta, err := o.discover()
if err != nil {
return "", err
return "", false, err
}
form := url.Values{
"grant_type": {"authorization_code"},
@@ -309,7 +321,7 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
return "", false, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
@@ -318,29 +330,53 @@ func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, er
}
res, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("token endpoint: %w", err)
return "", false, fmt.Errorf("token endpoint: %w", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
return "", false, fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body))
}
var tok struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" {
return "", errors.New("token endpoint: no id_token in the response")
return "", false, errors.New("token endpoint: no id_token in the response")
}
claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
if err != nil {
return "", err
return "", false, err
}
viewer := o.cfg.adminGroup != "" && !hasGroup(claims[o.cfg.groupsClaim], o.cfg.adminGroup)
for _, k := range []string{"preferred_username", "email", "name", "sub"} {
if v, _ := claims[k].(string); v != "" {
return v, nil
return v, viewer, nil
}
}
return "", errors.New("id_token: no sub")
return "", false, errors.New("id_token: no sub")
}
// hasGroup tells whether the groups claim (a list, or a single string) holds
// group; a leading "/" (Keycloak group paths) is ignored.
func hasGroup(claim any, group string) bool {
group = strings.TrimPrefix(group, "/")
var groups []string
switch v := claim.(type) {
case string:
groups = strings.Fields(strings.ReplaceAll(v, ",", " "))
case []any:
for _, g := range v {
if s, ok := g.(string); ok {
groups = append(groups, s)
}
}
}
for _, g := range groups {
if strings.TrimPrefix(g, "/") == group {
return true
}
}
return false
}
// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token.
+38 -16
View File
@@ -23,6 +23,8 @@ var loginFailDelay = time.Second // slows down password guessing
type Local struct {
user, pass string
viewerUser string // optional read-only account
viewerPass string
ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo
key []byte
@@ -32,14 +34,17 @@ type Local struct {
func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass + "\x00" + c.viewerUser + "\x00" + c.viewerPass))
if c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err)
}
}
log.Printf("local authentication enabled (user %s)", c.user)
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
if c.viewerUser != "" {
log.Printf("local read-only account enabled (user %s)", c.viewerUser)
}
return &Local{user: c.user, pass: c.pass, viewerUser: c.viewerUser, viewerPass: c.viewerPass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
}
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -58,12 +63,17 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, ok := l.sessionUser(r)
s, ok := l.sessionUser(r)
if !ok {
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
user, ok = u, true
if u, p, basic := r.BasicAuth(); basic {
if viewer, valid := l.check(u, p); valid {
s, ok = session{User: u, Viewer: viewer}, true
}
}
}
if ok && s.Viewer {
r = asViewer(r)
}
switch {
case r.URL.Path == loginPage:
if ok {
@@ -73,7 +83,7 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me":
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": s.User, "role": roleName(s.Viewer)})
case ok:
l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
@@ -94,7 +104,8 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
}
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r"))
if !l.check(user, pass) {
viewer, valid := l.check(user, pass)
if !valid {
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}}
@@ -104,10 +115,10 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return
}
log.Printf("auth: %s logged in from %s", user, clientIP(r))
log.Printf("auth: %s logged in from %s (%s)", user, clientIP(r), roleName(viewer))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix(), Viewer: viewer}),
Path: "/",
MaxAge: int(l.ttl.Seconds()),
HttpOnly: true,
@@ -117,19 +128,30 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, ret, http.StatusSeeOther)
}
func (l *Local) sessionUser(r *http.Request) (string, bool) {
func (l *Local) sessionUser(r *http.Request) (session, bool) {
var s session
c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
return "", false
return session{}, false
}
return s.User, true
return s, true
}
func (l *Local) check(user, pass string) bool {
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
return u&p == 1
// check validates a user and password: the admin account, or the read-only one
// (viewer=true) when AUTH_VIEWER_USER is set.
func (l *Local) check(user, pass string) (viewer, ok bool) {
if same(user, l.user) && same(pass, l.pass) {
return false, true
}
if l.viewerUser != "" && same(user, l.viewerUser) && same(pass, l.viewerPass) {
return true, true
}
return false, false
}
// same compares in constant time, so the answer time says nothing of the secret.
func same(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
+5 -1
View File
@@ -17,6 +17,8 @@ services:
AUTH_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte ci-dessous) ou oidc
AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-}
AUTH_VIEWER_USER: ${AUTH_VIEWER_USER:-} # compte en lecture seule (optionnel)
AUTH_VIEWER_PASS: ${AUTH_VIEWER_PASS:-}
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
OIDC_ISSUER: ${OIDC_ISSUER:-}
@@ -24,10 +26,12 @@ services:
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-} # vide = tout le monde est admin, sinon les autres sont en lecture seule
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
RDNS: ${RDNS:-on} # resol dns
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
ALLOW_PURGE: ${ALLOW_PURGE:-true}
ALLOW_PURGE: ${ALLOW_PURGE:-false} # true pour autoriser « Supprimer tous les logs »
EXPORT_MAX: ${EXPORT_MAX:-100000}
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API Docker
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"io/fs"
"net/http"
"net/url"
"regexp"
"strings"
)
// Request guards shared by every auth mode: security headers, a cross-site
// request check, and the read-only role.
type viewerKey struct{}
// asViewer marks the request as made by a read-only user.
func asViewer(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), viewerKey{}, true))
}
func isViewer(r *http.Request) bool {
v, _ := r.Context().Value(viewerKey{}).(bool)
return v
}
func roleName(viewer bool) string {
if viewer {
return "viewer"
}
return "admin"
}
func isSafeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// readOnly refuses the API calls that change something (tags, sources, purge)
// to read-only users. Without authentication everyone is admin.
func readOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isViewer(r) && !isSafeMethod(r.Method) && strings.HasPrefix(r.URL.Path, "/api/") {
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
return
}
next.ServeHTTP(w, r)
})
}
// crossSite tells whether a request that changes something comes from another
// site (a form or script on a third-party page), using the headers browsers
// add; tools such as curl send neither and are let through.
func crossSite(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return false
case "":
default: // same-site, cross-site
return true
}
o := r.Header.Get("Origin")
if o == "" {
return false
}
u, err := url.Parse(o)
return err != nil || !strings.EqualFold(u.Host, r.Host)
}
// secure adds the security headers to every answer and refuses cross-site
// changes. Without authentication it also answers /auth/me, so the UI can
// warn that anyone on the network has full access.
func secure(next http.Handler, csp string, authOn bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
if !isSafeMethod(r.Method) && crossSite(r) {
writeErr(w, http.StatusForbidden, &codedError{code: "cross_site", msg: "cross-site request refused"})
return
}
if !authOn && r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "none", "role": "admin"})
return
}
next.ServeHTTP(w, r)
})
}
var inlineScript = regexp.MustCompile(`(?s)<script>(.*?)</script>`)
// contentSecurityPolicy allows the UI's own files, the inline scripts of the
// embedded pages (by hash) and the optional Bunny Fonts.
func contentSecurityPolicy(static fs.FS) string {
scripts := []string{"'self'"}
for _, page := range []string{"index.html", "login.html"} {
b, err := fs.ReadFile(static, page)
if err != nil {
continue
}
for _, m := range inlineScript.FindAllSubmatch(b, -1) {
sum := sha256.Sum256(m[1])
scripts = append(scripts, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
}
}
return strings.Join([]string{
"default-src 'self'",
"script-src " + strings.Join(scripts, " "),
// Inline style attributes carry the tag and project colors.
"style-src 'self' 'unsafe-inline' https://fonts.bunny.net",
"font-src 'self' https://fonts.bunny.net",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
}, "; ")
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"errors"
"io/fs"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
var echo = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) })
func TestSecureHeadersAndCrossSite(t *testing.T) {
h := secure(echo, "default-src 'self'", false)
cases := []struct {
method string
hdr map[string]string
want int
}{
{"GET", map[string]string{"Sec-Fetch-Site": "cross-site"}, 200}, // reading is fine
{"POST", nil, 200}, // curl, scripts
{"POST", map[string]string{"Sec-Fetch-Site": "same-origin"}, 200},
{"POST", map[string]string{"Sec-Fetch-Site": "cross-site"}, 403},
{"DELETE", map[string]string{"Sec-Fetch-Site": "same-site"}, 403},
{"POST", map[string]string{"Origin": "http://logs.lan:8080"}, 200},
{"POST", map[string]string{"Origin": "https://evil.example"}, 403},
{"PUT", map[string]string{"Origin": "null"}, 403},
}
for _, c := range cases {
r := httptest.NewRequest(c.method, "http://logs.lan:8080/api/purge", nil)
for k, v := range c.hdr {
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != c.want {
t.Errorf("%s %v: %d, want %d", c.method, c.hdr, rec.Code, c.want)
}
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("%s %v: security headers missing", c.method, c.hdr)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if !strings.Contains(rec.Body.String(), `"mode":"none"`) || !strings.Contains(rec.Body.String(), `"role":"admin"`) {
t.Errorf("/auth/me without auth: %s", rec.Body)
}
rec = httptest.NewRecorder()
secure(echo, "", true).ServeHTTP(rec, httptest.NewRequest("GET", "/auth/me", nil))
if rec.Body.String() != "app /auth/me" {
t.Errorf("/auth/me with auth answered by the guard: %s", rec.Body)
}
}
func TestContentSecurityPolicyHashesInlineScripts(t *testing.T) {
static, _ := fs.Sub(webFS, "web")
csp := contentSecurityPolicy(static)
// index.html and login.html each have inline scripts.
if n := strings.Count(csp, "'sha256-"); n < 3 {
t.Errorf("%d script hashes in %q", n, csp)
}
for _, want := range []string{"frame-ancestors 'none'", "connect-src 'self'", "https://fonts.bunny.net"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q", want)
}
}
}
func TestLocalViewerIsReadOnly(t *testing.T) {
loginFailDelay = 0
h, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", viewerUser: "guest", viewerPass: "ro", dataDir: t.TempDir()}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: hosts{"app.test": h}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
app := "http://app.test"
login(t, c, app, "guest", "ro", "/")
if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"role":"viewer"`) {
t.Fatalf("me: %d %s", code, body)
}
if code, _ := get(t, c, app+"/api/logs"); code != 200 {
t.Errorf("viewer reading logs: %d", code)
}
res, err := c.Post(app+"/api/purge", "application/json", strings.NewReader(`{"confirm":"PURGE"}`))
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusForbidden {
t.Errorf("viewer purge: %d, want 403", res.StatusCode)
}
// The admin account still changes things, also through Basic auth.
req, _ := http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("admin", "pw")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != 200 {
t.Errorf("admin change: %d", res.StatusCode)
}
req, _ = http.NewRequest("PUT", app+"/api/syslog", strings.NewReader("{}"))
req.SetBasicAuth("guest", "ro")
if res, _ := (&http.Client{Transport: hosts{"app.test": h}}).Do(req); res.StatusCode != http.StatusForbidden {
t.Errorf("viewer change through Basic auth: %d", res.StatusCode)
}
}
func TestOIDCAdminGroup(t *testing.T) {
for _, tc := range []struct {
groups any
role string
}{
{[]any{"staff", "/logstream-admins"}, "admin"},
{[]any{"staff"}, "viewer"},
{nil, "viewer"},
} {
idp := newFakeIdP(t)
idp.claims = func(c map[string]any) {
if tc.groups != nil {
c["groups"] = tc.groups
}
}
h, err := newAuth(authConfig{
mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret",
redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(), adminGroup: "logstream-admins",
}, readOnly(echo))
if err != nil {
t.Fatal(err)
}
netw := hosts{"app.test": h, "idp.test": idp.mux}
h.(*OIDC).client.Transport = netw
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Transport: netw}
get(t, c, "http://app.test/")
if _, body := get(t, c, "http://app.test/auth/me"); !strings.Contains(body, `"role":"`+tc.role+`"`) {
t.Errorf("groups %v: %s, want role %s", tc.groups, body, tc.role)
}
}
}
func TestHasGroup(t *testing.T) {
if !hasGroup("ops logstream-admins", "/logstream-admins") || !hasGroup([]any{"a", "b"}, "b") || hasGroup(42, "b") {
t.Error("hasGroup")
}
}
func TestTCPIdleTimeout(t *testing.T) {
a, b := net.Pipe()
defer b.Close()
c := idleConn{a, 30 * time.Millisecond}
go func() { _, _ = b.Write([]byte("x")) }()
buf := make([]byte, 1)
if _, err := c.Read(buf); err != nil {
t.Fatal(err)
}
start := time.Now()
if _, err := c.Read(buf); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("silent connection: %v, want a deadline error", err)
}
if time.Since(start) > time.Second {
t.Error("deadline not applied")
}
}
+18 -2
View File
@@ -84,6 +84,10 @@ func main() {
mode: getenv("AUTH_MODE", "local"),
user: os.Getenv("AUTH_USER"),
pass: os.Getenv("AUTH_PASS"),
viewerUser: os.Getenv("AUTH_VIEWER_USER"),
viewerPass: os.Getenv("AUTH_VIEWER_PASS"),
adminGroup: os.Getenv("OIDC_ADMIN_GROUP"),
groupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
issuer: os.Getenv("OIDC_ISSUER"),
clientID: os.Getenv("OIDC_CLIENT_ID"),
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
@@ -95,7 +99,7 @@ func main() {
},
rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true),
allowPurge: getenvBool("ALLOW_PURGE", false),
exportMax: getenvInt("EXPORT_MAX", 100000),
dockerLogs: getenvBool("DOCKER_LOGS", false),
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
@@ -135,6 +139,10 @@ func main() {
store.Enqueue(e)
hub.Publish(e)
}
tcpMaxConns = getenvInt("SYSLOG_TCP_MAX_CONNS", tcpMaxConns)
if d := getenvDuration("SYSLOG_TCP_IDLE", tcpIdle); d > 0 {
tcpIdle = d
}
// Listening errors (port already used…) are shown in Settings > Sources.
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
@@ -161,17 +169,25 @@ func main() {
api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static)))
handler, err := newAuth(cfg.auth, mux)
handler, err := newAuth(cfg.auth, readOnly(mux))
if err != nil {
log.Fatalf("auth: %v", err)
}
if o, ok := handler.(*OIDC); ok {
go o.checkProvider()
}
_, local := handler.(*Local)
_, oidc := handler.(*OIDC)
authOn := local || oidc
if !authOn {
log.Printf("warning: no authentication (AUTH_USER is empty): anyone who can reach %s can read the logs and change the settings", cfg.httpAddr)
}
handler = secure(handler, contentSecurityPolicy(static), authOn)
srv := &http.Server{
Addr: cfg.httpAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
// Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx },
}
+32 -2
View File
@@ -241,7 +241,15 @@ func serveUDP(ctx context.Context, pc net.PacketConn, sink func(*Entry)) {
}
}
// TCP limits: connections open at once, and how long a connection may stay
// silent before it is closed (senders reconnect on their own).
var (
tcpMaxConns = 512
tcpIdle = 30 * time.Minute
)
func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
slots := make(chan struct{}, tcpMaxConns)
for {
conn, err := ln.Accept()
if err != nil {
@@ -252,10 +260,32 @@ func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
time.Sleep(100 * time.Millisecond)
continue
}
go handleTCP(ctx, conn, sink)
select {
case slots <- struct{}{}:
default:
log.Printf("syslog tcp: %d connections already open, refusing %s", tcpMaxConns, conn.RemoteAddr())
conn.Close()
continue
}
go func() {
defer func() { <-slots }()
handleTCP(ctx, conn, sink)
}()
}
}
// idleConn pushes the read deadline back before each read, so only a
// connection that stays silent for tcpIdle is closed.
type idleConn struct {
net.Conn
idle time.Duration
}
func (c idleConn) Read(p []byte) (int, error) {
_ = c.Conn.SetReadDeadline(time.Now().Add(c.idle))
return c.Conn.Read(p)
}
const maxFrame = 1 << 20
// handleTCP supports both RFC 6587 framings: octet counting
@@ -266,7 +296,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) {
defer stop()
src := hostOf(conn.RemoteAddr())
r := bufio.NewReaderSize(conn, 64*1024)
r := bufio.NewReaderSize(idleConn{conn, tcpIdle}, 64*1024)
for {
c, err := r.ReadByte()
if err != nil {
+30 -2
View File
@@ -131,6 +131,10 @@ const I18N = {
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_read_only: 'Read-only account: changes are reserved to administrators',
err_cross_site: 'Request refused: it comes from another site',
authOff: 'No authentication: anyone who can reach this page can read the logs and change the settings. Set AUTH_USER / AUTH_PASS or AUTH_MODE=oidc.',
readOnlyNote: 'Read-only account: these settings can only be changed by an administrator.',
err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range',
connZoom: 'live paused (zoom)',
@@ -282,6 +286,10 @@ const I18N = {
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).',
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_read_only: 'Compte en lecture seule : les modifications sont réservées aux administrateurs',
err_cross_site: 'Requête refusée : elle vient d\'un autre site',
authOff: 'Aucune authentification : toute personne qui atteint cette page peut lire les logs et changer les réglages. Définissez AUTH_USER / AUTH_PASS ou AUTH_MODE=oidc.',
readOnlyNote: 'Compte en lecture seule : seul un administrateur peut modifier ces réglages.',
err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée',
connZoom: 'direct en pause (zoom)',
@@ -2212,14 +2220,34 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal'));
$('#authWarnClose').addEventListener('click', () => {
$('#authWarn').hidden = true;
store.set('authWarnHidden', '1');
});
applyLang();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', '');
// With a login (local or OIDC), show who is logged in and the log out button.
// Without a login, warn that the UI is open to everyone. With a login (local or OIDC),
// show who is logged in and the log out button, and lock the admin settings of a
// read-only account.
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
if (!me || !me.user) return;
if (!me) return;
if (me.mode === 'none' && store.get('authWarnHidden') !== '1') $('#authWarn').hidden = false;
if (me.role === 'viewer') {
document.body.classList.add('read-only');
for (const p of document.querySelectorAll('[data-panel="filters"], [data-panel="sources"], [data-panel="data"]')) {
for (const s of p.querySelectorAll('.set-section')) s.inert = true;
const note = document.createElement('p');
note.className = 'ro-note';
note.dataset.i18n = 'readOnlyNote';
note.textContent = t('readOnlyNote');
p.prepend(note);
}
}
if (!me.user) return;
const btn = $('#logoutBtn');
btn.hidden = false;
btn.dataset.user = me.user;
+6
View File
@@ -97,6 +97,12 @@
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section>
<div id="authWarn" class="auth-warn" role="status" hidden>
<span data-i18n="authOff"></span>
<button id="authWarnClose" class="icon-btn" type="button" data-i18n-aria="close">
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg>
</button>
</div>
<div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button>
+17 -1
View File
@@ -433,6 +433,22 @@ mark.hit { background: var(--hit); color: inherit; border-radius: 3px; padding:
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap;
}
.auth-warn {
display: flex; align-items: center; gap: 10px;
margin: 6px 20px 0; padding: 6px 8px 6px 14px;
border: 1px solid color-mix(in srgb, var(--sev-warning) 45%, transparent);
background: color-mix(in srgb, var(--sev-warning) 12%, transparent);
color: var(--text); border-radius: var(--radius); font-size: 12.5px;
}
.auth-warn[hidden] { display: none; }
.auth-warn span { flex: 1; }
.auth-warn .icon-btn { width: 26px; height: 26px; flex: none; }
.ro-note {
margin: 0 0 12px; padding: 8px 12px; border-radius: var(--radius); font-size: 12.5px;
background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text);
}
.read-only .set-panel .set-section[inert] { opacity: .55; }
.pill {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
border: 0; border-radius: 999px; padding: 7px 16px;
@@ -662,7 +678,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; }
.h-leg { display: none; }
.list, .error-banner, .table { margin-left: 16px; margin-right: 16px; }
.list, .error-banner, .auth-warn, .table { margin-left: 16px; margin-right: 16px; }
/* Phones: no columns, two lines per log (layout below); the widths do not apply */
.table { display: block; }
.table .list { display: block; margin: 0; }