diff --git a/.gitignore b/.gitignore index 226287a..3f35734 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .idea/** .gocache/** tslink +config.toml diff --git a/USAGE.md b/USAGE.md index b1ada1b..47fb352 100644 --- a/USAGE.md +++ b/USAGE.md @@ -14,6 +14,14 @@ | `ephemeral` | bool | 否 | `true` | 节点是否临时节点,离开 Tailnet 后自动删除 | | `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.]]` — 转发规则(Tailscale → 本地) 将 Tailscale 上的流量转发到本地服务。`` 为自定义标签名。 @@ -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" diff --git a/config.example.toml b/config.example.toml index 6b2f9b3..9866d86 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 diff --git a/core/config.go b/core/config.go index 65ba94e..dac5fff 100644 --- a/core/config.go +++ b/core/config.go @@ -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) diff --git a/core/config_test.go b/core/config_test.go index 615b789..5658df8 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -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 } diff --git a/core/dns.go b/core/dns.go index d1375ff..52cfd2b 100644 --- a/core/dns.go +++ b/core/dns.go @@ -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,25 +127,70 @@ func resolveDialAddr(ctx context.Context, srv *tsnet.Server, addr string) (strin } var lastErr error - for _, name := range candidates { - ip, err := resolveHostViaResolver(ctx, dnsMgr, name) - if err != nil { - lastErr = err - continue + if dnsMgr, ok := srv.Sys().DNSManager.GetOK(); ok { + for _, name := range candidates { + ip, err := resolveHostViaResolver(ctx, dnsMgr, name) + if err != nil { + 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 // 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) diff --git a/core/doh.go b/core/doh.go new file mode 100644 index 0000000..ac3f378 --- /dev/null +++ b/core/doh.go @@ -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) + } +} diff --git a/core/doh_test.go b/core/doh_test.go new file mode 100644 index 0000000..76b201b --- /dev/null +++ b/core/doh_test.go @@ -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 +} diff --git a/main.go b/main.go index 0045d14..399078c 100644 --- a/main.go +++ b/main.go @@ -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")