This post is for educational purposes only. It documents how one ISP's filtering behaves and how the Linux network stack can be configured in response, so that you understand what happens on your own network. Check what is permitted where you live before changing how your network handles traffic.
For months a handful of sites would not open from my home network: Discord, Roblox, Wattpad and a few others that BTK (Turkey's telecom regulator) has ordered ISPs to block. Installing a VPN on every device was not an option. I run a small Kubernetes cluster behind this router and the pods need those services as much as my laptop does. A full-tunnel VPN on the router was out too. I want my ordinary traffic to keep leaving from my own ISP address at line speed, not to take a detour through a server abroad.
So the fix had to live in one place, on the OpenWrt router, and apply to every client behind it without any of them knowing. It turned out there were two separate blocks in play, and each needed its own fix. Here is what I found and what finally worked.
Block 1: poisoned DNS
Türk Telekom's resolvers (195.175.39.49 and 195.175.39.50) answer blocked names with 195.175.254.2, which is the block page:
$ nslookup discord.com 195.175.39.49
Name: discord.com
Address 1: 195.175.254.2
Plain DNS to 1.1.1.1 or 9.9.9.9 on port 53 from the router simply timed out. DNS over TLS on port 853 worked fine, so that is what I used. On OpenWrt the easy way is stubby, with dnsmasq forwarding to it.
apk add stubby # opkg install stubby on 24.x and older
/etc/config/stubby:
config stubby 'global'
option manual '0'
option trigger 'telekom' # the *name* of your WAN interface in /etc/config/network
list dns_transport 'GETDNS_TRANSPORT_TLS'
option tls_authentication '1'
option tls_query_padding_blocksize '128'
option edns_client_subnet_private '1'
option idle_timeout '9000'
option round_robin_upstreams '1'
option appdata_dir '/var/lib/stubby'
list listen_address '127.0.0.1@5453'
list listen_address '0::1@5453'
config resolver
option address '1.1.1.1'
option tls_auth_name 'cloudflare-dns.com'
config resolver
option address '1.0.0.1'
option tls_auth_name 'cloudflare-dns.com'
Then make dnsmasq ignore the ISP-supplied servers and forward only to stubby:
uci set dhcp.@dnsmasq[0].noresolv='1'
uci -q delete dhcp.@dnsmasq[0].server
uci add_list dhcp.@dnsmasq[0].server='127.0.0.1#5453'
uci commit dhcp
/etc/init.d/stubby enable
/etc/init.d/stubby restart
/etc/init.d/dnsmasq restart
Check it from the router:
nslookup -port=5453 discord.com 127.0.0.1 # real addresses, not 195.175.254.2
Three things bit me here:
triggermust name the interface that actually carries your uplink. Mine saidwanwhile the PPPoE interface is calledtelekom. stubby waited at boot for an interface that never came up, so it never started, and I assumed for a long time that DoT just did not work on this line.- Do not keep the Telekom servers as a fallback in dnsmasq. If stubby dies, DNS should fail loudly rather than silently go back to censored answers.
noresolv '1'plus a singleserverentry does that. - If the router has no IPv6 route, leave the IPv6 Cloudflare upstreams out. stubby otherwise wastes time on them.
In my case AdGuard Home sits on port 53 in front of dnsmasq, which is why dnsmasq listens on 5353 here. That detail does not matter. The chain is client → (AdGuard Home) → dnsmasq → stubby → Cloudflare over TLS, and every client, including the cluster's CoreDNS, gets clean answers.
Block 2: the DPI resets by SNI
With DNS fixed, discord.com resolved to the right address and still did not open. This test separates "the IP is blocked" from "the hostname is blocked":
# GitHub's IP, but discord.com in the TLS SNI
curl -sv --resolve discord.com:443:140.82.121.4 https://discord.com/
The connection was reset within about 4 ms. A traceroute put the responsible box two hops out, inside Telekom. Nothing to do with the destination: a DPI box reads the hostname from the TLS ClientHello's SNI (or the Host header on plain HTTP) and injects a TCP RST. Destination IPs themselves were never blocked.
The classic trick is to split the ClientHello into small TCP segments so the hostname never appears in a single packet. I forced 68-byte segments via the route MTU and it made no difference: this DPI reassembles TCP before it looks. That rules out the simple splitters and points straight at zapret.
zapret
zapret is built for exactly this. Its nfqws daemon sits on an nftables queue, sees the first few packets of every new TCP connection to ports 80 and 443, and sends a fake ClientHello ahead of the real one. The fake is crafted so that the DPI accepts it and the real server never sees it.
The combination that defeats Telekom's DPI:
--dpi-desync=fake --dpi-desync-ttl=3 --dpi-desync-fooling=md5sig
- TTL 3: the fake packet carries a harmless SNI and dies at the third hop. The DPI, at hop two, sees it, classifies the flow as allowed and does not look again. The real ClientHello follows and passes. The real server, six or more hops away, never receives the fake.
- md5sig: the fake also carries a TCP MD5 signature option. Any host that does receive it drops it as invalid, so even a close server cannot get confused by it.
TTL 2 through 8 all worked for me; TTL 1 expired before reaching the DPI. --dpi-desync-autottl, and the badseq and badsum fooling modes, each failed for at least one of the sites, so I stuck with the fixed TTL plus md5sig.
Install
apk add kmod-nft-queue # opkg install kmod-nft-queue on older releases
cd /tmp
wget https://github.com/bol-van/zapret/releases/download/v72.13/zapret-v72.13-openwrt-embedded.tar.gz
tar xzf zapret-v72.13-openwrt-embedded.tar.gz
mv zapret-v72.13 /opt/zapret
cd /opt/zapret
./install_bin.sh # links the binaries for your architecture (arm64 here)
cp config.default config
ln -s /opt/zapret/init.d/openwrt/zapret /etc/init.d/zapret
ln -s /opt/zapret/init.d/openwrt/90-zapret /etc/hotplug.d/iface/90-zapret
Config
The lines that matter in /opt/zapret/config:
NFQWS_ENABLE=1
NFQWS_PORTS_TCP=80,443
NFQWS_PORTS_UDP=
NFQWS_OPT="
--filter-tcp=80,443 --dpi-desync=fake --dpi-desync-ttl=3 --dpi-desync-fooling=md5sig <HOSTLIST>
"
MODE_FILTER=autohostlist
AUTOHOSTLIST_FAIL_THRESHOLD=3
AUTOHOSTLIST_FAIL_TIME=60
FLOWOFFLOAD=hardware
INIT_APPLY_FW=1
DISABLE_IPV6=1
TPWS_ENABLE=0
TPWS_SOCKS_ENABLE=0
/opt/zapret/ipset/zapret-hosts-user.txt lists the hosts to desync. Subdomains match automatically, so discord.com also covers cdn.discord.com:
discord.com
discord.gg
discordapp.com
discordapp.net
discord.media
roblox.com
rbxcdn.com
wattpad.com
/opt/zapret/ipset/zapret-hosts-user-exclude.txt lists what must never be touched. I put the private ranges, the whole .tr zone and my own domains there:
127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
169.254.0.0/16
100.64.0.0/10
::1
fc00::/7
fe80::/10
tr
example.com # your own domains
Why autohostlist rather than desyncing everything: only listed hosts get the fake packet, and all other traffic is byte-for-byte what it was before. When a host that is not on any list fails three times within 60 seconds (resets or retransmissions), nfqws adds it to /opt/zapret/ipset/zapret-hosts-auto.txt on its own. A newly blocked site therefore starts working after a couple of reloads without me touching the router. I review that file now and then and move keepers into the user list.
Why exclude .tr and my own domains: no Turkish site is blocked from Turkey, so there is nothing to gain and no reason to let nfqws touch that traffic. Excluded traffic leaves the router exactly as it did before.
Start and verify
/etc/init.d/zapret enable
/etc/init.d/zapret start
pidof nfqws
nft list table inet zapret
logread -e zapret
And from any client on the LAN:
curl -sI https://discord.com # headers back instead of "connection reset by peer"
That was enough. Every device on the LAN, every pod in the cluster and the router itself could reach the blocked sites, with no configuration on any of them.
The gotchas that cost the time
blockcheck.sh lied. zapret ships a script that tries every strategy and reports which ones work. On this router it reported every strategy as failing, because its harness never managed to get a single packet into the queue. If you see a 100% fail result, test by hand. A throwaway table and a foreground nfqws are all you need:
echo discord.com > /tmp/test-hosts.txt
nft add table inet ztest
nft add chain inet ztest post '{ type filter hook postrouting priority mangle; }'
nft add rule inet ztest post oifname "pppoe-telekom" meta mark \& 0x40000000 == 0 \
tcp dport {80,443} ct original packets 1-6 queue num 200 bypass
/opt/zapret/nfq/nfqws --qnum=200 --dpi-desync=fake --dpi-desync-ttl=3 \
--dpi-desync-fooling=md5sig --hostlist=/tmp/test-hosts.txt
Replace pppoe-telekom with your WAN netdev. Run curl -sI https://discord.com from a client, watch nfqws print what it does, then nft delete table inet ztest and stop nfqws before starting the real service.
fw4's flow offloading bypasses nfqws. With flow_offloading enabled in /etc/config/firewall, the kernel fast-paths flows before nfqws has seen the packets it needs. zapret runs its own flowtable instead (FLOWOFFLOAD=hardware above), which lets the first packets through to the queue and offloads the rest. So turn fw4's off:
uci set firewall.@defaults[0].flow_offloading='0'
uci set firewall.@defaults[0].flow_offloading_hw='0'
uci commit firewall
/etc/init.d/firewall restart
Throughput did not change: downloads still hit the line's 210 Mbit/s, conntrack -L shows [HW_OFFLOAD] on the flows, and the CPU sits around 90% idle.
The conntrack mark trap. zapret sets bit 0x40000000 in the conntrack mark of connections it handles, and its queue rule skips any packet whose packet mark carries that bit, so its own injected fakes are not queued again. I run multi-WAN policy routing, and one of my rules restored the packet mark from the conntrack mark on established packets with meta mark set ct mark. That copied zapret's flag into the packet mark, and from then on nfqws skipped those packets. In practice only the first connection after a restart got desynced, which was maddening to debug. The fix is to copy back only the bits you own:
ct state established,related meta mark set ct mark and 0xffff accept
If you use mwan3 or your own nftables for routing marks, check for this before anything else.
QUIC. I left NFQWS_PORTS_UDP empty, so UDP 443 is not desynced. Browsers try QUIC first, get nothing back, and fall back to TCP within a second. If that delay bothers you, either desync UDP 443 as well or block outbound UDP 443 so the fallback is instant.
Surviving sysupgrade. /opt/zapret/ is not in the default backup set. Add these to /etc/sysupgrade.conf:
/etc/config/stubby
/opt/zapret/
/etc/init.d/zapret
/etc/rc.d/S21zapret
/etc/hotplug.d/iface/90-zapret
kmod-nft-queue is an installed package, so attended sysupgrade brings it back on its own.
Why not just a VPN to a VPS
A WireGuard tunnel to a server abroad would also have worked, and it is the usual advice. Three reasons I did not want it:
- It changes my egress address for everything, and some services care which address they see.
- It adds a hop and a single point of failure to every connection, blocked or not.
- zapret touches only the first packets of connections to listed hosts. Everything else stays at line speed, on the normal path.
One honest caveat: this defeats the block, nothing more. The real ClientHello still carries the real SNI, so the ISP can still see which hostnames you connect to. If you need privacy rather than access, you still want a tunnel.
What it looks like now
Two config files and two hostlists, mirrored in a git repo and pushed to the router by a deploy script. No client knows anything changed. Discord, Roblox and Wattpad open on the laptop, on the phone and inside the cluster, and everything else is exactly as it was.
If your ISP's DPI behaves differently, the same workflow applies: fix DNS first, prove the block is SNI-based with the --resolve test, then try the fake strategies by hand with a foreground nfqws before trusting any automated checker.


