Files
dsh-web/internal/api/config.go
T
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00

684 lines
19 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/backup"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"gopkg.in/yaml.v3"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)
var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
var configBodySlots = make(chan struct{}, 4)
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
a.backupManagement(w, r, i, action)
return
}
controller := http.NewResponseController(w)
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
var flush func()
w, flush = deferJSONResponse(w)
defer flush()
}
if r.Method != http.MethodGet {
select {
case configBodySlots <- struct{}{}:
defer func() {
r.Body = http.NoBody
<-configBodySlots
}()
default:
fail(w, 503, "configuration uploads busy; retry later")
return
}
limit := int64(4096)
multipartUpload := action == "backups" && strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")
if action == "profile" {
limit = 2 << 20
} else if multipartUpload {
limit = min(64<<20, backup.MaxEncryptedBytes+16384)
}
if !configReadBody(w, r, controller, limit) {
return
}
if multipartUpload {
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
if r.ParseMultipartForm(1<<20) != nil {
fail(w, 400, "invalid or oversized backup upload")
return
}
}
}
inst := a.instanceLocks.Acquire(i.ID)
locked := inst
defer func() {
if locked != nil {
locked.Release()
}
}()
if r.Method != http.MethodGet {
a.readersMu.Lock()
readers := a.workspaceReaders[i.ID]
a.readersMu.Unlock()
if readers > 0 {
fail(w, 409, "instance files busy; retry later")
return
}
user := a.userLocks.Acquire(i.UID)
defer user.Release()
}
current, e := a.s.GetInstance(i.ID)
if e != nil || current.UID != i.UID {
fail(w, 404, "not found")
return
}
i = current
if r.Method != http.MethodGet {
admissionMu.RLock()
defer admissionMu.RUnlock()
buildQueue.Lock()
err := a.ensureBuilds()
buildQueue.Unlock()
if err != nil {
fail(w, 500, "database error")
return
}
var blocked int
if err = a.s.DB.QueryRowContext(r.Context(), "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked); err != nil {
fail(w, 500, "database error")
return
}
if blocked > 0 {
fail(w, 409, "image is blocked; configuration mutations and automatic restarts denied")
return
}
}
if i.Status == "error" || a.upgradeRecoveryRequired(i) {
fail(w, 409, "instance recovery required")
return
}
home := filepath.Join(a.root, "workspaces", i.UID, "instances", i.ID, "home")
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
storage, e := os.OpenRoot(a.root)
if e != nil {
fail(w, 500, "storage unavailable")
return
}
defer storage.Close()
if configCheckPath(storage, filepath.ToSlash(filepath.Join("backups", i.UID, i.ID, "archive"))) != nil || configCheckPath(storage, filepath.ToSlash(filepath.Join("workspaces", i.UID, "instances", i.ID, "home", "profile"))) != nil {
fail(w, 400, "unsafe storage path")
return
}
if e = os.MkdirAll(dir, 0700); e != nil {
fail(w, 500, "backup storage unavailable")
return
}
wasRunning := false
if r.Method != "GET" {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
c, e := a.docker.Inspect(ctx, i.ContainerID)
if e != nil {
fail(w, 502, "container inspection failed")
return
}
wasRunning = c.State.Running
if c.State.Paused || c.State.Restarting {
fail(w, 409, "unsupported container state")
return
}
if c.State.Running && action == "profile" {
fail(w, 409, "stop instance before changing configuration or backing up")
return
}
}
switch action {
case "profile":
parent, e := upgradeRoot(a.root, i)
if e != nil {
fail(w, 400, "unsafe home")
return
}
defer parent.Close()
if e = configCheckPath(parent, "home/profiles/web/cordis.patch.yml"); e != nil {
fail(w, 400, "unsafe profile")
return
}
root, e := configProfileRoot(parent, r.Method == "PUT")
if e != nil {
if r.Method == "GET" && errors.Is(e, os.ErrNotExist) {
writeJSON(w, map[string]string{"content": ""})
return
}
fail(w, 404, "home unavailable")
return
}
defer root.Close()
const path = "cordis.patch.yml"
if r.Method == "GET" {
f, e := root.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if errors.Is(e, os.ErrNotExist) {
writeJSON(w, map[string]string{"content": ""})
return
}
if e != nil {
fail(w, 400, "profile unavailable")
return
}
defer f.Close()
st, e := f.Stat()
if e != nil || !st.Mode().IsRegular() || st.Size() > 1<<20 {
fail(w, 400, "invalid profile")
return
}
b, e := io.ReadAll(io.LimitReader(f, 1<<20))
if e != nil {
fail(w, 500, "read failed")
return
}
writeJSON(w, map[string]string{"content": string(b)})
return
}
if r.Method != "PUT" {
fail(w, 405, "method not allowed")
return
}
var in struct {
Content string `json:"content"`
Restart bool `json:"restart"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&in) != nil || len(in.Content) > 1<<20 {
fail(w, 400, "invalid profile")
return
}
var document any
if strings.TrimSpace(in.Content) == "" {
in.Content = "[]\n"
}
if yaml.Unmarshal([]byte(in.Content), &document) != nil {
fail(w, 400, "invalid YAML")
return
}
var previous int64
if st, err := root.Lstat(path); err == nil {
if !st.Mode().IsRegular() {
fail(w, 400, "invalid profile")
return
}
previous = st.Size()
} else if !errors.Is(err, os.ErrNotExist) {
fail(w, 400, "profile unavailable")
return
}
if e = a.quotaGrowth(i.UID, int64(len(in.Content))-previous); e != nil {
fail(w, 413, "quota exceeded or usage unavailable")
return
}
tmp := ".panel-" + store.ID()
f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
fail(w, 500, "profile write failed")
return
}
defer root.Remove(tmp)
_, e = f.WriteString(in.Content)
if e == nil {
e = f.Chown(1000, 1000)
}
if e == nil {
e = f.Sync()
}
f.Close()
if e == nil {
e = root.Rename(tmp, path)
}
if e != nil {
fail(w, 500, "profile write failed")
return
}
a.s.AddAudit(i.UID, "profile.save", i.ID, "")
if in.Restart {
if a.configRestart(i) != nil {
fail(w, 502, "profile saved but restart failed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
if a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r)) != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "profile saved but runtime readiness failed")
return
}
a.s.SetInstance(i.ID, "running")
}
writeJSON(w, map[string]bool{"ok": true})
case "backups":
if r.Method == "GET" {
if id := r.URL.Query().Get("download"); id != "" {
if !backupIDPattern.MatchString(id) {
fail(w, 400, "invalid backup ID")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+id+".dshbak")
root, e := os.OpenRoot(dir)
if e != nil {
fail(w, 404, "backup unavailable")
return
}
defer root.Close()
f, e := root.OpenFile(id+".dshbak", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if e != nil {
fail(w, 404, "backup unavailable")
return
}
defer f.Close()
st, e := f.Stat()
if e != nil || !st.Mode().IsRegular() {
fail(w, 400, "invalid backup")
return
}
locked.Release()
locked = nil
http.ServeContent(w, r, id+".dshbak", st.ModTime(), f)
return
}
entries, e := os.ReadDir(dir)
if e != nil {
fail(w, 500, "backup listing failed")
return
}
out := []map[string]any{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".dshbak" {
continue
}
st, e := entry.Info()
if e != nil || !st.Mode().IsRegular() {
continue
}
id := entry.Name()[:len(entry.Name())-7]
kind, schema := "legacy", 0
_ = a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID).Scan(&kind, &schema)
out = append(out, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema})
}
writeJSON(w, out)
return
}
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
entries, _ := os.ReadDir(dir)
if len(entries) >= 20 {
fail(w, 409, "backup retention limit reached (20); administrator cleanup required")
return
}
id := store.ID()
path := filepath.Join(dir, id+".dshbak")
kind, schema := "manual", 2
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
kind, schema = "imported", 0
source, _, err := r.FormFile("file")
if err != nil {
fail(w, 400, "backup file required")
return
}
defer source.Close()
dest, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
fail(w, 500, "backup storage unavailable")
return
}
n, err := io.Copy(dest, io.LimitReader(source, backup.MaxEncryptedBytes+1))
closeErr := dest.Close()
if err != nil || closeErr != nil || n > backup.MaxEncryptedBytes {
os.Remove(path)
fail(w, 400, "invalid or oversized backup upload")
return
}
key, _ := a.configKey()
_, cleanup, err := backup.Prepare(path, r.FormValue("password"), key)
clear(key)
if err != nil {
os.Remove(path)
fail(w, 400, "invalid backup or original backup password")
return
}
cleanup()
} else {
var in struct {
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || len(in.Password) == 0 || len(in.Password) > 72 {
fail(w, 400, "login password required")
return
}
if !a.reauthenticate(w, i.UID, in.Password) {
return
}
if wasRunning {
if e = a.configStop(r.Context(), i.ContainerID); e != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "runtime stop failed")
return
}
}
if e = backup.CreateWithPassword(home, path, in.Password, backup.Metadata{DSHVersion: i.Version, CreatedAt: time.Now().UTC()}); e != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 400, "backup failed: unsafe or oversized configuration")
return
}
}
st, _ := os.Stat(path)
if e = a.indexBackup(i, id, kind, schema, st.Size()); e != nil {
os.Remove(path)
if wasRunning && kind == "manual" {
a.configRestart(i)
}
fail(w, 500, "backup index unavailable")
return
}
if wasRunning && kind == "manual" && a.configRestart(i) != nil {
fail(w, 502, "backup saved; runtime restart requires recovery")
return
}
a.s.AddAudit(i.UID, "backup.create", i.ID, id)
writeJSON(w, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema})
case "restore":
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
var in struct {
ID string `json:"backup_id"`
Password string `json:"password"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || !backupIDPattern.MatchString(in.ID) {
fail(w, 400, "invalid backup ID")
return
}
key, _ := a.configKey()
restore, cleanup, e := backup.Prepare(filepath.Join(dir, in.ID+".dshbak"), in.Password, key)
clear(key)
if e != nil {
fail(w, 400, "invalid backup or original backup password")
return
}
defer cleanup()
staging, e := os.MkdirTemp("", "dsh-restore-quota-")
if e != nil {
fail(w, 500, "restore staging unavailable")
return
}
defer os.RemoveAll(staging)
if e = restore(staging); e != nil {
fail(w, 400, "invalid restore contents")
return
}
staged, e := os.OpenRoot(staging)
if e != nil {
fail(w, 500, "restore staging unavailable")
return
}
restoredBytes, e := quotaTreeBytes(staged, ".")
staged.Close()
if e != nil {
fail(w, 413, "restore size unavailable")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
parent, e := upgradeRoot(a.root, i)
if e != nil {
fail(w, 500, "storage unavailable")
return
}
defer parent.Close()
f, e := parent.Open(".")
if e != nil {
fail(w, 500, "snapshot storage unavailable")
return
}
entries, e := f.ReadDir(-1)
f.Close()
count := 0
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") {
count++
}
}
if e != nil || count >= 3 {
fail(w, 409, "restore snapshot limit reached; administrator cleanup required")
return
}
snapshot := "pre-restore-" + store.ID()
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if wasRunning {
if e = a.configStop(ctx, i.ContainerID); e != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 502, "runtime stop failed")
return
}
}
snapshotBytes, quotaErr := a.quotaSnapshot(i.UID, parent, restoredBytes)
if quotaErr != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if e = upgradeCopy(r.Context(), parent, "home", snapshot, snapshotBytes); e != nil {
cleanupErr := parent.RemoveAll(snapshot)
if wasRunning {
a.configRestart(i)
}
if cleanupErr != nil {
a.s.AddAudit(i.UID, "backup.snapshot.cleanup_failed", i.ID, snapshot)
fail(w, 500, "restore snapshot cleanup requires administrator recovery")
return
}
fail(w, 400, "cannot snapshot home for restore")
return
}
if _, e = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); e != nil {
if wasRunning {
a.configRestart(i)
}
fail(w, 500, "cannot record restore state")
return
}
restoreErr := restore(home)
if restoreErr == nil {
var root *os.Root
root, restoreErr = os.OpenRoot(home)
if restoreErr == nil {
for _, p := range []string{"profiles", ".credentials.yaml", "cordis.patch.yml", "dsh-ssh.json", "skins", "pets", "agent-presets", ".agent-presets"} {
if restoreErr = configChownTree(root, p); restoreErr != nil {
break
}
}
root.Close()
}
}
if restoreErr == nil {
restoreErr = a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil)
}
if restoreErr == nil {
restoreErr = a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r))
}
if restoreErr == nil && !wasRunning {
restoreErr = a.configStop(ctx, i.ContainerID)
}
if restoreErr != nil {
rollbackCtx, done := context.WithTimeout(context.Background(), 45*time.Second)
defer done()
if a.configStop(rollbackCtx, i.ContainerID) != nil {
fail(w, 500, "restore rollback requires runtime stop")
return
}
failed := "failed-restore-" + store.ID()
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
a.s.SetInstance(i.ID, "error")
fail(w, 500, "restore rollback requires administrator recovery")
return
}
if wasRunning && a.configRestart(i) != nil {
fail(w, 500, "restore rollback restart requires recovery")
return
}
status := "exited"
if wasRunning {
status = "running"
}
if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil {
fail(w, 500, "restore state requires administrator recovery")
return
}
fail(w, 400, "restore rejected; previous home restored")
return
}
status := "exited"
if wasRunning {
status = "running"
}
if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil {
fail(w, 500, "restore state requires administrator recovery")
return
}
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
a.s.AddAudit(i.UID, "backup.restore", i.ID, in.ID)
writeJSON(w, map[string]bool{"ok": true})
default:
fail(w, 404, "not found")
}
}
func configReadBody(w http.ResponseWriter, r *http.Request, controller *http.ResponseController, limit int64) bool {
if r.ContentLength > limit {
fail(w, 413, "configuration request too large")
return false
}
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 500, "cannot set configuration read deadline")
return false
}
defer controller.SetReadDeadline(time.Time{})
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
var oversized *http.MaxBytesError
switch {
case errors.As(err, &oversized):
fail(w, 413, "configuration request too large")
case errors.Is(err, os.ErrDeadlineExceeded):
fail(w, 408, "configuration upload timed out")
default:
fail(w, 400, "cannot read configuration request")
}
return false
}
r.Body = io.NopCloser(bytes.NewReader(body))
return true
}
func (a *API) configKey() ([]byte, error) {
path := filepath.Join(a.root, "backup.key")
f, e := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if e == nil {
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() {
return nil, errors.New("invalid key")
}
b, e := io.ReadAll(io.LimitReader(f, 33))
if e != nil || len(b) != 32 {
return nil, errors.New("invalid key")
}
return b, nil
}
return nil, e
}
func configCheckPath(root *os.Root, name string) error {
parts := strings.Split(name, "/")
for n := range parts {
st, e := root.Lstat(strings.Join(parts[:n+1], "/"))
if errors.Is(e, os.ErrNotExist) {
return nil
}
if e != nil {
return e
}
if st.Mode()&os.ModeSymlink != 0 || (n < len(parts)-1 && !st.IsDir()) {
return errors.New("unsafe configuration path")
}
}
return nil
}
func configProfileRoot(parent *os.Root, create bool) (*os.Root, error) {
root, err := parent.OpenRoot(".")
if err != nil {
return nil, err
}
for _, name := range []string{"home", "profiles", "web"} {
created := false
st, e := root.Lstat(name)
if errors.Is(e, os.ErrNotExist) && create && name != "home" {
e = root.Mkdir(name, 0750)
if e == nil {
created = true
st, e = root.Lstat(name)
}
}
if e != nil {
root.Close()
return nil, e
}
if !st.IsDir() {
root.Close()
return nil, errors.New("unsafe profile directory")
}
next, e := root.OpenRoot(name)
root.Close()
if e != nil {
return nil, e
}
actual, e := next.Stat(".")
if e != nil || !os.SameFile(st, actual) {
next.Close()
return nil, errors.New("profile directory changed")
}
if created {
if e = next.Chown(".", 1000, 1000); e != nil {
next.Close()
return nil, e
}
}
root = next
}
return root, nil
}