From a2b9062f2c67005defc79242dd3d4daf1e2dc213 Mon Sep 17 00:00:00 2001 From: Mxmilu666 Date: Sat, 6 Jun 2026 11:12:05 +0800 Subject: [PATCH] feat(dns): add support for DNS-over-HTTPS and update version --- cmd/frpc/sub/root.go | 8 +- pkg/config/flags.go | 2 +- pkg/config/v1/client.go | 3 +- pkg/util/net/dns.go | 6 ++ pkg/util/net/doh.go | 181 ++++++++++++++++++++++++++++++++++++ pkg/util/version/version.go | 2 +- 6 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 pkg/util/net/doh.go diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 259b96e6..11c6e467 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -300,7 +300,13 @@ func runClientWithTokenAndIDs(token string, ids []string, unsafeFeatures *securi // Build URL with query parameters url := fmt.Sprintf("%s/api/v1/tunnel/frpc/config?token=%s&id=%s", apiServer, token, strings.Join(ids, ",")) // #nosec G107 -- URL is constructed from trusted source (environment variable or hardcoded) - resp, err := http.Get(url) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create API request: %v", err) + } + // Carry client version in User-Agent so the API knows which frpc version is requesting + req.Header.Set("User-Agent", version.Full()) + resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("failed to fetch config from API: %v", err) } diff --git a/pkg/config/flags.go b/pkg/config/flags.go index e1f2251d..b1849cd6 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -163,7 +163,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days") cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") - cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one") + cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one, support DoH url like https://1.1.1.1/dns-query") c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") } cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index bb95b6cd..c0d40f33 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -49,7 +49,8 @@ type ClientCommonConfig struct { // STUN server to help penetrate NAT hole. NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"` // DNSServer specifies a DNS server address for FRPC to use. If this value - // is "", the default DNS will be used. + // is "", the default DNS will be used. A DNS-over-HTTPS endpoint is also + // supported, e.g. "https://1.1.1.1/dns-query". DNSServer string `json:"dnsServer,omitempty"` // LoginFailExit controls whether or not the client should exit after a // failed login attempt. If false, the client will retry until a login diff --git a/pkg/util/net/dns.go b/pkg/util/net/dns.go index 441b1e67..ba5b3ff6 100644 --- a/pkg/util/net/dns.go +++ b/pkg/util/net/dns.go @@ -17,9 +17,15 @@ package net import ( "context" "net" + "strings" ) func SetDefaultDNSAddress(dnsAddress string) { + // DNS-over-HTTPS endpoint, e.g. https://1.1.1.1/dns-query + if strings.HasPrefix(dnsAddress, "https://") { + SetDefaultDNSOverHTTPS(dnsAddress) + return + } if _, _, err := net.SplitHostPort(dnsAddress); err != nil { dnsAddress = net.JoinHostPort(dnsAddress, "53") } diff --git a/pkg/util/net/doh.go b/pkg/util/net/doh.go new file mode 100644 index 00000000..499a708f --- /dev/null +++ b/pkg/util/net/doh.go @@ -0,0 +1,181 @@ +// Copyright 2026 The Lolia Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package net + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" +) + +const ( + dohMimeType = "application/dns-message" + dohMaxResponseSize = 65535 + dohRequestTimeout = 10 * time.Second +) + +// SetDefaultDNSOverHTTPS replaces net.DefaultResolver with one that sends +// DNS queries to the given DNS-over-HTTPS (RFC 8484) endpoint, +// e.g. "https://1.1.1.1/dns-query" or "https://dns.google/dns-query". +func SetDefaultDNSOverHTTPS(dohURL string) { + client := &http.Client{ + Timeout: dohRequestTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: dohRequestTimeout, + // Use a fresh resolver to look up the DoH server hostname itself, + // avoiding infinite recursion through net.DefaultResolver. + Resolver: &net.Resolver{}, + }).DialContext, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + } + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + return &dohConn{ + ctx: ctx, + client: client, + url: dohURL, + network: network, + }, nil + }, + } +} + +// dohConn adapts the DNS wire-format messages exchanged by net.Resolver +// into DNS-over-HTTPS requests. Since dohConn does not implement +// net.PacketConn, the resolver always uses stream (TCP-style) framing with a +// 2-byte big-endian length prefix, regardless of the dialed network. The +// resolver writes a query and then reads the response from the same +// connection; the HTTP round trip happens synchronously inside Write. +type dohConn struct { + ctx context.Context + client *http.Client + url string + network string + + mu sync.Mutex + deadline time.Time + reqBuf bytes.Buffer // unprocessed request bytes + respBuf bytes.Buffer // response stream with 2-byte length prefixes +} + +func (c *dohConn) Write(b []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + + c.reqBuf.Write(b) + for { + data := c.reqBuf.Bytes() + if len(data) < 2 { + break + } + msgLen := int(binary.BigEndian.Uint16(data)) + if len(data) < 2+msgLen { + break + } + msg := make([]byte, msgLen) + copy(msg, data[2:2+msgLen]) + c.reqBuf.Next(2 + msgLen) + + resp, err := c.roundTrip(msg) + if err != nil { + return 0, err + } + var lenBuf [2]byte + binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp))) + c.respBuf.Write(lenBuf[:]) + c.respBuf.Write(resp) + } + return len(b), nil +} + +func (c *dohConn) Read(b []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.respBuf.Len() == 0 { + return 0, io.EOF + } + return c.respBuf.Read(b) +} + +func (c *dohConn) roundTrip(query []byte) ([]byte, error) { + ctx := c.ctx + if !c.deadline.IsZero() { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, c.deadline) + defer cancel() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(query)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", dohMimeType) + req.Header.Set("Accept", dohMimeType) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("doh server %s returned status %d", c.url, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, dohMaxResponseSize+1)) + if err != nil { + return nil, err + } + if len(body) > dohMaxResponseSize { + return nil, fmt.Errorf("doh response from %s exceeds %d bytes", c.url, dohMaxResponseSize) + } + return body, nil +} + +func (c *dohConn) Close() error { return nil } + +func (c *dohConn) LocalAddr() net.Addr { return &dohAddr{network: c.network, addr: "doh-client"} } +func (c *dohConn) RemoteAddr() net.Addr { return &dohAddr{network: c.network, addr: c.url} } + +func (c *dohConn) SetDeadline(t time.Time) error { + c.mu.Lock() + defer c.mu.Unlock() + c.deadline = t + return nil +} + +func (c *dohConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dohConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) } + +type dohAddr struct { + network string + addr string +} + +func (a *dohAddr) Network() string { return a.network } +func (a *dohAddr) String() string { return a.addr } diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index 01c6b715..7103d0ae 100644 --- a/pkg/util/version/version.go +++ b/pkg/util/version/version.go @@ -14,7 +14,7 @@ package version -var version = "LoliaFRP-CLI 0.67.4" +var version = "LoliaFRP-CLI 0.67.5" func Full() string { return version