From 09193690af9a1f40ad000563f6145456f134787b Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:13:00 +0000 Subject: [PATCH] feat(dns): implement RFC DNSSEC DS record support Co-Authored-By: Virgil --- hsd.go | 2 ++ hsd_test.go | 36 ++++++++++++++++++++++++++++++++++++ serve.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ service.go | 4 ++++ service_test.go | 30 ++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+) diff --git a/hsd.go b/hsd.go index 8b8aed6..9920271 100644 --- a/hsd.go +++ b/hsd.go @@ -210,6 +210,7 @@ func parseHSDNameResource(raw json.RawMessage) (NameRecords, error) { AAAA []string `json:"aaaa"` TXT []string `json:"txt"` NS []string `json:"ns"` + DS []string `json:"ds"` } if err := json.Unmarshal(raw, &wrapped); err == nil { result = NameRecords{ @@ -217,6 +218,7 @@ func parseHSDNameResource(raw json.RawMessage) (NameRecords, error) { AAAA: wrapped.AAAA, TXT: wrapped.TXT, NS: wrapped.NS, + DS: wrapped.DS, } return result, nil } diff --git a/hsd_test.go b/hsd_test.go index 0f6d2c1..26f30fa 100644 --- a/hsd_test.go +++ b/hsd_test.go @@ -92,6 +92,42 @@ func TestHSDClientGetNameResourceParsesWrappedRecords(t *testing.T) { } } +func TestHSDClientGetNameResourceParsesDSRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + var payload struct { + Method string `json:"method"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatalf("unexpected request payload: %v", err) + } + if payload.Method != "getnameresource" { + t.Fatalf("expected method getnameresource, got %s", payload.Method) + } + + responseWriter.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(responseWriter).Encode(map[string]any{ + "result": map[string]any{ + "records": map[string]any{ + "ds": []string{"60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"}, + }, + }, + }) + })) + defer server.Close() + + client := NewHSDClient(HSDClientOptions{ + URL: server.URL, + }) + + record, err := client.GetNameResource(context.Background(), "charon.lthn") + if err != nil { + t.Fatalf("unexpected getnameresource error: %v", err) + } + if len(record.DS) != 1 || record.DS[0] != "60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678" { + t.Fatalf("unexpected DS result: %#v", record.DS) + } +} + func TestHSDClientGetBlockchainInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { var payload struct { diff --git a/serve.go b/serve.go index f79d196..82afc20 100644 --- a/serve.go +++ b/serve.go @@ -1,6 +1,7 @@ package dns import ( + "fmt" "net" "strconv" "strings" @@ -338,6 +339,11 @@ func (handler *dnsRequestHandler) ServeDNS(responseWriter dnsprotocol.ResponseWr } else { goto noRecord } + case dnsprotocol.TypeDS: + if !found { + goto noRecord + } + appendDNSSECResourceRecords(reply, question.Name, dnsprotocol.TypeDS, record.DS) default: reply.SetRcode(request, dnsprotocol.RcodeNotImplemented) _ = responseWriter.WriteMsg(reply) @@ -467,3 +473,46 @@ func appendAnyAnswers(reply *dnsprotocol.Msg, questionName string, lookupName st }) } } + +func appendDNSSECResourceRecords(reply *dnsprotocol.Msg, questionName string, recordType uint16, values []string) { + for _, value := range values { + rr, err := parseDNSSECResourceRecord(questionName, recordType, value) + if err != nil { + continue + } + reply.Answer = append(reply.Answer, rr) + } +} + +func parseDNSSECResourceRecord(questionName string, recordType uint16, raw string) (dnsprotocol.RR, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("empty dnssec resource value") + } + + fallback := fmt.Sprintf("%s %d IN %s %s", questionName, defaultDNSTTL, dnsprotocol.TypeToString[recordType], trimmed) + rr, err := dnsprotocol.NewRR(fallback) + if err == nil { + header := rr.Header() + if header.Rrtype != recordType { + return nil, fmt.Errorf("dnsprotocol record type mismatch") + } + header.Name = questionName + header.Class = dnsprotocol.ClassINET + header.Ttl = defaultDNSTTL + return rr, nil + } + + if rr, err := dnsprotocol.NewRR(trimmed); err == nil { + header := rr.Header() + if header.Rrtype != recordType { + return nil, fmt.Errorf("dnsprotocol record type mismatch") + } + header.Name = questionName + header.Class = dnsprotocol.ClassINET + header.Ttl = defaultDNSTTL + return rr, nil + } + + return nil, err +} diff --git a/service.go b/service.go index d7b4bab..b64fac2 100644 --- a/service.go +++ b/service.go @@ -32,6 +32,7 @@ type NameRecords struct { AAAA []string `json:"aaaa"` TXT []string `json:"txt"` NS []string `json:"ns"` + DS []string `json:"ds"` } type ResolveAllResult struct { @@ -1035,6 +1036,9 @@ func computeTreeRoot(records map[string]NameRecords) string { builder.WriteString("NS=") builder.WriteString(serializeRecordValues(record.NS)) builder.WriteByte('\n') + builder.WriteString("DS=") + builder.WriteString(serializeRecordValues(record.DS)) + builder.WriteByte('\n') } sum := sha256.Sum256([]byte(builder.String())) diff --git a/service_test.go b/service_test.go index cb943ef..9212773 100644 --- a/service_test.go +++ b/service_test.go @@ -1680,6 +1680,36 @@ func TestServiceServeResolvesAAndAAAARecords(t *testing.T) { } } +func TestServiceServeAnswersDSRecords(t *testing.T) { + service := NewService(ServiceOptions{ + Records: map[string]NameRecords{ + "gateway.charon.lthn": { + DS: []string{"60485 8 2 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"}, + }, + }, + }) + + srv, err := service.Serve("127.0.0.1", 0) + if err != nil { + t.Fatalf("expected server to start: %v", err) + } + defer func() { _ = srv.Close() }() + + client := dnsprotocol.Client{} + request := new(dnsprotocol.Msg) + request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeDS) + response := exchangeWithRetry(t, client, request, srv.Address()) + if response.Rcode != dnsprotocol.RcodeSuccess { + t.Fatalf("unexpected DS rcode: %d", response.Rcode) + } + if len(response.Answer) != 1 { + t.Fatalf("expected one DS answer, got %d", len(response.Answer)) + } + if _, ok := response.Answer[0].(*dnsprotocol.DS); !ok { + t.Fatalf("expected DS answer, got %#v", response.Answer[0]) + } +} + func TestServiceServeAnswersANYWithAllRecordTypes(t *testing.T) { service := NewService(ServiceOptions{ Records: map[string]NameRecords{ -- 2.45.3