restore.go 14 KB

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