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:
- Create a
Servervalue using thetailcatpackage. - Populate
ServedUDPPortswithfilter.PortRangeentries for your desired ports. - Assign an
OnUDPhandler to process incomingConnPacketConnconnections. - Call
Startto 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 theServerstruct definition includingServedUDPPorts, plus thebuildFilterfunction that enforces port restrictions.internal/filter/portrange.go— Defines thePortRangetype withFirstandLastfields.main/tailcat_test.go— Includes test cases around line 405 demonstratingServedUDPPortsusage 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.ServedUDPPortsbefore callingStartto restrict UDP listening to specific ports. - Default behavior: When
ServedUDPPortsis nil, the server accepts traffic on all ports. - Filter application: The
buildFilterfunction inmain/tailcat.goapplies your port list at startup; unauthorized packets are dropped by the WireGuard tunnel filter. - Range specification: Use
filter.PortRangewith inclusiveFirstandLastfields 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →