ipn/{ipnlocal,localapi}, client/tailscale: add file get/delete APIs

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2021-03-30 12:56:00 -07:00
parent a9745a0b68
commit 6d1a9017c9
5 changed files with 279 additions and 6 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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