316 lines
8.3 KiB
Go
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})
|
|
}
|