diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a9ac20a..91c486a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,6 +55,8 @@ mise run dev
| `--db` | `UBLPROXY_DB` | `~/.ublproxy/ublproxy.db` | Path to SQLite database |
| `--blocklist` | `UBLPROXY_BLOCKLIST` | *(none)* | Path or URL to a blocklist file (repeatable, comma-separated in env var) |
| `--transparent` | `UBLPROXY_TRANSPARENT` | `false` | Run in transparent proxy mode (intercept redirected traffic) |
+| `--dns-port` | `UBLPROXY_DNS_PORT` | `0` (disabled) | DNS resolver port for host-level blocking (disabled when 0) |
+| `--dns-upstream` | `UBLPROXY_DNS_UPSTREAM` | `1.1.1.1:53` | Upstream DNS resolver address |
| `--log-level` | `UBLPROXY_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error`. Use `debug` to log all proxied requests. |
On first run, a CA certificate and key are generated in the `--ca-dir` directory. You need to trust the CA certificate (`ca.crt`) in your OS or browser for HTTPS interception to work without warnings. Restart your browser after trusting the certificate.
diff --git a/DECISIONS.md b/DECISIONS.md
index a540342..1064585 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -96,3 +96,4 @@
- 2026-03-06 m+git@andri.dk — Dropped `!#include` pre-parsing directive from scope. Published filter lists (EasyList, EasyPrivacy, uBlock Filters) are pre-compiled — `!#include` references are resolved before distribution. No real-world need at the proxy level.
- 2026-03-06 m+git@andri.dk — Scriptlet injection (`##+js()`) via `IoT"]
end
subgraph UBL["ublproxy"]
HTTP[":8080
HTTP"]
HTTPS[":8443
HTTPS"]
+ DNS[":53
DNS"]
BL["Blocklist
Engine"]
CA["CA + Cert
Cache"]
DB["SQLite
Store"]
@@ -33,9 +35,11 @@ graph LR
D1 --> HTTPS
D2 --> HTTPS
D3 --> HTTP
+ D4 --> DNS
HTTPS --> BL
HTTP --> BL
+ DNS --> BL
BL --> CA
BL --> UP
@@ -140,6 +144,10 @@ WebAuthn passkey authentication gives each user their own set of rules and subsc
Deploy on a VLAN with firewall rules to intercept all traffic automatically — no client-side proxy configuration needed. A captive portal guides new devices through CA certificate installation. Enable with `--transparent`.
+### DNS resolver
+
+Built-in DNS resolver for devices that can't install a CA certificate — smart TVs, game consoles, IoT devices. Queries for blocked hostnames return `0.0.0.0` / `::`, everything else is forwarded to an upstream resolver. Uses the same blocklists and per-user rules as the proxy (matched by client IP). Enable with `--dns-port 53`.
+
## Filter syntax support
ublproxy implements the [Adblock Plus filter syntax](https://adblockplus.org/filter-cheatsheet) and the most-used [uBlock Origin extensions](https://github.com/gorhill/uBlock/wiki/Static-filter-syntax). Tested against EasyList (87K lines), uBlock Filters (11K lines, 2,500+ scriptlets), and EasyPrivacy with zero crashes and minimal parse errors.
@@ -194,6 +202,8 @@ Solutions like [Pi-hole](https://pi-hole.net/) and [AdGuard Home](https://github
These approaches aren't mutually exclusive. A DNS blocker can handle the bulk of known ad domains cheaply, while ublproxy handles the cases DNS blocking can't reach — same-origin ads, cosmetic filtering, and URL-path-specific rules.
+ublproxy also includes a built-in DNS resolver (`--dns-port`) that null-routes blocked hostnames for devices that can't install a CA certificate. This provides host-level blocking without needing a separate DNS blocker, though it only covers the host-level rules from your blocklists — URL-path blocking, cosmetic filtering, and scriptlet injection still require the proxy.
+
## References
- See [QUICK_START.md](QUICK_START.md) for setup instructions (local and Docker).
diff --git a/activity.go b/activity.go
index aafda2f..41172b3 100644
--- a/activity.go
+++ b/activity.go
@@ -12,6 +12,7 @@ const (
ActivityPassthrough = "passthrough"
ActivityAutoPassthrough = "auto-passthrough"
ActivityElementHidden = "element-hidden"
+ ActivityDNSBlocked = "dns-blocked"
)
// ActivityEntry represents a single proxy event.
diff --git a/dns.go b/dns.go
new file mode 100644
index 0000000..5468573
--- /dev/null
+++ b/dns.go
@@ -0,0 +1,153 @@
+package main
+
+import (
+ "log/slog"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// nullRouteTTL is the TTL for blocked DNS responses. Short so that if a
+// rule is removed, clients pick up the change quickly.
+const nullRouteTTL = 60
+
+// dnsServer is a DNS resolver that null-routes hostnames blocked by the
+// proxy's blocklists. Non-blocked queries are forwarded to an upstream
+// resolver. Intended for devices that cannot install the proxy's CA
+// certificate (smart TVs, game consoles, IoT devices).
+type dnsServer struct {
+ proxy *proxyHandler
+ upstream string // upstream resolver address (e.g. "1.1.1.1:53")
+ activity *ActivityLog // optional activity log
+}
+
+// ServeDNS implements dns.Handler. It checks whether the queried hostname
+// is blocked and either returns a null-route response or forwards the
+// query upstream.
+func (s *dnsServer) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
+ if len(r.Question) == 0 {
+ m := new(dns.Msg)
+ m.SetRcode(r, dns.RcodeFormatError)
+ _ = w.WriteMsg(m)
+ return
+ }
+
+ q := r.Question[0]
+ hostname := strings.TrimSuffix(q.Name, ".")
+ clientIP := extractDNSClientIP(w)
+
+ blocked := s.proxy.shouldBlockHost(clientIP, hostname)
+
+ if blocked {
+ s.respondBlocked(w, r, q)
+ s.logActivity(ActivityDNSBlocked, hostname, clientIP)
+ slog.Debug("dns blocked", "host", hostname, "client", clientIP)
+ return
+ }
+
+ s.forwardUpstream(w, r)
+ slog.Debug("dns forwarded", "host", hostname, "client", clientIP)
+}
+
+// respondBlocked writes a null-route DNS response. A queries get 0.0.0.0,
+// AAAA queries get ::, and all other query types for blocked hosts get an
+// empty authoritative answer (preventing the host from being reachable
+// via MX, SRV, etc.).
+func (s *dnsServer) respondBlocked(w dns.ResponseWriter, r *dns.Msg, q dns.Question) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ m.Authoritative = true
+
+ switch q.Qtype {
+ case dns.TypeA:
+ m.Answer = append(m.Answer, &dns.A{
+ Hdr: dns.RR_Header{
+ Name: q.Name,
+ Rrtype: dns.TypeA,
+ Class: dns.ClassINET,
+ Ttl: nullRouteTTL,
+ },
+ A: net.IPv4zero,
+ })
+ case dns.TypeAAAA:
+ m.Answer = append(m.Answer, &dns.AAAA{
+ Hdr: dns.RR_Header{
+ Name: q.Name,
+ Rrtype: dns.TypeAAAA,
+ Class: dns.ClassINET,
+ Ttl: nullRouteTTL,
+ },
+ AAAA: net.IPv6zero,
+ })
+ default:
+ // Empty authoritative answer for other record types on blocked hosts
+ }
+
+ _ = w.WriteMsg(m)
+}
+
+// forwardUpstream sends the query to the upstream resolver and relays
+// the response back to the client.
+func (s *dnsServer) forwardUpstream(w dns.ResponseWriter, r *dns.Msg) {
+ c := new(dns.Client)
+ c.Timeout = 5 * time.Second
+
+ resp, _, err := c.Exchange(r, s.upstream)
+ if err != nil {
+ slog.Warn("dns upstream error", "upstream", s.upstream, "err", err)
+ m := new(dns.Msg)
+ m.SetRcode(r, dns.RcodeServerFailure)
+ _ = w.WriteMsg(m)
+ return
+ }
+
+ _ = w.WriteMsg(resp)
+}
+
+// logActivity records a DNS event in the activity log if available.
+func (s *dnsServer) logActivity(activityType, hostname, clientIP string) {
+ if s.activity == nil {
+ return
+ }
+ credID := s.proxy.credentialForIP(clientIP)
+ s.activity.Add(ActivityEntry{
+ Type: activityType,
+ Host: hostname,
+ IP: clientIP,
+ User: shortUserID(credID),
+ })
+}
+
+// extractDNSClientIP extracts the client IP address from the DNS writer's
+// remote address.
+func extractDNSClientIP(w dns.ResponseWriter) string {
+ addr := w.RemoteAddr()
+ if addr == nil {
+ return ""
+ }
+ host, _, err := net.SplitHostPort(addr.String())
+ if err != nil {
+ return addr.String()
+ }
+ return host
+}
+
+// startDNS starts UDP and TCP DNS servers on the given address. Both
+// goroutines block; call this from a goroutine.
+func startDNS(addr string, handler dns.Handler) {
+ go func() {
+ srv := &dns.Server{Addr: addr, Net: "udp", Handler: handler}
+ slog.Info("dns resolver (udp)", "addr", addr)
+ if err := srv.ListenAndServe(); err != nil {
+ slog.Error("dns udp server error", "err", err)
+ }
+ }()
+
+ srv := &dns.Server{Addr: addr, Net: "tcp", Handler: handler}
+ slog.Info("dns resolver (tcp)", "addr", addr)
+ if err := srv.ListenAndServe(); err != nil {
+ slog.Error("dns tcp server error", "err", err)
+ }
+}
diff --git a/dns_test.go b/dns_test.go
new file mode 100644
index 0000000..755f106
--- /dev/null
+++ b/dns_test.go
@@ -0,0 +1,554 @@
+package main
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+
+ "ublproxy/internal/blocklist"
+)
+
+// startTestDNSServer starts a DNS server on a random UDP port and returns
+// its address. The caller must call shutdown to stop the server.
+func startTestDNSServer(t *testing.T, ds *dnsServer) (addr string, shutdown func()) {
+ t.Helper()
+
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr = pc.LocalAddr().String()
+
+ srv := &dns.Server{PacketConn: pc, Handler: ds}
+ go func() { _ = srv.ActivateAndServe() }()
+
+ // Wait for the server to be ready.
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ m := new(dns.Msg)
+ m.SetQuestion("test.invalid.", dns.TypeA)
+ if _, err := dns.Exchange(m, addr); err == nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ return addr, func() { _ = srv.Shutdown() }
+}
+
+// startMockUpstream starts a mock DNS server that answers A queries with
+// the given IP address.
+func startMockUpstream(t *testing.T, answerIP string) (addr string, shutdown func()) {
+ t.Helper()
+
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr = pc.LocalAddr().String()
+
+ handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ if len(r.Question) > 0 && r.Question[0].Qtype == dns.TypeA {
+ m.Answer = append(m.Answer, &dns.A{
+ Hdr: dns.RR_Header{
+ Name: r.Question[0].Name,
+ Rrtype: dns.TypeA,
+ Class: dns.ClassINET,
+ Ttl: 300,
+ },
+ A: net.ParseIP(answerIP),
+ })
+ }
+ _ = w.WriteMsg(m)
+ })
+
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ m := new(dns.Msg)
+ m.SetQuestion("test.invalid.", dns.TypeA)
+ if _, err := dns.Exchange(m, addr); err == nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ return addr, func() { _ = srv.Shutdown() }
+}
+
+func newTestProxyHandler() *proxyHandler {
+ return &proxyHandler{
+ sessions: newSessionMap(),
+ }
+}
+
+func TestDNSBlockedHostReturnsNullIP(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||ads.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "93.184.216.34")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // A record for blocked host should return 0.0.0.0
+ m := new(dns.Msg)
+ m.SetQuestion("ads.example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a, ok := resp.Answer[0].(*dns.A)
+ if !ok {
+ t.Fatalf("expected A record, got %T", resp.Answer[0])
+ }
+ if !a.A.Equal(net.IPv4zero) {
+ t.Errorf("expected 0.0.0.0, got %s", a.A)
+ }
+}
+
+func TestDNSBlockedHostAAAAReturnsNullIPv6(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||ads.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "93.184.216.34")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // AAAA record for blocked host should return ::
+ m := new(dns.Msg)
+ m.SetQuestion("ads.example.com.", dns.TypeAAAA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ aaaa, ok := resp.Answer[0].(*dns.AAAA)
+ if !ok {
+ t.Fatalf("expected AAAA record, got %T", resp.Answer[0])
+ }
+ if !aaaa.AAAA.Equal(net.IPv6zero) {
+ t.Errorf("expected ::, got %s", aaaa.AAAA)
+ }
+}
+
+func TestDNSNonBlockedHostForwardsUpstream(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||ads.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "93.184.216.34")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // Non-blocked host should be forwarded to upstream
+ m := new(dns.Msg)
+ m.SetQuestion("example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a, ok := resp.Answer[0].(*dns.A)
+ if !ok {
+ t.Fatalf("expected A record, got %T", resp.Answer[0])
+ }
+ if !a.A.Equal(net.ParseIP("93.184.216.34")) {
+ t.Errorf("expected 93.184.216.34, got %s", a.A)
+ }
+}
+
+func TestDNSDomainHierarchyBlocking(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "1.2.3.4")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // Subdomain should also be blocked (domain hierarchy walk)
+ m := new(dns.Msg)
+ m.SetQuestion("sub.example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a := resp.Answer[0].(*dns.A)
+ if !a.A.Equal(net.IPv4zero) {
+ t.Errorf("expected 0.0.0.0 for subdomain of blocked host, got %s", a.A)
+ }
+}
+
+func TestDNSPerUserExceptionOverridesBaseline(t *testing.T) {
+ proxy := newTestProxyHandler()
+
+ // Baseline blocks ads.example.com
+ baseline := blocklist.NewRuleSet()
+ baseline.AddLine("||ads.example.com^")
+ proxy.baselineRules.Store(baseline)
+
+ // User has an exception for ads.example.com
+ userRS := blocklist.NewRuleSet()
+ userRS.AddLine("@@||ads.example.com^")
+ proxy.userRules.Store("user-cred-1", userRS)
+
+ // Register the user's session from a specific IP
+ proxy.sessions.Set("127.0.0.1", sessionEntry{
+ CredentialID: "user-cred-1",
+ })
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "93.184.216.34")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // This user's exception should override the baseline block
+ m := new(dns.Msg)
+ m.SetQuestion("ads.example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a, ok := resp.Answer[0].(*dns.A)
+ if !ok {
+ t.Fatalf("expected A record, got %T", resp.Answer[0])
+ }
+ // Should NOT be 0.0.0.0 — the user excepted this host
+ if a.A.Equal(net.IPv4zero) {
+ t.Error("expected upstream response (user exception), got 0.0.0.0")
+ }
+}
+
+func TestDNSActivityLogging(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||blocked.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ activity := NewActivityLog(100)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "1.2.3.4")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ activity: activity,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // Query a blocked host
+ m := new(dns.Msg)
+ m.SetQuestion("blocked.example.com.", dns.TypeA)
+ if _, err := dns.Exchange(m, addr); err != nil {
+ t.Fatal(err)
+ }
+
+ // Query a non-blocked host
+ m2 := new(dns.Msg)
+ m2.SetQuestion("allowed.example.com.", dns.TypeA)
+ if _, err := dns.Exchange(m2, addr); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := activity.Recent(10)
+ if len(entries) < 1 {
+ t.Fatal("expected at least 1 activity entry")
+ }
+
+ // Most recent should be the blocked query
+ var foundBlock bool
+ for _, e := range entries {
+ if e.Type == ActivityDNSBlocked && e.Host == "blocked.example.com" {
+ foundBlock = true
+ break
+ }
+ }
+ if !foundBlock {
+ t.Errorf("expected dns-blocked activity entry for blocked.example.com, got: %+v", entries)
+ }
+}
+
+func TestDNSNonAddressQtypeForwardedRegardless(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||blocked.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ // Mock upstream that answers MX queries
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ upstreamAddr := pc.LocalAddr().String()
+ handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ if len(r.Question) > 0 && r.Question[0].Qtype == dns.TypeMX {
+ m.Answer = append(m.Answer, &dns.MX{
+ Hdr: dns.RR_Header{
+ Name: r.Question[0].Name,
+ Rrtype: dns.TypeMX,
+ Class: dns.ClassINET,
+ Ttl: 300,
+ },
+ Preference: 10,
+ Mx: "mail.example.com.",
+ })
+ }
+ _ = w.WriteMsg(m)
+ })
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+ defer func() { _ = srv.Shutdown() }()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // MX query for a blocked host should still be blocked (A/AAAA only get null-routed)
+ // For non-address types on a blocked host, we return an empty answer
+ // to prevent the host from being reachable via other record types.
+ m := new(dns.Msg)
+ m.SetQuestion("blocked.example.com.", dns.TypeMX)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Blocked host: should get empty response, not forwarded to upstream
+ if len(resp.Answer) != 0 {
+ t.Errorf("expected empty answer for non-A/AAAA query on blocked host, got %d answers", len(resp.Answer))
+ }
+}
+
+func TestDNSTCPSupport(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||ads.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "93.184.216.34")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+
+ // Start a TCP DNS server
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ tcpAddr := ln.Addr().String()
+
+ srv := &dns.Server{Listener: ln, Handler: ds, Net: "tcp"}
+ go func() { _ = srv.ActivateAndServe() }()
+ defer func() { _ = srv.Shutdown() }()
+
+ // Wait for TCP server to be ready
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ c := new(dns.Client)
+ c.Net = "tcp"
+ m := new(dns.Msg)
+ m.SetQuestion("test.invalid.", dns.TypeA)
+ if _, _, err := c.Exchange(m, tcpAddr); err == nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ // Query blocked host over TCP
+ c := new(dns.Client)
+ c.Net = "tcp"
+ m := new(dns.Msg)
+ m.SetQuestion("ads.example.com.", dns.TypeA)
+ resp, _, err := c.Exchange(m, tcpAddr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a, ok := resp.Answer[0].(*dns.A)
+ if !ok {
+ t.Fatalf("expected A record, got %T", resp.Answer[0])
+ }
+ if !a.A.Equal(net.IPv4zero) {
+ t.Errorf("expected 0.0.0.0 over TCP, got %s", a.A)
+ }
+}
+
+func TestDNSEmptyQuestion(t *testing.T) {
+ proxy := newTestProxyHandler()
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: "127.0.0.1:0",
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // Send a message with no question section
+ m := new(dns.Msg)
+ m.Id = dns.Id()
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Rcode != dns.RcodeFormatError {
+ t.Errorf("expected FORMERR for empty question, got rcode %d", resp.Rcode)
+ }
+}
+
+func TestDNSHostsFileFormatBlocking(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("0.0.0.0 malware.example.com")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "1.2.3.4")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ m := new(dns.Msg)
+ m.SetQuestion("malware.example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a := resp.Answer[0].(*dns.A)
+ if !a.A.Equal(net.IPv4zero) {
+ t.Errorf("expected 0.0.0.0 for hosts-file blocked entry, got %s", a.A)
+ }
+}
+
+func TestDNSNoBaselineRulesForwardsAll(t *testing.T) {
+ proxy := newTestProxyHandler()
+ // No baseline rules loaded
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "1.2.3.4")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ m := new(dns.Msg)
+ m.SetQuestion("anything.example.com.", dns.TypeA)
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a := resp.Answer[0].(*dns.A)
+ if !a.A.Equal(net.ParseIP("1.2.3.4")) {
+ t.Errorf("expected upstream IP 1.2.3.4, got %s", a.A)
+ }
+}
+
+// Verify multiple questions are handled (edge case from legacy DNS clients).
+func TestDNSMultipleQuestions(t *testing.T) {
+ proxy := newTestProxyHandler()
+ rs := blocklist.NewRuleSet()
+ rs.AddLine("||blocked.example.com^")
+ proxy.baselineRules.Store(rs)
+
+ upstreamAddr, upstreamShutdown := startMockUpstream(t, "1.2.3.4")
+ defer upstreamShutdown()
+
+ ds := &dnsServer{
+ proxy: proxy,
+ upstream: upstreamAddr,
+ }
+ addr, shutdown := startTestDNSServer(t, ds)
+ defer shutdown()
+
+ // DNS message with multiple questions (unusual but valid)
+ m := new(dns.Msg)
+ m.Id = dns.Id()
+ m.RecursionDesired = true
+ m.Question = []dns.Question{
+ {Name: "blocked.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
+ {Name: "allowed.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
+ }
+ resp, err := dns.Exchange(m, addr)
+ if err != nil {
+ // Some implementations reject multi-question; that's acceptable
+ return
+ }
+ // We only process the first question; response is valid as long as no panic
+ _ = resp
+}
diff --git a/go.mod b/go.mod
index f315506..0300b00 100644
--- a/go.mod
+++ b/go.mod
@@ -15,12 +15,16 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
+ golang.org/x/mod v0.31.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/tools v0.40.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index 1f6e227..319bb85 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -36,15 +38,21 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
diff --git a/main.go b/main.go
index a7e26b9..8c53609 100644
--- a/main.go
+++ b/main.go
@@ -81,6 +81,18 @@ func main() {
Usage: "run in transparent proxy mode (intercept redirected traffic instead of explicit proxy)",
Sources: cli.EnvVars("UBLPROXY_TRANSPARENT"),
},
+ &cli.IntFlag{
+ Name: "dns-port",
+ Value: 0,
+ Usage: "DNS resolver port for host-level blocking (disabled when 0)",
+ Sources: cli.EnvVars("UBLPROXY_DNS_PORT"),
+ },
+ &cli.StringFlag{
+ Name: "dns-upstream",
+ Value: "1.1.1.1:53",
+ Usage: "upstream DNS resolver address",
+ Sources: cli.EnvVars("UBLPROXY_DNS_UPSTREAM"),
+ },
&cli.StringFlag{
Name: "log-level",
Value: "info",
@@ -108,6 +120,8 @@ func run(_ context.Context, cmd *cli.Command) error {
dbPath := cmd.String("db")
blocklistSources := cmd.StringSlice("blocklist")
transparent := cmd.Bool("transparent")
+ dnsPort := cmd.Int("dns-port")
+ dnsUpstream := cmd.String("dns-upstream")
caCert, caKey, err := ca.LoadOrGenerate(caDir)
if err != nil {
@@ -164,6 +178,16 @@ func run(_ context.Context, cmd *cli.Command) error {
httpsAddr := fmt.Sprintf("%s:%d", addr, httpsPort)
httpAddr := fmt.Sprintf("%s:%d", addr, httpPort)
+ if dnsPort > 0 {
+ dnsAddr := fmt.Sprintf("%s:%d", addr, dnsPort)
+ ds := &dnsServer{
+ proxy: handler,
+ upstream: dnsUpstream,
+ activity: activityLog,
+ }
+ go startDNS(dnsAddr, ds)
+ }
+
if transparent {
return runTransparent(handler, certs, hostname, extraIPs, httpsAddr, httpAddr)
}
diff --git a/web/index.html b/web/index.html
index 6281d8d..e73fb38 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1110,6 +1110,11 @@ td:first-child {
Deploy on a VLAN with firewall rules to intercept all traffic automatically. A captive portal guides new devices through CA certificate setup.
+Devices that can't install a CA cert — smart TVs, game consoles, IoT — get host-level ad blocking via a built-in DNS resolver that null-routes blocked domains. Same blocklists, same per-user rules.
+Desktops and laptops connect over HTTPS on port 8443 via PAC file or manual proxy settings. Mobile devices connect over HTTP on port 8080. The proxy generates per-host TLS certificates on the fly using its own CA.
+Desktops and laptops connect over HTTPS on port 8443 via PAC file or manual proxy settings. Mobile devices connect over HTTP on port 8080. Devices that can't install a CA cert (smart TVs, game consoles, IoT) use the built-in DNS resolver on port 53 for host-level blocking. The proxy generates per-host TLS certificates on the fly using its own CA.
Blocked resource elements (<script>, <iframe>) are stripped from HTML. CSS element-hiding rules are pre-filtered from ~64K global selectors down to <200 per page. Scriptlets are injected to neutralize anti-adblock scripts. Blocked requests can be replaced with neutered placeholders via $redirect.
These approaches aren't mutually exclusive. A DNS blocker handles the bulk of known ad domains cheaply, while ublproxy handles the cases DNS blocking can't reach.
+These approaches aren't mutually exclusive. A DNS blocker handles the bulk of known ad domains cheaply, while ublproxy handles the cases DNS blocking can't reach. ublproxy's built-in DNS resolver (--dns-port) covers devices that can't install a CA cert.
Pull the multi-arch image and run: