diff --git a/CLAUDE.md b/CLAUDE.md index 2d6b58e..c7da1fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,13 @@ is the mutable one — update it as work lands. Keep prober result IDs aligned with the measurement-schema test-type registry. +## Versioning + +Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps +for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the +Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release + +registry image; the server on fmr can `--self-update` from those releases. + ## Layout Monorepo. The prober is one deliverable; the Go server and the production app land as siblings. diff --git a/server/README.md b/server/README.md index fcc9b8a..b95513d 100644 --- a/server/README.md +++ b/server/README.md @@ -36,9 +36,17 @@ All configurable via `ECHOLOT_*_LISTEN`. Plus: 2. **Second IP (optional):** full RFC 5780 NAT-behavior discovery (`stun-5780`) needs an alternate reply address; without it the profile advertises `stun-basic` and clients degrade gracefully. -3. **Delegated DNS subzone (optional, later):** the `canary-dns` capability needs port 53 on - some IP + an NS delegation (mind systemd-resolved on 127.0.0.53). Absent → capability simply - not advertised. +3. **Delegated DNS subzone (for `canary-dns`):** set `ECHOLOT_DNS_LISTEN` (udp+tcp/53 on the + service IPs) and `ECHOLOT_CANARY_ZONE` (e.g. `c.echo-lot.app`), then delegate the zone to + this host in your DNS provider: + ``` + c.echo-lot.app. NS fmr-1.echo-lot.app. + c.echo-lot.app. NS fmr-2.echo-lot.app. + ``` + The server is authoritative for that zone only, serving the spec §6.1 reference records + (frozen in `internal/canarydns/dns_reference.go`) plus per-query `..` + lookups it logs. Binding :53 on the public IPs is fine even with systemd-resolved (it only + claims 127.0.0.53). Absent config → capability simply not advertised. 4. **Outbound freedom** for connect-back / delayed-echo actions — no extra inbound ports; generated traffic goes only to the session's observed source. diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 771c2c7..b52e0f5 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -25,6 +25,7 @@ import ( "math/big" "net" "net/http" + "net/netip" "os" "os/signal" "path/filepath" @@ -32,6 +33,7 @@ import ( "syscall" "time" + "echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/config" "echo-lot.app/server/internal/control" "echo-lot.app/server/internal/dataplane" @@ -204,10 +206,40 @@ func serve(cfg *config.Config) error { ctl.Capabilities = caps } + // Canary DNS (spec §6.1) — authoritative for CanaryZone, udp+tcp per addr. + var dnsUDP []*net.UDPConn + var dnsTCP []net.Listener + if dnsAddrs := config.Addrs(cfg.DNSListen); len(dnsAddrs) > 0 && cfg.CanaryZone != "" { + v4, v6 := firstByFamily(dnsAddrs) + cd := canarydns.New(cfg.CanaryZone, cfg.Name, v4, v6) + for _, addr := range dnsAddrs { + ua, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return fmt.Errorf("dns udp addr %s: %w", addr, err) + } + uc, err := net.ListenUDP("udp", ua) + if err != nil { + return fmt.Errorf("dns udp listen %s: %w", addr, err) + } + dnsUDP = append(dnsUDP, uc) + go func(a string, c *net.UDPConn) { errCh <- fmt.Errorf("dns-udp %s: %w", a, cd.ServeUDP(c)) }(addr, uc) + + tl, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("dns tcp listen %s: %w", addr, err) + } + dnsTCP = append(dnsTCP, tl) + go func(a string, l net.Listener) { errCh <- fmt.Errorf("dns-tcp %s: %w", a, cd.ServeTCP(l)) }(addr, tl) + } + ctl.CanaryZone = cfg.CanaryZone + ctl.CanaryQueries = func(prefix string) any { return cd.RecentForPrefix(prefix) } + ctl.Capabilities = append(ctl.Capabilities, "canary-dns") + } + slog.Info("listening", "control", ctlAddrs, "admin", cfg.AdminListen, "udp", udpAddrs, "tcp", config.Addrs(cfg.TCPListen), "stun", config.Addrs(cfg.StunListen), - "capabilities", ctl.Capabilities) + "dns", config.Addrs(cfg.DNSListen), "capabilities", ctl.Capabilities) select { case <-ctx.Done(): @@ -225,12 +257,33 @@ func serve(cfg *config.Config) error { if stunSrv != nil { stunSrv.Close() } + for _, c := range dnsUDP { + _ = c.Close() + } + for _, l := range dnsTCP { + _ = l.Close() + } return nil case err := <-errCh: return err } } +// firstByFamily returns the first v4 and first v6 address from a list of +// "ip:port" specs — used for the canary zone's apex/NS answers. +func firstByFamily(addrs []string) (v4, v6 netip.Addr) { + for _, a := range addrs { + if ap, err := netip.ParseAddrPort(a); err == nil { + if ap.Addr().Unmap().Is4() && !v4.IsValid() { + v4 = ap.Addr().Unmap() + } else if ap.Addr().Is6() && !ap.Addr().Is4In6() && !v6.IsValid() { + v6 = ap.Addr() + } + } + } + return +} + func firstAddr(spec string) string { if a := config.Addrs(spec); len(a) > 0 { return a[0] diff --git a/server/internal/canarydns/canarydns.go b/server/internal/canarydns/canarydns.go new file mode 100644 index 0000000..b2ab9ac --- /dev/null +++ b/server/internal/canarydns/canarydns.go @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package canarydns + +import ( + "encoding/binary" + "hash/fnv" + "net" + "net/netip" + "strings" + "sync" + "time" +) + +// DNS constants (RFC 1035 + RFC 6891 EDNS). +const ( + typeA = 1 + typeNS = 2 + typeTXT = 16 + typeAAAA = 28 + typeOPT = 41 + + classIN = 1 + + rcodeNoError = 0 + rcodeNXDomain = 3 + + flagQR = 0x8000 + flagAA = 0x0400 + flagTC = 0x0200 + flagRD = 0x0100 + flagRA = 0x0080 + + udpMaxNoEDNS = 512 + ednsDO = 0x8000 // DO bit lives in the OPT TTL field's high half + optECS = 8 // EDNS Client Subnet option code +) + +// Query is one logged canary lookup (spec §6 dns_canary shape). +type Query struct { + QName string `json:"qname"` + At time.Time `json:"at"` + ResolverIP string `json:"resolver_ip"` + Transport string `json:"transport"` // "udp" | "tcp" + EDNS edns `json:"edns"` + ECS string `json:"ecs,omitempty"` + CasePreserved bool `json:"case_preserved"` + // qname_minimized is not reliably detectable authoritative-side without + // cross-query correlation; left false (TODO) rather than guessed. + QNameMinimized bool `json:"qname_minimized"` +} + +type edns struct { + Present bool `json:"present"` + Bufsize int `json:"bufsize"` + Flags []string `json:"flags"` +} + +// Server is the authoritative responder for one canary zone. +type Server struct { + zone string // fully-qualified, lowercase, trailing dot, e.g. "c.echo-lot.app." + nsName string // this server's own name for NS/authority answers + primaryV4 netip.Addr + primaryV6 netip.Addr + + mu sync.Mutex + log []Query // ring, newest last + retainTo time.Time +} + +const logCap = 8192 + +// New creates a server for zone (with or without trailing dot). nsName is the +// server's own hostname (for the zone's NS record); primary v4/v6 are this +// host's addresses used to answer the zone apex / NS glue. +func New(zone, nsName string, v4, v6 netip.Addr) *Server { + z := strings.ToLower(strings.TrimSuffix(zone, ".")) + "." + return &Server{zone: z, nsName: strings.TrimSuffix(nsName, ".") + ".", primaryV4: v4, primaryV6: v6} +} + +// RecentForPrefix returns logged queries whose qname contains ".." +// (the session prefix the app embeds: ..). +func (s *Server) RecentForPrefix(prefix string) []Query { + s.mu.Lock() + defer s.mu.Unlock() + needle := "." + strings.ToLower(prefix) + "." + var out []Query + for _, q := range s.log { + if strings.Contains(strings.ToLower(q.QName), needle) { + out = append(out, q) + } + } + return out +} + +func (s *Server) record(q Query) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.log) >= logCap { + s.log = s.log[1:] + } + s.log = append(s.log, q) +} + +// ServeUDP / ServeTCP run read loops; call one per bound address. +func (s *Server) ServeUDP(conn *net.UDPConn) error { + buf := make([]byte, 1500) + for { + n, raddr, err := conn.ReadFromUDPAddrPort(buf) + if err != nil { + return err + } + resp := s.handle(buf[:n], raddr.Addr(), "udp") + if resp != nil { + _, _ = conn.WriteToUDPAddrPort(resp, raddr) + } + } +} + +func (s *Server) ServeTCP(ln net.Listener) error { + for { + c, err := ln.Accept() + if err != nil { + return err + } + go s.handleTCP(c) + } +} + +func (s *Server) handleTCP(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(10 * time.Second)) + var lenBuf [2]byte + if _, err := readFull(c, lenBuf[:]); err != nil { + return + } + msg := make([]byte, binary.BigEndian.Uint16(lenBuf[:])) + if _, err := readFull(c, msg); err != nil { + return + } + ra, _ := netip.ParseAddrPort(c.RemoteAddr().String()) + resp := s.handle(msg, ra.Addr(), "tcp") + if resp == nil { + return + } + // TCP has no 512 limit; never truncate. + out := make([]byte, 2+len(resp)) + binary.BigEndian.PutUint16(out[0:2], uint16(len(resp))) + copy(out[2:], resp) + _, _ = c.Write(out) +} + +func readFull(c net.Conn, b []byte) (int, error) { + got := 0 + for got < len(b) { + n, err := c.Read(b[got:]) + got += n + if err != nil { + return got, err + } + } + return got, nil +} + +// handle parses one query, logs it, and returns the wire response (nil to drop). +func (s *Server) handle(pkt []byte, resolver netip.Addr, transport string) []byte { + if len(pkt) < 12 { + return nil + } + id := binary.BigEndian.Uint16(pkt[0:2]) + qdcount := binary.BigEndian.Uint16(pkt[4:6]) + arcount := binary.BigEndian.Uint16(pkt[10:12]) + if qdcount != 1 { + return s.errorResponse(id, rcodeNoError, nil) // we only answer single-question queries + } + + qnameRaw, qtype, _, qEnd, ok := parseQuestion(pkt, 12) + if !ok { + return nil + } + + // EDNS OPT is an additional-section RR; scan for it after the question. + opt := parseOPT(pkt, qEnd, arcount) + + // Log every query — this is the whole point of the canary zone. + q := Query{ + QName: strings.TrimSuffix(qnameRaw, "."), At: time.Now().UTC(), + ResolverIP: resolver.Unmap().String(), Transport: transport, + EDNS: opt.edns, + ECS: opt.ecs, + CasePreserved: qnameRaw == strings.ToLower(qnameRaw), // mixed case ⇒ 0x20 randomization + } + s.record(q) + + name := strings.ToLower(qnameRaw) + if !strings.HasSuffix(name, s.zone) { + return s.errorResponse(id, rcodeNXDomain, &opt) + } + sub := strings.TrimSuffix(name, s.zone) // e.g. "ttl-5." or "" for apex + + return s.answer(id, pkt, qEnd, sub, qtype, &opt, transport) +} + +// answer builds the response for a name known to be in-zone. +func (s *Server) answer(id uint16, pkt []byte, qEnd int, sub string, qtype uint16, opt *optInfo, transport string) []byte { + labels := splitLabels(sub) // e.g. ["ttl-5"], [], ["","miss"], ["",""] + + var rrs []rr + switch { + case len(labels) == 0: // zone apex + if qtype == typeNS { + rrs = append(rrs, rr{ttl: 3600, typ: typeNS, ns: s.nsName}) + } else if qtype == typeA && s.primaryV4.IsValid() { + rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: s.primaryV4}) + } else if qtype == typeAAAA && s.primaryV6.IsValid() { + rrs = append(rrs, rr{ttl: 3600, typ: typeAAAA, addr: s.primaryV6}) + } + case len(labels) == 1: + if ref := findReference(labels[0]); ref != nil { + rrs = referenceAnswers(ref, qtype) + } + default: + // Per-query names: .miss. and ... + // Deterministic A derived from the leftmost label (the nonce), TTL 3600, + // documentation range — ground truth that can never be pre-cached. + if qtype == typeA { + rrs = append(rrs, rr{ttl: 3600, typ: typeA, addr: nonceAddr(labels[0])}) + } + } + + if len(rrs) == 0 { + // In-zone but no such record/type → NOERROR/NODATA (or NXDOMAIN at apex miss). + return s.buildResponse(id, pkt, qEnd, nil, opt, transport, rcodeNoError) + } + return s.buildResponse(id, pkt, qEnd, rrs, opt, transport, rcodeNoError) +} + +// nonceAddr maps a nonce label into 192.0.2.0/24 deterministically. +func nonceAddr(nonce string) netip.Addr { + h := fnv.New32a() + _, _ = h.Write([]byte(nonce)) + return netip.AddrFrom4([4]byte{192, 0, 2, byte(h.Sum32()%254 + 1)}) +} + +func referenceAnswers(ref *refRecord, qtype uint16) []rr { + var rrs []rr + switch qtype { + case typeA: + for _, a := range ref.a { + if a.Is4() { + rrs = append(rrs, rr{ttl: ref.ttl, typ: typeA, addr: a}) + } + } + case typeAAAA: + for _, a := range ref.a { + if a.Is6() && !a.Is4In6() { + rrs = append(rrs, rr{ttl: ref.ttl, typ: typeAAAA, addr: a}) + } + } + case typeTXT: + if len(ref.txt) > 0 { + rrs = append(rrs, rr{ttl: ref.ttl, typ: typeTXT, txt: ref.txt}) + } + } + return rrs +} + +func splitLabels(sub string) []string { + sub = strings.TrimSuffix(sub, ".") + if sub == "" { + return nil + } + return strings.Split(sub, ".") +} + +func (s *Server) errorResponse(id uint16, rcode int, opt *optInfo) []byte { + hdr := make([]byte, 12) + binary.BigEndian.PutUint16(hdr[0:2], id) + binary.BigEndian.PutUint16(hdr[2:4], uint16(flagQR|flagAA|rcode)) + if opt != nil && opt.edns.Present { + binary.BigEndian.PutUint16(hdr[10:12], 1) + return append(hdr, buildOPT(opt)...) + } + return hdr +} + diff --git a/server/internal/canarydns/canarydns_test.go b/server/internal/canarydns/canarydns_test.go new file mode 100644 index 0000000..01844b3 --- /dev/null +++ b/server/internal/canarydns/canarydns_test.go @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package canarydns + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" +) + +// buildQuery makes a single-question DNS query, optionally with an EDNS OPT. +func buildQuery(name string, qtype uint16, ednsBufsize int) []byte { + msg := make([]byte, 12) + binary.BigEndian.PutUint16(msg[0:2], 0x1234) + binary.BigEndian.PutUint16(msg[2:4], flagRD) + binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT + msg = append(msg, encodeName(name)...) + msg = binary.BigEndian.AppendUint16(msg, qtype) + msg = binary.BigEndian.AppendUint16(msg, classIN) + if ednsBufsize > 0 { + binary.BigEndian.PutUint16(msg[10:12], 1) // ARCOUNT + msg = append(msg, 0) // root name + msg = binary.BigEndian.AppendUint16(msg, typeOPT) + msg = binary.BigEndian.AppendUint16(msg, uint16(ednsBufsize)) + msg = binary.BigEndian.AppendUint32(msg, 0) + msg = binary.BigEndian.AppendUint16(msg, 0) + } + return msg +} + +// parseAnswers pulls (type, ttl, rdata) tuples from a response. +type ans struct { + typ uint16 + ttl uint32 + data []byte +} + +func parseResponse(t *testing.T, resp []byte) (flags uint16, answers []ans) { + t.Helper() + flags = binary.BigEndian.Uint16(resp[2:4]) + qd := binary.BigEndian.Uint16(resp[4:6]) + an := binary.BigEndian.Uint16(resp[6:8]) + off := 12 + for i := uint16(0); i < qd; i++ { + _, next, ok := readName(resp, off) + if !ok { + t.Fatal("bad question name") + } + off = next + 4 + } + for i := uint16(0); i < an; i++ { + _, next, ok := readName(resp, off) + if !ok { + t.Fatal("bad answer name") + } + typ := binary.BigEndian.Uint16(resp[next : next+2]) + ttl := binary.BigEndian.Uint32(resp[next+4 : next+8]) + rdlen := int(binary.BigEndian.Uint16(resp[next+8 : next+10])) + answers = append(answers, ans{typ, ttl, resp[next+10 : next+10+rdlen]}) + off = next + 10 + rdlen + } + return +} + +func newTestServer() *Server { + return New("c.echo-lot.app", "fmr", netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1")) +} + +func TestReferenceRecords(t *testing.T) { + s := newTestServer() + resolver := netip.MustParseAddr("198.51.100.7") + + // ttl-5 A → 192.0.2.5, TTL 5 + resp := s.handle(buildQuery("ttl-5.c.echo-lot.app", typeA, 0), resolver, "udp") + _, answers := parseResponse(t, resp) + if len(answers) != 1 || answers[0].ttl != 5 || !netip.AddrFrom4([4]byte(answers[0].data)).IsValid() { + t.Fatalf("ttl-5 A: %+v", answers) + } + if got := net.IP(answers[0].data).String(); got != "192.0.2.5" { + t.Fatalf("ttl-5 A = %s, want 192.0.2.5", got) + } + + // many-rr → exactly 8 A records, in order .101..108 + resp = s.handle(buildQuery("many-rr.c.echo-lot.app", typeA, 0), resolver, "udp") + _, answers = parseResponse(t, resp) + if len(answers) != 8 { + t.Fatalf("many-rr: got %d A records, want 8", len(answers)) + } + for i, a := range answers { + if a.data[3] != byte(101+i) { + t.Fatalf("many-rr order: record %d = .%d, want .%d", i, a.data[3], 101+i) + } + } +} + +func TestBigTxtTruncationVsEDNS(t *testing.T) { + s := newTestServer() + resolver := netip.MustParseAddr("198.51.100.7") + + // No EDNS → 512 cap → TC set, answers dropped. + resp := s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "udp") + flags, answers := parseResponse(t, resp) + if flags&flagTC == 0 { + t.Fatal("big-txt over plain UDP should set TC") + } + if len(answers) != 0 { + t.Fatalf("truncated response should carry no answers, got %d", len(answers)) + } + + // EDNS bufsize 4096 → full answer, no TC. + resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 4096), resolver, "udp") + flags, answers = parseResponse(t, resp) + if flags&flagTC != 0 { + t.Fatal("big-txt with EDNS 4096 should not truncate") + } + if len(answers) != 1 { + t.Fatalf("want 1 TXT answer, got %d", len(answers)) + } + + // TCP → never truncates. + resp = s.handle(buildQuery("big-txt.c.echo-lot.app", typeTXT, 0), resolver, "tcp") + flags, _ = parseResponse(t, resp) + if flags&flagTC != 0 { + t.Fatal("TCP must not truncate") + } +} + +func TestQueryLogAndPerPrefix(t *testing.T) { + s := newTestServer() + s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 1232), netip.MustParseAddr("198.51.100.7"), "udp") + s.handle(buildQuery("def456.other.c.echo-lot.app", typeA, 0), netip.MustParseAddr("203.0.113.9"), "udp") + + all := s.RecentForPrefix("sessprefix1") + if len(all) != 1 { + t.Fatalf("per-prefix filter: got %d, want 1", len(all)) + } + q := all[0] + if q.ResolverIP != "198.51.100.7" || q.Transport != "udp" { + t.Fatalf("logged resolver/transport wrong: %+v", q) + } + if !q.EDNS.Present || q.EDNS.Bufsize != 1232 { + t.Fatalf("EDNS not captured: %+v", q.EDNS) + } + // nonce answer is deterministic + in doc range + resp := s.handle(buildQuery("abc123.SESSPREFIX1.c.echo-lot.app", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp") + _, answers := parseResponse(t, resp) + if len(answers) != 1 || answers[0].data[0] != 192 || answers[0].data[1] != 0 || answers[0].data[2] != 2 { + t.Fatalf("nonce answer not in 192.0.2.0/24: %+v", answers) + } +} + +func TestOutOfZoneNXDomain(t *testing.T) { + s := newTestServer() + resp := s.handle(buildQuery("example.com", typeA, 0), netip.MustParseAddr("198.51.100.7"), "udp") + flags, _ := parseResponse(t, resp) + if flags&0x000F != rcodeNXDomain { + t.Fatalf("out-of-zone should be NXDOMAIN, flags=%#x", flags) + } +} diff --git a/server/internal/canarydns/dns_reference.go b/server/internal/canarydns/dns_reference.go new file mode 100644 index 0000000..bb61b5c --- /dev/null +++ b/server/internal/canarydns/dns_reference.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package canarydns serves the authoritative canary zone (spec §6.1). The +// reference records below are FROZEN by the protocol spec — names, TTLs, and +// RDATA are ground truth the client compares against, so they must never +// change without a spec revision and a matching update in the app. All +// addresses are documentation-range (RFC 5737 192.0.2.0/24, RFC 3849 +// 2001:db8::/32). +package canarydns + +import "net/netip" + +// refRecord is one frozen reference name (relative to the zone) with its +// per-type answers. A zero value in a field means "no record of that type". +type refRecord struct { + label string + ttl uint32 + a []netip.Addr // A / AAAA answers (order preserved) + txt []string // one string per TXT record +} + +// referenceRecords are the spec §6.1 fixed records. The RDATA constants are +// frozen HERE (the spec calls this file the source of truth) and mirrored in +// the app. Order within many-rr is part of the test (order/stripping check). +var referenceRecords = []refRecord{ + {label: "ttl-5", ttl: 5, + a: []netip.Addr{netip.MustParseAddr("192.0.2.5"), netip.MustParseAddr("2001:db8::5")}, + txt: []string{"echolot-ref ttl=5"}}, + {label: "ttl-60", ttl: 60, + a: []netip.Addr{netip.MustParseAddr("192.0.2.60"), netip.MustParseAddr("2001:db8::60")}, + txt: []string{"echolot-ref ttl=60"}}, + {label: "ttl-3600", ttl: 3600, + a: []netip.Addr{netip.MustParseAddr("192.0.2.36"), netip.MustParseAddr("2001:db8::3600")}, + txt: []string{"echolot-ref ttl=3600"}}, + {label: "ttl-86400", ttl: 86400, + a: []netip.Addr{netip.MustParseAddr("192.0.2.86"), netip.MustParseAddr("2001:db8::8640")}, + txt: []string{"echolot-ref ttl=86400"}}, + // many-rr: exactly 8 A records in defined order. + {label: "many-rr", ttl: 300, a: []netip.Addr{ + netip.MustParseAddr("192.0.2.101"), netip.MustParseAddr("192.0.2.102"), + netip.MustParseAddr("192.0.2.103"), netip.MustParseAddr("192.0.2.104"), + netip.MustParseAddr("192.0.2.105"), netip.MustParseAddr("192.0.2.106"), + netip.MustParseAddr("192.0.2.107"), netip.MustParseAddr("192.0.2.108"), + }}, + // big-txt: ~1800 bytes, exercises EDNS bufsize / TCP fallback. + {label: "big-txt", ttl: 300, txt: bigTxt()}, +} + +// bigTxt builds a deterministic ~1800-byte TXT payload as a sequence of +// 255-byte character-strings (the DNS TXT chunk limit). The content is fixed +// so the client can verify integrity, not just length. +func bigTxt() []string { + const total = 1800 + const pattern = "echolot-big-txt-reference-0123456789abcdef-" + buf := make([]byte, 0, total) + for len(buf) < total { + buf = append(buf, pattern...) + } + buf = buf[:total] + var out []string + for len(buf) > 0 { + n := min(255, len(buf)) + out = append(out, string(buf[:n])) + buf = buf[n:] + } + return out +} + +// findReference returns the reference record for a label, or nil. +func findReference(label string) *refRecord { + for i := range referenceRecords { + if referenceRecords[i].label == label { + return &referenceRecords[i] + } + } + return nil +} diff --git a/server/internal/canarydns/wire.go b/server/internal/canarydns/wire.go new file mode 100644 index 0000000..a37d1a1 --- /dev/null +++ b/server/internal/canarydns/wire.go @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package canarydns + +import ( + "encoding/binary" + "net/netip" + "strings" +) + +// rr is a resource record to encode into the answer section. +type rr struct { + ttl uint32 + typ uint16 + addr netip.Addr // for A/AAAA + txt []string // for TXT + ns string // for NS +} + +// optInfo is the parsed EDNS OPT plus the derived observation fields. +type optInfo struct { + edns edns + ecs string +} + +// parseQuestion reads a single question starting at off. Returns the raw +// (case-preserved) qname with trailing dot, qtype, qclass, and the offset +// just past the question. +func parseQuestion(pkt []byte, off int) (qname string, qtype, qclass uint16, end int, ok bool) { + name, next, ok := readName(pkt, off) + if !ok || next+4 > len(pkt) { + return "", 0, 0, 0, false + } + qtype = binary.BigEndian.Uint16(pkt[next : next+2]) + qclass = binary.BigEndian.Uint16(pkt[next+2 : next+4]) + return name, qtype, qclass, next + 4, true +} + +// readName decodes a DNS name (with compression pointers) into a +// dot-terminated string, preserving label case. +func readName(pkt []byte, off int) (string, int, bool) { + var sb strings.Builder + end := -1 + jumps := 0 + for { + if off >= len(pkt) { + return "", 0, false + } + l := int(pkt[off]) + switch { + case l == 0: + off++ + if end < 0 { + end = off + } + if sb.Len() == 0 { + return ".", end, true + } + return sb.String(), end, true + case l&0xC0 == 0xC0: // compression pointer + if off+1 >= len(pkt) { + return "", 0, false + } + if end < 0 { + end = off + 2 + } + off = int(binary.BigEndian.Uint16(pkt[off:off+2]) & 0x3FFF) + jumps++ + if jumps > 16 { + return "", 0, false + } + default: + if off+1+l > len(pkt) { + return "", 0, false + } + sb.Write(pkt[off+1 : off+1+l]) + sb.WriteByte('.') + off += 1 + l + } + } +} + +// parseOPT scans the additional section for an EDNS OPT RR and extracts +// bufsize, the DO flag, and any ECS option. +func parseOPT(pkt []byte, off int, arcount uint16) optInfo { + var info optInfo + for i := uint16(0); i < arcount && off < len(pkt); i++ { + _, next, ok := readName(pkt, off) + if !ok || next+10 > len(pkt) { + return info + } + typ := binary.BigEndian.Uint16(pkt[next : next+2]) + class := binary.BigEndian.Uint16(pkt[next+2 : next+4]) // OPT: requester bufsize + ttl := binary.BigEndian.Uint32(pkt[next+4 : next+8]) // OPT: extended-rcode/version/flags + rdlen := int(binary.BigEndian.Uint16(pkt[next+8 : next+10])) + rdata := next + 10 + if rdata+rdlen > len(pkt) { + return info + } + if typ == typeOPT { + info.edns.Present = true + info.edns.Bufsize = int(class) + if ttl&ednsDO != 0 { + info.edns.Flags = append(info.edns.Flags, "do") + } + info.ecs = parseECS(pkt[rdata : rdata+rdlen]) + return info + } + off = rdata + rdlen + } + return info +} + +// parseECS extracts an EDNS Client Subnet option (RFC 7871) as "ip/scope". +func parseECS(rdata []byte) string { + for len(rdata) >= 4 { + code := binary.BigEndian.Uint16(rdata[0:2]) + olen := int(binary.BigEndian.Uint16(rdata[2:4])) + if 4+olen > len(rdata) { + return "" + } + if code == optECS && olen >= 4 { + fam := binary.BigEndian.Uint16(rdata[4:6]) + srcPrefix := rdata[6] + addrBytes := rdata[8 : 4+olen] + var ip netip.Addr + if fam == 1 { + var b [4]byte + copy(b[:], addrBytes) + ip = netip.AddrFrom4(b) + } else if fam == 2 { + var b [16]byte + copy(b[:], addrBytes) + ip = netip.AddrFrom16(b) + } + if ip.IsValid() { + return ip.String() + "/" + itoa(int(srcPrefix)) + } + } + rdata = rdata[4+olen:] + } + return "" +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [4]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// buildResponse assembles the answer, sets TC when a UDP response exceeds the +// negotiated buffer, and appends the OPT RR when the query used EDNS. +func (s *Server) buildResponse(id uint16, pkt []byte, qEnd int, answers []rr, opt *optInfo, transport string, rcode int) []byte { + msg := make([]byte, 12) + binary.BigEndian.PutUint16(msg[0:2], id) + // question is copied verbatim (case preserved) from the query + msg = append(msg, pkt[12:qEnd]...) + + body := make([]byte, 0, 512) + for _, a := range answers { + body = append(body, encodeRR(a)...) + } + + extra := 0 + if opt != nil && opt.edns.Present { + extra = 1 + } + + flags := uint16(flagQR|flagAA) | (binary.BigEndian.Uint16(pkt[2:4]) & flagRD) | uint16(rcode) + if opt != nil && opt.edns.Present { + body = append(body, buildOPT(opt)...) + } + + // UDP truncation: without EDNS the limit is 512; with EDNS it's the + // requester's bufsize (floored at 512). Drop the answer section and set TC. + if transport == "udp" { + limit := udpMaxNoEDNS + if opt != nil && opt.edns.Present && opt.edns.Bufsize > udpMaxNoEDNS { + limit = opt.edns.Bufsize + } + if 12+(qEnd-12)+len(body) > limit { + flags |= flagTC + // Keep only the OPT RR (if any); drop answers. + body = body[:0] + if opt != nil && opt.edns.Present { + body = append(body, buildOPT(opt)...) + answers = nil + } else { + answers = nil + } + } + } + + binary.BigEndian.PutUint16(msg[2:4], uint16(flags)) + binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT + binary.BigEndian.PutUint16(msg[6:8], uint16(len(answers))) + binary.BigEndian.PutUint16(msg[10:12], uint16(extra)) + return append(msg, body...) +} + +// encodeRR encodes one answer RR, using a compression pointer (0xC00C) to the +// question name at offset 12. +func encodeRR(a rr) []byte { + var rdata []byte + switch a.typ { + case typeA: + b := a.addr.As4() + rdata = b[:] + case typeAAAA: + b := a.addr.As16() + rdata = b[:] + case typeTXT: + for _, s := range a.txt { + for len(s) > 0 { + n := len(s) + if n > 255 { + n = 255 + } + rdata = append(rdata, byte(n)) + rdata = append(rdata, s[:n]...) + s = s[n:] + } + } + case typeNS: + rdata = encodeName(a.ns) + } + out := make([]byte, 0, 12+len(rdata)) + out = append(out, 0xC0, 0x0C) // name → pointer to question + out = binary.BigEndian.AppendUint16(out, a.typ) + out = binary.BigEndian.AppendUint16(out, classIN) + out = binary.BigEndian.AppendUint32(out, a.ttl) + out = binary.BigEndian.AppendUint16(out, uint16(len(rdata))) + return append(out, rdata...) +} + +func encodeName(name string) []byte { + var out []byte + for _, label := range strings.Split(strings.TrimSuffix(name, "."), ".") { + if label == "" { + continue + } + out = append(out, byte(len(label))) + out = append(out, label...) + } + return append(out, 0) +} + +// buildOPT emits a minimal EDNS OPT RR echoing our own bufsize (advertise a +// generous 4096) with DO cleared — we serve no DNSSEC. +func buildOPT(*optInfo) []byte { + out := []byte{0} // root name + out = binary.BigEndian.AppendUint16(out, typeOPT) + out = binary.BigEndian.AppendUint16(out, 4096) // our bufsize + out = binary.BigEndian.AppendUint32(out, 0) // ext-rcode/version/flags + out = binary.BigEndian.AppendUint16(out, 0) // rdlen + return out +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 374baab..e259a05 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -27,6 +27,8 @@ type Config struct { UDPListen string // ECHOLOT_UDP_LISTEN / --udp-listen (spec default port 8442) TCPListen string // ECHOLOT_TCP_LISTEN / --tcp-listen (spec default port 8441) StunListen string // ECHOLOT_STUN_LISTEN / --stun-listen (spec default 3478; empty disables) + DNSListen string // ECHOLOT_DNS_LISTEN / --dns-listen (canary zone; empty disables) + CanaryZone string // ECHOLOT_CANARY_ZONE / --canary-zone (e.g. c.echo-lot.app) // Admin UI / health listener (spec §7: localhost-only by default) AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen @@ -67,6 +69,8 @@ func Load(args []string) (*Config, *Actions, error) { fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address(es), comma-separated") fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address(es), comma-separated") fs.StringVar(&c.StunListen, "stun-listen", envOr("STUN_LISTEN", ":3478"), "STUN listen address(es), comma-separated; empty disables (spec §4)") + fs.StringVar(&c.DNSListen, "dns-listen", envOr("DNS_LISTEN", ""), "canary-DNS listen address(es) udp+tcp/53, comma-separated; empty disables (spec §6.1)") + fs.StringVar(&c.CanaryZone, "canary-zone", envOr("CANARY_ZONE", ""), "authoritative canary zone, e.g. c.echo-lot.app") fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)") fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)") fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name") diff --git a/server/internal/control/control.go b/server/internal/control/control.go index bfd43ab..a028832 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -47,6 +47,10 @@ type Server struct { TCPRecent func(ip string) any // DelayedEcho schedules/sends a DELAYED_ECHO for a session (may be nil). DelayedEcho func(sess *session.Session, actionID string) error + // CanaryQueries returns logged canary lookups for a session prefix (may be nil). + CanaryQueries func(sessionPrefix string) any + // CanaryZone is surfaced in the profile so the app knows what to query. + CanaryZone string } func (s *Server) Handler() http.Handler { @@ -93,11 +97,16 @@ func (s *Server) observations(w http.ResponseWriter, r *http.Request) { tcp = s.TCPRecent(sess.ControlSource.Unmap().String()) } } + var dnsCanary any + if s.CanaryQueries != nil { + dnsCanary = s.CanaryQueries(sess.ID[:16]) // the session's wire prefix + } writeJSON(w, http.StatusOK, map[string]any{ "udp": map[string]any{"packets_seen": packetsSeen, "packets": udp}, "tcp": tcp, "connect_back": cb, - // TODO(spec §6): http echo records, dns_canary + "dns_canary": dnsCanary, + // TODO(spec §6): http echo records }) } @@ -243,9 +252,10 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) { "tcp_port": s.TCPPort, "stun_port": s.StunPort, }}, - "pins": []string{"pin-sha256:" + s.PinB64}, - "next_pins": []string{}, - "limits": map[string]any{"max_kbps": 50000, "max_session_s": 900}, + "pins": []string{"pin-sha256:" + s.PinB64}, + "next_pins": []string{}, + "canary_zone": s.CanaryZone, + "limits": map[string]any{"max_kbps": 50000, "max_session_s": 900}, }) }