mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-18 02:48:40 +00:00
ipn/{ipnlocal,localapi}, client/tailscale: add file get/delete APIs
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
a9745a0b68
commit
6d1a9017c9
@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -137,3 +138,67 @@ func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
|||||||
}
|
}
|
||||||
return st, nil
|
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
|
||||||
|
}
|
||||||
|
@ -10,7 +10,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v2/ffcli"
|
"github.com/peterbourgon/ff/v2/ffcli"
|
||||||
"tailscale.com/client/tailscale"
|
"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.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.ipn, "ipn", false, "If true, subscribe to IPN notifications")
|
||||||
fs.BoolVar(&debugArgs.netMap, "netmap", true, "whether to include netmap in --ipn mode")
|
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
|
return fs
|
||||||
})(),
|
})(),
|
||||||
}
|
}
|
||||||
@ -33,6 +37,7 @@ var debugArgs struct {
|
|||||||
goroutines bool
|
goroutines bool
|
||||||
ipn bool
|
ipn bool
|
||||||
netMap bool
|
netMap bool
|
||||||
|
file string
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDebug(ctx context.Context, args []string) error {
|
func runDebug(ctx context.Context, args []string) error {
|
||||||
@ -62,5 +67,28 @@ func runDebug(ctx context.Context, args []string) error {
|
|||||||
pump(ctx, bc, c)
|
pump(ctx, bc, c)
|
||||||
return errors.New("exit")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -1974,3 +1975,33 @@ func temporarilySetMachineKeyInPersist() bool {
|
|||||||
}
|
}
|
||||||
return true
|
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)
|
||||||
|
}
|
||||||
|
@ -41,6 +41,17 @@ type peerAPIServer struct {
|
|||||||
|
|
||||||
const partialSuffix = ".tspartial"
|
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
|
// hasFilesWaiting reports whether any files are buffered in the
|
||||||
// tailscaled daemon storage.
|
// tailscaled daemon storage.
|
||||||
func (s *peerAPIServer) hasFilesWaiting() bool {
|
func (s *peerAPIServer) hasFilesWaiting() bool {
|
||||||
@ -80,6 +91,85 @@ func (s *peerAPIServer) hasFilesWaiting() bool {
|
|||||||
return false
|
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) {
|
func (s *peerAPIServer) listen(ip netaddr.IP, ifState *interfaces.State) (ln net.Listener, err error) {
|
||||||
ipStr := ip.String()
|
ipStr := ip.String()
|
||||||
|
|
||||||
@ -264,13 +354,12 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "no rootdir", http.StatusInternalServerError)
|
http.Error(w, "no rootdir", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := path.Base(r.URL.Path)
|
baseName := path.Base(r.URL.Path)
|
||||||
if name == "." || name == "/" || strings.HasSuffix(name, partialSuffix) {
|
dstFile, ok := h.ps.diskPath(baseName)
|
||||||
http.Error(w, "bad filename", http.StatusForbidden)
|
if !ok {
|
||||||
|
http.Error(w, "bad filename", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileBase := strings.ReplaceAll(url.PathEscape(name), ":", "%3a")
|
|
||||||
dstFile := filepath.Join(h.ps.rootDir, fileBase)
|
|
||||||
f, err := os.Create(dstFile)
|
f, err := os.Create(dstFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logf("put Create error: %v", err)
|
h.logf("put Create error: %v", err)
|
||||||
@ -296,7 +385,7 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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: set modtime
|
||||||
// TODO: some real response
|
// 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.knownEmpty.Set(false)
|
||||||
h.ps.b.send(ipn.Notify{}) // it will set FilesWaiting
|
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)
|
||||||
|
}
|
||||||
|
@ -7,10 +7,13 @@ package localapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"inet.af/netaddr"
|
"inet.af/netaddr"
|
||||||
"tailscale.com/ipn/ipnlocal"
|
"tailscale.com/ipn/ipnlocal"
|
||||||
@ -53,6 +56,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/localapi/v0/files/") {
|
||||||
|
h.serveFiles(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/localapi/v0/whois":
|
case "/localapi/v0/whois":
|
||||||
h.serveWhoIs(w, r)
|
h.serveWhoIs(w, r)
|
||||||
@ -131,6 +138,49 @@ func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
e.Encode(st)
|
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 {
|
func defBool(a string, def bool) bool {
|
||||||
if a == "" {
|
if a == "" {
|
||||||
return def
|
return def
|
||||||
|
Loading…
x
Reference in New Issue
Block a user