add doh support
This commit is contained in:
@@ -3,3 +3,4 @@
|
||||
.idea/**
|
||||
.gocache/**
|
||||
tslink
|
||||
config.toml
|
||||
|
||||
@@ -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.<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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+57
-25
@@ -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)
|
||||
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
|
||||
if err := resp.Unpack(respBytes); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user