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 @@
+