From 03fd24a3a43f3ddab1f870c37b44b4fde039f619 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Sun, 30 Aug 2026 14:14:08 +0000 Subject: [PATCH] feat(dns): expand HTTPS and SVCB inspection output --- internal/dnsinspect/dnsinspect.go | 281 ++++++++++++++++++++++++- internal/dnsinspect/dnsinspect_test.go | 86 +++++++- 2 files changed, 363 insertions(+), 4 deletions(-) diff --git a/internal/dnsinspect/dnsinspect.go b/internal/dnsinspect/dnsinspect.go index ea055de6..90519492 100644 --- a/internal/dnsinspect/dnsinspect.go +++ b/internal/dnsinspect/dnsinspect.go @@ -842,6 +842,18 @@ func populateRecordFromRaw(rec *record) { rec.target = target } } + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + // DoH JSON and wire responses normally provide Params directly. Parse + // raw RDATA as well so records from generic fixtures remain semantic + // and malformed values can still be shown safely by the renderer. + priority, target, params, _ := parseRawSVCB(rec.rawRData) + if target != "" { + rec.priority = priority + rec.target = target + } + if len(params) > 0 { + rec.params = params + } } } @@ -1126,6 +1138,221 @@ func formatSVCBValue(priority uint16, target string, params []dnsmessage.SVCPara return strings.Join(parts, " ") } +func svcParamRenderOrder(key uint16) int { + // This order follows the diagnostic fields rather than the wire key order: + // address hints stay together and ECH remains easy to find after them. + switch key { + case uint16(dnsmessage.SVCParamMandatory): + return 0 + case uint16(dnsmessage.SVCParamALPN): + return 1 + case uint16(dnsmessage.SVCParamNoDefaultALPN): + return 2 + case uint16(dnsmessage.SVCParamPort): + return 3 + case uint16(dnsmessage.SVCParamIPv4Hint): + return 4 + case uint16(dnsmessage.SVCParamIPv6Hint): + return 5 + case uint16(dnsmessage.SVCParamECH): + return 6 + case uint16(dnsmessage.SVCParamDOHPath): + return 7 + case uint16(dnsmessage.SVCParamOHTTP): + return 8 + case uint16(dnsmessage.SVCParamTLSSupportedGroups): + return 9 + default: + return 10 + } +} + +func formatStructuredSVCParam(param resolver.SVCParam) (label, value string) { + switch dnsmessage.SVCParamKey(param.Key) { + case dnsmessage.SVCParamMandatory: + return "Mandatory", formatSVCBKeyList(param.Value) + case dnsmessage.SVCParamALPN: + if value, ok := formatSVCBALPN(param.Value); ok { + return "ALPN", value + } + return "ALPN", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamNoDefaultALPN: + if len(param.Value) == 0 { + return "No default ALPN", "true" + } + return "No default ALPN", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamPort: + if len(param.Value) == 2 { + return "Port", strconv.Itoa(int(binary.BigEndian.Uint16(param.Value))) + } + return "Port", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamIPv4Hint: + if value, ok := formatSVCBHints(param.Value, 4); ok { + return "IPv4 hints", value + } + return "IPv4 hints", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamIPv6Hint: + if value, ok := formatSVCBHints(param.Value, 16); ok { + return "IPv6 hints", value + } + return "IPv6 hints", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamECH: + // ECH is already an opaque, length-prefixed binary value. Preserve its + // complete base64 representation; do not expose only a preview. + return "ECH", base64.StdEncoding.EncodeToString(param.Value) + case dnsmessage.SVCParamDOHPath: + return "DoH path", string(param.Value) + case dnsmessage.SVCParamOHTTP: + if len(param.Value) == 0 { + return "OHTTP", "true" + } + return "OHTTP", formatSVCBBytes(param.Value) + case dnsmessage.SVCParamTLSSupportedGroups: + if value, ok := formatSVCBUint16List(param.Value); ok { + return "TLS supported groups", value + } + return "TLS supported groups", formatSVCBBytes(param.Value) + default: + return formatSVCBParamName(param.Key), formatSVCBBytes(param.Value) + } +} + +func formatSVCBParamName(key uint16) string { + name := dnsmessage.SVCParamKey(key).String() + // x/net prints unknown SvcParam keys as bare numbers. The key prefix makes + // those values unambiguous and matches DNS presentation terminology. + if _, err := strconv.ParseUint(name, 10, 16); err == nil { + return "key" + name + } + return name +} + +func formatSVCBBytes(value []byte) string { + return "0x" + hex.EncodeToString(value) +} + +func formatSVCBALPN(value []byte) (string, bool) { + var values []string + for offset := 0; offset < len(value); { + length := int(value[offset]) + offset++ + if length == 0 || length > len(value)-offset { + return "", false + } + values = append(values, string(value[offset:offset+length])) + offset += length + } + if len(values) == 0 { + return "", false + } + return strings.Join(values, ", "), true +} + +func formatSVCBUint16List(value []byte) (string, bool) { + if len(value) == 0 || len(value)%2 != 0 { + return "", false + } + values := make([]string, 0, len(value)/2) + for offset := 0; offset < len(value); offset += 2 { + values = append(values, strconv.Itoa(int(binary.BigEndian.Uint16(value[offset:])))) + } + return strings.Join(values, ", "), true +} + +func formatSVCBHints(value []byte, width int) (string, bool) { + if len(value) == 0 || len(value)%width != 0 { + return "", false + } + values := make([]string, 0, len(value)/width) + for offset := 0; offset < len(value); offset += width { + values = append(values, net.IP(value[offset:offset+width]).String()) + } + return strings.Join(values, ", "), true +} + +func formatSVCBKeyList(value []byte) string { + if len(value) == 0 || len(value)%2 != 0 { + return formatSVCBBytes(value) + } + keys := make([]string, 0, len(value)/2) + for offset := 0; offset < len(value); offset += 2 { + key := binary.BigEndian.Uint16(value[offset:]) + keys = append(keys, formatSVCBKey(key)) + } + return strings.Join(keys, ", ") +} + +func formatSVCBKey(key uint16) string { + switch dnsmessage.SVCParamKey(key) { + case dnsmessage.SVCParamMandatory: + return "mandatory" + case dnsmessage.SVCParamALPN: + return "alpn" + case dnsmessage.SVCParamNoDefaultALPN: + return "no-default-alpn" + case dnsmessage.SVCParamPort: + return "port" + case dnsmessage.SVCParamIPv4Hint: + return "ipv4hint" + case dnsmessage.SVCParamECH: + return "ech" + case dnsmessage.SVCParamIPv6Hint: + return "ipv6hint" + case dnsmessage.SVCParamDOHPath: + return "dohpath" + case dnsmessage.SVCParamOHTTP: + return "ohttp" + case dnsmessage.SVCParamTLSSupportedGroups: + return "tls-supported-groups" + default: + return formatSVCBParamName(key) + } +} + +// parseRawSVCB returns as much of a generic SVCB/HTTPS RDATA value as can be +// safely decoded. The final boolean reports whether the complete value is +// well-formed, which lets the renderer retain malformed data as raw hex. +func parseRawSVCB(raw []byte) (priority uint16, target string, params []resolver.SVCParam, ok bool) { + if len(raw) < 3 { + return 0, "", nil, false + } + + // Use the resolver's strict parser for the validity bit. It checks more + // than framing, including parameter ordering, duplicate keys, reserved + // keys, and the semantics of known values. The local decode below still + // recovers a target and any complete parameters for a useful fallback. + if parsed, err := resolver.ParseSVCBRData(raw); err == nil { + params = make([]resolver.SVCParam, len(parsed.Params)) + for i, param := range parsed.Params { + params[i] = resolver.SVCParam{Key: param.Key, Value: append([]byte(nil), param.Value...)} + } + return parsed.Priority, parsed.Target.String(), params, true + } + + priority = binary.BigEndian.Uint16(raw) + var offset int + target, offset, ok = unpackDNSName(raw, 2) + if !ok { + return priority, "", nil, false + } + for offset < len(raw) { + if len(raw)-offset < 4 { + return priority, target, params, false + } + key := binary.BigEndian.Uint16(raw[offset:]) + length := int(binary.BigEndian.Uint16(raw[offset+2:])) + offset += 4 + if length > len(raw)-offset { + return priority, target, params, false + } + params = append(params, resolver.SVCParam{Key: key, Value: append([]byte(nil), raw[offset:offset+length]...)}) + offset += length + } + // Reaching this point means strict semantic validation failed. Keep the + // recovered fields for display, but make the caller retain the raw value. + return priority, target, params, false +} + func formatSVCParam(param dnsmessage.SVCParam) string { switch param.Key { case dnsmessage.SVCParamALPN: @@ -1238,6 +1465,7 @@ func parseSVCBRDATA(raw []byte) (string, bool) { func unpackDNSName(raw []byte, off int) (string, int, bool) { var labels []string + wireSize := 1 for { if off >= len(raw) { return "", 0, false @@ -1250,7 +1478,11 @@ func unpackDNSName(raw []byte, off int) (string, int, bool) { } return strings.Join(labels, ".") + ".", off, true } - if ln&0xc0 != 0 || off+ln > len(raw) { + if ln&0xc0 != 0 || ln > 63 || off+ln > len(raw) { + return "", 0, false + } + wireSize += 1 + ln + if wireSize > 255 { return "", 0, false } labels = append(labels, dnsLabelPresentation(raw[off:off+ln])) @@ -1794,6 +2026,8 @@ func (rec record) hasComplexRendering() bool { case dnsTypeCAA: _, _, _, ok := caaFields(rec.rawRData) return ok + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + return rec.target != "" default: return false } @@ -1807,7 +2041,9 @@ func renderComplexRecord(p *core.Printer, rec record, last bool) { p.Set(core.Green) if rec.owner != "" { p.WriteString(core.TerminalSafeText(rec.owner)) - if rec.typ != dnsmessage.TypeSOA && rec.typ != dnsTypeCAA { + if rec.typ == dnsmessage.TypeSVCB || rec.typ == dnsmessage.TypeHTTPS { + p.WriteString(" ") + } else if rec.typ != dnsmessage.TypeSOA && rec.typ != dnsTypeCAA { p.WriteString(" → ") } } @@ -1817,6 +2053,9 @@ func renderComplexRecord(p *core.Printer, rec record, last bool) { case dnsmessage.TypeSRV: p.WriteString(core.TerminalSafeText(rec.target)) p.WriteString(fmt.Sprintf(":%d", rec.port)) + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + p.WriteString(fmt.Sprintf("priority %d → ", rec.priority)) + p.WriteString(core.TerminalSafeText(safeRecordText(rec.target))) case dnsmessage.TypeSOA, dnsTypeCAA: // These records list their semantic values on indented lines below. } @@ -1847,6 +2086,44 @@ func renderComplexRecord(p *core.Printer, rec record, last bool) { writeRecordDetail(p, "Flags", strconv.Itoa(int(flags)), continued) writeRecordDetail(p, "Tag", tag, continued) writeRecordDetail(p, "Value", value, continued) + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + renderServiceBindingDetails(p, rec, continued) + return + } + writeRecordSourceAndTTL(p, rec, continued) +} + +// renderServiceBindingDetails expands HTTPS/SVCB parameters into stable, +// human-readable fields. Parameter values stay bytes until this point so the +// renderer can distinguish valid values from malformed or unknown ones. +func renderServiceBindingDetails(p *core.Printer, rec record, continued bool) { + if rec.priority == 0 { + writeRecordDetail(p, "Mode", "AliasMode", continued) + } + + params := slices.Clone(rec.params) + slices.SortStableFunc(params, func(a, b resolver.SVCParam) int { + if order := cmp.Compare(svcParamRenderOrder(a.Key), svcParamRenderOrder(b.Key)); order != 0 { + return order + } + if order := cmp.Compare(a.Key, b.Key); order != 0 { + return order + } + return bytes.Compare(a.Value, b.Value) + }) + for _, param := range params { + label, value := formatStructuredSVCParam(param) + writeRecordDetail(p, label, value, continued) + } + + // A malformed generic RDATA must remain inspectable. Valid responses have + // already been decoded into Params, but a generic fixture or an unusual + // provider can still leave only raw bytes available. + if len(rec.rawRData) > 0 { + _, _, _, valid := parseRawSVCB(rec.rawRData) + if !valid { + writeRecordDetail(p, "Raw RDATA", "0x"+hex.EncodeToString(rec.rawRData), continued) + } } writeRecordSourceAndTTL(p, rec, continued) } diff --git a/internal/dnsinspect/dnsinspect_test.go b/internal/dnsinspect/dnsinspect_test.go index 05094dac..45886061 100644 --- a/internal/dnsinspect/dnsinspect_test.go +++ b/internal/dnsinspect/dnsinspect_test.go @@ -1163,7 +1163,7 @@ func TestRenderUsesTypedDNSRecordData(t *testing.T) { "Primary NS: ns1.example.", "Responsible: hostmaster.example.", "Serial: 2026082901", "Refresh: 1h", "Retry: 10m", "Expire: 1w", "Minimum TTL: 5m", "Weight: 5", "service.example.:443", - "Flags: 0", "Tag: issue", "Value: letsencrypt.org", "0 .", "1 . ALPN=h2", + "Flags: 0", "Tag: issue", "Value: letsencrypt.org", "priority 0 → .", "priority 1 → .", "ALPN: h2", "TYPE99", "0xdead", } { if !strings.Contains(out, want) { @@ -1304,7 +1304,7 @@ func TestRenderEscapesCAAAndSVCBFields(t *testing.T) { }}, }}) out := string(p.Bytes()) - for _, want := range []string{`"bad\nname"`, `ALPN="bad\nname"`} { + for _, want := range []string{`"bad\nname"`, `ALPN: "bad\nname"`} { if !strings.Contains(out, want) { t.Fatalf("record field was not escaped as %q:\n%s", want, out) } @@ -1314,6 +1314,88 @@ func TestRenderEscapesCAAAndSVCBFields(t *testing.T) { } } +func TestRenderHTTPSExpandsServiceBindingParameters(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{ + {Key: uint16(dnsmessage.SVCParamMandatory), Value: []byte{0, 1, 0, 4}}, + {Key: uint16(dnsmessage.SVCParamDOHPath), Value: []byte("/dns-query{?dns}")}, + {Key: uint16(dnsmessage.SVCParamECH), Value: []byte{1, 2, 3}}, + {Key: uint16(dnsmessage.SVCParamIPv6Hint), Value: net.ParseIP("2001:db8::1")}, + {Key: uint16(dnsmessage.SVCParamPort), Value: []byte{1, 0xbb}}, + {Key: uint16(dnsmessage.SVCParamIPv4Hint), Value: []byte{192, 0, 2, 1, 192, 0, 2, 2}}, + {Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{2, 'h', '2', 2, 'h', '3'}}, + {Key: uint16(dnsmessage.SVCParamOHTTP)}, + {Key: uint16(dnsmessage.SVCParamTLSSupportedGroups), Value: []byte{0, 23, 0, 29}}, + {Key: 10, Value: []byte{0xde, 0xad}}, + }, + ttl: 300, hasTTL: true, + }}, + }}) + + out := string(p.Bytes()) + for _, want := range []string{ + "example.com. priority 1 → .", + "Mandatory: alpn, ipv4hint", + "ALPN: h2, h3", + "Port: 443", + "IPv4 hints: 192.0.2.1, 192.0.2.2", + "IPv6 hints: 2001:db8::1", + "ECH: AQID", + "DoH path: /dns-query{?dns}", + "OHTTP: true", + "TLS supported groups: 23, 29", + "key10: 0xdead", + "TTL: 5m", + } { + if !strings.Contains(out, want) { + t.Fatalf("HTTPS output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "ALPN=h2") || strings.Contains(out, "IPv4Hint=") { + t.Fatalf("HTTPS parameters were flattened:\n%s", out) + } +} + +func TestRenderSVCBAliasModeAndMalformedParameters(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "SVCB": {{ + owner: "example.com.", typ: dnsmessage.TypeSVCB, priority: 0, target: ".", + }}, + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + params: []resolver.SVCParam{{Key: uint16(dnsmessage.SVCParamALPN), Value: []byte{3, 'h', '2'}}}, + }}, + }}) + + out := string(p.Bytes()) + for _, want := range []string{ + "example.com. priority 0 → .", + "Mode: AliasMode", + "ALPN: 0x036832", + } { + if !strings.Contains(out, want) { + t.Fatalf("SVCB output missing %q:\n%s", want, out) + } + } +} + +func TestRenderSVCBUnknownRawDataWhenMalformed(t *testing.T) { + p := core.TestPrinter(false) + render(p, &result{host: "example.com", records: map[string][]record{ + "HTTPS": {{ + owner: "example.com.", typ: dnsmessage.TypeHTTPS, priority: 1, target: ".", + rawRData: []byte{0, 1, 1, 'x', 0, 9, 0, 4, 0xde}, + }}, + }}) + if out := string(p.Bytes()); !strings.Contains(out, "Raw RDATA: 0x0001017800090004de") { + t.Fatalf("malformed HTTPS RDATA was not retained:\n%s", out) + } +} + func TestRenderSortsTypedNumericFieldsNumerically(t *testing.T) { p := core.TestPrinter(false) render(p, &result{host: "example.com", records: map[string][]record{