restore.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. package backup
  2. import (
  3. "archive/zip"
  4. "fmt"
  5. "io"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "github.com/0xJacky/Nginx-UI/internal/nginx"
  10. "github.com/0xJacky/Nginx-UI/settings"
  11. "github.com/uozi-tech/cosy"
  12. "github.com/uozi-tech/cosy/logger"
  13. cosysettings "github.com/uozi-tech/cosy/settings"
  14. )
  15. // RestoreResult contains the results of a restore operation
  16. type RestoreResult struct {
  17. RestoreDir string
  18. NginxUIRestored bool
  19. NginxRestored bool
  20. HashMatch bool
  21. }
  22. // RestoreOptions contains options for restore operation
  23. type RestoreOptions struct {
  24. BackupPath string
  25. AESKey []byte
  26. AESIv []byte
  27. RestoreDir string
  28. RestoreNginx bool
  29. VerifyHash bool
  30. RestoreNginxUI bool
  31. }
  32. // Restore restores data from a backup archive
  33. func Restore(options RestoreOptions) (RestoreResult, error) {
  34. // Create restore directory if it doesn't exist
  35. if err := os.MkdirAll(options.RestoreDir, 0755); err != nil {
  36. return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateRestoreDir, err.Error())
  37. }
  38. // Extract main archive to restore directory
  39. if err := extractZipArchive(options.BackupPath, options.RestoreDir); err != nil {
  40. return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
  41. }
  42. // Decrypt the extracted files
  43. hashInfoPath := filepath.Join(options.RestoreDir, HashInfoFile)
  44. nginxUIZipPath := filepath.Join(options.RestoreDir, NginxUIZipName)
  45. nginxZipPath := filepath.Join(options.RestoreDir, NginxZipName)
  46. // Decrypt hash info file
  47. if err := decryptFile(hashInfoPath, options.AESKey, options.AESIv); err != nil {
  48. return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptFile, err.Error())
  49. }
  50. // Decrypt nginx-ui.zip
  51. if err := decryptFile(nginxUIZipPath, options.AESKey, options.AESIv); err != nil {
  52. return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptNginxUIDir, err.Error())
  53. }
  54. // Decrypt nginx.zip
  55. if err := decryptFile(nginxZipPath, options.AESKey, options.AESIv); err != nil {
  56. return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptNginxDir, err.Error())
  57. }
  58. // Extract zip files to subdirectories
  59. nginxUIDir := filepath.Join(options.RestoreDir, NginxUIDir)
  60. nginxDir := filepath.Join(options.RestoreDir, NginxDir)
  61. if err := os.MkdirAll(nginxUIDir, 0755); err != nil {
  62. return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, err.Error())
  63. }
  64. if err := os.MkdirAll(nginxDir, 0755); err != nil {
  65. return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, err.Error())
  66. }
  67. // Extract nginx-ui.zip to nginx-ui directory
  68. if err := extractZipArchive(nginxUIZipPath, nginxUIDir); err != nil {
  69. return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
  70. }
  71. // Extract nginx.zip to nginx directory
  72. if err := extractZipArchive(nginxZipPath, nginxDir); err != nil {
  73. return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
  74. }
  75. result := RestoreResult{
  76. RestoreDir: options.RestoreDir,
  77. NginxUIRestored: false,
  78. NginxRestored: false,
  79. HashMatch: false,
  80. }
  81. // Verify hashes if requested
  82. if options.VerifyHash {
  83. hashMatch, err := verifyHashes(options.RestoreDir, nginxUIZipPath, nginxZipPath)
  84. if err != nil {
  85. return result, cosy.WrapErrorWithParams(ErrVerifyHashes, err.Error())
  86. }
  87. result.HashMatch = hashMatch
  88. }
  89. // Restore nginx configs if requested
  90. if options.RestoreNginx {
  91. if err := restoreNginxConfigs(nginxDir); err != nil {
  92. return result, cosy.WrapErrorWithParams(ErrRestoreNginxConfigs, err.Error())
  93. }
  94. result.NginxRestored = true
  95. }
  96. // Restore nginx-ui config if requested
  97. if options.RestoreNginxUI {
  98. if err := restoreNginxUIConfig(nginxUIDir); err != nil {
  99. return result, cosy.WrapErrorWithParams(ErrBackupNginxUI, err.Error())
  100. }
  101. result.NginxUIRestored = true
  102. }
  103. return result, nil
  104. }
  105. // extractZipArchive extracts a zip archive to the specified directory
  106. func extractZipArchive(zipPath, destDir string) error {
  107. reader, err := zip.OpenReader(zipPath)
  108. if err != nil {
  109. return cosy.WrapErrorWithParams(ErrOpenZipFile, fmt.Sprintf("failed to open zip file %s: %v", zipPath, err))
  110. }
  111. defer reader.Close()
  112. for _, file := range reader.File {
  113. err := extractZipFile(file, destDir)
  114. if err != nil {
  115. return cosy.WrapErrorWithParams(ErrExtractArchive, fmt.Sprintf("failed to extract file %s: %v", file.Name, err))
  116. }
  117. }
  118. return nil
  119. }
  120. // extractZipFile extracts a single file from a zip archive
  121. func extractZipFile(file *zip.File, destDir string) error {
  122. // Check for directory traversal elements in the file name
  123. if strings.Contains(file.Name, "..") {
  124. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("file name contains directory traversal: %s", file.Name))
  125. }
  126. // Clean and normalize the file path
  127. cleanName := filepath.Clean(file.Name)
  128. if cleanName == "." || cleanName == ".." {
  129. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid file name after cleaning: %s", file.Name))
  130. }
  131. // Create directory path if needed
  132. filePath := filepath.Join(destDir, cleanName)
  133. // Ensure the resulting file path is within the destination directory
  134. destDirAbs, err := filepath.Abs(destDir)
  135. if err != nil {
  136. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("cannot resolve destination path %s: %v", destDir, err))
  137. }
  138. filePathAbs, err := filepath.Abs(filePath)
  139. if err != nil {
  140. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("cannot resolve file path %s: %v", filePath, err))
  141. }
  142. // Check if the file path is within the destination directory
  143. if !strings.HasPrefix(filePathAbs, destDirAbs+string(os.PathSeparator)) {
  144. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("file path %s is outside destination directory %s", filePathAbs, destDirAbs))
  145. }
  146. if file.FileInfo().IsDir() {
  147. if err := os.MkdirAll(filePath, file.Mode()); err != nil {
  148. return cosy.WrapErrorWithParams(ErrCreateDir, fmt.Sprintf("failed to create directory %s: %v", filePath, err))
  149. }
  150. return nil
  151. }
  152. // Create parent directory if needed
  153. parentDir := filepath.Dir(filePath)
  154. if err := os.MkdirAll(parentDir, 0755); err != nil {
  155. return cosy.WrapErrorWithParams(ErrCreateParentDir, fmt.Sprintf("failed to create parent directory %s: %v", parentDir, err))
  156. }
  157. // Check if this is a symlink by examining mode bits
  158. if file.Mode()&os.ModeSymlink != 0 {
  159. // Open source file in zip to read the link target
  160. srcFile, err := file.Open()
  161. if err != nil {
  162. return cosy.WrapErrorWithParams(ErrOpenZipEntry, fmt.Sprintf("failed to open symlink source %s: %v", file.Name, err))
  163. }
  164. defer srcFile.Close()
  165. // Read the link target
  166. linkTargetBytes, err := io.ReadAll(srcFile)
  167. if err != nil {
  168. return cosy.WrapErrorWithParams(ErrReadSymlink, fmt.Sprintf("failed to read symlink target for %s: %v", file.Name, err))
  169. }
  170. linkTarget := string(linkTargetBytes)
  171. // Clean and normalize the link target
  172. cleanLinkTarget := filepath.Clean(linkTarget)
  173. if cleanLinkTarget == "." || cleanLinkTarget == ".." {
  174. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("invalid symlink target: %s", linkTarget))
  175. }
  176. // Get allowed paths for symlinks
  177. confPath := nginx.GetConfPath()
  178. modulesPath := nginx.GetModulesPath()
  179. // Check if symlink target is to an allowed path (conf path or modules path)
  180. isAllowedSymlink := false
  181. // Check if link points to modules path
  182. if filepath.IsAbs(cleanLinkTarget) && (cleanLinkTarget == modulesPath || strings.HasPrefix(cleanLinkTarget, modulesPath+string(filepath.Separator))) {
  183. isAllowedSymlink = true
  184. }
  185. // Check if link points to nginx conf path
  186. if filepath.IsAbs(cleanLinkTarget) && (cleanLinkTarget == confPath || strings.HasPrefix(cleanLinkTarget, confPath+string(filepath.Separator))) {
  187. isAllowedSymlink = true
  188. }
  189. // Handle absolute paths
  190. if filepath.IsAbs(cleanLinkTarget) {
  191. // Remove any existing file/link at the target path
  192. if err := os.RemoveAll(filePath); err != nil && !os.IsNotExist(err) {
  193. // Ignoring error, continue creating symlink
  194. }
  195. // If this is a symlink to an allowed path, create it
  196. if isAllowedSymlink {
  197. if err := os.Symlink(cleanLinkTarget, filePath); err != nil {
  198. return cosy.WrapErrorWithParams(ErrCreateSymlink, fmt.Sprintf("failed to create symlink %s -> %s: %v", filePath, cleanLinkTarget, err))
  199. }
  200. return nil
  201. }
  202. // Skip symlinks that point to paths outside the allowed directories
  203. logger.Warn("Skipping symlink outside allowed paths during restore",
  204. "path", filePath,
  205. "target", cleanLinkTarget,
  206. "allowedConfPath", confPath,
  207. "allowedModulesPath", modulesPath)
  208. return nil
  209. }
  210. // For relative symlinks, verify they don't escape the destination directory
  211. absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), cleanLinkTarget))
  212. if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) {
  213. // Skip relative symlinks that point outside the destination directory
  214. logger.Warn("Skipping relative symlink pointing outside destination directory during restore",
  215. "path", filePath,
  216. "target", cleanLinkTarget,
  217. "resolvedTarget", absLinkTarget,
  218. "destinationDir", destDirAbs)
  219. return nil
  220. }
  221. // Remove any existing file/link at the target path
  222. if err := os.RemoveAll(filePath); err != nil && !os.IsNotExist(err) {
  223. // Ignoring error, continue creating symlink
  224. }
  225. // Create the symlink for relative paths within destination
  226. if err := os.Symlink(cleanLinkTarget, filePath); err != nil {
  227. return cosy.WrapErrorWithParams(ErrCreateSymlink, fmt.Sprintf("failed to create symlink %s -> %s: %v", filePath, cleanLinkTarget, err))
  228. }
  229. // Verify the resolved symlink path is within destination directory
  230. resolvedPath, err := filepath.EvalSymlinks(filePath)
  231. if err != nil {
  232. // If we can't resolve the symlink, it's not a critical error
  233. // Just continue
  234. return nil
  235. }
  236. resolvedPathAbs, err := filepath.Abs(resolvedPath)
  237. if err != nil {
  238. // Not a critical error, continue
  239. return nil
  240. }
  241. if !strings.HasPrefix(resolvedPathAbs, destDirAbs+string(os.PathSeparator)) {
  242. // Remove the symlink if it points outside the destination directory
  243. _ = os.Remove(filePath)
  244. return cosy.WrapErrorWithParams(ErrInvalidFilePath, fmt.Sprintf("resolved symlink path %s is outside destination directory %s", resolvedPathAbs, destDirAbs))
  245. }
  246. return nil
  247. }
  248. // Create file
  249. destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
  250. if err != nil {
  251. return cosy.WrapErrorWithParams(ErrCreateFile, fmt.Sprintf("failed to create file %s: %v", filePath, err))
  252. }
  253. defer destFile.Close()
  254. // Open source file in zip
  255. srcFile, err := file.Open()
  256. if err != nil {
  257. return cosy.WrapErrorWithParams(ErrOpenZipEntry, fmt.Sprintf("failed to open zip entry %s: %v", file.Name, err))
  258. }
  259. defer srcFile.Close()
  260. // Copy content
  261. if _, err := io.Copy(destFile, srcFile); err != nil {
  262. return cosy.WrapErrorWithParams(ErrCopyContent, fmt.Sprintf("failed to copy content for file %s: %v", file.Name, err))
  263. }
  264. return nil
  265. }
  266. // verifyHashes verifies the hashes of the extracted zip files
  267. func verifyHashes(restoreDir, nginxUIZipPath, nginxZipPath string) (bool, error) {
  268. hashFile := filepath.Join(restoreDir, HashInfoFile)
  269. hashContent, err := os.ReadFile(hashFile)
  270. if err != nil {
  271. return false, cosy.WrapErrorWithParams(ErrReadHashFile, err.Error())
  272. }
  273. hashInfo := parseHashInfo(string(hashContent))
  274. // Calculate hash for nginx-ui.zip
  275. nginxUIHash, err := calculateFileHash(nginxUIZipPath)
  276. if err != nil {
  277. return false, cosy.WrapErrorWithParams(ErrCalculateUIHash, err.Error())
  278. }
  279. // Calculate hash for nginx.zip
  280. nginxHash, err := calculateFileHash(nginxZipPath)
  281. if err != nil {
  282. return false, cosy.WrapErrorWithParams(ErrCalculateNginxHash, err.Error())
  283. }
  284. // Verify hashes
  285. return hashInfo.NginxUIHash == nginxUIHash && hashInfo.NginxHash == nginxHash, nil
  286. }
  287. // parseHashInfo parses hash info from content string
  288. func parseHashInfo(content string) HashInfo {
  289. info := HashInfo{}
  290. lines := strings.Split(content, "\n")
  291. for _, line := range lines {
  292. line = strings.TrimSpace(line)
  293. if line == "" {
  294. continue
  295. }
  296. parts := strings.SplitN(line, ":", 2)
  297. if len(parts) != 2 {
  298. continue
  299. }
  300. key := strings.TrimSpace(parts[0])
  301. value := strings.TrimSpace(parts[1])
  302. switch key {
  303. case "nginx-ui_hash":
  304. info.NginxUIHash = value
  305. case "nginx_hash":
  306. info.NginxHash = value
  307. case "timestamp":
  308. info.Timestamp = value
  309. case "version":
  310. info.Version = value
  311. }
  312. }
  313. return info
  314. }
  315. // restoreNginxConfigs restores nginx configuration files
  316. func restoreNginxConfigs(nginxBackupDir string) error {
  317. destDir := nginx.GetConfPath()
  318. if destDir == "" {
  319. return ErrNginxConfigDirEmpty
  320. }
  321. // Recursively clean destination directory preserving the directory structure
  322. if err := cleanDirectoryPreservingStructure(destDir); err != nil {
  323. return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to clean directory: "+err.Error())
  324. }
  325. // Copy files from backup to nginx config directory
  326. if err := copyDirectory(nginxBackupDir, destDir); err != nil {
  327. return err
  328. }
  329. return nil
  330. }
  331. // cleanDirectoryPreservingStructure removes all files and symlinks in a directory
  332. // but preserves the directory structure itself
  333. func cleanDirectoryPreservingStructure(dir string) error {
  334. entries, err := os.ReadDir(dir)
  335. if err != nil {
  336. return err
  337. }
  338. for _, entry := range entries {
  339. path := filepath.Join(dir, entry.Name())
  340. err = os.RemoveAll(path)
  341. if err != nil {
  342. return err
  343. }
  344. }
  345. return nil
  346. }
  347. // restoreNginxUIConfig restores nginx-ui configuration files
  348. func restoreNginxUIConfig(nginxUIBackupDir string) error {
  349. // Get config directory
  350. configDir := filepath.Dir(cosysettings.ConfPath)
  351. if configDir == "" {
  352. return ErrConfigPathEmpty
  353. }
  354. // Restore app.ini to the configured location
  355. srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
  356. if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
  357. return err
  358. }
  359. // Restore database file if exists
  360. dbName := settings.DatabaseSettings.GetName()
  361. srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db")
  362. destDBPath := filepath.Join(configDir, dbName+".db")
  363. // Only attempt to copy if database file exists in backup
  364. if _, err := os.Stat(srcDBPath); err == nil {
  365. if err := copyFile(srcDBPath, destDBPath); err != nil {
  366. return err
  367. }
  368. }
  369. return nil
  370. }