add doh support
This commit is contained in:
@@ -3,3 +3,4 @@
|
|||||||
.idea/**
|
.idea/**
|
||||||
.gocache/**
|
.gocache/**
|
||||||
tslink
|
tslink
|
||||||
|
config.toml
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
| `ephemeral` | bool | 否 | `true` | 节点是否临时节点,离开 Tailnet 后自动删除 |
|
| `ephemeral` | bool | 否 | `true` | 节点是否临时节点,离开 Tailnet 后自动删除 |
|
||||||
| `accept_routes` | bool | 否 | `true` | 是否接受其他节点发布的子网路由 |
|
| `accept_routes` | bool | 否 | `true` | 是否接受其他节点发布的子网路由 |
|
||||||
|
|
||||||
|
### `[dns]` — DNS 解析配置
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| `doh_servers` | []string | 否 | `[]` | DNS-over-HTTPS(RFC 8484)回落解析器地址列表,须为 `http(s)://` URL |
|
||||||
|
|
||||||
|
`dst_addr` 中的域名默认走 Tailnet 自身的 DNS 解析器(支持 MagicDNS 与 split-DNS)。当该解析器无法解析目标(例如宿主机本身没有可用的系统 DNS,或目标不在 Tailnet 的 split-DNS 路由内)时,会**依次**尝试 `doh_servers` 中配置的 DoH 端点解析公网域名。留空则关闭此回落。仅对原始域名发起 DoH 查询(Tailnet 内部 MagicDNS 名称无法通过 DoH 解析)。
|
||||||
|
|
||||||
### `[[forward.<name>]]` — 转发规则(Tailscale → 本地)
|
### `[[forward.<name>]]` — 转发规则(Tailscale → 本地)
|
||||||
|
|
||||||
将 Tailscale 上的流量转发到本地服务。`<name>` 为自定义标签名。
|
将 Tailscale 上的流量转发到本地服务。`<name>` 为自定义标签名。
|
||||||
@@ -89,6 +97,10 @@ hostname = "" # 可选,留空使用本机主机名
|
|||||||
ephemeral = true # 可选,临时节点
|
ephemeral = true # 可选,临时节点
|
||||||
accept_routes = true # 可选,接受子网路由
|
accept_routes = true # 可选,接受子网路由
|
||||||
|
|
||||||
|
[dns]
|
||||||
|
# 可选,Tailnet DNS 无法解析时回落到 DoH 解析公网域名;留空关闭
|
||||||
|
doh_servers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
|
||||||
|
|
||||||
# 示例1: 将 Tailnet 上 8080 端口的请求转发到本地 9090
|
# 示例1: 将 Tailnet 上 8080 端口的请求转发到本地 9090
|
||||||
[[forward.web]]
|
[[forward.web]]
|
||||||
protocol = "tcp"
|
protocol = "tcp"
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ hostname = "" # leave blank to use machine name
|
|||||||
ephemeral = true
|
ephemeral = true
|
||||||
accept_routes = true
|
accept_routes = true
|
||||||
|
|
||||||
|
[dns]
|
||||||
|
# Fallback DNS-over-HTTPS resolvers (RFC 8484), tried only when the tailnet
|
||||||
|
# resolver can't resolve a destination (e.g. the host has no working system DNS,
|
||||||
|
# or the name is outside the tailnet's split-DNS routes). Leave empty to disable.
|
||||||
|
doh_servers = []
|
||||||
|
# doh_servers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
|
||||||
|
|
||||||
[[forward.web]] # you -> others
|
[[forward.web]] # you -> others
|
||||||
protocol = "tcp"
|
protocol = "tcp"
|
||||||
tailscale_port = 8080
|
tailscale_port = 8080
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -41,6 +42,13 @@ type Core struct {
|
|||||||
AcceptRoutes bool `toml:"accept_routes"`
|
AcceptRoutes bool `toml:"accept_routes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNS holds resolver options. DoHServers are DNS-over-HTTPS endpoints (RFC 8484)
|
||||||
|
// queried as a fallback when the tailnet resolver cannot resolve a dial
|
||||||
|
// destination. An empty list disables the fallback.
|
||||||
|
type DNS struct {
|
||||||
|
DoHServers []string `toml:"doh_servers"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r ConnectRule) LANEnabled() bool {
|
func (r ConnectRule) LANEnabled() bool {
|
||||||
if r.LanEnable != nil {
|
if r.LanEnable != nil {
|
||||||
return *r.LanEnable
|
return *r.LanEnable
|
||||||
@@ -67,6 +75,7 @@ func (r ConnectRule) BindIP() string {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Core Core `toml:"core"`
|
Core Core `toml:"core"`
|
||||||
|
DNS DNS `toml:"dns"`
|
||||||
Forward map[string][]ForwardRule `toml:"forward"`
|
Forward map[string][]ForwardRule `toml:"forward"`
|
||||||
Connect map[string][]ConnectRule `toml:"connect"`
|
Connect map[string][]ConnectRule `toml:"connect"`
|
||||||
}
|
}
|
||||||
@@ -94,6 +103,13 @@ func (cfg *Config) Validate() error {
|
|||||||
errs = append(errs, errors.New("core.auth_key is required"))
|
errs = append(errs, errors.New("core.auth_key is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, server := range cfg.DNS.DoHServers {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(server))
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("dns.doh_servers[%d] must be a valid http(s) URL", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
usedForwardListeners := make(map[string]string)
|
usedForwardListeners := make(map[string]string)
|
||||||
usedConnectListeners := make(map[string]string)
|
usedConnectListeners := make(map[string]string)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func TestConfigValidateAcceptsValidConfig(t *testing.T) {
|
|||||||
|
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Core: Core{AuthKey: "tskey-auth-example"},
|
Core: Core{AuthKey: "tskey-auth-example"},
|
||||||
|
DNS: DNS{DoHServers: []string{"https://cloudflare-dns.com/dns-query"}},
|
||||||
Forward: map[string][]ForwardRule{
|
Forward: map[string][]ForwardRule{
|
||||||
"web": {
|
"web": {
|
||||||
{Protocol: "tcp", TailscalePort: 8080, LocalAddr: "127.0.0.1:9090"},
|
{Protocol: "tcp", TailscalePort: 8080, LocalAddr: "127.0.0.1:9090"},
|
||||||
@@ -106,6 +107,29 @@ func TestConfigValidateRejectsInvalidConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigValidateRejectsInvalidDoHServer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Core: Core{AuthKey: "tskey-auth-example"},
|
||||||
|
DNS: DNS{DoHServers: []string{"https://ok.example/dns-query", "not a url", "ftp://wrong.example"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() returned nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"dns.doh_servers[1] must be a valid http(s) URL",
|
||||||
|
"dns.doh_servers[2] must be a valid http(s) URL",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("Validate() error %q does not contain %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func boolPtr(v bool) *bool {
|
func boolPtr(v bool) *bool {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-32
@@ -117,11 +117,6 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
|
|||||||
return addr, nil // already ip:port, nothing to resolve
|
return addr, nil // already ip:port, nothing to resolve
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsMgr, ok := srv.Sys().DNSManager.GetOK()
|
|
||||||
if !ok {
|
|
||||||
return addr, errors.New("DNS manager not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Names to try, in order. A bare single-label name additionally gets the
|
// Names to try, in order. A bare single-label name additionally gets the
|
||||||
// MagicDNS suffix appended so short tailnet hostnames still resolve; a name
|
// MagicDNS suffix appended so short tailnet hostnames still resolve; a name
|
||||||
// that already contains a dot (an FQDN, including split-DNS suffixes) is
|
// that already contains a dot (an FQDN, including split-DNS suffixes) is
|
||||||
@@ -132,25 +127,70 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, name := range candidates {
|
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
|
||||||
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
|
for _, name := range candidates {
|
||||||
if err != nil {
|
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
|
||||||
lastErr = err
|
if err != nil {
|
||||||
continue
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(ip.String(), port), nil
|
||||||
}
|
}
|
||||||
return net.JoinHostPort(ip.String(), port), nil
|
} else {
|
||||||
|
lastErr = errors.New("DNS manager not available")
|
||||||
}
|
}
|
||||||
return addr, fmt.Errorf("resolve %q via tailnet DNS: %w", host, lastErr)
|
|
||||||
|
// Fallback: resolve public names via DNS-over-HTTPS when the tailnet
|
||||||
|
// resolver couldn't (no working system DNS on the host, or a name outside
|
||||||
|
// the tailnet's split-DNS routes). Only the original host is queried — DoH
|
||||||
|
// can't resolve tailnet-internal MagicDNS names.
|
||||||
|
if dohEnabled() {
|
||||||
|
if ip, derr := resolveHostViaDoH(ctx, host); derr == nil {
|
||||||
|
return net.JoinHostPort(ip.String(), port), nil
|
||||||
|
} else {
|
||||||
|
lastErr = fmt.Errorf("tailnet dns: %v; doh: %w", lastErr, derr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr, fmt.Errorf("resolve %q: %w", host, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dnsExchange sends a single DNS question and returns the first address answer,
|
||||||
|
// or a CNAME target if one is present instead. It abstracts the transport so the
|
||||||
|
// Tailscale resolver and the DNS-over-HTTPS fallback (see doh.go) can share the
|
||||||
|
// CNAME-chasing logic in resolveHostChase.
|
||||||
|
type dnsExchange func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error)
|
||||||
|
|
||||||
// resolveHostViaResolver resolves a hostname to a netip.Addr using the
|
// resolveHostViaResolver resolves a hostname to a netip.Addr using the
|
||||||
// Tailscale DNS resolver. It queries A then AAAA records and follows CNAME
|
// Tailscale DNS resolver. It queries A then AAAA records and follows CNAME
|
||||||
// chains (up to 8 levels deep).
|
// chains (up to 8 levels deep).
|
||||||
func resolveHostViaResolver(ctx context.Context, resolver *dns.Manager, host string) (netip.Addr, error) {
|
func resolveHostViaResolver(ctx context.Context, resolver *dns.Manager, host string) (netip.Addr, error) {
|
||||||
return resolveHostWithDepth(ctx, resolver, host, 0)
|
return resolveHostChase(ctx, host, 0, tailnetExchange(resolver))
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveHostWithDepth(ctx context.Context, r *dns.Manager, host string, depth int) (netip.Addr, error) {
|
// tailnetExchange returns a dnsExchange backed by the Tailscale DNS resolver.
|
||||||
|
func tailnetExchange(r *dns.Manager) dnsExchange {
|
||||||
|
return func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
|
||||||
|
queryBytes, err := buildDNSQuery(name, qType)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", err
|
||||||
|
}
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
respBytes, err := r.Query(qctx, queryBytes, "udp", netip.AddrPort{})
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", fmt.Errorf("DNS resolution failed for %s: %w", strings.TrimSuffix(name.String(), "."), err)
|
||||||
|
}
|
||||||
|
return parseDNSAnswer(respBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHostChase resolves host to an address by issuing A then AAAA questions
|
||||||
|
// through exchange and following CNAME chains (up to maxCNAMEChase levels deep).
|
||||||
|
// A first (MagicDNS hands out an IPv4 for tailnet peers), then AAAA so IPv6-only
|
||||||
|
// split-DNS hosts still resolve; a CNAME seen in either answer is chased once no
|
||||||
|
// address record is found.
|
||||||
|
func resolveHostChase(ctx context.Context, host string, depth int, exchange dnsExchange) (netip.Addr, error) {
|
||||||
const maxCNAMEChase = 8
|
const maxCNAMEChase = 8
|
||||||
if depth > maxCNAMEChase {
|
if depth > maxCNAMEChase {
|
||||||
return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host)
|
return netip.Addr{}, fmt.Errorf("CNAME chain too deep for %s", host)
|
||||||
@@ -161,12 +201,9 @@ func resolveHostWithDepth(ctx context.Context, r *dns.Manager, host string, dept
|
|||||||
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
|
return netip.Addr{}, fmt.Errorf("invalid hostname %s: %w", host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query A first (MagicDNS hands out an IPv4 for tailnet peers), then AAAA so
|
|
||||||
// IPv6-only split-DNS hosts still resolve. A CNAME seen in either answer is
|
|
||||||
// chased once no address record is found.
|
|
||||||
var cnameTarget string
|
var cnameTarget string
|
||||||
for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} {
|
for _, qType := range []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} {
|
||||||
ip, cname, err := queryResolver(ctx, r, name, qType)
|
ip, cname, err := exchange(ctx, name, qType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return netip.Addr{}, err
|
return netip.Addr{}, err
|
||||||
}
|
}
|
||||||
@@ -178,18 +215,15 @@ func resolveHostWithDepth(ctx context.Context, r *dns.Manager, host string, dept
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow CNAME if no direct address record was found.
|
|
||||||
if cnameTarget != "" {
|
if cnameTarget != "" {
|
||||||
return resolveHostWithDepth(ctx, r, cnameTarget, depth+1)
|
return resolveHostChase(ctx, cnameTarget, depth+1, exchange)
|
||||||
}
|
}
|
||||||
|
|
||||||
return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host)
|
return netip.Addr{}, fmt.Errorf("no A/AAAA record found for %s", host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryResolver sends a single question of the given type to the Tailscale DNS
|
// buildDNSQuery packs a single-question DNS query message for name/qType.
|
||||||
// resolver and returns the first address answer, or a CNAME target if one is
|
func buildDNSQuery(name dnsmessage.Name, qType dnsmessage.Type) ([]byte, error) {
|
||||||
// present instead.
|
|
||||||
func queryResolver(ctx context.Context, r *dns.Manager, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
|
|
||||||
msg := dnsmessage.Message{
|
msg := dnsmessage.Message{
|
||||||
Header: dnsmessage.Header{RecursionDesired: true},
|
Header: dnsmessage.Header{RecursionDesired: true},
|
||||||
Questions: []dnsmessage.Question{
|
Questions: []dnsmessage.Question{
|
||||||
@@ -198,16 +232,14 @@ func queryResolver(ctx context.Context, r *dns.Manager, name dnsmessage.Name, qT
|
|||||||
}
|
}
|
||||||
queryBytes, err := msg.Pack()
|
queryBytes, err := msg.Pack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return netip.Addr{}, "", fmt.Errorf("failed to pack DNS query: %w", err)
|
return nil, fmt.Errorf("failed to pack DNS query: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
qctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
respBytes, err := r.Query(qctx, queryBytes, "udp", netip.AddrPort{})
|
|
||||||
if err != nil {
|
|
||||||
return netip.Addr{}, "", fmt.Errorf("DNS resolution failed for %s: %w", strings.TrimSuffix(name.String(), "."), err)
|
|
||||||
}
|
}
|
||||||
|
return queryBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDNSAnswer unpacks a DNS response and returns the first A/AAAA address, or
|
||||||
|
// a CNAME target if one is present instead of an address record.
|
||||||
|
func parseDNSAnswer(respBytes []byte) (netip.Addr, string, error) {
|
||||||
var resp dnsmessage.Message
|
var resp dnsmessage.Message
|
||||||
if err := resp.Unpack(respBytes); err != nil {
|
if err := resp.Unpack(respBytes); err != nil {
|
||||||
return netip.Addr{}, "", fmt.Errorf("failed to unpack DNS response: %w", err)
|
return netip.Addr{}, "", fmt.Errorf("failed to unpack DNS response: %w", err)
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dohContentType is the RFC 8484 media type for binary DNS messages over HTTPS.
|
||||||
|
const dohContentType = "application/dns-message"
|
||||||
|
|
||||||
|
// maxDoHResponse bounds how much of a DoH response body we read, guarding against
|
||||||
|
// a hostile or misbehaving endpoint streaming an unbounded body.
|
||||||
|
const maxDoHResponse = 64 << 10 // 64 KiB
|
||||||
|
|
||||||
|
// Configured DNS-over-HTTPS endpoints, set once at startup from the config file
|
||||||
|
// (see SetDoHServers). Mirrors the magicDNSSuffix package-global pattern in
|
||||||
|
// dns.go so the resolver need not thread config through every call.
|
||||||
|
var (
|
||||||
|
dohMu sync.RWMutex
|
||||||
|
dohServers []string
|
||||||
|
dohClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetDoHServers records the DNS-over-HTTPS fallback endpoints.
|
||||||
|
func SetDoHServers(servers []string) {
|
||||||
|
dohMu.Lock()
|
||||||
|
defer dohMu.Unlock()
|
||||||
|
dohServers = append([]string(nil), servers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dohEnabled reports whether any DoH fallback endpoint is configured.
|
||||||
|
func dohEnabled() bool {
|
||||||
|
dohMu.RLock()
|
||||||
|
defer dohMu.RUnlock()
|
||||||
|
return len(dohServers) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDoHServers returns a copy of the configured DoH endpoints.
|
||||||
|
func getDoHServers() []string {
|
||||||
|
dohMu.RLock()
|
||||||
|
defer dohMu.RUnlock()
|
||||||
|
return append([]string(nil), dohServers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHostViaDoH resolves host through the configured DoH endpoints.
|
||||||
|
func resolveHostViaDoH(ctx context.Context, host string) (netip.Addr, error) {
|
||||||
|
return resolveViaDoHServers(ctx, getDoHServers(), host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveViaDoHServers tries each server in order, returning the first address
|
||||||
|
// that resolves. It takes the server list explicitly so it can be exercised in
|
||||||
|
// tests without touching package globals.
|
||||||
|
func resolveViaDoHServers(ctx context.Context, servers []string, host string) (netip.Addr, error) {
|
||||||
|
if len(servers) == 0 {
|
||||||
|
return netip.Addr{}, errors.New("no DoH servers configured")
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, server := range servers {
|
||||||
|
ip, err := resolveHostChase(ctx, host, 0, dohExchange(server))
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("doh %s: %w", server, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
return netip.Addr{}, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// dohExchange returns a dnsExchange that resolves a single question against a
|
||||||
|
// single DoH endpoint using the RFC 8484 binary wire format over HTTPS POST.
|
||||||
|
func dohExchange(server string) dnsExchange {
|
||||||
|
return func(ctx context.Context, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
|
||||||
|
query, err := buildDNSQuery(name, qType)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server, bytes.NewReader(query))
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", fmt.Errorf("build DoH request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", dohContentType)
|
||||||
|
req.Header.Set("Accept", dohContentType)
|
||||||
|
|
||||||
|
resp, err := dohClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", fmt.Errorf("DoH request to %s failed: %w", server, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return netip.Addr{}, "", fmt.Errorf("DoH request to %s: unexpected status %d", server, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxDoHResponse))
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, "", fmt.Errorf("read DoH response from %s: %w", server, err)
|
||||||
|
}
|
||||||
|
return parseDNSAnswer(respBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveViaDoHServers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
want := netip.AddrFrom4([4]byte{93, 184, 216, 34})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != dohContentType {
|
||||||
|
t.Errorf("Content-Type = %q, want %q", ct, dohContentType)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", dohContentType)
|
||||||
|
_, _ = w.Write(packAResponse(t, "example.com.", [4]byte{93, 184, 216, 34}))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := resolveViaDoHServers(context.Background(), []string{srv.URL}, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveViaDoHServers() error: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveViaDoHServers() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveViaDoHServersFallsThrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// First endpoint errors; the second answers. Confirms the loop advances past
|
||||||
|
// a failing server instead of giving up.
|
||||||
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer bad.Close()
|
||||||
|
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", dohContentType)
|
||||||
|
_, _ = w.Write(packAResponse(t, "example.com.", [4]byte{1, 2, 3, 4}))
|
||||||
|
}))
|
||||||
|
defer good.Close()
|
||||||
|
|
||||||
|
got, err := resolveViaDoHServers(context.Background(), []string{bad.URL, good.URL}, "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveViaDoHServers() error: %v", err)
|
||||||
|
}
|
||||||
|
if want := netip.AddrFrom4([4]byte{1, 2, 3, 4}); got != want {
|
||||||
|
t.Fatalf("resolveViaDoHServers() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveViaDoHServersNoServers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if _, err := resolveViaDoHServers(context.Background(), nil, "example.com"); err == nil {
|
||||||
|
t.Fatal("resolveViaDoHServers() with no servers returned nil error, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packAResponse builds a minimal DNS response carrying a single A record.
|
||||||
|
func packAResponse(t *testing.T, name string, ip [4]byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
dnsName, err := dnsmessage.NewName(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewName: %v", err)
|
||||||
|
}
|
||||||
|
msg := dnsmessage.Message{
|
||||||
|
Header: dnsmessage.Header{Response: true},
|
||||||
|
Answers: []dnsmessage.Resource{
|
||||||
|
{
|
||||||
|
Header: dnsmessage.ResourceHeader{
|
||||||
|
Name: dnsName,
|
||||||
|
Type: dnsmessage.TypeA,
|
||||||
|
Class: dnsmessage.ClassINET,
|
||||||
|
},
|
||||||
|
Body: &dnsmessage.AResource{A: ip},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := msg.Pack()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Pack: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ func serviceLogic(configPath string, isTsnetDebug bool, configURL string, logger
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
core.SetDoHServers(cfg.DNS.DoHServers)
|
||||||
|
if len(cfg.DNS.DoHServers) > 0 {
|
||||||
|
logger.Info("DNS-over-HTTPS fallback enabled", "servers", cfg.DNS.DoHServers)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancelAll := context.WithCancel(context.Background())
|
ctx, cancelAll := context.WithCancel(context.Background())
|
||||||
defer cancelAll()
|
defer cancelAll()
|
||||||
logger.Info("initializing tsnet server")
|
logger.Info("initializing tsnet server")
|
||||||
|
|||||||
Reference in New Issue
Block a user