From 95e65eebd8baa51cd0986141dfe04e144050c034 Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 2 Oct 2026 01:05:09 -0700 Subject: [PATCH] feat(dns): answer A and AAAA for bridged handles and refuse zone transfers Instance apexes and the names one label below them answer A with DNS_PUBLIC_IPV4, and AAAA with DNS_PUBLIC_IPV6 when it is set, at a 300-second TTL and without a store lookup. Deeper names get NODATA. AXFR and IXFR for any in-zone name are refused. The e2e stack runs the DNS server on 127.0.0.1:5380 (UDP and TCP), and TestDNS_BridgedHandleAndTombstone resolves a minted actor's _atproto TXT record, then checks the answer is empty once the actor is tombstoned. Co-Authored-By: Claude Opus 5.5 (1M context) --- Makefile | 2 +- README.md | 17 +++++--- docker-compose.e2e.yml | 6 +++ internal/dns/handler.go | 52 +++++++++++++++++----- internal/dns/handler_test.go | 85 ++++++++++++++++++++++++++++++++++++ tests/e2e/dns_test.go | 76 ++++++++++++++++++++++++++++++++ tests/e2e/helpers.go | 5 +++ 7 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/dns_test.go diff --git a/Makefile b/Makefile index 2bb1b18..3e31379 100644 --- a/Makefile +++ b/Makefile @@ -117,7 +117,7 @@ e2e: ## Full e2e run: build + start the stack, run the suite, tear down (-v) e2e-up: ## Start the e2e stack and leave it running (for iterating on tests) @$(E2E_COMPOSE) up -d --build --wait --wait-timeout 600 - @echo "$(GREEN)✓ e2e stack up: tidepool 127.0.0.1:8092, lemmy 127.0.0.1:8541, relay 127.0.0.1:2480, reference pds 127.0.0.1:3081, jetstream 127.0.0.1:6028$(RESET)" + @echo "$(GREEN)✓ e2e stack up: tidepool 127.0.0.1:8092, DNS 127.0.0.1:$(or $(TIDEPOOL_E2E_DNS_PORT),5380) (UDP/TCP), lemmy 127.0.0.1:8541, relay 127.0.0.1:2480, reference pds 127.0.0.1:3081, jetstream 127.0.0.1:6028$(RESET)" e2e-test: ## Run the e2e suite against an already-running stack (make e2e-up) @go test -tags e2e -count=1 -v -timeout 20m ./tests/e2e/... diff --git a/README.md b/README.md index 635b357..33e0e4c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ make test # start the test postgres (localhost:5443) and run the suite To try DNS locally, pass the DNS variables to `make run` (the dev Compose file has no Tidepool service). DNS listens on UDP and TCP at the same address and -answers SOA and NS at each label apex. Use a free port; 5353 is mDNS and +answers handle TXT, SOA/NS at each label apex, and A/AAAA at label apexes and +handle names. Zone transfers are refused. Use a free port; 5353 is mDNS and usually taken. The dev zone root is `localhost`: ```sh @@ -128,8 +129,9 @@ real Jetstream decoding the **relay's** firehose — in one compose network: │ │ (JSON) │ │ │ └───────┬─────┘ │ └───────────────────────────────────────────────────────────────┼───────┘ - host (127.0.0.1 only): tidepool :8092, lemmy :8541, │ - relay :2480, pds :3081, jetstream :6028 ◀──────┘ + host (127.0.0.1 only): tidepool :8092, DNS :5380 UDP/TCP, │ + lemmy :8541, relay :2480, pds :3081, │ + jetstream :6028 ◀───────────────────────────────────┘ tests/e2e (go test -tags e2e) ``` @@ -190,7 +192,8 @@ proves the rule: they sat in the community's repo with no separate attestation, so there was no cross-repo pair to reorder — those records still exist and are not migrated. -The host ports bind **loopback-only** (`127.0.0.1:8092/8541/2480/3081/6028`): +The host ports bind **loopback-only** (`127.0.0.1:8092/5380/8541/2480/3081/6028`; +DNS uses UDP and TCP and `TIDEPOOL_E2E_DNS_PORT` overrides 5380): the stack carries admin tokens and runs with `ALLOW_PRIVATE_FETCH=1`, so it must not be reachable from the local network. @@ -316,9 +319,9 @@ Two classes, and the difference matters at boot: | `DATABASE_URL` | local dev postgres | bridge state | | `LISTEN_ADDR` | `:8091` | HTTP bind address | | `BRIDGE_HOSTNAME` | `localhost` | public domain of the bridge; anchors handles and the PDS endpoint in minted DID docs | -| `DNS_LISTEN` | *(empty; disabled)* | UDP and TCP listen address (e.g. `:53`); empty disables the DNS server; answers SOA and NS at each label apex | -| `DNS_PUBLIC_IPV4` | *(unset)* | public IPv4 address; required when `DNS_LISTEN` is set | -| `DNS_PUBLIC_IPV6` | *(unset)* | optional public IPv6 address when DNS is enabled | +| `DNS_LISTEN` | *(empty; disabled)* | UDP and TCP listen address (e.g. `:53`); empty disables DNS; answers handle TXT, SOA/NS at each label apex, and A/AAAA at label apexes and handle names; zone transfers are refused | +| `DNS_PUBLIC_IPV4` | *(unset)* | IPv4 address returned in A answers; required when `DNS_LISTEN` is set | +| `DNS_PUBLIC_IPV6` | *(unset)* | optional IPv6 address returned in AAAA answers when DNS is enabled | | `DNS_NAMESERVERS` | `ns1`/`ns2` under `BRIDGE_HOSTNAME` when DNS is enabled | comma-separated nameserver hostnames returned in apex NS answers (first is the SOA primary) | | `BRIDGE_SCHEME` | `https` | scheme of the bridge's own AP URLs (actor id, inbox, activity ids). `http` is dev-only — the e2e harness federates with a debug-mode Lemmy over plain HTTP | | `PLC_DIRECTORY_URL` | `http://localhost:3002` (local, `make plc-up`) | did:plc directory; production uses `https://plc.directory` | diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 772082b..23526d6 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -80,6 +80,7 @@ # ALLOW_PRIVATE_FETCH=1, so it must not be reachable from the local # network; offset from the dev stack AND the Coves dev relay so all can run): # 127.0.0.1:8092 Tidepool HTTP (admin API, XRPC, healthz) +# 127.0.0.1:5380 Tidepool DNS (UDP and TCP; TIDEPOOL_E2E_DNS_PORT overrides) # 127.0.0.1:8541 Lemmy HTTP API (8541 nods to lemmy_alpha in upstream's federation compose) # 127.0.0.1:2480 Relay (BigSky) XRPC + admin API # 127.0.0.1:3081 Reference PDS XRPC — PUBLISHED ON THE `relay` SERVICE (the @@ -137,6 +138,9 @@ services: DATABASE_URL: postgres://tidepool:tidepool@tidepool-postgres:5432/tidepool?sslmode=disable LISTEN_ADDR: ":80" BRIDGE_HOSTNAME: tidepool + # Serve bridged handles on an unprivileged port; publish DNS on host loopback. + DNS_LISTEN: ":5300" + DNS_PUBLIC_IPV4: "192.0.2.80" # Plain-HTTP AP ids so a debug-mode Lemmy can fetch/deliver to us. BRIDGE_SCHEME: http # The compose network is private address space; the SSRF guard must let @@ -200,6 +204,8 @@ services: - handle-tls-ca:/handle-tls-ca:ro ports: - "127.0.0.1:${TIDEPOOL_E2E_PORT:-8092}:80" + - "127.0.0.1:${TIDEPOOL_E2E_DNS_PORT:-5380}:5300/udp" + - "127.0.0.1:${TIDEPOOL_E2E_DNS_PORT:-5380}:5300/tcp" # Handle verification's DNS half (_atproto.{handle} TXT) must not leave # the stack. On a user-defined network the container's resolver stays # Docker's embedded DNS (127.0.0.11), which keeps answering compose diff --git a/internal/dns/handler.go b/internal/dns/handler.go index 17ff232..071ed0a 100644 --- a/internal/dns/handler.go +++ b/internal/dns/handler.go @@ -21,12 +21,13 @@ import ( const handleLookupTimeout = 2 * time.Second const ( - zoneRecordTTL = 3600 - soaMinimumTTL = 300 - soaRefresh = 3600 - soaRetry = 600 - soaExpire = 1209600 - handleTXTTTL = 300 + zoneRecordTTL = 3600 + soaMinimumTTL = 300 + soaRefresh = 3600 + soaRetry = 600 + soaExpire = 1209600 + handleTXTTTL = 300 + addressRecordTTL = 300 ) // maxConcurrentHandleLookups bounds how many DNS queries may run a handle @@ -64,6 +65,8 @@ type Options struct { type Handler struct { zoneRoot string nameservers []string + publicIPv4 netip.Addr + publicIPv6 netip.Addr serial uint32 resolver identity.Resolver logger *slog.Logger @@ -107,6 +110,8 @@ func NewHandler(options Options) (*Handler, error) { return &Handler{ zoneRoot: zoneRoot, nameservers: nameservers, + publicIPv4: options.PublicIPv4, + publicIPv6: options.PublicIPv6, serial: options.Serial, resolver: options.Resolver, logger: logger, @@ -131,8 +136,9 @@ func normalizeDomainName(name string) (string, error) { return normalized, nil } -// ServeDNS answers handle TXT and label-apex SOA/NS queries, returning NODATA -// for other in-zone questions. +// ServeDNS answers handle TXT, label-apex SOA/NS, and label-apex and handle +// A/AAAA queries. It refuses zone transfers and returns NODATA for other +// in-zone questions. func (h *Handler) ServeDNS(writer miekgdns.ResponseWriter, request *miekgdns.Msg) { response := new(miekgdns.Msg) response.SetReply(request) @@ -159,9 +165,15 @@ func (h *Handler) ServeDNS(writer miekgdns.ResponseWriter, request *miekgdns.Msg nameLabels := miekgdns.SplitDomainName(name) labels := nameLabels[:len(nameLabels)-miekgdns.CountLabel(h.zoneRoot)] apex := labels[len(labels)-1] + "." + h.zoneRoot + + // Zone transfers are not supported for handle subzones. + if question.Qtype == miekgdns.TypeAXFR || question.Qtype == miekgdns.TypeIXFR { + response.Rcode = miekgdns.RcodeRefused + h.writeResponse(writer, response) + return + } response.Authoritative = true - // Task 03: refuse in-zone AXFR and IXFR here. if question.Qtype == miekgdns.TypeTXT && len(labels) == 3 && labels[0] == "_atproto" { handle := labels[1] + "." + labels[2] + "." + strings.TrimSuffix(h.zoneRoot, ".") select { @@ -209,7 +221,27 @@ func (h *Handler) ServeDNS(writer miekgdns.ResponseWriter, request *miekgdns.Msg return } } - // Task 03: answer A and AAAA queries here. + // Address answers belong to the question name, including unknown handles. + if len(labels) == 1 || len(labels) == 2 { + switch question.Qtype { + case miekgdns.TypeA: + response.Answer = []miekgdns.RR{&miekgdns.A{ + Hdr: miekgdns.RR_Header{Name: question.Name, Rrtype: miekgdns.TypeA, Class: miekgdns.ClassINET, Ttl: addressRecordTTL}, + A: net.IP(h.publicIPv4.AsSlice()), + }} + h.writeResponse(writer, response) + return + case miekgdns.TypeAAAA: + if h.publicIPv6.IsValid() { + response.Answer = []miekgdns.RR{&miekgdns.AAAA{ + Hdr: miekgdns.RR_Header{Name: question.Name, Rrtype: miekgdns.TypeAAAA, Class: miekgdns.ClassINET, Ttl: addressRecordTTL}, + AAAA: net.IP(h.publicIPv6.AsSlice()), + }} + h.writeResponse(writer, response) + return + } + } + } h.addSOA(response, apex) h.writeResponse(writer, response) } diff --git a/internal/dns/handler_test.go b/internal/dns/handler_test.go index 45c321b..8952f06 100644 --- a/internal/dns/handler_test.go +++ b/internal/dns/handler_test.go @@ -360,6 +360,91 @@ func TestHandlerApexSOAAndNS(t *testing.T) { } } +func TestHandlerAddressAnswersAndTransferRefusal(t *testing.T) { + for _, tc := range []struct { + name string + question string + questionType uint16 + publicIPv6 bool + wantAddress string + wantSOA bool + wantRefused bool + }{ + {name: "B1 A label apex", question: "lemmy-world.tdpl.example.", questionType: miekgdns.TypeA, wantAddress: "192.0.2.10"}, + {name: "B1 A known handle", question: "alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeA, wantAddress: "192.0.2.10"}, + {name: "B1 A unknown handle", question: "nobody.lemmy-world.tdpl.example.", questionType: miekgdns.TypeA, wantAddress: "192.0.2.10"}, + {name: "B1 A atproto name is NODATA", question: "_atproto.alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeA, wantSOA: true}, + {name: "B1 A four labels is NODATA", question: "x.y.alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeA, wantSOA: true}, + {name: "B2 AAAA label apex", question: "lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, publicIPv6: true, wantAddress: "2001:db8::10"}, + {name: "B2 AAAA handle", question: "alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, publicIPv6: true, wantAddress: "2001:db8::10"}, + {name: "B2 AAAA atproto name is NODATA", question: "_atproto.alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, publicIPv6: true, wantSOA: true}, + {name: "B2 AAAA four labels is NODATA", question: "x.y.alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, publicIPv6: true, wantSOA: true}, + {name: "B2 AAAA label apex without IPv6 is NODATA", question: "lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, wantSOA: true}, + {name: "B2 AAAA handle without IPv6 is NODATA", question: "alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeAAAA, wantSOA: true}, + {name: "B3 AXFR label apex refused", question: "lemmy-world.tdpl.example.", questionType: miekgdns.TypeAXFR, wantRefused: true}, + {name: "B3 IXFR label apex refused", question: "lemmy-world.tdpl.example.", questionType: miekgdns.TypeIXFR, wantRefused: true}, + {name: "B3 AXFR handle refused", question: "alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeAXFR, wantRefused: true}, + {name: "B3 IXFR handle refused", question: "alice.lemmy-world.tdpl.example.", questionType: miekgdns.TypeIXFR, wantRefused: true}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := &recordingResolver{handles: map[string]string{"alice.lemmy-world.tdpl.example": "did:plc:alice"}} + options := handlerOptions(resolver) + options.PublicIPv4 = netip.MustParseAddr("192.0.2.10") + if tc.publicIPv6 { + options.PublicIPv6 = netip.MustParseAddr("2001:db8::10") + } + handler, err := NewHandler(options) + require.NoError(t, err) + + request := new(miekgdns.Msg) + request.SetQuestion(tc.question, tc.questionType) + writer := &recordingDNSWriter{} + handler.ServeDNS(writer, request) + require.NotNil(t, writer.response, "no response written") + response := writer.response + require.Empty(t, resolver.asked, "address and transfer queries must not resolve handles") + require.Empty(t, response.Extra) + if tc.wantRefused { + require.Equal(t, miekgdns.RcodeRefused, response.Rcode) + require.Empty(t, response.Answer) + require.Empty(t, response.Ns) + return + } + + require.Equal(t, miekgdns.RcodeSuccess, response.Rcode) + require.True(t, response.Authoritative) + if tc.wantSOA { + require.Empty(t, response.Answer) + require.Len(t, response.Ns, 1) + soa, ok := response.Ns[0].(*miekgdns.SOA) + require.True(t, ok, "authority must be SOA, got %T", response.Ns[0]) + require.Equal(t, "lemmy-world.tdpl.example.", soa.Hdr.Name) + require.Equal(t, uint16(miekgdns.TypeSOA), soa.Hdr.Rrtype) + return + } + + require.Empty(t, response.Ns) + require.Len(t, response.Answer, 1) + switch tc.questionType { + case miekgdns.TypeA: + record, ok := response.Answer[0].(*miekgdns.A) + require.True(t, ok, "answer must be A, got %T", response.Answer[0]) + require.Equal(t, tc.question, record.Hdr.Name) + require.Equal(t, uint16(miekgdns.ClassINET), record.Hdr.Class) + require.Equal(t, uint32(300), record.Hdr.Ttl) + require.Equal(t, tc.wantAddress, record.A.String()) + case miekgdns.TypeAAAA: + record, ok := response.Answer[0].(*miekgdns.AAAA) + require.True(t, ok, "answer must be AAAA, got %T", response.Answer[0]) + require.Equal(t, tc.question, record.Hdr.Name) + require.Equal(t, uint16(miekgdns.ClassINET), record.Hdr.Class) + require.Equal(t, uint32(300), record.Hdr.Ttl) + require.Equal(t, tc.wantAddress, record.AAAA.String()) + } + }) + } +} + type failingDNSWriter struct { recordingDNSWriter writeErrors []error diff --git a/tests/e2e/dns_test.go b/tests/e2e/dns_test.go new file mode 100644 index 0000000..bd0a400 --- /dev/null +++ b/tests/e2e/dns_test.go @@ -0,0 +1,76 @@ +//go:build e2e + +package e2e + +import ( + "testing" + "time" + + "github.com/miekg/dns" +) + +func TestDNS_BridgedHandleAndTombstone(t *testing.T) { + h := newHarness(t) + community, _ := setupSubscribedCommunity(t, h, "dns") + username := h.uniqueName(t, "dnsuser") + user := h.registerUser(t, username) + + cursor := cursorNow() + listener := h.newListener(t, cursor, colActorProfile) + user.createPost(t, community.ID, "DNS actor "+h.suffix, "mint a bridged actor") + profile := listener.await("DNS actor.profile for "+username, func(event *jsEvent) bool { + name, _ := fieldOf(event.Commit.Record, "displayName") + return event.Commit.Collection == colActorProfile && event.Commit.Operation == opCreate && name == username + }) + handle := bridgedHandle(username) + client := &dns.Client{Net: "udp", Timeout: 2 * time.Second} + query := func(name string, questionType uint16) *dns.Msg { + t.Helper() + request := new(dns.Msg) + request.SetQuestion(name, questionType) + response, _, err := client.Exchange(request, dnsAddress()) + if err != nil { + t.Fatalf("DNS query %s at %s: %v", name, dnsAddress(), err) + } + return response + } + + txtName := "_atproto." + handle + "." + txtResponse := query(txtName, dns.TypeTXT) + if txtResponse.Rcode != dns.RcodeSuccess || len(txtResponse.Answer) != 1 { + t.Fatalf("live TXT %s: rcode %d, answers %v; want NOERROR and one TXT", txtName, txtResponse.Rcode, txtResponse.Answer) + } + txt, ok := txtResponse.Answer[0].(*dns.TXT) + if !ok || len(txt.Txt) != 1 || txt.Txt[0] != "did="+profile.Did { + t.Fatalf("live TXT %s: answer %v, want did=%s", txtName, txtResponse.Answer[0], profile.Did) + } + + aResponse := query(handle+".", dns.TypeA) + if aResponse.Rcode != dns.RcodeSuccess || len(aResponse.Answer) != 1 { + t.Fatalf("A %s: rcode %d, answers %v; want NOERROR and one A", handle, aResponse.Rcode, aResponse.Answer) + } + a, ok := aResponse.Answer[0].(*dns.A) + if !ok || a.A.String() != dnsPublicIPv4() { + t.Fatalf("A %s: answer %v, want %s", handle, aResponse.Answer[0], dnsPublicIPv4()) + } + + user.deleteAccount(t) + deadline := time.NewTimer(eventTimeout) + defer deadline.Stop() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + response := query(txtName, dns.TypeTXT) + if response.Rcode != dns.RcodeSuccess { + t.Fatalf("tombstoned TXT %s: rcode %d, want NOERROR", txtName, response.Rcode) + } + if len(response.Answer) == 0 { + return + } + select { + case <-ticker.C: + case <-deadline.C: + t.Fatalf("tombstoned TXT %s still answers %v after %s", txtName, response.Answer, eventTimeout) + } + } +} diff --git a/tests/e2e/helpers.go b/tests/e2e/helpers.go index 260d47a..6d39483 100644 --- a/tests/e2e/helpers.go +++ b/tests/e2e/helpers.go @@ -67,6 +67,11 @@ func envOr(name, fallback string) string { func tidepoolURL() string { return envOr("TIDEPOOL_E2E_URL", "http://localhost:8092") } func lemmyURL() string { return envOr("LEMMY_E2E_URL", "http://localhost:8541") } func relayURL() string { return envOr("RELAY_E2E_URL", "http://localhost:2480") } +func dnsAddress() string { return "127.0.0.1:" + envOr("TIDEPOOL_E2E_DNS_PORT", "5380") } + +// dnsPublicIPv4 is the A answer the e2e Tidepool serves. It must match +// DNS_PUBLIC_IPV4 on the tidepool service in docker-compose.e2e.yml. +func dnsPublicIPv4() string { return "192.0.2.80" } func jetstreamURL() string { return envOr("JETSTREAM_E2E_URL", "ws://localhost:6028") } -- 2.51.2