From f992749b98fc776c78ada244e6e3c9bcbfa1adde Mon Sep 17 00:00:00 2001 From: David Eger Date: Mon, 4 Apr 2022 08:43:59 -0700 Subject: [PATCH] cmd/tailscale: Add file get --loop flag. To "automatically receive taildrop files to my Downloads directory," user currently has to run 'tailscale file get' in a loop. Make it easy to do this without shell. Updates: #2312 Signed-off-by: David Eger --- cmd/tailscale/cli/file.go | 91 ++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/cmd/tailscale/cli/file.go b/cmd/tailscale/cli/file.go index a340275ae..6bf6ef092 100644 --- a/cmd/tailscale/cli/file.go +++ b/cmd/tailscale/cli/file.go @@ -318,6 +318,7 @@ var fileGetCmd = &ffcli.Command{ 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.loop, "loop", false, "run get in a loop, receiving files as they come in") fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output") fs.Var(&getArgs.conflict, "conflict", `behavior when a conflicting (same-named) file already exists in the target directory. skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files @@ -329,6 +330,7 @@ var fileGetCmd = &ffcli.Command{ var getArgs = struct { wait bool + loop bool verbose bool conflict onConflict }{conflict: skipOnExist} @@ -358,7 +360,7 @@ func openFileOrSubstitute(dir, base string, action onConflict) (*os.File, error) } return nil, fmt.Errorf("failed to write; %w", err) case overwriteExisting: - // remove the target file and create it anew so we don't fall for an + // remove the target file and create it anew so we don't fall for an // attacker who symlinks a known target name to a file he wants changed. if err = os.Remove(targetFile); err != nil { return nil, fmt.Errorf("unable to remove target file: %w", err) @@ -387,54 +389,48 @@ func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targe if err != nil { return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err) } + defer rc.Close() f, err := openFileOrSubstitute(dir, wf.Name, getArgs.conflict) if err != nil { return "", 0, err } _, err = io.Copy(f, rc) - rc.Close() if err != nil { + f.Close() return "", 0, fmt.Errorf("failed to write %v: %v", f.Name(), err) } return f.Name(), size, f.Close() } -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) - } - +func runFileGetOneBatch(ctx context.Context, dir string) []error { var wfs []apitype.WaitingFile var err error - for { + var errs []error + for len(errs) == 0 { wfs, err = tailscale.WaitingFiles(ctx) if err != nil { - return fmt.Errorf("getting WaitingFiles: %w", err) + errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err)) + break } - if len(wfs) != 0 || !getArgs.wait { + if len(wfs) != 0 || !(getArgs.wait || getArgs.loop) { break } if getArgs.verbose { printf("waiting for file...") } if err := waitForFile(ctx); err != nil { - return err + errs = append(errs, err) } } - var errs []error deleted := 0 - for _, wf := range wfs { + for i, wf := range wfs { + if len(errs) > 100 { + // Likely, everything is broken. + // Don't try to receive any more files in this batch. + errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs) - i)) + break + } writtenFile, size, err := receiveFile(ctx, wf, dir) if err != nil { errs = append(errs, err) @@ -449,9 +445,51 @@ func runFileGet(ctx context.Context, args []string) error { } deleted++ } - if getArgs.verbose { + if deleted == 0 && len(wfs) > 0 { + // persistently stuck files are basically an error + errs = append(errs, fmt.Errorf("moved %d/%d files", deleted, len(wfs))) + } else if getArgs.verbose { printf("moved %d/%d files\n", deleted, len(wfs)) } + return errs +} + +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) + } + if getArgs.loop { + for { + errs := runFileGetOneBatch(ctx, dir) + for _, err := range errs { + outln(err) + } + if len(errs) > 0 { + // It's possible whatever caused the error(s) (e.g. conflicting target file, + // full disk, unwritable target directory) will re-occur if we try again so + // let's back off and not busy loop on error. + // + // If we've been invoked as: + // tailscale file get --conflict=skip ~/Downloads + // then any file coming in named the same as one in ~/Downloads will always + // appear as an "error" until the user clears it, but other incoming files + // should be receivable when they arrive, so let's not wait too long to + // check again. + time.Sleep(5 * time.Second) + } + } + } + errs := runFileGetOneBatch(ctx, dir) if len(errs) == 0 { return nil } @@ -489,9 +527,10 @@ func waitForFile(ctx context.Context) error { c, bc, pumpCtx, cancel := connect(ctx) defer cancel() fileWaiting := make(chan bool, 1) + notifyError := make(chan error, 1) bc.SetNotifyCallback(func(n ipn.Notify) { if n.ErrMessage != nil { - fatalf("Notify.ErrMessage: %v\n", *n.ErrMessage) + notifyError <- fmt.Errorf("Notify.ErrMessage: %v", *n.ErrMessage) } if n.FilesWaiting != nil { select { @@ -508,5 +547,7 @@ func waitForFile(ctx context.Context) error { return pumpCtx.Err() case <-ctx.Done(): return ctx.Err() + case err := <-notifyError: + return err } }