GET /api/v1/logs?query=<LogsQL>&since=<cursor|time|duration>&limit=<n> returns the logs after a cursor, oldest first, with next_cursor and has_more. The cursor is a position on _time (the reception time) plus the number of rows already returned among those sharing that exact time, ordered by a hash of their content, so following the cursors gives every row once even within groups cut by a page. Rows count once older than the lag (30 s), and nothing is returned while the disk spool still holds logs. API tokens (Settings > API) give read-only access to /api/v1/ as "Authorization: Bearer lst_...", in every auth mode; only their SHA-256 is kept in /data/api_tokens.json. README (EN/FR) documents the route and an n8n workflow keeping the cursor in $getWorkflowStaticData. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
255 lines
7.4 KiB
Go
255 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// API tokens: read-only access to /api/v1/ for scripts and automation tools
|
|
// (n8n…), sent as "Authorization: Bearer lst_…". They are created and revoked
|
|
// in Settings > API by an administrator, and work whatever the AUTH_MODE,
|
|
// including without authentication. Only a SHA-256 of each token is kept, in
|
|
// DATA_DIR/api_tokens.json: a token is shown once, when it is created.
|
|
|
|
const (
|
|
tokenPrefix = "lst_"
|
|
maxTokens = 100
|
|
lastUsedPeriod = time.Minute // last use of a token is written to disk at most this often (or on a new IP)
|
|
)
|
|
|
|
var errTokenNotFound = errors.New("token not found")
|
|
|
|
type apiToken struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hint string `json:"hint"` // start of the token, to recognize it
|
|
Hash string `json:"hash"` // hex SHA-256 of the whole token
|
|
Created time.Time `json:"created"`
|
|
LastUsed time.Time `json:"last_used,omitzero"`
|
|
LastIP string `json:"last_ip,omitempty"`
|
|
}
|
|
|
|
// apiTokenInfo is what the UI sees of a token (never its hash).
|
|
type apiTokenInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hint string `json:"hint"`
|
|
Created time.Time `json:"created"`
|
|
LastUsed time.Time `json:"last_used,omitzero"`
|
|
LastIP string `json:"last_ip,omitempty"`
|
|
}
|
|
|
|
type TokenStore struct {
|
|
path string
|
|
mu sync.Mutex
|
|
tokens []apiToken
|
|
}
|
|
|
|
func LoadTokenStore(path string) (*TokenStore, error) {
|
|
s := &TokenStore{path: path}
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(b, &s.tokens); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// save writes the file atomically, readable by its owner only; the caller holds the lock.
|
|
func (s *TokenStore) save() error {
|
|
b, err := json.MarshalIndent(s.tokens, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s *TokenStore) List() []apiTokenInfo {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]apiTokenInfo, len(s.tokens))
|
|
for i, t := range s.tokens {
|
|
out[i] = apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created, LastUsed: t.LastUsed, LastIP: t.LastIP}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Create makes a new token and returns it in clear, the only time it exists so.
|
|
func (s *TokenStore) Create(name string) (apiTokenInfo, string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || utf8.RuneCountInString(name) > 80 {
|
|
return apiTokenInfo{}, "", &codedError{code: "token_name", msg: "give the token a name of 1 to 80 characters"}
|
|
}
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return apiTokenInfo{}, "", err
|
|
}
|
|
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
|
|
t := apiToken{ID: newID(), Name: name, Hint: secret[:len(tokenPrefix)+6], Hash: hashToken(secret), Created: time.Now().UTC()}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.tokens) >= maxTokens {
|
|
return apiTokenInfo{}, "", &codedError{code: "token_max", msg: fmt.Sprintf("at most %d API tokens", maxTokens)}
|
|
}
|
|
s.tokens = append(s.tokens, t)
|
|
if err := s.save(); err != nil {
|
|
s.tokens = s.tokens[:len(s.tokens)-1]
|
|
return apiTokenInfo{}, "", err
|
|
}
|
|
return apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created}, secret, nil
|
|
}
|
|
|
|
func (s *TokenStore) Delete(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i, t := range s.tokens {
|
|
if t.ID == id {
|
|
s.tokens = append(s.tokens[:i:i], s.tokens[i+1:]...)
|
|
return s.save()
|
|
}
|
|
}
|
|
return errTokenNotFound
|
|
}
|
|
|
|
// Check tells whether token is a valid API token, and notes its use.
|
|
func (s *TokenStore) Check(token, ip string) (name string, ok bool) {
|
|
if !strings.HasPrefix(token, tokenPrefix) {
|
|
return "", false
|
|
}
|
|
h := []byte(hashToken(token))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.tokens {
|
|
t := &s.tokens[i]
|
|
if subtle.ConstantTimeCompare(h, []byte(t.Hash)) != 1 {
|
|
continue
|
|
}
|
|
now := time.Now().UTC()
|
|
stale := now.Sub(t.LastUsed) >= lastUsedPeriod || t.LastIP != ip
|
|
t.LastUsed, t.LastIP = now, ip
|
|
if stale {
|
|
if err := s.save(); err != nil {
|
|
log.Printf("api tokens: %v", err)
|
|
}
|
|
}
|
|
return t.Name, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// bearerToken returns the token of an "Authorization: Bearer …" header.
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
|
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(token), true
|
|
}
|
|
|
|
// tokenAuth serves the requests carrying an API token itself, read-only and
|
|
// limited to /api/v1/, before the session login (auth); api is the handler
|
|
// behind the login.
|
|
func tokenAuth(tokens *TokenStore, auth, api http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
auth.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
ip := clientIP(r)
|
|
if _, valid := tokens.Check(token, ip); !valid {
|
|
log.Printf("api: invalid or revoked token from %s", ip)
|
|
time.Sleep(loginFailDelay)
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="logstream"`)
|
|
writeErr(w, http.StatusUnauthorized, &codedError{code: "token", msg: "invalid or revoked API token"})
|
|
return
|
|
}
|
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || !isSafeMethod(r.Method) {
|
|
writeErr(w, http.StatusForbidden, &codedError{code: "token_scope", msg: "API tokens only give read access to /api/v1/"})
|
|
return
|
|
}
|
|
api.ServeHTTP(w, asViewer(r))
|
|
})
|
|
}
|
|
|
|
// GET /api/tokens: the API tokens (Settings > API), for administrators.
|
|
func (a *API) listTokens(w http.ResponseWriter, r *http.Request) {
|
|
if isViewer(r) {
|
|
writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, a.tokens.List())
|
|
}
|
|
|
|
// POST /api/tokens {"name": "n8n"}: returns the new token, with "token" in clear.
|
|
func (a *API) createToken(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
info, secret, err := a.tokens.Create(body.Name)
|
|
var ce *codedError
|
|
switch {
|
|
case errors.As(err, &ce):
|
|
writeErr(w, http.StatusBadRequest, err)
|
|
return
|
|
case err != nil:
|
|
writeErr(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
log.Printf("api token %q created from %s", info.Name, clientIP(r))
|
|
writeJSON(w, http.StatusCreated, struct {
|
|
apiTokenInfo
|
|
Token string `json:"token"`
|
|
}{info, secret})
|
|
}
|
|
|
|
// DELETE /api/tokens/{id}: revokes a token at once.
|
|
func (a *API) deleteToken(w http.ResponseWriter, r *http.Request) {
|
|
err := a.tokens.Delete(r.PathValue("id"))
|
|
switch {
|
|
case errors.Is(err, errTokenNotFound):
|
|
writeErr(w, http.StatusNotFound, err)
|
|
case err != nil:
|
|
writeErr(w, http.StatusInternalServerError, err)
|
|
default:
|
|
log.Printf("api token %s revoked from %s", r.PathValue("id"), clientIP(r))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|