From 184002a66251474941f4c861e93da359e9b58588 Mon Sep 17 00:00:00 2001 From: SriVishnu230707 Date: Thu, 22 Oct 2026 12:46:07 +0530 Subject: [PATCH] feat: allow a fixed local UDP listen port --- README.md | 11 +++++++++++ cmd/tailcat/cli_test.go | 9 ++++++--- cmd/tailcat/tailcat.go | 5 ++++- tailcat.go | 22 ++++++++++++++++++---- tailcat_test.go | 11 +++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3082f93ef..e273a2794 100644 --- a/README.md +++ b/README.md @@ -454,6 +454,17 @@ ephemeral key anyway, `--key=` 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: diff --git a/cmd/tailcat/cli_test.go b/cmd/tailcat/cli_test.go index ebdb8b0f0..09fa7d2ae 100644 --- a/cmd/tailcat/cli_test.go +++ b/cmd/tailcat/cli_test.go @@ -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) @@ -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) @@ -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) + } } } diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go index 31d0ab4ec..0183819b8 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -62,6 +62,7 @@ var ( flagFullAddress *bool flagJSON *bool flagDERPMapURL *string + flagListenPort *int ) var serveFS *ff.FlagSet @@ -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.") @@ -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{}, } } @@ -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) diff --git a/tailcat.go b/tailcat.go index 7585d3ace..6c072d60f 100644 --- a/tailcat.go +++ b/tailcat.go @@ -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 @@ -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") } @@ -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) } @@ -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, @@ -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 @@ -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 @@ -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) } diff --git a/tailcat_test.go b/tailcat_test.go index 6b7e7b285..c7c01496c 100644 --- a/tailcat_test.go +++ b/tailcat_test.go @@ -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)