feat(dns): add resource json round-trip

This commit is contained in:
Virgil 2026-04-04 05:18:48 +00:00
parent dab6e84850
commit b71552d08b
2 changed files with 447 additions and 0 deletions

View file

@ -6,6 +6,8 @@ import (
"bytes"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/netip"
@ -28,6 +30,11 @@ type Resource struct {
Records []ResourceRecord
}
// ResourceJSON represents the JSON form of a DNS resource.
type ResourceJSON struct {
Records []json.RawMessage `json:"records"`
}
// DSRecord mirrors the JS DS payload entry.
type DSRecord struct {
KeyTag uint16
@ -36,38 +43,85 @@ type DSRecord struct {
Digest []byte
}
// DSRecordJSON represents the JSON form of a DS record.
type DSRecordJSON struct {
Type string `json:"type"`
KeyTag uint16 `json:"keyTag"`
Algorithm uint8 `json:"algorithm"`
DigestType uint8 `json:"digestType"`
Digest string `json:"digest"`
}
// NSRecord mirrors the JS NS payload entry.
type NSRecord struct {
NS string
}
// NSRecordJSON represents the JSON form of an NS record.
type NSRecordJSON struct {
Type string `json:"type"`
NS string `json:"ns"`
}
// GLUE4Record mirrors the JS IPv4 glue payload entry.
type GLUE4Record struct {
NS string
Address netip.Addr
}
// GLUE4RecordJSON represents the JSON form of a GLUE4 record.
type GLUE4RecordJSON struct {
Type string `json:"type"`
NS string `json:"ns"`
Address string `json:"address"`
}
// GLUE6Record mirrors the JS IPv6 glue payload entry.
type GLUE6Record struct {
NS string
Address netip.Addr
}
// GLUE6RecordJSON represents the JSON form of a GLUE6 record.
type GLUE6RecordJSON struct {
Type string `json:"type"`
NS string `json:"ns"`
Address string `json:"address"`
}
// SYNTH4Record mirrors the JS synthesized IPv4 payload entry.
type SYNTH4Record struct {
Address netip.Addr
}
// SYNTH4RecordJSON represents the JSON form of a SYNTH4 record.
type SYNTH4RecordJSON struct {
Type string `json:"type"`
Address string `json:"address"`
}
// SYNTH6Record mirrors the JS synthesized IPv6 payload entry.
type SYNTH6Record struct {
Address netip.Addr
}
// SYNTH6RecordJSON represents the JSON form of a SYNTH6 record.
type SYNTH6RecordJSON struct {
Type string `json:"type"`
Address string `json:"address"`
}
// TXTRecord mirrors the JS TXT payload entry.
type TXTRecord struct {
Entries []string
}
// TXTRecordJSON represents the JSON form of a TXT record.
type TXTRecordJSON struct {
Type string `json:"type"`
Entries []string `json:"txt"`
}
// NewResource constructs a resource with the reference default TTL.
func NewResource() *Resource {
return &Resource{TTL: DEFAULT_TTL}
@ -257,6 +311,53 @@ func (r *Resource) GetDecode(raw []byte) error {
return r.Decode(raw)
}
// GetJSON converts the resource into its JSON representation.
func (r *Resource) GetJSON() ResourceJSON {
if r == nil {
return ResourceJSON{}
}
jsonView := ResourceJSON{
Records: make([]json.RawMessage, 0, len(r.Records)),
}
for _, record := range r.Records {
if record == nil {
continue
}
raw, err := json.Marshal(resourceRecordJSON(record))
if err != nil {
continue
}
jsonView.Records = append(jsonView.Records, raw)
}
return jsonView
}
// FromJSON populates the resource from its JSON representation.
func (r *Resource) FromJSON(jsonView ResourceJSON) error {
if r == nil {
return core.E("dns.Resource.FromJSON", "resource is required", nil)
}
r.TTL = DEFAULT_TTL
r.Records = r.Records[:0]
for _, raw := range jsonView.Records {
record, err := resourceRecordFromJSON(raw)
if err != nil {
return err
}
r.Records = append(r.Records, record)
}
return nil
}
// DecodeResource decodes a raw DNS resource payload.
func DecodeResource(raw []byte) (*Resource, error) {
resource := NewResource()
@ -272,6 +373,288 @@ func GetDecodeResource(raw []byte) (*Resource, error) {
return DecodeResource(raw)
}
func resourceRecordJSON(record ResourceRecord) any {
switch rr := record.(type) {
case DSRecord:
return rr.GetJSON()
case *DSRecord:
return rr.GetJSON()
case NSRecord:
return rr.GetJSON()
case *NSRecord:
return rr.GetJSON()
case GLUE4Record:
return rr.GetJSON()
case *GLUE4Record:
return rr.GetJSON()
case GLUE6Record:
return rr.GetJSON()
case *GLUE6Record:
return rr.GetJSON()
case SYNTH4Record:
return rr.GetJSON()
case *SYNTH4Record:
return rr.GetJSON()
case SYNTH6Record:
return rr.GetJSON()
case *SYNTH6Record:
return rr.GetJSON()
case TXTRecord:
return rr.GetJSON()
case *TXTRecord:
return rr.GetJSON()
default:
return map[string]any{"type": "UNKNOWN"}
}
}
type resourceRecordProbe struct {
Type string `json:"type"`
}
func resourceRecordFromJSON(raw json.RawMessage) (ResourceRecord, error) {
var probe resourceRecordProbe
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid record encoding", err)
}
switch probe.Type {
case "DS":
var jsonRecord DSRecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid DS record", err)
}
var record DSRecord
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "NS":
var jsonRecord NSRecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid NS record", err)
}
var record NSRecord
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "GLUE4":
var jsonRecord GLUE4RecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid GLUE4 record", err)
}
var record GLUE4Record
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "GLUE6":
var jsonRecord GLUE6RecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid GLUE6 record", err)
}
var record GLUE6Record
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "SYNTH4":
var jsonRecord SYNTH4RecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH4 record", err)
}
var record SYNTH4Record
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "SYNTH6":
var jsonRecord SYNTH6RecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid SYNTH6 record", err)
}
var record SYNTH6Record
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
case "TXT":
var jsonRecord TXTRecordJSON
if err := json.Unmarshal(raw, &jsonRecord); err != nil {
return nil, core.E("dns.Resource.FromJSON", "invalid TXT record", err)
}
var record TXTRecord
if err := record.FromJSON(jsonRecord); err != nil {
return nil, err
}
return record, nil
default:
return nil, core.E("dns.Resource.FromJSON", "unknown record type", nil)
}
}
// GetJSON converts the DS record into its JSON representation.
func (r DSRecord) GetJSON() DSRecordJSON {
return DSRecordJSON{
Type: "DS",
KeyTag: r.KeyTag,
Algorithm: r.Algorithm,
DigestType: r.DigestType,
Digest: fmt.Sprintf("%x", r.Digest),
}
}
// FromJSON populates the DS record from its JSON representation.
func (r *DSRecord) FromJSON(jsonView DSRecordJSON) error {
if jsonView.Type != "DS" {
return core.E("dns.DSRecord.FromJSON", "invalid DS record type", nil)
}
raw, err := hex.DecodeString(jsonView.Digest)
if err != nil {
return core.E("dns.DSRecord.FromJSON", "invalid digest encoding", err)
}
r.KeyTag = jsonView.KeyTag
r.Algorithm = jsonView.Algorithm
r.DigestType = jsonView.DigestType
r.Digest = append(r.Digest[:0], raw...)
return nil
}
// GetJSON converts the NS record into its JSON representation.
func (r NSRecord) GetJSON() NSRecordJSON {
return NSRecordJSON{
Type: "NS",
NS: r.NS,
}
}
// FromJSON populates the NS record from its JSON representation.
func (r *NSRecord) FromJSON(jsonView NSRecordJSON) error {
if jsonView.Type != "NS" {
return core.E("dns.NSRecord.FromJSON", "invalid NS record type", nil)
}
r.NS = jsonView.NS
return nil
}
// GetJSON converts the GLUE4 record into its JSON representation.
func (r GLUE4Record) GetJSON() GLUE4RecordJSON {
return GLUE4RecordJSON{
Type: "GLUE4",
NS: r.NS,
Address: r.Address.String(),
}
}
// FromJSON populates the GLUE4 record from its JSON representation.
func (r *GLUE4Record) FromJSON(jsonView GLUE4RecordJSON) error {
if jsonView.Type != "GLUE4" {
return core.E("dns.GLUE4Record.FromJSON", "invalid GLUE4 record type", nil)
}
addr, err := netip.ParseAddr(jsonView.Address)
if err != nil || !addr.Is4() {
return core.E("dns.GLUE4Record.FromJSON", "invalid IPv4 address", err)
}
r.NS = jsonView.NS
r.Address = addr
return nil
}
// GetJSON converts the GLUE6 record into its JSON representation.
func (r GLUE6Record) GetJSON() GLUE6RecordJSON {
return GLUE6RecordJSON{
Type: "GLUE6",
NS: r.NS,
Address: r.Address.String(),
}
}
// FromJSON populates the GLUE6 record from its JSON representation.
func (r *GLUE6Record) FromJSON(jsonView GLUE6RecordJSON) error {
if jsonView.Type != "GLUE6" {
return core.E("dns.GLUE6Record.FromJSON", "invalid GLUE6 record type", nil)
}
addr, err := netip.ParseAddr(jsonView.Address)
if err != nil || !addr.Is6() {
return core.E("dns.GLUE6Record.FromJSON", "invalid IPv6 address", err)
}
r.NS = jsonView.NS
r.Address = addr
return nil
}
// GetJSON converts the SYNTH4 record into its JSON representation.
func (r SYNTH4Record) GetJSON() SYNTH4RecordJSON {
return SYNTH4RecordJSON{
Type: "SYNTH4",
Address: r.Address.String(),
}
}
// FromJSON populates the SYNTH4 record from its JSON representation.
func (r *SYNTH4Record) FromJSON(jsonView SYNTH4RecordJSON) error {
if jsonView.Type != "SYNTH4" {
return core.E("dns.SYNTH4Record.FromJSON", "invalid SYNTH4 record type", nil)
}
addr, err := netip.ParseAddr(jsonView.Address)
if err != nil || !addr.Is4() {
return core.E("dns.SYNTH4Record.FromJSON", "invalid IPv4 address", err)
}
r.Address = addr
return nil
}
// GetJSON converts the SYNTH6 record into its JSON representation.
func (r SYNTH6Record) GetJSON() SYNTH6RecordJSON {
return SYNTH6RecordJSON{
Type: "SYNTH6",
Address: r.Address.String(),
}
}
// FromJSON populates the SYNTH6 record from its JSON representation.
func (r *SYNTH6Record) FromJSON(jsonView SYNTH6RecordJSON) error {
if jsonView.Type != "SYNTH6" {
return core.E("dns.SYNTH6Record.FromJSON", "invalid SYNTH6 record type", nil)
}
addr, err := netip.ParseAddr(jsonView.Address)
if err != nil || !addr.Is6() {
return core.E("dns.SYNTH6Record.FromJSON", "invalid IPv6 address", err)
}
r.Address = addr
return nil
}
// GetJSON converts the TXT record into its JSON representation.
func (r TXTRecord) GetJSON() TXTRecordJSON {
return TXTRecordJSON{
Type: "TXT",
Entries: append([]string(nil), r.Entries...),
}
}
// FromJSON populates the TXT record from its JSON representation.
func (r *TXTRecord) FromJSON(jsonView TXTRecordJSON) error {
if jsonView.Type != "TXT" {
return core.E("dns.TXTRecord.FromJSON", "invalid TXT record type", nil)
}
r.Entries = append(r.Entries[:0], jsonView.Entries...)
return nil
}
func encodeRecord(buf *bytes.Buffer, record ResourceRecord) error {
switch rr := record.(type) {
case DSRecord:

View file

@ -98,6 +98,70 @@ func TestResourceEncodeDecodeRoundTrip(t *testing.T) {
}
}
func TestResourceJSONRoundTrip(t *testing.T) {
resource := NewResource()
resource.Records = []ResourceRecord{
DSRecord{
KeyTag: 1,
Algorithm: 2,
DigestType: 3,
Digest: []byte{0xaa, 0xbb, 0xcc},
},
NSRecord{NS: "ns1.example."},
GLUE4Record{NS: "ns1.example.", Address: netip.MustParseAddr("192.0.2.1")},
GLUE6Record{NS: "ns2.example.", Address: netip.MustParseAddr("2001:db8::1")},
SYNTH4Record{Address: netip.MustParseAddr("198.51.100.9")},
SYNTH6Record{Address: netip.MustParseAddr("2001:db8::9")},
TXTRecord{Entries: []string{"hello", "world"}},
}
jsonView := resource.GetJSON()
if len(jsonView.Records) != len(resource.Records) {
t.Fatalf("GetJSON returned %d records, want %d", len(jsonView.Records), len(resource.Records))
}
var decoded Resource
if err := decoded.FromJSON(jsonView); err != nil {
t.Fatalf("FromJSON returned error: %v", err)
}
if decoded.TTL != DEFAULT_TTL {
t.Fatalf("decoded TTL = %d, want %d", decoded.TTL, DEFAULT_TTL)
}
if len(decoded.Records) != len(resource.Records) {
t.Fatalf("decoded %d records, want %d", len(decoded.Records), len(resource.Records))
}
if ds, ok := decoded.Records[0].(DSRecord); !ok || ds.KeyTag != 1 || ds.Algorithm != 2 || ds.DigestType != 3 || !bytes.Equal(ds.Digest, []byte{0xaa, 0xbb, 0xcc}) {
t.Fatalf("decoded DS record = %#v, want keyTag=1 algorithm=2 digestType=3 digest=aabbcc", decoded.Records[0])
}
if ns, ok := decoded.Records[1].(NSRecord); !ok || ns.NS != "ns1.example." {
t.Fatalf("decoded NS record = %#v, want ns1.example.", decoded.Records[1])
}
if glue4, ok := decoded.Records[2].(GLUE4Record); !ok || glue4.NS != "ns1.example." || glue4.Address.String() != "192.0.2.1" {
t.Fatalf("decoded GLUE4 record = %#v, want ns1.example./192.0.2.1", decoded.Records[2])
}
if glue6, ok := decoded.Records[3].(GLUE6Record); !ok || glue6.NS != "ns2.example." || glue6.Address.String() != "2001:db8::1" {
t.Fatalf("decoded GLUE6 record = %#v, want ns2.example./2001:db8::1", decoded.Records[3])
}
if synth4, ok := decoded.Records[4].(SYNTH4Record); !ok || synth4.Address.String() != "198.51.100.9" {
t.Fatalf("decoded SYNTH4 record = %#v, want 198.51.100.9", decoded.Records[4])
}
if synth6, ok := decoded.Records[5].(SYNTH6Record); !ok || synth6.Address.String() != "2001:db8::9" {
t.Fatalf("decoded SYNTH6 record = %#v, want 2001:db8::9", decoded.Records[5])
}
if txt, ok := decoded.Records[6].(TXTRecord); !ok || len(txt.Entries) != 2 || txt.Entries[0] != "hello" || txt.Entries[1] != "world" {
t.Fatalf("decoded TXT record = %#v, want [hello world]", decoded.Records[6])
}
}
func TestResourceTypeHelpers(t *testing.T) {
resource := NewResource()
resource.Records = []ResourceRecord{