feat(dns): add support for DNS-over-HTTPS and update version

This commit is contained in:
2026-06-06 11:12:05 +08:00
parent 26f3af3fdd
commit a2b9062f2c
6 changed files with 198 additions and 4 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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")
}

181
pkg/util/net/doh.go Normal file
View File

@@ -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 }

View File

@@ -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