feat: add full snapshot downloads and safe manual restore
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit")
|
||||
var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type")
|
||||
|
||||
type snapshotArchiveWriter struct {
|
||||
ctx context.Context
|
||||
w io.Writer
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (w *snapshotArchiveWriter) Write(p []byte) (int, error) {
|
||||
if err := w.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int64(len(p)) > w.remaining {
|
||||
return 0, errSnapshotDownloadLimit
|
||||
}
|
||||
n, err := w.w.Write(p)
|
||||
w.remaining -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error {
|
||||
// Bound headers, padding and gzip expansion independently of file content.
|
||||
gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)})
|
||||
tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)})
|
||||
remaining := limit
|
||||
entries := 100000
|
||||
var walk func(*os.Root, string, string, int) error
|
||||
walk = func(parent *os.Root, name, rel string, depth int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
entries--
|
||||
if entries < 0 || depth > 128 {
|
||||
return errSnapshotDownloadLimit
|
||||
}
|
||||
st, err := parent.Lstat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := ""
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
link, err = parent.Readlink(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
// Root resolution also catches escapes through chains of relative links.
|
||||
if _, err = root.Stat(rel); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
} else if !st.IsDir() && !st.Mode().IsRegular() {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
if st.Mode().IsRegular() && st.Size() > remaining {
|
||||
return errSnapshotDownloadLimit
|
||||
}
|
||||
if st.Mode().IsRegular() {
|
||||
remaining -= st.Size()
|
||||
}
|
||||
h, err := tar.FileInfoHeader(st, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Name = filepath.ToSlash(rel)
|
||||
if st.IsDir() {
|
||||
h.Name += "/"
|
||||
}
|
||||
if err = tw.WriteHeader(h); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
child, err := parent.OpenRoot(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer child.Close()
|
||||
actual, err := child.Stat(".")
|
||||
if err != nil || !os.SameFile(st, actual) {
|
||||
return errors.New("snapshot directory changed")
|
||||
}
|
||||
f, err := child.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
children, err := f.ReadDir(128)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
for _, entry := range children {
|
||||
if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
actual, err := f.Stat()
|
||||
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() {
|
||||
return errors.New("snapshot file changed")
|
||||
}
|
||||
if _, err = io.CopyN(tw, f, st.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := f.Stat()
|
||||
if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
|
||||
return errors.New("snapshot file changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(root, ".", ".", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return gz.Close()
|
||||
}
|
||||
|
||||
func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
ids := r.URL.Query()["download"]
|
||||
if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) {
|
||||
fail(w, 400, "invalid snapshot ID")
|
||||
return
|
||||
}
|
||||
id := ids[0]
|
||||
select {
|
||||
case zipSlots <- struct{}{}:
|
||||
defer func() { <-zipSlots }()
|
||||
default:
|
||||
fail(w, 429, "too many archive downloads")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
|
||||
defer cancel()
|
||||
archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz")
|
||||
if err != nil {
|
||||
fail(w, 500, "temporary archive unavailable")
|
||||
return
|
||||
}
|
||||
defer os.Remove(archive.Name())
|
||||
defer archive.Close()
|
||||
code := 500
|
||||
err = func() error {
|
||||
lock := a.instanceLocks.Acquire(i.ID)
|
||||
defer lock.Release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := a.s.GetInstance(i.ID)
|
||||
if err != nil || current.UID != i.UID {
|
||||
code = 404
|
||||
return errors.New("not found")
|
||||
}
|
||||
root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
code = 404
|
||||
}
|
||||
if errors.Is(err, errUnsafeManagementPath) {
|
||||
code = 400
|
||||
}
|
||||
return errors.New("snapshot unavailable or unsafe path")
|
||||
}
|
||||
defer root.Close()
|
||||
return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit)
|
||||
}()
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnsafeSnapshotArchive) {
|
||||
code = 400
|
||||
}
|
||||
if errors.Is(err, errSnapshotDownloadLimit) {
|
||||
code = 413
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
code = 408
|
||||
}
|
||||
fail(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
fail(w, 408, "snapshot download timed out or canceled")
|
||||
return
|
||||
}
|
||||
if _, err = archive.Seek(0, io.SeekStart); err != nil {
|
||||
fail(w, 500, "archive unavailable")
|
||||
return
|
||||
}
|
||||
controller := http.NewResponseController(w)
|
||||
if err = controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
fail(w, 500, "download unavailable")
|
||||
return
|
||||
}
|
||||
defer controller.SetWriteDeadline(time.Time{})
|
||||
a.s.AddAudit(i.UID, "snapshot.download", i.ID, id)
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"}))
|
||||
http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive)
|
||||
}
|
||||
Reference in New Issue
Block a user