feat: implement Cloudflare Worker connection pooling and management - #290
Conversation
DanielLavrushin
commented
Aug 8, 2026
- Added cfWorkerPool for managing pooled connections to Cloudflare Workers.
- Introduced new transport plans for handling Worker connections.
- Enhanced connection dialing logic to support Worker-specific configurations.
- Implemented tests for Worker connection pooling and dialing behavior.
- Updated existing code to integrate Worker pooling into the MTProto transport layer.
- Added cfWorkerPool for managing pooled connections to Cloudflare Workers. - Introduced new transport plans for handling Worker connections. - Enhanced connection dialing logic to support Worker-specific configurations. - Implemented tests for Worker connection pooling and dialing behavior. - Updated existing code to integrate Worker pooling into the MTProto transport layer.
…nnection management for Cloudflare Workers
- Refactor dnsTCPServer to support both IPv4 and IPv6 listeners. - Add configuration options for enabling/disabling IPv4 and IPv6. - Update connection handling to manage separate listeners for each IP family. - Enhance error handling for listener startup failures. - Modify writeDNSTCPMessage to accept a timeout parameter. - Introduce tests for dual-stack functionality, ensuring proper blocking behavior for both IPv4 and IPv6. - Update configuration defaults and validation to ensure DNS over TCP settings are correctly applied. - Add UI components for DNS settings, allowing users to configure TCP options.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 high |
| Security | 2 critical 1 high |
| CodeStyle | 2 minor |
🟢 Metrics 671 complexity
Metric Results Complexity 671
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull request overview
This PR expands b4’s transport and DNS subsystems by adding Cloudflare Worker connection pooling for MTProto/WebSocket dialing and introducing full DNS-over-TCP interception (listener + firewall redirects), plus UI/config support to manage DNS timeouts and listener port.
Changes:
- Added a Cloudflare Worker warm-connection pool and updated MTProto dialing/planning to distinguish native Telegram WS edge vs Worker/CF-proxy routes.
- Implemented DNS-over-TCP interception end-to-end: TCP listener, iptables/nftables redirect rules, monitoring, and runtime readiness wiring.
- Added configuration schema/validation + Web UI settings/i18n for DNS TCP and DNS query timeouts, with tests and changelog updates.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tables/nftables.go | Applies DNS-over-TCP nftables rules as part of nftables apply/clear lifecycle. |
| src/tables/monitor.go | Extends firewall rule monitoring to include DNS-over-TCP redirect rule presence checks. |
| src/tables/iptables.go | Adds DNS-over-TCP chains/rules into iptables manifest and teardown paths. |
| src/tables/dnstcp.go | Implements iptables/nftables DNS-over-TCP redirect rule creation and cleanup logic. |
| src/nfq/types.go | Adds DNS TCP server handle to NFQ pool state. |
| src/nfq/pool.go | Starts/stops DNS-over-TCP listener and publishes readiness for firewall rule gating. |
| src/nfq/dnstcp.go | Implements DNS-over-TCP listener, query handling, and passthrough forwarding behavior. |
| src/nfq/dnstcp_test.go | Unit tests for DNS TCP message framing and original-destination helper behavior. |
| src/nfq/dnstcp_dualstack_test.go | Integration-style tests for dual-stack DNS-over-TCP listener behavior and blocking. |
| src/nfq/dns.go | Adds richer DNS trace event labeling and unifies endpoint extraction logic. |
| src/mtproto/workerprobe_test.go | Adds an opt-in manual diagnostic test for Worker dial/idle lifetime behavior. |
| src/mtproto/transparent.go | Switches transparent bridge dialing to use the Worker pool (when configured) and correct splitter selection. |
| src/mtproto/server.go | Creates/tears down Worker pool alongside wsPool and routes dialer through both pools. |
| src/mtproto/obfuscated2.go | Updates transport planning/dialing: native-vs-worker distinction, shuffle Worker domains, new dialInfo/dialPools. |
| src/mtproto/msgsplit.go | Introduces splitter selection logic based on whether the transport is a Worker relay. |
| src/mtproto/cfworkerpool.go | Implements warm spare WebSocket connections per Worker/DC key with max-age expiry. |
| src/mtproto/cfworkerpool_test.go | Adds tests covering Worker-pool behaviors and updated transport-planning semantics. |
| src/main.go | Wires NFQ DNS TCP readiness publisher into tables layer for rule gating. |
| src/http/ui/src/models/config.ts | Adds DNS system config typing to the UI config model. |
| src/http/ui/src/i18n/ru.json | Adds RU translations for the new DNS settings section. |
| src/http/ui/src/i18n/en.json | Adds EN translations for the new DNS settings section. |
| src/http/ui/src/components/settings/Page.tsx | Adds DNS settings section to the settings page layout. |
| src/http/ui/src/components/settings/Dns.tsx | Implements UI controls for DNS-over-TCP enable/port and DNS timeout configuration. |
| src/http/ui/src/assets/doh.json | Adds additional DoH presets to the UI asset list. |
| src/http/handler/preflight.go | Adds preflight port probing when enabling/changing DNS TCP listener port. |
| src/http/handler/config.go | Triggers soft-restart updates when DNS TCP settings change. |
| src/config/validation.go | Adds DNS TCP port range validation and port collision reservation logic. |
| src/config/types.go | Adds DNS system config types and default constants. |
| src/config/methods.go | Adds computed getters for DNS TCP port/timeouts and interception enablement logic. |
| src/config/dns_test.go | Adds tests for DNS system defaults, validation interactions, and config sparsification. |
| src/config/config.go | Sets default DNS system config values in DefaultConfig. |
| changelog.md | Documents DNS-over-TCP interception, Worker pooling changes, and related fixes in release notes. |
| changelog_ru.md | RU changelog entries for the new DNS and Worker-related changes. |
Suppressed comments (1)
src/mtproto/obfuscated2.go:454
- workerPool can be nil (e.g., DialObfuscatedDC called with pools == nil), but warm() is called unconditionally for Worker plans after a successful fresh dial. This can panic even though dialing itself succeeded.
if p.isWorker {
workerPool.warm(p)
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/nfq/dns.go:126
logDNSEventalways logs the protocol as "UDP". Since DNS-over-TCP events are reported via thetcp/action prefix, trace output will incorrectly label TCP activity as UDP.
if set != nil {
setName = set.Name
}
log.LogConnection("UDP", setName, domain, clientIP.String(), clientPort, "", serverIP.String(), 53, srcMac, "", action)
}
src/tables/monitor.go:236
- This iptables DNS-over-TCP rule check only fails when both
-S PREROUTINGand-S B4_DNSTCPsucceed. If the chain is missing (or the command errors), the monitor will incorrectly report rules as present and won’t trigger a restore.
src/tables/dnstcp.go:135 applyDNSTCPFamilyunconditionally runsnft add table ...and returns an error if the table already exists. If the per-family DNS nat table is left behind (e.g., after a partial cleanup or crash), future applies will treat that as fatal and skip installing DNS-over-TCP redirect rules for that family.
changelog.md:7- PR title/description focus on Cloudflare Worker pooling, but this change set also introduces a sizable DNS-over-TCP interception feature (firewall rules, NFQ TCP listener, config + UI + docs). Consider updating the PR title/description (or splitting) so reviewers can track both scopes explicitly.
## [1.75.0] - 2026-08-08
- FIXED: **DNS sent over TCP skipped the set's DNS server** - only requests over UDP were intercepted, so a device that switched to TCP reached the router's usual resolver instead.
- ADDED: **A DNS section in Settings** - the port used for DNS over TCP, how long to wait for an answer, an off switch, and a check against ports other services already use.
- ADDED: **DNS request outcomes in the trace** - a trace named the domain but never whether the request reached the set's DNS server or slipped past it.
- Added a switch for "any domain" and "any address" to simplify configuration. - Fixed an issue where `0.0.0.0/0` was lost in the firewall due to `ipset` limitations. - Enhanced DNS handling to capture TCP requests and added a DNS settings section. - Introduced a new catch-all mechanism for domains and IPs, including normalization and entry notices. - Updated UI components to support highlighting and displaying notices for catch-all entries. - Implemented tests for new CIDR handling logic to ensure correct expansion of zero-prefix entries.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/mtproto/obfuscated2.go:80
shuffledWorkerDomainsuses the globalmath/randwithout ever seeding it, so the “random” Worker order will be deterministic across process starts. This defeats the intended load spreading described in the comment.
mrand.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
src/tables/dnstcp.go:28
- PR metadata describes Cloudflare Worker connection pooling changes, but this PR also introduces a substantial DNS-over-TCP interception feature (iptables/nftables rules, NFQ listener, UI/settings, config validation, changelog). Please either update the PR title/description to reflect the full scope, or split the DNS and routing/UI changes into separate PR(s) so review/risk is clearer.
src/tables/monitor.go:344 - If
nft.runNft("list", "table", ...)returns an error, this check currently treats it as “rules present” and continues. That can mask missing DNS-over-TCP redirect rules (or permission/exec failures) and make the monitor incorrectly report a healthy ruleset.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 50 out of 50 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
src/tables/monitor.go:344
- In the nftables monitor, an error from
nft.runNft("list", "table", ...)is currently ignored, which can make the monitor report rules as present even when nftables queries are failing (false positive). Treat listing failures as missing rules so the monitor can trigger a restore.
src/config/methods.go:287 DNSTCPInterceptEnabled()is gated onHasDNSRedirect()(a set withDNS.Enabledand a DoH/TargetDNS). But the DNS-over-TCP server also implements routing-mode blocking, which does not requireset.DNS.Enabled. With the current gate, a block-only set will still be bypassed for DNS-over-TCP because the listener and redirect rules never start/install.
func (c *Config) DNSTCPInterceptEnabled() bool {
return !c.System.DNS.TCPDisabled && c.HasDNSRedirect()
}
src/http/ui/src/models/config.ts:356
MarshalSparsecan omitsystem.dnsentirely when all DNS settings are defaults, so the UI model should treat this field as optional. Making it required here makes it easier to accidentally dereferenceconfig.system.dnselsewhere without a fallback, and it doesn't match the on-the-wire shape for existing configs.
export interface SystemConfig {
logging: LoggingConfig;
web_server: WebServerConfig;
socks5: Socks5Config;
mtproto: MTProtoConfig;
tables: TableConfig;
checker: DiscoveryConfig;
geo: GeoConfig;
api: ApiConfig;
ai: AIConfig;
dns: DnsSystemConfig;
timezone: string;
memory_limit?: string;
src/http/handler/preflight.go:53
- DNS-over-TCP preflight probes only
0.0.0.0:<port>, but the DNS TCP listener can also bindtcp6on[::]:<port>when IPv6 is enabled. This can miss an IPv6-only port collision (or incorrectly report success when the IPv6 bind will fail), leading to a config that passes preflight but can't start the TCP listener.
if newCfg.DNSTCPInterceptEnabled() {
oldPort := oldCfg.DNSTCPListenPort()
newPort := newCfg.DNSTCPListenPort()
if !oldCfg.DNSTCPInterceptEnabled() || oldPort != newPort {
if f := probePort("system.dns.tcp_port", "0.0.0.0", newPort); f != nil {
src/config/validation.go:363
- The DNS TCP port is currently added to the port-collision set whenever
tcp_disabledis false, even if DNS-over-TCP interception is effectively inactive (e.g. no enabled sets with DNS redirect / block rules, so no listener or firewall redirect rules are started). This can produce unnecessary "port already in use" validation errors for services that would otherwise be able to use the port safely.
if !c.System.DNS.TCPDisabled {
if p := c.System.DNS.TCPPort; p != 0 && (p < 1 || p > 65535) {
v.add("system.dns.tcp_port", "out_of_range", "port must be between 1 and 65535", portRangeParams)
} else {
refs = append(refs, portRef{"system.dns.tcp_port", c.DNSTCPListenPort()})
}
}
… custom payload support
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 54 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/mtproto/obfuscated2.go:80
shuffledWorkerDomainsusesmath/rand.Shufflebut the codebase does not appear to seed the global PRNG. That makes the Worker order deterministic across process starts, which undermines the stated goal of spreading load/quota across workers.
src/tables/monitor.go:344- In the nftables DNS-over-TCP check, an error from
nft list tablecurrently does not fail the monitor check (it only checks for missing "redirect" whenerr == nil). If nft returns an error here, the monitor can incorrectly report rules as present and skip restoration.
src/tables/dnstcp.go:14 - The PR title/description focuses on Cloudflare Worker connection pooling, but this PR also introduces DNS-over-TCP interception (iptables/nftables rules + UI/config). Please update the PR description to reflect this added scope, or split the DNS work into a separate PR for easier review/release notes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 54 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/mtproto/obfuscated2.go:80
shuffledWorkerDomainsusesmath/rand's global source without any explicit seeding in the codebase, so the initial worker order after each process start is deterministic. If the goal is to spread load/quota across workers immediately after restart, use a per-call RNG seeded with time (or seed the global RNG once) before shuffling.
changelog.md:7- The PR title/description describe Cloudflare Worker connection pooling, but this release entry (and the diff) also includes multiple substantial, unrelated features (DNS-over-TCP interception + UI, routing/firewall fixes, matcher behavior changes, discovery payload changes). Please update the PR metadata to reflect the full scope, or consider splitting into focused PRs to make review/rollback safer.
## [1.75.0] - 2026-08-08
- ADDED: **A switch for matching every domain, and one for matching every address** - a set meant to catch everything had to be written by hand as `regexp:.*` or `0.0.0.0/0`, and neither form appears anywhere in the interface. The IPv6 half, `::/0`, was easy to leave out, so a catch-all set passed IPv6 traffic through untouched without saying so. Typing `*`, `any` or `all` into either field adds the same entries, and `*.example.com` is stored as `example.com`, which was taken as written and matched nothing.
- FIXED: **Two sets listing the same IP range fought over it unpredictably** - only one of them reached the matcher, and it was whichever was stored last rather than the one placed higher in the list. A set restricted to source devices lost the range outright to a general set below it, and the winner could change between restarts. Ranges of equal length are settled by set order, and a device-scoped set is offered the traffic first.
- FIXED: **`0.0.0.0/0` among a set's IP targets was dropped on the way to the firewall** - `ipset` cannot store a network with a zero prefix size, so on routers using iptables the routing set lost that entry, while per-set MSS clamping and duplication lost every entry alongside it, since those are loaded in one batch that fails whole. The matching engine had accepted the same entry all along, so a set matched traffic while its firewall side stood empty. Both catch-alls are written to the firewall as their two halves.
src/http/ui/src/models/config.ts:354
SystemConfigdeclaresdnsas required, but the UI already treats it as possibly absent (config.system.dns ?? {}) and the backend can omit the DNS section when it matches defaults. Marking this field optional avoids misleading typing and prevents other UI code from assuming it is always present.
dns: DnsSystemConfig;
src/tables/monitor.go:344
- If
nft list tablefails, the monitor currently treats DNS-over-TCP rules as present and continues. That can hide missing/failed DNS-over-TCP setup (e.g., permissions or table corruption) and prevent corrective re-apply logic from triggering. Treat list errors as a failure here.
src/http/handler/preflight.go:53 - DNS-over-TCP can bind both
0.0.0.0:<port>(tcp4) and[::]:<port>(tcp6), but preflight only probes0.0.0.0. This can report the port as free even when an IPv6 listener would fail to bind, leaving IPv6 DNS-over-TCP silently disabled. Probe::as well when IPv6 is enabled.
if newCfg.DNSTCPInterceptEnabled() {
oldPort := oldCfg.DNSTCPListenPort()
newPort := newCfg.DNSTCPListenPort()
if !oldCfg.DNSTCPInterceptEnabled() || oldPort != newPort {
if f := probePort("system.dns.tcp_port", "0.0.0.0", newPort); f != nil {
fields = append(fields, *f)
}
}
}
src/nfq/dnstcp.go:335
passthroughexplicitly clears read deadlines on both the client and upstream conns (SetReadDeadline(time.Time{})). That means the configured DNS-over-TCP timeouts (idle/io) are not enforced for unmatched/passthrough TCP sessions, which can keep idle redirected connections around indefinitely once interception is enabled.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 63 out of 63 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/tables/dnstcp.go:147
- applyDNSTCPFamily unconditionally runs
nft add table ..., which fails with “file exists” if the DNS nat table is left behind (e.g. a prior run crashed between creating the DNS table and clearing it). That would prevent DNS-over-TCP redirects from being installed for that family. Consider clearing/deleting an existing family table before creating it, or only creating it when missing.
changelog.md:19 - The PR title/description are scoped to Cloudflare Worker pooling/management, but this changelog entry (and the diff overall) includes additional user-facing features and behavioral changes (DNS-over-TCP interception + settings, set overlap handling, UI catch-all entries, discovery behavior changes, etc.). Please update the PR description/title to reflect the full scope, or split unrelated changes into separate PRs for easier review and safer rollout.
## [1.75.0] - 2026-08-08
- FIXED: **A domain was handled by a different strategy than the one Discovery found for it** - every result was published with all the domains of the run, so two applied results claimed the same domains and set order picked the winner.
- FIXED: **A set applied from Discovery had no effect until b4 was restarted** - it was saved and listed in the interface while the traffic it named kept going out under the previous settings.
- ADDED: **A warning when several enabled sets name the same domain** - only one of them handles that domain, chosen by its place in the list rather than by which strategy works.
- ADDED: **A switch for matching every domain, and one for matching every address** - a set meant to catch everything had to be written by hand as `regexp:.*` or `0.0.0.0/0`, and neither form appears anywhere in the interface. The IPv6 half, `::/0`, was easy to leave out, so a catch-all set passed IPv6 traffic through untouched without saying so. Typing `*`, `any` or `all` into either field adds the same entries, and `*.example.com` is stored as `example.com`, which was taken as written and matched nothing.
- FIXED: **Two sets listing the same IP range fought over it unpredictably** - only one of them reached the matcher, and it was whichever was stored last rather than the one placed higher in the list. A set restricted to source devices lost the range outright to a general set below it, and the winner could change between restarts. Ranges of equal length are settled by set order, and a device-scoped set is offered the traffic first.
- FIXED: **`0.0.0.0/0` among a set's IP targets was dropped on the way to the firewall** - `ipset` cannot store a network with a zero prefix size, so on routers using iptables the routing set lost that entry, while per-set MSS clamping and duplication lost every entry alongside it, since those are loaded in one batch that fails whole. The matching engine had accepted the same entry all along, so a set matched traffic while its firewall side stood empty. Both catch-alls are written to the firewall as their two halves.
- FIXED: **A set routed through a TCP-only upstream proxy left no firewall rules at all on routers using iptables** - each sync of the set failed on the name of its QUIC-refusing chain, one character over the length iptables accepts.
- FIXED: **DNS sent over TCP skipped the set's DNS server** - only requests over UDP were intercepted, so a device that switched to TCP reached the router's usual resolver instead.
- ADDED: **A DNS section in Settings** - the port used for DNS over TCP, how long to wait for an answer, an off switch, and a check against ports other services already use.
- ADDED: **DNS request outcomes in the trace** - a trace named the domain but never whether the request reached the set's DNS server or slipped past it.
- FIXED: **One refusal from Telegram's WebSocket edge switched off a working Cloudflare Worker** - the refusal was treated as a verdict on every WebSocket route to that data center, including the relay the user runs themselves.
- FIXED: **A paused Telegram data center address was answered with an instant failure** - Telegram read that as grounds to reconnect immediately, filling the log with hundreds of identical lines a second.
- ADDED: **A warm spare connection to your Cloudflare Worker** - one ready connection per Worker and data center, in place of the 65-90 ms handshake every new Telegram connection paid for.
- FIXED: **Traffic to a Cloudflare Worker was cut into one small write per Telegram message** - that framing was only ever required by Telegram's own WebSocket edge.
- CHANGED: **Several Worker addresses are tried in a random order** - they were tried strictly as written, so the first absorbed all the traffic and all the rate limiting.
src/nfq/dnstcp.go:199
- writeDNSTCPMessage writes the framed DNS message with a single Write call. net.Conn.Write is allowed to return a short write without an error, which could truncate DNS responses under load or when using non-TCP transports in tests. Loop until the whole buffer is written (or return an explicit short-write error).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 63 out of 63 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/mtproto/obfuscated2.go:80
shuffledWorkerDomainsusesmath/rand's global PRNG but the codebase doesn't seed it anywhere, so the shuffle order will be deterministic across process starts (and therefore won’t reliably spread load the way the comment describes). Consider using a locally-seededrand.Rand(or crypto/rand) for the shuffle.
src/tables/cidr.go:62expandZeroPrefixdedupes only the generated /1 halves, but it can still produce duplicate elements when the input already contains one of the halves (e.g.0.0.0.0/1plus0.0.0.0/0). This is risky for the nftables path becauseNFTablesManager.addSetElementsusesnft add element(no "-exist" equivalent) and will fail the whole batch if a duplicate is present. Consider deduping all emitted entries (while preserving order).
src/http/handler/preflight.go:53- DNS-over-TCP preflight currently probes only
0.0.0.0:<port>regardless of which IP families are enabled. This can (1) miss IPv6-only port collisions (so the v6 listener later fails to bind) and (2) incorrectly block config changes when IPv4 is disabled but the port is busy on v4 (even though b4 won’t bind tcp4). Probe per enabled family (0.0.0.0 for IPv4, :: for IPv6).
if f := probePort("system.dns.tcp_port", "0.0.0.0", newPort); f != nil {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 63 out of 63 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/mtproto/obfuscated2.go:81
shuffledWorkerDomainsusesmath/randwithout any seeding in the repo, so the shuffle sequence is deterministic across process starts (default seed = 1). That makes Worker selection predictable and can undermine the intended load/request-quota spreading after restarts; consider using a per-call RNG seeded with time/crypto, or seeding the global RNG at startup.
changelog.md:7- The PR title/description focus on Cloudflare Worker pooling, but this PR also includes a broad set of unrelated changes (DNS-over-TCP interception + settings UI, discovery/domain overlap handling, catch-all target UX, etc., as reflected in this changelog entry). Please update the PR description/title to match the actual scope, or consider splitting into separate PRs for easier review and rollback.
## [1.75.0] - 2026-08-08
- FIXED: **A domain was handled by a different strategy than the one Discovery found for it** - every result was published with all the domains of the run, so two applied results claimed the same domains and set order picked the winner.
- FIXED: **A set applied from Discovery had no effect until b4 was restarted** - it was saved and listed in the interface while the traffic it named kept going out under the previous settings.
- ADDED: **A warning when several enabled sets name the same domain** - only one of them handles that domain, chosen by its place in the list rather than by which strategy works.
src/tables/monitor.go:344
- If
nft.runNft("list", "table", ...)fails, this check currently ignores the error and may incorrectly treat DNS-over-TCP redirect rules as present. The monitor should treat list errors as a failed verification (return false), so rule restoration can run when nftables output isn't readable.



