index.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. package cache
  2. import (
  3. "context"
  4. "os"
  5. "path/filepath"
  6. "regexp"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/0xJacky/Nginx-UI/internal/event"
  11. "github.com/0xJacky/Nginx-UI/internal/nginx"
  12. "github.com/fsnotify/fsnotify"
  13. "github.com/uozi-tech/cosy/logger"
  14. )
  15. // ScanCallback is a function that gets called during config scanning
  16. // It receives the config file path and contents
  17. type ScanCallback func(configPath string, content []byte) error
  18. // Scanner is responsible for scanning and watching nginx config files
  19. type Scanner struct {
  20. ctx context.Context // Context for the scanner
  21. watcher *fsnotify.Watcher // File system watcher
  22. scanTicker *time.Ticker // Ticker for periodic scanning
  23. initialized bool // Whether the scanner has been initialized
  24. scanning bool // Whether a scan is currently in progress
  25. scanMutex sync.RWMutex // Mutex for protecting the scanning state
  26. }
  27. // Global variables
  28. var (
  29. // scanner is the singleton instance of Scanner
  30. scanner *Scanner
  31. configScannerInitMux sync.Mutex
  32. // This regex matches: include directives in nginx config files
  33. includeRegex = regexp.MustCompile(`include\s+([^;]+);`)
  34. // Global callbacks that will be executed during config file scanning
  35. scanCallbacks = make([]ScanCallback, 0)
  36. scanCallbacksMutex sync.RWMutex
  37. )
  38. // InitScanner initializes the config scanner
  39. func InitScanner(ctx context.Context) {
  40. if nginx.GetConfPath() == "" {
  41. logger.Error("Nginx config path is not set")
  42. return
  43. }
  44. s := GetScanner()
  45. err := s.Initialize(ctx)
  46. if err != nil {
  47. logger.Error("Failed to initialize config scanner:", err)
  48. }
  49. }
  50. // GetScanner returns the singleton instance of Scanner
  51. func GetScanner() *Scanner {
  52. configScannerInitMux.Lock()
  53. defer configScannerInitMux.Unlock()
  54. if scanner == nil {
  55. scanner = &Scanner{}
  56. }
  57. return scanner
  58. }
  59. // RegisterCallback adds a callback function to be executed during scans
  60. // This function can be called before Scanner is initialized
  61. func RegisterCallback(callback ScanCallback) {
  62. scanCallbacksMutex.Lock()
  63. defer scanCallbacksMutex.Unlock()
  64. scanCallbacks = append(scanCallbacks, callback)
  65. }
  66. // publishScanningStatus publishes the scanning status to the event bus
  67. func (s *Scanner) publishScanningStatus(scanning bool) {
  68. event.Publish(event.Event{
  69. Type: event.EventTypeIndexScanning,
  70. Data: scanning,
  71. })
  72. }
  73. // Initialize sets up the scanner and starts watching for file changes
  74. func (s *Scanner) Initialize(ctx context.Context) error {
  75. if s.initialized {
  76. return nil
  77. }
  78. // Create a new watcher
  79. watcher, err := fsnotify.NewWatcher()
  80. if err != nil {
  81. return err
  82. }
  83. s.watcher = watcher
  84. s.ctx = ctx
  85. // Scan for the first time
  86. err = s.ScanAllConfigs()
  87. if err != nil {
  88. return err
  89. }
  90. // Setup watcher for config directory
  91. configDir := filepath.Dir(nginx.GetConfPath())
  92. availableDir := nginx.GetConfPath("sites-available")
  93. enabledDir := nginx.GetConfPath("sites-enabled")
  94. streamAvailableDir := nginx.GetConfPath("streams-available")
  95. streamEnabledDir := nginx.GetConfPath("streams-enabled")
  96. // Watch the main directories
  97. err = s.watcher.Add(configDir)
  98. if err != nil {
  99. logger.Error("Failed to watch config directory:", err)
  100. }
  101. // Watch sites-available and sites-enabled if they exist
  102. if _, err := os.Stat(availableDir); err == nil {
  103. err = s.watcher.Add(availableDir)
  104. if err != nil {
  105. logger.Error("Failed to watch sites-available directory:", err)
  106. }
  107. }
  108. if _, err := os.Stat(enabledDir); err == nil {
  109. err = s.watcher.Add(enabledDir)
  110. if err != nil {
  111. logger.Error("Failed to watch sites-enabled directory:", err)
  112. }
  113. }
  114. // Watch streams-available and streams-enabled if they exist
  115. if _, err := os.Stat(streamAvailableDir); err == nil {
  116. err = s.watcher.Add(streamAvailableDir)
  117. if err != nil {
  118. logger.Error("Failed to watch streams-available directory:", err)
  119. }
  120. }
  121. if _, err := os.Stat(streamEnabledDir); err == nil {
  122. err = s.watcher.Add(streamEnabledDir)
  123. if err != nil {
  124. logger.Error("Failed to watch streams-enabled directory:", err)
  125. }
  126. }
  127. // Start the watcher goroutine
  128. go s.watchForChanges()
  129. // Setup a ticker for periodic scanning (every 5 minutes)
  130. s.scanTicker = time.NewTicker(5 * time.Minute)
  131. go func() {
  132. for {
  133. select {
  134. case <-s.ctx.Done():
  135. return
  136. case <-s.scanTicker.C:
  137. err := s.ScanAllConfigs()
  138. if err != nil {
  139. logger.Error("Periodic config scan failed:", err)
  140. }
  141. }
  142. }
  143. }()
  144. // Start a goroutine to listen for context cancellation
  145. go func() {
  146. <-s.ctx.Done()
  147. logger.Debug("Context cancelled, shutting down scanner")
  148. s.Shutdown()
  149. }()
  150. s.initialized = true
  151. return nil
  152. }
  153. // watchForChanges handles the fsnotify events and triggers rescans when necessary
  154. func (s *Scanner) watchForChanges() {
  155. for {
  156. select {
  157. case <-s.ctx.Done():
  158. return
  159. case event, ok := <-s.watcher.Events:
  160. if !ok {
  161. return
  162. }
  163. // Skip irrelevant events
  164. if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) &&
  165. !event.Has(fsnotify.Rename) && !event.Has(fsnotify.Remove) {
  166. continue
  167. }
  168. // Add newly created directories to the watch list
  169. if event.Has(fsnotify.Create) {
  170. if fi, err := os.Stat(event.Name); err == nil && fi.IsDir() {
  171. _ = s.watcher.Add(event.Name)
  172. }
  173. }
  174. // For remove events, perform a full scan
  175. if event.Has(fsnotify.Remove) {
  176. logger.Debug("Config item removed:", event.Name)
  177. if err := s.ScanAllConfigs(); err != nil {
  178. logger.Error("Failed to rescan configs after removal:", err)
  179. }
  180. continue
  181. }
  182. // Handle non-remove events
  183. fi, err := os.Stat(event.Name)
  184. if err != nil {
  185. logger.Error("Failed to stat changed path:", err)
  186. continue
  187. }
  188. if fi.IsDir() {
  189. // Directory change, perform full scan
  190. logger.Debug("Config directory changed:", event.Name)
  191. if err := s.ScanAllConfigs(); err != nil {
  192. logger.Error("Failed to rescan configs after directory change:", err)
  193. }
  194. } else {
  195. // File change, scan only the single file
  196. logger.Debug("Config file changed:", event.Name)
  197. // Give the system a moment to finish writing the file
  198. time.Sleep(100 * time.Millisecond)
  199. if err := s.scanSingleFile(event.Name); err != nil {
  200. logger.Error("Failed to scan changed file:", err)
  201. }
  202. }
  203. case err, ok := <-s.watcher.Errors:
  204. if !ok {
  205. return
  206. }
  207. logger.Error("Watcher error:", err)
  208. }
  209. }
  210. }
  211. // scanSingleFile scans a single file and executes all registered callbacks
  212. func (s *Scanner) scanSingleFile(filePath string) error {
  213. visited := make(map[string]bool)
  214. return s.scanSingleFileWithDepth(filePath, visited, 0)
  215. }
  216. // scanSingleFileWithDepth scans a single file with recursion protection
  217. func (s *Scanner) scanSingleFileWithDepth(filePath string, visited map[string]bool, depth int) error {
  218. // Maximum recursion depth to prevent infinite recursion
  219. const maxDepth = 5
  220. if depth > maxDepth {
  221. logger.Warn("Maximum recursion depth reached for file:", filePath)
  222. return nil
  223. }
  224. // Resolve the absolute path to handle symlinks properly
  225. absPath, err := filepath.Abs(filePath)
  226. if err != nil {
  227. logger.Error("Failed to resolve absolute path for:", filePath, err)
  228. return err
  229. }
  230. // Check for circular includes
  231. if visited[absPath] {
  232. // Circular include detected, skip this file
  233. return nil
  234. }
  235. // Mark this file as visited
  236. visited[absPath] = true
  237. // Set scanning state to true only for the root call (depth 0)
  238. var wasScanning bool
  239. if depth == 0 {
  240. s.scanMutex.Lock()
  241. wasScanning = s.scanning
  242. s.scanning = true
  243. if !wasScanning {
  244. // Only publish if status changed from not scanning to scanning
  245. s.publishScanningStatus(true)
  246. }
  247. s.scanMutex.Unlock()
  248. // Ensure we reset scanning state when done (only for root call)
  249. defer func() {
  250. s.scanMutex.Lock()
  251. s.scanning = false
  252. // Publish the completion
  253. s.publishScanningStatus(false)
  254. s.scanMutex.Unlock()
  255. }()
  256. }
  257. // Open the file
  258. file, err := os.Open(absPath)
  259. if err != nil {
  260. return err
  261. }
  262. defer file.Close()
  263. // Read the entire file content
  264. content, err := os.ReadFile(absPath)
  265. if err != nil {
  266. return err
  267. }
  268. // Execute all registered callbacks
  269. scanCallbacksMutex.RLock()
  270. for _, callback := range scanCallbacks {
  271. err := callback(absPath, content)
  272. if err != nil {
  273. logger.Error("Callback error for file", absPath, ":", err)
  274. }
  275. }
  276. scanCallbacksMutex.RUnlock()
  277. // Look for include directives to process included files
  278. includeMatches := includeRegex.FindAllSubmatch(content, -1)
  279. for _, match := range includeMatches {
  280. if len(match) >= 2 {
  281. includePath := string(match[1])
  282. // Handle glob patterns in include directives
  283. if strings.Contains(includePath, "*") {
  284. // If it's a relative path, make it absolute based on nginx config dir
  285. if !filepath.IsAbs(includePath) {
  286. configDir := filepath.Dir(nginx.GetConfPath())
  287. includePath = filepath.Join(configDir, includePath)
  288. }
  289. // Expand the glob pattern
  290. matchedFiles, err := filepath.Glob(includePath)
  291. if err != nil {
  292. logger.Error("Error expanding glob pattern:", includePath, err)
  293. continue
  294. }
  295. // Process each matched file
  296. for _, matchedFile := range matchedFiles {
  297. fileInfo, err := os.Stat(matchedFile)
  298. if err == nil && !fileInfo.IsDir() {
  299. err = s.scanSingleFileWithDepth(matchedFile, visited, depth+1)
  300. if err != nil {
  301. logger.Error("Failed to scan included file:", matchedFile, err)
  302. }
  303. }
  304. }
  305. } else {
  306. // Handle single file include
  307. // If it's a relative path, make it absolute based on nginx config dir
  308. if !filepath.IsAbs(includePath) {
  309. configDir := filepath.Dir(nginx.GetConfPath())
  310. includePath = filepath.Join(configDir, includePath)
  311. }
  312. fileInfo, err := os.Stat(includePath)
  313. if err == nil && !fileInfo.IsDir() {
  314. err = s.scanSingleFileWithDepth(includePath, visited, depth+1)
  315. if err != nil {
  316. logger.Error("Failed to scan included file:", includePath, err)
  317. }
  318. }
  319. }
  320. }
  321. }
  322. return nil
  323. }
  324. // ScanAllConfigs scans all nginx config files and executes all registered callbacks
  325. func (s *Scanner) ScanAllConfigs() error {
  326. // Set scanning state to true
  327. s.scanMutex.Lock()
  328. wasScanning := s.scanning
  329. s.scanning = true
  330. if !wasScanning {
  331. // Only publish if status changed from not scanning to scanning
  332. s.publishScanningStatus(true)
  333. }
  334. s.scanMutex.Unlock()
  335. // Ensure we reset scanning state when done
  336. defer func() {
  337. s.scanMutex.Lock()
  338. s.scanning = false
  339. // Publish the completion
  340. s.publishScanningStatus(false)
  341. s.scanMutex.Unlock()
  342. }()
  343. // Get the main config file
  344. mainConfigPath := nginx.GetConfEntryPath()
  345. err := s.scanSingleFile(mainConfigPath)
  346. if err != nil {
  347. logger.Error("Failed to scan main config:", err)
  348. }
  349. // Scan sites-available directory
  350. sitesAvailablePath := nginx.GetConfPath("sites-available", "")
  351. sitesAvailableFiles, err := os.ReadDir(sitesAvailablePath)
  352. if err == nil {
  353. for _, file := range sitesAvailableFiles {
  354. if !file.IsDir() {
  355. configPath := filepath.Join(sitesAvailablePath, file.Name())
  356. err := s.scanSingleFile(configPath)
  357. if err != nil {
  358. logger.Error("Failed to scan config:", configPath, err)
  359. }
  360. }
  361. }
  362. }
  363. // Scan streams-available directory if it exists
  364. streamAvailablePath := nginx.GetConfPath("streams-available", "")
  365. streamAvailableFiles, err := os.ReadDir(streamAvailablePath)
  366. if err == nil {
  367. for _, file := range streamAvailableFiles {
  368. if !file.IsDir() {
  369. configPath := filepath.Join(streamAvailablePath, file.Name())
  370. err := s.scanSingleFile(configPath)
  371. if err != nil {
  372. logger.Error("Failed to scan stream config:", configPath, err)
  373. }
  374. }
  375. }
  376. }
  377. return nil
  378. }
  379. // Shutdown cleans up resources used by the scanner
  380. func (s *Scanner) Shutdown() {
  381. if s.watcher != nil {
  382. s.watcher.Close()
  383. }
  384. if s.scanTicker != nil {
  385. s.scanTicker.Stop()
  386. }
  387. }
  388. // IsScanningInProgress returns whether a scan is currently in progress
  389. func IsScanningInProgress() bool {
  390. s := GetScanner()
  391. s.scanMutex.RLock()
  392. defer s.scanMutex.RUnlock()
  393. return s.scanning
  394. }
  395. // WithContext sets a context for the scanner that will be used to control its lifecycle
  396. func (s *Scanner) WithContext(ctx context.Context) *Scanner {
  397. // Create a context with cancel if not already done in Initialize
  398. if s.ctx == nil {
  399. s.ctx = ctx
  400. }
  401. return s
  402. }