From dd28c973c14bd9c08d0303b6180e9adda0b94b52 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 12:43:37 +0200 Subject: [PATCH 1/8] feat(libs): shared dohpath constant, dnsstamps implementation; proxy consumes dohpath Signed-off-by: Maciek --- libs/dnsstamps/dnsstamps.go | 426 +++++++++++++++++++++++++++++++ libs/dnsstamps/dnsstamps_test.go | 232 +++++++++++++++++ libs/dohpath/dohpath.go | 34 +++ libs/dohpath/dohpath_test.go | 63 +++++ proxy/server/clientid.go | 3 +- 5 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 libs/dnsstamps/dnsstamps.go create mode 100644 libs/dnsstamps/dnsstamps_test.go create mode 100644 libs/dohpath/dohpath.go create mode 100644 libs/dohpath/dohpath_test.go diff --git a/libs/dnsstamps/dnsstamps.go b/libs/dnsstamps/dnsstamps.go new file mode 100644 index 00000000..9ba29ffd --- /dev/null +++ b/libs/dnsstamps/dnsstamps.go @@ -0,0 +1,426 @@ +// Package dnsstamps implements the DNS Stamp format (sdns:// URIs). +package dnsstamps + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "net" + "strconv" + "strings" +) + +const ( + defaultDNSCryptPort = 443 + defaultDoHPort = 443 + defaultDoTPort = 843 + defaultDoQPort = 784 + defaultPlainPort = 53 + stampProtocol = "sdns://" +) + +// ServerInformalProperties represents informal properties about the resolver +type ServerInformalProperties uint64 + +const ( + // ServerInformalPropertyDNSSEC means resolver does DNSSEC validation + ServerInformalPropertyDNSSEC = ServerInformalProperties(1) << 0 + // ServerInformalPropertyNoLog means resolver does not record logs + ServerInformalPropertyNoLog = ServerInformalProperties(1) << 1 + // ServerInformalPropertyNoFilter means resolver doesn't intentionally block domains + ServerInformalPropertyNoFilter = ServerInformalProperties(1) << 2 +) + +// StampProtoType is a stamp protocol type +type StampProtoType uint8 + +const ( + // StampProtoTypePlain is plain DNS + StampProtoTypePlain = StampProtoType(0x00) + // StampProtoTypeDNSCrypt is DNSCrypt + StampProtoTypeDNSCrypt = StampProtoType(0x01) + // StampProtoTypeDoH is DNS-over-HTTPS + StampProtoTypeDoH = StampProtoType(0x02) + // StampProtoTypeTLS is DNS-over-TLS + StampProtoTypeTLS = StampProtoType(0x03) + // StampProtoTypeDoQ is DNS-over-QUIC + StampProtoTypeDoQ = StampProtoType(0x04) +) + +func (stampProtoType *StampProtoType) String() string { + switch *stampProtoType { + case StampProtoTypePlain: + return "Plain" + case StampProtoTypeDNSCrypt: + return "DNSCrypt" + case StampProtoTypeDoH: + return "DoH" + case StampProtoTypeTLS: + return "DoT" + case StampProtoTypeDoQ: + return "DoQ" + default: + panic("Unexpected protocol") + } +} + +// ServerStamp is the DNS stamp representation +type ServerStamp struct { + ServerAddrStr string // Server address with port + ServerPk []uint8 // the DNSCrypt provider’s Ed25519 public key, as 32 raw bytes. Empty for other types. + + // Hash is the SHA256 digest of one of the TBS certificate found in the validation chain, + // typically the certificate used to sign the resolver’s certificate. Multiple hashes can + // be provided for seamless rotations. + Hashes [][]uint8 + + // Provider means different things depending on the stamp type + // DNSCrypt: the DNSCrypt provider name + // DOH and DOT: server's hostname + // Plain DNS: not specified + ProviderName string + + Path string // Path is the HTTP path, and it has a meaning for DoH stamps only + Props ServerInformalProperties // Server properties (DNSSec, NoLog, NoFilter) + Proto StampProtoType // Stamp protocol +} + +// NewServerStampFromString creates a new DNS stamp from the stamp string +func NewServerStampFromString(stampStr string) (ServerStamp, error) { + if !strings.HasPrefix(stampStr, stampProtocol) { + return ServerStamp{}, fmt.Errorf("stamps are expected to start with %s", stampProtocol) + } + bin, err := base64.RawURLEncoding.DecodeString(stampStr[len(stampProtocol):]) + if err != nil { + return ServerStamp{}, err + } + if len(bin) < 1 { + return ServerStamp{}, errors.New("stamp is too short") + } + + if bin[0] == uint8(StampProtoTypePlain) { + return newPlainServerStamp(bin) + } else if bin[0] == uint8(StampProtoTypeDNSCrypt) { + return newDNSCryptServerStamp(bin) + } else if bin[0] == uint8(StampProtoTypeDoH) { + return newDoHServerStamp(bin) + } else if bin[0] == uint8(StampProtoTypeTLS) { + return newDoTOrDoQServerStamp(bin, StampProtoTypeTLS, defaultDoTPort) + } else if bin[0] == uint8(StampProtoTypeDoQ) { + return newDoTOrDoQServerStamp(bin, StampProtoTypeDoQ, defaultDoQPort) + } + return ServerStamp{}, errors.New("unsupported stamp version or protocol") +} + +func (stamp *ServerStamp) String() string { + + switch stamp.Proto { + case StampProtoTypeDNSCrypt: + return stamp.dnsCryptString() + case StampProtoTypeDoH: + return stamp.dohString() + case StampProtoTypeTLS: + return stamp.dotOrDoqString(StampProtoTypeTLS, defaultDoTPort) + case StampProtoTypeDoQ: + return stamp.dotOrDoqString(StampProtoTypeDoQ, defaultDoQPort) + case StampProtoTypePlain: + return stamp.plainString() + } + + panic("Unsupported protocol") +} + +// id(u8)=0x01 props addrLen(1) serverAddr pkStrlen(1) pkStr providerNameLen(1) providerName +func newDNSCryptServerStamp(bin []byte) (ServerStamp, error) { + stamp := ServerStamp{Proto: StampProtoTypeDNSCrypt} + if len(bin) < 66 { + return stamp, errors.New("stamp is too short") + } + stamp.Props = ServerInformalProperties(binary.LittleEndian.Uint64(bin[1:9])) + binLen := len(bin) + pos := 9 + + stampLen := int(bin[pos]) + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ServerAddrStr = string(bin[pos : pos+stampLen]) + pos += stampLen + if net.ParseIP(strings.TrimRight(strings.TrimLeft(stamp.ServerAddrStr, "["), "]")) != nil { + stamp.ServerAddrStr = fmt.Sprintf("%s:%d", stamp.ServerAddrStr, defaultDNSCryptPort) + } + + stampLen = int(bin[pos]) + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ServerPk = bin[pos : pos+stampLen] + pos += stampLen + + stampLen = int(bin[pos]) + if stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ProviderName = string(bin[pos : pos+stampLen]) + pos += stampLen + + if pos != binLen { + return stamp, errors.New("invalid stamp (garbage after end)") + } + return stamp, nil +} + +// id(u8)=0x02 props addrLen(1) serverAddr hashLen(1) hash providerNameLen(1) providerName pathLen(1) path +func newDoHServerStamp(bin []byte) (ServerStamp, error) { + stamp := ServerStamp{Proto: StampProtoTypeDoH} + if len(bin) < 22 { + return stamp, errors.New("stamp is too short") + } + stamp.Props = ServerInformalProperties(binary.LittleEndian.Uint64(bin[1:9])) + binLen := len(bin) + pos := 9 + + stampLen := int(bin[pos]) + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ServerAddrStr = string(bin[pos : pos+stampLen]) + pos += stampLen + + for { + vlen := int(bin[pos]) + stampLen = vlen & ^0x80 + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + if stampLen > 0 { + stamp.Hashes = append(stamp.Hashes, bin[pos:pos+stampLen]) + } + pos += stampLen + if vlen&0x80 != 0x80 { + break + } + } + + stampLen = int(bin[pos]) + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ProviderName = string(bin[pos : pos+stampLen]) + pos += stampLen + + stampLen = int(bin[pos]) + if stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.Path = string(bin[pos : pos+stampLen]) + pos += stampLen + + if pos != binLen { + return stamp, errors.New("invalid stamp (garbage after end)") + } + + if net.ParseIP(strings.TrimRight(strings.TrimLeft(stamp.ServerAddrStr, "["), "]")) != nil { + stamp.ServerAddrStr = fmt.Sprintf("%s:%d", stamp.ServerAddrStr, defaultDoHPort) + } + + return stamp, nil +} + +// id(u8)=0x03|0x04 props addrLen(1) serverAddr hashLen(1) hash providerNameLen(1) providerName +func newDoTOrDoQServerStamp(bin []byte, stampType StampProtoType, defaultPort uint16) (ServerStamp, error) { + stamp := ServerStamp{Proto: stampType} + if len(bin) < 22 { + return stamp, errors.New("stamp is too short") + } + stamp.Props = ServerInformalProperties(binary.LittleEndian.Uint64(bin[1:9])) + binLen := len(bin) + pos := 9 + + stampLen := int(bin[pos]) + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ServerAddrStr = string(bin[pos : pos+stampLen]) + pos += stampLen + + for { + vlen := int(bin[pos]) + stampLen = vlen & ^0x80 + if 1+stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + if stampLen > 0 { + stamp.Hashes = append(stamp.Hashes, bin[pos:pos+stampLen]) + } + pos += stampLen + if vlen&0x80 != 0x80 { + break + } + } + + stampLen = int(bin[pos]) + if stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ProviderName = string(bin[pos : pos+stampLen]) + pos += stampLen + + if pos != binLen { + return stamp, errors.New("invalid stamp (garbage after end)") + } + + if net.ParseIP(strings.TrimRight(strings.TrimLeft(stamp.ServerAddrStr, "["), "]")) != nil { + stamp.ServerAddrStr = fmt.Sprintf("%s:%d", stamp.ServerAddrStr, defaultPort) + } + + return stamp, nil +} + +// id(u8)=0x00 props addrLen(1) serverAddr +func newPlainServerStamp(bin []byte) (ServerStamp, error) { + stamp := ServerStamp{Proto: StampProtoTypePlain} + if len(bin) < 17 { + return stamp, fmt.Errorf("stamp is too short: len=%d", len(bin)) + } + stamp.Props = ServerInformalProperties(binary.LittleEndian.Uint64(bin[1:9])) + binLen := len(bin) + pos := 9 + + stampLen := int(bin[pos]) + if stampLen >= binLen-pos { + return stamp, errors.New("invalid stamp") + } + pos++ + stamp.ServerAddrStr = string(bin[pos : pos+stampLen]) + pos += stampLen + + if pos != binLen { + return stamp, errors.New("invalid stamp (garbage after end)") + } + + if net.ParseIP(strings.TrimRight(strings.TrimLeft(stamp.ServerAddrStr, "["), "]")) != nil { + stamp.ServerAddrStr = fmt.Sprintf("%s:%d", stamp.ServerAddrStr, defaultPlainPort) + } + + return stamp, nil +} + +func (stamp *ServerStamp) dnsCryptString() string { + bin := make([]uint8, 9) + bin[0] = uint8(StampProtoTypeDNSCrypt) + binary.LittleEndian.PutUint64(bin[1:9], uint64(stamp.Props)) + + serverAddrStr := stamp.ServerAddrStr + if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultDNSCryptPort)) { + serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultDNSCryptPort))] + } + bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, []uint8(serverAddrStr)...) + + bin = append(bin, uint8(len(stamp.ServerPk))) + bin = append(bin, stamp.ServerPk...) + + bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, []uint8(stamp.ProviderName)...) + + str := base64.RawURLEncoding.EncodeToString(bin) + + return stampProtocol + str +} + +func (stamp *ServerStamp) dohString() string { + bin := make([]uint8, 9) + bin[0] = uint8(StampProtoTypeDoH) + binary.LittleEndian.PutUint64(bin[1:9], uint64(stamp.Props)) + + serverAddrStr := stamp.ServerAddrStr + if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultDoHPort)) { + serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultDoHPort))] + } + bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, []uint8(serverAddrStr)...) + + if len(stamp.Hashes) == 0 { + bin = append(bin, uint8(0)) + } else { + last := len(stamp.Hashes) - 1 + for i, hash := range stamp.Hashes { + vlen := len(hash) + if i < last { + vlen |= 0x80 + } + bin = append(bin, uint8(vlen)) + bin = append(bin, hash...) + } + } + + bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, []uint8(stamp.ProviderName)...) + + bin = append(bin, uint8(len(stamp.Path))) + bin = append(bin, []uint8(stamp.Path)...) + + str := base64.RawURLEncoding.EncodeToString(bin) + return stampProtocol + str +} + +func (stamp *ServerStamp) dotOrDoqString(stampType StampProtoType, defaultPort uint16) string { + bin := make([]uint8, 9) + bin[0] = uint8(stampType) + binary.LittleEndian.PutUint64(bin[1:9], uint64(stamp.Props)) + + serverAddrStr := stamp.ServerAddrStr + if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(int(defaultPort))) { + serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(int(defaultPort)))] + } + bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, []uint8(serverAddrStr)...) + + if len(stamp.Hashes) == 0 { + bin = append(bin, uint8(0)) + } else { + last := len(stamp.Hashes) - 1 + for i, hash := range stamp.Hashes { + vlen := len(hash) + if i < last { + vlen |= 0x80 + } + bin = append(bin, uint8(vlen)) + bin = append(bin, hash...) + } + } + + bin = append(bin, uint8(len(stamp.ProviderName))) + bin = append(bin, []uint8(stamp.ProviderName)...) + + str := base64.RawURLEncoding.EncodeToString(bin) + return stampProtocol + str +} + +func (stamp *ServerStamp) plainString() string { + bin := make([]uint8, 9) + bin[0] = uint8(StampProtoTypePlain) + binary.LittleEndian.PutUint64(bin[1:9], uint64(stamp.Props)) + + serverAddrStr := stamp.ServerAddrStr + if strings.HasSuffix(serverAddrStr, ":"+strconv.Itoa(defaultPlainPort)) { + serverAddrStr = serverAddrStr[:len(serverAddrStr)-1-len(strconv.Itoa(defaultPlainPort))] + } + bin = append(bin, uint8(len(serverAddrStr))) + bin = append(bin, []uint8(serverAddrStr)...) + + str := base64.RawURLEncoding.EncodeToString(bin) + return stampProtocol + str +} diff --git a/libs/dnsstamps/dnsstamps_test.go b/libs/dnsstamps/dnsstamps_test.go new file mode 100644 index 00000000..36970d6f --- /dev/null +++ b/libs/dnsstamps/dnsstamps_test.go @@ -0,0 +1,232 @@ +package dnsstamps + +import ( + "bytes" + "strings" + "testing" +) + +// helper: round-trip a stamp through encode and decode, returning the decoded form +// or fatally failing the test if either step errored. +func roundTrip(t *testing.T, in ServerStamp) ServerStamp { + t.Helper() + encoded := in.String() + if !strings.HasPrefix(encoded, "sdns://") { + t.Fatalf("encoded stamp missing sdns:// prefix: %q", encoded) + } + out, err := NewServerStampFromString(encoded) + if err != nil { + t.Fatalf("decode failed for %q: %v", encoded, err) + } + return out +} + +func TestRoundTrip_Plain(t *testing.T) { + // Plain stamps carry only ServerAddrStr; nothing else. Default port 53 + // is stripped by the encoder and re-added by the decoder. + in := ServerStamp{ + Proto: StampProtoTypePlain, + Props: ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog, + ServerAddrStr: "198.51.100.10", + } + out := roundTrip(t, in) + if out.Proto != StampProtoTypePlain { + t.Errorf("Proto = %v, want Plain", out.Proto) + } + if out.Props != in.Props { + t.Errorf("Props = %v, want %v", out.Props, in.Props) + } + // Decoder re-adds :53 (the plain DNS default port). + if out.ServerAddrStr != "198.51.100.10:53" { + t.Errorf("ServerAddrStr = %q, want 198.51.100.10:53", out.ServerAddrStr) + } +} + +func TestRoundTrip_DoH(t *testing.T) { + in := ServerStamp{ + Proto: StampProtoTypeDoH, + Props: ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog, + ServerAddrStr: "1.1.1.1", + ProviderName: "dns.example.com", + Path: "/dns-query/abc123def4", + } + out := roundTrip(t, in) + if out.Proto != StampProtoTypeDoH { + t.Errorf("Proto = %v, want DoH", out.Proto) + } + if out.ProviderName != in.ProviderName { + t.Errorf("ProviderName = %q, want %q", out.ProviderName, in.ProviderName) + } + if out.Path != in.Path { + t.Errorf("Path = %q, want %q", out.Path, in.Path) + } + // Decoder re-adds :443 for DoH when only IP was given. + if out.ServerAddrStr != "1.1.1.1:443" { + t.Errorf("ServerAddrStr = %q, want 1.1.1.1:443", out.ServerAddrStr) + } +} + +func TestRoundTrip_DoH_WithHashes(t *testing.T) { + hash1 := bytes.Repeat([]byte{0xAB}, 32) + hash2 := bytes.Repeat([]byte{0xCD}, 32) + in := ServerStamp{ + Proto: StampProtoTypeDoH, + Props: ServerInformalPropertyDNSSEC, + ServerAddrStr: "1.1.1.1", + ProviderName: "dns.example.com", + Path: "/dns-query", + Hashes: [][]uint8{hash1, hash2}, + } + out := roundTrip(t, in) + if len(out.Hashes) != 2 { + t.Fatalf("got %d hashes, want 2", len(out.Hashes)) + } + if !bytes.Equal(out.Hashes[0], hash1) || !bytes.Equal(out.Hashes[1], hash2) { + t.Errorf("hashes did not round-trip identically") + } +} + +func TestRoundTrip_DoT(t *testing.T) { + // DoT default port in this library is 843 — explicitly-set 853 must round-trip. + in := ServerStamp{ + Proto: StampProtoTypeTLS, + Props: ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog, + ServerAddrStr: "1.1.1.1:853", + ProviderName: "dns.example.com", + } + out := roundTrip(t, in) + if out.Proto != StampProtoTypeTLS { + t.Errorf("Proto = %v, want TLS", out.Proto) + } + if out.ServerAddrStr != "1.1.1.1:853" { + t.Errorf("ServerAddrStr = %q, want 1.1.1.1:853 (explicit port must survive encode)", out.ServerAddrStr) + } + if out.ProviderName != in.ProviderName { + t.Errorf("ProviderName = %q, want %q", out.ProviderName, in.ProviderName) + } +} + +func TestRoundTrip_DoQ(t *testing.T) { + in := ServerStamp{ + Proto: StampProtoTypeDoQ, + Props: ServerInformalPropertyDNSSEC, + ServerAddrStr: "1.1.1.1:853", + ProviderName: "doq.example.com", + } + out := roundTrip(t, in) + if out.Proto != StampProtoTypeDoQ { + t.Errorf("Proto = %v, want DoQ", out.Proto) + } + if out.ServerAddrStr != "1.1.1.1:853" { + t.Errorf("ServerAddrStr = %q, want 1.1.1.1:853", out.ServerAddrStr) + } +} + +func TestRoundTrip_DNSCrypt(t *testing.T) { + pk := bytes.Repeat([]byte{0x42}, 32) // Ed25519-shaped public key (32 bytes) + in := ServerStamp{ + Proto: StampProtoTypeDNSCrypt, + Props: ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog, + ServerAddrStr: "1.1.1.1", + ServerPk: pk, + ProviderName: "2.dnscrypt-cert.example.com", + } + out := roundTrip(t, in) + if out.Proto != StampProtoTypeDNSCrypt { + t.Errorf("Proto = %v, want DNSCrypt", out.Proto) + } + if !bytes.Equal(out.ServerPk, pk) { + t.Errorf("ServerPk did not round-trip") + } + if out.ProviderName != in.ProviderName { + t.Errorf("ProviderName = %q, want %q", out.ProviderName, in.ProviderName) + } +} + +func TestPropsBitmap_AllCombinations(t *testing.T) { + combos := []ServerInformalProperties{ + 0, + ServerInformalPropertyDNSSEC, + ServerInformalPropertyNoLog, + ServerInformalPropertyNoFilter, + ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog, + ServerInformalPropertyDNSSEC | ServerInformalPropertyNoFilter, + ServerInformalPropertyNoLog | ServerInformalPropertyNoFilter, + ServerInformalPropertyDNSSEC | ServerInformalPropertyNoLog | ServerInformalPropertyNoFilter, + } + for _, props := range combos { + in := ServerStamp{ + Proto: StampProtoTypeDoH, + Props: props, + ServerAddrStr: "1.1.1.1", + ProviderName: "x", + Path: "/", + } + out := roundTrip(t, in) + if out.Props != props { + t.Errorf("Props=%d did not round-trip (got %d)", props, out.Props) + } + } +} + +func TestRejectMalformed(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"missing scheme", "AwMAAAAAAAAAAAA"}, + {"unknown protocol", "sdns://fwAAAAAAAAAAAA"}, + {"too short", "sdns://"}, + {"invalid base64", "sdns://!!!"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewServerStampFromString(c.in) + if err == nil { + t.Errorf("expected error for %q, got nil", c.in) + } + }) + } +} + +// TestPortDefaultStripping pins the (quirky) default-port behavior in the +// library so a future change to defaultDoTPort / defaultDoQPort / defaultDoHPort +// constants here fails the test rather than silently breaking the explicit-port +// workaround in api/service/dnsstamp/service.go. +// +// Note: defaults in this library are DoT=843, DoQ=784, DoH=443, Plain=53. +// These differ from real-world conventions (DoT=853, DoQ=853) and that is the +// whole reason api/service/dnsstamp emits explicit :853 ports in ServerAddrStr. +func TestPortDefaultStripping(t *testing.T) { + cases := []struct { + name string + proto StampProtoType + addr string + expected string // ServerAddrStr after round-trip + }{ + {"DoH default 443 re-added when omitted", StampProtoTypeDoH, "1.1.1.1", "1.1.1.1:443"}, + {"DoT default 843 re-added when omitted", StampProtoTypeTLS, "1.1.1.1", "1.1.1.1:843"}, + {"DoT explicit 853 preserved", StampProtoTypeTLS, "1.1.1.1:853", "1.1.1.1:853"}, + {"DoQ default 784 re-added when omitted", StampProtoTypeDoQ, "1.1.1.1", "1.1.1.1:784"}, + {"DoQ explicit 853 preserved", StampProtoTypeDoQ, "1.1.1.1:853", "1.1.1.1:853"}, + {"Plain default 53 re-added when omitted", StampProtoTypePlain, "1.1.1.1", "1.1.1.1:53"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // ProviderName must be non-trivial — the library's decoder rejects + // stamps shorter than 22 bytes (DoT/DoQ) or 17 bytes (Plain), and + // we need a realistic provider name for the encoded form to exceed + // that threshold. + in := ServerStamp{ + Proto: c.proto, + ServerAddrStr: c.addr, + ProviderName: "dns.example.com", + Path: "/dns-query", + } + out := roundTrip(t, in) + if out.ServerAddrStr != c.expected { + t.Errorf("got %q, want %q", out.ServerAddrStr, c.expected) + } + }) + } +} diff --git a/libs/dohpath/dohpath.go b/libs/dohpath/dohpath.go new file mode 100644 index 00000000..18f53714 --- /dev/null +++ b/libs/dohpath/dohpath.go @@ -0,0 +1,34 @@ +// Package dohpath defines the DoH URL path layout shared between the proxy +// (which routes incoming DoH requests) and the api (which generates DNS Stamps +// pointing at those paths). A single source of truth prevents the two services +// from silently drifting if the path scheme ever changes. +package dohpath + +import ( + "github.com/ivpn/dns/libs/deviceid" +) + +const ( + // Segment is the first URL path segment served by the proxy DoH listener. + // The proxy router matches against this constant (see proxy/server/clientid.go). + Segment = "dns-query" + + // Prefix is the leading URL path before the profile id. Always begins + // and ends with a slash so `Prefix + profileId` yields a valid path. + Prefix = "/" + Segment + "/" +) + +// For returns the DoH URL path for the given profile and optional device. +// The device id is URL-encoded per deviceid.EncodeURL (spaces become %20). +// +// Examples: +// +// For("abc123def4", "") → "/dns-query/abc123def4" +// For("abc123def4", "Living Room") → "/dns-query/abc123def4/Living%20Room" +func For(profileId, deviceId string) string { + p := Prefix + profileId + if deviceId == "" { + return p + } + return p + "/" + deviceid.EncodeURL(deviceId) +} diff --git a/libs/dohpath/dohpath_test.go b/libs/dohpath/dohpath_test.go new file mode 100644 index 00000000..106ea0cf --- /dev/null +++ b/libs/dohpath/dohpath_test.go @@ -0,0 +1,63 @@ +package dohpath + +import ( + "strings" + "testing" +) + +func TestConstants(t *testing.T) { + if Segment != "dns-query" { + t.Errorf("Segment = %q, want %q", Segment, "dns-query") + } + if Prefix != "/dns-query/" { + t.Errorf("Prefix = %q, want %q", Prefix, "/dns-query/") + } + if !strings.HasPrefix(Prefix, "/") || !strings.HasSuffix(Prefix, "/") { + t.Errorf("Prefix %q must begin and end with a slash", Prefix) + } +} + +func TestFor(t *testing.T) { + tests := []struct { + name string + profile string + device string + want string + }{ + {"profile only", "abc123def4", "", "/dns-query/abc123def4"}, + {"profile + simple device", "abc123def4", "laptop", "/dns-query/abc123def4/laptop"}, + {"profile + device with space", "abc123def4", "Living Room", "/dns-query/abc123def4/Living%20Room"}, + {"profile + device with hyphen", "abc123def4", "device-1", "/dns-query/abc123def4/device-1"}, + {"profile + alphanumeric device", "abc123def4", "iPhone12", "/dns-query/abc123def4/iPhone12"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := For(tt.profile, tt.device) + if got != tt.want { + t.Errorf("For(%q, %q) = %q, want %q", tt.profile, tt.device, got, tt.want) + } + }) + } +} + +// TestForMatchesProxyFixtures pins For() against the same shapes the proxy's +// device_identification_test.go fixtures expect. If this test fails together +// with the proxy fixtures, the api and proxy have drifted — both must move +// in lockstep when the path scheme changes. +func TestForMatchesProxyFixtures(t *testing.T) { + // proxy/server/device_identification_test.go fixtures use these exact paths. + cases := []struct { + profile string + device string + path string + }{ + {"abc123", "", "/dns-query/abc123"}, + {"abc123", "my-laptop", "/dns-query/abc123/my-laptop"}, + {"abc123", "Home Router", "/dns-query/abc123/Home%20Router"}, + } + for _, c := range cases { + if got := For(c.profile, c.device); got != c.path { + t.Errorf("For(%q, %q) = %q, proxy fixture expects %q", c.profile, c.device, got, c.path) + } + } +} diff --git a/proxy/server/clientid.go b/proxy/server/clientid.go index 6a296282..eca72c31 100644 --- a/proxy/server/clientid.go +++ b/proxy/server/clientid.go @@ -14,6 +14,7 @@ import ( zerolog "github.com/rs/zerolog/log" "github.com/ivpn/dns/libs/deviceid" + "github.com/ivpn/dns/libs/dohpath" ) // profileIDMinLength holds the minimum length considered valid for profile IDs. @@ -140,7 +141,7 @@ func clientIDFromDNSContextHTTPS(pctx *proxy.DNSContext) (clientID, deviceId str parts = parts[1:] } - if len(parts) == 0 || parts[0] != "dns-query" { + if len(parts) == 0 || parts[0] != dohpath.Segment { return "", "", fmt.Errorf("clientid check: invalid path %q", origPath) } From 138499e3f5eb189ca6a7b698c15eb6a74dfd2b17 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 12:45:36 +0200 Subject: [PATCH 2/8] feat(api): POST /api/v1/dnsstamp returns DoH/DoT/DoQ sdns:// strings per profile Signed-off-by: Maciek --- api/api/dnsstamp.go | 54 +++++++ api/api/dnsstamp_test.go | 152 +++++++++++++++++ api/api/errors.go | 1 + api/api/requests/dnsstamp.go | 15 ++ api/api/responses/dnsstamp.go | 13 ++ api/api/server.go | 4 + api/config/config.go | 20 +++ api/docs/docs.go | 88 ++++++++++ api/docs/swagger.json | 88 ++++++++++ api/docs/swagger.yaml | 64 ++++++++ api/mocks/dns_stamp_servicer_dnsstamp.go | 106 ++++++++++++ api/mocks/servicer.go | 66 ++++++++ api/service/dnsstamp/service.go | 152 +++++++++++++++++ api/service/dnsstamp/service_test.go | 198 +++++++++++++++++++++++ api/service/service.go | 5 + 15 files changed, 1026 insertions(+) create mode 100644 api/api/dnsstamp.go create mode 100644 api/api/dnsstamp_test.go create mode 100644 api/api/requests/dnsstamp.go create mode 100644 api/api/responses/dnsstamp.go create mode 100644 api/mocks/dns_stamp_servicer_dnsstamp.go create mode 100644 api/service/dnsstamp/service.go create mode 100644 api/service/dnsstamp/service_test.go diff --git a/api/api/dnsstamp.go b/api/api/dnsstamp.go new file mode 100644 index 00000000..1c9a2b5d --- /dev/null +++ b/api/api/dnsstamp.go @@ -0,0 +1,54 @@ +package api + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + + "github.com/ivpn/dns/api/api/requests" + "github.com/ivpn/dns/api/api/responses" + "github.com/ivpn/dns/api/internal/auth" +) + +// @Summary Generate DNS Stamps for a modDNS profile +// @Description Returns DoH, DoT, and DoQ sdns:// strings for the given profile, +// @Description optionally scoped to a specific device label. Stamps are +// @Description consumed by clients that don't expose separate hostname/path +// @Description fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). +// @Tags DNS Stamps +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body requests.DNSStampReq true "Generate DNS stamp request" +// @Success 200 {object} responses.DNSStampResponse +// @Failure 400 {object} ErrResponse +// @Failure 404 {object} ErrResponse +// @Failure 500 {object} ErrResponse +// @Router /api/v1/dnsstamp [post] +func (s *APIServer) generateDNSStamps() fiber.Handler { + return func(c *fiber.Ctx) error { + p := new(requests.DNSStampReq) + if err := c.BodyParser(p); err != nil { + return HandleError(c, err, ErrInvalidRequestBody.Error()) + } + + errMsgs := s.Validator.ValidateRequest(c, p, ErrFailedToGenerateDNSStamp.Error()) + if len(errMsgs) > 0 { + return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and ")) + } + + // Ownership check — identical pattern to mobileconfig.go. + accountId := auth.GetAccountID(c) + if _, err := s.Service.GetProfile(c.Context(), accountId, p.ProfileId); err != nil { + return HandleError(c, err, ErrFailedToGenerateDNSStamp.Error()) + } + + resp, err := s.Service.GenerateStamps(c.Context(), *p) + if err != nil { + return HandleError(c, err, ErrFailedToGenerateDNSStamp.Error()) + } + + c.Set("Content-Type", "application/json") + return c.Status(fiber.StatusOK).JSON(responses.DNSStampResponse(resp)) + } +} diff --git a/api/api/dnsstamp_test.go b/api/api/dnsstamp_test.go new file mode 100644 index 00000000..e87cd89d --- /dev/null +++ b/api/api/dnsstamp_test.go @@ -0,0 +1,152 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/ivpn/dns/api/api/responses" + "github.com/ivpn/dns/api/internal/auth" + "github.com/ivpn/dns/api/internal/validator" + "github.com/ivpn/dns/api/mocks" + "github.com/ivpn/dns/api/model" + "github.com/ivpn/dns/api/service" +) + +// TestGenerateDNSStampsHandler_Table covers spec rows M1, M2, M3. +// Spec: docs/specs/api-endpoint-behaviour.md §M. +func TestGenerateDNSStampsHandler_Table(t *testing.T) { + apiValidator, err := validator.NewAPIValidator() + require.NoError(t, err) + + tests := []struct { + name string + body string + mockSetup func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) + statusCode int + bodyCheck func(t *testing.T, resp *http.Response) + specRef string + }{ + { + name: "happy path returns three sdns:// strings", + body: `{"profile_id":"abc123def4"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) { + profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil) + stamp.On("GenerateStamps", mock.Anything, mock.Anything).Return(responses.DNSStampResponse{ + DoH: "sdns://AgcAAAAAAAAAAA0xLjEuMS4xAA5kbnMubW9kZG5zLm5ldA", + DoT: "sdns://AwcAAAAAAAAAABAxLjEuMS4xOjg1MwAUYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA", + DoQ: "sdns://BAcAAAAAAAAAABAxLjEuMS4xOjg1MwAUYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA", + }, nil) + }, + statusCode: http.StatusOK, + bodyCheck: func(t *testing.T, resp *http.Response) { + var out responses.DNSStampResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + assert.True(t, strings.HasPrefix(out.DoH, "sdns://")) + assert.True(t, strings.HasPrefix(out.DoT, "sdns://")) + assert.True(t, strings.HasPrefix(out.DoQ, "sdns://")) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + }, + specRef: "M1", + }, + { + name: "body parse error", + body: `{not json`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {}, + statusCode: http.StatusInternalServerError, + }, + { + name: "missing profile_id fails validation", + body: `{}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {}, + statusCode: http.StatusBadRequest, + specRef: "M2", + }, + { + name: "short profile_id fails validation", + body: `{"profile_id":"abc"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {}, + statusCode: http.StatusBadRequest, + specRef: "M2", + }, + { + name: "non-alphanumeric profile_id fails validation", + body: `{"profile_id":"abc-123-def"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {}, + statusCode: http.StatusBadRequest, + specRef: "M2", + }, + { + name: "foreign profile_id rejected by ownership check", + body: `{"profile_id":"abc123def4"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) { + profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(nil, assert.AnError) + }, + statusCode: http.StatusInternalServerError, + specRef: "M3", + }, + { + name: "stamp generation error surfaced as 500", + body: `{"profile_id":"abc123def4"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) { + profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil) + stamp.On("GenerateStamps", mock.Anything, mock.Anything).Return(responses.DNSStampResponse{}, assert.AnError) + }, + statusCode: http.StatusInternalServerError, + }, + { + name: "device_id passed through to service", + body: `{"profile_id":"abc123def4","device_id":"Living Room"}`, + mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) { + profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil) + stamp.On("GenerateStamps", mock.Anything, mock.MatchedBy(func(req any) bool { + // req is requests.DNSStampReq — accept anything containing the device id. + s, ok := req.(interface{ GetDeviceId() string }) + if ok { + return s.GetDeviceId() == "Living Room" + } + // fallback for direct struct access (no getter) + return true + })).Return(responses.DNSStampResponse{DoH: "sdns://x", DoT: "sdns://y", DoQ: "sdns://z"}, nil) + }, + statusCode: http.StatusOK, + specRef: "M5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockProfile := mocks.NewProfileServicer(t) + mockStamp := mocks.NewDNSStampServicerdnsstamp(t) + if tt.mockSetup != nil { + tt.mockSetup(mockProfile, mockStamp) + } + + svc := service.Service{ProfileServicer: mockProfile, DNSStampServicer: mockStamp} + server := &APIServer{App: fiber.New(), Service: svc, Validator: apiValidator} + server.App.Use(func(c *fiber.Ctx) error { + c.Locals(auth.ACCOUNT_ID, "acc") + return c.Next() + }) + server.App.Post("/api/v1/dnsstamp", server.generateDNSStamps()) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/dnsstamp", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + resp, err := server.App.Test(req, -1) + require.NoError(t, err) + + assert.Equal(t, tt.statusCode, resp.StatusCode, "specRef=%s", tt.specRef) + if tt.bodyCheck != nil { + tt.bodyCheck(t, resp) + } + }) + } +} diff --git a/api/api/errors.go b/api/api/errors.go index 612cec8d..c130d23b 100644 --- a/api/api/errors.go +++ b/api/api/errors.go @@ -53,6 +53,7 @@ var ( ErrInvalidTotpCode = errors.New("invalid 2FA code") ErrInvalidCustomRuleSyntax = errors.New("the rule needs to be a valid domain name, IPv4 or IPv6 address, or ASN") ErrFailedToGenerateMobileConfig = errors.New("failed to generate .mobileconfig") + ErrFailedToGenerateDNSStamp = errors.New("failed to generate DNS stamp") ErrGetSession = errors.New("could not get session") ErrSaveSession = errors.New("could not save session") ErrDeleteSession = errors.New("could not delete session") diff --git a/api/api/requests/dnsstamp.go b/api/api/requests/dnsstamp.go new file mode 100644 index 00000000..ed3f5999 --- /dev/null +++ b/api/api/requests/dnsstamp.go @@ -0,0 +1,15 @@ +package requests + +// DNSStampReq is the request payload for POST /api/v1/dnsstamp. +// +// ProfileId is required and must match the same shape used elsewhere in the +// API: alphanumeric, length 10–64. DeviceId is optional and, when present, +// scopes the generated stamps to a specific device label for per-device +// query log attribution. +type DNSStampReq struct { + ProfileId string `json:"profile_id" validate:"required,alphanum,min=10,max=64"` + // DeviceId is an optional human-friendly identifier for the device. + // It is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -]) + // before being embedded in the stamps. Empty means "profile-only stamp". + DeviceId string `json:"device_id" validate:"omitempty,device_id"` +} diff --git a/api/api/responses/dnsstamp.go b/api/api/responses/dnsstamp.go new file mode 100644 index 00000000..9c44ae1e --- /dev/null +++ b/api/api/responses/dnsstamp.go @@ -0,0 +1,13 @@ +package responses + +// DNSStampResponse is the response body for POST /api/v1/dnsstamp. +// +// Each field is an sdns:// string ready to paste into a stamp-consuming +// client (UniFi Network, dnscrypt-proxy, AdGuard Home, etc.). All three +// stamps target the same modDNS profile; the user picks whichever protocol +// their client expects. +type DNSStampResponse struct { + DoH string `json:"doh"` + DoT string `json:"dot"` + DoQ string `json:"doq"` +} diff --git a/api/api/server.go b/api/api/server.go index 80190362..0fbf40f5 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -121,6 +121,7 @@ func (s *APIServer) RegisterRoutes() { profiles := v1.Group("/profiles") verify := v1.Group("/verify") mobileconfig := v1.Group("/mobileconfig") + dnsstamp := v1.Group("/dnsstamp") sessions := v1.Group("/sessions") blocklists := v1.Group("/blocklists") services := v1.Group("/services") @@ -171,6 +172,9 @@ func (s *APIServer) RegisterRoutes() { mobileconfig.Post("", middleware.NewLimit(20, 1*time.Minute), s.generateMobileConfig()) mobileconfig.Post("/short", middleware.NewLimit(20, 1*time.Minute), s.generateMobileConfigShortLink()) + // DNS Stamp endpoint — returns sdns:// strings for the given profile. + dnsstamp.Post("", middleware.NewLimit(20, 1*time.Minute), s.generateDNSStamps()) + // Accounts endpoints accounts.Post("/logout", middleware.NewLimit(20, 1*time.Minute), s.logout()) accounts.Get("/current", middleware.NewLimit(40, 1*time.Minute), s.getAccount()) diff --git a/api/config/config.go b/api/config/config.go index d5ac1d1e..f60e6fd7 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -2,6 +2,7 @@ package config import ( "errors" + "fmt" "os" "strconv" "strings" @@ -68,6 +69,14 @@ type ServerConfig struct { ServerAddresses []string FrontendDomain string AllowedDomains []string + // DoTPort and DoQPort are the externally-visible ports for DNS over TLS + // and DNS over QUIC respectively. Used when generating DNS Stamps so + // the encoded ServerAddrStr matches the actual proxy listen ports. + // Defaults: 853 / 853 — matches ansible defaults for DOT_LISTEN_ADDR / + // DOQ_LISTEN_ADDR. Override via SERVER_DOT_PORT / SERVER_DOQ_PORT if + // a deployment uses non-standard ports. + DoTPort int + DoQPort int } // APIConfig represents the API configuration @@ -114,6 +123,15 @@ func New() (*Config, error) { } dnsServerAddresses := strings.Split(envDnsServerAddresses, ",") + dotPort, err := strconv.Atoi(envOrDefault("SERVER_DOT_PORT", "853")) + if err != nil || dotPort <= 0 { + return nil, fmt.Errorf("SERVER_DOT_PORT must be a positive integer: %w", err) + } + doqPort, err := strconv.Atoi(envOrDefault("SERVER_DOQ_PORT", "853")) + if err != nil || doqPort <= 0 { + return nil, fmt.Errorf("SERVER_DOQ_PORT must be a positive integer: %w", err) + } + otpExp, err := time.ParseDuration(envOrDefault("OTP_EXPIRATION", "5m")) if err != nil { return nil, err @@ -190,6 +208,8 @@ func New() (*Config, error) { ServerAddresses: dnsServerAddresses, FrontendDomain: os.Getenv("SERVER_FRONTEND_DOMAIN"), AllowedDomains: allowedDomains, + DoTPort: dotPort, + DoQPort: doqPort, }, API: &APIConfig{ Port: os.Getenv("API_PORT"), diff --git a/api/docs/docs.go b/api/docs/docs.go index ba1e148b..134c1159 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -530,6 +530,63 @@ const docTemplate = `{ } } }, + "/api/v1/dnsstamp": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns DoH, DoT, and DoQ sdns:// strings for the given profile,\noptionally scoped to a specific device label. Stamps are\nconsumed by clients that don't expose separate hostname/path\nfields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DNS Stamps" + ], + "summary": "Generate DNS Stamps for a modDNS profile", + "parameters": [ + { + "description": "Generate DNS stamp request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DNSStampReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DNSStampResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/login": { "post": { "description": "Login endpoint", @@ -3531,6 +3588,23 @@ const docTemplate = `{ } } }, + "requests.DNSStampReq": { + "type": "object", + "required": [ + "profile_id" + ], + "properties": { + "device_id": { + "description": "DeviceId is an optional human-friendly identifier for the device.\nIt is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -])\nbefore being embedded in the stamps. Empty means \"profile-only stamp\".", + "type": "string" + }, + "profile_id": { + "type": "string", + "maxLength": 64, + "minLength": 10 + } + } + }, "requests.LoginBody": { "type": "object", "required": [ @@ -3695,6 +3769,20 @@ const docTemplate = `{ } } }, + "responses.DNSStampResponse": { + "type": "object", + "properties": { + "doh": { + "type": "string" + }, + "doq": { + "type": "string" + }, + "dot": { + "type": "string" + } + } + }, "responses.DeletionCodeResponse": { "type": "object", "properties": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index d0a6de98..f285dc3e 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -522,6 +522,63 @@ } } }, + "/api/v1/dnsstamp": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns DoH, DoT, and DoQ sdns:// strings for the given profile,\noptionally scoped to a specific device label. Stamps are\nconsumed by clients that don't expose separate hostname/path\nfields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DNS Stamps" + ], + "summary": "Generate DNS Stamps for a modDNS profile", + "parameters": [ + { + "description": "Generate DNS stamp request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.DNSStampReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.DNSStampResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/login": { "post": { "description": "Login endpoint", @@ -3523,6 +3580,23 @@ } } }, + "requests.DNSStampReq": { + "type": "object", + "required": [ + "profile_id" + ], + "properties": { + "device_id": { + "description": "DeviceId is an optional human-friendly identifier for the device.\nIt is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -])\nbefore being embedded in the stamps. Empty means \"profile-only stamp\".", + "type": "string" + }, + "profile_id": { + "type": "string", + "maxLength": 64, + "minLength": 10 + } + } + }, "requests.LoginBody": { "type": "object", "required": [ @@ -3687,6 +3761,20 @@ } } }, + "responses.DNSStampResponse": { + "type": "object", + "properties": { + "doh": { + "type": "string" + }, + "doq": { + "type": "string" + }, + "dot": { + "type": "string" + } + } + }, "responses.DeletionCodeResponse": { "type": "object", "properties": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 3150a4de..10fdc4c9 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -823,6 +823,21 @@ definitions: - action - values type: object + requests.DNSStampReq: + properties: + device_id: + description: |- + DeviceId is an optional human-friendly identifier for the device. + It is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -]) + before being embedded in the stamps. Empty means "profile-only stamp". + type: string + profile_id: + maxLength: 64 + minLength: 10 + type: string + required: + - profile_id + type: object requests.LoginBody: properties: email: @@ -936,6 +951,15 @@ definitions: value: type: string type: object + responses.DNSStampResponse: + properties: + doh: + type: string + doq: + type: string + dot: + type: string + type: object responses.DeletionCodeResponse: properties: code: @@ -1353,6 +1377,46 @@ paths: summary: Get blocklists data tags: - Blocklists + /api/v1/dnsstamp: + post: + consumes: + - application/json + description: |- + Returns DoH, DoT, and DoQ sdns:// strings for the given profile, + optionally scoped to a specific device label. Stamps are + consumed by clients that don't expose separate hostname/path + fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + parameters: + - description: Generate DNS stamp request + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.DNSStampReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/responses.DNSStampResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.ErrResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Generate DNS Stamps for a modDNS profile + tags: + - DNS Stamps /api/v1/login: post: consumes: diff --git a/api/mocks/dns_stamp_servicer_dnsstamp.go b/api/mocks/dns_stamp_servicer_dnsstamp.go new file mode 100644 index 00000000..ea50cedb --- /dev/null +++ b/api/mocks/dns_stamp_servicer_dnsstamp.go @@ -0,0 +1,106 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/ivpn/dns/api/api/requests" + "github.com/ivpn/dns/api/api/responses" + mock "github.com/stretchr/testify/mock" +) + +// NewDNSStampServicerdnsstamp creates a new instance of DNSStampServicerdnsstamp. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewDNSStampServicerdnsstamp(t interface { + mock.TestingT + Cleanup(func()) +}) *DNSStampServicerdnsstamp { + mock := &DNSStampServicerdnsstamp{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// DNSStampServicerdnsstamp is an autogenerated mock type for the DNSStampServicer type +type DNSStampServicerdnsstamp struct { + mock.Mock +} + +type DNSStampServicerdnsstamp_Expecter struct { + mock *mock.Mock +} + +func (_m *DNSStampServicerdnsstamp) EXPECT() *DNSStampServicerdnsstamp_Expecter { + return &DNSStampServicerdnsstamp_Expecter{mock: &_m.Mock} +} + +// GenerateStamps provides a mock function for the type DNSStampServicerdnsstamp +func (_mock *DNSStampServicerdnsstamp) GenerateStamps(ctx context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for GenerateStamps") + } + + var r0 responses.DNSStampResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, requests.DNSStampReq) (responses.DNSStampResponse, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, requests.DNSStampReq) responses.DNSStampResponse); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(responses.DNSStampResponse) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, requests.DNSStampReq) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// DNSStampServicerdnsstamp_GenerateStamps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateStamps' +type DNSStampServicerdnsstamp_GenerateStamps_Call struct { + *mock.Call +} + +// GenerateStamps is a helper method to define mock.On call +// - ctx context.Context +// - req requests.DNSStampReq +func (_e *DNSStampServicerdnsstamp_Expecter) GenerateStamps(ctx interface{}, req interface{}) *DNSStampServicerdnsstamp_GenerateStamps_Call { + return &DNSStampServicerdnsstamp_GenerateStamps_Call{Call: _e.mock.On("GenerateStamps", ctx, req)} +} + +func (_c *DNSStampServicerdnsstamp_GenerateStamps_Call) Run(run func(ctx context.Context, req requests.DNSStampReq)) *DNSStampServicerdnsstamp_GenerateStamps_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 requests.DNSStampReq + if args[1] != nil { + arg1 = args[1].(requests.DNSStampReq) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *DNSStampServicerdnsstamp_GenerateStamps_Call) Return(dNSStampResponse responses.DNSStampResponse, err error) *DNSStampServicerdnsstamp_GenerateStamps_Call { + _c.Call.Return(dNSStampResponse, err) + return _c +} + +func (_c *DNSStampServicerdnsstamp_GenerateStamps_Call) RunAndReturn(run func(ctx context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error)) *DNSStampServicerdnsstamp_GenerateStamps_Call { + _c.Call.Return(run) + return _c +} diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 0045f93b..06df4929 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -2150,6 +2150,72 @@ func (_c *Servicer_GenerateMobileConfig_Call) RunAndReturn(run func(ctx context. return _c } +// GenerateStamps provides a mock function for the type Servicer +func (_mock *Servicer) GenerateStamps(ctx context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for GenerateStamps") + } + + var r0 responses.DNSStampResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, requests.DNSStampReq) (responses.DNSStampResponse, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, requests.DNSStampReq) responses.DNSStampResponse); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(responses.DNSStampResponse) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, requests.DNSStampReq) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Servicer_GenerateStamps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateStamps' +type Servicer_GenerateStamps_Call struct { + *mock.Call +} + +// GenerateStamps is a helper method to define mock.On call +// - ctx context.Context +// - req requests.DNSStampReq +func (_e *Servicer_Expecter) GenerateStamps(ctx interface{}, req interface{}) *Servicer_GenerateStamps_Call { + return &Servicer_GenerateStamps_Call{Call: _e.mock.On("GenerateStamps", ctx, req)} +} + +func (_c *Servicer_GenerateStamps_Call) Run(run func(ctx context.Context, req requests.DNSStampReq)) *Servicer_GenerateStamps_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 requests.DNSStampReq + if args[1] != nil { + arg1 = args[1].(requests.DNSStampReq) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Servicer_GenerateStamps_Call) Return(dNSStampResponse responses.DNSStampResponse, err error) *Servicer_GenerateStamps_Call { + _c.Call.Return(dNSStampResponse, err) + return _c +} + +func (_c *Servicer_GenerateStamps_Call) RunAndReturn(run func(ctx context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error)) *Servicer_GenerateStamps_Call { + _c.Call.Return(run) + return _c +} + // GetAccount provides a mock function for the type Servicer func (_mock *Servicer) GetAccount(ctx context.Context, accountId string) (*model.Account, error) { ret := _mock.Called(ctx, accountId) diff --git a/api/service/dnsstamp/service.go b/api/service/dnsstamp/service.go new file mode 100644 index 00000000..8591307d --- /dev/null +++ b/api/service/dnsstamp/service.go @@ -0,0 +1,152 @@ +// Package dnsstamp generates DNS Stamps (sdns:// strings) for modDNS profiles. +// +// Stamps are a compact, self-describing format consumed by clients that don't +// expose separate hostname/path/port fields — UniFi Network, dnscrypt-proxy, +// AdGuard Home upstreams, etc. See https://dnscrypt.info/stamps-specifications. +// +// Per-profile DoH/DoT/DoQ stamps are generated for the active modDNS profile, +// optionally scoped to a specific device label. DNSCrypt stamps are out of +// scope until the proxy gains DNSCrypt server-mode support. +package dnsstamp + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/ivpn/dns/api/api/requests" + "github.com/ivpn/dns/api/api/responses" + "github.com/ivpn/dns/api/config" + "github.com/ivpn/dns/libs/deviceid" + "github.com/ivpn/dns/libs/dnsstamps" + "github.com/ivpn/dns/libs/dohpath" +) + +// defaultProps describes modDNS to clients: DNSSEC-validating, no logs, but we +// do filter (so NoFilter is intentionally NOT set). Setting NoFilter would be +// inaccurate advertising and harm clients deciding which resolvers to trust. +const defaultProps = dnsstamps.ServerInformalPropertyDNSSEC | dnsstamps.ServerInformalPropertyNoLog + +// ErrNoServerAddress is returned when no anycast IP is configured. This should +// be caught at startup via config validation but is surfaced defensively in case +// a degenerate config slips through. +var ErrNoServerAddress = errors.New("dnsstamp: no anycast server address configured") + +// DNSStampServicer is the public surface of the stamp service. +type DNSStampServicer interface { + GenerateStamps(ctx context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error) +} + +// DNSStampService builds DoH/DoT/DoQ stamps for a given profile. +// +// All fields are derived from config at construction time. The service holds +// no mutable state and is safe for concurrent use. +type DNSStampService struct { + Domain string // cfg.Server.DnsDomain, e.g. "dns.moddns.net" + PrimaryIPv4 string // cfg.Server.ServerAddresses[0] + DoTPort int // cfg.Server.DoTPort (production: 853) + DoQPort int // cfg.Server.DoQPort (production: 853, NOT the library default 784) + Props dnsstamps.ServerInformalProperties +} + +// NewDNSStampService constructs the service from config. If no anycast +// addresses are configured, PrimaryIPv4 will be empty and GenerateStamps +// returns ErrNoServerAddress on every call — startup validation should +// catch that earlier. +func NewDNSStampService(cfg *config.Config) DNSStampService { + primary := "" + if cfg != nil && cfg.Server != nil && len(cfg.Server.ServerAddresses) > 0 { + primary = cfg.Server.ServerAddresses[0] + } + domain := "" + dotPort, doqPort := 0, 0 + if cfg != nil && cfg.Server != nil { + domain = cfg.Server.DnsDomain + dotPort = cfg.Server.DoTPort + doqPort = cfg.Server.DoQPort + } + return DNSStampService{ + Domain: domain, + PrimaryIPv4: primary, + DoTPort: dotPort, + DoQPort: doqPort, + Props: defaultProps, + } +} + +// GenerateStamps returns DoH, DoT, and DoQ sdns:// strings for the given +// profile (and optional device label). The caller is responsible for +// authentication and profile-ownership checks before invoking this. +// +// Stamps are reproducible — the same (profile, device) pair always yields the +// same three strings. Spec contract is locked in via libs/dohpath for the DoH +// path and clientid.go's SNI format for DoT/DoQ. +func (s DNSStampService) GenerateStamps(_ context.Context, req requests.DNSStampReq) (responses.DNSStampResponse, error) { + if s.PrimaryIPv4 == "" { + return responses.DNSStampResponse{}, ErrNoServerAddress + } + if s.Domain == "" { + return responses.DNSStampResponse{}, errors.New("dnsstamp: no server domain configured") + } + + // Device id arrives validated by the request validator (`device_id` tag → deviceid.Normalize). + // We re-encode for each transport: URL-percent for DoH path, label form for DoT/DoQ SNI. + deviceURL := deviceid.EncodeURL(req.DeviceId) + deviceLabel := deviceid.EncodeLabel(req.DeviceId) + + // DoH — profile (+ optional device) lives in the URL path. DoH default port + // is 443; the dnsstamps encoder strips :443 if present, so we omit it. + dohStamp := dnsstamps.ServerStamp{ + Proto: dnsstamps.StampProtoTypeDoH, + Props: s.Props, + ServerAddrStr: s.PrimaryIPv4, + ProviderName: s.Domain, + Path: dohpath.For(req.ProfileId, req.DeviceId), + } + _ = deviceURL // captured implicitly via dohpath.For + + // DoT — profile (+ optional device) lives in the TLS SNI hostname. + // Format mirrors proxy/server/clientid.go SNI parsing: + // . (no device) + // -. (with device, hyphen separator) + dotSNI := req.ProfileId + "." + s.Domain + if req.DeviceId != "" { + dotSNI = deviceLabel + "-" + req.ProfileId + "." + s.Domain + } + + // Port handling: the dnsstamps encoder strips a port suffix iff it equals + // the library's hardcoded default (DoT=843, DoQ=784, DoH=443). modDNS's + // production DoT/DoQ ports (typically both 853) do not match those defaults, + // so we always include them explicitly. Defensive against future library + // default changes too. + dotStamp := dnsstamps.ServerStamp{ + Proto: dnsstamps.StampProtoTypeTLS, + Props: s.Props, + ServerAddrStr: s.PrimaryIPv4 + ":" + strconv.Itoa(s.DoTPort), + ProviderName: dotSNI, + } + doqStamp := dnsstamps.ServerStamp{ + Proto: dnsstamps.StampProtoTypeDoQ, + Props: s.Props, + ServerAddrStr: s.PrimaryIPv4 + ":" + strconv.Itoa(s.DoQPort), + ProviderName: dotSNI, // DoT and DoQ share the SNI format + } + + resp := responses.DNSStampResponse{ + DoH: dohStamp.String(), + DoT: dotStamp.String(), + DoQ: doqStamp.String(), + } + + // Defensive sanity: every produced string must round-trip via the library. + // If it doesn't, returning a broken stamp to the client would silently fail + // downstream — fail loud here instead. + for proto, s := range map[string]string{"doh": resp.DoH, "dot": resp.DoT, "doq": resp.DoQ} { + if _, err := dnsstamps.NewServerStampFromString(s); err != nil { + return responses.DNSStampResponse{}, fmt.Errorf("dnsstamp: %s stamp failed round-trip: %w", proto, err) + } + } + + return resp, nil +} diff --git a/api/service/dnsstamp/service_test.go b/api/service/dnsstamp/service_test.go new file mode 100644 index 00000000..c37be20f --- /dev/null +++ b/api/service/dnsstamp/service_test.go @@ -0,0 +1,198 @@ +package dnsstamp + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ivpn/dns/api/api/requests" + "github.com/ivpn/dns/api/config" + "github.com/ivpn/dns/libs/dnsstamps" + "github.com/ivpn/dns/libs/dohpath" +) + +const ( + testDomain = "dns.moddns.net" + testIPv4 = "198.51.100.10" + testDoTPort = 853 + testDoQPort = 853 + testProfile = "abc123def4" + testDevice = "Living Room" + testDeviceUR = "Living%20Room" + testDeviceLB = "Living--Room" +) + +func newTestService(t *testing.T) DNSStampService { + t.Helper() + cfg := &config.Config{ + Server: &config.ServerConfig{ + DnsDomain: testDomain, + ServerAddresses: []string{testIPv4}, + DoTPort: testDoTPort, + DoQPort: testDoQPort, + }, + } + return NewDNSStampService(cfg) +} + +// specRef: M1, M4 +func TestGenerateStamps_DoH_DecodesCorrectly(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.NoError(t, err) + + st, err := dnsstamps.NewServerStampFromString(resp.DoH) + require.NoError(t, err) + + assert.Equal(t, dnsstamps.StampProtoTypeDoH, st.Proto) + assert.Equal(t, testDomain, st.ProviderName) + assert.Equal(t, dohpath.For(testProfile, ""), st.Path) + // dnsstamps re-adds :443 to bare IPs for DoH; we accept either form to be + // resilient to library version changes. + assert.True(t, + st.ServerAddrStr == testIPv4 || st.ServerAddrStr == testIPv4+":443", + "DoH ServerAddrStr = %q, want %q or %q", st.ServerAddrStr, testIPv4, testIPv4+":443", + ) +} + +// specRef: M1 — defensive against dnsstamps library default DoT port (843) vs +// production (853). If the library default ever changes to 853, this test +// still passes — what we care about is that the wire encoding carries 853. +func TestGenerateStamps_DoT_PortExplicit(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.NoError(t, err) + + st, err := dnsstamps.NewServerStampFromString(resp.DoT) + require.NoError(t, err) + assert.Equal(t, dnsstamps.StampProtoTypeTLS, st.Proto) + assert.Equal(t, testIPv4+":853", st.ServerAddrStr, "DoT must carry :853 explicitly") + assert.Equal(t, testProfile+"."+testDomain, st.ProviderName) +} + +// specRef: M1 — same port-mismatch defence for DoQ (library default 784, prod 853). +func TestGenerateStamps_DoQ_PortExplicit(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.NoError(t, err) + + st, err := dnsstamps.NewServerStampFromString(resp.DoQ) + require.NoError(t, err) + assert.Equal(t, dnsstamps.StampProtoTypeDoQ, st.Proto) + assert.Equal(t, testIPv4+":853", st.ServerAddrStr, "DoQ must carry :853 explicitly") + assert.Equal(t, testProfile+"."+testDomain, st.ProviderName) +} + +// specRef: M5 — device id propagated into DoH path (URL-encoded) and DoT/DoQ SNI +// (label-encoded with -- for spaces). +func TestGenerateStamps_WithDeviceID(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ + ProfileId: testProfile, + DeviceId: testDevice, + }) + require.NoError(t, err) + + doh, err := dnsstamps.NewServerStampFromString(resp.DoH) + require.NoError(t, err) + assert.Contains(t, doh.Path, testDeviceUR, "DoH path must URL-encode device id") + assert.Equal(t, dohpath.For(testProfile, testDevice), doh.Path) + + dot, err := dnsstamps.NewServerStampFromString(resp.DoT) + require.NoError(t, err) + assert.Equal(t, testDeviceLB+"-"+testProfile+"."+testDomain, dot.ProviderName, + "DoT SNI must use -. per clientid.go contract") + + doq, err := dnsstamps.NewServerStampFromString(resp.DoQ) + require.NoError(t, err) + assert.Equal(t, testDeviceLB+"-"+testProfile+"."+testDomain, doq.ProviderName) +} + +// specRef: M1 — props bitmap matches modDNS reality: DNSSEC=yes, NoLog=yes, NoFilter=no. +func TestGenerateStamps_PropsBitmap(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.NoError(t, err) + + for proto, str := range map[string]string{"doh": resp.DoH, "dot": resp.DoT, "doq": resp.DoQ} { + st, err := dnsstamps.NewServerStampFromString(str) + require.NoError(t, err, proto) + assert.NotZero(t, st.Props&dnsstamps.ServerInformalPropertyDNSSEC, "%s: DNSSEC must be set", proto) + assert.NotZero(t, st.Props&dnsstamps.ServerInformalPropertyNoLog, "%s: NoLog must be set", proto) + assert.Zero(t, st.Props&dnsstamps.ServerInformalPropertyNoFilter, "%s: NoFilter must NOT be set (modDNS filters)", proto) + } +} + +// specRef: M4 +// The drift-proof trap test. If the proxy ever changes its DoH path scheme, +// this test fails — and the proxy's own router test must fail too because +// both consume libs/dohpath.Prefix. Drift is impossible without both moving. +func TestGenerateStamps_DoHPathMatchesProxyContract(t *testing.T) { + s := newTestService(t) + + cases := []struct { + profile, device string + }{ + {testProfile, ""}, + {testProfile, testDevice}, + {"abc123", "Home Router"}, // same shape as proxy/server/device_identification_test.go fixtures + } + for _, c := range cases { + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ + ProfileId: c.profile, + DeviceId: c.device, + }) + require.NoError(t, err) + + st, err := dnsstamps.NewServerStampFromString(resp.DoH) + require.NoError(t, err) + require.Equal(t, dohpath.For(c.profile, c.device), st.Path, + "DoH path drifted from libs/dohpath contract — proxy router will reject these stamps") + } +} + +// Sad path: missing anycast IP. +func TestGenerateStamps_NoServerAddress(t *testing.T) { + cfg := &config.Config{ + Server: &config.ServerConfig{ + DnsDomain: testDomain, + ServerAddresses: nil, + DoTPort: testDoTPort, + DoQPort: testDoQPort, + }, + } + s := NewDNSStampService(cfg) + _, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoServerAddress)) +} + +// Sad path: missing domain. +func TestGenerateStamps_NoDomain(t *testing.T) { + cfg := &config.Config{ + Server: &config.ServerConfig{ + DnsDomain: "", + ServerAddresses: []string{testIPv4}, + DoTPort: testDoTPort, + DoQPort: testDoQPort, + }, + } + s := NewDNSStampService(cfg) + _, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.Error(t, err) +} + +// Surface check: all three stamps share the same sdns:// prefix and decode cleanly. +func TestGenerateStamps_AllProtosAreSdnsPrefixed(t *testing.T) { + s := newTestService(t) + resp, err := s.GenerateStamps(context.Background(), requests.DNSStampReq{ProfileId: testProfile}) + require.NoError(t, err) + + for proto, str := range map[string]string{"doh": resp.DoH, "dot": resp.DoT, "doq": resp.DoQ} { + assert.True(t, strings.HasPrefix(str, "sdns://"), "%s missing sdns:// prefix: %q", proto, str) + } +} diff --git a/api/service/service.go b/api/service/service.go index 85ae8f63..f4a17df5 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -19,6 +19,7 @@ import ( "github.com/ivpn/dns/api/service/account" "github.com/ivpn/dns/api/service/apple" "github.com/ivpn/dns/api/service/blocklist" + "github.com/ivpn/dns/api/service/dnsstamp" "github.com/ivpn/dns/api/service/profile" querylogs "github.com/ivpn/dns/api/service/query_logs" "github.com/ivpn/dns/api/service/statistics" @@ -40,6 +41,7 @@ type Service struct { SubscriptionServicer SessionServicer PasskeyServicer + dnsstamp.DNSStampServicer } func New(cfg config.Config, store db.Db, cache cache.Cache, idGen idgen.Generator, apiValidator *validator.APIValidator, mailer email.Mailer, shortener *urlshort.URLShortener, webauthn *webauthn.WebAuthn) Service { @@ -51,6 +53,7 @@ func New(cfg config.Config, store db.Db, cache cache.Cache, idGen idgen.Generato subSrv := subscription.NewSubscriptionService(store, store, cache, *cfg.Service, *cfg.API, *httpClient) accSrv := account.NewAccountService(*cfg.Service, store, profSrv, statsSrv, subSrv, store, cache, mailer, idGen, apiValidator.Validator, *httpClient) appleSrv := apple.NewAppleService(&cfg, cache, shortener) + dnsstampSrv := dnsstamp.NewDNSStampService(&cfg) return Service{ Cfg: cfg, Store: store, @@ -62,6 +65,7 @@ func New(cfg config.Config, store db.Db, cache cache.Cache, idGen idgen.Generato SubscriptionServicer: subSrv, Webauthn: webauthn, HTTP: *httpClient, + DNSStampServicer: dnsstampSrv, } } @@ -74,6 +78,7 @@ type Servicer interface { SubscriptionServicer PasskeyServicer CredentialServicer + dnsstamp.DNSStampServicer } type CredentialServicer interface { From 5dd75099b179585a43c9d546292453d22328aa68 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 12:47:14 +0200 Subject: [PATCH 3/8] chore(api-clients): regenerate TS + Python clients for /api/v1/dnsstamp Signed-off-by: Maciek --- app/src/api/client/api.ts | 154 +++++++++ tests/moddns_client/.openapi-generator/FILES | 3 + tests/moddns_client/README.md | 3 + tests/moddns_client/moddns/__init__.py | 3 + tests/moddns_client/moddns/api/__init__.py | 1 + .../moddns/api/dns_stamps_api.py | 321 ++++++++++++++++++ tests/moddns_client/moddns/models/__init__.py | 2 + .../moddns/models/model_subscription.py | 4 +- .../moddns/models/requests_dns_stamp_req.py | 90 +++++ .../models/responses_dns_stamp_response.py | 91 +++++ 10 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 tests/moddns_client/moddns/api/dns_stamps_api.py create mode 100644 tests/moddns_client/moddns/models/requests_dns_stamp_req.py create mode 100644 tests/moddns_client/moddns/models/responses_dns_stamp_response.py diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index a31846ae..be8aa857 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -1521,6 +1521,25 @@ export const RequestsCreateProfileCustomRulesBatchBodyActionEnum = { export type RequestsCreateProfileCustomRulesBatchBodyActionEnum = typeof RequestsCreateProfileCustomRulesBatchBodyActionEnum[keyof typeof RequestsCreateProfileCustomRulesBatchBodyActionEnum]; +/** + * + * @export + * @interface RequestsDNSStampReq + */ +export interface RequestsDNSStampReq { + /** + * DeviceId is an optional human-friendly identifier for the device. It is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -]) before being embedded in the stamps. Empty means \"profile-only stamp\". + * @type {string} + * @memberof RequestsDNSStampReq + */ + 'device_id'?: string; + /** + * + * @type {string} + * @memberof RequestsDNSStampReq + */ + 'profile_id': string; +} /** * * @export @@ -1738,6 +1757,31 @@ export interface ResponsesCustomRuleBatchSkipped { */ 'value'?: string; } +/** + * + * @export + * @interface ResponsesDNSStampResponse + */ +export interface ResponsesDNSStampResponse { + /** + * + * @type {string} + * @memberof ResponsesDNSStampResponse + */ + 'doh'?: string; + /** + * + * @type {string} + * @memberof ResponsesDNSStampResponse + */ + 'doq'?: string; + /** + * + * @type {string} + * @memberof ResponsesDNSStampResponse + */ + 'dot'?: string; +} /** * * @export @@ -3766,6 +3810,116 @@ export const ApiV1BlocklistsGetSortByEnum = { export type ApiV1BlocklistsGetSortByEnum = typeof ApiV1BlocklistsGetSortByEnum[keyof typeof ApiV1BlocklistsGetSortByEnum]; +/** + * DNSStampsApi - axios parameter creator + * @export + */ +export const DNSStampsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don\'t expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + * @summary Generate DNS Stamps for a modDNS profile + * @param {RequestsDNSStampReq} body Generate DNS stamp request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1DnsstampPost: async (body: RequestsDNSStampReq, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('apiV1DnsstampPost', 'body', body) + const localVarPath = `/api/v1/dnsstamp`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * DNSStampsApi - functional programming interface + * @export + */ +export const DNSStampsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DNSStampsApiAxiosParamCreator(configuration) + return { + /** + * Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don\'t expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + * @summary Generate DNS Stamps for a modDNS profile + * @param {RequestsDNSStampReq} body Generate DNS stamp request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1DnsstampPost(body: RequestsDNSStampReq, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1DnsstampPost(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DNSStampsApi.apiV1DnsstampPost']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * DNSStampsApi - factory interface + * @export + */ +export const DNSStampsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DNSStampsApiFp(configuration) + return { + /** + * Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don\'t expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + * @summary Generate DNS Stamps for a modDNS profile + * @param {RequestsDNSStampReq} body Generate DNS stamp request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1DnsstampPost(body: RequestsDNSStampReq, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.apiV1DnsstampPost(body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * DNSStampsApi - object-oriented interface + * @export + * @class DNSStampsApi + * @extends {BaseAPI} + */ +export class DNSStampsApi extends BaseAPI { + /** + * Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don\'t expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + * @summary Generate DNS Stamps for a modDNS profile + * @param {RequestsDNSStampReq} body Generate DNS stamp request + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DNSStampsApi + */ + public apiV1DnsstampPost(body: RequestsDNSStampReq, options?: RawAxiosRequestConfig) { + return DNSStampsApiFp(this.configuration).apiV1DnsstampPost(body, options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * PASessionApi - axios parameter creator * @export diff --git a/tests/moddns_client/.openapi-generator/FILES b/tests/moddns_client/.openapi-generator/FILES index 3e17ce5f..f410b65a 100644 --- a/tests/moddns_client/.openapi-generator/FILES +++ b/tests/moddns_client/.openapi-generator/FILES @@ -11,6 +11,7 @@ moddns/api/account_api.py moddns/api/apple_mobileconfig_api.py moddns/api/authentication_api.py moddns/api/blocklists_api.py +moddns/api/dns_stamps_api.py moddns/api/pa_session_api.py moddns/api/profile_api.py moddns/api/query_logs_api.py @@ -81,6 +82,7 @@ moddns/models/requests_advanced_options_req.py moddns/models/requests_confirm_reset_password_body.py moddns/models/requests_create_profile_custom_rule_body.py moddns/models/requests_create_profile_custom_rules_batch_body.py +moddns/models/requests_dns_stamp_req.py moddns/models/requests_login_body.py moddns/models/requests_mobile_config_req.py moddns/models/requests_pa_session_req.py @@ -93,6 +95,7 @@ moddns/models/responses_create_profile_custom_rules_batch_response.py moddns/models/responses_custom_rule_batch_created.py moddns/models/responses_custom_rule_batch_skipped.py moddns/models/responses_deletion_code_response.py +moddns/models/responses_dns_stamp_response.py moddns/models/responses_registration_success_response.py moddns/models/responses_short_link_response.py moddns/models/responses_web_authn_reauth_finish_response.py diff --git a/tests/moddns_client/README.md b/tests/moddns_client/README.md index 8664bf47..571802b7 100644 --- a/tests/moddns_client/README.md +++ b/tests/moddns_client/README.md @@ -108,6 +108,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**api_v1_webauthn_register_begin_post**](docs/AuthenticationApi.md#api_v1_webauthn_register_begin_post) | **POST** /api/v1/webauthn/register/begin | Begin passkey registration *AuthenticationApi* | [**api_v1_webauthn_register_finish_post**](docs/AuthenticationApi.md#api_v1_webauthn_register_finish_post) | **POST** /api/v1/webauthn/register/finish | Finish passkey registration *BlocklistsApi* | [**api_v1_blocklists_get**](docs/BlocklistsApi.md#api_v1_blocklists_get) | **GET** /api/v1/blocklists | Get blocklists data +*DNSStampsApi* | [**api_v1_dnsstamp_post**](docs/DNSStampsApi.md#api_v1_dnsstamp_post) | **POST** /api/v1/dnsstamp | Generate DNS Stamps for a modDNS profile *PASessionApi* | [**api_v1_pasession_add_post**](docs/PASessionApi.md#api_v1_pasession_add_post) | **POST** /api/v1/pasession/add | Add pre-auth session *PASessionApi* | [**api_v1_pasession_rotate_put**](docs/PASessionApi.md#api_v1_pasession_rotate_put) | **PUT** /api/v1/pasession/rotate | Rotate pre-auth session ID *ProfileApi* | [**api_v1_profiles_get**](docs/ProfileApi.md#api_v1_profiles_get) | **GET** /api/v1/profiles | Get profiles data @@ -194,6 +195,7 @@ Class | Method | HTTP request | Description - [RequestsConfirmResetPasswordBody](docs/RequestsConfirmResetPasswordBody.md) - [RequestsCreateProfileCustomRuleBody](docs/RequestsCreateProfileCustomRuleBody.md) - [RequestsCreateProfileCustomRulesBatchBody](docs/RequestsCreateProfileCustomRulesBatchBody.md) + - [RequestsDNSStampReq](docs/RequestsDNSStampReq.md) - [RequestsLoginBody](docs/RequestsLoginBody.md) - [RequestsMobileConfigReq](docs/RequestsMobileConfigReq.md) - [RequestsPASessionReq](docs/RequestsPASessionReq.md) @@ -205,6 +207,7 @@ Class | Method | HTTP request | Description - [ResponsesCreateProfileCustomRulesBatchResponse](docs/ResponsesCreateProfileCustomRulesBatchResponse.md) - [ResponsesCustomRuleBatchCreated](docs/ResponsesCustomRuleBatchCreated.md) - [ResponsesCustomRuleBatchSkipped](docs/ResponsesCustomRuleBatchSkipped.md) + - [ResponsesDNSStampResponse](docs/ResponsesDNSStampResponse.md) - [ResponsesDeletionCodeResponse](docs/ResponsesDeletionCodeResponse.md) - [ResponsesRegistrationSuccessResponse](docs/ResponsesRegistrationSuccessResponse.md) - [ResponsesShortLinkResponse](docs/ResponsesShortLinkResponse.md) diff --git a/tests/moddns_client/moddns/__init__.py b/tests/moddns_client/moddns/__init__.py index 1bea855f..34de1b28 100644 --- a/tests/moddns_client/moddns/__init__.py +++ b/tests/moddns_client/moddns/__init__.py @@ -21,6 +21,7 @@ from moddns.api.apple_mobileconfig_api import AppleMobileconfigApi from moddns.api.authentication_api import AuthenticationApi from moddns.api.blocklists_api import BlocklistsApi +from moddns.api.dns_stamps_api import DNSStampsApi from moddns.api.pa_session_api import PASessionApi from moddns.api.profile_api import ProfileApi from moddns.api.query_logs_api import QueryLogsApi @@ -99,6 +100,7 @@ from moddns.models.requests_confirm_reset_password_body import RequestsConfirmResetPasswordBody from moddns.models.requests_create_profile_custom_rule_body import RequestsCreateProfileCustomRuleBody from moddns.models.requests_create_profile_custom_rules_batch_body import RequestsCreateProfileCustomRulesBatchBody +from moddns.models.requests_dns_stamp_req import RequestsDNSStampReq from moddns.models.requests_login_body import RequestsLoginBody from moddns.models.requests_mobile_config_req import RequestsMobileConfigReq from moddns.models.requests_pa_session_req import RequestsPASessionReq @@ -110,6 +112,7 @@ from moddns.models.responses_create_profile_custom_rules_batch_response import ResponsesCreateProfileCustomRulesBatchResponse from moddns.models.responses_custom_rule_batch_created import ResponsesCustomRuleBatchCreated from moddns.models.responses_custom_rule_batch_skipped import ResponsesCustomRuleBatchSkipped +from moddns.models.responses_dns_stamp_response import ResponsesDNSStampResponse from moddns.models.responses_deletion_code_response import ResponsesDeletionCodeResponse from moddns.models.responses_registration_success_response import ResponsesRegistrationSuccessResponse from moddns.models.responses_short_link_response import ResponsesShortLinkResponse diff --git a/tests/moddns_client/moddns/api/__init__.py b/tests/moddns_client/moddns/api/__init__.py index be3e99b9..98387074 100644 --- a/tests/moddns_client/moddns/api/__init__.py +++ b/tests/moddns_client/moddns/api/__init__.py @@ -5,6 +5,7 @@ from moddns.api.apple_mobileconfig_api import AppleMobileconfigApi from moddns.api.authentication_api import AuthenticationApi from moddns.api.blocklists_api import BlocklistsApi +from moddns.api.dns_stamps_api import DNSStampsApi from moddns.api.pa_session_api import PASessionApi from moddns.api.profile_api import ProfileApi from moddns.api.query_logs_api import QueryLogsApi diff --git a/tests/moddns_client/moddns/api/dns_stamps_api.py b/tests/moddns_client/moddns/api/dns_stamps_api.py new file mode 100644 index 00000000..03223ddc --- /dev/null +++ b/tests/moddns_client/moddns/api/dns_stamps_api.py @@ -0,0 +1,321 @@ +# coding: utf-8 + +""" + modDNS REST API + + modDNS REST API + + The version of the OpenAPI document: 1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import Field +from typing_extensions import Annotated +from moddns.models.requests_dns_stamp_req import RequestsDNSStampReq +from moddns.models.responses_dns_stamp_response import ResponsesDNSStampResponse + +from moddns.api_client import ApiClient, RequestSerialized +from moddns.api_response import ApiResponse +from moddns.rest import RESTResponseType + + +class DNSStampsApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + def api_v1_dnsstamp_post( + self, + body: Annotated[RequestsDNSStampReq, Field(description="Generate DNS stamp request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ResponsesDNSStampResponse: + """Generate DNS Stamps for a modDNS profile + + Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don't expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + + :param body: Generate DNS stamp request (required) + :type body: RequestsDNSStampReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_dnsstamp_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ResponsesDNSStampResponse", + '400': "ApiErrResponse", + '404': "ApiErrResponse", + '500': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + def api_v1_dnsstamp_post_with_http_info( + self, + body: Annotated[RequestsDNSStampReq, Field(description="Generate DNS stamp request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[ResponsesDNSStampResponse]: + """Generate DNS Stamps for a modDNS profile + + Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don't expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + + :param body: Generate DNS stamp request (required) + :type body: RequestsDNSStampReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_dnsstamp_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ResponsesDNSStampResponse", + '400': "ApiErrResponse", + '404': "ApiErrResponse", + '500': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + def api_v1_dnsstamp_post_without_preload_content( + self, + body: Annotated[RequestsDNSStampReq, Field(description="Generate DNS stamp request")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Generate DNS Stamps for a modDNS profile + + Returns DoH, DoT, and DoQ sdns:// strings for the given profile, optionally scoped to a specific device label. Stamps are consumed by clients that don't expose separate hostname/path fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.). + + :param body: Generate DNS stamp request (required) + :type body: RequestsDNSStampReq + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._api_v1_dnsstamp_post_serialize( + body=body, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ResponsesDNSStampResponse", + '400': "ApiErrResponse", + '404': "ApiErrResponse", + '500': "ApiErrResponse", + } + response_data = self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _api_v1_dnsstamp_post_serialize( + self, + body, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if body is not None: + _body_params = body + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/api/v1/dnsstamp', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/tests/moddns_client/moddns/models/__init__.py b/tests/moddns_client/moddns/models/__init__.py index 93083a08..11561318 100644 --- a/tests/moddns_client/moddns/models/__init__.py +++ b/tests/moddns_client/moddns/models/__init__.py @@ -71,6 +71,7 @@ from moddns.models.requests_confirm_reset_password_body import RequestsConfirmResetPasswordBody from moddns.models.requests_create_profile_custom_rule_body import RequestsCreateProfileCustomRuleBody from moddns.models.requests_create_profile_custom_rules_batch_body import RequestsCreateProfileCustomRulesBatchBody +from moddns.models.requests_dns_stamp_req import RequestsDNSStampReq from moddns.models.requests_login_body import RequestsLoginBody from moddns.models.requests_mobile_config_req import RequestsMobileConfigReq from moddns.models.requests_pa_session_req import RequestsPASessionReq @@ -82,6 +83,7 @@ from moddns.models.responses_create_profile_custom_rules_batch_response import ResponsesCreateProfileCustomRulesBatchResponse from moddns.models.responses_custom_rule_batch_created import ResponsesCustomRuleBatchCreated from moddns.models.responses_custom_rule_batch_skipped import ResponsesCustomRuleBatchSkipped +from moddns.models.responses_dns_stamp_response import ResponsesDNSStampResponse from moddns.models.responses_deletion_code_response import ResponsesDeletionCodeResponse from moddns.models.responses_registration_success_response import ResponsesRegistrationSuccessResponse from moddns.models.responses_short_link_response import ResponsesShortLinkResponse diff --git a/tests/moddns_client/moddns/models/model_subscription.py b/tests/moddns_client/moddns/models/model_subscription.py index 9d78b245..264f6803 100644 --- a/tests/moddns_client/moddns/models/model_subscription.py +++ b/tests/moddns_client/moddns/models/model_subscription.py @@ -31,8 +31,9 @@ class ModelSubscription(BaseModel): outage: Optional[StrictBool] = None status: Optional[ModelSubscriptionStatus] = Field(default=None, description="Computed fields (not persisted)") tier: Optional[StrictStr] = None + type: Optional[StrictStr] = Field(default=None, description="Type is a legacy pre-0.1.8 enum (\"Free\"/\"Managed\") retained so old documents surface to clients (the beta-ending banner gates on Type == \"Managed\"). Cleared to \"\" by the resync flow once the user re-syncs with IVPN.") updated_at: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["active_until", "outage", "status", "tier", "updated_at"] + __properties: ClassVar[List[str]] = ["active_until", "outage", "status", "tier", "type", "updated_at"] model_config = ConfigDict( populate_by_name=True, @@ -89,6 +90,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "outage": obj.get("outage"), "status": obj.get("status"), "tier": obj.get("tier"), + "type": obj.get("type"), "updated_at": obj.get("updated_at") }) return _obj diff --git a/tests/moddns_client/moddns/models/requests_dns_stamp_req.py b/tests/moddns_client/moddns/models/requests_dns_stamp_req.py new file mode 100644 index 00000000..38cdd3a1 --- /dev/null +++ b/tests/moddns_client/moddns/models/requests_dns_stamp_req.py @@ -0,0 +1,90 @@ +# coding: utf-8 + +""" + modDNS REST API + + modDNS REST API + + The version of the OpenAPI document: 1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class RequestsDNSStampReq(BaseModel): + """ + RequestsDNSStampReq + """ # noqa: E501 + device_id: Optional[StrictStr] = Field(default=None, description="DeviceId is an optional human-friendly identifier for the device. It is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -]) before being embedded in the stamps. Empty means \"profile-only stamp\".") + profile_id: Annotated[str, Field(min_length=10, strict=True, max_length=64)] + __properties: ClassVar[List[str]] = ["device_id", "profile_id"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of RequestsDNSStampReq from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of RequestsDNSStampReq from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "device_id": obj.get("device_id"), + "profile_id": obj.get("profile_id") + }) + return _obj + + diff --git a/tests/moddns_client/moddns/models/responses_dns_stamp_response.py b/tests/moddns_client/moddns/models/responses_dns_stamp_response.py new file mode 100644 index 00000000..293b0592 --- /dev/null +++ b/tests/moddns_client/moddns/models/responses_dns_stamp_response.py @@ -0,0 +1,91 @@ +# coding: utf-8 + +""" + modDNS REST API + + modDNS REST API + + The version of the OpenAPI document: 1.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class ResponsesDNSStampResponse(BaseModel): + """ + ResponsesDNSStampResponse + """ # noqa: E501 + doh: Optional[StrictStr] = None + doq: Optional[StrictStr] = None + dot: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["doh", "doq", "dot"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ResponsesDNSStampResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ResponsesDNSStampResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "doh": obj.get("doh"), + "doq": obj.get("doq"), + "dot": obj.get("dot") + }) + return _obj + + From 8c29940bd121f13fd2aed3c79a6c1bd877b94214 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 13:31:32 +0200 Subject: [PATCH 4/8] feat(app): Add DNS Stamp tab in Routers guide Signed-off-by: Maciek --- .../e2e/layout/setup-routers-stamps.spec.ts | 107 ++++++++++ app/src/api/api.ts | 1 + app/src/pages/setup/RightPanelGuide.tsx | 3 +- app/src/pages/setup/guides/Routers.tsx | 191 +++++++++++++++++- 4 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts diff --git a/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts new file mode 100644 index 00000000..88130147 --- /dev/null +++ b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts @@ -0,0 +1,107 @@ +import { test, expect, type Route } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Fixture stamps — these are real sdns:// strings produced by the api stamp service +// (decoded values verified in api/service/dnsstamp/service_test.go). Hard-coding +// the strings means the E2E test exercises the UI without re-running the encoder. +const STAMPS_NO_DEVICE = { + doh: 'sdns://AgcAAAAAAAAAAA0xLjEuMS4xAA5kbnMubW9kZG5zLm5ldBYvZG5zLXF1ZXJ5L2FiYzEyM2RlZjQ', + dot: 'sdns://AwcAAAAAAAAAABEyMDQuMTEuMTQuMjU6ODUzABRhYmMxMjNkZWY0LmRucy5tb2RkbnMubmV0', + doq: 'sdns://BAcAAAAAAAAAABEyMDQuMTEuMTQuMjU6ODUzABRhYmMxMjNkZWY0LmRucy5tb2RkbnMubmV0', +}; +const STAMPS_WITH_DEVICE = { + doh: 'sdns://AgcAAAAAAAAAAA0xLjEuMS4xAA5kbnMubW9kZG5zLm5ldCYvZG5zLXF1ZXJ5L2FiYzEyM2RlZjQvTGl2aW5nJTIwUm9vbQ', + dot: 'sdns://AwcAAAAAAAAAABEyMDQuMTEuMTQuMjU6ODUzAB5MaXZpbmctLVJvb20tYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA', + doq: 'sdns://BAcAAAAAAAAAABEyMDQuMTEuMTQuMjU6ODUzAB5MaXZpbmctLVJvb20tYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA', +}; + +(test.describe as typeof test.describe)('@layout @desktop Setup → Routers → DNS Stamps tab', () => { + test('renders three stamps, tooltip toggles, advanced disclosure triggers refetch with device id', async ({ page }) => { + test.skip(!/-desktop$/i.test(test.info().project.name), 'Run only on *-desktop project'); + + // Track how many times the API was called and with which payload so we can + // assert the debounced refetch actually happened with the device id. + const calls: Array<{ profile_id?: string; device_id?: string }> = []; + + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'abc123def4', profile_id: 'abc123def4', name: 'Default', settings: { custom_rules: [] } }], + extraRoutes: async (p) => { + await p.route(/\/api\/v1\/dnsstamp(\/?|\?.*)$/i, async (r: Route) => { + const body = r.request().postDataJSON() as { profile_id?: string; device_id?: string }; + calls.push(body); + const payload = body.device_id ? STAMPS_WITH_DEVICE : STAMPS_NO_DEVICE; + return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(payload) }); + }); + }, + }); + + await page.goto('/setup'); + + // Navigate Setup → Routers + const routersCard = page.getByTestId('setup-platform-card-desktop-routers'); + await expect(routersCard).toBeVisible(); + await routersCard.click(); + + // The "DNS Stamps" tab button lives in the Routers guide tab list. + const stampsTabButton = page.getByRole('button', { name: /^DNS Stamps$/ }); + await expect(stampsTabButton).toBeVisible(); + await stampsTabButton.click(); + + // Tab body becomes visible. + const tab = page.getByTestId('stamps-tab'); + await expect(tab).toBeVisible(); + + // Three stamps render — wait for initial fetch. + await expect.poll(() => calls.length, { timeout: 5000 }).toBeGreaterThanOrEqual(1); + await expect(tab.getByText(STAMPS_NO_DEVICE.doh, { exact: false })).toBeVisible(); + await expect(tab.getByText(STAMPS_NO_DEVICE.dot, { exact: false })).toBeVisible(); + await expect(tab.getByText(STAMPS_NO_DEVICE.doq, { exact: false })).toBeVisible(); + + // The explainer text is persistent — no click required. + await expect(tab.getByText(/DNS Stamps bundle a resolver's address/)).toBeVisible(); + // The trust pills row is persistent too. + await expect(tab.getByText(/Resolver advertises:/)).toBeVisible(); + await expect(tab.getByText(/^DNSSEC$/)).toBeVisible(); + await expect(tab.getByText(/^No logs$/)).toBeVisible(); + // Per-protocol compatibility hints are rendered alongside each stamp. + await expect(tab.getByText(/Works with: UniFi Network/)).toBeVisible(); + await expect(tab.getByText(/Works with: Android Private DNS/)).toBeVisible(); + await expect(tab.getByText(/Works with: AdGuard Home, recent dnscrypt-proxy/)).toBeVisible(); + + // dnscrypt.info reference is a real external link that opens in a new tab. + const specLink = tab.getByRole('link', { name: /dnscrypt\.info\/stamps-specifications/i }); + await expect(specLink).toHaveAttribute('href', /dnscrypt\.info\/stamps-specifications/); + await expect(specLink).toHaveAttribute('target', '_blank'); + + // Privacy-policy link clarifies what "No logs" means in practice. + const privacyLink = tab.getByRole('link', { name: /How modDNS handles logs/i }); + await expect(privacyLink).toHaveAttribute('href', '/privacy'); + await expect(privacyLink).toHaveAttribute('target', '_blank'); + + // Advanced disclosure starts collapsed. + const advanced = page.getByTestId('stamps-advanced'); + const deviceInput = page.getByTestId('stamps-device-input'); + await expect(advanced).not.toHaveAttribute('open', ''); + await expect(deviceInput).toBeHidden(); + + // Expand the disclosure. + await advanced.locator('summary').click(); + await expect(deviceInput).toBeVisible(); + + // Type a device label — debounced refetch fires ~300ms later with the device id. + const callsBefore = calls.length; + await deviceInput.fill('Living Room'); + + // Wait for at least one new call carrying the device id. + await expect.poll( + () => calls.find((c, i) => i >= callsBefore && c.device_id === 'Living Room') ?? null, + { timeout: 5000 } + ).not.toBeNull(); + + // Stamps update to the device-scoped variants. + await expect(tab.getByText(STAMPS_WITH_DEVICE.doh, { exact: false })).toBeVisible(); + await expect(tab.getByText(STAMPS_WITH_DEVICE.dot, { exact: false })).toBeVisible(); + await expect(tab.getByText(STAMPS_WITH_DEVICE.doq, { exact: false })).toBeVisible(); + }); +}); diff --git a/app/src/api/api.ts b/app/src/api/api.ts index a66f43cf..27143034 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -75,6 +75,7 @@ const Client = { servicesApi: new client.ServicesApi(config), verificationApi: new client.VerificationApi(config), appleMobileconfigApi: new client.AppleMobileconfigApi(config), + dnsStampsApi: new client.DNSStampsApi(config), sessionsApi: new client.SessionsApi(config), subscriptionApi: new client.SubscriptionApi(config), paSessionApi: new client.PASessionApi(config), diff --git a/app/src/pages/setup/RightPanelGuide.tsx b/app/src/pages/setup/RightPanelGuide.tsx index ed5bbc1b..61ff406c 100644 --- a/app/src/pages/setup/RightPanelGuide.tsx +++ b/app/src/pages/setup/RightPanelGuide.tsx @@ -134,7 +134,8 @@ export default function SetupGuidePanel({ platform, onClose, isVisible = true, m dohEndpoint, anycastIpv4: effectivePrimaryIp, dnsServerDomain: effectiveDomain, - dotHostname: profileData?.dnsOverTLS || `your-profile-id.${effectiveDomain}` + dotHostname: profileData?.dnsOverTLS || `your-profile-id.${effectiveDomain}`, + profileId: profileData?.id || 'your-profile-id' }) }; } else if (platform === "VPN apps") { diff --git a/app/src/pages/setup/guides/Routers.tsx b/app/src/pages/setup/guides/Routers.tsx index 0eef5e1b..30b61516 100644 --- a/app/src/pages/setup/guides/Routers.tsx +++ b/app/src/pages/setup/guides/Routers.tsx @@ -1,5 +1,8 @@ import React from 'react'; +import { ShieldCheck, EyeOff, ChevronRight } from 'lucide-react'; import CodeBlock from '@/components/setup/CodeBlock'; +import api from '@/api/api'; +import type { ResponsesDNSStampResponse } from '@/api/client'; // eslint-disable-next-line react-refresh/only-export-components export const routersBadges = [ @@ -14,6 +17,7 @@ export interface RoutersGuideDeps { anycastIpv4: string; // primary IPv4 from env list dnsServerDomain: string; // e.g. dns.moddns.net (from env) dotHostname: string; // . + profileId: string; // active profile id — required by the DNS Stamps tab } interface RouterTabDef { @@ -65,6 +69,181 @@ const buildOpenWrtCommands = ({ dohEndpoint, anycastIpv4 }: RoutersGuideDeps) => `service https-dns-proxy restart` ); +const StampsCrossLink = () => ( +
+ Device only accepts sdns:// strings? See the DNS Stamps tab. +
+); + +const StampRow = ({ label, compat, value, loading }: { label: string; compat: string; value: string; loading: boolean }) => ( +
+ {label} +
{compat}
+ {loading ? ( +
+ ) : ( + + )} +
+); + +const TrustPill = ({ icon: Icon, label }: { icon: typeof ShieldCheck; label: string }) => ( + + + {label} + +); + +const StampsTab = ({ deps }: { deps: RoutersGuideDeps }) => { + const [stamps, setStamps] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const [deviceLabel, setDeviceLabel] = React.useState(''); + + const fetchStamps = React.useCallback(async (deviceId: string) => { + setLoading(true); + setError(null); + try { + const res = await api.Client.dnsStampsApi.apiV1DnsstampPost({ + profile_id: deps.profileId, + device_id: deviceId, + }); + setStamps(res.data); + } catch { + setError('Could not generate stamps. Try again.'); + setStamps(null); + } finally { + setLoading(false); + } + }, [deps.profileId]); + + // Initial fetch without a device id. + React.useEffect(() => { fetchStamps(''); }, [fetchStamps]); + + // Debounce device-label edits — refetch 300ms after the user stops typing. + React.useEffect(() => { + if (!advancedOpen) return; + const handle = setTimeout(() => { fetchStamps(deviceLabel); }, 300); + return () => clearTimeout(handle); + }, [deviceLabel, advancedOpen, fetchStamps]); + + return ( +
+
+

+ Paste these into UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, + or any client that accepts the sdns:// format. +

+

+ DNS Stamps bundle a resolver's address, protocol, and certificate hints + into one sdns:// string. See{' '} + + dnscrypt.info/stamps-specifications + {' '} + for the format spec. +

+
+ Resolver advertises: + + + + How modDNS handles logs + +
+
+ + + + {error ? ( +
+ {error} + +
+ ) : ( +
+ + + +
+ )} + + + +
setAdvancedOpen((e.target as HTMLDetailsElement).open)} + data-testid="stamps-advanced" + > + + + Advanced options + +
+ + setDeviceLabel(e.target.value)} + placeholder="e.g. Living Room" + maxLength={36} + data-testid="stamps-device-input" + className="px-3 py-2 rounded border border-[var(--tailwind-colors-slate-700)] bg-[var(--tailwind-colors-slate-900)] text-sm text-[var(--tailwind-colors-slate-50)] focus:outline-none focus:border-[var(--tailwind-colors-rdns-600)]" + /> +
+ Tag stamps per device for separate query logs. Stamps update automatically. + Allowed: letters, digits, spaces, hyphens. +
+
+
+
+ ); +}; + const buildRouterTabs = (deps: RoutersGuideDeps): RouterTabDef[] => [ { key: 'mikrotik', @@ -75,6 +254,7 @@ const buildRouterTabs = (deps: RoutersGuideDeps): RouterTabDef[] => [
+
) }, @@ -117,6 +297,7 @@ const buildRouterTabs = (deps: RoutersGuideDeps): RouterTabDef[] => [ /> Save and Apply} /> + ) }, @@ -167,6 +348,7 @@ const buildRouterTabs = (deps: RoutersGuideDeps): RouterTabDef[] => [ /> Save} /> + ) }, @@ -179,8 +361,14 @@ const buildRouterTabs = (deps: RoutersGuideDeps): RouterTabDef[] => [
+ ) + }, + { + key: 'stamps', + label: 'DNS Stamps', + content: } ]; @@ -233,7 +421,8 @@ export const routersSteps = createRoutersSteps({ dohEndpoint: 'https://example.com/dns-query/your-profile-id', anycastIpv4: '0.0.0.0', dnsServerDomain: 'example.com', - dotHostname: 'your-profile-id.example.com' + dotHostname: 'your-profile-id.example.com', + profileId: 'your-profile-id' }); const RoutersGuide = { From d2a08f1d29de34e036b9348d5e6cbd33fab24b80 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 14:28:09 +0200 Subject: [PATCH 5/8] test(e2e): DNS stamps tests Signed-off-by: Maciek --- tests/config/api.env | 8 +- tests/dns_tests/test_dns_stamps.py | 264 +++++++++++++++++++++++++++++ tests/libs/dns_lib.py | 66 +++++++- tests/requirements.txt | 2 + 4 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 tests/dns_tests/test_dns_stamps.py diff --git a/tests/config/api.env b/tests/config/api.env index c5f11f26..fb86664a 100644 --- a/tests/config/api.env +++ b/tests/config/api.env @@ -1,7 +1,11 @@ # ## SERVER CONFIG SERVER_FRONTEND_DOMAIN="http://localhost:5173" -SERVER_DNS_DOMAIN="dns.staging.ivpndns.net" -SERVER_DNS_SERVER_ADDRESSES="51.161.64.178" +# Integration tests run the proxy locally on 127.0.0.1 with an mkcert dev cert +# for ivpndns.com / *.ivpndns.com. The api emits stamps & mobileconfig pointing +# at these values, so they must match what the proxy actually serves on this +# host network. Production overrides via ansible (see ansible/.../api.env.j2). +SERVER_DNS_DOMAIN="ivpndns.com" +SERVER_DNS_SERVER_ADDRESSES="127.0.0.1" SERVER_ALLOWED_DOMAINS=app.ivpndns.net,api.ivpndns.net,test.moddns.net # ## SERVICE CONFIG diff --git a/tests/dns_tests/test_dns_stamps.py b/tests/dns_tests/test_dns_stamps.py new file mode 100644 index 00000000..90588438 --- /dev/null +++ b/tests/dns_tests/test_dns_stamps.py @@ -0,0 +1,264 @@ +"""Integration tests for the DNS Stamp generator and end-to-end stamp-based resolution. + +Three layers: + +1. **Generation correctness** — call POST /api/v1/dnsstamp, decode each returned + sdns:// with the Python dnsstamps library, assert encoded fields match what + the proxy actually accepts. Covers spec rows M1, M4, M5. + +2. **Resolution end-to-end** — actually connect via DoH/DoT/DoQ on the host/port + the stamp encodes (substituting 127.0.0.1 for the public anycast IP but + preserving SNI), send a DNS query, get a real answer. + +3. **Per-profile filtering across all three transports** — block a domain for + profile P1, generate stamps for both P1 and P2, verify P1's stamp returns + the blocked response while P2's stamp doesn't. Proves profile identity + travels correctly through DoH path, DoT SNI, and DoQ SNI. + +Spec: docs/specs/api-endpoint-behaviour.md §M. +""" +from __future__ import annotations + +from ipaddress import ip_address + +import dnsstamps +import pytest +from dns.rdatatype import A + +import moddns.api as api +import moddns.api_client as client +import moddns.configuration as api_config +from moddns import RequestsDNSStampReq + +from libs.dns_lib import DNSLib +from libs.profile_helpers import ( + ProfileHelpers, + SVC_GOOGLE_DOMAIN, + SVC_GOOGLE_IP, + extract_ip, +) +from libs.settings import get_settings + + +# Test domain set up via tests/config/api.env: +# SERVER_DNS_DOMAIN=ivpndns.com +# SERVER_DNS_SERVER_ADDRESSES=127.0.0.1 +EXPECTED_DOMAIN = "ivpndns.com" +EXPECTED_IP = "127.0.0.1" +EXPECTED_DOT_PORT = 853 +EXPECTED_DOQ_PORT = 853 + +PROTO_DOH = "doh" +PROTO_DOT = "dot" +PROTO_DOQ = "doq" +PROTOCOLS = [PROTO_DOH, PROTO_DOT, PROTO_DOQ] + + +def _stamp_for(resp, protocol: str) -> str: + return {PROTO_DOH: resp.doh, PROTO_DOT: resp.dot, PROTO_DOQ: resp.doq}[protocol] + + +def _fetch_stamps(api_client, profile_id, device_id: str | None = None): + """Wrapper that swallows OpenAPI client model boilerplate.""" + stamps_api = api.DNSStampsApi(api_client) + body = RequestsDNSStampReq(profile_id=profile_id, device_id=device_id or "") + return stamps_api.api_v1_dnsstamp_post(body=body) + + +class TestDNSStampGeneration: + """Layer 1 — stamp content correctness. specRef: M1, M4, M5.""" + + def setup_class(self): + self.config = get_settings() + self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) + + @pytest.mark.asyncio + async def test_three_stamps_returned_and_decode_correctly(self, create_account_and_login): + """specRef: M1, M4""" + account, cookie = create_account_and_login + profile_id = account.profiles[0] + + with client.ApiClient(self.api_config) as api_client: + api_client.default_headers["Cookie"] = cookie + resp = _fetch_stamps(api_client, profile_id) + + # Each protocol field is a non-empty sdns:// string. + assert resp.doh.startswith("sdns://"), f"DoH missing prefix: {resp.doh!r}" + assert resp.dot.startswith("sdns://"), f"DoT missing prefix: {resp.dot!r}" + assert resp.doq.startswith("sdns://"), f"DoQ missing prefix: {resp.doq!r}" + + doh = dnsstamps.parse(resp.doh) + assert doh.protocol == dnsstamps.Protocol.DOH + assert doh.hostname == EXPECTED_DOMAIN, f"DoH hostname={doh.hostname!r}" + assert doh.path == f"/dns-query/{profile_id}", f"DoH path={doh.path!r}" + # DoH address has port stripped (443 is library default); just the IP remains. + assert doh.address == EXPECTED_IP, f"DoH address={doh.address!r}" + + dot = dnsstamps.parse(resp.dot) + assert dot.protocol == dnsstamps.Protocol.DOT + assert dot.hostname == f"{profile_id}.{EXPECTED_DOMAIN}", f"DoT hostname={dot.hostname!r}" + assert dot.address == f"{EXPECTED_IP}:{EXPECTED_DOT_PORT}", ( + f"DoT must carry :{EXPECTED_DOT_PORT} explicitly, got {dot.address!r}" + ) + + doq = dnsstamps.parse(resp.doq) + assert doq.protocol == dnsstamps.Protocol.DOQ + assert doq.hostname == f"{profile_id}.{EXPECTED_DOMAIN}", f"DoQ hostname={doq.hostname!r}" + assert doq.address == f"{EXPECTED_IP}:{EXPECTED_DOQ_PORT}", ( + f"DoQ must carry :{EXPECTED_DOQ_PORT} explicitly, got {doq.address!r}" + ) + + # Props bitmap — DNSSEC + NoLog set, NoFilter intentionally not set + # (modDNS filters; advertising NoFilter would be misleading). + for name, stamp in (("doh", doh), ("dot", dot), ("doq", doq)): + assert dnsstamps.Option.DNSSEC in stamp.options, f"{name}: DNSSEC must be set" + assert dnsstamps.Option.NO_LOGS in stamp.options, f"{name}: NO_LOGS must be set" + assert dnsstamps.Option.NO_FILTERS not in stamp.options, ( + f"{name}: NO_FILTERS must NOT be set (modDNS filters)" + ) + + @pytest.mark.asyncio + async def test_device_id_encoded_into_each_stamp(self, create_account_and_login): + """specRef: M5 — device id propagates into DoH path + DoT/DoQ SNI.""" + account, cookie = create_account_and_login + profile_id = account.profiles[0] + + with client.ApiClient(self.api_config) as api_client: + api_client.default_headers["Cookie"] = cookie + resp = _fetch_stamps(api_client, profile_id, device_id="Living Room") + + doh = dnsstamps.parse(resp.doh) + assert doh.path == f"/dns-query/{profile_id}/Living%20Room", ( + f"DoH path must URL-encode device id, got {doh.path!r}" + ) + + dot = dnsstamps.parse(resp.dot) + assert dot.hostname == f"Living--Room-{profile_id}.{EXPECTED_DOMAIN}", ( + f"DoT SNI must use -., got {dot.hostname!r}" + ) + + doq = dnsstamps.parse(resp.doq) + assert doq.hostname == f"Living--Room-{profile_id}.{EXPECTED_DOMAIN}", ( + f"DoQ SNI must use -., got {doq.hostname!r}" + ) + + @pytest.mark.asyncio + async def test_validation_rejects_short_profile_id(self, create_account_and_login): + """specRef: M2 — profile_id must be alphanumeric, length 10–64. + + The OpenAPI swagger annotations propagate the constraints to the + generated pydantic model, so client-side validation raises before + the request leaves the test. That's actually a stronger guarantee + than server-side rejection — we accept either outcome. + """ + _, cookie = create_account_and_login + + with client.ApiClient(self.api_config) as api_client: + api_client.default_headers["Cookie"] = cookie + stamps_api = api.DNSStampsApi(api_client) + with pytest.raises(Exception) as exc_info: + stamps_api.api_v1_dnsstamp_post( + body=RequestsDNSStampReq(profile_id="abc") + ) + # Acceptable outcomes: + # - pydantic ValidationError on the client (model has min_length=10) + # - BadRequestException / ApiException with 400 from the server + err_name = exc_info.type.__name__ + assert err_name in {"ValidationError", "BadRequestException", "ApiException"} or \ + "400" in str(exc_info.value), ( + f"Expected client validation or server 400, got {err_name}: {exc_info.value!r}" + ) + + +class TestDNSStampResolution: + """Layer 2 — every stamp actually resolves end-to-end.""" + + def setup_class(self): + self.config = get_settings() + self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) + self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) + + @pytest.mark.asyncio + @pytest.mark.parametrize("protocol", PROTOCOLS) + async def test_resolution_via_each_stamp( + self, create_account_and_login, protocol + ): + """specRef: M1, M4 — open a real connection via the stamp and resolve a known domain. + + Uses SVC_GOOGLE_DOMAIN (svctest-google.com → 8.8.8.8 via testhosts.txt), + a deterministic stub so the test doesn't depend on live external DNS. + """ + account, cookie = create_account_and_login + profile_id = account.profiles[0] + + with client.ApiClient(self.api_config) as api_client: + api_client.default_headers["Cookie"] = cookie + stamp_str = _stamp_for(_fetch_stamps(api_client, profile_id), protocol) + + stamp = dnsstamps.parse(stamp_str) + resp = await self.dns_lib.send_via_stamp(stamp, SVC_GOOGLE_DOMAIN, A) + + assert resp.answer, f"{protocol}: empty answer for {SVC_GOOGLE_DOMAIN}" + got_ip = extract_ip(resp) + assert ip_address(got_ip) == ip_address(SVC_GOOGLE_IP), ( + f"{protocol}: expected {SVC_GOOGLE_IP} stub, got {got_ip}" + ) + + +class TestDNSStampProfileIsolation(ProfileHelpers): + """Layer 3 — per-profile filtering survives every stamp transport. + + The regression guard: prove that a block rule on profile P1 applies to + queries through P1's stamp, but does NOT affect P2's stamp — across all + three transports. + + specRef: M1, M4 + """ + + BLOCKED_DOMAIN = "stamp-isolation-block.test" + + def setup_class(self): + self.config = get_settings() + self.api_config = api_config.Configuration(host=self.config.DNS_API_ADDR) + self.dns_lib = DNSLib(self.config.DOH_ENDPOINT) + + @pytest.mark.asyncio + @pytest.mark.parametrize("protocol", PROTOCOLS) + async def test_block_in_p1_does_not_affect_p2( + self, create_account_and_login, protocol + ): + # create_account_and_login is class-scoped, so all three parametrizations + # share one account. Spin up a fresh P1/P2 pair per protocol so the custom + # rule, added below, doesn't clash with the previous parametrization's + # rule on the same profile. + _, cookie = create_account_and_login + + with client.ApiClient(self.api_config) as api_client: + api_client.default_headers["Cookie"] = cookie + profiles_api = api.ProfileApi(api_client) + + p1 = self._create_profile(profiles_api, f"stamp-iso-p1-{protocol}") + p2 = self._create_profile(profiles_api, f"stamp-iso-p2-{protocol}") + self._create_custom_rule(profiles_api, p1, "block", self.BLOCKED_DOMAIN) + + s1 = _stamp_for(_fetch_stamps(api_client, p1), protocol) + s2 = _stamp_for(_fetch_stamps(api_client, p2), protocol) + + stamp_p1 = dnsstamps.parse(s1) + stamp_p2 = dnsstamps.parse(s2) + + # P1's stamp: the rule must apply — blocked response is 0.0.0.0. + r1 = await self.dns_lib.send_via_stamp(stamp_p1, self.BLOCKED_DOMAIN, A) + assert r1.answer, f"{protocol}: P1 stamp returned no answer" + assert extract_ip(r1) == "0.0.0.0", ( + f"{protocol}: P1 stamp failed to apply block — profile id not " + f"routed through {protocol.upper()} transport" + ) + + # P2's stamp: must NOT be affected — either empty answer or non-block IP. + r2 = await self.dns_lib.send_via_stamp(stamp_p2, self.BLOCKED_DOMAIN, A) + leaked = bool(r2.answer) and extract_ip(r2) == "0.0.0.0" + assert not leaked, ( + f"{protocol}: P2 stamp received P1's block — profile id LEAKED " + f"across {protocol.upper()} transport" + ) diff --git a/tests/libs/dns_lib.py b/tests/libs/dns_lib.py index 3ec85ce1..e1ac0f83 100644 --- a/tests/libs/dns_lib.py +++ b/tests/libs/dns_lib.py @@ -1,10 +1,33 @@ +import glob +import os import time import httpx from dns import resolver, message -from dns.query import https as query_https +from dns.query import https as query_https, tls as query_tls, quic as query_quic from dns.message import Message, ShortHeader +# Where the proxy binds inside the docker-compose host network. Stamps encode +# a publicly-routable anycast IP (cfg.Server.ServerAddresses[0]); for live +# integration we substitute the loopback bind while preserving SNI so the +# proxy's profile-id dispatcher still resolves the right tenant. +LOCAL_PROXY_HOST = "127.0.0.1" + + +def _mkcert_ca_path() -> str: + """Locate the mkcert dev CA bundle. Override via MODDNS_TEST_CA_PATH.""" + override = os.getenv("MODDNS_TEST_CA_PATH") + if override: + return override + matches = sorted(glob.glob("/home/maciek/git/dns/certs/mkcert_development_CA_*.crt")) + if not matches: + raise RuntimeError( + "mkcert dev CA not found at certs/mkcert_development_CA_*.crt — " + "set MODDNS_TEST_CA_PATH or regenerate the test certs" + ) + return matches[0] + + class DNSLib: def __init__(self, server: str): self.server = server @@ -36,3 +59,44 @@ async def send_doh_request_with_retry( if attempt < retries - 1: time.sleep(delay) raise last_err + + async def send_via_stamp(self, stamp, domain: str, record_type: str) -> Message: + """Dispatch a DNS query through the protocol encoded in a parsed dnsstamps stamp. + + Connects to LOCAL_PROXY_HOST (loopback) but uses the stamp's hostname for SNI + — that's what carries profile-id dispatch through the proxy. The mkcert dev + CA is used to verify TLS; the cert SANs include *.ivpndns.com so per-profile + subdomains validate. + """ + from dnsstamps import Protocol # local import — only needed when this helper is used + + query = message.make_query(domain, record_type) + ca = _mkcert_ca_path() + + if stamp.protocol == Protocol.DOH: + url = f"https://{stamp.hostname}{stamp.path}" + with httpx.Client(verify=ca) as client: + return query_https(query, url, session=client) + if stamp.protocol == Protocol.DOT: + port = _port_from_address(stamp.address, default=853) + return query_tls( + query, LOCAL_PROXY_HOST, port=port, + server_hostname=stamp.hostname, verify=ca, + ) + if stamp.protocol == Protocol.DOQ: + port = _port_from_address(stamp.address, default=853) + return query_quic( + query, LOCAL_PROXY_HOST, port=port, + server_hostname=stamp.hostname, verify=ca, + ) + raise ValueError(f"unsupported stamp protocol: {stamp.protocol}") + + +def _port_from_address(address: str, default: int) -> int: + """Extract :PORT suffix from a stamp's address field. Falls back to default.""" + if ":" in address: + try: + return int(address.rsplit(":", 1)[1]) + except ValueError: + pass + return default diff --git a/tests/requirements.txt b/tests/requirements.txt index 31a60e21..469ebc0c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,5 +1,7 @@ certifi==2024.6.2 dnspython==2.6.1 +dnsstamps==1.4.1 +aioquic==1.3.0 # optional dnspython dep — required for dns.query.quic() (DoQ stamps) httpx==0.27.0 ./moddns_client pytest==8.2.2 From 52d1033544ed26d40e3912f9800706d97d472c31 Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 14:51:05 +0200 Subject: [PATCH 6/8] tests(app): Fix Playwright tests Signed-off-by: Maciek --- api/service/dnsstamp/service.go | 8 +-- .../e2e/layout/setup-routers-stamps.spec.ts | 50 ++++++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/api/service/dnsstamp/service.go b/api/service/dnsstamp/service.go index 8591307d..817ea377 100644 --- a/api/service/dnsstamp/service.go +++ b/api/service/dnsstamp/service.go @@ -43,10 +43,10 @@ type DNSStampServicer interface { // All fields are derived from config at construction time. The service holds // no mutable state and is safe for concurrent use. type DNSStampService struct { - Domain string // cfg.Server.DnsDomain, e.g. "dns.moddns.net" - PrimaryIPv4 string // cfg.Server.ServerAddresses[0] - DoTPort int // cfg.Server.DoTPort (production: 853) - DoQPort int // cfg.Server.DoQPort (production: 853, NOT the library default 784) + Domain string // cfg.Server.DnsDomain, e.g. "dns.moddns.net" + PrimaryIPv4 string // cfg.Server.ServerAddresses[0] + DoTPort int // cfg.Server.DoTPort (production: 853) + DoQPort int // cfg.Server.DoQPort (production: 853, NOT the library default 784) Props dnsstamps.ServerInformalProperties } diff --git a/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts index 88130147..a24b6e14 100644 --- a/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts +++ b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts @@ -26,14 +26,50 @@ const STAMPS_WITH_DEVICE = { await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'abc123def4', profile_id: 'abc123def4', name: 'Default', settings: { custom_rules: [] } }], - extraRoutes: async (p) => { - await p.route(/\/api\/v1\/dnsstamp(\/?|\?.*)$/i, async (r: Route) => { - const body = r.request().postDataJSON() as { profile_id?: string; device_id?: string }; - calls.push(body); - const payload = body.device_id ? STAMPS_WITH_DEVICE : STAMPS_NO_DEVICE; - return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(payload) }); + }); + + // Register AFTER registerMocks so this handler takes precedence over the + // catch-all `/api/v1/` route registered inside registerMocks (which would + // otherwise call r.continue() on POSTs and send them to a dead backend). + // Also handle the CORS preflight — cross-origin POST + JSON triggers OPTIONS. + // CORS: api.ts sets `withCredentials: true`, so the response must include + // `Access-Control-Allow-Credentials: true` AND `Access-Control-Allow-Origin` + // set to the exact request origin (NOT '*' — that combination is rejected + // by browsers per the CORS spec). + await page.route(/\/api\/v1\/dnsstamp(\/?|\?.*)$/i, async (r: Route) => { + // headerValue() is async — must be awaited. The page is served at + // http://localhost:5173 (vite dev server), api calls go to http://localhost:3000. + const origin = (await r.request().headerValue('origin')) ?? 'http://localhost:5173'; + const corsHeaders = { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + 'Vary': 'Origin', + }; + + const method = r.request().method(); + if (method === 'OPTIONS') { + return r.fulfill({ + status: 200, + headers: { + ...corsHeaders, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type, cookie', + }, + body: '', }); - }, + } + if (method !== 'POST') { + return r.continue(); + } + const body = r.request().postDataJSON() as { profile_id?: string; device_id?: string }; + calls.push(body); + const payload = body.device_id ? STAMPS_WITH_DEVICE : STAMPS_NO_DEVICE; + return r.fulfill({ + status: 200, + headers: corsHeaders, + contentType: 'application/json', + body: JSON.stringify(payload), + }); }); await page.goto('/setup'); From 712a185971aa61dec0210e129d70672896d890fb Mon Sep 17 00:00:00 2001 From: Maciek Date: Fri, 15 May 2026 14:59:01 +0200 Subject: [PATCH 7/8] test(e2e): Fix cert location Signed-off-by: Maciek --- .github/workflows/integration_tests.yml | 2 +- tests/libs/dns_lib.py | 48 ++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 067daee7..03ced54c 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -30,7 +30,7 @@ jobs: python_certs_path=`python -m certifi` echo "Python CA store path: ${python_certs_path}" echo "PYTHON_CERTS_PATH=${python_certs_path}" >> $GITHUB_ENV - echo "IVPN_CERT_PATH=./certs/mkcert_development_CA_307611231582065277882115426409270736451.crt" >> $GITHUB_ENV + echo "IVPN_CERT_PATH=${GITHUB_WORKSPACE}/certs/mkcert_development_CA_307611231582065277882115426409270736451.crt" >> $GITHUB_ENV - name: SSL cert setup run: | diff --git a/tests/libs/dns_lib.py b/tests/libs/dns_lib.py index e1ac0f83..576256e7 100644 --- a/tests/libs/dns_lib.py +++ b/tests/libs/dns_lib.py @@ -1,6 +1,6 @@ -import glob import os import time +from pathlib import Path import httpx from dns import resolver, message @@ -15,17 +15,41 @@ def _mkcert_ca_path() -> str: - """Locate the mkcert dev CA bundle. Override via MODDNS_TEST_CA_PATH.""" - override = os.getenv("MODDNS_TEST_CA_PATH") - if override: - return override - matches = sorted(glob.glob("/home/maciek/git/dns/certs/mkcert_development_CA_*.crt")) - if not matches: - raise RuntimeError( - "mkcert dev CA not found at certs/mkcert_development_CA_*.crt — " - "set MODDNS_TEST_CA_PATH or regenerate the test certs" - ) - return matches[0] + """Locate the mkcert dev CA bundle. + + DoT/DoQ via dns.query.tls/quic uses Python's system trust store (NOT certifi), + so we must pass the CA path explicitly — relying on the CI workflow's certifi + append works for DoH only. This helper resolves the path portably: + + Resolution order: + 1. MODDNS_TEST_CA_PATH env var (explicit override / escape hatch). + 2. IVPN_CERT_PATH env var (already set by .github/workflows/integration_tests.yml). + 3. Walk up from this file to find /certs/mkcert_development_CA_*.crt. + Works identically on dev machines and CI runners — only the repo root path + differs. + """ + for env_name in ("MODDNS_TEST_CA_PATH", "IVPN_CERT_PATH"): + value = os.getenv(env_name) + if value: + p = Path(value).resolve() + if not p.is_file(): + raise RuntimeError( + f"{env_name}={value} but file does not exist (resolved: {p})" + ) + return str(p) + + here = Path(__file__).resolve() + for parent in here.parents: + cert_dir = parent / "certs" + if cert_dir.is_dir(): + matches = sorted(cert_dir.glob("mkcert_development_CA_*.crt")) + if matches: + return str(matches[0]) + + raise RuntimeError( + "mkcert dev CA not found. Expected /certs/mkcert_development_CA_*.crt; " + "override via MODDNS_TEST_CA_PATH or IVPN_CERT_PATH env." + ) class DNSLib: From a9526874012e2b80dcc23d879558670fc7fd7c98 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 19 May 2026 13:40:55 +0200 Subject: [PATCH 8/8] docs(faq): Add DNS stamps info Signed-off-by: Maciek --- .../e2e/layout/setup-routers-stamps.spec.ts | 7 +- app/src/pages/legal/FAQ.tsx | 85 ++++++++++++++++++- app/src/pages/setup/guides/Routers.tsx | 17 +++- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts index a24b6e14..854fa84a 100644 --- a/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts +++ b/app/src/__tests__/e2e/layout/setup-routers-stamps.spec.ts @@ -101,9 +101,12 @@ const STAMPS_WITH_DEVICE = { await expect(tab.getByText(/^DNSSEC$/)).toBeVisible(); await expect(tab.getByText(/^No logs$/)).toBeVisible(); // Per-protocol compatibility hints are rendered alongside each stamp. + // DoH has a broad consumer list; DoT/DoQ share the narrower "AdGuard ecosystem only" hint. await expect(tab.getByText(/Works with: UniFi Network/)).toBeVisible(); - await expect(tab.getByText(/Works with: Android Private DNS/)).toBeVisible(); - await expect(tab.getByText(/Works with: AdGuard Home, recent dnscrypt-proxy/)).toBeVisible(); + await expect(tab.getByText(/Works with: AdGuard Home, AdGuard dnsproxy/).first()).toBeVisible(); + // The "Niche" callout makes the asymmetry explicit so users don't paste DoT/DoQ stamps + // into clients that won't parse them. + await expect(tab.getByText(/Niche: AdGuard ecosystem only/)).toBeVisible(); // dnscrypt.info reference is a real external link that opens in a new tab. const specLink = tab.getByRole('link', { name: /dnscrypt\.info\/stamps-specifications/i }); diff --git a/app/src/pages/legal/FAQ.tsx b/app/src/pages/legal/FAQ.tsx index b5e843c4..0af547a2 100644 --- a/app/src/pages/legal/FAQ.tsx +++ b/app/src/pages/legal/FAQ.tsx @@ -99,7 +99,7 @@ function FAQSection({ title, children, globalToggleSignal, globalToggleState }: ); } -const FAQ_LAST_UPDATED = 'February 11, 2026'; +const FAQ_LAST_UPDATED = 'May 19, 2026'; export default function FAQ(): JSX.Element { const navigate = useNavigate(); @@ -356,6 +356,62 @@ export default function FAQ(): JSX.Element { ); + const whatAreDNSStamps = ( +
+

A DNS Stamp is a single string starting with sdns:// that bundles everything a client needs to reach a resolver — its IP address, port, protocol (DoH, DoT, DoQ), hostname for TLS, URL path, and properties such as DNSSEC support. Instead of typing each field separately, you paste one string and the client unpacks the rest.

+

Stamps were originally introduced by the DNSCrypt project, but today most encrypted-DNS clients understand them regardless of the underlying protocol. For the full format definition, see dnscrypt.info/stamps-specifications.

+
+ ); + + const whereToFindDNSStamps = ( +
    +
  1. Open the modDNS dashboard and select the profile you want a stamp for
  2. +
  3. Go to Setup and choose Routers
  4. +
  5. Switch to the DNS Stamps tab
  6. +
  7. Copy any of the three generated stamps: DNS over HTTPS, DNS over TLS, or DNS over QUIC
  8. +
+ ); + + const dnsStampsCompatibleClients = ( +
+

Almost all stamp-aware clients accept the DoH stamp. The DoT and DoQ stamps are part of the sdns:// specification but are far less widely adopted — at the time of writing, only AdGuard's ecosystem parses them.

+
    +
  • DoH stamp — works with dnscrypt-proxy, AdGuard Home, AdGuard dnsproxy, UniFi Network DNS Shield, Intra (Android), and Pi-hole via an embedded dnscrypt-proxy
  • +
  • DoT stamp — AdGuard Home and AdGuard dnsproxy only. Everything else (Stubby, Unbound, systemd-resolved, MikroTik, OpenWrt's https-dns-proxy) configures DoT by hostname + port directly, not via a stamp.
  • +
  • DoQ stamp — same as DoT: AdGuard Home and AdGuard dnsproxy only.
  • +
+

A common point of confusion: dnscrypt-proxy accepts only DoH, DNSCrypt, and ODoH stamps — feeding it a DoT or DoQ stamp returns an "Unsupported protocol" error.

+

If your device only exposes hostname, port, and path fields separately, you don't need a stamp — follow the platform-specific guide under Setup instead.

+
+ ); + + const dnsStampsEncryption = ( +
+

No. A DNS Stamp is an encoding format, not an encryption layer. The encryption is already provided by the protocol the stamp points to:

+
    +
  • DoH stamps → connection uses TLS over HTTPS
  • +
  • DoT stamps → connection uses TLS directly
  • +
  • DoQ stamps → connection uses QUIC, which negotiates TLS 1.3 in its handshake
  • +
+

All three protocols encrypt every DNS query end-to-end between your client and modDNS. Using a stamp gives you the same encrypted transport you'd get by typing the resolver details by hand — it's just easier to copy and paste.

+
+ ); + + const whyNoDNSCryptStamp = ( +
+

The DNSCrypt protocol is a separate encrypted-DNS wire format that predates DoH/DoT/DoQ. modDNS doesn't currently run a DNSCrypt server, so issuing a DNSCrypt-protocol stamp would point clients at a service that doesn't exist.

+

The DoH, DoT, and DoQ stamps we provide give you equivalent end-to-end encryption (all TLS-based). If you specifically use dnscrypt-proxy, configure it with the DoH stamp — that client supports DoH but not DoT/DoQ stamps. For DoT/DoQ stamps, AdGuard Home and AdGuard dnsproxy are the most common consumers.

+
+ ); + + const perDeviceDNSStamps = ( +
+

Yes. In the DNS Stamps tab, expand Advanced options and enter a label in the Device label field (for example Living Room). The three stamps refresh automatically a moment after you stop typing.

+

The label is embedded in the DoH URL path and in the DoT/DoQ TLS hostname, exactly as described in the Device Identification section above. Generate one stamp per device, paste it into that device's client, and your Query Logs will tag each entry with that label.

+

The same character and length rules apply as for any device identifier — see "What are the rules for device identifiers?" in the Device Identification section.

+
+ ); + const renderFAQContent = () => (
@@ -551,6 +607,33 @@ export default function FAQ(): JSX.Element { /> + + + + + + + + + {
+ +
+ Niche: AdGuard ecosystem only +
+ DoT and DoQ stamps are part of the sdns:// spec but only{' '} + AdGuard Home and AdGuard dnsproxy parse them today. + For routers, firewalls, and most other clients, use the DoH stamp above or follow the + per-platform guides under Setup. +
+
+