package config import ( "bytes" "errors" "io" "os" "path/filepath" "runtime" "git.pcux.cn/xuans/Smail/utils/atomicfile" ) // ExportProjectTLS 为本机 TLS 入口导出一个原子更新的证书私钥合并文件。 // 仅接受项目生成的平铺证书;不允许高权限调用者按配置读取任意服务器路径。 func ExportProjectTLS(root, destination string) error { if runtime.GOOS != "linux" { return errors.New("本机 Nginx 证书同步仅支持 Linux") } if !filepath.IsAbs(root) || !filepath.IsAbs(destination) { return ErrUnsafeSSLFile } root, destination = filepath.Clean(root), filepath.Clean(destination) runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load() if err != nil { return ErrInvalidSSL } if EffectiveSSLManagement(runtime.SSL.TLS) != SSLManagementProject || runtime.Root.HTTP.TLSMode == HTTPSTLSModeProxy { return nil } publicPath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.CertificateRef)) privatePath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.PrivateKeyRef)) if !flatSSLPair(root, publicPath, privatePath) { return ErrInvalidSSLReference } sourceDirectory := filepath.Join(root, "ssl") resolvedSource, err := filepath.EvalSymlinks(sourceDirectory) if err != nil || resolvedSource != sourceDirectory { return ErrUnsafeSSLFile } sourceInfo, err := os.Stat(sourceDirectory) if err != nil || !sourceInfo.IsDir() || sourceInfo.Mode().Perm()&0022 != 0 { return ErrUnsafeSSLFile } storage, err := os.OpenRoot(sourceDirectory) if err != nil { return ErrUnsafeSSLFile } defer storage.Close() read := func(name string) ([]byte, error) { info, err := storage.Lstat(name) if err != nil || !info.Mode().IsRegular() || hasMultipleInfoLinks(info) || info.Size() > maxManagedSSLFileSize || info.Mode().Perm()&0077 != 0 { return nil, ErrUnsafeSSLFile } file, err := storage.Open(name) if err != nil { return nil, ErrUnsafeSSLFile } defer file.Close() opened, err := file.Stat() if err != nil || !os.SameFile(info, opened) || !opened.Mode().IsRegular() || hasMultipleInfoLinks(opened) || opened.Mode().Perm()&0077 != 0 { return nil, ErrUnsafeSSLFile } data, err := io.ReadAll(io.LimitReader(file, maxManagedSSLFileSize+1)) if err != nil || len(data) > maxManagedSSLFileSize { return nil, ErrUnsafeSSLFile } after, err := file.Stat() if err != nil || !os.SameFile(opened, after) || hasMultipleInfoLinks(after) || after.Size() != int64(len(data)) || !opened.ModTime().Equal(after.ModTime()) || after.Mode().Perm()&0077 != 0 { return nil, ErrUnsafeSSLFile } return data, nil } certificate, err := read(filepath.Base(publicPath)) if err != nil { return err } privateKey, err := read(filepath.Base(privatePath)) if err != nil { return err } if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil { return err } combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...) // 输出目录由部署管理员创建,拒绝符号链接和其他用户可写目录。 directory := filepath.Dir(destination) resolved, err := filepath.EvalSymlinks(directory) if err != nil || resolved != directory { return ErrUnsafeSSLFile } info, err := os.Stat(directory) if err != nil || !info.IsDir() || info.Mode().Perm()&0022 != 0 { return ErrUnsafeSSLFile } output, err := os.OpenRoot(directory) if err != nil { return ErrUnsafeSSLFile } defer output.Close() name := filepath.Base(destination) if info, err := output.Lstat(name); err == nil { if !info.Mode().IsRegular() || hasMultipleInfoLinks(info) || info.Mode().Perm()&0077 != 0 { return ErrUnsafeSSLFile } previous, err := output.ReadFile(name) if err != nil { return ErrUnsafeSSLFile } if bytes.Equal(previous, combined) { return nil } } else if !os.IsNotExist(err) { return ErrUnsafeSSLFile } return atomicfile.WriteRoot(output, name, combined, 0600) }