diff --git a/.env.example b/.env.example index dbc809d..41d51c9 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.fr.md b/README.fr.md index fbdfba7..abf8917 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 | diff --git a/README.md b/README.md index e29ae38..8fd1b14 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/auth.go b/auth.go index 0105389..96c5eaf 100644 --- a/auth.go +++ b/auth.go @@ -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. diff --git a/auth_local.go b/auth_local.go index 306b4ef..4e8c68b 100644 --- a/auth_local.go +++ b/auth_local.go @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index f72132b..d4d9089 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/guard.go b/guard.go new file mode 100644 index 0000000..0beb0a0 --- /dev/null +++ b/guard.go @@ -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)`) + +// 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'", + }, "; ") +} diff --git a/guard_test.go b/guard_test.go new file mode 100644 index 0000000..437b040 --- /dev/null +++ b/guard_test.go @@ -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") + } +} diff --git a/main.go b/main.go index ae4d058..8aec9d2 100644 --- a/main.go +++ b/main.go @@ -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 }, } diff --git a/syslog.go b/syslog.go index 9239510..81b1755 100644 --- a/syslog.go +++ b/syslog.go @@ -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 { diff --git a/web/app.js b/web/app.js index 807ade5..9dac174 100644 --- a/web/app.js +++ b/web/app.js @@ -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; diff --git a/web/index.html b/web/index.html index 84e4820..4286705 100644 --- a/web/index.html +++ b/web/index.html @@ -97,6 +97,12 @@ + diff --git a/web/style.css b/web/style.css index a54b7dd..b2a1e45 100644 --- a/web/style.css +++ b/web/style.css @@ -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; }