sandbox.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. package nginx
  2. import (
  3. "fmt"
  4. "io/fs"
  5. "os"
  6. "path/filepath"
  7. "regexp"
  8. "strings"
  9. "github.com/0xJacky/Nginx-UI/internal/helper"
  10. "github.com/0xJacky/Nginx-UI/settings"
  11. "github.com/uozi-tech/cosy/logger"
  12. )
  13. // NamespaceInfo represents minimal namespace info for sandbox
  14. type NamespaceInfo struct {
  15. ID uint64
  16. Name string
  17. DeployMode string
  18. }
  19. // SandboxTestConfigWithPaths tests nginx config in an isolated sandbox with provided paths
  20. func SandboxTestConfigWithPaths(namespace *NamespaceInfo, sitePaths, streamPaths []string) (stdOut string, stdErr error) {
  21. mutex.Lock()
  22. defer mutex.Unlock()
  23. // If custom test command is set, use it (no sandbox support)
  24. if settings.NginxSettings.TestConfigCmd != "" {
  25. return execShell(settings.NginxSettings.TestConfigCmd)
  26. }
  27. // Skip local test for remote-only namespaces
  28. if namespace != nil && namespace.DeployMode == "remote" {
  29. return "Config validation skipped for remote-only namespace", nil
  30. }
  31. // If namespace is nil, directly test in real directory (no sandbox)
  32. if namespace == nil {
  33. return TestConfig()
  34. }
  35. // Create sandbox and test
  36. sandbox, err := createSandbox(namespace, sitePaths, streamPaths)
  37. if err != nil {
  38. logger.Errorf("Failed to create sandbox: %v", err)
  39. return TestConfig() // Fallback to normal test
  40. }
  41. defer sandbox.Cleanup()
  42. // Test the sandbox config
  43. sbin := GetSbinPath()
  44. if sbin == "" {
  45. sbin = "nginx"
  46. }
  47. return execCommand(sbin, "-t", "-c", sandbox.ConfigPath)
  48. }
  49. // Sandbox represents an isolated nginx test environment
  50. type Sandbox struct {
  51. Dir string
  52. ConfigPath string
  53. Namespace *NamespaceInfo
  54. }
  55. // createSandbox creates an isolated nginx configuration environment for testing
  56. func createSandbox(namespace *NamespaceInfo, sitePaths, streamPaths []string) (*Sandbox, error) {
  57. // Create temp directory for sandbox
  58. tempDir, err := os.MkdirTemp("", "nginx-ui-sandbox-*")
  59. if err != nil {
  60. return nil, fmt.Errorf("failed to create sandbox temp dir: %w", err)
  61. }
  62. sandbox := &Sandbox{
  63. Dir: tempDir,
  64. Namespace: namespace,
  65. }
  66. // Copy full nginx conf directory to sandbox, excluding sites-* and streams-*
  67. if err := copyConfigBaseExceptSitesStreams(tempDir); err != nil {
  68. os.RemoveAll(tempDir)
  69. return nil, fmt.Errorf("failed to copy base configs: %w", err)
  70. }
  71. // Ensure sandbox sub-directories exist for selected includes
  72. if err := os.MkdirAll(filepath.Join(tempDir, "sites-enabled"), 0755); err != nil {
  73. os.RemoveAll(tempDir)
  74. return nil, fmt.Errorf("failed to create sandbox sites-enabled: %w", err)
  75. }
  76. if err := os.MkdirAll(filepath.Join(tempDir, "streams-enabled"), 0755); err != nil {
  77. os.RemoveAll(tempDir)
  78. return nil, fmt.Errorf("failed to create sandbox streams-enabled: %w", err)
  79. }
  80. // Collect and copy only enabled sites/streams for the given namespace
  81. siteFiles, streamFiles, err := collectAndCopyNamespaceEnabled(namespace, sitePaths, streamPaths, tempDir)
  82. if err != nil {
  83. os.RemoveAll(tempDir)
  84. return nil, fmt.Errorf("failed to collect/copy namespace configs: %w", err)
  85. }
  86. // Generate sandbox nginx.conf
  87. configContent, err := generateSandboxConfig(namespace, siteFiles, streamFiles, tempDir)
  88. if err != nil {
  89. os.RemoveAll(tempDir)
  90. return nil, fmt.Errorf("failed to generate sandbox config: %w", err)
  91. }
  92. // Write sandbox nginx.conf
  93. sandbox.ConfigPath = filepath.Join(tempDir, "nginx.conf")
  94. if err := os.WriteFile(sandbox.ConfigPath, []byte(configContent), 0644); err != nil {
  95. os.RemoveAll(tempDir)
  96. return nil, fmt.Errorf("failed to write sandbox config: %w", err)
  97. }
  98. logger.Debugf("Created sandbox at %s for namespace: %v", tempDir, namespace)
  99. return sandbox, nil
  100. }
  101. // Cleanup removes the sandbox directory
  102. func (s *Sandbox) Cleanup() {
  103. if s.Dir != "" {
  104. if err := os.RemoveAll(s.Dir); err != nil {
  105. logger.Warnf("Failed to cleanup sandbox %s: %v", s.Dir, err)
  106. } else {
  107. logger.Debugf("Cleaned up sandbox: %s", s.Dir)
  108. }
  109. }
  110. }
  111. // generateSandboxConfig generates a minimal nginx.conf that only includes configs from specified paths
  112. func generateSandboxConfig(namespace *NamespaceInfo, siteFiles, streamFiles []string, sandboxDir string) (string, error) {
  113. // Read the main nginx.conf to get basic structure
  114. mainConfPath := GetConfEntryPath()
  115. mainConf, err := os.ReadFile(mainConfPath)
  116. if err != nil {
  117. return "", fmt.Errorf("failed to read main nginx.conf: %w", err)
  118. }
  119. mainConfStr := string(mainConf)
  120. // Generate include patterns based on provided paths
  121. siteIncludeLines := make([]string, 0, len(siteFiles))
  122. for _, f := range siteFiles {
  123. siteIncludeLines = append(siteIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "sites-enabled", f)))
  124. }
  125. streamIncludeLines := make([]string, 0, len(streamFiles))
  126. for _, f := range streamFiles {
  127. streamIncludeLines = append(streamIncludeLines, fmt.Sprintf(" include %s;", filepath.Join(sandboxDir, "streams-enabled", f)))
  128. }
  129. // Replace include directives with sandbox-specific ones
  130. sandboxConf := replaceIncludeDirectives(mainConfStr, sandboxDir, siteIncludeLines, streamIncludeLines)
  131. return sandboxConf, nil
  132. }
  133. // replaceIncludeDirectives replaces only sites-enabled and streams-enabled includes
  134. // Rewrites other includes to point to copied files under sandboxDir, preserving isolation.
  135. func replaceIncludeDirectives(mainConf string, sandboxDir string, siteIncludeLines, streamIncludeLines []string) string {
  136. lines := strings.Split(mainConf, "\n")
  137. var result []string
  138. insideHTTP := false
  139. insideStream := false
  140. httpIncludesAdded := false
  141. streamIncludesAdded := false
  142. for _, line := range lines {
  143. trimmed := strings.TrimSpace(line)
  144. // Track http and stream blocks
  145. if strings.HasPrefix(trimmed, "http") && strings.Contains(trimmed, "{") {
  146. insideHTTP = true
  147. result = append(result, line)
  148. continue
  149. }
  150. if strings.HasPrefix(trimmed, "stream") && strings.Contains(trimmed, "{") {
  151. insideStream = true
  152. result = append(result, line)
  153. continue
  154. }
  155. // Handle include directives
  156. if strings.Contains(trimmed, "include") {
  157. isSitesEnabled := strings.Contains(trimmed, "sites-enabled")
  158. isStreamsEnabled := strings.Contains(trimmed, "streams-enabled")
  159. // If it's sites-enabled or streams-enabled, replace it
  160. if isSitesEnabled || isStreamsEnabled {
  161. // Add our sandbox-specific includes at the first occurrence
  162. if insideHTTP && isSitesEnabled && !httpIncludesAdded {
  163. result = append(result, " # Sandbox-specific includes (generated for isolated testing)")
  164. result = append(result, siteIncludeLines...)
  165. httpIncludesAdded = true
  166. }
  167. if insideStream && isStreamsEnabled && !streamIncludesAdded {
  168. result = append(result, " # Sandbox-specific includes (generated for isolated testing)")
  169. result = append(result, streamIncludeLines...)
  170. streamIncludesAdded = true
  171. }
  172. continue // Skip the original include line
  173. }
  174. // Rewrite includes to sandbox paths
  175. normalized := rewriteIncludeLineToSandbox(line, sandboxDir)
  176. if normalized != "" {
  177. result = append(result, normalized)
  178. }
  179. continue
  180. }
  181. // Detect end of http/stream block
  182. if strings.Contains(line, "}") {
  183. if insideHTTP {
  184. // Add includes before closing http block if not added yet
  185. if !httpIncludesAdded {
  186. result = append(result, " # Sandbox-specific includes (generated for isolated testing)")
  187. result = append(result, siteIncludeLines...)
  188. httpIncludesAdded = true
  189. }
  190. insideHTTP = false
  191. }
  192. if insideStream {
  193. // Add includes before closing stream block if not added yet
  194. if !streamIncludesAdded {
  195. result = append(result, " # Sandbox-specific includes (generated for isolated testing)")
  196. result = append(result, streamIncludeLines...)
  197. streamIncludesAdded = true
  198. }
  199. insideStream = false
  200. }
  201. }
  202. result = append(result, line)
  203. }
  204. return strings.Join(result, "\n")
  205. }
  206. // rewriteIncludeLineToSandbox rewrites include lines to point to files/directories inside sandboxDir.
  207. // If an include path is relative, it will be rewritten relative to the nginx conf dir inside sandbox.
  208. func rewriteIncludeLineToSandbox(line string, sandboxDir string) string {
  209. includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
  210. matches := includeRegex.FindStringSubmatch(line)
  211. if len(matches) < 2 {
  212. return line
  213. }
  214. path := strings.TrimSpace(matches[1])
  215. confBase := GetConfPath()
  216. var rewritten string
  217. if filepath.IsAbs(path) {
  218. // If absolute under confBase, map to sandbox
  219. if helper.IsUnderDirectory(path, confBase) {
  220. rel, err := filepath.Rel(confBase, path)
  221. if err == nil {
  222. rewritten = filepath.Join(sandboxDir, rel)
  223. }
  224. }
  225. } else {
  226. // Relative includes should point inside sandbox conf root
  227. rewritten = filepath.Join(sandboxDir, path)
  228. }
  229. if rewritten == "" {
  230. rewritten = path
  231. }
  232. trimmed := includeRegex.ReplaceAllString(line, "include "+rewritten+";")
  233. return trimmed
  234. }
  235. // collectAndCopyNamespaceEnabled collects and copies enabled site/stream configs based on provided paths.
  236. // It rewrites relative includes to absolute, and writes them into sandboxDir/{sites-enabled,streams-enabled}.
  237. // Returns the written file names.
  238. func collectAndCopyNamespaceEnabled(_ *NamespaceInfo, sitePaths, streamPaths []string, sandboxDir string) (siteFiles, streamFiles []string, err error) {
  239. // Helper to process and write a single config by kind and name
  240. readSourceAndWrite := func(kind, name string) (writtenName string, wErr error) {
  241. var enabledCandidates []string
  242. switch kind {
  243. case "site":
  244. enabledCandidates = []string{
  245. GetConfSymlinkPath(GetConfPath("sites-enabled", name)),
  246. GetConfPath("sites-enabled", name),
  247. }
  248. case "stream":
  249. enabledCandidates = []string{
  250. GetConfSymlinkPath(GetConfPath("streams-enabled", name)),
  251. GetConfPath("streams-enabled", name),
  252. }
  253. }
  254. var enabledPath string
  255. for _, cand := range enabledCandidates {
  256. if helper.FileExists(cand) {
  257. enabledPath = cand
  258. break
  259. }
  260. }
  261. if enabledPath == "" {
  262. return "", nil // not enabled, skip silently
  263. }
  264. // Determine source file: prefer the symlink target if possible; fallback to *-available
  265. srcPath := enabledPath
  266. if fi, lErr := os.Lstat(enabledPath); lErr == nil && (fi.Mode()&os.ModeSymlink) != 0 {
  267. if target, rErr := os.Readlink(enabledPath); rErr == nil {
  268. // If target is relative, resolve against enabled dir
  269. if !filepath.IsAbs(target) {
  270. target = filepath.Join(filepath.Dir(enabledPath), target)
  271. }
  272. srcPath = target
  273. }
  274. }
  275. if kind == "site" && !helper.FileExists(srcPath) {
  276. srcPath = GetConfPath("sites-available", name)
  277. }
  278. if kind == "stream" && !helper.FileExists(srcPath) {
  279. srcPath = GetConfPath("streams-available", name)
  280. }
  281. content, rErr := os.ReadFile(srcPath)
  282. if rErr != nil {
  283. return "", fmt.Errorf("read %s content %s: %w", kind, srcPath, rErr)
  284. }
  285. // Rewrite include lines to sandbox paths (resolve relative to source dir first)
  286. absRewriter := regexp.MustCompile(`(?m)^[ \t]*include\s+([^;#]+);`)
  287. rewritten := absRewriter.ReplaceAllStringFunc(string(content), func(m string) string {
  288. return normalizeIncludeLineRelativeTo(m, filepath.Dir(srcPath), sandboxDir)
  289. })
  290. // Compute destination file name respecting platform symlink naming
  291. var destName string
  292. switch kind {
  293. case "site":
  294. destName = filepath.Base(GetConfSymlinkPath(GetConfPath("sites-enabled", name)))
  295. case "stream":
  296. destName = filepath.Base(GetConfSymlinkPath(GetConfPath("streams-enabled", name)))
  297. }
  298. destDir := filepath.Join(sandboxDir, kind+"s-enabled")
  299. if err := os.WriteFile(filepath.Join(destDir, destName), []byte(rewritten), 0644); err != nil {
  300. return "", fmt.Errorf("write sandbox %s: %w", kind, err)
  301. }
  302. return destName, nil
  303. }
  304. // Process sites based on provided sitePaths
  305. for _, sp := range sitePaths {
  306. name := filepath.Base(sp)
  307. if written, wErr := readSourceAndWrite("site", name); wErr != nil {
  308. return nil, nil, wErr
  309. } else if written != "" {
  310. siteFiles = append(siteFiles, written)
  311. }
  312. }
  313. // Process streams based on provided streamPaths
  314. for _, st := range streamPaths {
  315. name := filepath.Base(st)
  316. if written, wErr := readSourceAndWrite("stream", name); wErr != nil {
  317. return nil, nil, wErr
  318. } else if written != "" {
  319. streamFiles = append(streamFiles, written)
  320. }
  321. }
  322. return siteFiles, streamFiles, nil
  323. }
  324. // normalizeIncludeLineRelativeTo rewrites a single include line:
  325. // - resolves relative paths against baseDir
  326. // - if the resolved path is under confBase, map to sandboxDir mirror; else keep as is
  327. func normalizeIncludeLineRelativeTo(line, baseDir, sandboxDir string) string {
  328. includeRegex := regexp.MustCompile(`(?i)include\s+([^;#]+);`)
  329. matches := includeRegex.FindStringSubmatch(line)
  330. if len(matches) < 2 {
  331. return line
  332. }
  333. path := strings.TrimSpace(matches[1])
  334. // If relative, make absolute to source file dir
  335. resolved := path
  336. if !filepath.IsAbs(resolved) {
  337. resolved = filepath.Clean(filepath.Join(baseDir, resolved))
  338. }
  339. confBase := GetConfPath()
  340. if helper.IsUnderDirectory(resolved, confBase) {
  341. if rel, err := filepath.Rel(confBase, resolved); err == nil {
  342. resolved = filepath.Join(sandboxDir, rel)
  343. }
  344. }
  345. return includeRegex.ReplaceAllString(line, "include "+resolved+";")
  346. }
  347. // copyConfigBaseExceptSitesStreams copies the entire nginx conf directory into sandboxDir,
  348. // excluding any paths under sites-* and streams-* and skipping the entry nginx.conf (we generate our own).
  349. func copyConfigBaseExceptSitesStreams(sandboxDir string) error {
  350. confBase := GetConfPath()
  351. entry := GetConfEntryPath()
  352. copyFile := func(src, dst string, mode fs.FileMode) error {
  353. parent := filepath.Dir(dst)
  354. if err := os.MkdirAll(parent, 0755); err != nil {
  355. return err
  356. }
  357. data, err := os.ReadFile(src)
  358. if err != nil {
  359. return err
  360. }
  361. return os.WriteFile(dst, data, 0644)
  362. }
  363. return filepath.WalkDir(confBase, func(path string, d fs.DirEntry, err error) error {
  364. if err != nil {
  365. return err
  366. }
  367. rel, rErr := filepath.Rel(confBase, path)
  368. if rErr != nil {
  369. return rErr
  370. }
  371. if rel == "." {
  372. return nil
  373. }
  374. // Skip blacklisted directories
  375. if d.IsDir() {
  376. base := filepath.Base(path)
  377. if strings.HasPrefix(base, "sites-") || strings.HasPrefix(base, "streams-") {
  378. return filepath.SkipDir
  379. }
  380. // Create directory in sandbox
  381. return os.MkdirAll(filepath.Join(sandboxDir, rel), 0755)
  382. }
  383. // Skip entry nginx.conf to avoid overwriting generated file
  384. if path == entry && filepath.Base(path) == "nginx.conf" {
  385. return nil
  386. }
  387. // Copy regular file (follow symlinks by reading content)
  388. dst := filepath.Join(sandboxDir, rel)
  389. info, sErr := os.Lstat(path)
  390. if sErr != nil {
  391. return sErr
  392. }
  393. return copyFile(path, dst, info.Mode())
  394. })
  395. }