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.
684 lines
19 KiB
Go
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
|
|
}
|