Skip to content

Commit 534f781

Browse files
authored
Merge branch 'main' into application-layer-udp
2 parents 8559846 + abbaffb commit 534f781

27 files changed

Lines changed: 1581 additions & 106 deletions

README.md

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ Or build from source with a Go toolchain:
6969
$ go install github.com/tailscale/tailcat/cmd/tailcat@latest
7070
```
7171

72-
Or with Nix flakes, run it directly or install it:
72+
Or with Nix, from [nixpkgs](https://search.nixos.org/packages?channel=unstable&query=tailcat):
73+
74+
```sh
75+
$ nix profile install nixpkgs#tailcat
76+
$ nix-env -iA nixpkgs.tailcat # or with classic nix-env
77+
```
78+
79+
Or with Nix flakes from this repo, run it directly or install it:
7380

7481
```sh
7582
$ nix run github:tailscale/tailcat
@@ -197,9 +204,33 @@ $ tailcat forward --bind=0.0.0.0 tcXXXXXXXXX 18080:8080
197204

198205
Press Ctrl-C to stop forwarding.
199206

207+
### Public-key-authenticated SSH server
208+
209+
Run an SSH server that accepts keys from local `authorized_keys` files,
210+
literal OpenSSH public key lines, or GitHub accounts:
211+
212+
```sh
213+
$ tailcat serve --ssh-authorized-keys=~/.ssh/authorized_keys ssh
214+
# 🐈 Server listening with new address: tcXXXXXXXXX
215+
```
216+
217+
Multiple sources can be comma-separated. A `user@github` source fetches
218+
`https://github.com/user.keys` once, before the server starts:
219+
220+
```sh
221+
$ tailcat serve --ssh-authorized-keys=bradfitz@github,./contractor.pub ssh
222+
```
223+
224+
Every source must exist, fetch successfully, and contain valid public key
225+
lines or startup fails. Authorized-key options such as `command=` and
226+
`from=` are rejected because the built-in server does not implement them.
227+
Running `tailcat serve ssh` without `--ssh-authorized-keys` also fails; use the
228+
explicit `no-auth-ssh` service when the tunnel identity alone is sufficient.
229+
200230
### Auth-free SSH server
201231

202-
On Linux and macOS, you can run an SSH server too with no auth. (If you want auth, you can just `tailcat serve 22` and proxy to your system SSH server)
232+
On Linux, macOS, and Windows, you can also explicitly run the SSH server with
233+
no client authentication. The encrypted tunnel provides the client identity.
203234

204235
```sh
205236
$ tailcat serve no-auth-ssh
@@ -253,8 +284,8 @@ The server confines all paths to the served directory (via Go's
253284
`os.Root`), so neither `..` nor symlinks escape it. The file service
254285
speaks SFTP, so the stock `sftp` and `scp` clients also work against
255286
it, given a ProxyCommand that pipes through tailcat (the same trick
256-
`tailcat cp` and `tailcat ssh` use). A `no-auth-ssh` server serves
257-
SFTP too, with the same access as the shell.
287+
`tailcat cp` and `tailcat ssh` use). Both `ssh` and `no-auth-ssh`
288+
servers serve SFTP too, with the same access as the shell.
258289

259290
Transfers are not compressed: the SFTP protocol has no compression
260291
of its own, and the SSH transport here doesn't either (Go's SSH
@@ -341,8 +372,8 @@ A server can print the long self-contained form directly with the
341372

342373
## Key Management
343374

344-
A server's tailcat address is derived from its WireGuard key, so
345-
the key you use determines who can reach you:
375+
A server's tailcat address contains its WireGuard public key and an independent
376+
WireGuard pre-shared key, so the saved key material determines who can reach you:
346377

347378
* **Ephemeral keys (the default):** each server run generates a fresh key in
348379
memory and prints an address nobody has ever seen. When the process exits,
@@ -359,6 +390,11 @@ The CLI says at startup which kind it's using, so you know whether you're
359390
starting a fresh single-use server or re-listening on an address you may
360391
have shared in the past.
361392

393+
WireGuard pre-shared keys are enabled by default and strongly recommended. For
394+
compatibility with tailcat clients v0.5.0 and earlier, `--psk=false` on `serve`
395+
or `genkey` produces shorter addresses, but removes post-quantum protection and
396+
protection from public DERP operators that observe the peers' public keys.
397+
362398
```sh
363399
$ tailcat genkey --key=default --region=nyc
364400
# prints the tailcat address; key saved to ~/.config/tailcat/keys/default.private.json
@@ -597,12 +633,19 @@ followed by base64-encoded [CBOR](https://cbor.io/) containing:
597633

598634
- The server's WireGuard public key (Curve25519, 32 bytes)
599635
- A separate path-discovery public key (Curve25519, 32 bytes)
636+
- By default, an independent WireGuard pre-shared key (256 random bits),
637+
which prevents a DERP operator that observes the peers' public keys from
638+
joining the tunnel and provides post-quantum protection against recorded
639+
traffic
600640
- DERP info. Either:
601641
1. a small integer referencing one of the default [Tailscale-run tailcat servers](https://tailcat.dev/derpmap.json), or
602642
2. full DERP server metadata, to either use a custom DERP server, or to avoid the client needing a potential round-trip to fetch the latest DERP map (the `tailcat serve --full-address` flag and the `tailcat resolve` subcommand produce this form)
603643

604-
A typical tailcat address with just an integer region ID is around 95 bytes. With
605-
embedded DERP node details it's longer but self-contained.
644+
A typical tailcat address with just an integer region ID is around 140 bytes.
645+
With embedded DERP node details it's longer but self-contained.
646+
647+
The default address is a secret bearer capability because it contains the
648+
pre-shared key. Share it only with clients that should be able to connect.
606649

607650
### Network stack
608651

@@ -624,15 +667,17 @@ without the control plane.
624667

625668
### Connection flow
626669

627-
1. **Server starts.** It generates (or loads) a WireGuard keypair,
628-
connects to a DERP relay, and prints its tailcat address to stderr.
629-
It then waits for clients.
670+
1. **Server starts.** It generates (or loads) a WireGuard keypair and, by
671+
default, a pre-shared key, connects to a DERP relay, and prints its tailcat
672+
address to stderr. It then waits for clients.
630673

631-
2. **Client parses the tailcat address** to learn the server's public key and
632-
path-discovery key, plus its DERP region. It generates its own ephemeral
633-
keypair and connects to the same DERP relay. The separate path-discovery
634-
key can appear in cleartext direct-path disco frames without revealing the
635-
WireGuard public key that acts as the unlisted connection capability.
674+
2. **Client parses the tailcat address** to learn the server's public key,
675+
path-discovery key, optional pre-shared key, and DERP region. It generates
676+
its own ephemeral keypair and connects to the same DERP relay. The separate
677+
path-discovery key can appear in cleartext direct-path disco frames without
678+
revealing the WireGuard public key. The pre-shared key remains the secret
679+
connection capability even when a relay operator observes both peers'
680+
public keys.
636681

637682
3. **Discovery handshake.** The client sends a "**Meow**" ping message
638683
to the server through the
@@ -641,10 +686,10 @@ without the control plane.
641686
network map, reconfigures the WireGuard engine, and replies with a
642687
"**Meowed**" acknowledgment.
643688

644-
4. **WireGuard tunnel.** With both sides configured as WireGuard
645-
peers, the standard WireGuard handshake proceeds (routed through
646-
DERP initially). Once complete, the tunnel is up and encrypted
647-
traffic can flow.
689+
4. **WireGuard tunnel.** With both sides configured as WireGuard peers using
690+
the address's pre-shared key when present, the WireGuard handshake proceeds
691+
(routed through DERP initially). Once complete, the tunnel is up and
692+
encrypted traffic can flow.
648693

649694
5. **NAT traversal.** In parallel, each side advertises its UDP
650695
endpoints (public IP:port learned via STUN, plus local interface

cmd/tailcat/cli_test.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ package main
55

66
import (
77
"bytes"
8+
"encoding/json"
89
"errors"
10+
"os"
911
"os/exec"
12+
"path/filepath"
1013
"strings"
1114
"testing"
1215

@@ -70,7 +73,7 @@ func TestHelpListsCommandTree(t *testing.T) {
7073
// Server-only flags live on the serve subcommand, not the root.
7174
// (The long help prose may still mention them; only reject them
7275
// as rendered flag entries.)
73-
for _, notWant := range []string{"\n --allow", "\n --full-address"} {
76+
for _, notWant := range []string{"\n --allow", "\n --full-address", "\n --psk", "\n --ssh-authorized-keys"} {
7477
if strings.Contains(help, notWant) {
7578
t.Errorf("root help lists server-only flag %q", strings.TrimSpace(notWant))
7679
}
@@ -91,13 +94,41 @@ func TestServeHelpListsServerFlags(t *testing.T) {
9194
t.Fatal("no serve subcommand")
9295
}
9396
help := ffhelp.Command(serve).String()
94-
for _, want := range []string{"--allow", "--full-address", "--key"} {
97+
for _, want := range []string{"--allow", "--full-address", "--key", "--psk", "--ssh-authorized-keys"} {
9598
if !strings.Contains(help, want) {
9699
t.Errorf("serve help is missing %q", want)
97100
}
98101
}
99102
}
100103

104+
func TestPSKFlagDefaults(t *testing.T) {
105+
if _, err := parseCLI(t, "serve"); err != nil {
106+
t.Fatal(err)
107+
}
108+
if !*flagPSK {
109+
t.Error("serve --psk defaulted to false; want true")
110+
}
111+
if _, err := parseCLI(t, "serve", "--psk=false"); err != nil {
112+
t.Fatal(err)
113+
}
114+
if *flagPSK {
115+
t.Error("serve --psk=false parsed as true")
116+
}
117+
118+
if _, err := parseCLI(t, "genkey", "--key=k", "--region=1"); err != nil {
119+
t.Fatal(err)
120+
}
121+
if !*genkeyPSK {
122+
t.Error("genkey --psk defaulted to false; want true")
123+
}
124+
if _, err := parseCLI(t, "genkey", "--key=k", "--region=1", "--psk=false"); err != nil {
125+
t.Fatal(err)
126+
}
127+
if *genkeyPSK {
128+
t.Error("genkey --psk=false parsed as true")
129+
}
130+
}
131+
101132
func TestParseFilesFlagWriteOnlyModes(t *testing.T) {
102133
dir := t.TempDir()
103134
for _, tt := range []struct {
@@ -158,6 +189,27 @@ func TestServeSubcommand(t *testing.T) {
158189
}
159190
}
160191

192+
func TestServeSSHAuthorizedKeysFlag(t *testing.T) {
193+
root, err := parseCLI(t, "serve", "--ssh-authorized-keys=one.pub,alice@github", "ssh")
194+
if err != nil {
195+
t.Fatal(err)
196+
}
197+
if got, want := *flagSSHAuthorizedKeys, "one.pub,alice@github"; got != want {
198+
t.Errorf("--ssh-authorized-keys = %q; want %q", got, want)
199+
}
200+
args := root.GetSelected().Flags.(*ff.FlagSet).GetArgs()
201+
if len(args) != 1 || args[0] != "ssh" {
202+
t.Errorf("serve args = %q; want [ssh]", args)
203+
}
204+
_, services, err := parsePortSet("ssh")
205+
if err != nil {
206+
t.Fatal(err)
207+
}
208+
if !services.Contains("ssh") {
209+
t.Error("parsePortSet did not select the ssh service")
210+
}
211+
}
212+
161213
// TestSSHTrailingArgs verifies that flag parsing stops at the ssh
162214
// destination, so a remote command's own flags (here "-la") are
163215
// passed through rather than parsed.
@@ -332,6 +384,51 @@ func TestGenkeyRequiresKeyName(t *testing.T) {
332384
}
333385
}
334386

387+
func TestGenkeyPSK(t *testing.T) {
388+
bin := buildTailcat(t)
389+
for _, tt := range []struct {
390+
name string
391+
pskArg string
392+
wantPSK bool
393+
}{
394+
{name: "default", wantPSK: true},
395+
{name: "disabled", pskArg: "--psk=false", wantPSK: false},
396+
} {
397+
t.Run(tt.name, func(t *testing.T) {
398+
keyFile := filepath.Join(t.TempDir(), "server.private.json")
399+
args := []string{"genkey", "--key=" + keyFile, "--region=1"}
400+
if tt.pskArg != "" {
401+
args = append(args, tt.pskArg)
402+
}
403+
cmd := exec.Command(bin, args...)
404+
cmd.Env = append(os.Environ(), cacheEnv(t)...)
405+
out, err := cmd.Output()
406+
if err != nil {
407+
t.Fatalf("genkey: %v", err)
408+
}
409+
ci, err := tailcat.ParseAddr(tailcat.Addr(strings.TrimSpace(string(out))))
410+
if err != nil {
411+
t.Fatalf("ParseAddr: %v", err)
412+
}
413+
if got := !ci.PresharedKey.IsZero(); got != tt.wantPSK {
414+
t.Errorf("address has PSK = %v; want %v", got, tt.wantPSK)
415+
}
416+
417+
j, err := os.ReadFile(keyFile)
418+
if err != nil {
419+
t.Fatal(err)
420+
}
421+
var key tailcat.PrivateKey
422+
if err := json.Unmarshal(j, &key); err != nil {
423+
t.Fatal(err)
424+
}
425+
if got := !key.Public.PresharedKey.IsZero(); got != tt.wantPSK {
426+
t.Errorf("saved key has PSK = %v; want %v", got, tt.wantPSK)
427+
}
428+
})
429+
}
430+
}
431+
335432
// TestForwardSubcommand verifies that forward parses its bind flag and
336433
// positional tailcat address and mappings without executing the listener.
337434
func TestForwardSubcommand(t *testing.T) {

cmd/tailcat/cp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func cpCommand(parent *ff.FlagSet) *ff.Command {
3737
const cpLongHelp = `Remote paths are written <tc-addr>:[path], like scp's host:path.
3838
Paths are relative to the server's served directory ("tailcat serve
3939
files"), or to the remote home directory for a full SSH server
40-
("tailcat serve no-auth-ssh"). A DNS name with a "tailcat=" TXT
40+
("tailcat serve ssh" or "tailcat serve no-auth-ssh"). A DNS name with a "tailcat=" TXT
4141
record works in place of a tailcat address.
4242
4343
Copy a file to a server, keeping its name, and fetch it back:

cmd/tailcat/ls.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func lsCommand(parent *ff.FlagSet) *ff.Command {
3737
}
3838

3939
const lsLongHelp = `List the files a tailcat server offers ("tailcat serve files" or a
40-
no-auth-ssh server), speaking SFTP directly: no ssh or sftp binary
40+
ssh or no-auth-ssh server), speaking SFTP directly: no ssh or sftp binary
4141
is involved.
4242
4343
List the served directory, or a path under it:

cmd/tailcat/serve_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ import (
1111
"net"
1212
"net/netip"
1313
"os/exec"
14+
"path/filepath"
1415
"regexp"
1516
"strconv"
1617
"strings"
1718
"testing"
1819
"time"
20+
21+
"github.com/tailscale/tailcat"
1922
)
2023

2124
// startEchoListener starts a TCP echo server on a 127.0.0.1 ephemeral
@@ -42,6 +45,74 @@ func startEchoListener(t *testing.T) uint16 {
4245
return uint16(ln.Addr().(*net.TCPAddr).Port)
4346
}
4447

48+
func TestServeWithoutPSK(t *testing.T) {
49+
e := newTestEnv(t)
50+
port := startEchoListener(t)
51+
_, addr, serverStderr := e.startServer("serve", "--psk=false", strconv.Itoa(int(port)))
52+
53+
ci, err := tailcat.ParseAddr(tailcat.Addr(addr))
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
if !ci.PresharedKey.IsZero() {
58+
t.Fatal("serve --psk=false produced an address containing a PSK")
59+
}
60+
if !strings.Contains(serverStderr.String(), "# ⚠️ WARNING: serving without a WireGuard PSK\n") {
61+
t.Errorf("server stderr lacks PSK warning:\n%s", serverStderr.String())
62+
}
63+
64+
const payload = "echo without a pre-shared key"
65+
got, err := runClient(t, e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, addr, strconv.Itoa(int(port))), serverStderr, payload)
66+
if err != nil {
67+
t.Fatalf("client to server without PSK: %v", err)
68+
}
69+
if got != payload {
70+
t.Errorf("server echoed %q; want %q", got, payload)
71+
}
72+
}
73+
74+
func TestServeRemembersSavedKeyWithoutPSK(t *testing.T) {
75+
e := newTestEnv(t)
76+
port := startEchoListener(t)
77+
keyFile := filepath.Join(t.TempDir(), "server.private.json")
78+
genkey := e.cmd("genkey", "--key="+keyFile, "--region=1", "--psk=false")
79+
if out, err := genkey.CombinedOutput(); err != nil {
80+
t.Fatalf("genkey: %v\n%s", err, out)
81+
}
82+
83+
addrFile := filepath.Join(t.TempDir(), "addr")
84+
server := e.cmd("--key="+keyFile, "--derpmap-url="+e.derpMapURL, "serve", strconv.Itoa(int(port)))
85+
server.Env = append(server.Env, "TAILCAT_ADDR_FILE="+addrFile)
86+
var serverStderr bytes.Buffer
87+
server.Stderr = &serverStderr
88+
if err := server.Start(); err != nil {
89+
t.Fatal(err)
90+
}
91+
t.Cleanup(func() { server.Process.Kill() })
92+
addr := waitAddr(t, addrFile, &serverStderr)
93+
94+
ci, err := tailcat.ParseAddr(tailcat.Addr(addr))
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
if !ci.PresharedKey.IsZero() {
99+
t.Fatal("saved PSK-free key produced an address containing a PSK")
100+
}
101+
wantWarning := fmt.Sprintf("# ⚠️ WARNING: saved key %q is not using a WireGuard PSK\n", keyFile)
102+
if !strings.Contains(serverStderr.String(), wantWarning) {
103+
t.Errorf("server stderr lacks %q:\n%s", wantWarning, serverStderr.String())
104+
}
105+
106+
const payload = "echo with saved PSK policy"
107+
got, err := runClient(t, e.cmd("--key=new", "--derpmap-url="+e.derpMapURL, addr, strconv.Itoa(int(port))), &serverStderr, payload)
108+
if err != nil {
109+
t.Fatalf("client to saved server without PSK: %v", err)
110+
}
111+
if got != payload {
112+
t.Errorf("server echoed %q; want %q", got, payload)
113+
}
114+
}
115+
45116
// runClient runs an unstarted tailcat client command with payload on
46117
// its stdin and a 60 second watchdog, returning its stdout and error.
47118
// The watchdog only exists to catch true hangs; it's generous because

0 commit comments

Comments
 (0)