Files
hetzner-api-dyndns-records-…/internal/updateagent/controller.go
T
admin bb60c06c0e
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
feat: establish secure update foundation
2026-08-29 11:30:43 +02:00

145 lines
4.5 KiB
Go

package updateagent
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
type Runner interface {
Update(context.Context, updateprotocol.Request, func(phase, message string)) error
}
type Controller struct {
runner Runner
logger *slog.Logger
mu sync.RWMutex
status updateprotocol.Status
}
func NewController(runner Runner, logger *slog.Logger) *Controller {
return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
}
func agentStatus(phase string) updateprotocol.Status {
return updateprotocol.Status{
Phase: phase,
UpdatedAt: time.Now().UTC(),
AgentVersion: buildinfo.Version,
Protocol: buildinfo.UpdateProtocolVersion,
}
}
func (c *Controller) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/status", c.statusHandler)
mux.HandleFunc("POST /v1/update", c.updateHandler)
return mux
}
func (c *Controller) statusHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(c.Status())
}
func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
defer r.Body.Close()
var request updateprotocol.Request
decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
request.Version = strings.TrimSpace(request.Version)
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
http.Error(w, "invalid update request", http.StatusBadRequest)
return
}
if !c.begin(request.Version) {
http.Error(w, "update already running", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("{\"accepted\":true}\n"))
go c.run(request)
}
func (c *Controller) begin(version string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.status.Busy() {
return false
}
now := time.Now().UTC()
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion}
return true
}
func (c *Controller) run(request updateprotocol.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
err := c.runner.Update(ctx, request, c.setPhase)
registryToken := request.RegistryToken
request.RegistryToken = ""
if err != nil {
message := publicAgentError(err)
c.setPhase(updateprotocol.PhaseFailed, message)
c.logger.Error("software update failed", "version", request.Version, "error", redactSecret(err, registryToken))
return
}
c.setPhase(updateprotocol.PhaseComplete, "Update erfolgreich installiert")
c.logger.Info("software update completed", "version", request.Version)
}
func redactSecret(err error, secret string) error {
if err == nil || secret == "" || !strings.Contains(err.Error(), secret) {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), secret, "[redacted]"))
}
func (c *Controller) setPhase(phase, message string) {
c.mu.Lock()
c.status.Phase = phase
c.status.Message = message
c.status.UpdatedAt = time.Now().UTC()
c.mu.Unlock()
}
func (c *Controller) Status() updateprotocol.Status {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func publicAgentError(err error) string {
if err == nil {
return "Update fehlgeschlagen"
}
var public interface{ PublicMessage() string }
if errors.As(err, &public) {
return public.PublicMessage()
}
return "Update fehlgeschlagen; der bisherige Container wurde wiederhergestellt"
}