diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 99d533484..daabb3eed 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -69,6 +69,7 @@ change in the future. pingCmd, versionCmd, webCmd, + pushCmd, }, FlagSet: rootfs, Exec: func(context.Context, []string) error { return flag.ErrHelp }, diff --git a/cmd/tailscale/cli/push.go b/cmd/tailscale/cli/push.go new file mode 100644 index 000000000..f6bec389a --- /dev/null +++ b/cmd/tailscale/cli/push.go @@ -0,0 +1,155 @@ +// 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" + "log" + "mime" + "net" + "net/http" + "net/url" + "os" + "time" + + "github.com/peterbourgon/ff/v2/ffcli" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" +) + +var pushCmd = &ffcli.Command{ + Name: "push", + ShortUsage: "push [--flags] ", + ShortHelp: "Push a file to a host", + Exec: runPush, + FlagSet: (func() *flag.FlagSet { + fs := flag.NewFlagSet("push", flag.ExitOnError) + fs.StringVar(&pushArgs.name, "name", "", "alternate filename to use, especially useful when is \"-\" (stdin)") + fs.BoolVar(&pushArgs.verbose, "verbose", false, "verbose output") + return fs + })(), +} + +var pushArgs struct { + name string + verbose bool +} + +func runPush(ctx context.Context, args []string) error { + if len(args) != 2 || args[0] == "" { + return errors.New("usage: push ") + } + var ip string + + hostOrIP, fileArg := args[0], args[1] + ip, err := tailscaleIPFromArg(ctx, hostOrIP) + if err != nil { + return err + } + + peerAPIPort, err := discoverPeerAPIPort(ctx, ip) + if err != nil { + return err + } + + var fileContents io.Reader + var name = pushArgs.name + if fileArg == "-" { + fileContents = os.Stdin + if name == "" { + sniff, err := io.ReadAll(io.LimitReader(fileContents, 4<<20)) + if err != nil { + return err + } + if exts, _ := mime.ExtensionsByType(http.DetectContentType(sniff)); len(exts) > 0 { + name = "stdin" + exts[0] + } else { + name = "stdin.txt" + } + fileContents = io.MultiReader(bytes.NewReader(sniff), fileContents) + } + } else { + f, err := os.Open(fileArg) + if err != nil { + return err + } + defer f.Close() + fileContents = f + if name == "" { + name = fileArg + } + } + + dstURL := "http://" + net.JoinHostPort(ip, fmt.Sprint(peerAPIPort)) + "/v0/put/" + url.PathEscape(name) + req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, fileContents) + if err != nil { + return err + } + if pushArgs.verbose { + log.Printf("sending to %v ...", dstURL) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode == 200 { + return nil + } + io.Copy(os.Stdout, res.Body) + return errors.New(res.Status) +} + +func discoverPeerAPIPort(ctx context.Context, ip string) (port uint16, err error) { + c, bc, ctx, cancel := connect(ctx) + defer cancel() + + prc := make(chan *ipnstate.PingResult, 2) + bc.SetNotifyCallback(func(n ipn.Notify) { + if n.ErrMessage != nil { + log.Fatal(*n.ErrMessage) + } + if pr := n.PingResult; pr != nil && pr.IP == ip { + prc <- pr + } + }) + go pump(ctx, bc, c) + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + discoPings := 0 + timer := time.NewTimer(10 * time.Second) + defer timer.Stop() + + sendPings := func() { + bc.Ping(ip, false) + bc.Ping(ip, true) + } + sendPings() + for { + select { + case <-ticker.C: + sendPings() + case <-timer.C: + return 0, fmt.Errorf("timeout contacting %v; it offline?", ip) + case pr := <-prc: + if p := pr.PeerAPIPort; p != 0 { + return p, nil + } + discoPings++ + if discoPings == 3 { + return 0, fmt.Errorf("%v is online, but seems to be running an old Tailscale version", ip) + } + case <-ctx.Done(): + return 0, ctx.Err() + } + } +}