mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
284 lines
8.1 KiB
Go
284 lines
8.1 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
)
|
|
|
|
const (
|
|
defaultGatewayUpgradeScript = "/usr/local/bin/goclaw-upgrade-release"
|
|
defaultGatewayUpgradeStatus = "/var/lib/goclaw/update-jobs/current.json"
|
|
gatewayUpgradeTokenHeader = "X-GoClaw-Upgrade-Token"
|
|
gatewayUpgradeRunningMaxAge = 30 * time.Minute
|
|
)
|
|
|
|
var gatewayUpgradeTagRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$`)
|
|
|
|
type gatewayUpgradeRunner interface {
|
|
Start(tag string) error
|
|
}
|
|
|
|
type gatewayUpgradeCommandRunner struct {
|
|
scriptPath string
|
|
}
|
|
|
|
func (r gatewayUpgradeCommandRunner) Start(tag string) error {
|
|
cmd := exec.Command("sudo", "-n", r.scriptPath, tag)
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
go func() {
|
|
if err := cmd.Wait(); err != nil {
|
|
slog.Warn("gateway upgrade command exited with error", "error", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// GatewayUpgradeHandler triggers the host-local GoClaw release upgrade script.
|
|
// It never accepts arbitrary commands or URLs.
|
|
type GatewayUpgradeHandler struct {
|
|
ScriptPath string
|
|
StatusPath string
|
|
TriggerToken string
|
|
Runner gatewayUpgradeRunner
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewGatewayUpgradeHandlerFromEnv() *GatewayUpgradeHandler {
|
|
scriptPath := strings.TrimSpace(os.Getenv("GOCLAW_UPGRADE_SCRIPT"))
|
|
if scriptPath == "" {
|
|
scriptPath = defaultGatewayUpgradeScript
|
|
}
|
|
statusPath := strings.TrimSpace(os.Getenv("GOCLAW_UPGRADE_STATUS_PATH"))
|
|
if statusPath == "" {
|
|
statusPath = defaultGatewayUpgradeStatus
|
|
}
|
|
h := &GatewayUpgradeHandler{
|
|
ScriptPath: scriptPath,
|
|
StatusPath: statusPath,
|
|
TriggerToken: os.Getenv("GOCLAW_UPGRADE_TRIGGER_TOKEN"),
|
|
}
|
|
h.Runner = gatewayUpgradeCommandRunner{scriptPath: h.ScriptPath}
|
|
return h
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/system/gateway/upgrade/status", requireAuth(permissions.RoleAdmin, h.handleStatus))
|
|
mux.HandleFunc("POST /v1/system/gateway/upgrade", requireAuth(permissions.RoleAdmin, h.handleStart))
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMasterScope(w, r) {
|
|
return
|
|
}
|
|
if !h.requireTriggerToken(w, r) {
|
|
return
|
|
}
|
|
|
|
status, err := h.readStatus()
|
|
if err != nil {
|
|
slog.Error("gateway upgrade status read failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read gateway upgrade status"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) handleStart(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMasterScope(w, r) {
|
|
return
|
|
}
|
|
if !enforcePackagesWriteLimit(w, r, "/v1/system/gateway/upgrade") {
|
|
return
|
|
}
|
|
if !h.requireTriggerToken(w, r) {
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
|
var req struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
if !bindJSON(w, r, extractLocale(r), &req) {
|
|
return
|
|
}
|
|
tag := strings.TrimSpace(req.Tag)
|
|
if !validGatewayUpgradeTag(tag) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tag must be latest or vMAJOR.MINOR.PATCH[-beta.N|-rc.N]"})
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
status, err := h.readStatus()
|
|
if err != nil {
|
|
slog.Error("gateway upgrade status read failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read gateway upgrade status"})
|
|
return
|
|
}
|
|
if gatewayUpgradeStatusRunning(status, time.Now().UTC()) {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "gateway upgrade already running"})
|
|
return
|
|
}
|
|
|
|
runner := h.Runner
|
|
if runner == nil {
|
|
runner = gatewayUpgradeCommandRunner{scriptPath: h.ScriptPath}
|
|
}
|
|
if err := h.writeRunningStatus(tag); err != nil {
|
|
slog.Error("gateway upgrade status write failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write gateway upgrade status"})
|
|
return
|
|
}
|
|
if err := runner.Start(tag); err != nil {
|
|
slog.Error("gateway upgrade start failed", "error", err)
|
|
_ = h.writeFailedStatus(tag, "failed to start gateway upgrade")
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start gateway upgrade"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"ok": true,
|
|
"accepted": true,
|
|
"tag": tag,
|
|
})
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) requireTriggerToken(w http.ResponseWriter, r *http.Request) bool {
|
|
if h.TriggerToken == "" {
|
|
slog.Warn("security.gateway_upgrade_token_unconfigured", "path", r.URL.Path)
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "gateway upgrade trigger token is not configured"})
|
|
return false
|
|
}
|
|
provided := r.Header.Get(gatewayUpgradeTokenHeader)
|
|
if subtle.ConstantTimeCompare([]byte(provided), []byte(h.TriggerToken)) == 1 {
|
|
return true
|
|
}
|
|
slog.Warn("security.gateway_upgrade_token_denied", "path", r.URL.Path)
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "upgrade trigger token required"})
|
|
return false
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) readStatus() (map[string]any, error) {
|
|
path := h.statusPath()
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return map[string]any{"state": "idle"}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read upgrade status: %w", err)
|
|
}
|
|
if len(data) > 64*1024 {
|
|
return nil, fmt.Errorf("upgrade status too large")
|
|
}
|
|
var status map[string]any
|
|
if err := json.Unmarshal(data, &status); err != nil {
|
|
return nil, fmt.Errorf("decode upgrade status: %w", err)
|
|
}
|
|
if status == nil {
|
|
return map[string]any{"state": "idle"}, nil
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func gatewayUpgradeStatusRunning(status map[string]any, now time.Time) bool {
|
|
if status["state"] != "running" {
|
|
return false
|
|
}
|
|
startedRaw, ok := status["startedAt"].(string)
|
|
if !ok || strings.TrimSpace(startedRaw) == "" {
|
|
return true
|
|
}
|
|
startedAt, err := time.Parse(time.RFC3339, startedRaw)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
age := now.Sub(startedAt)
|
|
if age < 0 || age <= gatewayUpgradeRunningMaxAge {
|
|
return true
|
|
}
|
|
slog.Warn("gateway upgrade stale running status ignored", "started_at", startedRaw, "age", age.String())
|
|
return false
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) writeRunningStatus(tag string) error {
|
|
return h.writeStatus(map[string]any{
|
|
"jobId": time.Now().UTC().Format("20060102T150405Z") + "-" + tag,
|
|
"state": "running",
|
|
"requestedTag": tag,
|
|
"resolvedTag": "",
|
|
"startedAt": time.Now().UTC().Format(time.RFC3339),
|
|
"finishedAt": nil,
|
|
"error": nil,
|
|
})
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) writeFailedStatus(tag, reason string) error {
|
|
return h.writeStatus(map[string]any{
|
|
"jobId": time.Now().UTC().Format("20060102T150405Z") + "-" + tag,
|
|
"state": "failed",
|
|
"requestedTag": tag,
|
|
"resolvedTag": "",
|
|
"startedAt": time.Now().UTC().Format(time.RFC3339),
|
|
"finishedAt": time.Now().UTC().Format(time.RFC3339),
|
|
"error": reason,
|
|
})
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) writeStatus(status map[string]any) error {
|
|
path := h.statusPath()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return fmt.Errorf("create upgrade status dir: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".current-*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("create upgrade status tmp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
encErr := json.NewEncoder(tmp).Encode(status)
|
|
closeErr := tmp.Close()
|
|
if encErr != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("encode upgrade status: %w", encErr)
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("close upgrade status tmp: %w", closeErr)
|
|
}
|
|
if err := os.Chmod(tmpName, 0o640); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("chmod upgrade status tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("replace upgrade status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *GatewayUpgradeHandler) statusPath() string {
|
|
if h.StatusPath == "" {
|
|
return defaultGatewayUpgradeStatus
|
|
}
|
|
return filepath.Clean(h.StatusPath)
|
|
}
|
|
|
|
func validGatewayUpgradeTag(tag string) bool {
|
|
return tag == "latest" || gatewayUpgradeTagRE.MatchString(tag)
|
|
}
|