From 35f0b9d305393adf59d35cf4bad74f7b5162b9d9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 17:56:45 +0200 Subject: [PATCH 1/2] webui: let a bundle upload take longer than the server timeouts Upload & Install failed on a Raspberry Pi with the nginx "device is starting" page in the progress card, after the upload had reached 100%. nginx spools the whole upload before handing it to the backend, and reading a bundle into a temp file on an SD card takes longer than the server's 15 s read timeout, so the connection closed and nginx answered 502. The handler also copied the spilled part once more into a second temp file, doubling the writes. Extend the request's own deadlines for the duration of the upload, and rename the spilled part into place instead of copying it. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/software_test.go | 40 +++++++++++++- src/webui/internal/handlers/system.go | 57 +++++++++++++++----- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/webui/internal/handlers/software_test.go b/src/webui/internal/handlers/software_test.go index 3b2c3915e..afeea996c 100644 --- a/src/webui/internal/handlers/software_test.go +++ b/src/webui/internal/handlers/software_test.go @@ -1,6 +1,11 @@ package handlers -import "testing" +import ( + "bytes" + "io" + "os" + "testing" +) func TestOtherSlots(t *testing.T) { tests := []struct { @@ -40,3 +45,36 @@ func TestOtherSlots(t *testing.T) { }) } } + +type memPart struct{ *bytes.Reader } + +func (memPart) Close() error { return nil } + +func TestKeepUpload(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + + spilled, err := os.CreateTemp("", "multipart-") + if err != nil { + t.Fatal(err) + } + spilled.WriteString("bundle") + spilled.Seek(0, io.SeekStart) + path, err := keepUpload(spilled) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(spilled.Name()); !os.IsNotExist(err) { + t.Errorf("spilled part still present, was copied rather than renamed") + } + if b, _ := os.ReadFile(path); string(b) != "bundle" { + t.Errorf("kept bundle = %q", b) + } + + path, err = keepUpload(memPart{bytes.NewReader([]byte("small"))}) + if err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(path); string(b) != "small" { + t.Errorf("kept small bundle = %q", b) + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 36236a6f8..ae1171558 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -11,6 +11,7 @@ import ( "html/template" "io" "log" + "mime/multipart" "net/http" "os" "os/exec" @@ -687,6 +688,17 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { return } + // nginx spools the whole upload before handing it over, and reading + // a bundle into the temp file takes longer than the server's 15 s + // read and write timeouts on a slow card. Push both out. + rc := http.NewResponseController(w) + if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil { + log.Printf("software upload: extend read deadline: %v", err) + } + if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil { + log.Printf("software upload: extend write deadline: %v", err) + } + // 1 MiB in-RAM threshold; larger parts spill to $TMPDIR (/var/tmp on // the target, eMMC-backed) instead of the RAM-backed /tmp. if err := r.ParseMultipartForm(1 << 20); err != nil { @@ -706,22 +718,12 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") + tmpPath, err := keepUpload(file) if err != nil { - log.Printf("software upload: create temp: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - tmpPath := tmp.Name() - - if _, err := io.Copy(tmp, file); err != nil { - tmp.Close() - os.Remove(tmpPath) - log.Printf("software upload: write: %v", err) + log.Printf("software upload: keep bundle: %v", err) http.Error(w, "failed to save bundle", http.StatusInternalServerError) return } - tmp.Close() body := map[string]map[string]string{ "infix-system:input": {"url": tmpPath}, @@ -737,6 +739,37 @@ func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, target) } +// keepUpload moves the uploaded part to a file of its own that outlives +// the request. A part larger than the in-RAM threshold already sits in a +// temp file, which is renamed rather than copied, sparing the card a +// second write of the whole bundle. Smaller parts are written out. +func keepUpload(file multipart.File) (string, error) { + tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + tmp.Close() + + if spilled, ok := file.(*os.File); ok { + if err := os.Rename(spilled.Name(), tmpPath); err == nil { + return tmpPath, nil + } + } + + tmp, err = os.OpenFile(tmpPath, os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + os.Remove(tmpPath) + return "", err + } + defer tmp.Close() + if _, err := io.Copy(tmp, file); err != nil { + os.Remove(tmpPath) + return "", err + } + return tmpPath, nil +} + // runInstall fires the install-bundle RPC and then waits for RAUC to finish // before deleting the uploaded .pkg. The 30-minute outer timeout is the // safety net for a stuck RAUC. From 3924731ae7489e10f357c93901a52107ba475459 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 1 Oct 2026 18:16:09 +0200 Subject: [PATCH 2/2] webui: show the resolved hostname on the dashboard again The dashboard showed the hostname template, e.g. "rpi-%m", since its system config fetch moved to the running datastore for the Software Updates card. Hostname, contact and location are resolved in the operational view, so read them from there as before and keep the running datastore for the card. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/dashboard.go | 36 ++++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 7a8d558b2..6c85ea61c 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -159,9 +159,6 @@ type systemConfigWrapper struct { } type systemConfig struct { - Hostname string `json:"hostname"` - Contact string `json:"contact"` - Location string `json:"location"` Software swConfig `json:"infix-system:software"` Schedules struct { Schedule []scheduleEntry `json:"schedule"` @@ -464,28 +461,42 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { // The RESTCONF client's own 10 s timeout still bounds each call. ctx := context.WithoutCancel(r.Context()) var ( - state systemStateWrapper - hw hardwareWrapper - sysConf systemConfigWrapper + state systemStateWrapper + hw hardwareWrapper + sysConf systemConfigWrapper + ident struct { + System struct { + Hostname string `json:"hostname"` + Contact string `json:"contact"` + Location string `json:"location"` + } `json:"ietf-system:system"` + } ifaces interfacesWrapper routes ribWrapper stateErr, hwErr, confErr error wg sync.WaitGroup ) - wg.Add(5) + wg.Add(6) go func() { defer wg.Done() stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) }() + // The operational view resolves hostname templates like "%m". + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-system:system", &ident); err != nil { + log.Printf("restconf system identity: %v", err) + } + }() go func() { defer wg.Done() hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) }() go func() { defer wg.Done() - // Running, not /data: statd serves operational data for the system - // container and that answer hides running config it does not emit. + // Running, not /data, for the update card: the operational view + // hides config under the system container that statd does not emit. confErr = h.RC.Get(ctx, "/ds/ietf-datastores:running/ietf-system:system", &sysConf) }() // Connectivity/Addresses cards are best-effort: a failure here logs but @@ -614,12 +625,13 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { disambiguateVitals(data.KeyVitals) + data.Hostname = ident.System.Hostname + data.Contact = ident.System.Contact + data.Location = ident.System.Location + if confErr != nil { log.Printf("restconf system config: %v", confErr) } else { - data.Hostname = sysConf.System.Hostname - data.Contact = sysConf.System.Contact - data.Location = sysConf.System.Location u := newUpdateEntry(sysConf.System.Software, sysConf.System.Schedules.Schedule, state.SystemState.Software.Update) data.Update = &u