|
|
|
|
@ -19,9 +19,12 @@ import ( |
|
|
|
|
"path" |
|
|
|
|
"path/filepath" |
|
|
|
|
"strings" |
|
|
|
|
"sync" |
|
|
|
|
"sync/atomic" |
|
|
|
|
"time" |
|
|
|
|
"unicode/utf8" |
|
|
|
|
|
|
|
|
|
"github.com/mattn/go-isatty" |
|
|
|
|
"github.com/peterbourgon/ff/v3/ffcli" |
|
|
|
|
"golang.org/x/time/rate" |
|
|
|
|
"tailscale.com/client/tailscale/apitype" |
|
|
|
|
@ -49,6 +52,17 @@ var fileCmd = &ffcli.Command{ |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
type countingReader struct { |
|
|
|
|
io.Reader |
|
|
|
|
n atomic.Uint64 |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func (c *countingReader) Read(buf []byte) (int, error) { |
|
|
|
|
n, err := c.Reader.Read(buf) |
|
|
|
|
c.n.Add(uint64(n)) |
|
|
|
|
return n, err |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
var fileCpCmd = &ffcli.Command{ |
|
|
|
|
Name: "cp", |
|
|
|
|
ShortUsage: "file cp <files...> <target>:", |
|
|
|
|
@ -116,11 +130,11 @@ func runCp(ctx context.Context, args []string) error { |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
for _, fileArg := range files { |
|
|
|
|
var fileContents io.Reader |
|
|
|
|
var fileContents *countingReader |
|
|
|
|
var name = cpArgs.name |
|
|
|
|
var contentLength int64 = -1 |
|
|
|
|
if fileArg == "-" { |
|
|
|
|
fileContents = os.Stdin |
|
|
|
|
fileContents = &countingReader{Reader: os.Stdin} |
|
|
|
|
if name == "" { |
|
|
|
|
name, fileContents, err = pickStdinFilename() |
|
|
|
|
if err != nil { |
|
|
|
|
@ -144,19 +158,29 @@ func runCp(ctx context.Context, args []string) error { |
|
|
|
|
return errors.New("directories not supported") |
|
|
|
|
} |
|
|
|
|
contentLength = fi.Size() |
|
|
|
|
fileContents = io.LimitReader(f, contentLength) |
|
|
|
|
fileContents = &countingReader{Reader: io.LimitReader(f, contentLength)} |
|
|
|
|
if name == "" { |
|
|
|
|
name = filepath.Base(fileArg) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if envknob.Bool("TS_DEBUG_SLOW_PUSH") { |
|
|
|
|
fileContents = &slowReader{r: fileContents} |
|
|
|
|
fileContents = &countingReader{Reader: &slowReader{r: fileContents}} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if cpArgs.verbose { |
|
|
|
|
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
var ( |
|
|
|
|
done = make(chan struct{}, 1) |
|
|
|
|
wg sync.WaitGroup |
|
|
|
|
) |
|
|
|
|
if isatty.IsTerminal(os.Stderr.Fd()) { |
|
|
|
|
go printProgress(&wg, done, fileContents, name, contentLength) |
|
|
|
|
wg.Add(1) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents) |
|
|
|
|
if err != nil { |
|
|
|
|
return err |
|
|
|
|
@ -164,10 +188,61 @@ func runCp(ctx context.Context, args []string) error { |
|
|
|
|
if cpArgs.verbose { |
|
|
|
|
log.Printf("sent %q", name) |
|
|
|
|
} |
|
|
|
|
done <- struct{}{} |
|
|
|
|
wg.Wait() |
|
|
|
|
} |
|
|
|
|
return nil |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const vtRestartLine = "\r\x1b[K" |
|
|
|
|
|
|
|
|
|
func printProgress(wg *sync.WaitGroup, done <-chan struct{}, r *countingReader, name string, contentLength int64) { |
|
|
|
|
defer wg.Done() |
|
|
|
|
var lastBytesRead uint64 |
|
|
|
|
|
|
|
|
|
for { |
|
|
|
|
select { |
|
|
|
|
case <-done: |
|
|
|
|
fmt.Fprintln(os.Stderr) |
|
|
|
|
return |
|
|
|
|
case <-time.After(time.Second): |
|
|
|
|
n := r.n.Load() |
|
|
|
|
contentLengthStr := "???" |
|
|
|
|
if contentLength > 0 { |
|
|
|
|
contentLengthStr = fmt.Sprint(contentLength / 1024) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fmt.Fprintf(os.Stderr, "%s%s\t\t%s", vtRestartLine, padTruncateString(name, 36), padTruncateString(fmt.Sprintf("%d/%s kb", n/1024, contentLengthStr), 16)) |
|
|
|
|
if contentLength > 0 { |
|
|
|
|
fmt.Fprintf(os.Stderr, "\t%.02f%%", float64(n)/float64(contentLength)*100) |
|
|
|
|
} else { |
|
|
|
|
fmt.Fprintf(os.Stderr, "\t-------%%") |
|
|
|
|
} |
|
|
|
|
if lastBytesRead > 0 { |
|
|
|
|
fmt.Fprintf(os.Stderr, "\t%d kb/s", (n-lastBytesRead)/1024) |
|
|
|
|
} else { |
|
|
|
|
fmt.Fprintf(os.Stderr, "\t-------") |
|
|
|
|
} |
|
|
|
|
lastBytesRead = n |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func padTruncateString(str string, truncateAt int) string { |
|
|
|
|
if len(str) <= truncateAt { |
|
|
|
|
return str + strings.Repeat(" ", truncateAt-len(str)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Truncate the string, but respect unicode codepoint boundaries.
|
|
|
|
|
// As of RFC3629 utf-8 codepoints can be at most 4 bytes wide.
|
|
|
|
|
for i := 1; i <= 4 && i < len(str)-truncateAt; i++ { |
|
|
|
|
if utf8.ValidString(str[:truncateAt-i]) { |
|
|
|
|
return str[:truncateAt-i] + "…" |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
return "" // Should be unreachable
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNodeID, isOffline bool, err error) { |
|
|
|
|
ip, err := netip.ParseAddr(ipStr) |
|
|
|
|
if err != nil { |
|
|
|
|
@ -230,12 +305,12 @@ func ext(b []byte) string { |
|
|
|
|
// 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) { |
|
|
|
|
func pickStdinFilename() (name string, r *countingReader, 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 |
|
|
|
|
return "stdin" + ext(sniff), &countingReader{Reader: io.MultiReader(bytes.NewReader(sniff), os.Stdin)}, nil |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
type slowReader struct { |
|
|
|
|
|