And reverse order, require final colon, and support multiple files. Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>main
parent
bb2141e0cf
commit
cafa037de0
@ -0,0 +1,275 @@ |
||||
// 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/v2/ffcli" |
||||
"golang.org/x/time/rate" |
||||
"inet.af/netaddr" |
||||
"tailscale.com/client/tailscale" |
||||
) |
||||
|
||||
var fileCmd = &ffcli.Command{ |
||||
Name: "file", |
||||
ShortUsage: "file <cp|get> ...", |
||||
ShortHelp: "Send or receive files", |
||||
Subcommands: []*ffcli.Command{ |
||||
fileCpCmd, |
||||
// TODO: 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 <files...> <target>:", |
||||
ShortHelp: "Copy file(s) to a host", |
||||
Exec: runCp, |
||||
FlagSet: (func() *flag.FlagSet { |
||||
fs := flag.NewFlagSet("cp", flag.ExitOnError) |
||||
fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> 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 { |
||||
//lint:ignore ST1005 no sorry need that colon at the end
|
||||
return errors.New("usage: tailscale file cp <files...> <target>:") |
||||
} |
||||
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, lastSeen, isOffline, err := discoverPeerAPIBase(ctx, ip) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if isOffline { |
||||
fmt.Fprintf(os.Stderr, "# warning: %s is offline\n", target) |
||||
} else if !lastSeen.IsZero() && time.Since(lastSeen) > lastSeenOld { |
||||
fmt.Fprintf(os.Stderr, "# warning: %s last seen %v ago\n", target, time.Since(lastSeen).Round(time.Minute)) |
||||
} |
||||
|
||||
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 { |
||||
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(os.Stdout, res.Body) |
||||
res.Body.Close() |
||||
return errors.New(res.Status) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func discoverPeerAPIBase(ctx context.Context, ipStr string) (base string, lastSeen time.Time, isOffline bool, err error) { |
||||
ip, err := netaddr.ParseIP(ipStr) |
||||
if err != nil { |
||||
return "", time.Time{}, false, err |
||||
} |
||||
fts, err := tailscale.FileTargets(ctx) |
||||
if err != nil { |
||||
return "", time.Time{}, false, err |
||||
} |
||||
for _, ft := range fts { |
||||
n := ft.Node |
||||
for _, a := range n.Addresses { |
||||
if a.IP != ip { |
||||
continue |
||||
} |
||||
if n.LastSeen != nil { |
||||
lastSeen = *n.LastSeen |
||||
} |
||||
isOffline = n.Online != nil && !*n.Online |
||||
return ft.PeerAPIURL, lastSeen, isOffline, nil |
||||
} |
||||
} |
||||
return "", time.Time{}, false, errors.New("target seems to be running an old Tailscale version") |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
const lastSeenOld = 20 * time.Minute |
||||
|
||||
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 |
||||
} |
||||
fmt.Printf("%s\t%s%s\n", n.Addresses[0].IP, n.ComputedName, detail) |
||||
} |
||||
return nil |
||||
} |
||||
@ -1,228 +0,0 @@ |
||||
// 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/http" |
||||
"net/url" |
||||
"os" |
||||
"path/filepath" |
||||
"strconv" |
||||
"time" |
||||
"unicode/utf8" |
||||
|
||||
"github.com/peterbourgon/ff/v2/ffcli" |
||||
"golang.org/x/time/rate" |
||||
"inet.af/netaddr" |
||||
"tailscale.com/client/tailscale" |
||||
) |
||||
|
||||
var pushCmd = &ffcli.Command{ |
||||
Name: "push", |
||||
ShortUsage: "push [--flags] <hostname-or-IP> <file>", |
||||
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 <file> is \"-\" (stdin)") |
||||
fs.BoolVar(&pushArgs.verbose, "verbose", false, "verbose output") |
||||
fs.BoolVar(&pushArgs.targets, "targets", false, "list possible push targets") |
||||
return fs |
||||
})(), |
||||
} |
||||
|
||||
var pushArgs struct { |
||||
name string |
||||
verbose bool |
||||
targets bool |
||||
} |
||||
|
||||
func runPush(ctx context.Context, args []string) error { |
||||
if pushArgs.targets { |
||||
return runPushTargets(ctx, args) |
||||
} |
||||
if len(args) != 2 || args[0] == "" { |
||||
return errors.New("usage: push <hostname-or-IP> <file>\n push --targets") |
||||
} |
||||
var ip string |
||||
|
||||
hostOrIP, fileArg := args[0], args[1] |
||||
ip, err := tailscaleIPFromArg(ctx, hostOrIP) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
peerAPIBase, lastSeen, isOffline, err := discoverPeerAPIBase(ctx, ip) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if isOffline { |
||||
fmt.Fprintf(os.Stderr, "# warning: %s is offline\n", hostOrIP) |
||||
} else if !lastSeen.IsZero() && time.Since(lastSeen) > lastSeenOld { |
||||
fmt.Fprintf(os.Stderr, "# warning: %s last seen %v ago\n", hostOrIP, time.Since(lastSeen).Round(time.Minute)) |
||||
} |
||||
|
||||
var fileContents io.Reader |
||||
var name = pushArgs.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 { |
||||
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 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 discoverPeerAPIBase(ctx context.Context, ipStr string) (base string, lastSeen time.Time, isOffline bool, err error) { |
||||
ip, err := netaddr.ParseIP(ipStr) |
||||
if err != nil { |
||||
return "", time.Time{}, false, err |
||||
} |
||||
fts, err := tailscale.FileTargets(ctx) |
||||
if err != nil { |
||||
return "", time.Time{}, false, err |
||||
} |
||||
for _, ft := range fts { |
||||
n := ft.Node |
||||
for _, a := range n.Addresses { |
||||
if a.IP != ip { |
||||
continue |
||||
} |
||||
if n.LastSeen != nil { |
||||
lastSeen = *n.LastSeen |
||||
} |
||||
isOffline = n.Online != nil && !*n.Online |
||||
return ft.PeerAPIURL, lastSeen, isOffline, nil |
||||
} |
||||
} |
||||
return "", time.Time{}, false, errors.New("target seems to be running an old Tailscale version") |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
const lastSeenOld = 20 * time.Minute |
||||
|
||||
func runPushTargets(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 |
||||
} |
||||
fmt.Printf("%s\t%s%s\n", n.Addresses[0].IP, n.ComputedName, detail) |
||||
} |
||||
return nil |
||||
} |
||||
Loading…
Reference in new issue