Remove old config copies from updates
Settings -> Upkeep -> Backup & restore lists the config.json.bak-* files that update leaves behind and removes one or all of them; they hold the same secrets as a backup. Removing needs a signed-in user and is logged. After a successful update only the newest 3 copies are kept, and a copy that would overwrite an older one (version unknown, or the same version twice) gets the time appended. The backup card now also names preshared keys and authenticator app secrets. The update notice uses the existing compareVersions instead of its own.
This commit is contained in:
@@ -182,7 +182,7 @@ the running service.
|
|||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
||||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||||
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
||||||
| `GHOSTWIRE version` | Prints the version. |
|
| `GHOSTWIRE version` | Prints the version. |
|
||||||
@@ -224,6 +224,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE` | the program |
|
| `GHOSTWIRE` | the program |
|
||||||
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
||||||
|
| `config.json.bak-*` | copies of `config.json` made by `update`; the newest 3 are kept, and Settings → Upkeep lists and removes them |
|
||||||
| `stats.json` | traffic and connection history per peer |
|
| `stats.json` | traffic and connection history per peer |
|
||||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
||||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||||
@@ -273,6 +274,7 @@ GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
|||||||
GET /settings PATCH /settings POST /restart POST /updates/check
|
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||||
|
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
|
||||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ func (a *App) routes() http.Handler {
|
|||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
adm("GET /api/v1/backup", a.backup)
|
||||||
adm("POST /api/v1/restore", a.restore)
|
adm("POST /api/v1/restore", a.restore)
|
||||||
|
adm("GET /api/v1/update-backups", a.listUpdateBackups)
|
||||||
|
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
|
||||||
|
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
|
||||||
|
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||||
|
|||||||
@@ -2211,6 +2211,37 @@
|
|||||||
} catch (x) { retErr.textContent = x.message; }
|
} catch (x) { retErr.textContent = x.message; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// copies of config.json that updates leave behind
|
||||||
|
const copies = h('div', { class: 'section' });
|
||||||
|
const drawCopies = async () => {
|
||||||
|
let list;
|
||||||
|
try { list = (await api('GET', '/update-backups')).backups; } catch (x) { fill(copies, h('p', { class: 'err-text' }, x.message)); return; }
|
||||||
|
const remove = async (b) => {
|
||||||
|
if (!await confirmDialog({ title: 'Remove ' + b.name + '?', text: 'This copy of your settings from ' + b.version + ' is deleted from the server. It cannot be restored.', ok: 'Remove', danger: true })) return;
|
||||||
|
try { await api('DELETE', '/update-backups/' + encodeURIComponent(b.name)); toast('Removed ' + b.name); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||||
|
};
|
||||||
|
const removeAll = async () => {
|
||||||
|
const n = list.length;
|
||||||
|
if (!await confirmDialog({ title: n === 1 ? 'Remove the copy?' : 'Remove all ' + n + ' copies?', text: 'They are deleted from the server and cannot be restored. Your current settings are not affected.', ok: 'Remove all', danger: true })) return;
|
||||||
|
try { await api('DELETE', '/update-backups'); toast(n === 1 ? 'Removed 1 copy' : 'Removed ' + n + ' copies'); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||||
|
};
|
||||||
|
const total = list.reduce((t, b) => t + b.size, 0);
|
||||||
|
fill(copies,
|
||||||
|
h('div', { class: 'cardhead' },
|
||||||
|
h('div', null, h('strong', null, 'Copies made by updates'),
|
||||||
|
h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Each update saves the previous config.json next to it. They hold the same secrets as a backup.')),
|
||||||
|
list.length ? h('button', { type: 'button', class: 'btn', onClick: removeAll }, 'Remove all') : null),
|
||||||
|
list.length ? h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||||
|
h('thead', null, h('tr', null, h('th', null, 'File'), h('th', null, 'From version'), h('th', null, 'Saved'), h('th', { class: 'num' }, 'Size'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||||
|
h('tbody', null, list.map((b, i) => h('tr', null,
|
||||||
|
h('td', null, h('span', { class: 'mono' }, b.name), i === 0 ? h('span', { class: 'tag plain' }, 'Newest') : null),
|
||||||
|
h('td', { class: 'mono' }, b.version),
|
||||||
|
h('td', null, fmtStamp(b.modified)),
|
||||||
|
h('td', { class: 'num' }, fmtBytes(b.size)),
|
||||||
|
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => remove(b) }, 'Remove'))))))) : h('p', { class: 'muted', style: { margin: '12px 0 0' } }, 'No copies from updates.'),
|
||||||
|
list.length ? h('p', { class: 'hint', style: { margin: '8px 0 0' } }, list.length + (list.length === 1 ? ' copy, ' : ' copies, ') + fmtBytes(total) + ' next to config.json. After each update, only the newest 3 are kept.') : null);
|
||||||
|
};
|
||||||
|
|
||||||
// backup
|
// backup
|
||||||
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
@@ -2303,11 +2334,13 @@
|
|||||||
|
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'bk' },
|
h('section', { class: 'card', 'aria-labelledby': 'bk' },
|
||||||
h('h3', { id: 'bk' }, 'Backup & restore'),
|
h('h3', { id: 'bk' }, 'Backup & restore'),
|
||||||
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'),
|
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key, preshared keys and authenticator app secrets.'),
|
||||||
h('div', { class: 'actions' },
|
h('div', { class: 'actions' },
|
||||||
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
||||||
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
||||||
restoreInput)));
|
restoreInput),
|
||||||
|
copies));
|
||||||
|
drawCopies();
|
||||||
const jumpTo = location.hash.split('#')[2];
|
const jumpTo = location.hash.split('#')[2];
|
||||||
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
|
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,30 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||||
bearer("GET", "/backup", 403)
|
bearer("GET", "/backup", 403)
|
||||||
|
bearer("GET", "/update-backups", 403)
|
||||||
|
bearer("DELETE", "/update-backups", 403)
|
||||||
|
|
||||||
|
// Config copies made by update: listed newest first, removed one by
|
||||||
|
// one or all at once; nothing else in the folder can be removed.
|
||||||
|
for i, v := range []string{"v0.3.2", "v0.4.0"} {
|
||||||
|
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||||
|
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||||
|
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
|
||||||
|
}
|
||||||
|
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
|
||||||
|
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
|
||||||
|
t.Fatalf("update backups: %v", list)
|
||||||
|
}
|
||||||
|
call("DELETE", "/update-backups/config.json", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/stats.json", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
|
||||||
|
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
|
||||||
|
t.Fatalf("remove all: %v", r)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
|
||||||
|
t.Fatal("config.json is gone:", err)
|
||||||
|
}
|
||||||
|
|
||||||
call("DELETE", "/peers/"+id, nil, 200)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
if len(store.Get().Peers) != 0 {
|
if len(store.Get().Peers) != 0 {
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ func cmdUpdate(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
backup := configFile + ".bak-" + oldVersion
|
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
|
||||||
step("Backing up config to %s", backup)
|
step("Backing up config to %s", backup)
|
||||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -627,6 +627,11 @@ func cmdUpdate(args []string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
||||||
}
|
}
|
||||||
|
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
|
||||||
|
}
|
||||||
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -212,39 +210,15 @@ func fetchRelease(ctx context.Context, url string) (*Release, error) {
|
|||||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||||
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
||||||
}
|
}
|
||||||
if r.Draft || r.Prerelease || parseVersion(r.Tag) == nil {
|
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
|
||||||
return nil, errors.New("the latest release is not a published version")
|
return nil, errors.New("the latest release is not a published version")
|
||||||
}
|
}
|
||||||
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var versionRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)`)
|
|
||||||
|
|
||||||
// parseVersion reads "v0.4.0", "0.4.0" or "v0.4.0-3-gb18d16a" (a build
|
|
||||||
// after v0.4.0) as major, minor and patch, or nil.
|
|
||||||
func parseVersion(s string) []int {
|
|
||||||
m := versionRe.FindStringSubmatch(s)
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]int, 3)
|
|
||||||
for i := range out {
|
|
||||||
out[i], _ = strconv.Atoi(m[i+1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// newerVersion reports whether latest is a higher version than running.
|
// newerVersion reports whether latest is a higher version than running.
|
||||||
// A running version that is not a version number is never out of date.
|
// A running version that is not a version number is never out of date.
|
||||||
func newerVersion(latest, running string) bool {
|
func newerVersion(latest, running string) bool {
|
||||||
l, r := parseVersion(latest), parseVersion(running)
|
c, ok := compareVersions(latest, running)
|
||||||
if l == nil || r == nil {
|
return ok && c > 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range l {
|
|
||||||
if l[i] != r[i] {
|
|
||||||
return l[i] > r[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each update copies config.json to config.json.bak-<old version> next to
|
||||||
|
// it, in case the new version must be rolled back. The copies hold the same
|
||||||
|
// secrets as a backup, so the web interface lists them and can remove them,
|
||||||
|
// and update keeps only the newest few.
|
||||||
|
|
||||||
|
const keepUpdateBackups = 3
|
||||||
|
|
||||||
|
type UpdateBackup struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy that would overwrite an older one gets the time appended.
|
||||||
|
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
|
||||||
|
|
||||||
|
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
|
||||||
|
|
||||||
|
// newUpdateBackupPath names the copy update makes of configPath.
|
||||||
|
func newUpdateBackupPath(configPath, version string, now time.Time) string {
|
||||||
|
p := configPath + ".bak-" + version
|
||||||
|
if _, err := os.Lstat(p); err == nil {
|
||||||
|
p += now.Format("-20060102-1504")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUpdateBackups returns the copies next to configPath, newest first.
|
||||||
|
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(configPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefix := updateBackupPrefix(configPath)
|
||||||
|
out := []UpdateBackup{}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
|
||||||
|
Modified: fi.ModTime(), Size: fi.Size()})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUpdateBackup deletes one copy; any other name is refused.
|
||||||
|
func removeUpdateBackup(configPath, name string) error {
|
||||||
|
prefix := updateBackupPrefix(configPath)
|
||||||
|
path := filepath.Join(filepath.Dir(configPath), name)
|
||||||
|
fi, err := os.Lstat(path)
|
||||||
|
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
|
||||||
|
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
|
||||||
|
return badRequest("no copy named %q", name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
|
||||||
|
func pruneUpdateBackups(configPath string, keep int) (int, error) {
|
||||||
|
list, err := listUpdateBackups(configPath)
|
||||||
|
if err != nil || len(list) <= keep {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, b := range list[keep:] {
|
||||||
|
if err := removeUpdateBackup(configPath, b.Name); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := listUpdateBackups(a.store.path)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if err := removeUpdateBackup(a.store.path, name); err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "update backup removed", "file", name)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := pruneUpdateBackups(a.store.path, 0)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "update backups removed", "count", n)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateBackups(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := filepath.Join(dir, "config.json")
|
||||||
|
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
|
||||||
|
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// A second copy of the same version gets the time appended instead of
|
||||||
|
// overwriting the first.
|
||||||
|
first := newUpdateBackupPath(cfg, "unknown", now)
|
||||||
|
if filepath.Base(first) != "config.json.bak-unknown" {
|
||||||
|
t.Fatalf("first copy: %s", first)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(first, []byte("{}"), 0o600)
|
||||||
|
second := newUpdateBackupPath(cfg, "unknown", now)
|
||||||
|
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
|
||||||
|
t.Fatalf("second copy: %s", second)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(second, []byte("{}"), 0o600)
|
||||||
|
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
|
||||||
|
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||||
|
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||||
|
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
|
||||||
|
_ = os.Chtimes(second, now, now.Add(-time.Hour))
|
||||||
|
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
|
||||||
|
|
||||||
|
list, err := listUpdateBackups(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, b := range list {
|
||||||
|
got = append(got, b.Version)
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
|
||||||
|
t.Fatalf("versions, newest first: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
|
||||||
|
if err := removeUpdateBackup(cfg, bad); err == nil {
|
||||||
|
t.Errorf("removed %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
|
||||||
|
t.Fatalf("prune: %d, %v", n, err)
|
||||||
|
}
|
||||||
|
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
|
||||||
|
t.Fatalf("after prune: %v", list)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cfg); err != nil {
|
||||||
|
t.Fatal("config.json is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user