Files
dsh-web/internal/api/backup_management.go
T

316 lines
8.3 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"io/fs"
"net/http"
"os"
"regexp"
"strings"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
var snapshotIDPattern = regexp.MustCompile(`^(pre-restore-[a-f0-9]{32}|failed-restore-[a-f0-9]{32}|upgrade-[a-f0-9]{32}-snapshot|failed-home-[a-f0-9]{32})$`)
var errUnsafeManagementPath = errors.New("unsafe storage path")
func managementPathError(w http.ResponseWriter, err error) {
if errors.Is(err, errUnsafeManagementPath) {
fail(w, 400, "unsafe storage path")
return
}
fail(w, 500, "storage unavailable")
}
type snapshotInfo struct {
ID string `json:"id"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
Protected bool `json:"protected"`
ProtectionReason string `json:"protection_reason"`
SizeError string `json:"size_error,omitempty"`
Restorable bool `json:"restorable"`
RestoreReason string `json:"restore_reason"`
SourceVersion string `json:"source_version,omitempty"`
SourceImage string `json:"source_image,omitempty"`
}
func managementRoot(base string, parts ...string) (*os.Root, error) {
root, err := os.OpenRoot(base)
if err != nil {
return nil, err
}
for _, part := range parts {
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") {
root.Close()
return nil, errUnsafeManagementPath
}
st, err := root.Lstat(part)
if err != nil {
root.Close()
return nil, err
}
if !st.IsDir() {
root.Close()
return nil, errUnsafeManagementPath
}
next, err := root.OpenRoot(part)
root.Close()
if err != nil {
return nil, err
}
actual, err := next.Stat(".")
if err != nil {
next.Close()
return nil, err
}
if !os.SameFile(st, actual) {
next.Close()
return nil, errUnsafeManagementPath
}
root = next
}
return root, nil
}
func (a *API) backupProtection(i store.Instance) string {
if i.Status == "error" {
return "instance recovery required"
}
var exists, pending int
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil {
return "recovery journal unavailable"
}
if exists != 0 {
if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND (phase IS NULL OR phase NOT IN ('complete','rolled_back'))", i.ID).Scan(&pending); err != nil {
return "recovery journal unavailable"
}
if pending != 0 {
return "instance recovery required"
}
}
return ""
}
func snapshotBytes(ctx context.Context, root *os.Root, name string) (int64, error) {
var total int64
count := 0
err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
return err
}
count++
if count > 100000 || strings.Count(p, "/") > 128 {
return errors.New("tree limit")
}
st, err := d.Info()
if err != nil {
return err
}
if st.Mode().IsRegular() {
total += st.Size()
}
return nil
})
return total, err
}
func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
w, flush := deferJSONResponse(w)
defer flush()
if r.Method != http.MethodDelete && !(action == "snapshots" && r.Method == http.MethodGet) {
fail(w, 405, "method not allowed")
return
}
id := r.URL.Query().Get("id")
if r.Method == http.MethodDelete && ((action == "snapshots" && !snapshotIDPattern.MatchString(id)) || (action == "backups" && !backupIDPattern.MatchString(id))) {
fail(w, 400, "invalid ID")
return
}
lock := a.instanceLocks.Acquire(i.ID)
defer lock.Release()
user := a.userLocks.Acquire(i.UID)
defer user.Release()
current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID {
fail(w, 404, "not found")
return
}
i = current
reason := a.backupProtection(i)
if r.Method == http.MethodDelete && reason != "" {
fail(w, 409, reason)
return
}
parts := []string{"workspaces", i.UID, "instances", i.ID}
if action == "backups" {
parts = []string{"backups", i.UID, i.ID}
}
root, err := managementRoot(a.root, parts...)
if errors.Is(err, os.ErrNotExist) {
if action == "backups" {
a.deleteBackup(w, i, nil, id)
return
}
if r.Method == http.MethodGet {
writeJSON(w, []snapshotInfo{})
return
}
fail(w, 404, "not found")
return
}
if err != nil {
managementPathError(w, err)
return
}
defer root.Close()
if r.Method == http.MethodGet {
f, err := root.Open(".")
if err != nil {
fail(w, 500, "snapshot listing failed")
return
}
entries, err := f.ReadDir(-1)
f.Close()
if err != nil {
fail(w, 500, "snapshot listing failed")
return
}
out := []snapshotInfo{}
for _, entry := range entries {
if !snapshotIDPattern.MatchString(entry.Name()) || !entry.IsDir() {
continue
}
st, err := entry.Info()
if err != nil {
fail(w, 500, "snapshot metadata unavailable")
return
}
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID)
s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason
if reason != "" {
s.Restorable = false
s.RestoreReason = reason
}
s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
if err != nil {
s.Size = -1
s.SizeError = "snapshot size unavailable: " + err.Error()
s.Restorable = false
s.RestoreReason = "snapshot size unavailable"
}
out = append(out, s)
}
writeJSON(w, out)
return
}
if action == "backups" {
a.deleteBackup(w, i, root, id)
return
}
st, err := root.Lstat(id)
if errors.Is(err, os.ErrNotExist) {
fail(w, 404, "snapshot not found")
return
}
if err != nil {
managementPathError(w, err)
return
}
if !st.IsDir() {
fail(w, 400, "unsafe snapshot path")
return
}
a.deleteSnapshot(w, i, id, func() error { return root.RemoveAll(id) })
}
func (a *API) deleteSnapshot(w http.ResponseWriter, i store.Instance, id string, remove func() error) {
audit := func(state string) error {
_, err := a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.delete."+state, i.ID, id, time.Now().Unix())
return err
}
if err := audit("started"); err != nil {
fail(w, 500, "audit unavailable")
return
}
if err := remove(); err != nil {
if err := audit("failed"); err != nil {
fail(w, 500, "snapshot deletion failed; audit unavailable")
return
}
fail(w, 500, "snapshot deletion failed")
return
}
if err := audit("completed"); err != nil {
fail(w, 500, "snapshot removed; completion audit unavailable")
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) deleteBackup(w http.ResponseWriter, i store.Instance, root *os.Root, id string) {
name := id + ".dshbak"
var st os.FileInfo
statErr := error(os.ErrNotExist)
if root != nil {
st, statErr = root.Lstat(name)
}
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
fail(w, 500, "backup unavailable")
return
}
if statErr == nil && !st.Mode().IsRegular() {
fail(w, 400, "unsafe backup path")
return
}
tx, err := a.s.DB.Begin()
if err != nil {
fail(w, 500, "backup index unavailable")
return
}
defer tx.Rollback()
var exists, indexed int
err = tx.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='backups'").Scan(&exists)
if err == nil && exists != 0 {
var result sql.Result
result, err = tx.Exec("DELETE FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID)
if err == nil {
n, e := result.RowsAffected()
err = e
indexed = int(n)
}
}
if err != nil {
fail(w, 500, "backup index unavailable")
return
}
if statErr != nil && indexed == 0 {
fail(w, 404, "backup not found")
return
}
if _, err = tx.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "backup.delete", i.ID, id, time.Now().Unix()); err != nil {
fail(w, 500, "audit unavailable")
return
}
if statErr == nil {
if err = root.Remove(name); err != nil {
fail(w, 500, "backup deletion failed")
return
}
}
// Filesystem and SQLite cannot commit atomically; a retry clears a stale index after a failed commit.
if err = tx.Commit(); err != nil {
fail(w, 500, "backup removed; index update failed, retry deletion")
return
}
writeJSON(w, map[string]bool{"ok": true})
}