mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 & Restore
|
||||
Backup & Support
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user