From 2b45aa33e13494d3a677a2a466b9a882016b95ea Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 12:18:35 +0200 Subject: [PATCH] feat(admin): ask the panel to restart, from verdande's own settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 + docs/configuration.md | 12 ++ docs/openapi.yaml | 61 ++++++++ internal/config/config.go | 19 +++ internal/httpapi/panel_handlers.go | 105 ++++++++++++++ internal/httpapi/panel_test.go | 131 ++++++++++++++++++ internal/httpapi/server.go | 5 + rune/verdande.yaml | 18 +++ web/src/lib/api.js | 2 + web/src/lib/locales/da.js | 13 +- web/src/lib/locales/en.js | 13 +- .../indstillinger/notifikationer/+page.svelte | 63 +++++++++ 12 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/panel_handlers.go create mode 100644 internal/httpapi/panel_test.go diff --git a/README.md b/README.md index abe4979..7227ca7 100644 --- a/README.md +++ b/README.md @@ -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_SECRET` | — | | | `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. | ## What lives in `/data` diff --git a/docs/configuration.md b/docs/configuration.md index 88ea829..8fb19b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from | Variable | Default | What it does | |---|---|---| | `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 at most every six hours and sends nothing about your instance — no identifier, no diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f313ebf..bc0e33e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1337,6 +1337,67 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "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: get: tags: [auth] diff --git a/internal/config/config.go b/internal/config/config.go index ab0ad56..c6e1ec1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,22 @@ type Config struct { // the deal its operator made by self-hosting. 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 time.Duration @@ -69,6 +85,9 @@ func Load() (*Config, error) { Dev: envBool("VERDANDE_DEV", false), 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", ""), UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false), } diff --git a/internal/httpapi/panel_handlers.go b/internal/httpapi/panel_handlers.go new file mode 100644 index 0000000..06166f5 --- /dev/null +++ b/internal/httpapi/panel_handlers.go @@ -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}) +} diff --git a/internal/httpapi/panel_test.go b/internal/httpapi/panel_test.go new file mode 100644 index 0000000..fd5ac42 --- /dev/null +++ b/internal/httpapi/panel_test.go @@ -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") + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1050c0a..3be111d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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.Use(s.requireSession, s.requireAdmin) 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 // group is sessions-only as well as administrators-only: a leaked // token must not be able to download everybody's data. diff --git a/rune/verdande.yaml b/rune/verdande.yaml index 699b192..94a1b1d 100644 --- a/rune/verdande.yaml +++ b/rune/verdande.yaml @@ -78,6 +78,24 @@ gameskill: type: bool 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: # 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 diff --git a/web/src/lib/api.js b/web/src/lib/api.js index fb2bf57..36dfc2b 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -199,6 +199,8 @@ export const api = { deleteUser: (id) => del(`/users/${id}`), revokeInvite: (id) => del(`/invites/${id}`), listErrors: () => get('/errors'), + panelStatus: () => get('/panel'), + restartFromPanel: () => post('/panel/restart'), // 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 diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 743aff0..e6352ad 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -635,5 +635,16 @@ export const da = { 'passkey.signingIn': 'Venter på enheden…', '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.' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index c1fa9b9..21276b9 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -630,5 +630,16 @@ export const en = { 'passkey.signingIn': 'Waiting for the device…', '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.' }; diff --git a/web/src/routes/indstillinger/notifikationer/+page.svelte b/web/src/routes/indstillinger/notifikationer/+page.svelte index 6459eba..05f0c81 100644 --- a/web/src/routes/indstillinger/notifikationer/+page.svelte +++ b/web/src/routes/indstillinger/notifikationer/+page.svelte @@ -89,6 +89,55 @@ 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(() => { api.version().then((v) => (version = v)).catch(() => {}); }); @@ -182,6 +231,20 @@ {/if}

+ + {#if panel?.configured} +
+ + {#if restartNote}{restartNote}{/if} +
+ {:else if panel} +

{t('update.notConfigured', { missing: panel.missing.join(', ') })}

+ {/if} + {#if version.update_available} {#if version.notes}

{version.notes}