From 6d1a9017c90e17a582802f3330c492d07c373142 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 30 Mar 2021 12:56:00 -0700 Subject: [PATCH] ipn/{ipnlocal,localapi}, client/tailscale: add file get/delete APIs Signed-off-by: Brad Fitzpatrick --- client/tailscale/tailscale.go | 65 ++++++++++++++++++++ cmd/tailscale/cli/debug.go | 28 +++++++++ ipn/ipnlocal/local.go | 31 ++++++++++ ipn/ipnlocal/peerapi.go | 111 ++++++++++++++++++++++++++++++++-- ipn/localapi/localapi.go | 50 +++++++++++++++ 5 files changed, 279 insertions(+), 6 deletions(-) diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 62e0e69fc..f2ac76b7b 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" "net" "net/http" @@ -137,3 +138,67 @@ func status(ctx context.Context, queryString string) (*ipnstate.Status, error) { } return st, nil } + +type WaitingFile struct { + Name string + Size int64 +} + +func WaitingFiles(ctx context.Context) ([]WaitingFile, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/", nil) + if err != nil { + return nil, err + } + res, err := DoLocalRequest(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + body, _ := ioutil.ReadAll(res.Body) + return nil, fmt.Errorf("HTTP %s: %s", res.Status, body) + } + var wfs []WaitingFile + if err := json.NewDecoder(res.Body).Decode(&wfs); err != nil { + return nil, err + } + return wfs, nil +} + +func DeleteWaitingFile(ctx context.Context, baseName string) error { + req, err := http.NewRequestWithContext(ctx, "DELETE", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil) + if err != nil { + return err + } + res, err := DoLocalRequest(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + body, _ := ioutil.ReadAll(res.Body) + return fmt.Errorf("expected 204 No Content; got HTTP %s: %s", res.Status, body) + } + return nil +} + +func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil) + if err != nil { + return nil, 0, err + } + res, err := DoLocalRequest(req) + if err != nil { + return nil, 0, err + } + if res.ContentLength == -1 { + res.Body.Close() + return nil, 0, fmt.Errorf("unexpected chunking") + } + if res.StatusCode != 200 { + body, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + return nil, 0, fmt.Errorf("expected 204 No Content; got HTTP %s: %s", res.Status, body) + } + return res.Body, res.ContentLength, nil +} diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index b7a9e28bd..0a1e527ae 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -10,7 +10,10 @@ import ( "errors" "flag" "fmt" + "io" + "log" "os" + "strings" "github.com/peterbourgon/ff/v2/ffcli" "tailscale.com/client/tailscale" @@ -25,6 +28,7 @@ var debugCmd = &ffcli.Command{ fs.BoolVar(&debugArgs.goroutines, "daemon-goroutines", false, "If true, dump the tailscaled daemon's goroutines") fs.BoolVar(&debugArgs.ipn, "ipn", false, "If true, subscribe to IPN notifications") fs.BoolVar(&debugArgs.netMap, "netmap", true, "whether to include netmap in --ipn mode") + fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME") return fs })(), } @@ -33,6 +37,7 @@ var debugArgs struct { goroutines bool ipn bool netMap bool + file string } func runDebug(ctx context.Context, args []string) error { @@ -62,5 +67,28 @@ func runDebug(ctx context.Context, args []string) error { pump(ctx, bc, c) return errors.New("exit") } + if debugArgs.file != "" { + if debugArgs.file == "get" { + wfs, err := tailscale.WaitingFiles(ctx) + if err != nil { + log.Fatal(err) + } + e := json.NewEncoder(os.Stdout) + e.SetIndent("", "\t") + e.Encode(wfs) + return nil + } + delete := strings.HasPrefix(debugArgs.file, "delete:") + if delete { + return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:")) + } + rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file) + if err != nil { + return err + } + log.Printf("Size: %v\n", size) + io.Copy(os.Stdout, rc) + return nil + } return nil } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 4496874a1..86e032828 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -1974,3 +1975,33 @@ func temporarilySetMachineKeyInPersist() bool { } return true } + +func (b *LocalBackend) WaitingFiles() ([]WaitingFile, error) { + b.mu.Lock() + apiSrv := b.peerAPIServer + b.mu.Unlock() + if apiSrv == nil { + return nil, errors.New("peerapi disabled") + } + return apiSrv.WaitingFiles() +} + +func (b *LocalBackend) DeleteFile(name string) error { + b.mu.Lock() + apiSrv := b.peerAPIServer + b.mu.Unlock() + if apiSrv == nil { + return errors.New("peerapi disabled") + } + return apiSrv.DeleteFile(name) +} + +func (b *LocalBackend) OpenFile(name string) (rc io.ReadCloser, size int64, err error) { + b.mu.Lock() + apiSrv := b.peerAPIServer + b.mu.Unlock() + if apiSrv == nil { + return nil, 0, errors.New("peerapi disabled") + } + return apiSrv.OpenFile(name) +} diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index d3b81e1d2..568c6a811 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/ipn/ipnlocal/peerapi.go @@ -41,6 +41,17 @@ type peerAPIServer struct { const partialSuffix = ".tspartial" +func (s *peerAPIServer) diskPath(baseName string) (fullPath string, ok bool) { + clean := path.Clean(baseName) + if clean != baseName || + clean == "." || + strings.ContainsAny(clean, `/\`) || + strings.HasSuffix(clean, partialSuffix) { + return "", false + } + return filepath.Join(s.rootDir, strings.ReplaceAll(url.PathEscape(baseName), ":", "%3a")), true +} + // hasFilesWaiting reports whether any files are buffered in the // tailscaled daemon storage. func (s *peerAPIServer) hasFilesWaiting() bool { @@ -80,6 +91,85 @@ func (s *peerAPIServer) hasFilesWaiting() bool { return false } +// WaitingFile is a JSON-marshaled struct sent by the localapi to pick +// up queued files. +type WaitingFile struct { + Name string + Size int64 +} + +func (s *peerAPIServer) WaitingFiles() (ret []WaitingFile, err error) { + if s.rootDir == "" { + return nil, errors.New("peerapi disabled; no storage configured") + } + f, err := os.Open(s.rootDir) + if err != nil { + return nil, err + } + defer f.Close() + for { + des, err := f.ReadDir(10) + for _, de := range des { + name := de.Name() + if strings.HasSuffix(name, partialSuffix) { + continue + } + if de.Type().IsRegular() { + fi, err := de.Info() + if err != nil { + continue + } + ret = append(ret, WaitingFile{ + Name: filepath.Base(name), + Size: fi.Size(), + }) + } + } + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + return ret, nil +} + +func (s *peerAPIServer) DeleteFile(baseName string) error { + if s.rootDir == "" { + return errors.New("peerapi disabled; no storage configured") + } + path, ok := s.diskPath(baseName) + if !ok { + return errors.New("bad filename") + } + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (s *peerAPIServer) OpenFile(baseName string) (rc io.ReadCloser, size int64, err error) { + if s.rootDir == "" { + return nil, 0, errors.New("peerapi disabled; no storage configured") + } + path, ok := s.diskPath(baseName) + if !ok { + return nil, 0, errors.New("bad filename") + } + f, err := os.Open(path) + if err != nil { + return nil, 0, err + } + fi, err := f.Stat() + if err != nil { + f.Close() + return nil, 0, err + } + return f, fi.Size(), nil +} + func (s *peerAPIServer) listen(ip netaddr.IP, ifState *interfaces.State) (ln net.Listener, err error) { ipStr := ip.String() @@ -264,13 +354,12 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) { http.Error(w, "no rootdir", http.StatusInternalServerError) return } - name := path.Base(r.URL.Path) - if name == "." || name == "/" || strings.HasSuffix(name, partialSuffix) { - http.Error(w, "bad filename", http.StatusForbidden) + baseName := path.Base(r.URL.Path) + dstFile, ok := h.ps.diskPath(baseName) + if !ok { + http.Error(w, "bad filename", 400) return } - fileBase := strings.ReplaceAll(url.PathEscape(name), ":", "%3a") - dstFile := filepath.Join(h.ps.rootDir, fileBase) f, err := os.Create(dstFile) if err != nil { h.logf("put Create error: %v", err) @@ -296,7 +385,7 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) { return } - h.logf("put(%q): %d bytes from %v/%v", name, n, h.remoteAddr.IP, h.peerNode.ComputedName) + h.logf("put of %s from %v/%v", baseName, approxSize(n), h.remoteAddr.IP, h.peerNode.ComputedName) // TODO: set modtime // TODO: some real response @@ -305,3 +394,13 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) { h.ps.knownEmpty.Set(false) h.ps.b.send(ipn.Notify{}) // it will set FilesWaiting } + +func approxSize(n int64) string { + if n <= 1<<10 { + return "<=1KB" + } + if n <= 1<<20 { + return "<=1MB" + } + return fmt.Sprintf("~%dMB", n/1<<20) +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 169f18ba4..b88dc3643 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -7,10 +7,13 @@ package localapi import ( "encoding/json" + "fmt" "io" "net/http" + "net/url" "runtime" "strconv" + "strings" "inet.af/netaddr" "tailscale.com/ipn/ipnlocal" @@ -53,6 +56,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } + if strings.HasPrefix(r.URL.Path, "/localapi/v0/files/") { + h.serveFiles(w, r) + return + } switch r.URL.Path { case "/localapi/v0/whois": h.serveWhoIs(w, r) @@ -131,6 +138,49 @@ func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) { e.Encode(st) } +func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "file access denied", http.StatusForbidden) + return + } + suffix := strings.TrimPrefix(r.URL.Path, "/localapi/v0/files/") + if suffix == "" { + if r.Method != "GET" { + http.Error(w, "want GET to list files", 400) + return + } + wfs, err := h.b.WaitingFiles() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(wfs) + return + } + name, err := url.PathUnescape(suffix) + if err != nil { + http.Error(w, "bad filename", 400) + return + } + if r.Method == "DELETE" { + if err := h.b.DeleteFile(name); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + rc, size, err := h.b.OpenFile(name) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rc.Close() + w.Header().Set("Content-Length", fmt.Sprint(size)) + io.Copy(w, rc) +} + func defBool(a string, def bool) bool { if a == "" { return def