Reading API /api/v1/logs with a stable cursor, and API tokens
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>
This commit is contained in:
1 parent
d61384ed2c
commit
c8f45044a3
12 files changed
+1327
-7
No files matched your search
+254
@@ -0,0 +1,254 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user