From 629f0aa74aa5ae179567dd8736ec568d369ff2ac Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 17:24:10 +0200 Subject: [PATCH] webui: add support bundle to Backup & Support Rename the Maintenance > Backup & Restore page to Backup & Support and add a Support Bundle card. Download support runs the on-device `support collect` tool, which gathers configuration, logs, routing, interfaces, hardware inventory, and container and service status into a single archive; the WebUI runs as root so the collection is complete. The handler buffers the archive and only commits response headers once collection succeeds, so a mid-collection failure is a clean error rather than a truncated download. An optional password encrypts the bundle via the tool's GPG support, fed on stdin so it never reaches the process list. Collection blocks for up to a minute with no output, so the write deadline is extended past the server's 15s WriteTimeout and nginx gets a matching read timeout; the browser shows a "collecting" state and saves the blob client-side using the server's filename. Signed-off-by: Joachim Wiberg --- package/webui/webui.conf | 10 ++++ src/webui/internal/handlers/system.go | 63 ++++++++++++++++++++- src/webui/internal/server/server.go | 1 + src/webui/static/css/style.css | 9 +++ src/webui/static/js/app.js | 70 ++++++++++++++++++++++++ src/webui/templates/layouts/sidebar.html | 2 +- src/webui/templates/pages/backup.html | 21 +++++++ 7 files changed, 174 insertions(+), 2 deletions(-) diff --git a/package/webui/webui.conf b/package/webui/webui.conf index 6723b92d..ac37f626 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -45,6 +45,16 @@ location ~ ^/maintenance/logs/[^/]+/tail$ { include /etc/nginx/webui-proxy.conf; } +# Support bundle collection runs `support collect`, which emits nothing +# until it finishes (~50 s) and then sends the whole archive at once. +# The upstream is silent for that window, so the default 60 s +# proxy_read_timeout would 504 just as the bundle is ready. Buffering +# stays on — it's a plain file download, not a stream. +location = /maintenance/support-bundle { + proxy_read_timeout 300s; + include /etc/nginx/webui-proxy.conf; +} + # Liveness probe — nginx-only, no upstream call. Used by the watchdog # div in base.html and the reboot-overlay poller. location = /device-status { diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 66d2df63..4fadd8fc 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -198,7 +198,7 @@ const factoryResetSpinnerHTML = `
// Backup renders the Backup & Restore maintenance page. func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { - data := newPageData(w, r, "backup", "Backup & Restore") + data := newPageData(w, r, "backup", "Backup & Support") tmplName := "backup.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" @@ -208,6 +208,67 @@ func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { } } +// SupportBundle runs the on-device `support collect` tool and streams the +// resulting archive back as a download. The WebUI runs as root, so the +// collection is complete (dmesg, ethtool, etc.). An optional password +// encrypts the archive via the tool's GPG support and is fed on stdin so +// it never lands in the process list. +// +// Collection emits nothing on stdout until it finishes (~50 s), then the +// whole archive at once. We buffer it and only commit response headers +// once the tool exits successfully, so a mid-collection failure becomes a +// clean 500 rather than a truncated download. --work-dir /tmp keeps the +// transient files in tmpfs; the tool cleans up after itself. +// POST /maintenance/support-bundle +func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { + // Collection blocks ~50 s with no output, but the server's 15 s + // WriteTimeout would close the connection long before then (nginx + // then logs a 502 "upstream prematurely closed connection"). Push + // the write deadline out for this long-running download. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(4 * time.Minute)); err != nil { + log.Printf("support bundle: extend write deadline: %v", err) + } + + password := r.FormValue("password") + encrypt := password != "" + + args := []string{"--work-dir", "/tmp", "collect"} + ext, ctype := "tar.gz", "application/gzip" + if encrypt { + args = append(args, "-p") + ext, ctype = "tar.gz.gpg", "application/pgp-encrypted" + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "/usr/sbin/support", args...) + if encrypt { + cmd.Stdin = strings.NewReader(password + "\n") + } + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = strings.TrimSpace(string(ee.Stderr)) + } + log.Printf("support bundle: %v: %s", err, stderr) + http.Error(w, "Failed to collect support bundle", http.StatusInternalServerError) + return + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "device" + } + fname := fmt.Sprintf("support-%s-%s.%s", hostname, time.Now().UTC().Format("20060102-1504"), ext) + + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fname)) + w.Header().Set("Content-Length", fmt.Sprint(len(out))) + w.Write(out) //nolint:errcheck +} + // RestoreConfig accepts a multipart-uploaded JSON config file and applies it. // target="running" (default): PUT to running so changes take effect immediately; // sets the cfg-unsaved cookie so the persistent notification prompts a save. diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 9036ef6a..766de368 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -320,6 +320,7 @@ func New( mux.HandleFunc("GET /maintenance/diagnostics/resolve", diag.Resolve) mux.HandleFunc("GET /maintenance/backup", sys.Backup) mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) + mux.HandleFunc("POST /maintenance/support-bundle", sys.SupportBundle) mux.HandleFunc("GET /maintenance/system", sys.SystemControl) mux.HandleFunc("POST /maintenance/system/reboot", sys.Reboot) mux.HandleFunc("POST /maintenance/system/shutdown", sys.Shutdown) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index d0ac4406..a5a2b661 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1504,6 +1504,15 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { .diag-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } .diag-status.err::before { color: var(--danger, #b03030); } +/* Support bundle status pill — shares the diag-status visual language, + but sits on its own line below the button. */ +.support-status { display: block; font-size: 0.8rem; color: var(--fg-muted); margin: 0.5rem 0 0; } +.support-status:empty { margin: 0; } +.support-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.support-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.support-status.err::before { color: var(--danger, #b03030); } +.sc-hint { font-size: 0.8rem; opacity: 0.8; margin-top: 0.4rem; } + .diag-output { padding: 0.25rem 1rem 1rem; } .diag-placeholder { color: var(--fg-muted); font-size: 0.85rem; padding: 1rem 0; } .diag-text { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 22f9e971..21c1652d 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2563,3 +2563,73 @@ function renderCfgLog() { } }); })(); + +// ─── Maintenance > Backup & Support: support bundle download ──────────────── +// `support collect` takes up to a minute and returns the whole archive at +// once, so a plain would just hang with no feedback. Instead +// fetch the bundle as a blob with a visible "generating…" state, then save +// it client-side using the filename the server set in Content-Disposition. +(function () { + function filenameFromDisposition(cd, fallback) { + var m = /filename="?([^";]+)"?/.exec(cd || ''); + return m ? m[1] : fallback; + } + + function initSupportBundle(scope) { + (scope || document).querySelectorAll('.support-generate:not([data-init])').forEach(function (btn) { + btn.dataset.init = 'true'; + var card = btn.closest('.info-card'); + var pass = card.querySelector('.support-pass'); + var status = card.querySelector('.support-status'); + + function setStatus(text, cls) { + if (!status) return; + status.textContent = text || ''; + status.className = 'support-status' + (cls ? ' ' + cls : ''); + } + + btn.addEventListener('click', function () { + btn.disabled = true; + setStatus('Collecting… (up to a minute)', 'pending'); + + var body = new FormData(); + if (pass && pass.value) body.append('password', pass.value); + + fetch('/maintenance/support-bundle', { + method: 'POST', + headers: { 'X-CSRF-Token': btn.getAttribute('data-csrf') || '' }, + body: body + }).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + var name = filenameFromDisposition(r.headers.get('Content-Disposition'), 'support-bundle.tar.gz'); + return r.blob().then(function (blob) { return { blob: blob, name: name }; }); + }).then(function (res) { + var url = URL.createObjectURL(res.blob); + var a = document.createElement('a'); + a.href = url; + a.download = res.name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 10000); + setStatus('Downloaded ' + res.name, ''); + if (pass) pass.value = ''; + }).catch(function (e) { + setStatus('Failed to generate bundle', 'err'); + if (window.console) console.warn('[support]', e); + }).finally(function () { + btn.disabled = false; + }); + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initSupportBundle(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + initSupportBundle((evt.detail && evt.detail.target) || document); + }); + } + }); +})(); diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 7f9e9505..5939d4e9 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -331,7 +331,7 @@ hx-push-url="true" class="nav-link {{if eq .ActivePage "backup"}}active{{end}}"> - Backup & Restore + Backup & Support
  • diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html index 8b120165..b1da3ad2 100644 --- a/src/webui/templates/pages/backup.html +++ b/src/webui/templates/pages/backup.html @@ -44,5 +44,26 @@
  • +
    +
    Support Bundle
    +
    +

    Collect system state — configuration, logs, routing, + interfaces, hardware inventory, and container and service status — into a + single archive to attach to a support ticket. Collection can take up to + a minute.

    +
    + +
    +

    Leave blank for a plain .tar.gz, or set + a password to encrypt the bundle (GPG) before download.

    +
    + +
    +

    +
    +
    + {{end}}