PATTERN Cited by 1 source
DNS proxy for hostname filtering (eBPF)¶
DNS proxy for hostname filtering (eBPF) is the pattern of turning a kernel-level packet / socket firewall into a hostname-based allow/deny list by redirecting the target processes' DNS syscalls to a userspace DNS proxy that enforces policy on the resolved hostname, then correlating blocked queries back to the originating process via a DNS transaction-ID → PID eBPF map.
The pattern emerged as GitHub's solution to "how do we block github.com from deploy scripts" when IP blocklists are impractical at the scale of github.com's IP footprint. (Source: sources/2026-04-16-github-ebpf-deployment-safety)
Why IP-based blocking loses¶
IP-based egress allow/deny lists bit-rot rapidly:
- Large SaaS / CDN IP ranges rotate continuously.
- A single hostname (github.com, api.github.com) maps to many IPs; blocking all of them is a moving target.
- IP-range publishing (AWS
ip-ranges.json, etc.) exists precisely because this is hard.
The stable identifier is the hostname. GitHub's post: "Given the breadth of GitHub's systems and rate of change, keeping an up-to-date block IP list would be very hard." The same force shows up in SNI filtering — both patterns are "use the name, not the address" but at different layers.
Architecture — four moving parts¶
-
cGroup-scoped eBPF firewall as the substrate (see that page for the base shape).
-
BPF_PROG_TYPE_CGROUP_SOCK_ADDRDNS-redirect program. Hooks theconnect4syscall. When the destination port is 53 (DNS), rewrite the destination IP + port to127.0.0.1:<dns_proxy_port>. Kernel proceeds to the rewritten destination unaware the target was changed:
SEC("cgroup/connect4")
int connect4(struct bpf_sock_addr *ctx) {
if (ctx->user_port == bpf_htons(53)) {
ctx->user_ip4 = const_mitm_proxy_address;
ctx->user_port = bpf_htons(const_dns_proxy_port);
}
return 1;
}
-
Userspace DNS proxy — listens on
localhost:<port>; parses incoming DNS queries; evaluates hostname against blocklist; either synthesises a DNS NXDOMAIN/empty response back to the caller or forwards upstream to the real resolver. Uses eBPF maps to communicate per-query state to theCGROUP_SKBprogram (e.g. "IP X was resolved from a blocked hostname Y — drop subsequent packets to X"). -
DNS transaction-ID → PID correlation map — filled by the
CGROUP_SKBegress program on each outbound DNS query:
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u16 skb_read_offset = sizeof(struct iphdr) + sizeof(struct udphdr);
__u16 dns_transaction_id =
get_transaction_id_from_dns_header(skb, skb_read_offset);
if (pid && dns_transaction_id != 0) {
bpf_map_update_elem(&dns_transaction_id_to_pid, &dns_transaction_id,
pid, BPF_ANY);
}
The userspace proxy, on blocking a query, looks up the PID
from its transaction ID, reads /proc/<pid>/cmdline, and
logs the full command line that triggered the block.
The per-process-attribution payoff¶
The TXID↔PID correlation is the load-bearing insight that makes this pattern a debugging primitive, not just a blocker. GitHub's audit log line for a blocked event:
WARN DNS BLOCKED reason=FromDNSRequest blocked=true
blockedAt=dns domain=github.com.
pid=266767 cmd="curl github.com "
firewallMethod=blocklist
The owning team sees which script / tool / invocation triggered the block, with the offending command line, not just that a deploy fingerprint touched github.com. This is what turns the firewall from "mysterious deploy failure" into "here is the exact curl call your Dockerfile added".
Composability wins — three outputs from one primitive¶
- Hostname-based allow/deny (the feature).
- Audit log of all hostnames the process contacted during a run (useful even when no block fires — "provide an audit list of all domains contacted during a deployment").
- Per-process command-line attribution for both allowed + blocked traffic, giving ownership to remediation efforts.
Comparison to sibling hostname-filtering patterns¶
- SNI filtering — reads hostname from TLS ClientHello's SNI field at a middlebox / VPC firewall layer. Pros: protocol-layer, works without touching hosts. Cons: breaks if ECH deploys; no per-process attribution; doesn't cover non-TLS protocols. Positioned for VPC-wide policy.
- DNS observation without redirect — snoop port-53 traffic passively at the host or VPC, log hostnames, alert post- facto. Observational only; doesn't enforce.
- HTTP forward proxy with auth. Classic middlebox approach; requires app cooperation (configure proxy, carry credentials). More complete than SNI or DNS-based filtering but heavier to operate.
- DNS redirect via
/etc/resolv.confor systemd-resolved. Redirects at the host level, not the cGroup level — wrong granularity for multi-tenant hosts; same-box processes that legitimately need DNS are affected.
DNS-proxy-via-eBPF is the right choice when:
- You need per-process / per-cGroup scope (dogfooded stateful hosts).
- You want per-query attribution back to the calling process.
- You want on-host enforcement that is opt-in by cGroup membership, not by firewall-rule placement in the VPC.
Caveats¶
- DNS over HTTPS / DoH bypasses the port-53 hook entirely;
if the target process can do DoH (
443to1.1.1.1/8.8.8.8/dns.google), it sidesteps the proxy unless the eBPFCGROUP_SKBprogram also blocks those known DoH endpoints. - DNS over TLS / DoT (TCP/853) similarly needs an explicit block or a proxy-rewrite rule.
- Hostname-to-IP binding drift. Between the DNS resolution (policy decision) and the subsequent TCP connect, IPs may change. Reasonable-TTL caching in the proxy + short-lived eBPF map entries make this acceptable but not perfect.
- Direct-IP connections (processes with hardcoded IPs)
skip DNS entirely. Fallback to the
CGROUP_SKBIP blocklist still catches known IPs, but the unknown-IP case is a gap. - Userspace proxy is a failure mode. If it crashes or hangs, deploy-script DNS queries stall at the redirect. Needs explicit fail-open / fail-closed design; not detailed in the GitHub post.
- TXID collisions under high query volume. 16-bit DNS transaction IDs wrap; a hot-path process can recycle TXIDs within the attribution window. For low-volume workloads (deploy scripts) this is fine; for general-purpose traffic it is a real limit.
Seen in¶
- sources/2026-04-16-github-ebpf-deployment-safety — the
pattern originates in GitHub's deployment-safety firewall.
PoC published as
lawrencegripper/ebpf-cgroup-firewall.