Release / Build and publish image (push) Failing after 10m2s
A container cannot replace its own image from the inside, so "update" is really "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the way, which is what makes a restart an update at all. This removes the second browser tab, not the panel's authority: the panel still does the work and still records who asked. Three settings, needed together, and the page names whichever are missing — an operator reading "not configured" should not have to go and find out which of three. The token is never sent to a browser; the page is told only whether it is set. Answers 202 rather than waiting. The panel stops this container as part of answering, so the reply may never arrive, and a request cut off mid-flight is the successful case wearing a failure's clothes. The interface believes neither answer and polls /healthz until it responds. Administrators only, and sessions only. A leaked API token that could restart the server is a denial of service in one request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
3.8 KiB
Go
106 lines
3.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// Asking the panel to restart this instance.
|
|
//
|
|
// A container cannot replace its own image from the inside, so "update" is really
|
|
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
|
// the way, which is what makes a restart an update at all.
|
|
//
|
|
// The alternative was a second browser tab: read the version here, go to the
|
|
// panel, find the server, press Restart. This removes the tab, not the panel's
|
|
// authority — the panel still does the work and still records who asked.
|
|
//
|
|
// Administrators only, and sessions only. A leaked API token that could restart
|
|
// the server is a denial of service in one request, and the whole point of the
|
|
// session rule elsewhere in this file is that a stolen token must not be able to
|
|
// do things only a person at a keyboard should.
|
|
|
|
type panelStatusJSON struct {
|
|
// Configured is all three settings being present. The token itself is never
|
|
// sent: the page needs to know whether the button can work, not what the
|
|
// credential is.
|
|
Configured bool `json:"configured"`
|
|
PanelURL string `json:"panel_url,omitempty"`
|
|
// Why it cannot work, when it cannot. An operator reading "not configured"
|
|
// should not have to go and find out which of three things is missing.
|
|
Missing []string `json:"missing,omitempty"`
|
|
}
|
|
|
|
func (s *Server) panelStatus() panelStatusJSON {
|
|
var missing []string
|
|
if s.cfg.PanelURL == "" {
|
|
missing = append(missing, "VERDANDE_PANEL_URL")
|
|
}
|
|
if s.cfg.PanelToken == "" {
|
|
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
|
}
|
|
if s.cfg.PanelServerID == "" {
|
|
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
|
}
|
|
return panelStatusJSON{
|
|
Configured: len(missing) == 0,
|
|
PanelURL: s.cfg.PanelURL,
|
|
Missing: missing,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.panelStatus())
|
|
}
|
|
|
|
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
|
status := s.panelStatus()
|
|
if !status.Configured {
|
|
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
|
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
|
return
|
|
}
|
|
|
|
user := userFrom(r.Context())
|
|
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
|
|
|
// A short timeout of its own. The panel stops this container as part of
|
|
// answering, so the reply may never arrive — that is not a failure, and a
|
|
// request that hung waiting for it would look like one.
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
|
if err != nil {
|
|
s.internal(w, r, "build panel request", err)
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
|
|
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
if err != nil {
|
|
// Including "the panel killed us mid-reply", which is the successful case
|
|
// wearing a failure's clothes. The interface polls /healthz rather than
|
|
// believing either answer, so this only has to be honest about what it saw.
|
|
s.log.Warn("panel restart call did not complete", "err", err)
|
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
|
"restarting": true,
|
|
"note": "the panel did not answer; it may already be restarting this container",
|
|
})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
|
writeError(w, http.StatusBadGateway, CodeInternal,
|
|
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
|
|
}
|