Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,17 @@ ephemeral key anyway, `--key=<name>` to use a different saved key, or
`tailcat genkey --delete --key=default` to remove the saved default key.
`tailcat genkey --list` lists your saved keys.

For manual router port forwarding, set `--listen-port` to a fixed local UDP
port on the server (and forward that UDP port on the router):

```sh
$ tailcat serve --listen-port=41641 8080
```

The flag also works in client modes. By default, each process uses an
OS-assigned local UDP port. `--listen-port` does not change the service port
(`8080` above) inside the tunnel.

Tailcat addresses can also be published as DNS TXT records and looked up by name;
a DNS name works anywhere the CLI takes a tailcat address:

Expand Down
9 changes: 6 additions & 3 deletions cmd/tailcat/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestHelpListsCommandTree(t *testing.T) {
for _, want := range []string{
"serve", "recv", "ping", "perf", "socks", "ssh", "cp", "parse", "resolve",
"forward", "genkey", "printpub", "version", "readme",
"--serve", "--key", "--derpmap-url",
"--serve", "--key", "--derpmap-url", "--listen-port",
} {
if !strings.Contains(help, want) {
t.Errorf("root help is missing %q", want)
Expand Down Expand Up @@ -267,8 +267,8 @@ func TestSocksTrailingArgs(t *testing.T) {
// it (via flag set parenting).
func TestGlobalFlagPlacement(t *testing.T) {
for _, args := range [][]string{
{"--key=new", "--derpmap-url=http://d/", "ping", "--timeout=5s", "tcaddr"},
{"ping", "--key=new", "--derpmap-url=http://d/", "--timeout=5s", "tcaddr"},
{"--key=new", "--derpmap-url=http://d/", "--listen-port=41641", "ping", "--timeout=5s", "tcaddr"},
{"ping", "--key=new", "--derpmap-url=http://d/", "--listen-port=41641", "--timeout=5s", "tcaddr"},
} {
if _, err := parseCLI(t, args...); err != nil {
t.Fatalf("parse %q: %v", args, err)
Expand All @@ -279,6 +279,9 @@ func TestGlobalFlagPlacement(t *testing.T) {
if *flagDERPMapURL != "http://d/" {
t.Errorf("parse %q: --derpmap-url = %q; want http://d/", args, *flagDERPMapURL)
}
if *flagListenPort != 41641 {
t.Errorf("parse %q: --listen-port = %d; want 41641", args, *flagListenPort)
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion cmd/tailcat/tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var (
flagFullAddress *bool
flagJSON *bool
flagDERPMapURL *string
flagListenPort *int
)

var serveFS *ff.FlagSet
Expand Down Expand Up @@ -98,6 +99,7 @@ func newRootCommand() *ff.Command {
flagVerbose = rootFS.BoolLong("verbose", "be verbose")
flagJSON = rootFS.BoolLong("json", "in server mode, write {\"listenAddr\": ...} JSON to stdout; with perf, write the results as JSON")
flagDERPMapURL = rootFS.StringLong("derpmap-url", cmp.Or(os.Getenv("TAILCAT_DERPMAP_URL"), tailcat.DefaultDERPMapURL), "URL of the JSON DERP map used to resolve or auto-select a DERP region; its default can also be set with the TAILCAT_DERPMAP_URL environment variable")
flagListenPort = rootFS.IntLong("listen-port", 0, "local UDP port for direct connections (0 lets the OS choose; useful with manual port forwarding)")

serveFS = ff.NewFlagSet("serve").SetParent(rootFS)
flagAllow = serveFS.StringLong("allow", "", "comma-separated list of public keys to allow access to the server, or 'none' to allow no clients. If empty, all clients are allowed.")
Expand Down Expand Up @@ -824,6 +826,7 @@ func newClient(logf logger.Logf, addr tailcat.Addr, priv key.NodePrivate) *tailc
Key: priv,
Logf: logf,
DERPMapURL: *flagDERPMapURL,
ListenPort: *flagListenPort,
DERPMapCache: derpMapCache{},
}
}
Expand Down Expand Up @@ -1395,7 +1398,7 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) {
ci.ServerDiscoPublic = tailcat.DiscoPublicForNode(priv)
connStr := ci.Addr()

s := &tailcat.Server{Key: priv, PresharedKey: psk, DisablePresharedKey: !usePSK, Logf: logf, Region: reg}
s := &tailcat.Server{Key: priv, PresharedKey: psk, DisablePresharedKey: !usePSK, Logf: logf, Region: reg, ListenPort: *flagListenPort}
sshServices := services.Contains("ssh") || services.Contains("no-auth-ssh") || services.Contains("files")
if sshServices && !tailcat.SupportsSSHServer() {
log.Fatalf("Tailscale SSH server not supported on %v", runtime.GOOS)
Expand Down
22 changes: 18 additions & 4 deletions tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,10 @@ type Server struct {
// If nil, log.Printf is used.
Logf logger.Logf

// ListenPort is the local UDP port used for direct WireGuard connections.
// Zero (the default) lets the OS choose a port. Set it before Start or Listen.
ListenPort int

// Region, if non-nil, is the DERP region to use as the bootstrap
// relay, without fetching any DERP map.
Region *tailcfg.DERPRegion
Expand Down Expand Up @@ -607,6 +611,9 @@ func (s *Server) startLocked(ctx context.Context) error {
if s.lb != nil {
return errors.New("tailcat: Server.Start called twice")
}
if s.ListenPort < 0 || s.ListenPort > 65535 {
return errors.New("tailcat: Server.ListenPort must be between 0 and 65535")
}
if s.UDPIdleTimeout < 0 {
return errors.New("tailcat: Server.UDPIdleTimeout must not be negative")
}
Expand Down Expand Up @@ -692,7 +699,7 @@ func (s *Server) startLocked(ctx context.Context) error {
return false
}

if err := createEngine(logf, lb); err != nil {
if err := createEngine(logf, lb, s.ListenPort); err != nil {
lb.Close()
return fmt.Errorf("createEngine: %w", err)
}
Expand Down Expand Up @@ -1882,10 +1889,10 @@ func newNetstack(logf logger.Logf, sys *tsd.System) (*netstack.Impl, error) {
}

// createEngine creates the wgengine.Engine with userspace networking.
func createEngine(logf logger.Logf, lb *locoBackend) (err error) {
func createEngine(logf logger.Logf, lb *locoBackend, listenPort int) (err error) {
sys := &lb.sys
conf := wgengine.Config{
ListenPort: 0,
ListenPort: uint16(listenPort),
NetMon: sys.NetMon.Get(),
Dialer: sys.Dialer.Get(),
SetSubsystem: sys.Set,
Expand Down Expand Up @@ -1937,6 +1944,10 @@ type Client struct {
// is used. If set, it must be set before the client's first use.
Logf logger.Logf

// ListenPort is the local UDP port used for direct WireGuard connections.
// Zero (the default) lets the OS choose a port. Set it before first use.
ListenPort int

// DERPMapURL, if non-empty, is an alternate URL to fetch the DERP
// map from when the address doesn't embed the relay details.
// If empty, [DefaultDERPMapURL] is used. If set, it must be set
Expand Down Expand Up @@ -1991,6 +2002,9 @@ func (c *Client) initLocked() error {
if c.lb != nil {
return nil
}
if c.ListenPort < 0 || c.ListenPort > 65535 {
return errors.New("tailcat: Client.ListenPort must be between 0 and 65535")
}
logf := c.Logf
if logf == nil {
logf = log.Printf
Expand Down Expand Up @@ -2046,7 +2060,7 @@ func (c *Client) initLocked() error {
return true // client ignores MeowPing
}

if err := createEngine(logf, lb); err != nil {
if err := createEngine(logf, lb, c.ListenPort); err != nil {
lb.Close()
return fmt.Errorf("createEngine: %w", err)
}
Expand Down
11 changes: 11 additions & 0 deletions tailcat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,17 @@ func TestServerRejectsNegativeUDPIdleTimeout(t *testing.T) {
}
}

func TestListenPortRange(t *testing.T) {
for _, port := range []int{-1, 65536} {
if err := (&Server{ListenPort: port}).Start(); err == nil || !strings.Contains(err.Error(), "ListenPort") {
t.Errorf("Server.Start with ListenPort %d: error = %v; want range error", port, err)
}
if err := (&Client{ListenPort: port}).initLocked(); err == nil || !strings.Contains(err.Error(), "ListenPort") {
t.Errorf("Client.initLocked with ListenPort %d: error = %v; want range error", port, err)
}
}
}

func TestUDPIdleTimeoutDefault(t *testing.T) {
if got := (&Server{}).udpIdleTimeout(); got != DefaultUDPIdleTimeout {
t.Fatalf("zero UDPIdleTimeout = %v; want DefaultUDPIdleTimeout %v", got, DefaultUDPIdleTimeout)
Expand Down