Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,55 @@ $ tailcat ssh tcXXXXXXXXX
$ tailcat ssh tcXXXXXXXXX ls -la
```

### Send and receive files

To receive files, run a drop box and share the printed address:

```sh
$ tailcat recv ~/inbox
# 🐈 Server listening with new address: tcXXXXXXXXX
```

The sender then runs:

```sh
$ tailcat cp report.pdf tcXXXXXXXXX:
```

`tailcat cp` runs the system `scp` with the connection routed through
tailcat, so you get its usual progress display, and `-r` for
directory trees. The drop box is write-only: senders can't list the
directory, read anything back, or touch existing files.

To offer files instead, serve a directory read-only (the default) or
read-write:

```sh
$ tailcat serve files # current directory, read-only
$ tailcat serve --files=/pub:rw files # a given directory, read-write
```

```sh
$ tailcat ls -l tcXXXXXXXXX
$ tailcat cp tcXXXXXXXXX:report.pdf .
```

`tailcat ls` speaks SFTP natively, so it works even without OpenSSH
installed.

The server confines all paths to the served directory (via Go's
`os.Root`), so neither `..` nor symlinks escape it. The file service
speaks SFTP, so the stock `sftp` and `scp` clients also work against
it, given a ProxyCommand that pipes through tailcat (the same trick
`tailcat cp` and `tailcat ssh` use). A `no-auth-ssh` server serves
SFTP too, with the same access as the shell.

Transfers are not compressed: the SFTP protocol has no compression
of its own, and the SSH transport here doesn't either (Go's SSH
stack omits it; transport compression has a history of security
problems, and TLS dropped it too). Compress files before sending
if it matters.

### Misc commands

Ping to test connectivity; each pong reports whether it arrived via a
Expand Down
4 changes: 2 additions & 2 deletions cmd/tailcat/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import (
func TestHelpListsCommandTree(t *testing.T) {
help := ffhelp.Command(newRootCommand()).String()
for _, want := range []string{
"serve", "ping", "socks", "ssh", "parse", "resolve", "genkey",
"printpub", "version", "readme",
"serve", "recv", "ping", "socks", "ssh", "cp", "parse", "resolve",
"genkey", "printpub", "version", "readme",
"--serve", "--key", "--derpmap-url",
} {
if !strings.Contains(help, want) {
Expand Down
127 changes: 127 additions & 0 deletions cmd/tailcat/cp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

//go:build !ts_omit_ssh

package main

import (
"context"
"log"
"os"
"os/exec"
"strings"

"github.com/peterbourgon/ff/v4"
)

// cpCommand returns the "tailcat cp" subcommand, with parent as the
// parent flag set for the global flags.
func cpCommand(parent *ff.FlagSet) *ff.Command {
fs := ff.NewFlagSet("cp").SetParent(parent)
recursive := fs.BoolShort('r', "recursively copy directories")
preserve := fs.BoolShort('p', "preserve modification times and modes")
port := fs.StringShort('P', "22", "port number of the server's SSH (file service) port")
return &ff.Command{
Name: "cp",
Usage: "tailcat cp [-r] [-p] <source>... <target>",
ShortHelp: "copy files to or from a tailcat server, using the system scp",
LongHelp: cpLongHelp,
Flags: fs,
Exec: func(ctx context.Context, args []string) error {
return clientCPMode(*recursive, *preserve, *port, args)
},
}
}

const cpLongHelp = `Remote paths are written <addrblob>:[path], like scp's host:path.
Paths are relative to the server's served directory ("tailcat serve
files"), or to the remote home directory for a full SSH server
("tailcat serve no-auth-ssh"). A DNS name with a "tailcat=" TXT
record works in place of an address blob.

Copy a file to a server, keeping its name, and fetch it back:

tailcat cp foo.txt <addrblob>:
tailcat cp <addrblob>:foo.txt copy.txt

Copy a directory tree to a directory the server offers read-write:

tailcat cp -r ./photos <addrblob>:photos

The actual copying is done by the system scp, with the connection
routed through tailcat, so scp's progress display applies.`

// clientCPMode runs the system scp with all remote arguments routed
// through one tailcat server.
func clientCPMode(recursive, preserve bool, portOrIPPort string, args []string) error {
if len(args) < 2 {
return usagef("cp requires at least one source and a target")
}

// Translate <addrblob>:path arguments to scp host:path ones. The
// host handed to scp is a short deterministic label (see
// sshDestHost); the blob itself does the routing, inside the
// ProxyCommand.
blob := ""
scpArgs := make([]string, 0, len(args))
for _, arg := range args {
host, path, ok := splitRemoteArg(arg)
if !ok {
scpArgs = append(scpArgs, arg)
continue
}
if blob != "" && host != blob {
return usagef("all remote paths must name the same server (%q and %q differ)", blob, host)
}
blob = host
scpArgs = append(scpArgs, sshDestHost(host)+":"+path)
}
if blob == "" {
return usagef("no remote <addrblob>:path argument; nothing to copy through tailcat")
}

exe, err := os.Executable()
if err != nil {
log.Fatal(err)
}
scpExe, err := exec.LookPath("scp")
if err != nil {
log.Fatalf("no scp found in $PATH: %v", err)
}
argv := []string{
scpExe,
"-o", "UpdateHostKeys no",
"-o", "StrictHostKeyChecking no",
"-o", "UserKnownHostsFile " + os.DevNull,
"-o", "LogLevel ERROR",
"-o", "ProxyCommand=" + sshProxyCommand(exe, *flagKey, *flagDERPMapURL, blob, portOrIPPort),
}
if recursive {
argv = append(argv, "-r")
}
if preserve {
argv = append(argv, "-p")
}
argv = append(argv, scpArgs...)
err = execSSH(scpExe, argv)
log.Fatalf("failed to run scp: %v", err)
return nil
}

// splitRemoteArg splits an scp-style remote argument "host:path",
// where host is an address blob or a DNS name with a "tailcat=" TXT
// record. ok reports whether arg is remote: it has a colon that
// isn't preceded by a path separator, and the part before the colon
// is longer than one character (so a Windows drive path like
// "C:\foo" stays local).
func splitRemoteArg(arg string) (host, path string, ok bool) {
i := strings.Index(arg, ":")
if i <= 1 {
return "", "", false
}
if strings.ContainsAny(arg[:i], `/\`) {
return "", "", false
}
return arg[:i], arg[i+1:], true
}
141 changes: 141 additions & 0 deletions cmd/tailcat/cp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

//go:build (linux || darwin || windows) && !ts_omit_ssh

package main

import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
)

func TestSplitRemoteArg(t *testing.T) {
for _, tt := range []struct {
arg string
host, path string
ok bool
}{
{"tcBLOB:foo.txt", "tcBLOB", "foo.txt", true},
{"tcBLOB:", "tcBLOB", "", true},
{"example.com:dir/foo", "example.com", "dir/foo", true},
{"foo.txt", "", "", false},
{"./dir:with:colons", "", "", false},
{`C:\Users\foo`, "", "", false},
{"C:/Users/foo", "", "", false},
{":leading-colon", "", "", false},
} {
host, path, ok := splitRemoteArg(tt.arg)
if host != tt.host || path != tt.path || ok != tt.ok {
t.Errorf("splitRemoteArg(%q) = %q, %q, %v; want %q, %q, %v",
tt.arg, host, path, ok, tt.host, tt.path, tt.ok)
}
}
}

// TestCPUsageErrors verifies cp's argument validation, which happens
// before any scp exec.
func TestCPUsageErrors(t *testing.T) {
for _, tt := range []struct {
name string
args []string
}{
{"too few args", []string{"cp", "foo.txt"}},
{"no remote arg", []string{"cp", "foo.txt", "bar.txt"}},
{"two different servers", []string{"cp", "tcAAA:x", "tcBBB:y"}},
} {
root, err := parseCLI(t, tt.args...)
if err != nil {
t.Fatalf("%s: parse: %v", tt.name, err)
}
err = root.Run(t.Context())
var ue usageError
if !errors.As(err, &ue) {
t.Errorf("%s: err = %v; want a usageError", tt.name, err)
}
}
}

// TestRecvDropBox copies a file into a "tailcat recv" server and
// checks that it lands, that a second copy of another name works,
// and that reading anything back is refused (write-only drop box).
func TestRecvDropBox(t *testing.T) {
if _, err := exec.LookPath("scp"); err != nil {
t.Skipf("no scp in $PATH: %v", err)
}
e := newTestEnv(t)

recvDir := t.TempDir()
_, blob, _ := e.startServer("recv", recvDir)

src := filepath.Join(t.TempDir(), "gift.txt")
const content = "drop box content"
if err := os.WriteFile(src, []byte(content), 0644); err != nil {
t.Fatal(err)
}

out, err := e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, "cp", src, blob+":").CombinedOutput()
if err != nil {
t.Fatalf("cp into recv: %v\n%s", err, out)
}
v, err := os.ReadFile(filepath.Join(recvDir, "gift.txt"))
if err != nil {
t.Fatal(err)
}
if string(v) != content {
t.Errorf("received content = %q; want %q", v, content)
}

back := filepath.Join(t.TempDir(), "back.txt")
out, err = e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, "cp", blob+":gift.txt", back).CombinedOutput()
if err == nil {
t.Errorf("cp out of a write-only drop box succeeded:\n%s", out)
}
}

// TestCPRoundTrip copies a file to a read-write file server with
// "tailcat cp" (which runs the system scp) and fetches it back.
func TestCPRoundTrip(t *testing.T) {
if _, err := exec.LookPath("scp"); err != nil {
t.Skipf("no scp in $PATH: %v", err)
}
e := newTestEnv(t)

serveDir := t.TempDir()
_, blob, _ := e.startServer("serve", "--files="+serveDir+":rw")

srcDir := t.TempDir()
src := filepath.Join(srcDir, "src.txt")
const content = "cp round trip content"
if err := os.WriteFile(src, []byte(content), 0644); err != nil {
t.Fatal(err)
}

out, err := e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, "cp", src, blob+":uploaded.txt").CombinedOutput()
if err != nil {
t.Fatalf("cp upload: %v\n%s", err, out)
}
v, err := os.ReadFile(filepath.Join(serveDir, "uploaded.txt"))
if err != nil {
t.Fatal(err)
}
if string(v) != content {
t.Errorf("uploaded content = %q; want %q", v, content)
}

back := filepath.Join(srcDir, "back.txt")
out, err = e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, "cp", blob+":uploaded.txt", back).CombinedOutput()
if err != nil {
t.Fatalf("cp download: %v\n%s", err, out)
}
v, err = os.ReadFile(back)
if err != nil {
t.Fatal(err)
}
if string(v) != content {
t.Errorf("downloaded content = %q; want %q", v, content)
}
}
Loading