mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-10-01 05:13:16 +08:00
A directory holding only certificates deployed "successfully" while nothing reached the node: collection silently dropped every non-.conf file and an empty batch was acknowledged as a success. - Allow certificate, key and htpasswd files (.pem, .crt, .cer, .key, .csr, .crl, .ca-bundle, .htpasswd) outside the site and stream directories. They are checked as plain text and never parsed as Nginx directives. - Report every file left out of a directory sync with its reason (unsupported type, too large, not text, unreadable, entry config), and do not push an empty batch. - The batch receiver now names the files it kept because they already exist and overwrite is off; the result carries them per node. - Tell the user about skipped and kept files instead of a plain success. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
4.4 KiB
Go
160 lines
4.4 KiB
Go
package clustersync
|
|
|
|
import (
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/0xJacky/Nginx-UI/internal/config"
|
|
"github.com/0xJacky/Nginx-UI/internal/helper"
|
|
"github.com/0xJacky/Nginx-UI/internal/nginx"
|
|
"github.com/uozi-tech/cosy/logger"
|
|
)
|
|
|
|
// maxSyncFileSize keeps binaries such as GeoIP databases out of a config sync.
|
|
const maxSyncFileSize = 5 << 20
|
|
|
|
// ConfigFile is a configuration file payload addressed relative to the Nginx
|
|
// configuration root.
|
|
type ConfigFile struct {
|
|
BaseDir string `json:"base_dir"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// RelativePath returns the file location relative to the Nginx config root.
|
|
func (f ConfigFile) RelativePath() string {
|
|
if f.BaseDir == "" {
|
|
return f.Name
|
|
}
|
|
return strings.TrimPrefix(filepath.ToSlash(filepath.Join(f.BaseDir, f.Name)), "/")
|
|
}
|
|
|
|
// CollectConfigFiles walks a directory below the Nginx configuration root and
|
|
// returns every replicable file it contains.
|
|
//
|
|
// Sites and streams are excluded on purpose: they are replicated as sites and
|
|
// streams so their enabled state travels with the content instead of being
|
|
// recreated as a plain file in sites-available.
|
|
func CollectConfigFiles(root string) ([]ConfigFile, error) {
|
|
files, _, err := CollectConfigFilesReport(root)
|
|
|
|
return files, err
|
|
}
|
|
|
|
// CollectConfigFilesReport is CollectConfigFiles that also reports every file it
|
|
// left out and why, so the user can be told what was not replicated.
|
|
func CollectConfigFilesReport(root string) ([]ConfigFile, []SkippedFile, error) {
|
|
confPath := filepath.Clean(nginx.GetConfPath())
|
|
root = filepath.Clean(root)
|
|
|
|
if !helper.IsUnderDirectory(root, confPath) && root != confPath {
|
|
return nil, nil, ErrPathOutsideConfDir
|
|
}
|
|
|
|
var files []ConfigFile
|
|
var skipped []SkippedFile
|
|
skip := func(path string, reason SkipReason) {
|
|
relative, err := filepath.Rel(confPath, path)
|
|
if err != nil {
|
|
relative = path
|
|
}
|
|
skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(relative), Reason: reason})
|
|
}
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// An unreadable entry must not abort the whole directory.
|
|
logger.Debugf("cluster sync skips unreadable entry %s: %v", path, err)
|
|
return nil
|
|
}
|
|
|
|
if entry.IsDir() {
|
|
if isManagedDir(confPath, path) {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if !entry.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
// The entry configuration is node specific: it names the pid file, the
|
|
// worker user and the include roots of that host. Replicating it in bulk
|
|
// would break a node whose layout differs. It can still be deployed
|
|
// deliberately from the configuration editor.
|
|
if filepath.Clean(path) == filepath.Clean(nginx.GetConfEntryPath()) {
|
|
skip(path, SkipEntryConfig)
|
|
return nil
|
|
}
|
|
|
|
// Check the name first so an unsupported file is never read.
|
|
if err := config.ValidateConfigFilename(path); err != nil {
|
|
logger.Debugf("cluster sync skips unsupported config name %s: %v", path, err)
|
|
skip(path, SkipUnsupportedType)
|
|
return nil
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
logger.Debugf("cluster sync skips unreadable file %s: %v", path, err)
|
|
skip(path, SkipUnreadable)
|
|
return nil
|
|
}
|
|
if info.Size() > maxSyncFileSize {
|
|
logger.Debugf("cluster sync skips oversized file %s", path)
|
|
skip(path, SkipTooLarge)
|
|
return nil
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
logger.Debugf("cluster sync skips unreadable file %s: %v", path, err)
|
|
skip(path, SkipUnreadable)
|
|
return nil
|
|
}
|
|
|
|
if !utf8.Valid(content) {
|
|
logger.Debugf("cluster sync skips non-text file %s", path)
|
|
skip(path, SkipNotText)
|
|
return nil
|
|
}
|
|
|
|
relativeDir, err := filepath.Rel(confPath, filepath.Dir(path))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if relativeDir == "." {
|
|
relativeDir = ""
|
|
}
|
|
|
|
files = append(files, ConfigFile{
|
|
BaseDir: filepath.ToSlash(relativeDir),
|
|
Name: entry.Name(),
|
|
Content: string(content),
|
|
})
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return files, skipped, nil
|
|
}
|
|
|
|
// isManagedDir reports whether a directory below the config root is owned by the
|
|
// site or stream synchronization instead of the plain config synchronization.
|
|
func isManagedDir(confPath, path string) bool {
|
|
relative, err := filepath.Rel(confPath, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
top := strings.SplitN(filepath.ToSlash(relative), "/", 2)[0]
|
|
|
|
return strings.HasPrefix(top, "sites-") || strings.HasPrefix(top, "streams-")
|
|
}
|