Skip to content

Commit 3e72216

Browse files
feat: add UDP support for upstream proxy and implement UDP relay func… (#239)
1 parent 4b9fae4 commit 3e72216

13 files changed

Lines changed: 734 additions & 11 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## [1.65.0] - 2026-06-06
44

55
- ADDED: **Block (blackhole) routing mode** - a new "Block" option in a set's Routing tab that blocks all matched traffic (ad/tracker domains, IPs, or a GeoSite category like `category-ads-all`) across the whole network - every LAN device and the router itself - with no output interface needed. It blocks by name and not just by IP, so it keeps working even with encrypted DNS and won't break unrelated sites sharing the same servers. See [Blocking](https://daniellavrushin.github.io/b4/docs/sets/blocking) for setup and details.
6+
- ADDED: **Upstream SOCKS5 proxy now routes UDP too** - sets that send traffic to an upstream SOCKS5 proxy used to forward only regular (TCP) connections; UDP traffic - such as QUIC (used by YouTube and many Google and video services) and DNS - bypassed the proxy and went out directly. UDP from LAN devices now goes through the upstream proxy as well, so a set can route a device's full traffic through it. The proxy must accept UDP.
67

78
## [1.64.0] - 2026-06-01
89

src/config/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ type UpstreamProxyConfig struct {
417417
Password string `json:"password,omitempty"`
418418
FailOpen bool `json:"fail_open"`
419419
UseDomain bool `json:"use_domain"`
420+
UDP bool `json:"udp"`
420421
}
421422

422423
type SetMSSClampEntry struct {

src/http/ui/src/components/sets/routing/TrafficRouting.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const TrafficRouting = ({
6464
password: "",
6565
fail_open: false,
6666
use_domain: true,
67+
udp: false,
6768
};
6869

6970
let flowDestination: string;
@@ -405,6 +406,16 @@ export const TrafficRouting = ({
405406
description={t("sets.routing.useDomainDesc")}
406407
/>
407408
</Grid>
409+
<Grid size={{ xs: 12 }}>
410+
<B4Switch
411+
label={t("sets.routing.udp")}
412+
checked={upstream.udp === true}
413+
onChange={(checked: boolean) =>
414+
onChange("routing.upstream.udp", checked)
415+
}
416+
description={t("sets.routing.udpDesc")}
417+
/>
418+
</Grid>
408419
<Grid size={{ xs: 12 }}>
409420
<B4Switch
410421
label={t("sets.routing.failOpen")}

src/http/ui/src/i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,8 @@
12511251
"failOpen": "Fall back to direct on upstream failure",
12521252
"failOpenDesc": "If the upstream proxy is unreachable, open a plain direct connection to the original destination instead of failing.",
12531253
"failOpenWarning": "Fail-open exposes the destination to your ISP if the upstream goes down. Disable for whitelist-mode regions where direct connections do not work and you want failures to be visible.",
1254+
"udp": "Route UDP through upstream",
1255+
"udpDesc": "Also tunnel matched UDP traffic through the upstream SOCKS5 proxy (via UDP ASSOCIATE), not just TCP. Applies to forwarded traffic from LAN clients. Leave off for TCP-only routing.",
12541256
"proxyManipulationNote": "In proxy mode, packet manipulation (faking, fragmentation, desync) is disabled for matched traffic. The upstream proxy handles obfuscation.",
12551257
"flowNoUpstream": "upstream not configured",
12561258
"flowProxyCaption": "Matched traffic is handed off to the upstream SOCKS5 proxy. The proxy is responsible for reaching the destination.",

src/http/ui/src/i18n/ru.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,8 @@
12481248
"failOpen": "Прямое подключение при недоступности upstream",
12491249
"failOpenDesc": "Если upstream proxy недоступен, открыть прямое соединение с исходным адресом назначения вместо ошибки.",
12501250
"failOpenWarning": "Fail-open показывает адрес назначения вашему провайдеру, если upstream упал. Отключите для регионов с белыми списками, где прямые подключения не работают и вы хотите видеть ошибки.",
1251+
"udp": "Маршрутизировать UDP через upstream",
1252+
"udpDesc": "Туннелировать совпавший UDP-трафик через upstream SOCKS5 proxy (через UDP ASSOCIATE), а не только TCP. Применяется к транзитному трафику от клиентов в LAN. Оставьте выключенным для маршрутизации только TCP.",
12511253
"proxyManipulationNote": "В режиме proxy манипуляции с пакетами (faking, fragmentation, desync) для совпавшего трафика отключены. Обфускацией занимается upstream proxy.",
12521254
"flowNoUpstream": "upstream не настроен",
12531255
"flowProxyCaption": "Совпавший трафик передаётся в upstream SOCKS5 proxy. Доставку до места назначения обеспечивает proxy.",

src/http/ui/src/models/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export interface UpstreamProxyConfig {
391391
password?: string;
392392
fail_open: boolean;
393393
use_domain: boolean;
394+
udp: boolean;
394395
}
395396

396397
export interface RoutingConfig {

src/socks5/client_udp.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package socks5
2+
3+
import (
4+
"context"
5+
"encoding/binary"
6+
"fmt"
7+
"io"
8+
"net"
9+
"strconv"
10+
"time"
11+
)
12+
13+
type UDPUpstream struct {
14+
ctrl net.Conn
15+
relay net.Conn
16+
hdr []byte
17+
}
18+
19+
func DialUpstreamUDP(ctx context.Context, cfg ClientConfig, dstIP net.IP, dstPort int) (*UDPUpstream, error) {
20+
if cfg.Host == "" || cfg.Port < 1 || cfg.Port > 65535 {
21+
return nil, fmt.Errorf("invalid upstream config")
22+
}
23+
if dstPort < 1 || dstPort > 65535 {
24+
return nil, fmt.Errorf("invalid target port")
25+
}
26+
if dstIP == nil {
27+
return nil, fmt.Errorf("invalid target ip")
28+
}
29+
30+
timeout := cfg.Timeout
31+
if timeout <= 0 {
32+
timeout = dialTimeout
33+
}
34+
35+
d := net.Dialer{Timeout: timeout}
36+
ApplyBypassMark(&d, cfg.BypassMark)
37+
ctrlAddr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
38+
ctrl, err := d.DialContext(ctx, "tcp", ctrlAddr)
39+
if err != nil {
40+
return nil, fmt.Errorf("dial upstream: %w", err)
41+
}
42+
43+
_ = ctrl.SetDeadline(time.Now().Add(timeout))
44+
if err := clientGreet(ctrl, cfg.Username, cfg.Password); err != nil {
45+
ctrl.Close()
46+
return nil, err
47+
}
48+
relayHost, relayPort, err := clientUDPAssociate(ctrl)
49+
if err != nil {
50+
ctrl.Close()
51+
return nil, err
52+
}
53+
_ = ctrl.SetDeadline(time.Time{})
54+
55+
if ip := net.ParseIP(relayHost); ip == nil || ip.IsUnspecified() {
56+
relayHost = cfg.Host
57+
}
58+
59+
ud := net.Dialer{Timeout: timeout}
60+
ApplyBypassMark(&ud, cfg.BypassMark)
61+
relayAddr := net.JoinHostPort(relayHost, strconv.Itoa(relayPort))
62+
relay, err := ud.DialContext(ctx, "udp", relayAddr)
63+
if err != nil {
64+
ctrl.Close()
65+
return nil, fmt.Errorf("dial relay: %w", err)
66+
}
67+
68+
u := &UDPUpstream{
69+
ctrl: ctrl,
70+
relay: relay,
71+
hdr: buildUDPHeader(&net.UDPAddr{IP: dstIP, Port: dstPort}),
72+
}
73+
go u.watchCtrl()
74+
return u, nil
75+
}
76+
77+
func (u *UDPUpstream) watchCtrl() {
78+
buf := make([]byte, 1)
79+
_, _ = u.ctrl.Read(buf)
80+
u.Close()
81+
}
82+
83+
func (u *UDPUpstream) Write(payload []byte) (int, error) {
84+
pkt := make([]byte, 0, len(u.hdr)+len(payload))
85+
pkt = append(pkt, u.hdr...)
86+
pkt = append(pkt, payload...)
87+
if _, err := u.relay.Write(pkt); err != nil {
88+
return 0, err
89+
}
90+
return len(payload), nil
91+
}
92+
93+
func (u *UDPUpstream) Read(buf []byte) (int, error) {
94+
n, err := u.relay.Read(buf)
95+
if err != nil {
96+
return 0, err
97+
}
98+
if n < 4 {
99+
return 0, fmt.Errorf("short datagram")
100+
}
101+
if buf[0] != 0 || buf[1] != 0 {
102+
return 0, fmt.Errorf("bad rsv")
103+
}
104+
if buf[2] != 0 {
105+
return 0, fmt.Errorf("fragmented datagram")
106+
}
107+
_, off, perr := parseUDPAddress(buf[:n])
108+
if perr != nil {
109+
return 0, perr
110+
}
111+
copy(buf, buf[off:n])
112+
return n - off, nil
113+
}
114+
115+
func (u *UDPUpstream) SetReadDeadline(t time.Time) error {
116+
return u.relay.SetReadDeadline(t)
117+
}
118+
119+
func (u *UDPUpstream) Close() error {
120+
if u.ctrl != nil {
121+
u.ctrl.Close()
122+
}
123+
if u.relay != nil {
124+
return u.relay.Close()
125+
}
126+
return nil
127+
}
128+
129+
func clientUDPAssociate(conn net.Conn) (host string, port int, err error) {
130+
req := []byte{socks5Version, cmdUDPAssociate, 0x00, atypIPv4, 0, 0, 0, 0, 0, 0}
131+
if _, err := conn.Write(req); err != nil {
132+
return "", 0, fmt.Errorf("udp associate write: %w", err)
133+
}
134+
135+
head := make([]byte, 4)
136+
if _, err := io.ReadFull(conn, head); err != nil {
137+
return "", 0, fmt.Errorf("udp associate reply head: %w", err)
138+
}
139+
if head[0] != socks5Version {
140+
return "", 0, fmt.Errorf("upstream bad version in reply: %d", head[0])
141+
}
142+
if head[1] != repSuccess {
143+
return "", 0, fmt.Errorf("upstream udp associate rejected: code=%d", head[1])
144+
}
145+
146+
var hostBuf []byte
147+
switch head[3] {
148+
case atypIPv4:
149+
hostBuf = make([]byte, 4)
150+
case atypIPv6:
151+
hostBuf = make([]byte, 16)
152+
case atypDomain:
153+
l := make([]byte, 1)
154+
if _, err := io.ReadFull(conn, l); err != nil {
155+
return "", 0, fmt.Errorf("udp associate reply addr len: %w", err)
156+
}
157+
hostBuf = make([]byte, int(l[0]))
158+
default:
159+
return "", 0, fmt.Errorf("upstream bad atyp in reply: %d", head[3])
160+
}
161+
if _, err := io.ReadFull(conn, hostBuf); err != nil {
162+
return "", 0, fmt.Errorf("udp associate reply addr: %w", err)
163+
}
164+
portBuf := make([]byte, 2)
165+
if _, err := io.ReadFull(conn, portBuf); err != nil {
166+
return "", 0, fmt.Errorf("udp associate reply port: %w", err)
167+
}
168+
port = int(binary.BigEndian.Uint16(portBuf))
169+
170+
switch head[3] {
171+
case atypIPv4, atypIPv6:
172+
host = net.IP(hostBuf).String()
173+
case atypDomain:
174+
host = string(hostBuf)
175+
}
176+
return host, port, nil
177+
}

src/socks5/client_udp_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package socks5
2+
3+
import (
4+
"context"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
"github.com/daniellavrushin/b4/config"
10+
)
11+
12+
func freePort(t *testing.T) int {
13+
t.Helper()
14+
l, err := net.Listen("tcp", "127.0.0.1:0")
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
defer l.Close()
19+
return l.Addr().(*net.TCPAddr).Port
20+
}
21+
22+
func startUDPEcho(t *testing.T) int {
23+
t.Helper()
24+
echo, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
t.Cleanup(func() { echo.Close() })
29+
go func() {
30+
buf := make([]byte, 2048)
31+
for {
32+
n, addr, err := echo.ReadFromUDP(buf)
33+
if err != nil {
34+
return
35+
}
36+
_, _ = echo.WriteToUDP(buf[:n], addr)
37+
}
38+
}()
39+
return echo.LocalAddr().(*net.UDPAddr).Port
40+
}
41+
42+
func TestDialUpstreamUDPRoundTrip(t *testing.T) {
43+
echoPort := startUDPEcho(t)
44+
45+
port := freePort(t)
46+
cfg := config.NewConfig()
47+
cfg.System.Socks5.Enabled = true
48+
cfg.System.Socks5.BindAddress = "127.0.0.1"
49+
cfg.System.Socks5.Port = port
50+
51+
srv := NewServer(&cfg)
52+
if err := srv.Start(); err != nil {
53+
t.Fatalf("server start: %v", err)
54+
}
55+
defer srv.Stop()
56+
57+
time.Sleep(50 * time.Millisecond)
58+
59+
ucfg := ClientConfig{Host: "127.0.0.1", Port: port, Timeout: 3 * time.Second}
60+
u, err := DialUpstreamUDP(context.Background(), ucfg, net.IPv4(127, 0, 0, 1), echoPort)
61+
if err != nil {
62+
t.Fatalf("dial upstream udp: %v", err)
63+
}
64+
defer u.Close()
65+
66+
msg := []byte("hello udp world")
67+
if _, err := u.Write(msg); err != nil {
68+
t.Fatalf("write: %v", err)
69+
}
70+
71+
_ = u.SetReadDeadline(time.Now().Add(3 * time.Second))
72+
buf := make([]byte, 2048)
73+
n, err := u.Read(buf)
74+
if err != nil {
75+
t.Fatalf("read: %v", err)
76+
}
77+
if string(buf[:n]) != string(msg) {
78+
t.Fatalf("echo mismatch: got %q want %q", buf[:n], msg)
79+
}
80+
}
81+
82+
func TestDialUpstreamUDPAuth(t *testing.T) {
83+
echoPort := startUDPEcho(t)
84+
85+
port := freePort(t)
86+
cfg := config.NewConfig()
87+
cfg.System.Socks5.Enabled = true
88+
cfg.System.Socks5.BindAddress = "127.0.0.1"
89+
cfg.System.Socks5.Port = port
90+
cfg.System.Socks5.Username = "user"
91+
cfg.System.Socks5.Password = "pass"
92+
93+
srv := NewServer(&cfg)
94+
if err := srv.Start(); err != nil {
95+
t.Fatalf("server start: %v", err)
96+
}
97+
defer srv.Stop()
98+
99+
time.Sleep(50 * time.Millisecond)
100+
101+
ucfg := ClientConfig{Host: "127.0.0.1", Port: port, Username: "user", Password: "pass", Timeout: 3 * time.Second}
102+
u, err := DialUpstreamUDP(context.Background(), ucfg, net.IPv4(127, 0, 0, 1), echoPort)
103+
if err != nil {
104+
t.Fatalf("dial upstream udp with auth: %v", err)
105+
}
106+
defer u.Close()
107+
108+
msg := []byte("authed datagram")
109+
if _, err := u.Write(msg); err != nil {
110+
t.Fatalf("write: %v", err)
111+
}
112+
_ = u.SetReadDeadline(time.Now().Add(3 * time.Second))
113+
buf := make([]byte, 2048)
114+
n, err := u.Read(buf)
115+
if err != nil {
116+
t.Fatalf("read: %v", err)
117+
}
118+
if string(buf[:n]) != string(msg) {
119+
t.Fatalf("echo mismatch: got %q want %q", buf[:n], msg)
120+
}
121+
}

0 commit comments

Comments
 (0)