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" && r.Method == http.MethodGet && r.URL.Query().Get("download") != "" { a.snapshotDownload(w, r, i) return } if action == "snapshots" && r.Method == http.MethodPost { a.snapshotRestore(w, r, i) return } 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() failed := "failed-restore-" + store.ID() if e = a.recordSnapshot(i, snapshot, i.Version, i.ImageID); e != nil { fail(w, 500, "snapshot metadata unavailable") return } if e = a.recordSnapshot(i, failed, i.Version, i.ImageID); e != nil { fail(w, 500, "snapshot metadata unavailable") return } 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 } 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 }