mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-25 09:09:07 -04:00
pii_default_detectors was applied to the live config only by a live POST /api/settings (ApplyRuntimeSettings) — neither the startup loader nor the config file watcher read it back. So after a restart the persisted default detectors were dropped, and the cloud-proxy MITM listener (which resolves each intercept host's detectors once at start via ResolvePIIPolicy) came up with an empty set and forwarded intercepted traffic unredacted, even though the MITM model had pii.enabled:true and the defaults were on disk. Request-side default redaction broke the same way. - startup.go: loadRuntimeSettingsFromFile now applies pii_default_detectors, before startMITMIfConfigured, with env > file precedence. - config_file_watcher.go: apply pii_default_detectors on live file edits, matching the existing env-guard pattern used for the other fields. - settings endpoint: rebuild the MITM listener when pii_default_detectors changes (its per-host detector map is frozen at listener start), not only on a mitm_listen change — so toggling a default detector takes effect on cloud-proxy traffic immediately. - new LOCALAI_PII_DEFAULT_DETECTORS env var / CLI flag (WithPIIDefaultDetectors) so the default detector set can be pinned at boot for immutable deployments. Assisted-by: Claude:claude-opus-4-8 Claude-Code Signed-off-by: Richard Palethorpe <io@richiejp.com> Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
379 lines
14 KiB
Go
379 lines
14 KiB
Go
package application
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"time"
|
|
|
|
"dario.cat/mergo"
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
type fileHandler func(fileContent []byte, appConfig *config.ApplicationConfig) error
|
|
|
|
type configFileHandler struct {
|
|
handlers map[string]fileHandler
|
|
|
|
watcher *fsnotify.Watcher
|
|
|
|
appConfig *config.ApplicationConfig
|
|
}
|
|
|
|
// TODO: This should be a singleton eventually so other parts of the code can register config file handlers,
|
|
// then we can export it to other packages
|
|
func newConfigFileHandler(appConfig *config.ApplicationConfig) configFileHandler {
|
|
c := configFileHandler{
|
|
handlers: make(map[string]fileHandler),
|
|
appConfig: appConfig,
|
|
}
|
|
err := c.Register("api_keys.json", readApiKeysJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "api_keys.json")
|
|
}
|
|
err = c.Register("external_backends.json", readExternalBackendsJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "external_backends.json")
|
|
}
|
|
err = c.Register("runtime_settings.json", readRuntimeSettingsJson(*appConfig), true)
|
|
if err != nil {
|
|
xlog.Error("unable to register config file handler", "error", err, "file", "runtime_settings.json")
|
|
}
|
|
// Note: agent_tasks.json and agent_jobs.json are handled by AgentJobService directly
|
|
// The service watches and reloads these files internally
|
|
return c
|
|
}
|
|
|
|
func (c *configFileHandler) Register(filename string, handler fileHandler, runNow bool) error {
|
|
_, ok := c.handlers[filename]
|
|
if ok {
|
|
return fmt.Errorf("handler already registered for file %s", filename)
|
|
}
|
|
c.handlers[filename] = handler
|
|
if runNow {
|
|
c.callHandler(filename, handler)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *configFileHandler) callHandler(filename string, handler fileHandler) {
|
|
rootedFilePath := filepath.Join(c.appConfig.DynamicConfigsDir, filepath.Clean(filename))
|
|
xlog.Debug("reading file for dynamic config update", "filename", rootedFilePath)
|
|
fileContent, err := os.ReadFile(rootedFilePath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
xlog.Error("could not read file", "error", err, "filename", rootedFilePath)
|
|
}
|
|
|
|
if err = handler(fileContent, c.appConfig); err != nil {
|
|
xlog.Error("WatchConfigDirectory goroutine failed to update options", "error", err)
|
|
}
|
|
}
|
|
|
|
func (c *configFileHandler) Watch() error {
|
|
configWatcher, err := fsnotify.NewWatcher()
|
|
c.watcher = configWatcher
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.appConfig.DynamicConfigsDirPollInterval > 0 {
|
|
xlog.Debug("Poll interval set, falling back to polling for configuration changes")
|
|
ticker := time.NewTicker(c.appConfig.DynamicConfigsDirPollInterval)
|
|
go func() {
|
|
for {
|
|
<-ticker.C
|
|
for file, handler := range c.handlers {
|
|
xlog.Debug("polling config file", "file", file)
|
|
c.callHandler(file, handler)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Start listening for events.
|
|
go func() {
|
|
for {
|
|
select {
|
|
case event, ok := <-c.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
if event.Has(fsnotify.Write | fsnotify.Create | fsnotify.Remove) {
|
|
handler, ok := c.handlers[path.Base(event.Name)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
c.callHandler(filepath.Base(event.Name), handler)
|
|
}
|
|
case err, ok := <-c.watcher.Errors:
|
|
xlog.Error("config watcher error received", "error", err)
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Add a path.
|
|
err = c.watcher.Add(c.appConfig.DynamicConfigsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create a watcher on the configuration directory: %+v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TODO: When we institute graceful shutdown, this should be called
|
|
func (c *configFileHandler) Stop() error {
|
|
return c.watcher.Close()
|
|
}
|
|
|
|
func readApiKeysJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing api keys runtime update", "numKeys", len(startupAppConfig.ApiKeys))
|
|
|
|
if len(fileContent) > 0 {
|
|
// Parse JSON content from the file
|
|
var fileKeys []string
|
|
err := json.Unmarshal(fileContent, &fileKeys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
xlog.Debug("discovered API keys from api keys dynamic config file", "numKeys", len(fileKeys))
|
|
|
|
appConfig.ApiKeys = append(startupAppConfig.ApiKeys, fileKeys...)
|
|
} else {
|
|
xlog.Debug("no API keys discovered from dynamic config file")
|
|
appConfig.ApiKeys = startupAppConfig.ApiKeys
|
|
}
|
|
xlog.Debug("total api keys after processing", "numKeys", len(appConfig.ApiKeys))
|
|
return nil
|
|
}
|
|
|
|
return handler
|
|
}
|
|
|
|
func readExternalBackendsJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing external_backends.json")
|
|
|
|
if len(fileContent) > 0 {
|
|
// Parse JSON content from the file
|
|
var fileBackends map[string]string
|
|
err := json.Unmarshal(fileContent, &fileBackends)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appConfig.ExternalGRPCBackends = startupAppConfig.ExternalGRPCBackends
|
|
err = mergo.Merge(&appConfig.ExternalGRPCBackends, &fileBackends)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
appConfig.ExternalGRPCBackends = startupAppConfig.ExternalGRPCBackends
|
|
}
|
|
xlog.Debug("external backends loaded from external_backends.json")
|
|
return nil
|
|
}
|
|
return handler
|
|
}
|
|
|
|
func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHandler {
|
|
handler := func(fileContent []byte, appConfig *config.ApplicationConfig) error {
|
|
xlog.Debug("processing runtime_settings.json")
|
|
|
|
// Determine if settings came from env vars by comparing with startup config
|
|
// startupAppConfig contains the original values set from env vars at startup.
|
|
// If current values match startup values, they came from env vars (or defaults).
|
|
// We apply file settings only if current values match startup values (meaning not from env vars).
|
|
envWatchdogIdle := appConfig.WatchDogIdle == startupAppConfig.WatchDogIdle
|
|
envWatchdogBusy := appConfig.WatchDogBusy == startupAppConfig.WatchDogBusy
|
|
envWatchdogIdleTimeout := appConfig.WatchDogIdleTimeout == startupAppConfig.WatchDogIdleTimeout
|
|
envWatchdogBusyTimeout := appConfig.WatchDogBusyTimeout == startupAppConfig.WatchDogBusyTimeout
|
|
envSingleBackend := appConfig.SingleBackend == startupAppConfig.SingleBackend
|
|
envMaxActiveBackends := appConfig.MaxActiveBackends == startupAppConfig.MaxActiveBackends
|
|
envMemoryReclaimerEnabled := appConfig.MemoryReclaimerEnabled == startupAppConfig.MemoryReclaimerEnabled
|
|
envMemoryReclaimerThreshold := appConfig.MemoryReclaimerThreshold == startupAppConfig.MemoryReclaimerThreshold
|
|
envThreads := appConfig.Threads == startupAppConfig.Threads
|
|
envContextSize := appConfig.ContextSize == startupAppConfig.ContextSize
|
|
envF16 := appConfig.F16 == startupAppConfig.F16
|
|
envDebug := appConfig.Debug == startupAppConfig.Debug
|
|
envCORS := appConfig.CORS == startupAppConfig.CORS
|
|
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
|
|
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
|
|
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
|
|
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
|
|
envFederated := appConfig.Federated == startupAppConfig.Federated
|
|
envGalleries := slices.Equal(appConfig.Galleries, startupAppConfig.Galleries)
|
|
envBackendGalleries := slices.Equal(appConfig.BackendGalleries, startupAppConfig.BackendGalleries)
|
|
envAutoloadGalleries := appConfig.AutoloadGalleries == startupAppConfig.AutoloadGalleries
|
|
envAutoloadBackendGalleries := appConfig.AutoloadBackendGalleries == startupAppConfig.AutoloadBackendGalleries
|
|
envPIIDefaultDetectors := slices.Equal(appConfig.PIIDefaultDetectors, startupAppConfig.PIIDefaultDetectors)
|
|
envAgentJobRetentionDays := appConfig.AgentJobRetentionDays == startupAppConfig.AgentJobRetentionDays
|
|
envForceEvictionWhenBusy := appConfig.ForceEvictionWhenBusy == startupAppConfig.ForceEvictionWhenBusy
|
|
envLRUEvictionMaxRetries := appConfig.LRUEvictionMaxRetries == startupAppConfig.LRUEvictionMaxRetries
|
|
envLRUEvictionRetryInterval := appConfig.LRUEvictionRetryInterval == startupAppConfig.LRUEvictionRetryInterval
|
|
|
|
if len(fileContent) > 0 {
|
|
var settings config.RuntimeSettings
|
|
err := json.Unmarshal(fileContent, &settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply file settings only if they don't match startup values (i.e., not from env vars)
|
|
if settings.WatchdogIdleEnabled != nil && !envWatchdogIdle {
|
|
appConfig.WatchDogIdle = *settings.WatchdogIdleEnabled
|
|
if appConfig.WatchDogIdle {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
if settings.WatchdogBusyEnabled != nil && !envWatchdogBusy {
|
|
appConfig.WatchDogBusy = *settings.WatchdogBusyEnabled
|
|
if appConfig.WatchDogBusy {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
if settings.WatchdogIdleTimeout != nil && !envWatchdogIdleTimeout {
|
|
dur, err := time.ParseDuration(*settings.WatchdogIdleTimeout)
|
|
if err == nil {
|
|
appConfig.WatchDogIdleTimeout = dur
|
|
} else {
|
|
xlog.Warn("invalid watchdog idle timeout in runtime_settings.json", "error", err, "timeout", *settings.WatchdogIdleTimeout)
|
|
}
|
|
}
|
|
if settings.WatchdogBusyTimeout != nil && !envWatchdogBusyTimeout {
|
|
dur, err := time.ParseDuration(*settings.WatchdogBusyTimeout)
|
|
if err == nil {
|
|
appConfig.WatchDogBusyTimeout = dur
|
|
} else {
|
|
xlog.Warn("invalid watchdog busy timeout in runtime_settings.json", "error", err, "timeout", *settings.WatchdogBusyTimeout)
|
|
}
|
|
}
|
|
// Handle MaxActiveBackends (new) and SingleBackend (deprecated)
|
|
if settings.MaxActiveBackends != nil && !envMaxActiveBackends {
|
|
appConfig.MaxActiveBackends = *settings.MaxActiveBackends
|
|
// For backward compatibility, also set SingleBackend if MaxActiveBackends == 1
|
|
appConfig.SingleBackend = (*settings.MaxActiveBackends == 1)
|
|
} else if settings.SingleBackend != nil && !envSingleBackend {
|
|
// Legacy: SingleBackend maps to MaxActiveBackends = 1
|
|
appConfig.SingleBackend = *settings.SingleBackend
|
|
if *settings.SingleBackend {
|
|
appConfig.MaxActiveBackends = 1
|
|
} else {
|
|
appConfig.MaxActiveBackends = 0
|
|
}
|
|
}
|
|
if settings.MemoryReclaimerEnabled != nil && !envMemoryReclaimerEnabled {
|
|
appConfig.MemoryReclaimerEnabled = *settings.MemoryReclaimerEnabled
|
|
if appConfig.MemoryReclaimerEnabled {
|
|
appConfig.WatchDog = true // Memory reclaimer requires watchdog
|
|
}
|
|
}
|
|
if settings.MemoryReclaimerThreshold != nil && !envMemoryReclaimerThreshold {
|
|
appConfig.MemoryReclaimerThreshold = *settings.MemoryReclaimerThreshold
|
|
}
|
|
if settings.ForceEvictionWhenBusy != nil && !envForceEvictionWhenBusy {
|
|
appConfig.ForceEvictionWhenBusy = *settings.ForceEvictionWhenBusy
|
|
}
|
|
if settings.LRUEvictionMaxRetries != nil && !envLRUEvictionMaxRetries {
|
|
appConfig.LRUEvictionMaxRetries = *settings.LRUEvictionMaxRetries
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil && !envLRUEvictionRetryInterval {
|
|
dur, err := time.ParseDuration(*settings.LRUEvictionRetryInterval)
|
|
if err == nil {
|
|
appConfig.LRUEvictionRetryInterval = dur
|
|
} else {
|
|
xlog.Warn("invalid LRU eviction retry interval in runtime_settings.json", "error", err, "interval", *settings.LRUEvictionRetryInterval)
|
|
}
|
|
}
|
|
if settings.Threads != nil && !envThreads {
|
|
appConfig.Threads = *settings.Threads
|
|
}
|
|
if settings.ContextSize != nil && !envContextSize {
|
|
appConfig.ContextSize = *settings.ContextSize
|
|
}
|
|
if settings.F16 != nil && !envF16 {
|
|
appConfig.F16 = *settings.F16
|
|
}
|
|
if settings.Debug != nil && !envDebug {
|
|
appConfig.Debug = *settings.Debug
|
|
}
|
|
if settings.CORS != nil && !envCORS {
|
|
appConfig.CORS = *settings.CORS
|
|
}
|
|
if settings.CSRF != nil && !envCSRF {
|
|
appConfig.DisableCSRF = *settings.CSRF
|
|
}
|
|
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
|
|
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins
|
|
}
|
|
if settings.P2PToken != nil && !envP2PToken {
|
|
appConfig.P2PToken = *settings.P2PToken
|
|
}
|
|
if settings.P2PNetworkID != nil && !envP2PNetworkID {
|
|
appConfig.P2PNetworkID = *settings.P2PNetworkID
|
|
}
|
|
if settings.Federated != nil && !envFederated {
|
|
appConfig.Federated = *settings.Federated
|
|
}
|
|
if settings.Galleries != nil && !envGalleries {
|
|
appConfig.Galleries = *settings.Galleries
|
|
}
|
|
if settings.BackendGalleries != nil && !envBackendGalleries {
|
|
appConfig.BackendGalleries = *settings.BackendGalleries
|
|
}
|
|
if settings.AutoloadGalleries != nil && !envAutoloadGalleries {
|
|
appConfig.AutoloadGalleries = *settings.AutoloadGalleries
|
|
}
|
|
if settings.AutoloadBackendGalleries != nil && !envAutoloadBackendGalleries {
|
|
appConfig.AutoloadBackendGalleries = *settings.AutoloadBackendGalleries
|
|
}
|
|
if settings.PIIDefaultDetectors != nil && !envPIIDefaultDetectors {
|
|
// Request-side default redaction reads this live via
|
|
// ResolvePIIPolicy, so a file edit takes effect on the next chat
|
|
// request. The MITM listener resolves its per-host detector map
|
|
// once at start, so a raw file edit reaches cloud-proxy traffic
|
|
// only after a restart or a POST /api/settings (which rebuilds
|
|
// the listener) — the admin UI uses the latter.
|
|
appConfig.PIIDefaultDetectors = append([]string(nil), (*settings.PIIDefaultDetectors)...)
|
|
}
|
|
if settings.AutoUpgradeBackends != nil {
|
|
appConfig.AutoUpgradeBackends = *settings.AutoUpgradeBackends
|
|
}
|
|
if settings.PreferDevelopmentBackends != nil {
|
|
appConfig.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
|
|
}
|
|
if settings.ApiKeys != nil {
|
|
// API keys from env vars (startup) should be kept, runtime settings keys replace all runtime keys
|
|
// If runtime_settings.json specifies ApiKeys (even if empty), it replaces all runtime keys
|
|
// Start with env keys, then add runtime_settings.json keys (which may be empty to clear them)
|
|
envKeys := startupAppConfig.ApiKeys
|
|
runtimeKeys := *settings.ApiKeys
|
|
// Replace all runtime keys with what's in runtime_settings.json
|
|
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
|
|
}
|
|
if settings.AgentJobRetentionDays != nil && !envAgentJobRetentionDays {
|
|
appConfig.AgentJobRetentionDays = *settings.AgentJobRetentionDays
|
|
}
|
|
|
|
// If watchdog is enabled via file but not via env, ensure WatchDog flag is set
|
|
if !envWatchdogIdle && !envWatchdogBusy {
|
|
if settings.WatchdogEnabled != nil && *settings.WatchdogEnabled {
|
|
appConfig.WatchDog = true
|
|
}
|
|
}
|
|
}
|
|
xlog.Debug("runtime settings loaded from runtime_settings.json")
|
|
return nil
|
|
}
|
|
return handler
|
|
}
|