Replace active S3 sync with encrypted WebDAV synchronization and independent backups. Support explicit empty passwords and unauthenticated MongoDB, Redis, HTTP and HTTPS targets. Include migration, design and validation documentation. Validation: build and go vet passed; Playwright 113 passed with 4 existing failures. Full Go run reproduced the existing cluster discovery test failure. Preserve unrelated SSH fingerprint changes outside this commit.
144 lines
5.5 KiB
Go
144 lines
5.5 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type WebDAVConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Prefix string `json:"prefix"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func validDAVPath(path string) bool {
|
|
if strings.ContainsAny(path, "\\\r\n\x00") {
|
|
return false
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == "." || segment == ".." {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (config WebDAVConfig) Validate() error {
|
|
endpoint, err := url.Parse(config.Endpoint)
|
|
if err != nil || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || len(config.Endpoint) > 2048 || !validDAVPath(endpoint.Path) {
|
|
return errors.New("WebDAV 地址必须是有效的 HTTP(S) 目录地址,不含账号、查询参数或片段")
|
|
}
|
|
if len(config.Prefix) > 512 || strings.HasPrefix(config.Prefix, "/") || strings.Contains(config.Prefix, "//") || !validDAVPath(config.Prefix) {
|
|
return errors.New("WebDAV 目录必须是最多 512 字节的相对路径,不能包含 .. 或反斜杠")
|
|
}
|
|
if len(config.Username) > 256 || strings.ContainsAny(config.Username, ":\r\n\x00") || (config.Username != "" && strings.TrimSpace(config.Username) == "") || len(config.Password) > 4096 || (config.Username == "" && config.Password != "") {
|
|
return errors.New("WebDAV 用户名或密码无效;匿名访问时两者均应为空")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func davRequest(ctx context.Context, config WebDAVConfig, method, key string, payload []byte) (*http.Request, error) {
|
|
endpoint, err := url.Parse(config.Endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + key
|
|
endpoint.RawPath = ""
|
|
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if config.Username != "" {
|
|
request.SetBasicAuth(config.Username, config.Password)
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func strongDAVTag(tag string) bool {
|
|
return len(tag) >= 2 && len(tag) <= 1024 && tag[0] == '"' && tag[len(tag)-1] == '"' && !strings.ContainsAny(tag[1:len(tag)-1], "\"\r\n\x00")
|
|
}
|
|
|
|
func davCollections(ctx context.Context, client *http.Client, config WebDAVConfig, key string) error {
|
|
parts := strings.Split(key, "/")
|
|
for index := 1; index < len(parts); index++ {
|
|
request, err := davRequest(ctx, config, "MKCOL", strings.Join(parts[:index], "/")+"/", nil)
|
|
if err != nil {
|
|
return errors.New("无法创建 WebDAV 目录请求")
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return errors.New("WebDAV 目录创建失败,请检查网络、地址和 TLS 证书")
|
|
}
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
|
response.Body.Close()
|
|
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed {
|
|
return fmt.Errorf("WebDAV 创建目录返回 HTTP %d,请检查基础目录及 MKCOL 权限", response.StatusCode)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func WebDAVExchange(ctx context.Context, config WebDAVConfig, method, key string, payload []byte, etag string) ([]byte, string, error) {
|
|
if err := config.Validate(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if (method != http.MethodGet && method != http.MethodPut) || key == "" || strings.HasPrefix(key, "/") || strings.HasSuffix(key, "/") || strings.Contains(key, "//") || !validDAVPath(key) || len(key) > 2048 || len(payload) > 24<<20 {
|
|
return nil, "", errors.New("无效的 WebDAV 文件请求或文件超过 24 MiB")
|
|
}
|
|
if etag != "" && !strongDAVTag(etag) {
|
|
return nil, "", errors.New("WebDAV 服务未提供有效的强 ETag,无法安全同步")
|
|
}
|
|
client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
if method == http.MethodPut {
|
|
if err := davCollections(ctx, client, config, key); err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
request, err := davRequest(ctx, config, method, key, payload)
|
|
if err != nil {
|
|
return nil, "", errors.New("无法创建 WebDAV 文件请求")
|
|
}
|
|
if method == http.MethodPut {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if etag == "" {
|
|
request.Header.Set("If-None-Match", "*")
|
|
} else {
|
|
request.Header.Set("If-Match", etag)
|
|
}
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, "", errors.New("WebDAV 请求失败,请检查网络、目录地址和 TLS 证书;写入超时后请重新同步确认结果")
|
|
}
|
|
defer response.Body.Close()
|
|
if method == http.MethodGet && response.StatusCode == http.StatusNotFound {
|
|
return nil, "", ErrNotFound
|
|
}
|
|
if response.StatusCode == http.StatusPreconditionFailed {
|
|
return nil, "", ErrObjectExists
|
|
}
|
|
if (method == http.MethodGet && response.StatusCode != http.StatusOK) || (method == http.MethodPut && response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusNoContent) {
|
|
return nil, "", fmt.Errorf("WebDAV 返回 HTTP %d,请检查账号、目录及读写权限", response.StatusCode)
|
|
}
|
|
tag := response.Header.Get("ETag")
|
|
if method == http.MethodPut {
|
|
return nil, tag, nil
|
|
}
|
|
if !strongDAVTag(tag) {
|
|
return nil, "", errors.New("WebDAV 服务未提供有效的强 ETag,无法安全同步")
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, (24<<20)+1))
|
|
if err != nil || len(raw) > 24<<20 {
|
|
return nil, "", errors.New("WebDAV 文件读取失败或超过 24 MiB")
|
|
}
|
|
return raw, tag, nil
|
|
}
|