Sécurité : purge désactivée par défaut, rôle lecture seule, en-têtes et limites TCP #16

Merged
claude Bot merged 1 commits from feat/securite into main 2026-10-03 16:39:50 +02:00
13 changed files with 541 additions and 48 deletions
Showing only changes of commit 42f6137391 - Show all commits

No files matched your search

+8 -1
View File
@@ -9,6 +9,9 @@ AUTH_MODE=local
# local mode: user and password (empty = no authentication) # local mode: user and password (empty = no authentication)
AUTH_USER= AUTH_USER=
AUTH_PASS= 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). # 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 # Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
LOGIN_LOGO= LOGIN_LOGO=
@@ -28,12 +31,16 @@ OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added) # Requested scopes (openid is always added)
OIDC_SCOPES=openid profile email 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) # Reverse DNS: show host names instead of IP addresses (on/off)
RDNS=on RDNS=on
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver # DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
DNS_SERVER= DNS_SERVER=
# Allow "Delete all logs" in Settings (true/false) # Allow "Delete all logs" in Settings (true/false)
ALLOW_PURGE=true ALLOW_PURGE=false
# Maximum number of rows in a CSV export # Maximum number of rows in a CSV export
EXPORT_MAX=100000 EXPORT_MAX=100000
# Collect the logs of the Docker containers of this machine (on/off) # 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. 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 - **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 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 être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`). La fonction est
`ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface désactivée par défaut : mettez `ALLOW_PURGE=true` pour l'autoriser. Tout administrateur peut
peut purger : activez l'[authentification](#authentification) si l'interface est accessible alors purger : activez l'[authentification](#authentification) si l'interface est accessible
à d'autres. à d'autres.
## Authentification ## 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 - **`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à). 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…), - **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE. 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 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`). 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 Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
son chemin dans `LOGIN_LOGO` : 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 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. 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) ## 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), 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 | | `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) | | `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_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`) | | `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) | | `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_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_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_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés | | `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 | | `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`) | | `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 | | `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)) | | `PRESETS_FILE` | `/data/presets.json` | fichier des préréglages de tags ; liste intégrée s'il est absent (voir [docs/presets.fr.md](docs/presets.fr.md)) |
| `DOCKER_LOGS` | `on` dans compose | collecter les logs des conteneurs Docker locaux | | `DOCKER_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. typed. The densest setting is Tiny + Compact + Inconsolata Condensed.
- **Data**: "Delete all logs" permanently erases every stored log (you must type - **Data**: "Delete all logs" permanently erases every stored log (you must type
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` `PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
(already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature. (already set in `docker-compose.yml`). The feature is off by default: set `ALLOW_PURGE=true`
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI to allow it. Any admin can then purge: turn on [authentication](#authentication) if the UI
is reachable by others. is reachable by others.
## Authentication ## Authentication
@@ -244,7 +244,8 @@ remembered per browser.
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open): `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 - **`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…), - **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE. 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`). 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`). 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: To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
```yaml ```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 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. 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) ## Host names (reverse DNS)
When a device sends its IP address as host name (or no host name at all), Logstream looks up 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 | | `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) | | `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_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) | | `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) | | `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_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider | | `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_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes | | `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 | | `RDNS` | `on` | resolve IP hosts to DNS names |
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) | | `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings | | `ALLOW_PURGE` | `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 | | `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)) | | `PRESETS_FILE` | `/data/presets.json` | color tag presets file; the built-in list when missing (see [docs/presets.md](docs/presets.md)) |
| `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers | | `DOCKER_LOGS` | `on` in compose | collect the logs of the local Docker containers |
+51 -15
View File
@@ -42,6 +42,10 @@ const (
type authConfig struct { type authConfig struct {
mode string mode string
user, pass string // local mode 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 issuer string
clientID string clientID string
clientSecret string clientSecret string
@@ -124,6 +128,9 @@ func newOIDC(c authConfig) (*OIDC, error) {
if !strings.Contains(" "+c.scopes+" ", " openid ") { if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes c.scopes = "openid " + c.scopes
} }
if c.groupsClaim == "" {
c.groupsClaim = "groups"
}
return &OIDC{ return &OIDC{
cfg: c, cfg: c,
callback: ru.Path, callback: ru.Path,
@@ -159,6 +166,7 @@ func sessionKey(dir string) []byte {
type session struct { type session struct {
User string `json:"u"` User string `json:"u"`
Exp int64 `json:"e"` 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 // 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 var s session
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp { 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" { 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 return
} }
if s.Viewer {
r = asViewer(r)
}
o.next.ServeHTTP(w, r) o.next.ServeHTTP(w, r)
return 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}) 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 { if err != nil {
log.Printf("oidc: login failed: %v", err) log.Printf("oidc: login failed: %v", err)
http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden) http.Error(w, "login failed, see the LogStream logs", http.StatusForbidden)
return return
} }
log.Printf("oidc: %s logged in", user) log.Printf("oidc: %s logged in (%s)", user, roleName(viewer))
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, 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: "/", Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()), MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -286,14 +297,15 @@ func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
// exchange trades the code for tokens and returns the user name from the verified ID token. // exchange trades the code for tokens and returns the user name from the verified ID
func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) { // 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 == "" { if code == "" {
return "", errors.New("no code in the callback") return "", false, errors.New("no code in the callback")
} }
meta, err := o.discover() meta, err := o.discover()
if err != nil { if err != nil {
return "", err return "", false, err
} }
form := url.Values{ form := url.Values{
"grant_type": {"authorization_code"}, "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())) req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
if err != nil { if err != nil {
return "", err return "", false, err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") 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) res, err := o.client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("token endpoint: %w", err) return "", false, fmt.Errorf("token endpoint: %w", err)
} }
defer res.Body.Close() defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK { 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 { var tok struct {
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
} }
if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" { 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) claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce)
if err != nil { 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"} { for _, k := range []string{"preferred_username", "email", "name", "sub"} {
if v, _ := claims[k].(string); v != "" { 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. // 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 { type Local struct {
user, pass string user, pass string
viewerUser string // optional read-only account
viewerPass string
ttl time.Duration ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo logo string // LOGIN_LOGO, served at /auth/logo
key []byte key []byte
@@ -32,14 +34,17 @@ type Local struct {
func newLocal(c authConfig) *Local { func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session. // The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir)) 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 c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil { if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err) log.Printf("auth: LOGIN_LOGO: %v", err)
} }
} }
log.Printf("local authentication enabled (user %s)", c.user) 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) { 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) http.Redirect(w, r, loginPage, http.StatusFound)
return return
} }
user, ok := l.sessionUser(r) s, ok := l.sessionUser(r)
if !ok { if !ok {
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) { if u, p, basic := r.BasicAuth(); basic {
user, ok = u, true if viewer, valid := l.check(u, p); valid {
s, ok = session{User: u, Viewer: viewer}, true
} }
} }
}
if ok && s.Viewer {
r = asViewer(r)
}
switch { switch {
case r.URL.Path == loginPage: case r.URL.Path == loginPage:
if ok { if ok {
@@ -73,7 +83,7 @@ func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r) l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me": 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: case ok:
l.next.ServeHTTP(w, r) l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me": 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") user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r")) 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)) log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay) time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}} 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) http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return 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{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, 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: "/", Path: "/",
MaxAge: int(l.ttl.Seconds()), MaxAge: int(l.ttl.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -117,19 +128,30 @@ func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, ret, http.StatusSeeOther) 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 var s session
c, err := r.Cookie(sessionCookie) c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp { 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 { // check validates a user and password: the admin account, or the read-only one
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user)) // (viewer=true) when AUTH_VIEWER_USER is set.
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass)) func (l *Local) check(user, pass string) (viewer, ok bool) {
return u&p == 1 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. // 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_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_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
AUTH_PASS: ${AUTH_PASS:-} 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) 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) PRESETS_FILE: ${PRESETS_FILE:-} # prereglages de tags (defaut /data/presets.json, voir docs/presets.fr.md)
OIDC_ISSUER: ${OIDC_ISSUER:-} OIDC_ISSUER: ${OIDC_ISSUER:-}
@@ -24,10 +26,12 @@ services:
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email} 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) SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
RDNS: ${RDNS:-on} # resol dns RDNS: ${RDNS:-on} # resol dns
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe 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} EXPORT_MAX: ${EXPORT_MAX:-100000}
DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker DOCKER_LOGS: ${DOCKER_LOGS:-on} # collecte des logs des conteneurs Docker
DOCKER_HOST: tcp://docker-proxy:2375 # lecture seul de l'API 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"), mode: getenv("AUTH_MODE", "local"),
user: os.Getenv("AUTH_USER"), user: os.Getenv("AUTH_USER"),
pass: os.Getenv("AUTH_PASS"), 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"), issuer: os.Getenv("OIDC_ISSUER"),
clientID: os.Getenv("OIDC_CLIENT_ID"), clientID: os.Getenv("OIDC_CLIENT_ID"),
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"), clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
@@ -95,7 +99,7 @@ func main() {
}, },
rdns: getenvBool("RDNS", true), rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"), dnsServer: os.Getenv("DNS_SERVER"),
allowPurge: getenvBool("ALLOW_PURGE", true), allowPurge: getenvBool("ALLOW_PURGE", false),
exportMax: getenvInt("EXPORT_MAX", 100000), exportMax: getenvInt("EXPORT_MAX", 100000),
dockerLogs: getenvBool("DOCKER_LOGS", false), dockerLogs: getenvBool("DOCKER_LOGS", false),
dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"), dockerHost: getenv("DOCKER_HOST", "unix:///var/run/docker.sock"),
@@ -135,6 +139,10 @@ func main() {
store.Enqueue(e) store.Enqueue(e)
hub.Publish(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. // Listening errors (port already used…) are shown in Settings > Sources.
syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink) syslogSrv := NewSyslogServer(ctx, cfg.syslogAddr, getenv("SYSLOG_PUBLIC_PORT", ""), cfg.dataDir, sink)
@@ -161,17 +169,25 @@ func main() {
api.Routes(mux) api.Routes(mux)
mux.Handle("GET /", http.FileServer(http.FS(static))) mux.Handle("GET /", http.FileServer(http.FS(static)))
handler, err := newAuth(cfg.auth, mux) handler, err := newAuth(cfg.auth, readOnly(mux))
if err != nil { if err != nil {
log.Fatalf("auth: %v", err) log.Fatalf("auth: %v", err)
} }
if o, ok := handler.(*OIDC); ok { if o, ok := handler.(*OIDC); ok {
go o.checkProvider() 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{ srv := &http.Server{
Addr: cfg.httpAddr, Addr: cfg.httpAddr,
Handler: handler, Handler: handler,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
// Requests inherit the global context so SSE streams end on shutdown. // Requests inherit the global context so SSE streams end on shutdown.
BaseContext: func(net.Listener) context.Context { return ctx }, BaseContext: func(net.Listener) context.Context { return ctx },
} }
+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)) { func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
slots := make(chan struct{}, tcpMaxConns)
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
@@ -252,8 +260,30 @@ func serveTCP(ctx context.Context, ln net.Listener, sink func(*Entry)) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
continue 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 const maxFrame = 1 << 20
@@ -266,7 +296,7 @@ func handleTCP(ctx context.Context, conn net.Conn, sink func(*Entry)) {
defer stop() defer stop()
src := hostOf(conn.RemoteAddr()) src := hostOf(conn.RemoteAddr())
r := bufio.NewReaderSize(conn, 64*1024) r := bufio.NewReaderSize(idleConn{conn, tcpIdle}, 64*1024)
for { for {
c, err := r.ReadByte() c, err := r.ReadByte()
if err != nil { if err != nil {
+30 -2
View File
@@ -131,6 +131,10 @@ const I18N = {
purgeForbidden: 'Purging is disabled on this server (ALLOW_PURGE=false).', 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_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_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', err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range', liveZoomed: 'Live view is not available on a zoomed range',
connZoom: 'live paused (zoom)', connZoom: 'live paused (zoom)',
@@ -282,6 +286,10 @@ const I18N = {
purgeForbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false).', 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_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_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', err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée', liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée',
connZoom: 'direct en pause (zoom)', connZoom: 'direct en pause (zoom)',
@@ -2212,14 +2220,34 @@ $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.curre
applyLogFont(store.get('logFont', 'system')); applyLogFont(store.get('logFont', 'system'));
applyLogSize(store.get('logSize', 'medium')); applyLogSize(store.get('logSize', 'medium'));
applyLogDensity(store.get('logDensity', 'normal')); applyLogDensity(store.get('logDensity', 'normal'));
$('#authWarnClose').addEventListener('click', () => {
$('#authWarn').hidden = true;
store.set('authWarnHidden', '1');
});
applyLang(); applyLang();
$('#range').value = store.get('range', '1h'); $('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h'; if (!$('#range').value) $('#range').value = '1h';
$('#severity').value = store.get('severity', ''); $('#severity').value = store.get('severity', '');
// With a login (local or OIDC), show who is logged in and the log out button. // 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) => { 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'); const btn = $('#logoutBtn');
btn.hidden = false; btn.hidden = false;
btn.dataset.user = me.user; btn.dataset.user = me.user;
+6
View File
@@ -97,6 +97,12 @@
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section> <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> <div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button> <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; 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 { .pill {
position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30; position: fixed; top: 70px; left: 50%; transform: translateX(-50%); z-index: 30;
border: 0; border-radius: 999px; padding: 7px 16px; 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; } #count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; } .histo { padding: 0 16px 6px; }
.h-leg { display: none; } .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 */ /* Phones: no columns, two lines per log (layout below); the widths do not apply */
.table { display: block; } .table { display: block; }
.table .list { display: block; margin: 0; } .table .list { display: block; margin: 0; }