diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 4fadd8fc..3a63d8ff 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -273,6 +273,49 @@ func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { // target="running" (default): PUT to running so changes take effect immediately; // sets the cfg-unsaved cookie so the persistent notification prompts a save. // target="startup": PUT to startup only; reboot required to apply. +// migrateConfig runs an uploaded .cfg backup through the on-target migrate(1) +// tool so a config saved by an older release is brought up to the running +// syntax before it is applied. The config is written to a temp file and +// migrated in place — migrate only reads stdin from a TTY, so the file path is +// the reliable interface. A config already at the current version comes back +// unchanged. An error carrying migrate's stderr is returned for a config newer +// than the system supports (downgrade) or any other migrate failure. +func migrateConfig(ctx context.Context, raw []byte) ([]byte, error) { + f, err := os.CreateTemp("", "restore-*.cfg") + if err != nil { + return nil, err + } + tmp := f.Name() + defer os.Remove(tmp) + + if _, err := f.Write(raw); err != nil { + f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "/usr/sbin/migrate", "-i", "-e", tmp) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + msg = strings.ReplaceAll(msg, tmp+": ", "") + msg = strings.TrimPrefix(msg, "Error: ") + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("%s", msg) + } + + if notes := strings.TrimSpace(stderr.String()); notes != "" { + log.Printf("restore: migrated uploaded config:\n%s", notes) + } + + return os.ReadFile(tmp) +} + func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -304,12 +347,22 @@ func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { return } + // Bring an older backup up to the running syntax before applying it. + migrated, err := migrateConfig(r.Context(), raw) + if err != nil { + log.Printf("restore: migrate: %v", err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `Restore failed: %s`, + template.HTMLEscapeString(err.Error())) + return + } + target := "running" if r.FormValue("save-to-startup") == "on" { target = "startup" } - if err := h.RC.PutDatastore(r.Context(), target, check); err != nil { + if err := h.RC.PutDatastore(r.Context(), target, json.RawMessage(migrated)); err != nil { log.Printf("restore(%s): %v", target, err) w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, `Restore failed: %s`, diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 0ef816c2..db935cc5 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2813,6 +2813,19 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } margin-top: 0.75rem; cursor: pointer; } + +/* Restore card: let the form fill the body so its file selector and checkbox + sit under the leading paragraph, with only the submit button anchored at the + bottom (aligned with the other action cards). */ +#restore-form { + display: flex; + flex-direction: column; + flex: 1; +} +.sc-form-actions { + margin-top: auto; + padding-top: 0.75rem; +} .sc-restore-warn-text { color: var(--warning-fg); font-size: 0.875rem; diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html index b1da3ad2..71083559 100644 --- a/src/webui/templates/pages/backup.html +++ b/src/webui/templates/pages/backup.html @@ -9,7 +9,7 @@
Download the current startup configuration as a JSON file. - The file format is
startup-config-$hostname-$date-$time.cfg+ The file format is
startup-config-$hostname-$date-$time.cfg
Restore a previously backed-up configuration. By default the restore operation is made to running config, this is the safe default.
+If the file was saved by an older release, it is migrated to + the current configuration syntax before being applied.