Jelajahi Sumber

Add DERP service

世界 6 bulan lalu
induk
melakukan
e2440a569e

+ 1 - 0
constant/proxy.go

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

+ 1 - 1
docs/configuration/endpoint/index.md

@@ -6,7 +6,7 @@ icon: material/new-box
 
 # Endpoint
 
-Endpoint is protocols that has both inbound and outbound behavior.
+An endpoint is a protocol with inbound and outbound behavior.
 
 ### Structure
 

+ 2 - 0
docs/configuration/index.md

@@ -14,6 +14,7 @@ sing-box uses JSON for configuration files.
   "inbounds": [],
   "outbounds": [],
   "route": {},
+  "services": [],
   "experimental": {}
 }
 ```
@@ -30,6 +31,7 @@ sing-box uses JSON for configuration files.
 | `inbounds`     | [Inbound](./inbound/)           |
 | `outbounds`    | [Outbound](./outbound/)         |
 | `route`        | [Route](./route/)               |
+| `services`     | [Service](./service/)           |
 | `experimental` | [Experimental](./experimental/) |
 
 ### Check

+ 2 - 0
docs/configuration/index.zh.md

@@ -14,6 +14,7 @@ sing-box 使用 JSON 作为配置文件格式。
   "inbounds": [],
   "outbounds": [],
   "route": {},
+  "services": [],
   "experimental": {}
 }
 ```
@@ -30,6 +31,7 @@ sing-box 使用 JSON 作为配置文件格式。
 | `inbounds`     | [入站](./inbound/)       |
 | `outbounds`    | [出站](./outbound/)      |
 | `route`        | [路由](./route/)         |
+| `services`     | [服务](./service/)       |
 | `experimental` | [实验性](./experimental/) |
 
 ### 检查

+ 130 - 0
docs/configuration/service/derp.md

@@ -0,0 +1,130 @@
+---
+icon: material/new-box
+---
+
+!!! question "Since sing-box 1.12.0"
+
+# DERP
+
+DERP service is a Tailscale DERP server, similar to [derper](https://pkg.go.dev/tailscale.com/cmd/derper).
+
+### Structure
+
+```json
+{
+  "type": "derp",
+  
+  ... // Listen Fields
+
+  "tls": {},
+  "config_path": "",
+  "verify_client_endpoint": [],
+  "verify_client_url": [],
+  "mesh_with": [],
+  "mesh_psk": "",
+  "mesh_psk_file": "",
+  "stun": {}
+}
+```
+
+### Listen Fields
+
+See [Listen Fields](/configuration/shared/listen/) for details.
+
+### Fields
+
+#### tls
+
+TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
+
+#### config_path
+
+==Required==
+
+Derper configuration file path.
+
+Example: `derper.key`
+
+#### verify_client_endpoint
+
+Tailscale endpoints tags to verify clients.
+
+#### verify_client_url
+
+URL to verify clients.
+
+Object format:
+
+```json
+{
+  "url": "https://my-headscale.com/verify",
+  
+  ... // Dial Fields
+}
+```
+
+Setting Array value to a string `__URL__` is equivalent to configuring:
+
+```json
+{ "url": __URL__ }
+```
+
+#### mesh_with
+
+Mesh with other DERP servers.
+
+Object format:
+
+```json
+{
+  "server": "",
+  "server_port": "",
+  "host": "",
+  "tls": {},
+  
+  ... // Dial Fields
+}
+```
+
+Object fields:
+
+- `server`: **Required** DERP server address.
+- `server_port`: **Required** DERP server port.
+- `host`: Custom DERP hostname.
+- `tls`: [TLS](/configuration/shared/tls/#outbound)
+- `Dial Fields`: [Dial Fields](/configuration/shared/dial/)
+
+#### mesh_psk
+
+Pre-shared key for DERP mesh.
+
+#### mesh_psk_file
+
+Pre-shared key file for DERP mesh.
+
+#### stun
+
+STUN server listen options.
+
+Object format:
+
+```json
+{
+  "enabled": true,
+  
+  ... // Listen Fields
+}
+```
+
+Object fields:
+
+- `enabled`: **Required** Enable STUN server.
+- `listen`: **Required** STUN server listen address, default to `::`.
+- `listen_port`: **Required** STUN server listen port, default to `3478`.
+- `other Listen Fields`: [Listen Fields](/configuration/shared/listen/)
+
+Setting `stun` value to a number `__PORT__` is equivalent to configuring:
+
+```json
+{ "enabled": true, "listen_port": __PORT__ }
+```

+ 30 - 0
docs/configuration/service/index.md

@@ -0,0 +1,30 @@
+---
+icon: material/new-box
+---
+
+!!! question "Since sing-box 1.12.0"
+
+# Service
+
+### Structure
+
+```json
+{
+  "endpoints": [
+    {
+      "type": "",
+      "tag": ""
+    }
+  ]
+}
+```
+
+### Fields
+
+| Type        | Format                   |
+|-------------|--------------------------|
+| `derp`      | [DERP](./derp)           |
+
+#### tag
+
+The tag of the endpoint.

+ 1 - 1
go.mod

@@ -6,6 +6,7 @@ require (
 	github.com/anytls/sing-anytls v0.0.8
 	github.com/caddyserver/certmagic v0.23.0
 	github.com/cloudflare/circl v1.6.1
+	github.com/coder/websocket v1.8.12
 	github.com/cretz/bine v0.2.0
 	github.com/go-chi/chi/v5 v5.2.1
 	github.com/go-chi/render v1.0.3
@@ -65,7 +66,6 @@ require (
 	github.com/bits-and-blooms/bitset v1.13.0 // indirect
 	github.com/caddyserver/zerossl v0.1.3 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
-	github.com/coder/websocket v1.8.12 // indirect
 	github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect

+ 4 - 0
include/registry.go

@@ -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"
@@ -120,6 +121,9 @@ func DNSTransportRegistry() *dns.TransportRegistry {
 
 func ServiceRegistry() *service.Registry {
 	registry := service.NewRegistry()
+
+	registerDERPService(registry)
+
 	return registry
 }
 

+ 6 - 0
include/tailscale.go

@@ -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,7 @@ func registerTailscaleEndpoint(registry *endpoint.Registry) {
 func registerTailscaleTransport(registry *dns.TransportRegistry) {
 	tailscale.RegistryTransport(registry)
 }
+
+func registerDERPService(registry *service.Registry) {
+	derp.Register(registry)
+}

+ 7 - 0
include/tailscale_stub.go

@@ -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,9 @@ 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`)
+	})
+}

+ 3 - 0
mkdocs.yml

@@ -169,6 +169,9 @@ nav:
           - DNS: configuration/outbound/dns.md
           - Selector: configuration/outbound/selector.md
           - URLTest: configuration/outbound/urltest.md
+      - Service:
+          - configuration/service/index.md
+          - DERP: configuration/service/derp.md
 markdown_extensions:
   - pymdownx.inlinehilite
   - pymdownx.snippets

+ 90 - 0
option/tailscale.go

@@ -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 {
@@ -23,3 +29,87 @@ 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"`
+	STUN                 *DERPSTUNListenOptions                          `json:"stun,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 _DERPSTUNListenOptions struct {
+	Enabled bool
+	ListenOptions
+}
+
+type DERPSTUNListenOptions _DERPSTUNListenOptions
+
+func (d DERPSTUNListenOptions) MarshalJSON() ([]byte, error) {
+	portOptions := _DERPSTUNListenOptions{
+		Enabled: d.Enabled,
+		ListenOptions: ListenOptions{
+			ListenPort: d.ListenPort,
+		},
+	}
+	if _DERPSTUNListenOptions(d) == portOptions {
+		return json.Marshal(d.Enabled)
+	} else {
+		return json.Marshal(_DERPSTUNListenOptions(d))
+	}
+}
+
+func (d *DERPSTUNListenOptions) UnmarshalJSON(bytes []byte) error {
+	var portValue uint16
+	err := json.Unmarshal(bytes, &portValue)
+	if err == nil {
+		d.Enabled = true
+		d.ListenPort = portValue
+		return nil
+	}
+	return json.Unmarshal(bytes, (*_DERPSTUNListenOptions)(d))
+}

+ 518 - 0
service/derp/service.go

@@ -0,0 +1,518 @@
+package derp
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/netip"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"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/json/badoption"
+	"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/stun"
+	"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
+	stunListener         *listener.Listener
+	tlsConfig            tls.ServerConfig
+	server               *derp.Server
+	configPath           string
+	verifyClientEndpoint []string
+	verifyClientURL      []*option.DERPVerifyClientURLOptions
+	home                 string
+	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 {
+		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")
+		}
+	}
+
+	var stunListener *listener.Listener
+	if options.STUN != nil && options.STUN.Enabled {
+		if options.STUN.Listen == nil {
+			options.STUN.Listen = (*badoption.Addr)(common.Ptr(netip.IPv6Unspecified()))
+		}
+		if options.STUN.ListenPort == 0 {
+			options.STUN.ListenPort = 3478
+		}
+		stunListener = listener.New(listener.Options{
+			Context: ctx,
+			Logger:  logger,
+			Network: []string{N.NetworkUDP},
+			Listen:  options.STUN.ListenOptions,
+		})
+	}
+
+	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,
+		}),
+		stunListener:         stunListener,
+		tlsConfig:            tlsConfig,
+		configPath:           configPath,
+		verifyClientEndpoint: options.VerifyClientEndpoint,
+		verifyClientURL:      options.VerifyClientURL,
+		meshKey:              options.MeshPSK,
+		meshKeyPath:          options.MeshPSKFile,
+		meshWith:             options.MeshWith,
+	}, nil
+}
+
+func (d *Service) Start(stage adapter.StartStage) error {
+	switch stage {
+	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(),
+					NewDialer:      true,
+				})
+				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)))
+		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)
+
+		if d.stunListener != nil {
+			stunConn, err := d.stunListener.ListenUDP()
+			if err != nil {
+				return err
+			}
+			go d.loopSTUNPacket(stunConn.(*net.UDPConn))
+		}
+	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()
+	}
+	var serverURL string
+	if stdConfig != nil {
+		serverURL = "https://" + meshHost + "/derp"
+	} else {
+		serverURL = "http://" + meshHost + "/derp"
+	}
+	meshClient, err := derphttp.NewClient(derpServer.PrivateKey(), serverURL, 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) 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, adapter.DNSQueryOptions{})
+			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
+}
+
+func (d *Service) loopSTUNPacket(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)
+	}
+}

+ 1 - 1
test/box_test.go

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