mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
Add usersFile (newUsersFile/load) for the JSON users-file shape
{"users":[{name,group,apiKey,limits?}]}, and authStore.maybeReload
for throttled hot reload: checks mtime at most every reloadEvery
(5s), reloads on change, keeps the last good set and logs via
errorf on any error (stat, parse, or a rejected replace), and logs
via logf when the reload changes the file-sourced user count.
nowFn is injectable for tests.
Wire-up in llmgateway.go: newGateway does a synchronous initial
load when Users.File is set (a bad initial file fails the
constructor); ServeHTTP calls g.auth.maybeReload() at entry
(no-op when no file is configured).
Also fixes replaceFileUsers's merge semantics: a file user now
overrides an inline user of the same name, even when their keys
differ, dropping the inline entry from the rebuilt set. The prior
digest-only keying let a same-named inline/file pair with
different keys coexist instead of the file version winning.
106 lines
3.2 KiB
Go
106 lines
3.2 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// reloadEvery is the minimum interval between users-file mtime checks on the
|
|
// request path. maybeReload never stats the file more often than this, even
|
|
// under heavy concurrent traffic.
|
|
const reloadEvery = 5 * time.Second
|
|
|
|
// usersFile is a JSON-encoded file of API-key users at path, in the shape
|
|
// {"users":[{"name":...,"group":...,"apiKey":...,"limits":{...}}]}.
|
|
type usersFile struct {
|
|
path string
|
|
}
|
|
|
|
// newUsersFile returns a usersFile reading from path. It does not touch the
|
|
// filesystem — call load to read and parse the file.
|
|
func newUsersFile(path string) *usersFile {
|
|
return &usersFile{path: path}
|
|
}
|
|
|
|
// usersFileDoc is the on-disk shape of a users file.
|
|
type usersFileDoc struct {
|
|
Users []*UserConfig `json:"users"`
|
|
}
|
|
|
|
// load reads and parses uf's file. path is operator-supplied middleware
|
|
// configuration, not untrusted request input.
|
|
func (uf *usersFile) load() ([]*UserConfig, error) {
|
|
b, err := os.ReadFile(uf.path) // #nosec G304 -- operator-supplied users file path from middleware config
|
|
if err != nil {
|
|
return nil, fmt.Errorf("llmgateway: cannot read users file %q: %w", uf.path, err)
|
|
}
|
|
var doc usersFileDoc
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
return nil, fmt.Errorf("llmgateway: cannot parse users file %q: %w", uf.path, err)
|
|
}
|
|
return doc.Users, nil
|
|
}
|
|
|
|
// gatewayLogger is the logging surface maybeReload needs. *Gateway
|
|
// implements it (see logger.go); authStore depends on this small interface
|
|
// rather than *Gateway directly, so the reload path can be tested without an
|
|
// HTTP handler chain.
|
|
type gatewayLogger interface {
|
|
logf(format string, args ...any)
|
|
errorf(format string, args ...any)
|
|
}
|
|
|
|
// maybeReload reloads a's file-sourced users when a.usersFile is configured,
|
|
// reloadEvery has elapsed since the last check, and the file's mtime has
|
|
// changed since the last successful load. It runs on the request path
|
|
// (ServeHTTP calls it at entry), so it must stay cheap when nothing changed
|
|
// and must never panic: on any error — stat, load, or replaceFileUsers
|
|
// rejecting the new set — it logs via a.log.errorf and keeps the last good
|
|
// set. A successful reload that changes the file-sourced user count logs via
|
|
// a.log.logf.
|
|
func (a *authStore) maybeReload() {
|
|
if a.usersFile == nil {
|
|
return
|
|
}
|
|
|
|
a.reloadMu.Lock()
|
|
defer a.reloadMu.Unlock()
|
|
|
|
now := a.nowFn()
|
|
if now.Sub(a.lastCheck) < reloadEvery {
|
|
return
|
|
}
|
|
a.lastCheck = now
|
|
|
|
info, err := os.Stat(a.usersFile.path)
|
|
if err != nil {
|
|
a.log.errorf("llmgateway: cannot stat users file %q: %v", a.usersFile.path, err)
|
|
return
|
|
}
|
|
if !info.ModTime().After(a.lastModTime) {
|
|
return
|
|
}
|
|
|
|
users, err := a.usersFile.load()
|
|
if err != nil {
|
|
a.log.errorf("llmgateway: users file %q reload failed: %v", a.usersFile.path, err)
|
|
return
|
|
}
|
|
|
|
a.mu.RLock()
|
|
prevCount := a.fileUserCount
|
|
a.mu.RUnlock()
|
|
|
|
if err := a.replaceFileUsers(users); err != nil {
|
|
a.log.errorf("llmgateway: users file %q reload rejected: %v", a.usersFile.path, err)
|
|
return
|
|
}
|
|
a.lastModTime = info.ModTime()
|
|
|
|
if len(users) != prevCount {
|
|
a.log.logf("llmgateway: users file %q reload: user count changed from %d to %d", a.usersFile.path, prevCount, len(users))
|
|
}
|
|
}
|