Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b139e8931 | ||
|
|
19ed16ac12 | ||
|
|
7aebb1120f | ||
|
|
ab38a54d54 |
No files matched your search
+7
-3
@@ -4,11 +4,16 @@ HTTP_PORT=8080
|
|||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||||
RETENTION=30d
|
RETENTION=30d
|
||||||
# Web UI authentication: local (HTTP Basic below, or none) or oidc (OpenID Connect provider)
|
# Web UI authentication: local (login page with the account below, or none) or oidc (OpenID Connect provider)
|
||||||
AUTH_MODE=local
|
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: PNG logo shown on the login page, path inside the container (empty = no logo).
|
||||||
|
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
|
||||||
|
LOGIN_LOGO=
|
||||||
|
# Session lifetime, both modes (e.g. 8h, 24h)
|
||||||
|
SESSION_TTL=12h
|
||||||
# oidc mode: issuer URL exactly as the provider announces it
|
# oidc mode: issuer URL exactly as the provider announces it
|
||||||
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
||||||
OIDC_ISSUER=
|
OIDC_ISSUER=
|
||||||
@@ -16,9 +21,8 @@ OIDC_CLIENT_ID=
|
|||||||
OIDC_CLIENT_SECRET=
|
OIDC_CLIENT_SECRET=
|
||||||
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
|
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
|
||||||
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
|
||||||
# Requested scopes (openid is always added) and session lifetime (e.g. 8h, 24h)
|
# Requested scopes (openid is always added)
|
||||||
OIDC_SCOPES=openid profile email
|
OIDC_SCOPES=openid profile email
|
||||||
OIDC_SESSION_TTL=12h
|
|
||||||
# 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
|
||||||
|
|||||||
+29
-7
@@ -250,11 +250,31 @@ couleur, est mémorisé par navigateur.
|
|||||||
|
|
||||||
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
|
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) :
|
||||||
|
|
||||||
- **`local`** (par défaut) : authentification HTTP Basic avec `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à).
|
||||||
- **`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.
|
||||||
|
|
||||||
|
En mode `local`, la page de connexion suit le thème et la langue de l'interface. La session dure
|
||||||
|
`SESSION_TTL` (12 h par défaut), survit aux redémarrages (sa clé de signature est dans
|
||||||
|
`/data/session.key`) et se termine quand `AUTH_USER` ou `AUTH_PASS` change ; le bouton de
|
||||||
|
déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrits dans les logs avec
|
||||||
|
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
|
||||||
|
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
|
||||||
|
|
||||||
|
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
|
||||||
|
son chemin dans `LOGIN_LOGO` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml, service logstream
|
||||||
|
volumes:
|
||||||
|
- ./logo.png:/config/logo.png:ro
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LOGIN_LOGO=/config/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
Pour utiliser OIDC :
|
Pour utiliser OIDC :
|
||||||
|
|
||||||
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
|
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
|
||||||
@@ -271,7 +291,7 @@ Pour utiliser OIDC :
|
|||||||
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
|
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…).
|
||||||
|
|
||||||
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
|
Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream.
|
||||||
La session dure `OIDC_SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
|
La session dure `SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de
|
||||||
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
|
signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le
|
||||||
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
|
bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de
|
||||||
déconnexion du fournisseur s'il en a une.
|
déconnexion du fournisseur s'il en a une.
|
||||||
@@ -302,13 +322,14 @@ résolutions.
|
|||||||
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
||||||
| `HTTP_PORT` | `8080` | port de l'interface web |
|
| `HTTP_PORT` | `8080` | port de l'interface web |
|
||||||
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
||||||
| `AUTH_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) |
|
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) |
|
| `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
|
||||||
|
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
|
||||||
|
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
|
||||||
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) |
|
| `OIDC_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_SESSION_TTL` | `12h` | durée de la session |
|
|
||||||
| `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` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres |
|
||||||
@@ -366,7 +387,8 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| Fichier | Contenu |
|
| Fichier | Contenu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | configuration, démarrage |
|
| `main.go` | configuration, démarrage |
|
||||||
| `auth.go` | authentification : HTTP Basic ou OpenID Connect (découverte, PKCE, contrôle de l'ID token, cookie de session) |
|
| `auth.go` | authentification : OpenID Connect (découverte, PKCE, contrôle de l'ID token) et cookie de session signé |
|
||||||
|
| `auth_local.go` | mode `local` : page de connexion (`web/login.html`), cookie de session, `LOGIN_LOGO` |
|
||||||
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
|
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
|
||||||
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
||||||
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
||||||
@@ -379,7 +401,7 @@ Pour mettre à jour l'une d'elles :
|
|||||||
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
|
||||||
| `tags.go` | stockage des tags de couleur |
|
| `tags.go` | stockage des tags de couleur |
|
||||||
| `api.go` | routes HTTP `/api/*` |
|
| `api.go` | routes HTTP `/api/*` |
|
||||||
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`) |
|
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
|
||||||
|
|
||||||
## Remarque
|
## Remarque
|
||||||
|
|
||||||
|
|||||||
@@ -226,11 +226,29 @@ 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): HTTP Basic authentication with `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).
|
||||||
- **`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.
|
||||||
|
|
||||||
|
In `local` mode the login page follows the theme and language of the UI. The session lasts
|
||||||
|
`SESSION_TTL` (12 h by default), survives restarts (its signing key is in `/data/session.key`) and
|
||||||
|
ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) ends it. Failed logins
|
||||||
|
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
|
||||||
|
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
|
||||||
|
|
||||||
|
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml, logstream service
|
||||||
|
volumes:
|
||||||
|
- ./logo.png:/config/logo.png:ro
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LOGIN_LOGO=/config/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
To use OIDC:
|
To use OIDC:
|
||||||
|
|
||||||
1. In the provider, create a **confidential** client (with a secret) for logstream and register
|
1. In the provider, create a **confidential** client (with a secret) for logstream and register
|
||||||
@@ -247,7 +265,7 @@ To use OIDC:
|
|||||||
provider could not be read (wrong issuer, unreachable…).
|
provider could not be read (wrong issuer, unreachable…).
|
||||||
|
|
||||||
Opening the UI sends you to the provider's login page, then back to logstream. The session
|
Opening the UI sends you to the provider's login page, then back to logstream. The session
|
||||||
lasts `OIDC_SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
|
lasts `SESSION_TTL` (12 h by default) and survives restarts (its signing key is in
|
||||||
`/data/session.key`); when it ends, the page goes through the login again. The log out button
|
`/data/session.key`); when it ends, the page goes through the login again. The log out button
|
||||||
(top right) ends the logstream session, then opens the provider's log out page if it has one.
|
(top right) ends the logstream session, then opens the provider's log out page if it has one.
|
||||||
|
|
||||||
@@ -274,13 +292,14 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set
|
|||||||
| `SYSLOG_PORT` | `514` | syslog port published on the host |
|
| `SYSLOG_PORT` | `514` | syslog port published on the host |
|
||||||
| `HTTP_PORT` | `8080` | web UI port |
|
| `HTTP_PORT` | `8080` | web UI port |
|
||||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
||||||
| `AUTH_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) |
|
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) |
|
| `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
|
||||||
|
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
|
||||||
|
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
|
||||||
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) |
|
| `OIDC_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_SESSION_TTL` | `12h` | session lifetime |
|
|
||||||
| `RDNS` | `on` | resolve IP hosts to DNS names |
|
| `RDNS` | `on` | resolve IP hosts to DNS names |
|
||||||
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
||||||
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
|
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
|
||||||
@@ -335,7 +354,8 @@ To update one of them:
|
|||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.go` | configuration, startup |
|
| `main.go` | configuration, startup |
|
||||||
| `auth.go` | authentication: HTTP Basic or OpenID Connect (discovery, PKCE, ID token checks, session cookie) |
|
| `auth.go` | authentication: OpenID Connect (discovery, PKCE, ID token checks) and the signed session cookie |
|
||||||
|
| `auth_local.go` | `local` mode: login page (`web/login.html`), session cookie, `LOGIN_LOGO` |
|
||||||
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
|
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
|
||||||
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
||||||
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
||||||
@@ -348,7 +368,7 @@ To update one of them:
|
|||||||
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
|
||||||
| `tags.go` | color tag storage |
|
| `tags.go` | color tag storage |
|
||||||
| `api.go` | `/api/*` HTTP routes |
|
| `api.go` | `/api/*` HTTP routes |
|
||||||
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) |
|
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Web UI authentication. AUTH_MODE=local (default) keeps the optional HTTP Basic
|
// Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
|
||||||
// authentication (AUTH_USER / AUTH_PASS); AUTH_MODE=oidc delegates the login to an
|
// AUTH_PASS are set (auth_local.go); AUTH_MODE=oidc delegates the login to an
|
||||||
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
|
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
|
||||||
// flow and PKCE. Only the standard library is used.
|
// flow and PKCE. Only the standard library is used.
|
||||||
|
|
||||||
@@ -49,13 +49,23 @@ type authConfig struct {
|
|||||||
scopes string
|
scopes string
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
dataDir string
|
dataDir string
|
||||||
|
loginLogo string // local mode: PNG shown on the login page
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAuth returns the middleware that protects the UI and the API (except /healthz).
|
// newAuth returns the middleware that protects the UI and the API (except /healthz).
|
||||||
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
||||||
|
if c.sessionTTL <= 0 {
|
||||||
|
c.sessionTTL = 12 * time.Hour
|
||||||
|
}
|
||||||
switch strings.ToLower(c.mode) {
|
switch strings.ToLower(c.mode) {
|
||||||
case "", "local":
|
case "", "local":
|
||||||
return basicAuth(c.user, c.pass, next), nil
|
if c.user == "" {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
l := newLocal(c)
|
||||||
|
l.next = next
|
||||||
|
return l, nil
|
||||||
case "oidc":
|
case "oidc":
|
||||||
o, err := newOIDC(c)
|
o, err := newOIDC(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,28 +78,6 @@ func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
|||||||
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
|
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
|
|
||||||
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
|
||||||
if user == "" {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/healthz" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u, p, ok := r.BasicAuth()
|
|
||||||
if !ok ||
|
|
||||||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
|
|
||||||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
|
|
||||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type oidcMeta struct {
|
type oidcMeta struct {
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
AuthEndpoint string `json:"authorization_endpoint"`
|
AuthEndpoint string `json:"authorization_endpoint"`
|
||||||
@@ -136,9 +124,6 @@ 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.sessionTTL <= 0 {
|
|
||||||
c.sessionTTL = 12 * time.Hour
|
|
||||||
}
|
|
||||||
return &OIDC{
|
return &OIDC{
|
||||||
cfg: c,
|
cfg: c,
|
||||||
callback: ru.Path,
|
callback: ru.Path,
|
||||||
@@ -166,7 +151,7 @@ func sessionKey(dir string) []byte {
|
|||||||
log.Fatalf("session key: %v", err)
|
log.Fatalf("session key: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, k, 0o600); err != nil {
|
if err := os.WriteFile(path, k, 0o600); err != nil {
|
||||||
log.Printf("oidc: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
||||||
}
|
}
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
@@ -176,6 +161,14 @@ type session struct {
|
|||||||
Exp int64 `json:"e"`
|
Exp int64 `json:"e"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
|
||||||
|
// (and so into a new login).
|
||||||
|
func writeAuthRequired(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
type loginState struct {
|
type loginState struct {
|
||||||
Nonce string `json:"n"`
|
Nonce string `json:"n"`
|
||||||
Verifier string `json:"v"`
|
Verifier string `json:"v"`
|
||||||
@@ -196,7 +189,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var s session
|
var s session
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(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})
|
||||||
return
|
return
|
||||||
@@ -210,9 +203,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
o.startLogin(w, r)
|
o.startLogin(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeAuthRequired(w)
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -229,7 +220,7 @@ func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: loginCookie + state,
|
Name: loginCookie + state,
|
||||||
Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
Value: signCookie(o.key, loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(loginTTL.Seconds()),
|
MaxAge: int(loginTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -260,8 +251,8 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
state := q.Get("state")
|
state := q.Get("state")
|
||||||
var ls loginState
|
var ls loginState
|
||||||
c, err := r.Cookie(loginCookie + state)
|
c, err := r.Cookie(loginCookie + state)
|
||||||
if state == "" || err != nil || !o.verifyCookie(c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||||
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
|
http.Error(w, "login expired or started in another browser: open LogStream again", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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})
|
||||||
@@ -269,13 +260,13 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := o.exchange(r, q.Get("code"), ls)
|
user, 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", user)
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: o.signCookie(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()}),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -564,15 +555,15 @@ func (o *OIDC) getJSON(u string, v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
||||||
func (o *OIDC) signCookie(v any) string {
|
func signCookie(key []byte, v any) string {
|
||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
p := base64.RawURLEncoding.EncodeToString(b)
|
p := base64.RawURLEncoding.EncodeToString(b)
|
||||||
m := hmac.New(sha256.New, o.key)
|
m := hmac.New(sha256.New, key)
|
||||||
m.Write([]byte(p))
|
m.Write([]byte(p))
|
||||||
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OIDC) verifyCookie(s string, v any) bool {
|
func verifyCookie(key []byte, s string, v any) bool {
|
||||||
p, sig, ok := strings.Cut(s, ".")
|
p, sig, ok := strings.Cut(s, ".")
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -581,7 +572,7 @@ func (o *OIDC) verifyCookie(s string, v any) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
m := hmac.New(sha256.New, o.key)
|
m := hmac.New(sha256.New, key)
|
||||||
m.Write([]byte(p))
|
m.Write([]byte(p))
|
||||||
if !hmac.Equal(got, m.Sum(nil)) {
|
if !hmac.Equal(got, m.Sum(nil)) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
|
||||||
|
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
|
||||||
|
// accepted so scripts calling the API keep working, but the browser popup is gone.
|
||||||
|
|
||||||
|
const loginPage = "/login.html"
|
||||||
|
|
||||||
|
var loginFailDelay = time.Second // slows down password guessing
|
||||||
|
|
||||||
|
type Local struct {
|
||||||
|
user, pass string
|
||||||
|
ttl time.Duration
|
||||||
|
logo string // LOGIN_LOGO, served at /auth/logo
|
||||||
|
key []byte
|
||||||
|
next http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLocal(c authConfig) *Local {
|
||||||
|
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||||
|
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
||||||
|
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
|
||||||
|
if c.loginLogo != "" {
|
||||||
|
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||||
|
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("local authentication enabled (user %s)", c.user)
|
||||||
|
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/healthz", "/style.css":
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/logo":
|
||||||
|
l.serveLogo(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/login":
|
||||||
|
l.handleLogin(w, r)
|
||||||
|
return
|
||||||
|
case "/auth/logout":
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok := l.sessionUser(r)
|
||||||
|
if !ok {
|
||||||
|
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
||||||
|
user, ok = u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == loginPage:
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
case ok && r.URL.Path == "/auth/me":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
|
||||||
|
case ok:
|
||||||
|
l.next.ServeHTTP(w, r)
|
||||||
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||||
|
target := loginPage
|
||||||
|
if ret := r.URL.RequestURI(); ret != "/" {
|
||||||
|
target += "?" + url.Values{"r": {ret}}.Encode()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
default:
|
||||||
|
writeAuthRequired(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||||
|
ret := safeReturn(r.PostFormValue("r"))
|
||||||
|
if !l.check(user, pass) {
|
||||||
|
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||||
|
time.Sleep(loginFailDelay)
|
||||||
|
q := url.Values{"e": {"1"}}
|
||||||
|
if ret != "/" {
|
||||||
|
q.Set("r", ret)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("auth: %s logged in from %s", user, clientIP(r))
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(l.ttl.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isHTTPS(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) sessionUser(r *http.Request) (string, bool) {
|
||||||
|
var s session
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return s.User, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Local) check(user, pass string) bool {
|
||||||
|
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
||||||
|
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
||||||
|
return u&p == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
||||||
|
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if l.logo == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
http.ServeFile(w, r, l.logo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeReturn keeps the page to open after login inside logstream.
|
||||||
|
func safeReturn(ret string) string {
|
||||||
|
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
||||||
|
func isHTTPS(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newLocalApp puts the local login in front of a handler that echoes "app" and returns a
|
||||||
|
// browser (client with cookies) that does not follow redirects.
|
||||||
|
func newLocalApp(t *testing.T, c authConfig) (string, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
loginFailDelay = 0
|
||||||
|
c.mode, c.dataDir = "local", t.TempDir()
|
||||||
|
h, err := newAuth(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return "http://app.test", &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Transport: hosts{"app.test": h},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(t *testing.T, c *http.Client, app, user, pass, ret string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
res, err := c.PostForm(app+"/auth/login", url.Values{"user": {user}, "pass": {pass}, "r": {ret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLoginFlow(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
|
||||||
|
res, _ := c.Get(app + "/api/logs?q=x")
|
||||||
|
if res.StatusCode != http.StatusUnauthorized || res.Header.Get("WWW-Authenticate") != "" {
|
||||||
|
t.Fatalf("API without session: %d %q", res.StatusCode, res.Header.Get("WWW-Authenticate"))
|
||||||
|
}
|
||||||
|
res, _ = c.Get(app + "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusFound || loc != "/login.html?r=%2F%3Fq%3Ddisk" {
|
||||||
|
t.Fatalf("page without session: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/login.html", "/style.css", "/healthz"} {
|
||||||
|
if code, body := get(t, c, app+p); code != http.StatusOK || body != "app "+p {
|
||||||
|
t.Errorf("%s without session: %d %q", p, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res = login(t, c, app, "admin", "wrong", "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || !strings.Contains(loc, "e=1") {
|
||||||
|
t.Fatalf("wrong password: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatal("session created by a wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
res = login(t, c, app, "admin", "pw", "//evil.example/")
|
||||||
|
if loc := res.Header.Get("Location"); loc != "/" {
|
||||||
|
t.Fatalf("open redirect: %q", loc)
|
||||||
|
}
|
||||||
|
res = login(t, c, app, "admin", "pw", "/?q=disk")
|
||||||
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || loc != "/?q=disk" {
|
||||||
|
t.Fatalf("login: %d %q", res.StatusCode, loc)
|
||||||
|
}
|
||||||
|
if code, body := get(t, c, app+"/api/logs"); code != http.StatusOK || body != "app /api/logs" {
|
||||||
|
t.Fatalf("API with session: %d %q", code, body)
|
||||||
|
}
|
||||||
|
if code, body := get(t, c, app+"/auth/me"); code != http.StatusOK || !strings.Contains(body, `"user":"admin"`) || !strings.Contains(body, `"mode":"local"`) {
|
||||||
|
t.Fatalf("/auth/me: %d %s", code, body)
|
||||||
|
}
|
||||||
|
if res, _ := c.Get(app + "/login.html"); res.StatusCode != http.StatusFound {
|
||||||
|
t.Errorf("login page while logged in: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, _ := c.Get(app + "/auth/logout"); res.Header.Get("Location") != "/login.html" {
|
||||||
|
t.Fatalf("logout: %q", res.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatal("session still valid after logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBasicAuthForScripts(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, app+"/api/logs", nil)
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
if res, _ := c.Do(req); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("basic auth: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth("admin", "nope")
|
||||||
|
if res, _ := c.Do(req); res.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong basic auth: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalPasswordChangeEndsSessions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
loginFailDelay = 0
|
||||||
|
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h1, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "old", dataDir: dir}, echo)
|
||||||
|
h2, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "new", dataDir: dir}, echo)
|
||||||
|
cookie := signCookie(h1.(*Local).key, session{User: "admin", Exp: 9999999999})
|
||||||
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
|
||||||
|
if _, ok := h1.(*Local).sessionUser(r); !ok {
|
||||||
|
t.Fatal("session refused with the same password")
|
||||||
|
}
|
||||||
|
if _, ok := h2.(*Local).sessionUser(r); ok {
|
||||||
|
t.Fatal("session kept after a password change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalLogo(t *testing.T) {
|
||||||
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
||||||
|
if code, _ := get(t, c, app+"/auth/logo"); code != http.StatusNotFound {
|
||||||
|
t.Errorf("no LOGIN_LOGO: %d", code)
|
||||||
|
}
|
||||||
|
png := filepath.Join(t.TempDir(), "logo.png")
|
||||||
|
_ = os.WriteFile(png, []byte("\x89PNG\r\n\x1a\nfake"), 0o644)
|
||||||
|
app, c = newLocalApp(t, authConfig{user: "admin", pass: "pw", loginLogo: png})
|
||||||
|
res, _ := c.Get(app + "/auth/logo")
|
||||||
|
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "image/png" {
|
||||||
|
t.Errorf("LOGIN_LOGO: %d %q", res.StatusCode, res.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalWithoutUserIsOpen(t *testing.T) {
|
||||||
|
if h, _ := newAuth(authConfig{mode: "local"}, http.NotFoundHandler()); h == nil || isLocal(h) {
|
||||||
|
t.Error("local mode without AUTH_USER should not protect anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocal(h http.Handler) bool { _, ok := h.(*Local); return ok }
|
||||||
+5
-2
@@ -14,15 +14,16 @@ services:
|
|||||||
VLOGS_URL: http://victorialogs:9428
|
VLOGS_URL: http://victorialogs:9428
|
||||||
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
AUTH_MODE: ${AUTH_MODE:-local} # local (Basic Auth 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:-}
|
||||||
|
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
|
||||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
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_SESSION_TTL: ${OIDC_SESSION_TTL:-12h}
|
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:-true}
|
||||||
@@ -38,6 +39,8 @@ services:
|
|||||||
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
|
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
|
||||||
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
|
||||||
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
|
||||||
|
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
|
||||||
|
# - ./logo.png:/config/logo.png:ro
|
||||||
labels:
|
labels:
|
||||||
logstream.exclude: "true" # pas de collect des logs logstream
|
logstream.exclude: "true" # pas de collect des logs logstream
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ func main() {
|
|||||||
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||||
scopes: os.Getenv("OIDC_SCOPES"),
|
scopes: os.Getenv("OIDC_SCOPES"),
|
||||||
sessionTTL: getenvDuration("OIDC_SESSION_TTL", 12*time.Hour),
|
// SESSION_TTL applies to both modes; OIDC_SESSION_TTL is its former name.
|
||||||
|
sessionTTL: getenvDuration("SESSION_TTL", getenvDuration("OIDC_SESSION_TTL", 12*time.Hour)),
|
||||||
|
loginLogo: os.Getenv("LOGIN_LOGO"),
|
||||||
},
|
},
|
||||||
rdns: getenvBool("RDNS", true),
|
rdns: getenvBool("RDNS", true),
|
||||||
dnsServer: os.Getenv("DNS_SERVER"),
|
dnsServer: os.Getenv("DNS_SERVER"),
|
||||||
|
|||||||
+7
-7
@@ -71,7 +71,7 @@ const I18N = {
|
|||||||
srcHost: 'Host system',
|
srcHost: 'Host system',
|
||||||
hostTitle: 'Host system logs',
|
hostTitle: 'Host system logs',
|
||||||
hostEnabled: 'Collect the system logs of this machine',
|
hostEnabled: 'Collect the system logs of this machine',
|
||||||
hostOff: 'Off: the system logs of the machine hosting Logstream are not collected.',
|
hostOff: 'Off: the system logs of the machine hosting LogStream are not collected.',
|
||||||
hostWaiting: 'Starting…',
|
hostWaiting: 'Starting…',
|
||||||
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
|
hostJournal: ({ n, r }) => `Reading the systemd journal (${n} files): ${r} entries since startup.`,
|
||||||
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
|
hostFiles: ({ n, r }) => `Following ${n} files of /var/log: ${r} lines since startup.`,
|
||||||
@@ -98,8 +98,8 @@ const I18N = {
|
|||||||
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
|
dockerLegend: 'Colored: followed · grey: not followed · dashed: stopped · the color identifies the compose project. Click a label to switch it.',
|
||||||
dockerNoMatch: 'No container',
|
dockerNoMatch: 'No container',
|
||||||
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
|
stFollowing: 'followed', stSelected: 'followed when running', stIgnored: 'not followed', stStopped: 'stopped',
|
||||||
stLocked: 'excluded', stLockedTitle: 'Logstream itself, or label logstream.exclude=true',
|
stLocked: 'excluded', stLockedTitle: 'LogStream itself, or label logstream.exclude=true',
|
||||||
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: Logstream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
dockerHelp: 'Logs are read through docker-socket-proxy, a read-only gateway: LogStream can list containers and read their logs, nothing else. Choices apply per compose service (or container name), so they survive container re-creations.',
|
||||||
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
|
fContainer: 'container', fContainerId: 'container ID', fImage: 'image', fProject: 'compose project',
|
||||||
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
|
fService: 'compose service', fStream: 'stream', fSourceType: 'source',
|
||||||
fUnit: 'systemd unit', fLogFile: 'log file',
|
fUnit: 'systemd unit', fLogFile: 'log file',
|
||||||
@@ -215,7 +215,7 @@ const I18N = {
|
|||||||
srcHost: 'Système hôte',
|
srcHost: 'Système hôte',
|
||||||
hostTitle: 'Logs système de l\'hôte',
|
hostTitle: 'Logs système de l\'hôte',
|
||||||
hostEnabled: 'Collecter les logs système de cette machine',
|
hostEnabled: 'Collecter les logs système de cette machine',
|
||||||
hostOff: 'Désactivé : les logs système de la machine qui héberge Logstream ne sont pas collectés.',
|
hostOff: 'Désactivé : les logs système de la machine qui héberge LogStream ne sont pas collectés.',
|
||||||
hostWaiting: 'Démarrage…',
|
hostWaiting: 'Démarrage…',
|
||||||
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
|
hostJournal: ({ n, r }) => `Lecture du journal systemd (${n} fichiers) : ${r} entrées depuis le démarrage.`,
|
||||||
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
|
hostFiles: ({ n, r }) => `Suivi de ${n} fichiers de /var/log : ${r} lignes depuis le démarrage.`,
|
||||||
@@ -242,8 +242,8 @@ const I18N = {
|
|||||||
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
|
dockerLegend: 'En couleur : suivis · en gris : non suivis · pointillés : arrêtés · la couleur identifie le projet compose. Cliquez sur une étiquette pour la basculer.',
|
||||||
dockerNoMatch: 'Aucun conteneur',
|
dockerNoMatch: 'Aucun conteneur',
|
||||||
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
|
stFollowing: 'suivi', stSelected: 'suivi dès qu\'il tourne', stIgnored: 'non suivi', stStopped: 'arrêté',
|
||||||
stLocked: 'exclu', stLockedTitle: 'Logstream lui-même, ou étiquette logstream.exclude=true',
|
stLocked: 'exclu', stLockedTitle: 'LogStream lui-même, ou étiquette logstream.exclude=true',
|
||||||
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : Logstream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
dockerHelp: 'Les logs sont lus via docker-socket-proxy, une passerelle en lecture seule : LogStream peut lister les conteneurs et lire leurs logs, rien d\'autre. Les choix s\'appliquent par service compose (ou nom de conteneur), ils survivent donc à la recréation des conteneurs.',
|
||||||
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
|
fContainer: 'conteneur', fContainerId: 'ID du conteneur', fImage: 'image', fProject: 'projet compose',
|
||||||
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
|
fService: 'service compose', fStream: 'flux', fSourceType: 'source',
|
||||||
fUnit: 'unité systemd', fLogFile: 'fichier de log',
|
fUnit: 'unité systemd', fLogFile: 'fichier de log',
|
||||||
@@ -2103,7 +2103,7 @@ $('#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 OIDC login, show who is logged in and the log out button.
|
// With a login (local or OIDC), show who is logged in and the log out button.
|
||||||
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
|
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
|
||||||
if (!me || !me.user) return;
|
if (!me || !me.user) return;
|
||||||
const btn = $('#logoutBtn');
|
const btn = $('#logoutBtn');
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Logstream</title>
|
<title>LogStream</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<script>
|
<script>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||||
<span>Logstream</span>
|
<span>LogStream</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>LogStream</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script>
|
||||||
|
// Same saved theme and language as the UI, applied before first paint.
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('logstream.theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||||
|
var l = localStorage.getItem('logstream.lang');
|
||||||
|
if (l) document.documentElement.lang = l;
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-tools">
|
||||||
|
<div class="seg" role="radiogroup" id="langSwitch">
|
||||||
|
<button type="button" role="radio" data-lang="fr">FR</button>
|
||||||
|
<button type="button" role="radio" data-lang="en">EN</button>
|
||||||
|
</div>
|
||||||
|
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
|
||||||
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||||
|
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="login-card">
|
||||||
|
<img id="logo" class="login-logo" src="auth/logo" alt="" hidden>
|
||||||
|
<div class="brand">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
|
||||||
|
<span>LogStream</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted" data-i18n="intro">Sign in to view the logs.</p>
|
||||||
|
|
||||||
|
<form method="post" action="auth/login">
|
||||||
|
<input type="hidden" name="r" id="ret">
|
||||||
|
<label for="user" data-i18n="user">User</label>
|
||||||
|
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
||||||
|
<label for="pass" data-i18n="pass">Password</label>
|
||||||
|
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
|
||||||
|
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Wrong user or password.</p>
|
||||||
|
<button class="btn primary" type="submit" data-i18n="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var I18N = {
|
||||||
|
en: { intro: 'Sign in to view the logs.', user: 'User', pass: 'Password', submit: 'Sign in',
|
||||||
|
error: 'Wrong user or password.', theme: 'Light / dark theme' },
|
||||||
|
fr: { intro: 'Connectez-vous pour consulter les logs.', user: 'Utilisateur', pass: 'Mot de passe',
|
||||||
|
submit: 'Se connecter', error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre' },
|
||||||
|
};
|
||||||
|
function get(k) { try { return localStorage.getItem('logstream.' + k); } catch (e) { return null; } }
|
||||||
|
function set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch (e) {} }
|
||||||
|
var lang = get('lang');
|
||||||
|
if (!I18N[lang]) lang = /^fr\b/i.test(navigator.language || '') ? 'fr' : 'en';
|
||||||
|
|
||||||
|
function applyLang() {
|
||||||
|
var d = I18N[lang];
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = d[el.dataset.i18n]; });
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = d[el.dataset.i18nTitle]; });
|
||||||
|
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', d[el.dataset.i18nAria]); });
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('themeBtn').addEventListener('click', function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
root.dataset.theme = dark ? 'light' : 'dark';
|
||||||
|
set('theme', root.dataset.theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
document.getElementById('ret').value = q.get('r') || '/';
|
||||||
|
document.getElementById('err').hidden = q.get('e') !== '1';
|
||||||
|
|
||||||
|
// LOGIN_LOGO: shown only when the server has one.
|
||||||
|
var logo = document.getElementById('logo');
|
||||||
|
logo.addEventListener('load', function () { logo.hidden = false; });
|
||||||
|
if (logo.complete && logo.naturalWidth) logo.hidden = false;
|
||||||
|
|
||||||
|
applyLang();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -672,3 +672,29 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
|
|||||||
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
|
||||||
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
|
.tag-row [data-del] { grid-column: 3; grid-row: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Login page (AUTH_MODE=local) ---------- */
|
||||||
|
body.login-page {
|
||||||
|
min-height: 100vh; padding: 16px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.login-tools { position: fixed; top: 12px; right: 16px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.login-card {
|
||||||
|
width: 100%; max-width: 360px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.login-logo { max-width: 200px; max-height: 96px; object-fit: contain; margin-bottom: 6px; }
|
||||||
|
.login-card .brand { font-size: 20px; }
|
||||||
|
.login-card .brand svg { width: 32px; height: 32px; }
|
||||||
|
.login-card > p { margin: 0 0 8px; text-align: center; }
|
||||||
|
.login-card form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.login-card label { font-size: 13px; font-weight: 550; }
|
||||||
|
.login-card input {
|
||||||
|
height: 38px; padding: 0 11px; margin-bottom: 6px;
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; background: var(--bg);
|
||||||
|
}
|
||||||
|
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
|
||||||
|
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }
|
||||||
Reference in new issue
Block a user