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
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenStore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "api_tokens.json")
|
||||
s, err := LoadTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Create(" "); err == nil {
|
||||
t.Fatal("a token without a name should be refused")
|
||||
}
|
||||
info, secret, err := s.Create("n8n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(secret, tokenPrefix) || len(secret) < 40 || !strings.HasPrefix(secret, info.Hint) {
|
||||
t.Fatalf("token %q, hint %q", secret, info.Hint)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(b), secret) || !strings.Contains(string(b), hashToken(secret)) {
|
||||
t.Fatal("the file must hold the hash of the token, not the token")
|
||||
}
|
||||
if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("file mode %v, want 0600", st.Mode().Perm())
|
||||
}
|
||||
if _, ok := s.Check(secret+"x", "10.0.0.1"); ok {
|
||||
t.Fatal("a wrong token was accepted")
|
||||
}
|
||||
if name, ok := s.Check(secret, "10.0.0.1"); !ok || name != "n8n" {
|
||||
t.Fatal("the token was refused")
|
||||
}
|
||||
|
||||
// Reloaded from disk (restart): still valid, last use kept.
|
||||
s2, err := LoadTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := s2.List(); len(l) != 1 || l[0].LastIP != "10.0.0.1" || l[0].LastUsed.IsZero() {
|
||||
t.Fatalf("after reload: %+v", l)
|
||||
}
|
||||
if _, ok := s2.Check(secret, "10.0.0.2"); !ok {
|
||||
t.Fatal("token refused after reload")
|
||||
}
|
||||
if err := s2.Delete(info.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s2.Check(secret, "10.0.0.2"); ok {
|
||||
t.Fatal("a revoked token was accepted")
|
||||
}
|
||||
if err := s2.Delete(info.ID); err != errTokenNotFound {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAuth(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||
_, secret, _ := tokens.Create("n8n")
|
||||
|
||||
var reached string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = r.URL.Path
|
||||
if isViewer(r) {
|
||||
reached += " viewer"
|
||||
}
|
||||
})
|
||||
app := readOnly(mux)
|
||||
login := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAuthRequired(w) })
|
||||
h := tokenAuth(tokens, login, app)
|
||||
|
||||
call := func(method, path, auth string) int {
|
||||
reached = ""
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := call("GET", "/api/v1/logs", "Bearer "+secret); code != http.StatusOK || reached != "/api/v1/logs viewer" {
|
||||
t.Fatalf("valid token: %d, reached %q", code, reached)
|
||||
}
|
||||
if code := call("GET", "/api/v1/logs", "bearer "+secret); code != http.StatusOK {
|
||||
t.Fatalf("lower-case scheme: %d", code)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
method, path, auth string
|
||||
want int
|
||||
}{
|
||||
{"GET", "/api/v1/logs", "Bearer lst_wrong", http.StatusUnauthorized},
|
||||
{"GET", "/api/v1/logs", "Bearer ", http.StatusUnauthorized},
|
||||
{"GET", "/api/tags", "Bearer " + secret, http.StatusForbidden},
|
||||
{"GET", "/api/tokens", "Bearer " + secret, http.StatusForbidden},
|
||||
{"POST", "/api/v1/logs", "Bearer " + secret, http.StatusForbidden},
|
||||
{"GET", "/api/v1/logs", "", http.StatusUnauthorized}, // no token: the login decides
|
||||
{"GET", "/api/v1/logs", "Basic dTpw", http.StatusUnauthorized}, // Basic goes to the login
|
||||
} {
|
||||
if code := call(tc.method, tc.path, tc.auth); code != tc.want || reached != "" {
|
||||
t.Errorf("%s %s %q: %d (reached %q), want %d", tc.method, tc.path, tc.auth, code, reached, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With local authentication, a token opens /api/v1/ without a session.
|
||||
func TestTokenWithLocalAuth(t *testing.T) {
|
||||
loginFailDelay = 0
|
||||
dir := t.TempDir()
|
||||
tokens, _ := LoadTokenStore(filepath.Join(dir, "api_tokens.json"))
|
||||
_, secret, _ := tokens.Create("script")
|
||||
app := readOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
|
||||
auth, err := newAuth(authConfig{mode: "local", user: "admin", pass: "pw", dataDir: dir, sessionTTL: time.Hour}, app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAuth(tokens, auth, app)
|
||||
req := httptest.NewRequest("GET", "/api/v1/logs", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("without token: %d", rec.Code)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
||||
t.Fatalf("with token: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandlers(t *testing.T) {
|
||||
tokens, _ := LoadTokenStore(filepath.Join(t.TempDir(), "api_tokens.json"))
|
||||
a := &API{tokens: tokens}
|
||||
mux := http.NewServeMux()
|
||||
a.Routes(mux)
|
||||
do := func(r *http.Request) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
readOnly(mux).ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
rec := do(httptest.NewRequest("POST", "/api/tokens", strings.NewReader(`{"name":"n8n"}`)))
|
||||
var created struct {
|
||||
ID, Token, Hint string
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); rec.Code != http.StatusCreated || err != nil || created.Token == "" {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = do(httptest.NewRequest("GET", "/api/tokens", nil))
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), created.Token) || strings.Contains(rec.Body.String(), "hash") {
|
||||
t.Fatalf("list must not show the token or its hash: %s", rec.Body)
|
||||
}
|
||||
if rec := do(asViewer(httptest.NewRequest("GET", "/api/tokens", nil))); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer list: %d", rec.Code)
|
||||
}
|
||||
if rec := do(asViewer(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil))); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer delete: %d", rec.Code)
|
||||
}
|
||||
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
if rec := do(httptest.NewRequest("DELETE", "/api/tokens/"+created.ID, nil)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete again: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user