add doh support

This commit is contained in:
iceBear67
2026-07-26 14:14:03 +08:00
parent 20097a02a1
commit 5dc1759d80
9 changed files with 335 additions and 32 deletions
+1
View File
@@ -3,3 +3,4 @@
.idea/**
.gocache/**
tslink
config.toml
+12
View File
@@ -14,6 +14,14 @@
| `ephemeral` | bool | 否 | `true` | 节点是否临时节点,离开 Tailnet 后自动删除 |
| `accept_routes` | bool | 否 | `true` | 是否接受其他节点发布的子网路由 |
### `[dns]` — DNS 解析配置
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `doh_servers` | []string | 否 | `[]` | DNS-over-HTTPSRFC 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 → 本地)
将 Tailscale 上的流量转发到本地服务。`<name>` 为自定义标签名。
@@ -89,6 +97,10 @@ hostname = "" # 可选,留空使用本机主机名
ephemeral = 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
[[forward.web]]
protocol = "tcp"
+7
View File
@@ -5,6 +5,13 @@ hostname = "" # leave blank to use machine name
ephemeral = 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
protocol = "tcp"
tailscale_port = 8080
+16
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -41,6 +42,13 @@ type Core struct {
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 {
if r.LanEnable != nil {
return *r.LanEnable
@@ -67,6 +75,7 @@ func (r ConnectRule) BindIP() string {
type Config struct {
Core Core `toml:"core"`
DNS DNS `toml:"dns"`
Forward map[string][]ForwardRule `toml:"forward"`
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"))
}
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)
usedConnectListeners := make(map[string]string)
+24
View File
@@ -51,6 +51,7 @@ func TestConfigValidateAcceptsValidConfig(t *testing.T) {
cfg := Config{
Core: Core{AuthKey: "tskey-auth-example"},
DNS: DNS{DoHServers: []string{"https://cloudflare-dns.com/dns-query"}},
Forward: map[string][]ForwardRule{
"web": {
{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 {
return &v
}
+58 -26
View File
@@ -117,11 +117,6 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
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
// MagicDNS suffix appended so short tailnet hostnames still resolve; a name
// that already contains a dot (an FQDN, including split-DNS suffixes) is
@@ -132,6 +127,7 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
}
var lastErr error
if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok {
for _, name := range candidates {
ip, err := resolveHostViaResolver(ctx, dnsMgr, name)
if err != nil {
@@ -140,17 +136,61 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin
}
return net.JoinHostPort(ip.String(), port), nil
}
return addr, fmt.Errorf("resolve %q via tailnet DNS: %w", host, lastErr)
} else {
lastErr = errors.New("DNS manager not available")
}
// 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
// Tailscale DNS resolver. It queries A then AAAA records and follows CNAME
// chains (up to 8 levels deep).
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
if depth > maxCNAMEChase {
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)
}
// 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
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 {
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 != "" {
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)
}
// queryResolver sends a single question of the given type to the Tailscale DNS
// resolver and returns the first address answer, or a CNAME target if one is
// present instead.
func queryResolver(ctx context.Context, r *dns.Manager, name dnsmessage.Name, qType dnsmessage.Type) (netip.Addr, string, error) {
// buildDNSQuery packs a single-question DNS query message for name/qType.
func buildDNSQuery(name dnsmessage.Name, qType dnsmessage.Type) ([]byte, error) {
msg := dnsmessage.Message{
Header: dnsmessage.Header{RecursionDesired: true},
Questions: []dnsmessage.Question{
@@ -198,16 +232,14 @@ func queryResolver(ctx context.Context, r *dns.Manager, name dnsmessage.Name, qT
}
queryBytes, err := msg.Pack()
if err != nil {
return netip.Addr{}, "", 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 nil, fmt.Errorf("failed to pack DNS query: %w", 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
if err := resp.Unpack(respBytes); err != nil {
return netip.Addr{}, "", fmt.Errorf("failed to unpack DNS response: %w", err)
+110
View File
@@ -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)
}
}
+96
View File
@@ -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
}
+5
View File
@@ -28,6 +28,11 @@ func serviceLogic(configPath string, isTsnetDebug bool, configURL string, logger
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())
defer cancelAll()
logger.Info("initializing tsnet server")