| |
| |
| package watcher |
|
|
| import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "os" |
| "path/filepath" |
| "runtime" |
| "strings" |
| "time" |
|
|
| "github.com/fsnotify/fsnotify" |
| log "github.com/sirupsen/logrus" |
| ) |
|
|
| func matchProvider(provider string, targets []string) (string, bool) { |
| p := strings.ToLower(strings.TrimSpace(provider)) |
| for _, t := range targets { |
| if strings.EqualFold(p, strings.TrimSpace(t)) { |
| return p, true |
| } |
| } |
| return p, false |
| } |
|
|
| func (w *Watcher) start(ctx context.Context) error { |
| if errAddConfig := w.watcher.Add(w.configPath); errAddConfig != nil { |
| log.Errorf("failed to watch config file %s: %v", w.configPath, errAddConfig) |
| return errAddConfig |
| } |
| log.Debugf("watching config file: %s", w.configPath) |
|
|
| if errAddAuthDir := w.watcher.Add(w.authDir); errAddAuthDir != nil { |
| log.Errorf("failed to watch auth directory %s: %v", w.authDir, errAddAuthDir) |
| return errAddAuthDir |
| } |
| log.Debugf("watching auth directory: %s", w.authDir) |
|
|
| go w.processEvents(ctx) |
|
|
| w.reloadClients(true, nil, false) |
| return nil |
| } |
|
|
| func (w *Watcher) processEvents(ctx context.Context) { |
| for { |
| select { |
| case <-ctx.Done(): |
| return |
| case event, ok := <-w.watcher.Events: |
| if !ok { |
| return |
| } |
| w.handleEvent(event) |
| case errWatch, ok := <-w.watcher.Errors: |
| if !ok { |
| return |
| } |
| log.Errorf("file watcher error: %v", errWatch) |
| } |
| } |
| } |
|
|
| func (w *Watcher) handleEvent(event fsnotify.Event) { |
| |
| configOps := fsnotify.Write | fsnotify.Create | fsnotify.Rename |
| normalizedName := w.normalizeAuthPath(event.Name) |
| normalizedConfigPath := w.normalizeAuthPath(w.configPath) |
| normalizedAuthDir := w.normalizeAuthPath(w.authDir) |
| isConfigEvent := normalizedName == normalizedConfigPath && event.Op&configOps != 0 |
| authOps := fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename |
| isAuthJSON := strings.HasPrefix(normalizedName, normalizedAuthDir) && strings.HasSuffix(normalizedName, ".json") && event.Op&authOps != 0 |
| if !isConfigEvent && !isAuthJSON { |
| |
| return |
| } |
|
|
| now := time.Now() |
| log.Debugf("file system event detected: %s %s", event.Op.String(), event.Name) |
|
|
| |
| if isConfigEvent { |
| log.Debugf("config file change details - operation: %s, timestamp: %s", event.Op.String(), now.Format("2006-01-02 15:04:05.000")) |
| w.scheduleConfigReload() |
| return |
| } |
|
|
| |
| if event.Op&(fsnotify.Remove|fsnotify.Rename) != 0 { |
| if w.shouldDebounceRemove(normalizedName, now) { |
| log.Debugf("debouncing remove event for %s", filepath.Base(event.Name)) |
| return |
| } |
| |
| |
| time.Sleep(replaceCheckDelay) |
| if _, statErr := os.Stat(event.Name); statErr == nil { |
| if unchanged, errSame := w.authFileUnchanged(event.Name); errSame == nil && unchanged { |
| log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name)) |
| return |
| } |
| log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name)) |
| w.addOrUpdateClient(event.Name) |
| return |
| } |
| if !w.isKnownAuthFile(event.Name) { |
| log.Debugf("ignoring remove for unknown auth file: %s", filepath.Base(event.Name)) |
| return |
| } |
| log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name)) |
| w.removeClient(event.Name) |
| return |
| } |
| if event.Op&(fsnotify.Create|fsnotify.Write) != 0 { |
| if unchanged, errSame := w.authFileUnchanged(event.Name); errSame == nil && unchanged { |
| log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(event.Name)) |
| return |
| } |
| log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name)) |
| w.addOrUpdateClient(event.Name) |
| } |
| } |
|
|
| func (w *Watcher) authFileUnchanged(path string) (bool, error) { |
| data, errRead := os.ReadFile(path) |
| if errRead != nil { |
| return false, errRead |
| } |
| if len(data) == 0 { |
| return false, nil |
| } |
| sum := sha256.Sum256(data) |
| curHash := hex.EncodeToString(sum[:]) |
|
|
| normalized := w.normalizeAuthPath(path) |
| w.clientsMutex.RLock() |
| prevHash, ok := w.lastAuthHashes[normalized] |
| w.clientsMutex.RUnlock() |
| if ok && prevHash == curHash { |
| return true, nil |
| } |
| return false, nil |
| } |
|
|
| func (w *Watcher) isKnownAuthFile(path string) bool { |
| normalized := w.normalizeAuthPath(path) |
| w.clientsMutex.RLock() |
| defer w.clientsMutex.RUnlock() |
| _, ok := w.lastAuthHashes[normalized] |
| return ok |
| } |
|
|
| func (w *Watcher) normalizeAuthPath(path string) string { |
| trimmed := strings.TrimSpace(path) |
| if trimmed == "" { |
| return "" |
| } |
| cleaned := filepath.Clean(trimmed) |
| if runtime.GOOS == "windows" { |
| cleaned = strings.TrimPrefix(cleaned, `\\?\`) |
| cleaned = strings.ToLower(cleaned) |
| } |
| return cleaned |
| } |
|
|
| func (w *Watcher) shouldDebounceRemove(normalizedPath string, now time.Time) bool { |
| if normalizedPath == "" { |
| return false |
| } |
| w.clientsMutex.Lock() |
| if w.lastRemoveTimes == nil { |
| w.lastRemoveTimes = make(map[string]time.Time) |
| } |
| if last, ok := w.lastRemoveTimes[normalizedPath]; ok { |
| if now.Sub(last) < authRemoveDebounceWindow { |
| w.clientsMutex.Unlock() |
| return true |
| } |
| } |
| w.lastRemoveTimes[normalizedPath] = now |
| if len(w.lastRemoveTimes) > 128 { |
| cutoff := now.Add(-2 * authRemoveDebounceWindow) |
| for p, t := range w.lastRemoveTimes { |
| if t.Before(cutoff) { |
| delete(w.lastRemoveTimes, p) |
| } |
| } |
| } |
| w.clientsMutex.Unlock() |
| return false |
| } |
|
|