Files
BackupX/server/internal/http/router.go
Wu Qing 1a699da8d6 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败 (#48)
* 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败

根因(多因素,任何一个都可能导致用户复现的 "sh: 2: Syntax error: newline unexpected"):
- Debian/Ubuntu 默认 /bin/sh → dash;pipe 方式下 shebang 被忽略
- Content-Type: text/x-shellscript 会触发部分 CDN/反向代理的脚本识别与改写
- 如果响应被改写为 HTML,sh 在第 2 行(<html>)即报此语法错误

修复:
1. 前端命令改为 `curl -fsSL URL | sudo bash`(避开 dash)
2. 命令面板增加"先下载再执行"备用命令(代理过滤场景兜底)
3. install handler Content-Type 改为 text/plain;加 nosniff / no-store /
   Content-Disposition 三头,减少中间层改写的概率
4. 脚本模板加 magic marker `BACKUPX_AGENT_INSTALL_V1`,用户可通过
   `head -3` 自查响应完整性;加 bash 自举段,文件执行时优先切到 bash

测试:
- installscript/issue46_test.go 断言 magic + bash-bootstrap 存在于三种模式
- install_flow_test.go 断言新 headers 与 marker
- go test ./... 全绿,前端 build 通过

* 修复: #46 用户截图证实 nginx SPA fallback 返回 index.html

用户反馈截图显示 curl 下载到的是 BackupX 前端 HTML,而非 shell 脚本——
说明 /install/:token 未被反向代理转发到后端,nginx 按 try_files fallback
到 /index.html,sh 读第 2 行 <html> 报语法错误。

真正的根因修复:
1. 后端 install 端点额外暴露 /api/install/:token 别名,让反向代理
   已有的 /api/ 转发规则自动接管
2. 节点创建时返回的 url/composeUrl 统一使用 /api/install/ 前缀
3. 更新 deploy/nginx.conf 模板:
   - 新增 location /install/ 转发(兼容旧版本生成的命令)
   - 新增 /health /ready /metrics 单独转发,避免 SPA fallback

测试:
- install_flow_test.go 新增 TestInstallScriptAliasUnderAPI 断言
  /api/install/:token 路径可用 + 新生成的 url 用 /api/install/ 前缀
2026-04-20 23:35:39 +08:00

360 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"context"
"errors"
stdhttp "net/http"
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/metrics"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type RouterDependencies struct {
// Context 控制 handler 启动的后台协程(如 ipLimiter GC的生命周期。
// app 应传入随进程退出可取消的 ctx若为 nil 则退化为 context.Background()。
Context context.Context
Config config.Config
Version string
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
StorageTargetService *service.StorageTargetService
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
RestoreService *service.RestoreService
VerificationService *service.VerificationService
ReplicationService *service.ReplicationService
TaskTemplateService *service.TaskTemplateService
TaskExportService *service.TaskExportService
SearchService *service.SearchService
EventBroadcaster *service.EventBroadcaster
UserService *service.UserService
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
NodeService *service.NodeService
AgentService *service.AgentService
DatabaseDiscoveryService *service.DatabaseDiscoveryService
AuditService *service.AuditService
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
InstallTokenService *service.InstallTokenService
MasterExternalURL string
// DB 注入给健康检查端点做 liveness/readiness 探测。
DB *gorm.DB
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
Metrics *metrics.Metrics
}
func NewRouter(deps RouterDependencies) *gin.Engine {
gin.SetMode(deps.Config.Server.Mode)
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(CORSMiddleware())
engine.Use(requestLogger(deps.Logger))
authHandler := NewAuthHandler(deps.AuthService)
systemHandler := NewSystemHandler(deps.SystemService)
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.RestoreService, deps.AuditService)
restoreRecordHandler := NewRestoreRecordHandler(deps.RestoreService, deps.AuditService)
verificationHandler := NewVerificationHandler(deps.VerificationService, deps.AuditService)
replicationHandler := NewReplicationHandler(deps.ReplicationService, deps.AuditService)
taskTemplateHandler := NewTaskTemplateHandler(deps.TaskTemplateService, deps.AuditService)
userHandler := NewUserHandler(deps.UserService, deps.AuditService)
apiKeyHandler := NewApiKeyHandler(deps.ApiKeyService, deps.AuditService)
// apiKeyAuth给 AuthMiddleware 注入 API Key 验证能力。
// 为 nil 时中间件仅支持 JWT不影响向后兼容。
var apiKeyAuth ApiKeyAuthenticator
if deps.ApiKeyService != nil {
apiKeyAuth = deps.ApiKeyService
}
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
auditHandler := NewAuditHandler(deps.AuditService)
api := engine.Group("/api")
{
auth := api.Group("/auth")
{
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
}
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
system.GET("/info", systemHandler.Info)
system.GET("/update-check", systemHandler.CheckUpdate)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
storageTargets.GET("", storageTargetHandler.List)
storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
rcloneHandler := NewRcloneHandler()
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
// 参数路由
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/tags", backupTaskHandler.ListTags)
backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", RequireNotViewer(), backupRunHandler.Run)
backupTasks.POST("/batch/toggle", RequireNotViewer(), backupTaskHandler.BatchToggle)
backupTasks.POST("/batch/delete", RequireNotViewer(), backupTaskHandler.BatchDelete)
backupTasks.POST("/batch/run", RequireNotViewer(), backupRunHandler.BatchRun)
// 任务配置导入/导出(集群迁移 & 灾备)
if deps.TaskExportService != nil {
taskExportHandler := NewTaskExportHandler(deps.TaskExportService, deps.AuditService)
backupTasks.GET("/export", taskExportHandler.Export)
backupTasks.POST("/import", RequireNotViewer(), taskExportHandler.Import)
}
if deps.VerificationService != nil {
backupTasks.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByTask)
}
backupRecords := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点
if deps.RestoreService != nil {
restoreRecords := api.Group("/restore/records")
restoreRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
restoreRecords.GET("", restoreRecordHandler.List)
restoreRecords.GET("/:id", restoreRecordHandler.Get)
restoreRecords.GET("/:id/logs/stream", restoreRecordHandler.StreamLogs)
}
// 备份复制记录3-2-1 规则)
if deps.ReplicationService != nil {
replicationRecords := api.Group("/replication/records")
replicationRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
replicationRecords.GET("", replicationHandler.List)
replicationRecords.GET("/:id", replicationHandler.Get)
backupRecords.POST("/:id/replicate", RequireNotViewer(), replicationHandler.TriggerByRecord)
}
// 任务模板(批量创建)
if deps.TaskTemplateService != nil {
templates := api.Group("/task-templates")
templates.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
templates.GET("", taskTemplateHandler.List)
templates.GET("/:id", taskTemplateHandler.Get)
templates.POST("", RequireNotViewer(), taskTemplateHandler.Create)
templates.PUT("/:id", RequireNotViewer(), taskTemplateHandler.Update)
templates.DELETE("/:id", RequireNotViewer(), taskTemplateHandler.Delete)
templates.POST("/:id/apply", RequireNotViewer(), taskTemplateHandler.Apply)
}
// 备份验证/演练记录
if deps.VerificationService != nil {
verifyRecords := api.Group("/verify/records")
verifyRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
verifyRecords.GET("", verificationHandler.List)
verifyRecords.GET("/:id", verificationHandler.Get)
verifyRecords.GET("/:id/logs/stream", verificationHandler.StreamLogs)
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline)
dashboard.GET("/sla", dashboardHandler.SLA)
dashboard.GET("/cluster", dashboardHandler.Cluster)
dashboard.GET("/breakdown", dashboardHandler.Breakdown)
dashboard.GET("/node-performance", dashboardHandler.NodePerformance)
notifications := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", RequireNotViewer(), notificationHandler.Create)
notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
settings.GET("", settingsHandler.Get)
settings.PUT("", RequireRole("admin"), settingsHandler.Update)
// 用户管理admin 专属)
if deps.UserService != nil {
users := api.Group("/users")
users.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
}
// API Key 管理admin 专属)
if deps.ApiKeyService != nil {
apiKeys := api.Group("/api-keys")
apiKeys.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
apiKeys.GET("", apiKeyHandler.List)
apiKeys.POST("", apiKeyHandler.Create)
apiKeys.PUT("/:id/toggle", apiKeyHandler.Toggle)
apiKeys.DELETE("/:id", apiKeyHandler.Revoke)
}
auditLogs := api.Group("/audit-logs")
auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
auditLogs.GET("", auditHandler.List)
auditLogs.GET("/export", auditHandler.Export)
// 实时事件 SSE 流Dashboard 自刷新、桌面告警)
if deps.EventBroadcaster != nil {
eventsHandler := NewEventsHandler(deps.EventBroadcaster)
events := api.Group("/events")
events.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
events.GET("/stream", eventsHandler.Stream)
}
// 全局搜索
if deps.SearchService != nil {
searchHandler := NewSearchHandler(deps.SearchService)
searchGroup := api.Group("/search")
searchGroup.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
searchGroup.GET("", searchHandler.Search)
}
if deps.DatabaseDiscoveryService != nil {
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
database := api.Group("/database")
database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
agent := api.Group("/agent")
agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.POST("/commands/poll", agentHandler.Poll)
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord)
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
agent.POST("/restores/:id", agentHandler.UpdateRestore)
// Agent v1安装脚本探活用仅 Self 端点
v1Agent := api.Group("/v1/agent")
v1Agent.GET("/self", agentHandler.Self)
} else {
// 未启用 Agent 服务时,保留原有 heartbeat 端点以兼容
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
}
}
// 健康检查端点(公开、无认证、低开销)
// K8s/Swarm/Nomad 等编排系统使用这些端点做 liveness/readiness 探测。
healthHandler := NewHealthHandler(deps.DB, deps.Version)
engine.GET("/health", healthHandler.Live)
engine.GET("/ready", healthHandler.Ready)
// 在 /api 下也暴露一份,方便反向代理按 path 前缀统一路由
engine.GET("/api/health", healthHandler.Live)
engine.GET("/api/ready", healthHandler.Ready)
// Prometheus /metrics 端点(公开、无认证;内网/反向代理授权即可)。
// 业内通行做法:/metrics 通常由 Prometheus pull 抓取,不走 API Key。
if deps.Metrics != nil {
engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler()))
}
// 公开安装路由(不走 JWT 中间件)。
// 同时注册到 / 和 /api 前缀下:
// - /install/:token 保留历史 URL兼容旧 nginx 部署
// - /api/install/:token 新 URL自动走反向代理的 /api/ 转发规则
//
// Issue #46用户的 nginx 只转发 /api//install/* 被 SPA fallback 到 index.html
// 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。
if deps.InstallTokenService != nil {
gcCtx := deps.Context
if gcCtx == nil {
gcCtx = context.Background()
}
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose)
engine.GET("/api/install/:token", installHandler.Script)
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
}
engine.NoRoute(func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})
return engine
}
func requestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}