// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package cli import ( "bytes" "context" "errors" "flag" "fmt" "io" "io/ioutil" "log" "mime" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" "github.com/peterbourgon/ff/v3/ffcli" "golang.org/x/time/rate" "inet.af/netaddr" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/net/tsaddr" "tailscale.com/version" ) var fileCmd = &ffcli.Command{ Name: "file", ShortUsage: "file ...", ShortHelp: "Send or receive files", Subcommands: []*ffcli.Command{ fileCpCmd, fileGetCmd, }, Exec: func(context.Context, []string) error { // TODO(bradfitz): is there a better ffcli way to // annotate subcommand-required commands that don't // have an exec body of their own? return errors.New("file subcommand required; run 'tailscale file -h' for details") }, } var fileCpCmd = &ffcli.Command{ Name: "cp", ShortUsage: "file cp :", ShortHelp: "Copy file(s) to a host", Exec: runCp, FlagSet: (func() *flag.FlagSet { fs := newFlagSet("cp") fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when is \"-\" (stdin)") fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output") fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets") return fs })(), } var cpArgs struct { name string verbose bool targets bool } func runCp(ctx context.Context, args []string) error { if cpArgs.targets { return runCpTargets(ctx, args) } if len(args) < 2 { return errors.New("usage: tailscale file cp :") } files, target := args[:len(args)-1], args[len(args)-1] if !strings.HasSuffix(target, ":") { return fmt.Errorf("final argument to 'tailscale file cp' must end in colon") } target = strings.TrimSuffix(target, ":") hadBrackets := false if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { hadBrackets = true target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]") } if ip, err := netaddr.ParseIP(target); err == nil && ip.Is6() && !hadBrackets { return fmt.Errorf("an IPv6 literal must be written as [%s]", ip) } else if hadBrackets && (err != nil || !ip.Is6()) { return errors.New("unexpected brackets around target") } ip, _, err := tailscaleIPFromArg(ctx, target) if err != nil { return err } peerAPIBase, isOffline, err := discoverPeerAPIBase(ctx, ip) if err != nil { return fmt.Errorf("can't send to %s: %v", target, err) } if isOffline { fmt.Fprintf(Stderr, "# warning: %s is offline\n", target) } if len(files) > 1 { if cpArgs.name != "" { return errors.New("can't use --name= with multiple files") } for _, fileArg := range files { if fileArg == "-" { return errors.New("can't use '-' as STDIN file when providing filename arguments") } } } for _, fileArg := range files { var fileContents io.Reader var name = cpArgs.name var contentLength int64 = -1 if fileArg == "-" { fileContents = os.Stdin if name == "" { name, fileContents, err = pickStdinFilename() if err != nil { return err } } } else { f, err := os.Open(fileArg) if err != nil { if version.IsSandboxedMacOS() { return errors.New("the GUI version of Tailscale on macOS runs in a macOS sandbox that can't read files") } return err } defer f.Close() fi, err := f.Stat() if err != nil { return err } if fi.IsDir() { return errors.New("directories not supported") } contentLength = fi.Size() fileContents = io.LimitReader(f, contentLength) if name == "" { name = filepath.Base(fileArg) } if slow, _ := strconv.ParseBool(os.Getenv("TS_DEBUG_SLOW_PUSH")); slow { fileContents = &slowReader{r: fileContents} } } dstURL := peerAPIBase + "/v0/put/" + url.PathEscape(name) req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, fileContents) if err != nil { return err } req.ContentLength = contentLength if cpArgs.verbose { log.Printf("sending to %v ...", dstURL) } res, err := http.DefaultClient.Do(req) if err != nil { return err } if res.StatusCode == 200 { io.Copy(ioutil.Discard, res.Body) res.Body.Close() continue } io.Copy(Stdout, res.Body) res.Body.Close() return errors.New(res.Status) } return nil } func discoverPeerAPIBase(ctx context.Context, ipStr string) (base string, isOffline bool, err error) { ip, err := netaddr.ParseIP(ipStr) if err != nil { return "", false, err } fts, err := tailscale.FileTargets(ctx) if err != nil { return "", false, err } for _, ft := range fts { n := ft.Node for _, a := range n.Addresses { if a.IP() != ip { continue } isOffline = n.Online != nil && !*n.Online return ft.PeerAPIURL, isOffline, nil } } return "", false, fileTargetErrorDetail(ctx, ip) } // fileTargetErrorDetail returns a non-nil error saying why ip is an // invalid file sharing target. func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error { found := false if st, err := tailscale.Status(ctx); err == nil && st.Self != nil { for _, peer := range st.Peer { for _, pip := range peer.TailscaleIPs { if pip == ip { found = true if peer.UserID != st.Self.UserID { return errors.New("owned by different user; can only send files to your own devices") } } } } } if found { return errors.New("target seems to be running an old Tailscale version") } if !tsaddr.IsTailscaleIP(ip) { return fmt.Errorf("unknown target; %v is not a Tailscale IP address", ip) } return errors.New("unknown target; not in your Tailnet") } const maxSniff = 4 << 20 func ext(b []byte) string { if len(b) < maxSniff && utf8.Valid(b) { return ".txt" } if exts, _ := mime.ExtensionsByType(http.DetectContentType(b)); len(exts) > 0 { return exts[0] } return "" } // pickStdinFilename reads a bit of stdin to return a good filename // for its contents. The returned Reader is the concatenation of the // read and unread bits. func pickStdinFilename() (name string, r io.Reader, err error) { sniff, err := io.ReadAll(io.LimitReader(os.Stdin, maxSniff)) if err != nil { return "", nil, err } return "stdin" + ext(sniff), io.MultiReader(bytes.NewReader(sniff), os.Stdin), nil } type slowReader struct { r io.Reader rl *rate.Limiter } func (r *slowReader) Read(p []byte) (n int, err error) { const burst = 4 << 10 plen := len(p) if plen > burst { plen = burst } if r.rl == nil { r.rl = rate.NewLimiter(rate.Limit(1<<10), burst) } n, err = r.r.Read(p[:plen]) r.rl.WaitN(context.Background(), n) return } func runCpTargets(ctx context.Context, args []string) error { if len(args) > 0 { return errors.New("invalid arguments with --targets") } fts, err := tailscale.FileTargets(ctx) if err != nil { return err } for _, ft := range fts { n := ft.Node var detail string if n.Online != nil { if !*n.Online { detail = "offline" } } else { detail = "unknown-status" } if detail != "" && n.LastSeen != nil { d := time.Since(*n.LastSeen) detail += fmt.Sprintf("; last seen %v ago", d.Round(time.Minute)) } if detail != "" { detail = "\t" + detail } printf("%s\t%s%s\n", n.Addresses[0].IP(), n.ComputedName, detail) } return nil } var fileGetCmd = &ffcli.Command{ Name: "get", ShortUsage: "file get [--wait] [--verbose] ", ShortHelp: "Move files out of the Tailscale file inbox", Exec: runFileGet, FlagSet: (func() *flag.FlagSet { fs := newFlagSet("get") fs.BoolVar(&getArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty") fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output") return fs })(), } var getArgs struct { wait bool verbose bool } func runFileGet(ctx context.Context, args []string) error { if len(args) != 1 { return errors.New("usage: file get ") } log.SetFlags(0) dir := args[0] if dir == "/dev/null" { return wipeInbox(ctx) } if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { return fmt.Errorf("%q is not a directory", dir) } var wfs []apitype.WaitingFile var err error for { wfs, err = tailscale.WaitingFiles(ctx) if err != nil { return fmt.Errorf("getting WaitingFiles: %v", err) } if len(wfs) != 0 || !getArgs.wait { break } if getArgs.verbose { log.Printf("waiting for file...") } if err := waitForFile(ctx); err != nil { return err } } deleted := 0 for _, wf := range wfs { rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name) if err != nil { return fmt.Errorf("opening inbox file %q: %v", wf.Name, err) } targetFile := filepath.Join(dir, wf.Name) of, err := os.OpenFile(targetFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644) if err != nil { if _, err := os.Stat(targetFile); err == nil { return fmt.Errorf("refusing to overwrite %v", targetFile) } return err } _, err = io.Copy(of, rc) rc.Close() if err != nil { return fmt.Errorf("failed to write %v: %v", targetFile, err) } if err := of.Close(); err != nil { return err } if getArgs.verbose { log.Printf("wrote %v (%d bytes)", wf.Name, size) } if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil { return fmt.Errorf("deleting %q from inbox: %v", wf.Name, err) } deleted++ } if getArgs.verbose { log.Printf("moved %d files", deleted) } return nil } func wipeInbox(ctx context.Context) error { if getArgs.wait { return errors.New("can't use --wait with /dev/null target") } wfs, err := tailscale.WaitingFiles(ctx) if err != nil { return fmt.Errorf("getting WaitingFiles: %v", err) } deleted := 0 for _, wf := range wfs { if getArgs.verbose { log.Printf("deleting %v ...", wf.Name) } if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil { return fmt.Errorf("deleting %q: %v", wf.Name, err) } deleted++ } if getArgs.verbose { log.Printf("deleted %d files", deleted) } return nil } func waitForFile(ctx context.Context) error { c, bc, pumpCtx, cancel := connect(ctx) defer cancel() fileWaiting := make(chan bool, 1) bc.SetNotifyCallback(func(n ipn.Notify) { if n.ErrMessage != nil { fatalf("Notify.ErrMessage: %v\n", *n.ErrMessage) } if n.FilesWaiting != nil { select { case fileWaiting <- true: default: } } }) go pump(pumpCtx, bc, c) select { case <-fileWaiting: return nil case <-pumpCtx.Done(): return pumpCtx.Err() case <-ctx.Done(): return ctx.Err() } }