657 lines
18 KiB
Go
657 lines
18 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"
|
|
"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
|
|
}
|