231 lines
5.9 KiB
Go
231 lines
5.9 KiB
Go
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
|
|
}
|
|
// Archive link text only; do not read or traverse its target.
|
|
} 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)
|
|
}
|