Skip to content

Commit 629b4f0

Browse files
authored
feat: support DNS Stamp upstream format (#1922)
1 parent acbf953 commit 629b4f0

29 files changed

+1386
-101
lines changed

.golangci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ linters:
7878
- forbidigo
7979
- gosmopolitan
8080
- recvcheck
81+
- wrapcheck
8182
settings:
8283
ginkgolinter:
8384
forbid-focus-container: true
@@ -113,6 +114,7 @@ linters:
113114
- gosec
114115
- forcetypeassert
115116
- wrapcheck
117+
- lll
116118
path: _test\.go
117119
- linters:
118120
- staticcheck

cache/stringcache/string_caches_benchmark_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ func init() {
6868
// Most memory efficient: Wildcard (blocky/trie radix) because of peak
6969
// Fastest: Wildcard (blocky/trie original)
7070
//
71-
//nolint:lll
7271
// BenchmarkRegexFactory-8 1 1 253 023 507 ns/op 430.60 fact_heap_MB 430.60 peak_heap_MB 1 792 669 024 B/op 9 826 986 allocs/op
7372
// BenchmarkStringFactory-8 7 163 969 933 ns/op 11.79 fact_heap_MB 26.91 peak_heap_MB 67 613 890 B/op 1 306 allocs/op
7473
// BenchmarkWildcardFactory-8 19 60 592 988 ns/op 16.60 fact_heap_MB 16.60 peak_heap_MB 26 740 317 B/op 92 245 allocs/op (original)
@@ -131,7 +130,6 @@ func benchmarkFactory(b *testing.B, data []string, newFactory func() cacheFactor
131130
// Most memory efficient: Wildcard (blocky/trie radix)
132131
// Fastest: Wildcard (blocky/trie original)
133132
//
134-
//nolint:lll
135133
// BenchmarkStringCache-8 6 204 754 798 ns/op 15.11 cache_heap_MB 0 B/op 0 allocs/op
136134
// BenchmarkWildcardCache-8 14 76 186 334 ns/op 16.61 cache_heap_MB 0 B/op 0 allocs/op (original)
137135
// BenchmarkWildcardCache-8 12 95 316 121 ns/op 14.91 cache_heap_MB 0 B/op 0 allocs/op (radix)

config/config_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,163 @@ bootstrapDns:
955955
Expect(hook.Messages).ShouldNot(ContainElement(ContainSubstring("deprecated")))
956956
})
957957
})
958+
959+
Describe("DNS Stamp Upstreams", func() {
960+
When("Config contains DNS stamps", func() {
961+
It("should parse DNS stamp upstreams correctly", func() {
962+
confFile := tmpDir.CreateStringFile("config.yml",
963+
"upstreams:",
964+
" groups:",
965+
" default:",
966+
" - sdns://AgcAAAAAAAAABzEuMC4wLjGgENk8mGSlIfMGXMOlIlCcKvq7AVgcrZxtjon911-ep0cg63Ul-I8NlFj4GplQGb_TTLiczclX57DvMV8Q-JdjgRgSZG5zLmNsb3VkZmxhcmUuY29tCi9kbnMtcXVlcnk", // Cloudflare DoH
967+
" - sdns://AAcAAAAAAAAABzguOC44Ljg", // Google DNS Plain
968+
)
969+
970+
c, err = LoadConfig(confFile.Path, true)
971+
Expect(err).Should(Succeed())
972+
973+
// Verify default group
974+
defaultUpstreams := c.Upstreams.Groups["default"]
975+
Expect(defaultUpstreams).Should(HaveLen(2))
976+
977+
// Verify Cloudflare DoH
978+
Expect(defaultUpstreams[0].Net).Should(Equal(NetProtocolHttps))
979+
Expect(defaultUpstreams[0].Host).Should(Equal("dns.cloudflare.com"))
980+
Expect(defaultUpstreams[0].Port).Should(Equal(uint16(443)))
981+
Expect(defaultUpstreams[0].Path).Should(Equal("/dns-query"))
982+
Expect(defaultUpstreams[0].CommonName).Should(Equal("dns.cloudflare.com"))
983+
Expect(defaultUpstreams[0].CertificateFingerprints).ShouldNot(BeEmpty())
984+
985+
// Verify Google Plain DNS
986+
Expect(defaultUpstreams[1].Net).Should(Equal(NetProtocolTcpUdp))
987+
Expect(defaultUpstreams[1].Host).Should(Equal("8.8.8.8"))
988+
Expect(defaultUpstreams[1].Port).Should(Equal(uint16(53)))
989+
})
990+
991+
It("should support mixed DNS stamp and traditional format", func() {
992+
confFile := tmpDir.CreateStringFile("config.yml",
993+
"upstreams:",
994+
" groups:",
995+
" default:",
996+
" - 8.8.8.8", // Traditional
997+
" - https://dns.google/dns-query", // Traditional DoH
998+
" - sdns://AAcAAAAAAAAABzguOC44Ljg", // DNS Stamp
999+
" - sdns://AgcAAAAAAAAABzEuMC4wLjGgENk8mGSlIfMGXMOlIlCcKvq7AVgcrZxtjon911-ep0cg63Ul-I8NlFj4GplQGb_TTLiczclX57DvMV8Q-JdjgRgSZG5zLmNsb3VkZmxhcmUuY29tCi9kbnMtcXVlcnk", // DNS Stamp DoH
1000+
)
1001+
1002+
c, err = LoadConfig(confFile.Path, true)
1003+
Expect(err).Should(Succeed())
1004+
1005+
defaultUpstreams := c.Upstreams.Groups["default"]
1006+
Expect(defaultUpstreams).Should(HaveLen(4))
1007+
1008+
// All should be parsed correctly
1009+
Expect(defaultUpstreams[0].Host).Should(Equal("8.8.8.8"))
1010+
Expect(defaultUpstreams[0].Net).Should(Equal(NetProtocolTcpUdp))
1011+
1012+
Expect(defaultUpstreams[1].Host).Should(Equal("dns.google"))
1013+
Expect(defaultUpstreams[1].Net).Should(Equal(NetProtocolHttps))
1014+
1015+
Expect(defaultUpstreams[2].Host).Should(Equal("8.8.8.8"))
1016+
Expect(defaultUpstreams[2].Net).Should(Equal(NetProtocolTcpUdp))
1017+
1018+
Expect(defaultUpstreams[3].Host).Should(Equal("dns.cloudflare.com"))
1019+
Expect(defaultUpstreams[3].Net).Should(Equal(NetProtocolHttps))
1020+
})
1021+
1022+
It("should reject unsupported protocol in DNS stamp", func() {
1023+
confFile := tmpDir.CreateStringFile("config.yml",
1024+
"upstreams:",
1025+
" groups:",
1026+
" default:",
1027+
" - sdns://AQMAAAAAAAAAETk0Ljc2Ljc2LjE6ODQ0MyAK-Y3YBV0rO9yqiOWp6OMQNvPPRMfOqCvQV7C8BmOW6hnSZG5zY3J5cHQuZGU", // DNSCrypt
1028+
)
1029+
1030+
_, err = LoadConfig(confFile.Path, true)
1031+
Expect(err).Should(HaveOccurred())
1032+
// May fail with various error messages depending on stamp validity
1033+
})
1034+
1035+
It("should reject invalid DNS stamp", func() {
1036+
confFile := tmpDir.CreateStringFile("config.yml",
1037+
"upstreams:",
1038+
" groups:",
1039+
" default:",
1040+
" - sdns://invalid!!!", // Invalid stamp
1041+
)
1042+
1043+
_, err = LoadConfig(confFile.Path, true)
1044+
Expect(err).Should(HaveOccurred())
1045+
})
1046+
1047+
It("should preserve certificate fingerprints from DNS stamps", func() {
1048+
confFile := tmpDir.CreateStringFile("config.yml",
1049+
"upstreams:",
1050+
" groups:",
1051+
" default:",
1052+
" - sdns://AgcAAAAAAAAABzEuMC4wLjGgENk8mGSlIfMGXMOlIlCcKvq7AVgcrZxtjon911-ep0cg63Ul-I8NlFj4GplQGb_TTLiczclX57DvMV8Q-JdjgRgSZG5zLmNsb3VkZmxhcmUuY29tCi9kbnMtcXVlcnk", // Cloudflare with certs
1053+
)
1054+
1055+
c, err = LoadConfig(confFile.Path, true)
1056+
Expect(err).Should(Succeed())
1057+
1058+
upstream := c.Upstreams.Groups["default"][0]
1059+
Expect(upstream.CertificateFingerprints).Should(HaveLen(2))
1060+
1061+
// Each fingerprint should be 32 bytes (SHA256)
1062+
for _, fp := range upstream.CertificateFingerprints {
1063+
Expect(fp).Should(HaveLen(32))
1064+
}
1065+
})
1066+
1067+
It("should handle IPv6 in DNS stamps", func() {
1068+
confFile := tmpDir.CreateStringFile("config.yml",
1069+
"upstreams:",
1070+
" groups:",
1071+
" default:",
1072+
" - sdns://AAcAAAAAAAAAKVsyMDAxOjBkYjg6ODVhMzowMDAwOjAwMDA6OGEyZTowMzcwOjczMzRd",
1073+
)
1074+
1075+
c, err = LoadConfig(confFile.Path, true)
1076+
Expect(err).Should(Succeed())
1077+
1078+
upstream := c.Upstreams.Groups["default"][0]
1079+
1080+
// All should be parsed correctly
1081+
Expect(upstream.Host).Should(Equal("2001:0db8:85a3:0000:0000:8a2e:0370:7334"))
1082+
Expect(upstream.Net).Should(Equal(NetProtocolTcpUdp))
1083+
})
1084+
})
1085+
1086+
When("Config uses conditional upstream with DNS stamps", func() {
1087+
It("should parse DNS stamps in conditional mapping", func() {
1088+
confFile := tmpDir.CreateStringFile("config.yml",
1089+
"upstreams:",
1090+
" groups:",
1091+
" default:",
1092+
" - 8.8.8.8",
1093+
"conditional:",
1094+
" mapping:",
1095+
" example.com: sdns://AgcAAAAAAAAABzEuMC4wLjGgENk8mGSlIfMGXMOlIlCcKvq7AVgcrZxtjon911-ep0cg63Ul-I8NlFj4GplQGb_TTLiczclX57DvMV8Q-JdjgRgSZG5zLmNsb3VkZmxhcmUuY29tCi9kbnMtcXVlcnk",
1096+
" test.com: 1.1.1.1", // Traditional format
1097+
)
1098+
1099+
c, err = LoadConfig(confFile.Path, true)
1100+
Expect(err).Should(Succeed())
1101+
1102+
// Verify conditional mapping parsed DNS stamp
1103+
exampleUpstreams := c.Conditional.Mapping.Upstreams["example.com"]
1104+
Expect(exampleUpstreams).Should(HaveLen(1))
1105+
Expect(exampleUpstreams[0].Host).Should(Equal("dns.cloudflare.com"))
1106+
Expect(exampleUpstreams[0].Net).Should(Equal(NetProtocolHttps))
1107+
1108+
// Verify traditional format still works
1109+
testUpstreams := c.Conditional.Mapping.Upstreams["test.com"]
1110+
Expect(testUpstreams).Should(HaveLen(1))
1111+
Expect(testUpstreams[0].Host).Should(Equal("1.1.1.1"))
1112+
})
1113+
})
1114+
})
9581115
})
9591116

9601117
func defaultTestFileConfig(config *Config) {

config/upstream.go

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,38 @@
11
package config
22

33
import (
4+
"errors"
45
"fmt"
56
"net"
67
"regexp"
78
"strconv"
89
"strings"
10+
11+
"github.com/jedisct1/go-dnsstamps"
912
)
1013

1114
var validDomain = regexp.MustCompile(
1215
`^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$`)
1316

17+
// CertificateFingerprint represents a SHA256 fingerprint of a TLS certificate (32 bytes)
18+
type CertificateFingerprint []byte
19+
1420
// Upstream is the definition of external DNS server
1521
type Upstream struct {
1622
Net NetProtocol
1723
Host string
1824
Port uint16
1925
Path string
2026
CommonName string // Common Name to use for certificate verification; optional. "" uses .Host
27+
28+
// DNS stamp metadata (optional) - only populated when parsing DNS stamps
29+
CertificateFingerprints []CertificateFingerprint // SHA256 fingerprints for TLS certificate pinning
2130
}
2231

2332
// IsDefault returns true if u is the default value
2433
func (u *Upstream) IsDefault() bool {
25-
return *u == Upstream{}
34+
return u.Net == 0 && u.Host == "" && u.Port == 0 && u.Path == "" &&
35+
u.CommonName == "" && len(u.CertificateFingerprints) == 0
2636
}
2737

2838
// String returns the string representation of u
@@ -76,7 +86,14 @@ func (u *Upstream) UnmarshalText(data []byte) error {
7686
}
7787

7888
// ParseUpstream creates new Upstream from passed string in format [net]:host[:port][/path][#commonname]
89+
// or DNS Stamp format: sdns://...
7990
func ParseUpstream(upstream string) (Upstream, error) {
91+
// Check if it's a DNS stamp
92+
if isDNSStamp(upstream) {
93+
return parseStamp(upstream)
94+
}
95+
96+
// Existing parsing logic for traditional format
8097
var path string
8198

8299
var port uint16
@@ -163,3 +180,115 @@ func extractNet(upstream string) (NetProtocol, string) {
163180

164181
return NetProtocolTcpUdp, upstream
165182
}
183+
184+
// isDNSStamp checks if a string is a DNS stamp format
185+
func isDNSStamp(s string) bool {
186+
return strings.HasPrefix(s, "sdns://")
187+
}
188+
189+
// parseStamp parses a DNS stamp and converts it to an Upstream
190+
func parseStamp(stampStr string) (Upstream, error) {
191+
stamp, err := dnsstamps.NewServerStampFromString(stampStr)
192+
if err != nil {
193+
return Upstream{}, fmt.Errorf("invalid DNS stamp: %w", err)
194+
}
195+
196+
// Map stamp protocol to NetProtocol
197+
netProto, err := stampProtoToNetProtocol(stamp.Proto)
198+
if err != nil {
199+
return Upstream{}, err
200+
}
201+
202+
// Extract host and port from ServerAddrStr
203+
host, port, err := extractStampHostPort(stamp.ServerAddrStr, netProto)
204+
if err != nil {
205+
return Upstream{}, err
206+
}
207+
208+
// Use provider name as hostname if available (for DoH/DoT)
209+
hostname := host
210+
if stamp.ProviderName != "" {
211+
// Validate provider name is a valid hostname or IP
212+
if ip := net.ParseIP(stamp.ProviderName); ip == nil {
213+
// Not an IP, must be a valid hostname
214+
if !validDomain.MatchString(stamp.ProviderName) {
215+
return Upstream{}, fmt.Errorf("invalid provider name in DNS stamp: '%s'", stamp.ProviderName)
216+
}
217+
}
218+
219+
hostname = stamp.ProviderName
220+
}
221+
222+
// Convert stamp hashes to CertificateFingerprint type
223+
certFingerprints := make([]CertificateFingerprint, 0, len(stamp.Hashes))
224+
for _, hash := range stamp.Hashes {
225+
certFingerprints = append(certFingerprints, CertificateFingerprint(hash))
226+
}
227+
228+
upstream := Upstream{
229+
Net: netProto,
230+
Host: hostname,
231+
Port: port,
232+
Path: stamp.Path,
233+
CommonName: stamp.ProviderName, // Use provider name for TLS verification
234+
CertificateFingerprints: certFingerprints, // SHA256 fingerprints for certificate pinning
235+
}
236+
237+
return upstream, nil
238+
}
239+
240+
// extractStampHostPort extracts host and port from a DNS stamp server address string
241+
func extractStampHostPort(serverAddr string, netProto NetProtocol) (string, uint16, error) {
242+
if serverAddr == "" {
243+
return "", netDefaultPort[netProto], nil
244+
}
245+
246+
h, portStr, err := net.SplitHostPort(serverAddr)
247+
if err != nil {
248+
// SplitHostPort failed - could be missing port or raw IP/hostname
249+
// This is not an error for our purposes, just means no port specified
250+
// Strip IPv6 brackets if present (e.g., "[2001:db8::1]" -> "2001:db8::1")
251+
host := serverAddr
252+
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
253+
host = host[1 : len(host)-1]
254+
}
255+
256+
return host, netDefaultPort[netProto], nil
257+
}
258+
259+
// Successfully split host and port
260+
if portStr != "" {
261+
p, err := ConvertPort(portStr)
262+
if err != nil {
263+
return "", 0, fmt.Errorf("invalid port in stamp: %w", err)
264+
}
265+
266+
return h, p, nil
267+
}
268+
269+
return h, netDefaultPort[netProto], nil
270+
}
271+
272+
// stampProtoToNetProtocol maps DNS stamp protocol to Blocky's NetProtocol
273+
func stampProtoToNetProtocol(proto dnsstamps.StampProtoType) (NetProtocol, error) {
274+
switch proto {
275+
case dnsstamps.StampProtoTypePlain:
276+
return NetProtocolTcpUdp, nil
277+
case dnsstamps.StampProtoTypeDoH:
278+
return NetProtocolHttps, nil
279+
case dnsstamps.StampProtoTypeTLS:
280+
return NetProtocolTcpTls, nil
281+
case dnsstamps.StampProtoTypeDNSCrypt:
282+
return NetProtocol(0), errors.New("DNSCrypt protocol not supported")
283+
case dnsstamps.StampProtoTypeDoQ:
284+
return NetProtocol(0), errors.New("DNS-over-QUIC protocol not supported")
285+
case dnsstamps.StampProtoTypeODoHTarget:
286+
return NetProtocol(0), errors.New("oblivious DoH target not supported")
287+
case dnsstamps.StampProtoTypeDNSCryptRelay:
288+
return NetProtocol(0), errors.New("DNSCrypt Relay not supported")
289+
case dnsstamps.StampProtoTypeODoHRelay:
290+
return NetProtocol(0), errors.New("ODoH Relay not supported")
291+
default:
292+
return NetProtocol(0), fmt.Errorf("unsupported DNS stamp protocol: %v", proto)
293+
}
294+
}

0 commit comments

Comments
 (0)