123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585 |
- package cache
- import (
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "sync"
- "time"
- "github.com/0xJacky/Nginx-UI/internal/helper"
- "github.com/0xJacky/Nginx-UI/internal/nginx"
- "github.com/0xJacky/Nginx-UI/settings"
- "github.com/fsnotify/fsnotify"
- "github.com/uozi-tech/cosy/logger"
- )
- // NginxLogCache represents a cached log entry from nginx configuration
- type NginxLogCache struct {
- Path string `json:"path"` // Path to the log file
- Type string `json:"type"` // Type of log: "access" or "error"
- Name string `json:"name"` // Name of the log file
- }
- // NginxLogScanner is responsible for scanning and watching nginx config files for log directives
- type NginxLogScanner struct {
- logCache map[string]*NginxLogCache // Map of log path to cache entry
- cacheMutex sync.RWMutex // Mutex for protecting the cache
- watcher *fsnotify.Watcher // File system watcher
- scanTicker *time.Ticker // Ticker for periodic scanning
- initialized bool // Whether the scanner has been initialized
- scanning bool // Whether a scan is currently in progress
- scanMutex sync.RWMutex // Mutex for protecting the scanning state
- statusChan chan bool // Channel to broadcast scanning status changes
- subscribers map[chan bool]struct{} // Set of subscribers
- subscriberMux sync.RWMutex // Mutex for protecting the subscribers map
- }
- // Add regex constants at package level
- var (
- // logScanner is the singleton instance of NginxLogScanner
- logScanner *NginxLogScanner
- scannerInitMux sync.Mutex
- )
- // Compile the regular expressions for matching log directives
- var (
- // This regex matches: access_log or error_log, followed by a path, and optional parameters ending with semicolon
- logDirectiveRegex = regexp.MustCompile(`(?m)(access_log|error_log)\s+([^\s;]+)(?:\s+[^;]+)?;`)
- )
- // InitNginxLogScanner initializes the nginx log scanner
- func InitNginxLogScanner() {
- scanner := GetNginxLogScanner()
- err := scanner.Initialize()
- if err != nil {
- logger.Error("Failed to initialize nginx log scanner:", err)
- }
- }
- // GetNginxLogScanner returns the singleton instance of NginxLogScanner
- func GetNginxLogScanner() *NginxLogScanner {
- scannerInitMux.Lock()
- defer scannerInitMux.Unlock()
- if logScanner == nil {
- logScanner = &NginxLogScanner{
- logCache: make(map[string]*NginxLogCache),
- statusChan: make(chan bool, 10), // Buffer to prevent blocking
- subscribers: make(map[chan bool]struct{}),
- }
- // Start broadcaster goroutine
- go logScanner.broadcastStatus()
- }
- return logScanner
- }
- // broadcastStatus listens for status changes and broadcasts to all subscribers
- func (s *NginxLogScanner) broadcastStatus() {
- for status := range s.statusChan {
- s.subscriberMux.RLock()
- for ch := range s.subscribers {
- // Non-blocking send to prevent slow subscribers from blocking others
- select {
- case ch <- status:
- default:
- // Skip if channel buffer is full
- }
- }
- s.subscriberMux.RUnlock()
- }
- }
- // SubscribeStatusChanges allows a client to subscribe to scanning status changes
- func SubscribeStatusChanges() chan bool {
- s := GetNginxLogScanner()
- ch := make(chan bool, 5) // Buffer to prevent blocking
- // Add to subscribers
- s.subscriberMux.Lock()
- s.subscribers[ch] = struct{}{}
- s.subscriberMux.Unlock()
- // Send current status immediately
- s.scanMutex.RLock()
- currentStatus := s.scanning
- s.scanMutex.RUnlock()
- // Non-blocking send
- select {
- case ch <- currentStatus:
- default:
- }
- return ch
- }
- // UnsubscribeStatusChanges removes a subscriber from receiving status updates
- func UnsubscribeStatusChanges(ch chan bool) {
- s := GetNginxLogScanner()
- s.subscriberMux.Lock()
- delete(s.subscribers, ch)
- s.subscriberMux.Unlock()
- // Close the channel so the client knows it's unsubscribed
- close(ch)
- }
- // Initialize sets up the log scanner and starts watching for file changes
- func (s *NginxLogScanner) Initialize() error {
- if s.initialized {
- return nil
- }
- // Create a new watcher
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- return err
- }
- s.watcher = watcher
- // Scan for the first time
- err = s.ScanAllConfigs()
- if err != nil {
- return err
- }
- // Setup watcher for config directory
- configDir := filepath.Dir(nginx.GetConfPath("", ""))
- availableDir := nginx.GetConfPath("sites-available", "")
- enabledDir := nginx.GetConfPath("sites-enabled", "")
- streamAvailableDir := nginx.GetConfPath("stream-available", "")
- streamEnabledDir := nginx.GetConfPath("stream-enabled", "")
- // Watch the main directories
- err = s.watcher.Add(configDir)
- if err != nil {
- logger.Error("Failed to watch config directory:", err)
- }
- // Watch sites-available and sites-enabled if they exist
- if _, err := os.Stat(availableDir); err == nil {
- err = s.watcher.Add(availableDir)
- if err != nil {
- logger.Error("Failed to watch sites-available directory:", err)
- }
- }
- if _, err := os.Stat(enabledDir); err == nil {
- err = s.watcher.Add(enabledDir)
- if err != nil {
- logger.Error("Failed to watch sites-enabled directory:", err)
- }
- }
- // Watch stream-available and stream-enabled if they exist
- if _, err := os.Stat(streamAvailableDir); err == nil {
- err = s.watcher.Add(streamAvailableDir)
- if err != nil {
- logger.Error("Failed to watch stream-available directory:", err)
- }
- }
- if _, err := os.Stat(streamEnabledDir); err == nil {
- err = s.watcher.Add(streamEnabledDir)
- if err != nil {
- logger.Error("Failed to watch stream-enabled directory:", err)
- }
- }
- // Start the watcher goroutine
- go s.watchForChanges()
- // Setup a ticker for periodic scanning (every 5 minutes)
- s.scanTicker = time.NewTicker(5 * time.Minute)
- go func() {
- for range s.scanTicker.C {
- err := s.ScanAllConfigs()
- if err != nil {
- logger.Error("Periodic config scan failed:", err)
- }
- }
- }()
- s.initialized = true
- return nil
- }
- // watchForChanges handles the fsnotify events and triggers rescans when necessary
- func (s *NginxLogScanner) watchForChanges() {
- for {
- select {
- case event, ok := <-s.watcher.Events:
- if !ok {
- return
- }
- // Check if this is a relevant event (create, write, rename, remove)
- if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) ||
- event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove) {
- // If it's a directory, add it to the watch list
- if event.Has(fsnotify.Create) {
- fi, err := os.Stat(event.Name)
- if err == nil && fi.IsDir() {
- _ = s.watcher.Add(event.Name)
- }
- }
- // Process file changes - no .conf restriction anymore
- if !event.Has(fsnotify.Remove) {
- logger.Debug("Config file changed:", event.Name)
- // Give the system a moment to finish writing the file
- time.Sleep(100 * time.Millisecond)
- // Only scan the changed file instead of all configs
- err := s.scanSingleFile(event.Name)
- if err != nil {
- logger.Error("Failed to scan changed file:", err)
- }
- } else {
- // For removed files, we need to clean up any log entries that came from this file
- // This would require tracking which logs came from which config files
- // For now, we'll do a full rescan which is simpler but less efficient
- err := s.ScanAllConfigs()
- if err != nil {
- logger.Error("Failed to rescan configs after file removal:", err)
- }
- }
- }
- case err, ok := <-s.watcher.Errors:
- if !ok {
- return
- }
- logger.Error("Watcher error:", err)
- }
- }
- }
- // scanSingleFile scans a single file and updates the log cache accordingly
- func (s *NginxLogScanner) scanSingleFile(filePath string) error {
- // Set scanning state to true
- s.scanMutex.Lock()
- wasScanning := s.scanning
- s.scanning = true
- if !wasScanning {
- // Only broadcast if status changed from not scanning to scanning
- s.statusChan <- true
- }
- s.scanMutex.Unlock()
- // Ensure we reset scanning state when done
- defer func() {
- s.scanMutex.Lock()
- s.scanning = false
- // Broadcast the completion
- s.statusChan <- false
- s.scanMutex.Unlock()
- }()
- // Create a temporary cache for new entries from this file
- newEntries := make(map[string]*NginxLogCache)
- // Scan the file
- err := s.scanConfigFile(filePath, newEntries)
- if err != nil {
- return err
- }
- // Update the main cache with new entries
- s.cacheMutex.Lock()
- for path, entry := range newEntries {
- s.logCache[path] = entry
- }
- s.cacheMutex.Unlock()
- return nil
- }
- // ScanAllConfigs scans all nginx config files for log directives
- func (s *NginxLogScanner) ScanAllConfigs() error {
- // Set scanning state to true
- s.scanMutex.Lock()
- wasScanning := s.scanning
- s.scanning = true
- if !wasScanning {
- // Only broadcast if status changed from not scanning to scanning
- s.statusChan <- true
- }
- s.scanMutex.Unlock()
- // Ensure we reset scanning state when done
- defer func() {
- s.scanMutex.Lock()
- s.scanning = false
- // Broadcast the completion
- s.statusChan <- false
- s.scanMutex.Unlock()
- }()
- // Initialize a new cache to replace the old one
- newCache := make(map[string]*NginxLogCache)
- // Get the main config file
- mainConfigPath := nginx.GetConfPath("", "nginx.conf")
- err := s.scanConfigFile(mainConfigPath, newCache)
- if err != nil {
- logger.Error("Failed to scan main config:", err)
- }
- // Scan sites-available directory - no .conf restriction anymore
- sitesAvailablePath := nginx.GetConfPath("sites-available", "")
- sitesAvailableFiles, err := os.ReadDir(sitesAvailablePath)
- if err == nil {
- for _, file := range sitesAvailableFiles {
- if !file.IsDir() {
- configPath := filepath.Join(sitesAvailablePath, file.Name())
- err := s.scanConfigFile(configPath, newCache)
- if err != nil {
- logger.Error("Failed to scan config:", configPath, err)
- }
- }
- }
- }
- // Scan stream-available directory if it exists
- streamAvailablePath := nginx.GetConfPath("stream-available", "")
- streamAvailableFiles, err := os.ReadDir(streamAvailablePath)
- if err == nil {
- for _, file := range streamAvailableFiles {
- if !file.IsDir() {
- configPath := filepath.Join(streamAvailablePath, file.Name())
- err := s.scanConfigFile(configPath, newCache)
- if err != nil {
- logger.Error("Failed to scan stream config:", configPath, err)
- }
- }
- }
- }
- // Replace the old cache with the new one
- s.cacheMutex.Lock()
- s.logCache = newCache
- s.cacheMutex.Unlock()
- return nil
- }
- // scanConfigFile scans a single config file for log directives using regex
- func (s *NginxLogScanner) scanConfigFile(configPath string, cache map[string]*NginxLogCache) error {
- // Open the file
- file, err := os.Open(configPath)
- if err != nil {
- return err
- }
- defer file.Close()
- // Read the entire file content
- content, err := os.ReadFile(configPath)
- if err != nil {
- return err
- }
- // Find all matches of log directives
- matches := logDirectiveRegex.FindAllSubmatch(content, -1)
- for _, match := range matches {
- if len(match) >= 3 {
- directiveType := string(match[1]) // "access_log" or "error_log"
- logPath := string(match[2]) // The log file path
- // Validate the log path
- if isValidLogPath(logPath) {
- logType := "access"
- if directiveType == "error_log" {
- logType = "error"
- }
- cache[logPath] = &NginxLogCache{
- Path: logPath,
- Type: logType,
- Name: filepath.Base(logPath),
- }
- }
- }
- }
- // Look for include directives to process included files
- includeRegex := regexp.MustCompile(`include\s+([^;]+);`)
- includeMatches := includeRegex.FindAllSubmatch(content, -1)
- for _, match := range includeMatches {
- if len(match) >= 2 {
- includePath := string(match[1])
- // Handle glob patterns in include directives
- if strings.Contains(includePath, "*") {
- // If it's a relative path, make it absolute based on nginx config dir
- if !filepath.IsAbs(includePath) {
- configDir := filepath.Dir(nginx.GetConfPath("", ""))
- includePath = filepath.Join(configDir, includePath)
- }
- // Expand the glob pattern
- matchedFiles, err := filepath.Glob(includePath)
- if err != nil {
- logger.Error("Error expanding glob pattern:", includePath, err)
- continue
- }
- // Process each matched file
- for _, matchedFile := range matchedFiles {
- fileInfo, err := os.Stat(matchedFile)
- if err == nil && !fileInfo.IsDir() {
- err = s.scanConfigFile(matchedFile, cache)
- if err != nil {
- logger.Error("Failed to scan included file:", matchedFile, err)
- }
- }
- }
- } else {
- // Handle single file include
- // If it's a relative path, make it absolute based on nginx config dir
- if !filepath.IsAbs(includePath) {
- configDir := filepath.Dir(nginx.GetConfPath("", ""))
- includePath = filepath.Join(configDir, includePath)
- }
- fileInfo, err := os.Stat(includePath)
- if err == nil && !fileInfo.IsDir() {
- err = s.scanConfigFile(includePath, cache)
- if err != nil {
- logger.Error("Failed to scan included file:", includePath, err)
- }
- }
- }
- }
- }
- return nil
- }
- // isLogPathUnderWhiteList checks if the log path is under one of the paths in LogDirWhiteList
- // This is a duplicate of the function in nginx_log package to avoid import cycle
- func isLogPathUnderWhiteList(path string) bool {
- // deep copy
- logDirWhiteList := append([]string{}, settings.NginxSettings.LogDirWhiteList...)
- accessLogPath := nginx.GetAccessLogPath()
- errorLogPath := nginx.GetErrorLogPath()
- if accessLogPath != "" {
- logDirWhiteList = append(logDirWhiteList, filepath.Dir(accessLogPath))
- }
- if errorLogPath != "" {
- logDirWhiteList = append(logDirWhiteList, filepath.Dir(errorLogPath))
- }
- for _, whitePath := range logDirWhiteList {
- if helper.IsUnderDirectory(path, whitePath) {
- return true
- }
- }
- return false
- }
- // isValidLogPath checks if a log path is valid:
- // 1. It must be a regular file or a symlink to a regular file
- // 2. It must not point to a console or special device
- // 3. It must be under the whitelist directories
- func isValidLogPath(logPath string) bool {
- // First check if the path is under the whitelist
- if !isLogPathUnderWhiteList(logPath) {
- logger.Warn("Log path is not under whitelist:", logPath)
- return false
- }
- // Check if the path exists
- fileInfo, err := os.Lstat(logPath)
- if err != nil {
- // If file doesn't exist, it might be created later
- // We'll assume it's valid for now
- return true
- }
- // If it's a symlink, follow it
- if fileInfo.Mode()&os.ModeSymlink != 0 {
- linkTarget, err := os.Readlink(logPath)
- if err != nil {
- return false
- }
- // Make absolute path if the link target is relative
- if !filepath.IsAbs(linkTarget) {
- linkTarget = filepath.Join(filepath.Dir(logPath), linkTarget)
- }
- // Check the target file
- targetInfo, err := os.Stat(linkTarget)
- if err != nil {
- return false
- }
- // Only accept regular files as targets
- return targetInfo.Mode().IsRegular()
- }
- // For non-symlinks, just check if it's a regular file
- return fileInfo.Mode().IsRegular()
- }
- // Shutdown cleans up resources used by the scanner
- func (s *NginxLogScanner) Shutdown() {
- if s.watcher != nil {
- s.watcher.Close()
- }
- if s.scanTicker != nil {
- s.scanTicker.Stop()
- }
- // Clean up subscriber resources
- s.subscriberMux.Lock()
- // Close all subscriber channels
- for ch := range s.subscribers {
- close(ch)
- }
- // Clear the map
- s.subscribers = make(map[chan bool]struct{})
- s.subscriberMux.Unlock()
- // Close the status channel
- close(s.statusChan)
- }
- // GetAllLogPaths returns all cached log paths
- func GetAllLogPaths(filters ...func(*NginxLogCache) bool) []*NginxLogCache {
- s := GetNginxLogScanner()
- s.cacheMutex.RLock()
- defer s.cacheMutex.RUnlock()
- result := make([]*NginxLogCache, 0, len(s.logCache))
- for _, cache := range s.logCache {
- flag := true
- if len(filters) > 0 {
- for _, filter := range filters {
- if !filter(cache) {
- flag = false
- break
- }
- }
- }
- if flag {
- result = append(result, cache)
- }
- }
- return result
- }
- // IsScanning returns whether a scan is currently in progress
- func IsScanning() bool {
- s := GetNginxLogScanner()
- s.scanMutex.RLock()
- defer s.scanMutex.RUnlock()
- return s.scanning
- }
|