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 differs // from the last observed mtime (forward or backward). 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 } // TryLock, not Lock: ServeHTTP calls maybeReload on every request, and a // reload's build phase can block on file I/O (resolveSecret's "file:" // reads inside replaceFileUsers). Blocking every concurrent request // behind one full reload would undo the auth.go buildMu/mu lock split's // benefit one layer up. A request that finds a reload already in flight // just skips this cycle — the next request's check (or the next // throttle window) covers it. Reload is deliberately best-effort and // throttled, not "every change lands on the very next request." if !a.reloadMu.TryLock() { return } 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 } // Equal, not After: an After-only check misses a backward mtime move — // a restore from backup, or a `cp -p` from an older file. Any // difference from the last observed mtime, forward or backward, must // trigger a reload; only an unchanged mtime skips it. if info.ModTime().Equal(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)) } }