feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
19aa02e80c
commit
2b45aa33e1
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
|
|||||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||||
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||||
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||||
|
|
||||||
## What lives in `/data`
|
## What lives in `/data`
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
|||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||||
|
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||||
|
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||||
|
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||||
|
|
||||||
|
All three together let **Indstillinger → Notifikationer** restart this instance,
|
||||||
|
which is also how it picks up a new version: a container cannot replace its own
|
||||||
|
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
|
||||||
|
three alone does nothing, and the page names the ones that are missing.
|
||||||
|
|
||||||
|
The token is a panel credential with control over that server. Anybody who can
|
||||||
|
read this environment can restart it — which is true of anybody with the host
|
||||||
|
anyway — and it is never sent to a browser.
|
||||||
|
|
||||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||||
at most every six hours and sends nothing about your instance — no identifier, no
|
at most every six hours and sends nothing about your instance — no identifier, no
|
||||||
|
|||||||
@@ -1337,6 +1337,67 @@ paths:
|
|||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/panel:
|
||||||
|
get:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Whether this instance can restart itself. Administrators only.
|
||||||
|
description: |
|
||||||
|
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 says whether the three settings are present. The token is never sent:
|
||||||
|
the page needs to know whether the button can work, not what the credential
|
||||||
|
is. When it cannot, the missing variables are named — an operator reading
|
||||||
|
"not configured" should not have to go and find out which of three.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
panel_url: { type: string }
|
||||||
|
missing:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: The environment variables that are not set.
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/api/v1/panel/restart:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: Ask the panel to recreate this container. Administrators only.
|
||||||
|
description: |
|
||||||
|
The panel does the work and records who asked; this removes the second
|
||||||
|
browser tab, not the panel's authority.
|
||||||
|
|
||||||
|
Answers 202 rather than waiting: the panel stops this container as part of
|
||||||
|
answering, so the reply may never arrive. A request cut off mid-flight is
|
||||||
|
the successful case wearing a failure's clothes — the interface polls
|
||||||
|
`/healthz` rather than believing either answer.
|
||||||
|
|
||||||
|
Sessions only, like the rest of this group. A leaked API token that could
|
||||||
|
restart the server is a denial of service in one request.
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: The panel accepted, or was already restarting this container.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
restarting: { type: boolean }
|
||||||
|
note: { type: string }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"502":
|
||||||
|
description: |
|
||||||
|
The panel refused. Usually a token without control of this server.
|
||||||
|
"503":
|
||||||
|
description: The three settings are not all present.
|
||||||
|
|
||||||
/api/v1/errors:
|
/api/v1/errors:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ type Config struct {
|
|||||||
// the deal its operator made by self-hosting.
|
// the deal its operator made by self-hosting.
|
||||||
UpdateCheck bool
|
UpdateCheck bool
|
||||||
|
|
||||||
|
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||||
|
// from its own interface rather than from another browser tab.
|
||||||
|
//
|
||||||
|
// A container cannot replace its own image from the inside. Restarting is the
|
||||||
|
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||||
|
// what this needs is a way to ask. All three are required together; any one
|
||||||
|
// alone cannot do anything.
|
||||||
|
//
|
||||||
|
// The token is a panel credential with control over that server. Anybody who
|
||||||
|
// can read this environment can restart it, which is true of anybody with the
|
||||||
|
// host anyway. It is never sent to a browser: the settings page is told only
|
||||||
|
// whether it is set.
|
||||||
|
PanelURL string
|
||||||
|
PanelToken string
|
||||||
|
PanelServerID string
|
||||||
|
|
||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
@@ -69,6 +85,9 @@ func Load() (*Config, error) {
|
|||||||
Dev: envBool("VERDANDE_DEV", false),
|
Dev: envBool("VERDANDE_DEV", false),
|
||||||
|
|
||||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||||
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||||
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||||
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restarting is administrators only, and sessions only. A leaked API token that
|
||||||
|
// could restart the server is a denial of service in one request.
|
||||||
|
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
||||||
|
method := "GET"
|
||||||
|
if path != "/api/v1/panel" {
|
||||||
|
method = "POST"
|
||||||
|
}
|
||||||
|
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := ts.apiToken(t, admin.ID)
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unconfigured, it says which of the three settings are missing rather than
|
||||||
|
// "not configured" — an operator should not have to go and find out which.
|
||||||
|
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||||
|
if status["configured"] != false {
|
||||||
|
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
||||||
|
}
|
||||||
|
missing, _ := status["missing"].([]any)
|
||||||
|
if len(missing) != 3 {
|
||||||
|
t.Errorf("missing = %v, want all three settings named", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["error"] == nil {
|
||||||
|
t.Error("the refusal says nothing about why")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured, it calls the panel with a bearer token and reports what came back.
|
||||||
|
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
||||||
|
var gotPath, gotAuth string
|
||||||
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer panel.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.PanelURL = panel.URL
|
||||||
|
cfg.PanelToken = "hemmelig"
|
||||||
|
cfg.PanelServerID = "abc-123"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||||
|
if status["configured"] != true {
|
||||||
|
t.Fatalf("configured = %v with all three set", status["configured"])
|
||||||
|
}
|
||||||
|
// The token is a credential and must never reach a browser.
|
||||||
|
for key, value := range status {
|
||||||
|
if s, ok := value.(string); ok && s == "hemmelig" {
|
||||||
|
t.Errorf("the panel token was sent to the client in %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
|
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/servers/abc-123/restart" {
|
||||||
|
t.Errorf("called %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer hemmelig" {
|
||||||
|
t.Errorf("Authorization = %q", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panel that refuses is reported as the panel refusing, not as this server
|
||||||
|
// breaking: the fix is a token with control of that server, and saying "internal
|
||||||
|
// error" would send somebody looking in the wrong place.
|
||||||
|
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
||||||
|
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
}))
|
||||||
|
defer panel.Close()
|
||||||
|
|
||||||
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||||
|
cfg.PanelURL = panel.URL
|
||||||
|
cfg.PanelToken = "forkert"
|
||||||
|
cfg.PanelServerID = "abc-123"
|
||||||
|
})
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if msg, _ := body["error"].(string); msg == "" {
|
||||||
|
t.Error("the refusal does not say what to check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,6 +347,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireSession, s.requireAdmin)
|
r.Use(s.requireSession, s.requireAdmin)
|
||||||
r.Get("/errors", s.handleListErrors)
|
r.Get("/errors", s.handleListErrors)
|
||||||
|
// Restarting this instance from its own interface. The panel does
|
||||||
|
// the work — a container cannot replace its own image — and pulls
|
||||||
|
// `:latest` on the way, which is what makes a restart an update.
|
||||||
|
r.Get("/panel", s.handlePanelStatus)
|
||||||
|
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||||
// A backup file is a complete copy of the database, so this whole
|
// A backup file is a complete copy of the database, so this whole
|
||||||
// group is sessions-only as well as administrators-only: a leaked
|
// group is sessions-only as well as administrators-only: a leaked
|
||||||
// token must not be able to download everybody's data.
|
// token must not be able to download everybody's data.
|
||||||
|
|||||||
@@ -78,6 +78,24 @@ gameskill:
|
|||||||
type: bool
|
type: bool
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
# Restarting from verdande's own settings, which is also how it picks up a new
|
||||||
|
# version: a container cannot replace its own image, so the panel recreates it
|
||||||
|
# and pulls :latest on the way. All three are needed together; the page names
|
||||||
|
# whichever are missing.
|
||||||
|
- key: VERDANDE_PANEL_URL
|
||||||
|
name: "Panel address, so verdande can ask to be restarted from its own settings"
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- key: VERDANDE_PANEL_TOKEN
|
||||||
|
name: "Panel API token — from an account with control of this server"
|
||||||
|
type: string
|
||||||
|
secret: true
|
||||||
|
default: ""
|
||||||
|
- key: VERDANDE_PANEL_SERVER_ID
|
||||||
|
name: "This server's id in the panel (the one in its URL)"
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
install:
|
install:
|
||||||
# The image has no shell, so the install step borrows one. It only has to make
|
# The image has no shell, so the install step borrows one. It only has to make
|
||||||
# the directory layout exist; verdande creates and migrates its own database on
|
# the directory layout exist; verdande creates and migrates its own database on
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export const api = {
|
|||||||
deleteUser: (id) => del(`/users/${id}`),
|
deleteUser: (id) => del(`/users/${id}`),
|
||||||
revokeInvite: (id) => del(`/invites/${id}`),
|
revokeInvite: (id) => del(`/invites/${id}`),
|
||||||
listErrors: () => get('/errors'),
|
listErrors: () => get('/errors'),
|
||||||
|
panelStatus: () => get('/panel'),
|
||||||
|
restartFromPanel: () => post('/panel/restart'),
|
||||||
|
|
||||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||||
// The OAuth client itself, which is the instance's registration with Google
|
// The OAuth client itself, which is the instance's registration with Google
|
||||||
|
|||||||
@@ -635,5 +635,16 @@ export const da = {
|
|||||||
'passkey.signingIn': 'Venter på enheden…',
|
'passkey.signingIn': 'Venter på enheden…',
|
||||||
|
|
||||||
'passkey.unavailable':
|
'passkey.unavailable':
|
||||||
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.'
|
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
|
||||||
|
|
||||||
|
// --- restarting from the interface ---------------------------------------------
|
||||||
|
'update.restart': 'Genstart og hent nyeste version',
|
||||||
|
'update.restarting': 'Genstarter…',
|
||||||
|
'update.restartQuestion':
|
||||||
|
'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
|
||||||
|
'update.asking': 'Beder panelet om at genstarte…',
|
||||||
|
'update.waiting': 'Venter på at serveren kommer op igen…',
|
||||||
|
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
||||||
|
'update.notConfigured':
|
||||||
|
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -630,5 +630,16 @@ export const en = {
|
|||||||
'passkey.signingIn': 'Waiting for the device…',
|
'passkey.signingIn': 'Waiting for the device…',
|
||||||
|
|
||||||
'passkey.unavailable':
|
'passkey.unavailable':
|
||||||
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.'
|
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
|
||||||
|
|
||||||
|
// --- restarting from the interface ---------------------------------------------
|
||||||
|
'update.restart': 'Restart and pull the latest version',
|
||||||
|
'update.restarting': 'Restarting…',
|
||||||
|
'update.restartQuestion':
|
||||||
|
'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
|
||||||
|
'update.asking': 'Asking the panel to restart…',
|
||||||
|
'update.waiting': 'Waiting for the server to come back…',
|
||||||
|
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
||||||
|
'update.notConfigured':
|
||||||
|
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,6 +89,55 @@
|
|||||||
|
|
||||||
let version = $state(null);
|
let version = $state(null);
|
||||||
|
|
||||||
|
// --- restarting from here ---------------------------------------------------
|
||||||
|
|
||||||
|
let panel = $state(null);
|
||||||
|
let restarting = $state(false);
|
||||||
|
let restartNote = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api
|
||||||
|
.panelStatus()
|
||||||
|
.then((p) => (panel = p))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the panel to recreate this container, then waits for it to come back.
|
||||||
|
*
|
||||||
|
* The reply may never arrive — the panel stops this container as part of
|
||||||
|
* answering — so the answer is not believed either way. `/healthz` is polled
|
||||||
|
* until it responds, which is the only thing that actually says "it is up".
|
||||||
|
*/
|
||||||
|
async function restart() {
|
||||||
|
if (!confirm(t('update.restartQuestion'))) return;
|
||||||
|
restarting = true;
|
||||||
|
restartNote = t('update.asking');
|
||||||
|
try {
|
||||||
|
await api.restartFromPanel();
|
||||||
|
} catch (e) {
|
||||||
|
// A request cut off mid-flight is the successful case wearing a
|
||||||
|
// failure's clothes. The poll below decides.
|
||||||
|
restartNote = humanMessage(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
restartNote = t('update.waiting');
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const response = await fetch('/healthz', { cache: 'no-store' });
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still down, which is expected for the first few seconds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restarting = false;
|
||||||
|
restartNote = t('update.stillDown');
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
api.version().then((v) => (version = v)).catch(() => {});
|
api.version().then((v) => (version = v)).catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -182,6 +231,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Restarting is what makes a new version arrive: a container cannot replace
|
||||||
|
its own image, and the panel pulls `:latest` when it recreates one. The
|
||||||
|
button is here rather than in another browser tab. -->
|
||||||
|
{#if panel?.configured}
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={restart} disabled={restarting}>
|
||||||
|
{restarting ? t('update.restarting') : t('update.restart')}
|
||||||
|
</button>
|
||||||
|
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if panel}
|
||||||
|
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if version.update_available}
|
{#if version.update_available}
|
||||||
{#if version.notes}
|
{#if version.notes}
|
||||||
<p class="notes">{version.notes}</p>
|
<p class="notes">{version.notes}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user