How to Configure Tailcat Server to Listen on Specific UDP Ports

Configure a Tailcat server to listen on specific UDP ports by populating the ServedUDPPorts field with filter.PortRange entries before calling Server.Start.

The tailcat package from Tailscale provides a lightweight WireGuard-based tunnel server that can restrict UDP traffic to a predefined set of ports. This article explains how to configure the Server struct to enforce these restrictions using the source implementation in tailscale/tailcat.

Understanding UDP Port Filtering in Tailcat

Tailcat's server architecture separates packet filtering from handler logic. When you start a server, the buildFilter function—located in main/tailcat.go—constructs a packet filter that governs which inbound flows are permitted.

The key decision point occurs at lines 42-46 of main/tailcat.go:

// In main/tailcat.go, buildFilter constructs the filter:
udpPorts = s.ServedUDPPorts
if udpPorts == nil {
    udpPorts = filter.AllPorts() // fallback when not specified
}

If ServedUDPPorts is non-nil, the filter is built exclusively from your specified ranges. If left nil, the server defaults to accepting traffic on all ports. This filter is applied to the underlying WireGuard tunnel, meaning packets to unlisted ports are dropped before reaching your OnUDP handler.

The Server.ServedUDPPorts Field

The Server struct definition in main/tailcat.go (lines 75-90) exposes the configuration field:

type Server struct {
    // ...
    ServedUDPPorts []filter.PortRange
    // ...
}

Each filter.PortRange specifies an inclusive range using two uint16 fields:

Field Type Purpose
First uint16 Starting port number (inclusive)
Last uint16 Ending port number (inclusive)

To allow a single port, set both fields to the same value. For a range, specify the bounds accordingly.

Configuration Steps

Follow this sequence to configure port-restricted UDP listening:

  1. Create a Server value using the tailcat package.
  2. Populate ServedUDPPorts with filter.PortRange entries for your desired ports.
  3. Assign an OnUDP handler to process incoming ConnPacketConn connections.
  4. Call Start to initialize the server with the restricted filter.

Practical Implementation

This complete example demonstrates serving DNS (port 53) and NTP (port 123) only:

package main

import (
	"log"
	"net"

	"tailscale.com/tailcfg"
	"tailscale.com/tailcat"
	"tailscale.com/tailcat/internal/filter"
)

func main() {
	// Create a server instance.
	srv := &tailcat.Server{
		// Accept UDP traffic only on ports 53 (DNS) and 123 (NTP).
		ServedUDPPorts: []filter.PortRange{
			{First: 53, Last: 53},
			{First: 123, Last: 123},
		},

		// Handle incoming UDP flows.
		OnUDP: func(port uint16) func(tailcat.ConnPacketConn) {
			return func(pc tailcat.ConnPacketConn) {
				buf := make([]byte, 1500)
				for {
					n, err := pc.Read(buf)
					if err != nil {
						if err != net.ErrClosed {
							log.Printf("udp read error: %v", err)
						}
						return
					}
					// Echo the payload back to the client.
					if _, err := pc.Write(buf[:n]); err != nil {
						log.Printf("udp write error: %v", err)
						return
					}
				}
			}
		},
	}

	// Start the server.
	if err := srv.Start(); err != nil {
		log.Fatalf("failed to start tailcat server: %v", err)
	}

	// Block forever.
	select {}
}

Serving a Contiguous Range

To allow ports 5000 through 5005 inclusive, use a single range entry:

ServedUDPPorts: []filter.PortRange{
    {First: 5000, Last: 5005},
},

Key Source Files

Understanding the implementation requires familiarity with these files in tailscale/tailcat:

  • main/tailcat.go — Contains the Server struct definition including ServedUDPPorts, plus the buildFilter function that enforces port restrictions.
  • internal/filter/portrange.go — Defines the PortRange type with First and Last fields.
  • main/tailcat_test.go — Includes test cases around line 405 demonstrating ServedUDPPorts usage for UDP filtering.
  • cmd/tailcat/tailcat.go — Shows how the official CLI binary configures the server, serving as a production reference.

Summary

  • Primary mechanism: Set Server.ServedUDPPorts before calling Start to restrict UDP listening to specific ports.
  • Default behavior: When ServedUDPPorts is nil, the server accepts traffic on all ports.
  • Filter application: The buildFilter function in main/tailcat.go applies your port list at startup; unauthorized packets are dropped by the WireGuard tunnel filter.
  • Range specification: Use filter.PortRange with inclusive First and Last fields to define single ports or contiguous ranges.

Frequently Asked Questions

What happens if I don't set ServedUDPPorts?

If ServedUDPPorts remains nil, buildFilter falls back to filter.AllPorts(), allowing the server to receive UDP traffic on any port. This is implemented in main/tailcat.go lines 42-46.

Can I mix single ports and ranges in the same configuration?

Yes. The ServedUDPPorts field accepts a slice of filter.PortRange, so you can combine any number of single-port entries ({First: 80, Last: 80}) and multi-port ranges ({First: 1000, Last: 2000}) in the same slice.

Does the server need to restart to change allowed ports?

Yes. The packet filter is constructed once during Server.Start. To modify allowed ports, you must create a new Server instance with updated ServedUDPPorts and call Start again. The source code shows no runtime reconfiguration of the filter after initialization.

Where is PortRange defined in the codebase?

The PortRange type is defined in internal/filter/portrange.go (imported as "tailscale.com/tailcat/internal/filter"). It consists of two uint16 fields: First and Last, representing an inclusive port range.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →