feat: add resilient container restart controls
This commit is contained in:
1 parent
1bbb14470c
commit
7bb33e528c
13 files changed
+747
-28
No files matched your search
+84
-13
@@ -33,16 +33,20 @@ import (
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
type Server struct {
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
config config.Config
|
||||
store *store.Store
|
||||
cipher *secure.Cipher
|
||||
scheduler *scheduler.Scheduler
|
||||
updates *appupdate.Manager
|
||||
logger *slog.Logger
|
||||
templates map[string]*template.Template
|
||||
limiter *loginLimiter
|
||||
dummyHash string
|
||||
authSlots chan struct{}
|
||||
instanceID string
|
||||
restartContainer func()
|
||||
restartMu sync.Mutex
|
||||
restartPending bool
|
||||
}
|
||||
|
||||
type viewData struct {
|
||||
@@ -79,6 +83,7 @@ type viewData struct {
|
||||
ErrorCount int
|
||||
PiHoleView *piHoleView
|
||||
SoftwareUpdate appupdate.State
|
||||
InstanceID string
|
||||
}
|
||||
|
||||
type flashMessage struct {
|
||||
@@ -113,12 +118,16 @@ type schedulerResponse struct {
|
||||
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger, restartContainer func()) (*Server, error) {
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
||||
instanceID, err := secure.RandomToken(18)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create process instance ID: %w", err)
|
||||
}
|
||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2), instanceID: instanceID, restartContainer: restartContainer}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -170,6 +179,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
|
||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||
@@ -192,6 +202,10 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.instanceID != "" {
|
||||
w.Header().Set("X-Domain-Control-Instance", s.instanceID)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
@@ -1304,7 +1318,7 @@ func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, sess
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State(), InstanceID: s.instanceID})
|
||||
}
|
||||
|
||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
@@ -1418,6 +1432,63 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) containerRestart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
if err := s.parseForm(w, r); err != nil {
|
||||
return
|
||||
}
|
||||
if !s.validSessionCSRF(r, session) {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.restartContainer == nil {
|
||||
http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if s.updates != nil {
|
||||
statusCtx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
updateStatus, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||
cancel()
|
||||
if statusErr == nil && updateStatus.Busy() {
|
||||
http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.restartMu.Lock()
|
||||
if s.restartPending {
|
||||
s.restartMu.Unlock()
|
||||
s.writeRestartAccepted(w, r, true)
|
||||
return
|
||||
}
|
||||
if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil {
|
||||
s.restartMu.Unlock()
|
||||
s.serverError(w, err)
|
||||
return
|
||||
}
|
||||
s.restartPending = true
|
||||
restart := s.restartContainer
|
||||
s.restartMu.Unlock()
|
||||
|
||||
s.writeRestartAccepted(w, r, false)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
restart()
|
||||
}
|
||||
|
||||
func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "already_pending": alreadyPending, "instance_id": s.instanceID})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n"))
|
||||
}
|
||||
|
||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user