feat(admin): ask the panel to restart, from verdande's own settings
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:
Kristian Wind
2026-08-18 12:18:35 +02:00
co-authored by Claude Opus 5
parent 19aa02e80c
commit 2b45aa33e1
12 changed files with 443 additions and 2 deletions
+3
View File
@@ -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`
+12
View File
@@ -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
+61
View File
@@ -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]
+19
View File
@@ -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),
} }
+105
View File
@@ -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})
}
+131
View File
@@ -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")
}
}
+5
View File
@@ -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.
+18
View File
@@ -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
+2
View File
@@ -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
+12 -1
View File
@@ -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.'
}; };
+12 -1
View File
@@ -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>