Files

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)
}