mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 14:07:36 +02:00
535 lines
18 KiB
Go
535 lines
18 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"cyberstrike-ai/internal/audit"
|
||
"cyberstrike-ai/internal/update"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// updateRestartDelay is how long a stand-down waits after the response is written: the
|
||
// page that asked for the restart gets one more poll window to read the final state
|
||
// before the process it is watching goes away. A variable so tests do not sleep for it.
|
||
var updateRestartDelay = 2 * time.Second
|
||
|
||
// UpdateHandler is the "update my own source" surface: what this installation is, what
|
||
// its remote has that it does not, and one action that moves the tree and rebuilds the
|
||
// binary.
|
||
//
|
||
// It exists because the alternative was a shell script pointed at one fixed upstream
|
||
// repository, which for anyone running a fork meant "downgrade yourself to somebody
|
||
// else's code". Here the remote is whatever this directory already tracks, and the person
|
||
// at the keyboard never types git or go build.
|
||
type UpdateHandler struct {
|
||
root string // the installation to update: the directory the config file lives in
|
||
logger *zap.Logger
|
||
audit *audit.Service
|
||
// restart is the operator's own shutdown hook. It is only ever called when the
|
||
// 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.repo) as the
|
||
// operator last saved it; an empty string means "not configured" and the official
|
||
// repository is used. A function rather than a value because the config can change
|
||
// while the process runs.
|
||
source func() string
|
||
// saveSource persists a new source through the config layer; nil means this start
|
||
// has no config to write to.
|
||
saveSource func(repo string) error
|
||
// baseline is the binary this process is actually running: the file on disk at
|
||
// construction time. An update, a rollback or a CLI run that replaces it leaves a
|
||
// different file behind, and that difference is what "restart to activate" means.
|
||
baseline update.BinaryStamp
|
||
|
||
mu sync.Mutex
|
||
jobs map[string]*updateJob
|
||
order []string
|
||
active string
|
||
}
|
||
|
||
// updateJob is one running or finished apply. A build takes minutes, so the request
|
||
// returns a handle and the page polls it rather than sitting in one HTTP call until the
|
||
// 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"`
|
||
Steps []update.Step `json:"steps"`
|
||
Result *update.Result `json:"result,omitempty"`
|
||
Failure *update.Error `json:"failure,omitempty"`
|
||
Restart bool `json:"restartRequested"`
|
||
}
|
||
|
||
// view copies a job for transport, because its fields are written by the job's own
|
||
// goroutine: a response must never serialize the live struct. Callers hold h.mu.
|
||
func (j *updateJob) view() *updateJob {
|
||
out := *j
|
||
out.Steps = append([]update.Step{}, j.Steps...)
|
||
return &out
|
||
}
|
||
|
||
// 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(),
|
||
source func() string, saveSource func(repo string) error) *UpdateHandler {
|
||
return &UpdateHandler{
|
||
root: root,
|
||
logger: logger,
|
||
audit: auditSvc,
|
||
restart: restart,
|
||
source: source,
|
||
saveSource: saveSource,
|
||
baseline: update.StampBinary(root, update.Options{Root: root}),
|
||
jobs: map[string]*updateJob{},
|
||
}
|
||
}
|
||
|
||
// restartPending reports whether the binary on disk is no longer the one this process is
|
||
// running, and when that binary was built (empty when there is nothing on disk to run).
|
||
func (h *UpdateHandler) restartPending() (bool, string) {
|
||
stamp := update.StampBinary(h.root, h.options())
|
||
if stamp.Same(h.baseline) {
|
||
return false, ""
|
||
}
|
||
builtAt := ""
|
||
if stamp.Exists {
|
||
builtAt = stamp.ModTime.Format(time.RFC3339)
|
||
}
|
||
return true, builtAt
|
||
}
|
||
|
||
// restartSupervised reports whether something outside this process is set up to bring it
|
||
// back: launchd (XPC_SERVICE_NAME) and systemd (INVOCATION_ID / JOURNAL_STREAM) both leave
|
||
// a marker in the environment. It is only the page's default for the restart tick - the
|
||
// operator's own checkbox remains the decision.
|
||
func restartSupervised() bool {
|
||
return strings.TrimSpace(os.Getenv("XPC_SERVICE_NAME")) != "" ||
|
||
strings.TrimSpace(os.Getenv("INVOCATION_ID")) != "" ||
|
||
strings.TrimSpace(os.Getenv("JOURNAL_STREAM")) != ""
|
||
}
|
||
|
||
func (h *UpdateHandler) options() update.Options {
|
||
opts := update.Options{Root: h.root}
|
||
if h.source != nil {
|
||
opts.Repo = h.source()
|
||
}
|
||
return opts
|
||
}
|
||
|
||
// sourceView is the configured source as the page edits it: the address (empty = the
|
||
// official repository) plus the default, so the page never hardcodes a URL the server
|
||
// would disagree with.
|
||
func (h *UpdateHandler) sourceView() gin.H {
|
||
repo := ""
|
||
if h.source != nil {
|
||
repo = strings.TrimSpace(h.source())
|
||
}
|
||
return gin.H{
|
||
"repo": repo,
|
||
"configured": repo != "",
|
||
"defaultRepo": update.DefaultRepoURL,
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||
snap, err := update.Status(c.Request.Context(), h.options())
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
needsRestart, binaryBuiltAt := h.restartPending()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"status": snap,
|
||
"job": h.latest(),
|
||
"canRestart": h.restart != nil,
|
||
"supervised": restartSupervised(),
|
||
"needsRestart": needsRestart,
|
||
"binaryBuiltAt": binaryBuiltAt,
|
||
"source": h.sourceView(),
|
||
})
|
||
}
|
||
|
||
// Check answers POST /api/system/update/check: fetch the tracked branch and report the
|
||
// gap. A failed fetch is a 200 with checkError, not a 500 - "offline" and "bad token"
|
||
// are answers the operator needs to read, not server faults to debug.
|
||
func (h *UpdateHandler) Check(c *gin.Context) {
|
||
snap, err := update.Check(c.Request.Context(), h.options())
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
h.record(c, "update.check", resultOf(snap.CheckError), fmt.Sprintf("检查更新:%s(默认分支 %s)", snap.Repo, snap.TargetBranch), map[string]interface{}{
|
||
"commit": snap.Commit, "remote_commit": snap.RemoteCommit, "behind": snap.Behind, "error": snap.CheckError,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{"status": snap})
|
||
}
|
||
|
||
// SaveSource answers POST /api/system/update/source: the operator picks which repository
|
||
// this installation updates from - the official one, a mirror, or their own fork - and
|
||
// the choice lands in the update section of config.yaml. An empty address clears the
|
||
// setting, which means the official repository again.
|
||
func (h *UpdateHandler) SaveSource(c *gin.Context) {
|
||
var body struct {
|
||
Repo string `json:"repo"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
|
||
return
|
||
}
|
||
body.Repo = strings.TrimSpace(body.Repo)
|
||
if body.Repo != "" && !update.ValidRemoteURL(body.Repo) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"})
|
||
return
|
||
}
|
||
if h.saveSource == nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"})
|
||
return
|
||
}
|
||
release, err := update.LockInstall(h.options())
|
||
if err != nil {
|
||
c.JSON(statusForError(err), errorBody(err))
|
||
return
|
||
}
|
||
defer release()
|
||
if busy := h.activeJob(); busy != nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,不能切换更新源", "job": busy})
|
||
return
|
||
}
|
||
if err := h.saveSource(body.Repo); 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
|
||
}
|
||
message := "更新源已保存:" + body.Repo
|
||
if body.Repo == "" {
|
||
message = "已恢复默认更新源(官方仓库)"
|
||
}
|
||
h.record(c, "update.source", "success", message, map[string]interface{}{"repo": body.Repo})
|
||
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.
|
||
func (h *UpdateHandler) Apply(c *gin.Context) {
|
||
var body struct {
|
||
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.Restart && h.restart == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"})
|
||
return
|
||
}
|
||
|
||
job, started := h.startJob("apply", body.Restart)
|
||
if !started {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
|
||
return
|
||
}
|
||
h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart})
|
||
|
||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
|
||
}
|
||
|
||
// Restart answers POST /api/system/update/restart: stand this process down so the binary
|
||
// already on disk - an update or a rollback that ran without one - becomes the thing
|
||
// answering requests. It refuses when there is nothing to activate, because a bounce that
|
||
// cannot change anything is a dropped service for no reason.
|
||
func (h *UpdateHandler) Restart(c *gin.Context) {
|
||
release, err := update.LockInstall(h.options())
|
||
if err != nil {
|
||
c.JSON(statusForError(err), errorBody(err))
|
||
return
|
||
}
|
||
scheduled := false
|
||
defer func() {
|
||
if !scheduled {
|
||
release()
|
||
}
|
||
}()
|
||
if busy := h.activeJob(); busy != nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy})
|
||
return
|
||
}
|
||
if h.restart == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,请由外部进程管理器重启服务"})
|
||
return
|
||
}
|
||
if needsRestart, _ := h.restartPending(); !needsRestart {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制与当前进程一致,没有待生效的版本", "reason": "nothing_pending"})
|
||
return
|
||
}
|
||
if !update.StampBinary(h.root, h.options()).Exists {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"})
|
||
return
|
||
}
|
||
h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil)
|
||
c.JSON(http.StatusAccepted, gin.H{"restarting": true})
|
||
scheduled = true
|
||
time.AfterFunc(updateRestartDelay, func() { defer release(); h.restart() })
|
||
}
|
||
|
||
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
|
||
// every progress line so far.
|
||
func (h *UpdateHandler) Job(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"job": h.latest()})
|
||
}
|
||
|
||
// Rollback answers POST /api/system/update/rollback: back to the commit the last update
|
||
// came from, with the binary kept before the swap. It refuses when HEAD has moved since
|
||
// that update, rather than resetting away work nobody asked it to touch.
|
||
func (h *UpdateHandler) Rollback(c *gin.Context) {
|
||
if busy := h.activeJob(); busy != nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,不能同时回滚", "job": busy})
|
||
return
|
||
}
|
||
res, err := update.Rollback(c.Request.Context(), h.options())
|
||
if err != nil {
|
||
h.record(c, "update.rollback", "failure", "回滚失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)})
|
||
c.JSON(statusForError(err), errorBody(err))
|
||
return
|
||
}
|
||
h.record(c, "update.rollback", "success", fmt.Sprintf("已回滚到 %s", res.ToCommit), map[string]interface{}{
|
||
"from": res.FromCommit, "to": res.ToCommit,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{"result": res})
|
||
}
|
||
|
||
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,
|
||
}
|
||
h.jobs[job.ID] = job
|
||
h.order = append(h.order, job.ID)
|
||
for len(h.order) > 20 {
|
||
oldest := h.order[0]
|
||
h.order = h.order[1:]
|
||
if oldest != h.active {
|
||
delete(h.jobs, oldest)
|
||
}
|
||
}
|
||
h.active = job.ID
|
||
snapshot := job.view()
|
||
opts := h.options()
|
||
h.mu.Unlock()
|
||
|
||
// The update outlives its HTTP request on purpose: the caller gets a handle, and a
|
||
// browser that closes the tab must not cancel a merge halfway through. Once the
|
||
// working tree has moved there is no going back, so the context is generous with
|
||
// time instead of inheriting the request's deadline.
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute)
|
||
defer cancel()
|
||
|
||
onStep := func(s update.Step) {
|
||
h.mu.Lock()
|
||
job.Steps = append(job.Steps, s)
|
||
h.mu.Unlock()
|
||
if h.logger != nil {
|
||
h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message))
|
||
}
|
||
}
|
||
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, opts, onStep)
|
||
finish := "succeeded"
|
||
if err != nil {
|
||
finish = "failed"
|
||
}
|
||
|
||
h.mu.Lock()
|
||
job.Result = res
|
||
if ue, ok := asUpdateError(err); ok {
|
||
job.Failure = ue
|
||
} else if err != nil {
|
||
job.Failure = &update.Error{Reason: "error", Message: err.Error()}
|
||
}
|
||
job.State = finish
|
||
job.Finished = time.Now().Format(time.RFC3339)
|
||
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.restart != nil {
|
||
release, lockErr := update.LockInstall(opts)
|
||
if lockErr == nil {
|
||
h.mu.Unlock()
|
||
// Keep both CLI and HTTP writers out until the old process exits.
|
||
time.AfterFunc(updateRestartDelay, func() {
|
||
defer release()
|
||
defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }()
|
||
h.restart()
|
||
})
|
||
return
|
||
}
|
||
// Another process began an operation after the build. The binary is
|
||
// ready, but shutting down now would interrupt that operation.
|
||
job.Restart = false
|
||
}
|
||
h.active = ""
|
||
h.mu.Unlock()
|
||
}()
|
||
return snapshot, true
|
||
}
|
||
|
||
func (h *UpdateHandler) activeJob() *updateJob {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if h.active == "" {
|
||
return nil
|
||
}
|
||
if job := h.jobs[h.active]; job != nil {
|
||
return job.view()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *UpdateHandler) latest() *updateJob {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if len(h.order) == 0 {
|
||
return nil
|
||
}
|
||
if job := h.jobs[h.order[len(h.order)-1]]; job != nil {
|
||
return job.view()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *UpdateHandler) record(c *gin.Context, action, result, message string, detail map[string]interface{}) {
|
||
if h.audit == nil {
|
||
return
|
||
}
|
||
h.audit.Record(c, audit.Entry{
|
||
Level: auditLevelFor(result),
|
||
Category: "update",
|
||
Action: action,
|
||
Result: result,
|
||
Message: message,
|
||
ResourceType: "system",
|
||
ResourceID: h.root,
|
||
Detail: detail,
|
||
})
|
||
}
|
||
|
||
func auditLevelFor(result string) string {
|
||
if result == "failure" {
|
||
return "error"
|
||
}
|
||
return "info"
|
||
}
|
||
|
||
func resultOf(checkError string) string {
|
||
if checkError != "" {
|
||
return "failure"
|
||
}
|
||
return "success"
|
||
}
|
||
|
||
func asUpdateError(err error) (*update.Error, bool) {
|
||
if err == nil {
|
||
return nil, false
|
||
}
|
||
if ue, ok := err.(*update.Error); ok {
|
||
return ue, true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func errMessage(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
// statusForError keeps a refusal distinguishable from an accident: the precondition
|
||
// failures are the ones the page can act on (409), while anything else is a bad request it
|
||
// can show. No case maps a refusal onto a 2xx - a status that reads as accepted would
|
||
// contradict the failure the same response carries, and an asynchronous update never asks
|
||
// this function (the job's own state says whether it worked).
|
||
func statusForError(err error) int {
|
||
ue, ok := asUpdateError(err)
|
||
if !ok {
|
||
return http.StatusInternalServerError
|
||
}
|
||
switch ue.Reason {
|
||
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo", "update_busy", "content_path_conflict", "existing_git_metadata":
|
||
return http.StatusConflict
|
||
default:
|
||
return http.StatusBadRequest
|
||
}
|
||
}
|
||
|
||
func errorBody(err error) gin.H {
|
||
body := gin.H{"error": errMessage(err)}
|
||
if ue, ok := asUpdateError(err); ok {
|
||
body["reason"] = ue.Reason
|
||
if len(ue.Items) > 0 {
|
||
body["items"] = ue.Items
|
||
}
|
||
}
|
||
return body
|
||
}
|