Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/backup"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
if r.Method != http.MethodGet && a.workspaceReaders[i.ID] > 0 {
|
||||
fail(w, 409, "instance files busy; retry later")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
workspaceMu.Lock()
|
||||
defer workspaceMu.Unlock()
|
||||
}
|
||||
current, e := a.s.GetInstance(i.ID)
|
||||
if e != nil || current.UID != i.UID {
|
||||
fail(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
i = current
|
||||
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 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
|
||||
}
|
||||
a.mu.Unlock()
|
||||
locked = false
|
||||
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
|
||||
}
|
||||
var hash string
|
||||
if a.s.DB.QueryRow("SELECT pass_hash FROM users WHERE id=?", i.UID).Scan(&hash) != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
|
||||
fail(w, 403, "incorrect login 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
|
||||
}
|
||||
Reference in New Issue
Block a user