Add DERP service

This commit is contained in:
世界 2025-03-29 17:24:34 +08:00
parent 85a8d8163f
commit 9e6de24f15
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
8 changed files with 643 additions and 1 deletions

View file

@ -25,6 +25,8 @@ const (
TypeTUIC = "tuic"
TypeHysteria2 = "hysteria2"
TypeTailscale = "tailscale"
TypeDERP = "derp"
TypeDERPSTUN = "derp-stun"
)
const (

View file

@ -7,6 +7,7 @@ import (
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/dns/transport"
@ -118,6 +119,14 @@ func DNSTransportRegistry() *dns.TransportRegistry {
return registry
}
func ServiceRegistry() *service.Registry {
registry := service.NewRegistry()
registerDERPService(registry)
return registry
}
func ServiceRegistry() *service.Registry {
registry := service.NewRegistry()
return registry

View file

@ -4,8 +4,10 @@ package include
import (
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/protocol/tailscale"
"github.com/sagernet/sing-box/service/derp"
)
func registerTailscaleEndpoint(registry *endpoint.Registry) {
@ -15,3 +17,8 @@ func registerTailscaleEndpoint(registry *endpoint.Registry) {
func registerTailscaleTransport(registry *dns.TransportRegistry) {
tailscale.RegistryTransport(registry)
}
func registerDERPService(registry *service.Registry) {
derp.Register(registry)
derp.RegisterSTUN(registry)
}

View file

@ -7,6 +7,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/log"
@ -25,3 +26,12 @@ func registerTailscaleTransport(registry *dns.TransportRegistry) {
return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`)
})
}
func registerDERPService(registry *service.Registry) {
service.Register[option.DERPServiceOptions](registry, C.TypeDERP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) {
return nil, E.New(`DERP is not included in this build, rebuild with -tags with_tailscale`)
})
service.Register[option.DERPSTUNServiceOptions](registry, C.TypeDERP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPSTUNServiceOptions) (adapter.Service, error) {
return nil, E.New(`STUN (DERP) is not included in this build, rebuild with -tags with_tailscale`)
})
}

View file

@ -2,6 +2,12 @@ package option
import (
"net/netip"
"net/url"
"reflect"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
)
type TailscaleEndpointOptions struct {
@ -22,3 +28,59 @@ type TailscaleDNSServerOptions struct {
Endpoint string `json:"endpoint,omitempty"`
AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"`
}
type DERPServiceOptions struct {
ListenOptions
InboundTLSOptionsContainer
ConfigPath string `json:"config_path,omitempty"`
VerifyClientEndpoint badoption.Listable[string] `json:"verify_client_endpoint,omitempty"`
VerifyClientURL badoption.Listable[DERPVerifyClientURLOptions] `json:"verify_client_url,omitempty"`
MeshWith badoption.Listable[DERPMeshOptions] `json:"mesh_with,omitempty"`
MeshPSK string `json:"mesh_psk,omitempty"`
MeshPSKFile string `json:"mesh_psk_file,omitempty"`
DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"`
}
type _DERPVerifyClientURLOptions struct {
URL string `json:"url,omitempty"`
DialerOptions
}
type DERPVerifyClientURLOptions _DERPVerifyClientURLOptions
func (d DERPVerifyClientURLOptions) ServerIsDomain() bool {
verifyURL, err := url.Parse(d.URL)
if err != nil {
return false
}
return M.IsDomainName(verifyURL.Host)
}
func (d DERPVerifyClientURLOptions) MarshalJSON() ([]byte, error) {
if reflect.DeepEqual(d, _DERPVerifyClientURLOptions{}) {
return json.Marshal(d.URL)
} else {
return json.Marshal(_DERPVerifyClientURLOptions(d))
}
}
func (d *DERPVerifyClientURLOptions) UnmarshalJSON(bytes []byte) error {
var stringValue string
err := json.Unmarshal(bytes, &stringValue)
if err == nil {
d.URL = stringValue
return nil
}
return json.Unmarshal(bytes, (*_DERPVerifyClientURLOptions)(d))
}
type DERPMeshOptions struct {
ServerOptions
Host string `json:"host,omitempty"`
OutboundTLSOptionsContainer
DialerOptions
}
type DERPSTUNServiceOptions struct {
ListenOptions
}

463
service/derp/derp.go Normal file
View file

@ -0,0 +1,463 @@
package derp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
boxScale "github.com/sagernet/sing-box/protocol/tailscale"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
"github.com/sagernet/tailscale/client/tailscale"
"github.com/sagernet/tailscale/derp"
"github.com/sagernet/tailscale/derp/derphttp"
"github.com/sagernet/tailscale/net/netmon"
"github.com/sagernet/tailscale/net/wsconn"
"github.com/sagernet/tailscale/tsweb"
"github.com/sagernet/tailscale/types/key"
"github.com/coder/websocket"
"github.com/go-chi/render"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func Register(registry *boxService.Registry) {
boxService.Register[option.DERPServiceOptions](registry, C.TypeDERP, NewService)
}
type Service struct {
boxService.Adapter
ctx context.Context
logger logger.ContextLogger
listener *listener.Listener
tlsConfig tls.ServerConfig
server *derp.Server
configPath string
verifyClientEndpoint []string
verifyClientURL []option.DERPVerifyClientURLOptions
home string
domainResolveOptions *option.DomainResolveOptions
domainResolver *adapter.DNSQueryOptions
meshKey string
meshKeyPath string
meshWith []option.DERPMeshOptions
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) {
if options.TLS == nil || !options.TLS.Enabled {
return nil, E.New("TLS is required for DERP server")
}
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
if err != nil {
return nil, err
}
var configPath string
if options.ConfigPath != "" {
configPath = filemanager.BasePath(ctx, os.ExpandEnv(options.ConfigPath))
} else if os.Getuid() == 0 {
configPath = "/var/lib/derper/derper.key"
} else {
return nil, E.New("missing config_path")
}
if options.MeshPSK != "" {
err = checkMeshKey(options.MeshPSK)
if err != nil {
return nil, E.Cause(err, "invalid mesh_psk")
}
}
return &Service{
Adapter: boxService.NewAdapter(C.TypeDERP, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
tlsConfig: tlsConfig,
configPath: configPath,
verifyClientEndpoint: options.VerifyClientEndpoint,
verifyClientURL: options.VerifyClientURL,
meshKey: options.MeshPSK,
meshKeyPath: options.MeshPSKFile,
meshWith: options.MeshWith,
domainResolveOptions: options.DomainResolver,
}, nil
}
func (d *Service) Start(stage adapter.StartStage) error {
switch stage {
case adapter.StartStateInitialize:
domainResolver, err := adapter.DNSQueryOptionsFrom(d.ctx, d.domainResolveOptions)
if err != nil {
return err
}
d.domainResolver = domainResolver
case adapter.StartStateStart:
config, err := readDERPConfig(d.configPath)
if err != nil {
return err
}
server := derp.NewServer(config.PrivateKey, func(format string, args ...any) {
d.logger.Debug(fmt.Sprintf(format, args...))
})
if len(d.verifyClientURL) > 0 {
var httpClients []*http.Client
var urls []string
for index, options := range d.verifyClientURL {
verifyDialer, createErr := dialer.NewWithOptions(dialer.Options{
Context: d.ctx,
Options: options.DialerOptions,
RemoteIsDomain: options.ServerIsDomain(),
})
if createErr != nil {
return E.Cause(createErr, "verify_client_url[", index, "]")
}
httpClients = append(httpClients, &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return verifyDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
},
})
urls = append(urls, options.URL)
}
server.SetVerifyClientHTTPClient(httpClients)
server.SetVerifyClientURL(urls)
}
if d.meshKey != "" {
server.SetMeshKey(d.meshKey)
} else if d.meshKeyPath != "" {
var meshKeyContent []byte
meshKeyContent, err = os.ReadFile(d.meshKeyPath)
if err != nil {
return err
}
err = checkMeshKey(string(meshKeyContent))
if err != nil {
return E.Cause(err, "invalid mesh_psk_path file")
}
server.SetMeshKey(string(meshKeyContent))
}
d.server = server
derpMux := http.NewServeMux()
derpHandler := derphttp.Handler(server)
derpHandler = addWebSocketSupport(server, derpHandler)
derpMux.Handle("/derp", derpHandler)
homeHandler, ok := getHomeHandler(d.home)
if !ok {
return E.New("invalid home value: ", d.home)
}
derpMux.HandleFunc("/derp/probe", derphttp.ProbeHandler)
derpMux.HandleFunc("/derp/latency-check", derphttp.ProbeHandler)
derpMux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS(d.ctx, d.domainResolver)))
derpMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
homeHandler.ServeHTTP(w, r)
}))
derpMux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
io.WriteString(w, "User-agent: *\nDisallow: /\n")
}))
derpMux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent))
err = d.tlsConfig.Start()
if err != nil {
return err
}
tcpListener, err := d.listener.ListenTCP()
if err != nil {
return err
}
if len(d.tlsConfig.NextProtos()) == 0 {
d.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"})
} else if !common.Contains(d.tlsConfig.NextProtos(), http2.NextProtoTLS) {
d.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, d.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, d.tlsConfig)
httpServer := &http.Server{
Handler: h2c.NewHandler(derpMux, &http2.Server{}),
}
go httpServer.Serve(tcpListener)
case adapter.StartStatePostStart:
if len(d.verifyClientEndpoint) > 0 {
var endpoints []*tailscale.LocalClient
endpointManager := service.FromContext[adapter.EndpointManager](d.ctx)
for _, endpointTag := range d.verifyClientEndpoint {
endpoint, loaded := endpointManager.Get(endpointTag)
if !loaded {
return E.New("verify_client_endpoint: endpoint not found: ", endpointTag)
}
tsEndpoint, isTailscale := endpoint.(*boxScale.Endpoint)
if !isTailscale {
return E.New("verify_client_endpoint: endpoint is not Tailscale: ", endpointTag)
}
localClient, err := tsEndpoint.Server().LocalClient()
if err != nil {
return err
}
endpoints = append(endpoints, localClient)
}
d.server.SetVerifyClientLocalClient(endpoints)
}
if len(d.meshWith) > 0 {
if !d.server.HasMeshKey() {
return E.New("missing mesh psk")
}
for _, options := range d.meshWith {
err := d.startMeshWithHost(d.server, options)
if err != nil {
return err
}
}
}
}
return nil
}
func checkMeshKey(meshKey string) error {
checkRegex, err := regexp.Compile(`^[0-9a-f]{64}$`)
if err != nil {
return err
}
if !checkRegex.MatchString(meshKey) {
return E.New("key must contain exactly 64 hex digits")
}
return nil
}
func (d *Service) startMeshWithHost(derpServer *derp.Server, server option.DERPMeshOptions) error {
meshDialer, err := dialer.NewWithOptions(dialer.Options{
Context: d.ctx,
Options: server.DialerOptions,
RemoteIsDomain: server.ServerIsDomain(),
NewDialer: true,
})
if err != nil {
return err
}
var hostname string
if server.Host != "" {
hostname = server.Host
} else {
hostname = server.Server
}
var stdConfig *tls.STDConfig
if server.TLS != nil && server.TLS.Enabled {
tlsConfig, err := tls.NewClient(d.ctx, hostname, common.PtrValueOrDefault(server.TLS))
if err != nil {
return err
}
stdConfig, err = tlsConfig.Config()
if err != nil {
return err
}
}
logf := func(format string, args ...any) {
d.logger.Debug(F.ToString("mesh(", hostname, "): ", fmt.Sprintf(format, args...)))
}
var meshHost string
if server.ServerPort == 0 || server.ServerPort == 443 {
meshHost = hostname
} else {
meshHost = M.ParseSocksaddrHostPort(hostname, server.ServerPort).String()
}
meshClient, err := derphttp.NewClient(derpServer.PrivateKey(), "https://"+meshHost+"/derp", logf, netmon.NewStatic())
if err != nil {
return err
}
meshClient.TLSConfig = stdConfig
meshClient.MeshKey = derpServer.MeshKey()
meshClient.WatchConnectionChanges = true
meshClient.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) {
return meshDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
})
add := func(m derp.PeerPresentMessage) { derpServer.AddPacketForwarder(m.Key, meshClient) }
remove := func(m derp.PeerGoneMessage) { derpServer.RemovePacketForwarder(m.Peer, meshClient) }
go meshClient.RunWatchConnectionLoop(context.Background(), derpServer.PublicKey(), logf, add, remove)
return nil
}
func (d *Service) Close() error {
return common.Close(
common.PtrOrNil(d.listener),
d.tlsConfig,
)
}
var homePage = `
<h1>DERP</h1>
<p>
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
</p>
<p>
It provides STUN, interactive connectivity establishment, and relaying of end-to-end encrypted traffic
for Tailscale clients.
</p>
<p>
Documentation:
</p>
<ul>
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
</body>
</html>
`
func getHomeHandler(val string) (_ http.Handler, ok bool) {
if val == "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(homePage))
}), true
}
if val == "blank" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
}), true
}
if strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") {
return http.RedirectHandler(val, http.StatusFound), true
}
return nil, false
}
func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := strings.ToLower(r.Header.Get("Upgrade"))
// Very early versions of Tailscale set "Upgrade: WebSocket" but didn't actually
// speak WebSockets (they still assumed DERP's binary framing). So to distinguish
// clients that actually want WebSockets, look for an explicit "derp" subprotocol.
if up != "websocket" || !strings.Contains(r.Header.Get("Sec-Websocket-Protocol"), "derp") {
base.ServeHTTP(w, r)
return
}
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"derp"},
OriginPatterns: []string{"*"},
// Disable compression because we transmit WireGuard messages that
// are not compressible.
// Additionally, Safari has a broken implementation of compression
// (see https://github.com/nhooyr/websocket/issues/218) that makes
// enabling it actively harmful.
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
return
}
defer c.Close(websocket.StatusInternalError, "closing")
if c.Subprotocol() != "derp" {
c.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol")
return
}
wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary, r.RemoteAddr)
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
})
}
func handleBootstrapDNS(ctx context.Context, queryOptions *adapter.DNSQueryOptions) http.HandlerFunc {
dnsRouter := service.FromContext[adapter.DNSRouter](ctx)
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Connection", "close")
if queryDomain := r.URL.Query().Get("q"); queryDomain != "" {
addresses, err := dnsRouter.Lookup(ctx, queryDomain, *queryOptions)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
render.JSON(w, r, render.M{
queryDomain: addresses,
})
return
}
w.Write([]byte("{}"))
}
}
type derpConfig struct {
PrivateKey key.NodePrivate
}
func readDERPConfig(path string) (*derpConfig, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return writeNewDERPConfig(path)
}
return nil, err
}
var config derpConfig
err = json.Unmarshal(content, &config)
if err != nil {
return nil, err
}
return &config, nil
}
func writeNewDERPConfig(path string) (*derpConfig, error) {
newKey := key.NewNode()
err := os.MkdirAll(filepath.Dir(path), 0o777)
if err != nil {
return nil, err
}
config := derpConfig{
PrivateKey: newKey,
}
content, err := json.Marshal(config)
if err != nil {
return nil, err
}
err = os.WriteFile(path, content, 0o644)
if err != nil {
return nil, err
}
return &config, nil
}

89
service/derp/stun.go Normal file
View file

@ -0,0 +1,89 @@
package derp
import (
"context"
"net"
"net/netip"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/tailscale/net/stun"
)
func RegisterSTUN(registry *boxService.Registry) {
boxService.Register[option.DERPSTUNServiceOptions](registry, C.TypeDERPSTUN, NewSTUNService)
}
type STUNService struct {
boxService.Adapter
ctx context.Context
logger logger.ContextLogger
listener *listener.Listener
}
func NewSTUNService(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPSTUNServiceOptions) (adapter.Service, error) {
return &STUNService{
Adapter: boxService.NewAdapter(C.TypeDERPSTUN, tag),
ctx: ctx,
logger: logger,
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkUDP},
Listen: options.ListenOptions,
}),
}, nil
}
func (d *STUNService) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
packetConn, err := d.listener.ListenUDP()
if err != nil {
return err
}
go d.loopPacket(packetConn.(*net.UDPConn))
return nil
}
func (d *STUNService) Close() error {
return d.listener.Close()
}
func (d *STUNService) loopPacket(packetConn *net.UDPConn) {
buffer := make([]byte, 65535)
oob := make([]byte, 1024)
var (
n int
oobN int
addrPort netip.AddrPort
err error
)
for {
n, oobN, _, addrPort, err = packetConn.ReadMsgUDPAddrPort(buffer, oob)
if err != nil {
if E.IsClosedOrCanceled(err) {
return
}
time.Sleep(time.Second)
continue
}
if !stun.Is(buffer[:n]) {
continue
}
txid, err := stun.ParseBindingRequest(buffer[:n])
if err != nil {
continue
}
packetConn.WriteMsgUDPAddrPort(stun.Response(txid, addrPort), oob[:oobN], addrPort)
}
}

View file

@ -32,7 +32,7 @@ func TestMain(m *testing.M) {
var globalCtx context.Context
func init() {
globalCtx = box.Context(context.Background(), include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), include.DNSTransportRegistry())
globalCtx = box.Context(context.Background(), include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), include.DNSTransportRegistry(), include.ServiceRegistry())
}
func startInstance(t *testing.T, options option.Options) *box.Box {