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, AnonymousPull: true, 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) anonymous := request.RegistryUsername == "" && request.RegistryToken == "" credentialValid := request.RegistryUsername != "" && len(request.RegistryUsername) <= 128 && len(request.RegistryToken) >= 8 && len(request.RegistryToken) <= 1024 if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || (!anonymous && !credentialValid) { 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, AnonymousPull: true} 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) registryCredential := 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, registryCredential)) 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" }