diff --git a/deploy/install.sh b/deploy/install.sh index 99ff81c..d451ae7 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -59,8 +59,11 @@ cp -R "$WEB_SOURCE/." "$PREFIX/web/" chown -R "$APP_USER:$APP_GROUP" "$PREFIX" if [ ! -f "$ETC_DIR/config.yaml" ]; then - install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" + install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" fi +# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置, +# 导致以 backupx 身份运行的服务因无权读取配置而启动失败(exit 1)。 +chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml" if [ -f "$SERVICE_SOURCE" ]; then install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service" @@ -105,6 +108,14 @@ cat <:8340 + +(如已安装 nginx,脚本会自动写入反向代理配置,可继续用 80 端口访问。) + +排查:若服务未监听端口,请查看日志: + journalctl -u "$SERVICE_NAME" -n 50 --no-pager + 如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行: systemctl restart "$SERVICE_NAME" MESSAGE diff --git a/server/config.example.yaml b/server/config.example.yaml index 181482b..351b378 100644 --- a/server/config.example.yaml +++ b/server/config.example.yaml @@ -4,6 +4,8 @@ server: port: 8340 mode: "release" # debug | release external_url: "" # 可选:Master 对 Agent 可达的 URL,例如 https://backup.example.com + web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。 + # 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。 database: path: "./data/backupx.db" # SQLite 数据库路径 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index b5edb4c..05fcb85 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -21,6 +21,10 @@ type ServerConfig struct { Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` ExternalURL string `mapstructure:"external_url"` + // WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测 + // (./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管 + // 前端 SPA,无需额外的 nginx 反向代理即可访问 Web 控制台。 + WebRoot string `mapstructure:"web_root"` } type DatabaseConfig struct { @@ -138,6 +142,7 @@ func applyDefaults(v *viper.Viper) { v.SetDefault("server.port", 8340) v.SetDefault("server.mode", "release") v.SetDefault("server.external_url", "") + v.SetDefault("server.web_root", "") v.SetDefault("database.path", "./data/backupx.db") v.SetDefault("security.jwt_expire", "24h") v.SetDefault("backup.temp_dir", "/tmp/backupx") diff --git a/server/internal/http/backup_record_handler.go b/server/internal/http/backup_record_handler.go index 4230559..4d9b733 100644 --- a/server/internal/http/backup_record_handler.go +++ b/server/internal/http/backup_record_handler.go @@ -216,7 +216,7 @@ func writeSSEEvent(writer io.Writer, event backup.LogEvent) error { } func parseUintString(value string) (uint, bool) { - parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0) if err != nil { return 0, false } diff --git a/server/internal/http/router.go b/server/internal/http/router.go index f1ca3a2..62d5a64 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -353,9 +353,25 @@ func NewRouter(deps RouterDependencies) *gin.Engine { engine.GET("/api/install/:token/compose.yml", installHandler.Compose) } - engine.NoRoute(func(c *gin.Context) { + // 未匹配路由处理: + // - 找到前端产物目录时,托管 SPA(静态文件 + index.html 回退), + // 使后端在无 nginx 反向代理时也能直接提供 Web 控制台(issue #62); + // - 未找到前端目录时退化为纯 API 服务,统一返回结构化 JSON 404。 + apiNotFound := func(c *gin.Context) { response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found"))) - }) + } + if webRoot := resolveWebRoot(deps.Config.Server.WebRoot); webRoot != "" { + if deps.Logger != nil { + deps.Logger.Info("serving web frontend", zap.String("web_root", webRoot)) + } + engine.NoRoute(spaFileServer(webRoot, apiNotFound)) + } else { + if deps.Logger != nil { + deps.Logger.Warn("web frontend directory not found; serving API only", + zap.String("hint", "set server.web_root in config, or place the built frontend at ./web")) + } + engine.NoRoute(apiNotFound) + } return engine } diff --git a/server/internal/http/spa.go b/server/internal/http/spa.go new file mode 100644 index 0000000..f457bb5 --- /dev/null +++ b/server/internal/http/spa.go @@ -0,0 +1,86 @@ +package http + +import ( + stdhttp "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// resolveWebRoot 返回前端静态资源目录。优先使用显式配置的路径, +// 否则按部署惯例依次探测常见位置,返回首个包含 index.html 的目录。 +// 返回空字符串表示未找到前端产物,此时后端退化为纯 API 服务。 +func resolveWebRoot(configured string) string { + candidates := []string{ + configured, + "./web/dist", // 源码树根目录构建产物(优先于 ./web,避免命中前端源码模板) + "./web", // systemd:WorkingDirectory=/opt/backupx → /opt/backupx/web;容器 WORKDIR=/app → /app/web + "../web/dist", // 从 server/ 目录运行(make dev-server) + "/opt/backupx/web", + "/app/web", + } + for _, dir := range candidates { + if strings.TrimSpace(dir) == "" { + continue + } + if hasIndexHTML(dir) { + if abs, err := filepath.Abs(dir); err == nil { + return abs + } + return dir + } + } + return "" +} + +func hasIndexHTML(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "index.html")) + return err == nil && !info.IsDir() +} + +// isReservedBackendPath 判断请求是否命中后端保留前缀(API、探针、安装脚本)。 +// 这些路径即使未匹配到具体路由,也应返回结构化 JSON 404,而不是回退到 +// 前端 index.html —— 否则反向代理/安装脚本会把 HTML 当成接口响应(参考 issue #46)。 +func isReservedBackendPath(p string) bool { + switch p { + case "/health", "/ready", "/metrics", "/api", "/install": + return true + } + return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/install/") +} + +// spaFileServer 构造 SPA 静态资源处理器,用作 gin 的 NoRoute 回退: +// - 后端保留前缀返回 apiNotFound(JSON 404); +// - 其余 GET/HEAD 请求若在 webRoot 内命中真实文件则直接返回该文件; +// - 未命中文件的路径回退到 index.html,交由前端路由处理(history 模式刷新)。 +func spaFileServer(webRoot string, apiNotFound gin.HandlerFunc) gin.HandlerFunc { + indexPath := filepath.Join(webRoot, "index.html") + return func(c *gin.Context) { + reqPath := c.Request.URL.Path + if isReservedBackendPath(reqPath) { + apiNotFound(c) + return + } + if c.Request.Method != stdhttp.MethodGet && c.Request.Method != stdhttp.MethodHead { + apiNotFound(c) + return + } + + // 防目录穿越:以 webRoot 为根清理路径,确保最终目标仍位于 webRoot 内。 + clean := filepath.Clean("/" + strings.TrimPrefix(reqPath, "/")) + target := filepath.Join(webRoot, clean) + if rel, err := filepath.Rel(webRoot, target); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + apiNotFound(c) + return + } + + if info, err := os.Stat(target); err == nil && !info.IsDir() { + c.File(target) + return + } + // 前端 SPA 路由(/dashboard、/tasks 等)回退到 index.html。 + c.File(indexPath) + } +} diff --git a/server/internal/http/spa_test.go b/server/internal/http/spa_test.go new file mode 100644 index 0000000..3372240 --- /dev/null +++ b/server/internal/http/spa_test.go @@ -0,0 +1,175 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestResolveWebRoot(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("explicit configured dir with index.html", func(t *testing.T) { + dir := t.TempDir() + writeIndex(t, dir) + got := resolveWebRoot(dir) + abs, _ := filepath.Abs(dir) + if got != abs { + t.Fatalf("resolveWebRoot(%q) = %q, want %q", dir, got, abs) + } + }) + + t.Run("configured dir without index.html falls through to none", func(t *testing.T) { + dir := t.TempDir() // no index.html, and no conventional ./web in CWD during test + if got := resolveWebRoot(dir); got != "" { + // 允许 CWD 恰好存在约定目录的环境,但临时目录本身不应被选中。 + abs, _ := filepath.Abs(dir) + if got == abs { + t.Fatalf("expected dir without index.html to be skipped, got %q", got) + } + } + }) + + t.Run("empty configured uses auto-detect order", func(t *testing.T) { + // 切到一个仅含 ./web/dist/index.html 的临时工作目录,验证自动探测。 + root := t.TempDir() + distDir := filepath.Join(root, "web", "dist") + if err := os.MkdirAll(distDir, 0o755); err != nil { + t.Fatal(err) + } + writeIndex(t, distDir) + + restore := chdir(t, root) + defer restore() + + // 以 chdir 之后的实际工作目录为基准计算期望值,避免 macOS 上 + // /var → /private/var 符号链接导致字符串不一致。 + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(wd, "web", "dist") + + got := resolveWebRoot("") + if got != want { + t.Fatalf("auto-detect = %q, want %q", got, want) + } + }) +} + +func TestIsReservedBackendPath(t *testing.T) { + reserved := []string{"/health", "/ready", "/metrics", "/api", "/install", "/api/", "/api/system/info", "/install/abc", "/install/abc/compose.yml"} + for _, p := range reserved { + if !isReservedBackendPath(p) { + t.Errorf("isReservedBackendPath(%q) = false, want true", p) + } + } + notReserved := []string{"/", "/dashboard", "/assets/app.js", "/installer", "/apidocs", "/favicon.ico"} + for _, p := range notReserved { + if isReservedBackendPath(p) { + t.Errorf("isReservedBackendPath(%q) = true, want false", p) + } + } +} + +func TestSpaFileServer(t *testing.T) { + gin.SetMode(gin.TestMode) + webRoot := t.TempDir() + writeIndexContent(t, webRoot, "BackupX") + assetsDir := filepath.Join(webRoot, "assets") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log(1)"), 0o644); err != nil { + t.Fatal(err) + } + + apiNotFoundHit := false + apiNotFound := func(c *gin.Context) { + apiNotFoundHit = true + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND"}) + } + + engine := gin.New() + engine.NoRoute(spaFileServer(webRoot, apiNotFound)) + + cases := []struct { + name string + method string + path string + wantStatus int + wantBody string // 子串;为空表示不校验 + wantAPI404 bool + }{ + {name: "root serves index", method: http.MethodGet, path: "/", wantStatus: 200, wantBody: "BackupX"}, + {name: "spa route falls back to index", method: http.MethodGet, path: "/dashboard", wantStatus: 200, wantBody: "BackupX"}, + {name: "real asset served", method: http.MethodGet, path: "/assets/app.js", wantStatus: 200, wantBody: "console.log"}, + {name: "api path returns json 404", method: http.MethodGet, path: "/api/garbage", wantStatus: 404, wantAPI404: true}, + {name: "health returns json 404 via reserved", method: http.MethodGet, path: "/health", wantStatus: 404, wantAPI404: true}, + {name: "non-GET on spa path is api 404", method: http.MethodPost, path: "/dashboard", wantStatus: 404, wantAPI404: true}, + {name: "directory falls back to index", method: http.MethodGet, path: "/assets/", wantStatus: 200, wantBody: "BackupX"}, + // 含 ".." 的请求路径被 net/http 在文件服务层直接拒绝(400 invalid URL path), + // 绝不会泄露 webRoot 之外的文件;这是在 filepath.Rel 校验之上的纵深防御。 + {name: "traversal rejected, never serves passwd", method: http.MethodGet, path: "/../../etc/passwd", wantStatus: 400, wantBody: "invalid URL path"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + apiNotFoundHit = false + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantBody != "" && !contains(rec.Body.String(), tc.wantBody) { + t.Fatalf("body %q does not contain %q", rec.Body.String(), tc.wantBody) + } + if tc.wantAPI404 != apiNotFoundHit { + t.Fatalf("apiNotFoundHit = %v, want %v", apiNotFoundHit, tc.wantAPI404) + } + }) + } +} + +func writeIndex(t *testing.T, dir string) { + t.Helper() + writeIndexContent(t, dir, "BackupX") +} + +func writeIndexContent(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func chdir(t *testing.T, dir string) func() { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + return func() { _ = os.Chdir(orig) } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/server/internal/http/storage_target_handler.go b/server/internal/http/storage_target_handler.go index 1a9c99a..be89001 100644 --- a/server/internal/http/storage_target_handler.go +++ b/server/internal/http/storage_target_handler.go @@ -199,7 +199,7 @@ func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) { func parseUintParam(c *gin.Context, key string) (uint, bool) { value := strings.TrimSpace(c.Param(key)) - parsed, err := strconv.ParseUint(value, 10, 64) + parsed, err := strconv.ParseUint(value, 10, 0) if err != nil { response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err)) return 0, false diff --git a/server/internal/notify/email.go b/server/internal/notify/email.go index eb38a4f..8258efa 100644 --- a/server/internal/notify/email.go +++ b/server/internal/notify/email.go @@ -37,13 +37,12 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M from := strings.TrimSpace(asString(config["from"])) toList := splitCommaValues(asString(config["to"])) address := host + ":" + strconv.Itoa(port) - headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body} var auth smtp.Auth if username != "" { auth = smtp.PlainAuth("", username, password, host) } - rawMessage := []byte(strings.Join(headers, "\r\n")) + rawMessage := buildRawMessage(from, toList, message) if port == 465 { tlsConfig := &tls.Config{ServerName: host} @@ -86,3 +85,31 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M return smtp.SendMail(address, auth, from, toList, rawMessage) } + +// buildRawMessage 构造 RFC 5322 邮件原文。所有头部值都会剔除 CR/LF, +// 防止 SMTP 头注入:备份任务名等用户可控内容会进入 Subject,若包含 +// 换行符可被用来注入额外头部(如 Bcc)或伪造正文。正文本身不做处理, +// 允许包含换行。 +func buildRawMessage(from string, toList []string, message Message) []byte { + sanitizedTo := make([]string, 0, len(toList)) + for _, addr := range toList { + if s := sanitizeHeaderValue(addr); s != "" { + sanitizedTo = append(sanitizedTo, s) + } + } + headers := []string{ + "From: " + sanitizeHeaderValue(from), + "To: " + strings.Join(sanitizedTo, ", "), + "Subject: " + sanitizeHeaderValue(message.Title), + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + message.Body, + } + return []byte(strings.Join(headers, "\r\n")) +} + +// sanitizeHeaderValue 移除头部值中的 CR 与 LF,消除头注入向量。 +func sanitizeHeaderValue(value string) string { + return strings.NewReplacer("\r", "", "\n", "").Replace(strings.TrimSpace(value)) +} diff --git a/server/internal/notify/email_test.go b/server/internal/notify/email_test.go new file mode 100644 index 0000000..66117fd --- /dev/null +++ b/server/internal/notify/email_test.go @@ -0,0 +1,55 @@ +package notify + +import ( + "strings" + "testing" +) + +// TestBuildRawMessageStripsHeaderInjection 验证用户可控内容(如备份任务名 +// 进入 Subject)中的 CR/LF 被剔除,无法注入额外头部或伪造正文。 +func TestBuildRawMessageStripsHeaderInjection(t *testing.T) { + msg := Message{ + Title: "备份失败\r\nBcc: attacker@evil.com\r\n\r\n伪造正文", + Body: "正文第一行\n正文第二行", + } + raw := string(buildRawMessage("sender@example.com", []string{"ops@example.com"}, msg)) + + parts := strings.SplitN(raw, "\r\n\r\n", 2) + if len(parts) != 2 { + t.Fatalf("缺少头部/正文分隔符,原文=%q", raw) + } + headerBlock, body := parts[0], parts[1] + + // 头部区不得出现独立的注入头行。 + for _, line := range strings.Split(headerBlock, "\r\n") { + if strings.HasPrefix(line, "Bcc:") { + t.Fatalf("检测到头注入:出现独立 Bcc 头行 %q", line) + } + } + // 头部区应恰好是固定的 5 行(From/To/Subject/MIME-Version/Content-Type)。 + if got := len(strings.Split(headerBlock, "\r\n")); got != 5 { + t.Fatalf("头部行数=%d,期望 5;headerBlock=%q", got, headerBlock) + } + // 正文必须保持原样(正文中的 \n 合法,不应被处理)。 + if body != "正文第一行\n正文第二行" { + t.Fatalf("正文被篡改:%q", body) + } + // Subject 行必须包含原始标题文本(CRLF 被移除后拼接在同一行)。 + if !strings.Contains(headerBlock, "Subject: 备份失败Bcc: attacker@evil.com伪造正文") { + t.Fatalf("Subject 行不符合预期:%q", headerBlock) + } +} + +func TestSanitizeHeaderValue(t *testing.T) { + cases := map[string]string{ + " normal ": "normal", + "a\r\nb": "ab", + "x\ny\rz": "xyz", + "no-control-chars": "no-control-chars", + } + for in, want := range cases { + if got := sanitizeHeaderValue(in); got != want { + t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/server/internal/notify/webhook.go b/server/internal/notify/webhook.go index 46d8c13..11c7d11 100644 --- a/server/internal/notify/webhook.go +++ b/server/internal/notify/webhook.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" ) @@ -21,9 +22,18 @@ func (n *WebhookNotifier) Type() string { return "webhook" } func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} } func (n *WebhookNotifier) Validate(config map[string]any) error { - if strings.TrimSpace(asString(config["url"])) == "" { + raw := strings.TrimSpace(asString(config["url"])) + if raw == "" { return fmt.Errorf("webhook url is required") } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("webhook url is invalid: %w", err) + } + // 仅允许 http/https,杜绝 file://、gopher:// 等可被用于 SSRF 的协议。 + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("webhook url must use http or https scheme") + } return nil } diff --git a/server/internal/service/auth_service.go b/server/internal/service/auth_service.go index 0b3ce63..1db4cc1 100644 --- a/server/internal/service/auth_service.go +++ b/server/internal/service/auth_service.go @@ -293,7 +293,7 @@ func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, inpu } func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) { - userID, err := strconv.ParseUint(subject, 10, 64) + userID, err := strconv.ParseUint(subject, 10, 0) if err != nil { return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err) }