Created
October 18, 2025 20:47
-
-
Save JohnCMcDonough/2de248e2875d958a0351a0cdf2673744 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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