mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-11 15:43:52 +08:00
支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。 加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
256 lines
7.3 KiB
Go
256 lines
7.3 KiB
Go
// Package installscript 负责把一次性安装令牌 + 节点配置渲染为可执行 shell 脚本或 docker-compose YAML。
|
||
//
|
||
// 模板文件通过 go:embed 嵌入二进制,避免运行时依赖外部资源。
|
||
package installscript
|
||
|
||
import (
|
||
"bytes"
|
||
_ "embed"
|
||
"fmt"
|
||
"net/url"
|
||
"path"
|
||
"strings"
|
||
"text/template"
|
||
|
||
"backupx/server/internal/model"
|
||
)
|
||
|
||
//go:embed templates/agent-install.sh.tmpl
|
||
var installScriptTmpl string
|
||
|
||
//go:embed templates/agent-compose.yml.tmpl
|
||
var composeYamlTmpl string
|
||
|
||
// Context 是模板渲染输入。
|
||
type Context struct {
|
||
MasterURL string
|
||
AgentToken string
|
||
AgentVersion string
|
||
Mode string // systemd|docker|foreground
|
||
Arch string // amd64|arm64|auto
|
||
DownloadBase string
|
||
InstallPrefix string
|
||
NodeID uint
|
||
ProxyURL string
|
||
CACertFile string
|
||
}
|
||
|
||
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
|
||
func DownloadBaseFor(src string) string {
|
||
switch src {
|
||
case model.InstallSourceGhproxy:
|
||
return "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download"
|
||
default:
|
||
return "https://github.com/Awuqing/BackupX/releases/download"
|
||
}
|
||
}
|
||
|
||
// RenderScript 渲染目标机安装脚本。
|
||
func RenderScript(ctx Context) (string, error) {
|
||
ctx = withDefaults(ctx)
|
||
if err := validateContext(ctx); err != nil {
|
||
return "", err
|
||
}
|
||
tmpl, err := template.New("install").Parse(installScriptTmpl)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse template: %w", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||
return "", fmt.Errorf("execute template: %w", err)
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// RenderComposeYaml 渲染 docker-compose.yml 片段。
|
||
func RenderComposeYaml(ctx Context) (string, error) {
|
||
ctx = withDefaults(ctx)
|
||
if err := validateContext(ctx); err != nil {
|
||
return "", err
|
||
}
|
||
tmpl, err := template.New("compose").Parse(composeYamlTmpl)
|
||
if err != nil {
|
||
return "", fmt.Errorf("parse template: %w", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||
return "", fmt.Errorf("execute template: %w", err)
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// validateContext 对模板变量做安全校验,防止 YAML/shell 注入。
|
||
// - MasterURL:必须是合法 http(s) URL,无控制字符
|
||
// - AgentToken:仅允许 hex 字符,最长 128
|
||
// - AgentVersion:仅允许 tag 常见字符(字母数字、点、连字符、下划线、加号)
|
||
//
|
||
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
|
||
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
|
||
func validateContext(ctx Context) error {
|
||
if ctx.Mode != model.InstallModeSystemd && ctx.Mode != model.InstallModeDocker && ctx.Mode != model.InstallModeForeground {
|
||
return fmt.Errorf("unsupported install mode %q", ctx.Mode)
|
||
}
|
||
if ctx.Arch != model.InstallArchAmd64 && ctx.Arch != model.InstallArchArm64 && ctx.Arch != model.InstallArchAuto {
|
||
return fmt.Errorf("unsupported install architecture %q", ctx.Arch)
|
||
}
|
||
if err := validateMasterURL(ctx.MasterURL); err != nil {
|
||
return err
|
||
}
|
||
if err := validateAgentToken(ctx.AgentToken); err != nil {
|
||
return err
|
||
}
|
||
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
|
||
return err
|
||
}
|
||
if err := validateProxyURL(ctx.ProxyURL); err != nil {
|
||
return err
|
||
}
|
||
if err := validateCACertFile(ctx.CACertFile); err != nil {
|
||
return err
|
||
}
|
||
if !path.IsAbs(ctx.InstallPrefix) || path.Clean(ctx.InstallPrefix) != ctx.InstallPrefix {
|
||
return fmt.Errorf("install prefix must be a clean absolute path without shell metacharacters")
|
||
}
|
||
for _, c := range ctx.InstallPrefix {
|
||
switch {
|
||
case c >= '0' && c <= '9':
|
||
case c >= 'a' && c <= 'z':
|
||
case c >= 'A' && c <= 'Z':
|
||
case c == '/' || c == '.' || c == '_' || c == '-' || c == '+':
|
||
default:
|
||
return fmt.Errorf("install prefix contains illegal character %q", c)
|
||
}
|
||
}
|
||
if err := validateDownloadBase(ctx.DownloadBase); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateDownloadBase(raw string) error {
|
||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||
return fmt.Errorf("download base contains illegal characters")
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||
return fmt.Errorf("download base must be an absolute http(s) URL")
|
||
}
|
||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||
return fmt.Errorf("download base must not contain credentials, query or fragment")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateMasterURL(raw string) error {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return fmt.Errorf("master URL empty")
|
||
}
|
||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||
return fmt.Errorf("master URL contains illegal characters")
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid master URL: %w", err)
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return fmt.Errorf("master URL scheme must be http or https, got %q", u.Scheme)
|
||
}
|
||
if u.Host == "" {
|
||
return fmt.Errorf("master URL missing host")
|
||
}
|
||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||
return fmt.Errorf("master URL must not contain credentials, query or fragment")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateProxyURL(raw string) error {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||
return fmt.Errorf("proxy URL contains illegal characters")
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.Host == "" {
|
||
return fmt.Errorf("invalid proxy URL")
|
||
}
|
||
switch u.Scheme {
|
||
case "http", "https", "socks5", "socks5h":
|
||
default:
|
||
return fmt.Errorf("proxy URL scheme must be http, https, socks5 or socks5h")
|
||
}
|
||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||
return fmt.Errorf("proxy URL must not contain credentials, path, query or fragment")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateCACertFile(file string) error {
|
||
file = strings.TrimSpace(file)
|
||
if file == "" {
|
||
return nil
|
||
}
|
||
if !path.IsAbs(file) || path.Clean(file) != file || strings.ContainsAny(file, " \t\r\n\"'`$\\") {
|
||
return fmt.Errorf("CA certificate path must be a clean absolute path without shell metacharacters")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateAgentToken 允许占位符 <AGENT_TOKEN>(PreviewScript 使用),
|
||
// 或 32 字节 hex(64 字符)+ 小幅兼容(16-128 hex 字符)
|
||
func validateAgentToken(tok string) error {
|
||
if tok == "<AGENT_TOKEN>" {
|
||
return nil
|
||
}
|
||
if len(tok) < 8 || len(tok) > 128 {
|
||
return fmt.Errorf("agent token length out of range")
|
||
}
|
||
for _, c := range tok {
|
||
switch {
|
||
case c >= '0' && c <= '9':
|
||
case c >= 'a' && c <= 'f':
|
||
case c >= 'A' && c <= 'F':
|
||
default:
|
||
return fmt.Errorf("agent token must be hex")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateAgentVersion(v string) error {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return fmt.Errorf("agent version empty")
|
||
}
|
||
if len(v) > 64 {
|
||
return fmt.Errorf("agent version too long")
|
||
}
|
||
for _, c := range v {
|
||
switch {
|
||
case c >= '0' && c <= '9':
|
||
case c >= 'a' && c <= 'z':
|
||
case c >= 'A' && c <= 'Z':
|
||
case c == '.' || c == '-' || c == '_' || c == '+':
|
||
default:
|
||
return fmt.Errorf("agent version contains illegal char %q", c)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func withDefaults(ctx Context) Context {
|
||
if ctx.Arch == "" {
|
||
ctx.Arch = model.InstallArchAuto
|
||
}
|
||
if ctx.InstallPrefix == "" {
|
||
ctx.InstallPrefix = "/opt/backupx-agent"
|
||
}
|
||
if ctx.DownloadBase == "" {
|
||
ctx.DownloadBase = DownloadBaseFor(model.InstallSourceGitHub)
|
||
}
|
||
return ctx
|
||
}
|