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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:23 +02:00
parent dc2d429093
commit 629f0aa74a
7 changed files with 174 additions and 2 deletions
+10
View File
@@ -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 {
+62 -1
View File
@@ -198,7 +198,7 @@ const factoryResetSpinnerHTML = `<div class="reboot-overlay">
// 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.
+1
View File
@@ -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)
+9
View File
@@ -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 {
+70
View File
@@ -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 <a download> 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);
});
}
});
})();
+1 -1
View File
@@ -331,7 +331,7 @@
hx-push-url="true"
class="nav-link {{if eq .ActivePage "backup"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/backup.svg')"></span>
Backup &amp; Restore
Backup &amp; Support
</a>
</li>
<li>
+21
View File
@@ -44,5 +44,26 @@
</div>
</section>
<section class="info-card">
<div class="card-header">Support Bundle</div>
<div class="card-body sc-action-body">
<p class="sc-desc">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.</p>
<div class="sc-dt-row">
<input type="password" class="cfg-input support-pass"
placeholder="Encryption password (optional)" autocomplete="new-password">
</div>
<p class="sc-desc sc-hint">Leave blank for a plain <code>.tar.gz</code>, or set
a password to encrypt the bundle (GPG) before download.</p>
<div style="margin-top:0.75rem">
<button type="button" class="btn btn-primary support-generate"
data-csrf="{{.CsrfToken}}">Download support</button>
</div>
<p class="support-status" aria-live="polite"></p>
</div>
</section>
</div>
{{end}}