Skip to content

Instantly share code, notes, and snippets.

@JohnCMcDonough
Created October 18, 2025 20:47
Show Gist options
  • Select an option

  • Save JohnCMcDonough/2de248e2875d958a0351a0cdf2673744 to your computer and use it in GitHub Desktop.

Select an option

Save JohnCMcDonough/2de248e2875d958a0351a0cdf2673744 to your computer and use it in GitHub Desktop.
// udprelay.go
// A small UDP relay/proxy that lets LAN clients discover & talk to a server
// reachable only via a routed overlay (e.g., Tailscale 100.x). It listens on
// specified UDP ports on 0.0.0.0, accepts packets from a LAN CIDR, forwards
// them to a fixed upstream server address, and relays replies back to the
// original client. Works for broadcast discovery (27016) and follow-up traffic
// (e.g., 8766, 9700).
//
// go run udprelay.go -lan-cidr 10.0.0.0/24 -server 100.74.146.12 -ports 27016,8766,9700
//
// Tested on Linux & Windows.
//
// Disclaimer: This code was written by ChatGPT, and has not been reviewed.
package main
import (
"errors"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
type tuple struct {
src string // "ip:port" of the sender
dst string // "ip:port" we forwarded to
p int // local listening port
}
type mapping struct {
client *net.UDPAddr // original LAN client
server *net.UDPAddr // upstream server (tailscale IP, same port)
last time.Time
}
type relay struct {
upstreamIP net.IP
lanNet *net.IPNet
ports []int
timeout time.Duration
// one UDP socket per port bound to 0.0.0.0:<port>
socks map[int]*net.UDPConn
// NAT-ish table: index by (server src -> client dst) and (client src -> server dst)
mu sync.Mutex
byCli map[tuple]*mapping // key: clientAddr:port + local listen port
bySrv map[tuple]*mapping // key: serverAddr:port + local listen port
closing chan struct{}
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
func parsePorts(s string) ([]int, error) {
var out []int
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
p, err := strconv.Atoi(part)
if err != nil || p <= 0 || p > 65535 {
return nil, fmt.Errorf("bad port: %q", part)
}
out = append(out, p)
}
if len(out) == 0 {
return nil, errors.New("no ports")
}
return out, nil
}
func newRelay(lanCIDR, upstream string, ports []int, timeout time.Duration) (*relay, error) {
_, lanNet, err := net.ParseCIDR(lanCIDR)
if err != nil {
return nil, fmt.Errorf("parse -lan-cidr: %w", err)
}
ip := net.ParseIP(upstream)
if ip == nil {
return nil, fmt.Errorf("parse -server IP: %q", upstream)
}
r := &relay{
upstreamIP: ip,
lanNet: lanNet,
ports: ports,
timeout: timeout,
socks: make(map[int]*net.UDPConn),
byCli: make(map[tuple]*mapping),
bySrv: make(map[tuple]*mapping),
closing: make(chan struct{}),
}
return r, nil
}
func (r *relay) listenAll() error {
for _, p := range r.ports {
addr := &net.UDPAddr{IP: net.IPv4zero, Port: p}
c, err := net.ListenUDP("udp", addr)
if err != nil {
return fmt.Errorf("listen udp %d: %w", p, err)
}
r.socks[p] = c
}
return nil
}
func (r *relay) closeAll() {
for _, c := range r.socks {
_ = c.Close()
}
close(r.closing)
}
func (r *relay) isLAN(ip net.IP) bool {
return r.lanNet.Contains(ip)
}
func (r *relay) gcLoop() {
t := time.NewTicker(r.timeout / 2)
defer t.Stop()
for {
select {
case <-t.C:
now := time.Now()
r.mu.Lock()
for k, m := range r.byCli {
if now.Sub(m.last) > r.timeout {
delete(r.byCli, k)
}
}
for k, m := range r.bySrv {
if now.Sub(m.last) > r.timeout {
delete(r.bySrv, k)
}
}
r.mu.Unlock()
case <-r.closing:
return
}
}
}
func (r *relay) run() {
var wg sync.WaitGroup
wg.Add(len(r.socks))
for port, sock := range r.socks {
go func(port int, sock *net.UDPConn) {
defer wg.Done()
buf := make([]byte, 2048)
for {
n, src, err := sock.ReadFromUDP(buf)
if err != nil {
// likely closed on shutdown
return
}
payload := make([]byte, n)
copy(payload, buf[:n])
// Decide direction by source address
if r.isLAN(src.IP) || src.IP.IsMulticast() || src.IP.Equal(net.IPv4bcast) {
// Client-side packet (LAN/broadcast): forward to upstream Tailscale IP:port
dst := &net.UDPAddr{IP: r.upstreamIP, Port: port}
_, _ = sock.WriteToUDP(payload, dst)
// Record mapping so replies go back to this client
r.mu.Lock()
m := &mapping{client: src, server: dst, last: time.Now()}
r.byCli[tuple{src.String(), dst.String(), port}] = m
r.bySrv[tuple{dst.String(), src.String(), port}] = m
r.mu.Unlock()
continue
}
// Server-side packet (coming from upstreamIP:port)
if src.IP.Equal(r.upstreamIP) {
// Look up the last client for this server:port
r.mu.Lock()
m := r.bySrv[tuple{src.String(), "", port}]
// If no broad mapping exists, try any with this server:port and pick most recent
if m == nil {
var newest *mapping
for k, v := range r.bySrv {
if strings.HasPrefix(k.src, src.String()) && k.p == port {
if newest == nil || v.last.After(newest.last) {
newest = v
}
}
}
m = newest
}
if m != nil {
m.last = time.Now()
}
r.mu.Unlock()
if m != nil {
_, _ = sock.WriteToUDP(payload, m.client)
}
continue
}
// Unknown source — ignore.
}
}(port, sock)
}
go r.gcLoop()
wg.Wait()
}
func main() {
var portsStr, lanCIDR, upstream string
var idleSec int
flag.StringVar(&lanCIDR, "lan-cidr", "192.168.50.0/24", "LAN subnet (e.g. 10.0.0.0/24) to accept clients from")
flag.StringVar(&upstream, "server", "100.74.146.12", "Upstream server IP (Tailscale) to forward to (e.g. 100.74.146.12)")
flag.StringVar(&portsStr, "ports", "27016,8766,9700", "Comma-separated UDP ports to relay")
flag.IntVar(&idleSec, "idle-timeout", 30, "Idle mapping timeout in seconds")
flag.Parse()
if lanCIDR == "" || upstream == "" {
fmt.Println("usage: udprelay -lan-cidr 10.0.0.0/24 -server 100.x.y.z -ports 27016,8766,9700")
os.Exit(2)
}
ports, err := parsePorts(portsStr)
must(err)
r, err := newRelay(lanCIDR, upstream, ports, time.Duration(idleSec)*time.Second)
must(err)
must(r.listenAll())
fmt.Printf("[udprelay] LAN %s -> server %s on ports %v (timeout %ds)\n",
r.lanNet.String(), r.upstreamIP.String(), ports, idleSec)
// Graceful shutdown
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
fmt.Println("\n[udprelay] shutting down…")
r.closeAll()
}()
r.run()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment