feat: let an installation choose its update source, and adopt a plain directory

config.yaml gains an optional update section (remote / remote_url / branch). The
resolver keeps its default - whatever remote this tree already tracks - and the
explicit choice wins when set, which is also what lets a tarball installation
that never saw git get on the update rails at all.

- RemoteURL is fetched straight into a ref namespace of its own, so a URL source
  cannot be shadowed by a same-named remote's stale refs. ValidRemoteURL allowlists
  https/http/ssh/git/file and local absolute paths, and rejects git's ext::
  transport (which executes commands) before anything reaches argv.
- Adopt connects a plain directory to the source. Preview is side-effect free (the
  fetch happens in a throwaway bare repository) and lists every local file whose
  content the target would replace, plus the operator content that is put aside.
  The confirmed run inits, adds origin, stashes operator content, keeps every
  replaced file under .update-backup/<ts>/overwritten/ so "the update ate my edit"
  has an address, lands the target content, sets upstream, rebuilds the binary.
  A failure removes only the .git it created itself; once the content has landed
  the tree stays adopted even if the build fails.
- Console/API: the status envelope carries the configured source;
  POST /system/update/source writes it through the same config path every other
  setting uses (validated, audited); POST /system/update/adopt is preview-then-job,
  with the job's kind recorded. OpenAPI updated; RBAC rides the existing
  update:apply prefix and the route inventory stays green.

Tests: URL-mode check/apply against real repositories (including the address
beating a remote name), URL validation table, and the full adoption story -
preview touches nothing, operator content survives, the replaced edit is in the
backup, and the adopted tree updates and rolls back like any other installation.
This commit is contained in:
Sycun committed 2026-10-07 12:46:32 +08:00
1 parent dc5c4d6a75
commit acd84cd525
12 files changed
+1125 -50

No files matched your search

+50
View File
@@ -1912,6 +1912,26 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
})
}
// SetUpdateSource 记录一键更新的更新源(remote 与 remote_url 二选一,另有可选 branch)。
// 走与其他设置相同的写入路径:备份旧文件、按 YAML 文档只改 update 段、原子落盘。
func (h *ConfigHandler) SetUpdateSource(remote, remoteURL, branch string) error {
h.mu.Lock()
h.config.Update = config.UpdateConfig{
Remote: strings.TrimSpace(remote),
RemoteURL: strings.TrimSpace(remoteURL),
Branch: strings.TrimSpace(branch),
}
h.mu.Unlock()
return h.saveConfig()
}
// UpdateSource 读当前生效的更新源(加读锁;更新处理器每次请求现读,保存后立即生效)。
func (h *ConfigHandler) UpdateSource() (string, string, string) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.config.Update.Remote, h.config.Update.RemoteURL, h.config.Update.Branch
}
// saveConfig 保存配置到文件
func (h *ConfigHandler) saveConfig() error {
configFileMu.Lock()
@@ -1938,6 +1958,7 @@ func (h *ConfigHandler) saveConfig() error {
updateAIConfig(root, h.config.AI)
removeKeyFromMap(root.Content[0], "openai")
updateVisionConfig(root, h.config.Vision)
updateUpdateSourceConfig(root, h.config.Update)
updateFOFAConfig(root, h.config.FOFA)
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
updateSpaceSearchConfig(root, "quake", h.config.Quake)
@@ -2065,6 +2086,35 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
setIntInMap(mcpNode, "port", cfg.Port)
}
// updateUpdateSourceConfig 写入一键更新的更新源(update 段)。三项全空时把整段删掉,
// 而不是留下一个空的 update: 块——"没有配置"应当就是文件里没有这一段。
func updateUpdateSourceConfig(doc *yaml.Node, cfg config.UpdateConfig) {
root := doc.Content[0]
remote := strings.TrimSpace(cfg.Remote)
remoteURL := strings.TrimSpace(cfg.RemoteURL)
branch := strings.TrimSpace(cfg.Branch)
if remote == "" && remoteURL == "" && branch == "" {
removeKeyFromMap(root, "update")
return
}
node := ensureMap(root, "update")
if remote != "" {
setStringInMap(node, "remote", remote)
} else {
removeKeyFromMap(node, "remote")
}
if remoteURL != "" {
setStringInMap(node, "remote_url", remoteURL)
} else {
removeKeyFromMap(node, "remote_url")
}
if branch != "" {
setStringInMap(node, "branch", branch)
} else {
removeKeyFromMap(node, "branch")
}
}
func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) {
root := doc.Content[0]
visionNode := ensureMap(root, "vision")
+55
View File
@@ -3418,6 +3418,61 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/system/update/source": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "配置更新源(本安装从哪个仓库更新)",
"description": "把更新源写进 config.yaml 的 update 段:remote(已有远端名)与 remote_url(仓库地址)二选一,branch 可选。不配置时更新跟随本目录自己跟踪的远端;地址白名单为 https/http/ssh/git/file:// 与本机绝对路径(git 的 ext:: 传输会执行命令,一律拒绝)。",
"operationId": "saveUpdateSource",
"requestBody": map[string]interface{}{
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"remote": map[string]interface{}{"type": "string", "description": "安装树里已有的远端名(mine/origin/upstream/任意名)"},
"remoteUrl": map[string]interface{}{"type": "string", "description": "仓库地址(与 remote 二选一)"},
"branch": map[string]interface{}{"type": "string", "description": "要跟踪的分支;留空按 upstream/当前分支推断"},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "已保存(返回生效的更新源)"},
"400": map[string]interface{}{"description": "远端名/地址/分支不合法,或两者同时给出"},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/system/update/adopt": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "把非 git 目录接入更新源",
"description": "解压/打包安装所在的目录接入配置好的更新源:不带 confirm 做无副作用预览(在临时仓库里 fetch,列出会被目标版本替换的文件与会保留的运维者内容);confirm=true 时异步执行(202 + job_id,用 /api/system/update/job 轮询):git init、添加 origin、落地目标分支内容,运维者内容先暂存再放回,被替换的文件全部留底到 .update-backup/<时间戳>/overwritten/。已是 git 工作树时返回 409(用一键更新而不是接入)。",
"operationId": "adoptUpdateSource",
"requestBody": map[string]interface{}{
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"},
"restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "预览结果(plan:将被替换/保留的文件清单与总数)"},
"202": map[string]interface{}{"description": "接入任务已受理,返回 job_id"},
"400": map[string]interface{}{"description": "没有配置更新源、地址不合法、分支不合法,或要求重启但没有重启钩子"},
"409": map[string]interface{}{"description": "目录已是 git 工作树;或有任务在进行中"},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/config": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"配置管理"},
+133 -11
View File
@@ -6,6 +6,7 @@ import (
"cyberstrike-ai/internal/update"
"fmt"
"net/http"
"strings"
"sync"
"time"
@@ -29,6 +30,13 @@ type UpdateHandler struct {
// request asked for a restart, because an unsupervised process that exits stays
// exited - "I restarted you" would be a lie on most deployments.
restart func()
// source reports the configured update source (config.yaml update section) as the
// operator last saved it; empty strings mean "not configured". A function rather
// than a value because the config can change while the process runs.
source func() (remote, remoteURL, branch string)
// saveSource persists a new source through the config layer; nil means this start
// has no config to write to.
saveSource func(remote, remoteURL, branch string) error
mu sync.Mutex
jobs map[string]*updateJob
@@ -41,6 +49,7 @@ type UpdateHandler struct {
// compiler finishes.
type updateJob struct {
ID string `json:"id"`
Kind string `json:"kind"` // apply | adopt
State string `json:"state"` // running | succeeded | failed
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
@@ -61,17 +70,38 @@ func (j *updateJob) view() *updateJob {
// NewUpdateHandler takes the audit service as a constructor argument rather than through
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
// a privileged endpoint that writes no audit records at all.
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func()) *UpdateHandler {
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func(),
source func() (remote, remoteURL, branch string), saveSource func(remote, remoteURL, branch string) error) *UpdateHandler {
return &UpdateHandler{
root: root,
logger: logger,
audit: auditSvc,
restart: restart,
jobs: map[string]*updateJob{},
root: root,
logger: logger,
audit: auditSvc,
restart: restart,
source: source,
saveSource: saveSource,
jobs: map[string]*updateJob{},
}
}
func (h *UpdateHandler) options() update.Options { return update.Options{Root: h.root} }
func (h *UpdateHandler) options() update.Options {
opts := update.Options{Root: h.root}
if h.source != nil {
opts.Remote, opts.RemoteURL, opts.Branch = h.source()
}
return opts
}
// sourceView is the configured source as the page edits it (empty strings = unset).
func (h *UpdateHandler) sourceView() gin.H {
var remote, remoteURL, branch string
if h.source != nil {
remote, remoteURL, branch = h.source()
}
return gin.H{
"remote": remote, "remoteUrl": remoteURL, "branch": branch,
"configured": remote != "" || remoteURL != "" || branch != "",
}
}
// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a
// network round trip, so opening the page is never gated on GitHub being reachable.
@@ -85,6 +115,7 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
"status": snap,
"job": h.latest(),
"canRestart": h.restart != nil,
"source": h.sourceView(),
})
}
@@ -103,6 +134,92 @@ func (h *UpdateHandler) Check(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": snap})
}
// SaveSource answers POST /api/system/update/source: the operator picks what this
// installation updates from - the official repository, their own fork, or a second
// development line - and the choice lands in the update section of config.yaml. A remote
// name and an address are two ways to say the same thing and are refused together rather
// than silently ordered.
func (h *UpdateHandler) SaveSource(c *gin.Context) {
var body struct {
Remote string `json:"remote"`
RemoteURL string `json:"remoteUrl"`
Branch string `json:"branch"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
return
}
body.Remote = strings.TrimSpace(body.Remote)
body.RemoteURL = strings.TrimSpace(body.RemoteURL)
body.Branch = strings.TrimSpace(body.Branch)
if body.Remote != "" && body.RemoteURL != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "远端名与远端地址二选一:要么指名已有远端,要么直接给地址"})
return
}
if body.Remote != "" && !update.ValidName(body.Remote) {
c.JSON(http.StatusBadRequest, gin.H{"error": "远端名不合法:" + body.Remote})
return
}
if body.RemoteURL != "" && !update.ValidRemoteURL(body.RemoteURL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"})
return
}
if body.Branch != "" && !update.ValidName(body.Branch) {
c.JSON(http.StatusBadRequest, gin.H{"error": "分支名不合法:" + body.Branch})
return
}
if h.saveSource == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"})
return
}
if err := h.saveSource(body.Remote, body.RemoteURL, body.Branch); err != nil {
h.record(c, "update.source", "failure", "保存更新源失败: "+err.Error(), map[string]interface{}{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()})
return
}
h.record(c, "update.source", "success", "更新源已保存", map[string]interface{}{
"remote": body.Remote, "remote_url": body.RemoteURL, "branch": body.Branch,
})
c.JSON(http.StatusOK, gin.H{"source": h.sourceView()})
}
// Adopt answers POST /api/system/update/adopt. Without confirm it is a side-effect-free
// preview (the fetch happens in a throwaway repository outside the install tree); with
// confirm it starts the same kind of job an update does.
func (h *UpdateHandler) Adopt(c *gin.Context) {
var body struct {
Confirm bool `json:"confirm"`
Restart bool `json:"restart"`
}
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
return
}
}
if body.Confirm && body.Restart && h.restart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"})
return
}
if !body.Confirm {
plan, err := update.Preview(c.Request.Context(), h.options())
if err != nil {
h.record(c, "update.adopt", "failure", "接入预览失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)})
c.JSON(statusForError(err), errorBody(err))
return
}
c.JSON(http.StatusOK, gin.H{"plan": plan})
return
}
job, started := h.startJob("adopt", body.Restart)
if !started {
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
return
}
h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart})
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
}
// Apply answers POST /api/system/update/apply. It starts one job and returns immediately;
// a second concurrent apply is refused, because two processes moving the same working
// tree and swapping the same binary is how an installation becomes unrecoverable.
@@ -121,7 +238,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
return
}
job, started := h.startJob(body.Restart)
job, started := h.startJob("apply", body.Restart)
if !started {
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
return
@@ -157,13 +274,14 @@ func (h *UpdateHandler) Rollback(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": res})
}
func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) {
func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
h.mu.Lock()
if h.active != "" {
return h.jobs[h.active].view(), false
}
job := &updateJob{
ID: fmt.Sprintf("upd-%d", time.Now().UnixNano()),
Kind: kind,
State: "running",
Started: time.Now().Format(time.RFC3339),
Restart: restart,
@@ -197,7 +315,11 @@ func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) {
h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message))
}
}
res, err := update.Apply(ctx, h.options(), onStep)
var run func(context.Context, update.Options, func(update.Step)) (*update.Result, error) = update.Apply
if kind == "adopt" {
run = update.Adopt
}
res, err := run(ctx, h.options(), onStep)
finish := "succeeded"
if err != nil {
finish = "failed"
@@ -308,7 +430,7 @@ func statusForError(err error) int {
return http.StatusInternalServerError
}
switch ue.Reason {
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary":
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo":
return http.StatusConflict
default:
return http.StatusBadRequest
+79 -1
View File
@@ -16,14 +16,21 @@ import (
// the length of a compile, and refusals that stay distinguishable from accidents.
func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) {
return newUpdateRouterWithSource(root, restart, nil, nil)
}
func newUpdateRouterWithSource(root string, restart func(),
source func() (string, string, string), save func(string, string, string) error) (*gin.Engine, *UpdateHandler) {
gin.SetMode(gin.TestMode)
h := NewUpdateHandler(root, nil, nil, restart)
h := NewUpdateHandler(root, nil, nil, restart, source, save)
router := gin.New()
router.GET("/api/system/update", h.GetStatus)
router.GET("/api/system/update/job", h.Job)
router.POST("/api/system/update/check", h.Check)
router.POST("/api/system/update/apply", h.Apply)
router.POST("/api/system/update/rollback", h.Rollback)
router.POST("/api/system/update/adopt", h.Adopt)
router.POST("/api/system/update/source", h.SaveSource)
return router, h
}
@@ -166,3 +173,74 @@ func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) {
t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"])
}
}
func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) {
var saved []string
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
func() (string, string, string) { return "", "", "" },
func(r, u, b string) error { saved = append(saved, r+"|"+u+"|"+b); return nil })
// A name and an address are two ways to say the same thing; guessing which one the
// operator meant is worse than asking again.
w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remote":"origin","remoteUrl":"https://github.com/x/y.git"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 for remote+remoteUrl together", w.Code, w.Body)
}
// git's ext:: transport runs commands; it must never reach the config.
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":"ext::sh -c true"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 for an ext:: address", w.Code, w.Body)
}
if len(saved) != 0 {
t.Fatalf("a refused save must not reach the config layer: %v", saved)
}
// A valid save is trimmed and lands in the config layer as three values.
w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":" https://github.com/Sycun/CyberStrikeAI.git ","branch":"main"}`)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s, want 200", w.Code, w.Body)
}
if len(saved) != 1 || saved[0] != "|https://github.com/Sycun/CyberStrikeAI.git|main" {
t.Fatalf("saved = %v, want the trimmed address and branch", saved)
}
}
func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) {
router, _ := newUpdateRouterWithSource(t.TempDir(), nil,
func() (string, string, string) { return "", "https://github.com/Sycun/CyberStrikeAI.git", "main" }, nil)
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var body struct {
Source struct {
Remote string `json:"remote"`
RemoteURL string `json:"remoteUrl"`
Branch string `json:"branch"`
Configured bool `json:"configured"`
} `json:"source"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Source.Configured || body.Source.RemoteURL != "https://github.com/Sycun/CyberStrikeAI.git" || body.Source.Branch != "main" {
t.Fatalf("source = %+v, want the configured address and branch", body.Source)
}
}
func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/adopt", `{}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["reason"] != "no_source" {
t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"])
}
}