From ea20749a229b0f080ccf79e6d08dfaada281053b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 30 Mar 2025 23:34:15 +0800 Subject: [PATCH] Add split DNS server --- .fpm | 3 + .goreleaser.fury.yaml | 6 + .goreleaser.yaml | 6 + constant/dns.go | 1 + dns/transport/split/resolve1.go | 140 +++++++++++++++++++ dns/transport/split/split.go | 191 ++++++++++++++++++++++++++ include/registry.go | 2 + option/dns.go | 4 + release/config/sing-box-split-dns.xml | 15 ++ release/config/sing-box.rules | 8 ++ release/config/sing-box.service | 2 + release/config/sing-box.sysusers | 1 + release/config/sing-box@.service | 2 + 13 files changed, 381 insertions(+) create mode 100644 dns/transport/split/resolve1.go create mode 100644 dns/transport/split/split.go create mode 100644 release/config/sing-box-split-dns.xml create mode 100644 release/config/sing-box.rules create mode 100644 release/config/sing-box.sysusers diff --git a/.fpm b/.fpm index 718244b2..757b1081 100644 --- a/.fpm +++ b/.fpm @@ -11,6 +11,9 @@ release/config/config.json=/etc/sing-box/config.json release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service +release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf +release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules +release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish diff --git a/.goreleaser.fury.yaml b/.goreleaser.fury.yaml index beef42ec..f3f3cb9b 100644 --- a/.goreleaser.fury.yaml +++ b/.goreleaser.fury.yaml @@ -56,6 +56,12 @@ nfpms: dst: /usr/lib/systemd/system/sing-box.service - src: release/config/sing-box@.service dst: /usr/lib/systemd/system/sing-box@.service + - src: release/config/sing-box.sysusers + dst: /usr/lib/sysusers.d/sing-box.conf + - src: release/config/sing-box.rules + dst: /usr/share/polkit-1/rules.d/sing-box.rules + - src: release/config/sing-box-split-dns.xml + dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf - src: release/completions/sing-box.bash dst: /usr/share/bash-completion/completions/sing-box.bash diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4a7efcf4..eba5dcd9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -138,6 +138,12 @@ nfpms: dst: /usr/lib/systemd/system/sing-box.service - src: release/config/sing-box@.service dst: /usr/lib/systemd/system/sing-box@.service + - src: release/config/sing-box.sysusers + dst: /usr/lib/sysusers.d/sing-box.conf + - src: release/config/sing-box.rules + dst: /usr/share/polkit-1/rules.d/sing-box.rules + - src: release/config/sing-box-split-dns.xml + dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf - src: release/completions/sing-box.bash dst: /usr/share/bash-completion/completions/sing-box.bash diff --git a/constant/dns.go b/constant/dns.go index 99e1ec0e..ac4adbee 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -28,6 +28,7 @@ const ( DNSTypeFakeIP = "fakeip" DNSTypeDHCP = "dhcp" DNSTypeTailscale = "tailscale" + DNSTypeSplitDNS = "split-dns" ) const ( diff --git a/dns/transport/split/resolve1.go b/dns/transport/split/resolve1.go new file mode 100644 index 00000000..36d87a10 --- /dev/null +++ b/dns/transport/split/resolve1.go @@ -0,0 +1,140 @@ +package split + +import ( + "net/netip" + "strings" + + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + + "github.com/godbus/dbus/v5" +) + +type resolve1Manager Transport + +type resolve1LinkNameserver struct { + Family int32 + Address []byte +} + +type resolve1LinkDomain struct { + Domain string + RoutingOnly bool +} + +func (t *resolve1Manager) getLink(ifIndex uint32) (*TransportLink, error) { + link, loaded := t.links[ifIndex] + if !loaded { + link = &TransportLink{} + t.links[ifIndex] = link + iif, err := t.network.InterfaceFinder().ByIndex(int(ifIndex)) + if err != nil { + return nil, dbus.MakeFailedError(err) + } + link.iif = iif + } + return link, nil +} + +func (t *resolve1Manager) SetLinkDNS(sender dbus.Sender, ifIndex uint32, addresses []resolve1LinkNameserver) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return dbus.MakeFailedError(err) + } + for _, ns := range link.nameservers { + ns.Close() + } + link.nameservers = link.nameservers[:0] + if len(addresses) > 0 { + serverDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: link.iif.Name, + UDPFragmentDefault: true, + })) + var serverAddresses []netip.Addr + for _, address := range addresses { + serverAddr, ok := netip.AddrFromSlice(address.Address) + if !ok { + return dbus.MakeFailedError(E.New("invalid address")) + } + serverAddresses = append(serverAddresses, serverAddr) + } + for _, serverAddress := range serverAddresses { + link.nameservers = append(link.nameservers, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddress, 53))) + } + t.logger.Info(sender, ": SetLinkDNS ", link.iif.Name, " ", strings.Join(common.Map(serverAddresses, netip.Addr.String), ", ")) + } else { + t.logger.Info(sender, ": SetLinkDNS ", link.iif.Name, " (empty)") + } + return nil +} + +func (t *resolve1Manager) SetLinkDomains(sender dbus.Sender, ifIndex uint32, domains []resolve1LinkDomain) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return dbus.MakeFailedError(err) + } + link.domains = domains + if len(domains) > 0 { + t.logger.Info(sender, ": SetLinkDomains ", link.iif.Name, " ", strings.Join(common.Map(domains, func(domain resolve1LinkDomain) string { + if !domain.RoutingOnly { + return domain.Domain + } else { + return domain.Domain + " (routing)" + } + }), ", ")) + } else { + t.logger.Info(sender, ": SetLinkDomains ", link.iif.Name, " (empty)") + } + return nil +} + +func (t *resolve1Manager) SetLinkDefaultRoute(sender dbus.Sender, ifIndex uint32, defaultRoute bool) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return dbus.MakeFailedError(err) + } + link.defaultRoute = defaultRoute + t.logger.Info(sender, ": SetLinkDefaultRoute ", link.iif.Name, " ", defaultRoute) + return nil +} + +func (t *resolve1Manager) SetLinkLLMNR(ifIndex uint32, llmnrMode string) { +} + +func (t *resolve1Manager) SetLinkMulticastDNS(ifIndex uint32, mdnsMode string) { +} + +func (t *resolve1Manager) SetLinkDNSOverTLS(ifIndex uint32, dotMode string) { +} + +func (t *resolve1Manager) SetLinkDNSSEC(ifIndex uint32, dnssecMode string) { +} + +func (t *resolve1Manager) SetLinkDNSSECNegativeTrustAnchors(ifIndex uint32, domains []string) { +} + +func (t *resolve1Manager) RevertLink(sender dbus.Sender, ifIndex uint32) *dbus.Error { + t.linkAccess.Lock() + defer t.linkAccess.Unlock() + link, err := t.getLink(ifIndex) + if err != nil { + return dbus.MakeFailedError(err) + } + delete(t.links, ifIndex) + t.logger.Info(sender, ": RevertLink ", link.iif.Name) + return nil +} + +func (t *resolve1Manager) FlushCaches() { + t.dnsRouter.ClearCache() +} diff --git a/dns/transport/split/split.go b/dns/transport/split/split.go new file mode 100644 index 00000000..15647228 --- /dev/null +++ b/dns/transport/split/split.go @@ -0,0 +1,191 @@ +package split + +import ( + "context" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + + "github.com/godbus/dbus/v5" + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.SplitDNSServerOptions](registry, C.DNSTypeSplitDNS, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + network adapter.NetworkManager + dnsRouter adapter.DNSRouter + logger logger.ContextLogger + acceptDefaultResolvers bool + linkAccess sync.Mutex + links map[uint32]*TransportLink +} + +type TransportLink struct { + iif *control.Interface + nameservers []adapter.DNSTransport + domains []resolve1LinkDomain + defaultRoute bool + dnsOverTLS bool +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.SplitDNSServerOptions) (adapter.DNSTransport, error) { + if !C.IsLinux { + return nil, E.New("split DNS server is only supported on Linux") + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeDHCP, tag, nil), + ctx: ctx, + logger: logger, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + network: service.FromContext[adapter.NetworkManager](ctx), + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + links: make(map[uint32]*TransportLink), + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + dnsTransportManager := service.FromContext[adapter.DNSTransportManager](t.ctx) + for _, transport := range dnsTransportManager.Transports() { + if transport.Type() == C.DNSTypeSplitDNS && transport != t { + return E.New("multiple split DNS server are not supported") + } + } + case adapter.StartStateStart: + systemBus, err := dbus.SystemBus() + if err != nil { + return err + } + reply, err := systemBus.RequestName("org.freedesktop.resolve1", dbus.NameFlagDoNotQueue) + if err != nil { + return err + } + switch reply { + case dbus.RequestNameReplyPrimaryOwner: + case dbus.RequestNameReplyExists: + return E.New("D-Bus object already exists, maybe real resolved is running") + default: + return E.New("unknown request name reply: ", reply) + } + err = systemBus.Export((*resolve1Manager)(t), "/org/freedesktop/resolve1", "org.freedesktop.resolve1.Manager") + if err != nil { + return err + } + } + return nil +} + +func (t *Transport) Close() error { + return nil +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + var selectedLink *TransportLink + for _, link := range t.links { + for _, domain := range link.domains { + if domain.RoutingOnly && !t.acceptDefaultResolvers { + continue + } + if strings.HasSuffix(question.Name, domain.Domain) { + selectedLink = link + } + } + } + if selectedLink == nil && t.acceptDefaultResolvers { + for _, link := range t.links { + if link.defaultRoute { + selectedLink = link + } + } + } + if selectedLink == nil { + return nil, dns.RcodeNameError + } + if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { + return t.exchangeParallel(ctx, selectedLink.nameservers, message) + } else { + return t.exchangeSingleRequest(ctx, selectedLink.nameservers, message) + } +} + +func (t *Transport) exchangeSingleRequest(ctx context.Context, transports []adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + var errors []error + for _, transport := range transports { + response, err := transport.Exchange(ctx, message) + if err == nil { + addresses, _ := dns.MessageToAddresses(response) + if len(addresses) == 0 { + err = E.New("empty result") + } + } + if err != nil { + errors = append(errors, err) + } else { + return response, nil + } + } + return nil, E.Errors(errors...) +} + +func (t *Transport) exchangeParallel(ctx context.Context, transports []adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + returned := make(chan struct{}) + defer close(returned) + type queryResult struct { + response *mDNS.Msg + err error + } + results := make(chan queryResult) + startRacer := func(ctx context.Context, transport adapter.DNSTransport) { + response, err := transport.Exchange(ctx, message) + if err == nil { + addresses, _ := dns.MessageToAddresses(response) + if len(addresses) == 0 { + err = E.New("empty result") + } + } + select { + case results <- queryResult{response, err}: + case <-returned: + } + } + queryCtx, queryCancel := context.WithCancel(ctx) + defer queryCancel() + var nameCount int + for _, fqdn := range transports { + nameCount++ + go startRacer(queryCtx, fqdn) + } + var errors []error + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-results: + if result.err == nil { + return result.response, nil + } + errors = append(errors, result.err) + if len(errors) == nameCount { + return nil, E.Errors(errors...) + } + } + } +} diff --git a/include/registry.go b/include/registry.go index 9be1f2b4..359d002b 100644 --- a/include/registry.go +++ b/include/registry.go @@ -13,6 +13,7 @@ import ( "github.com/sagernet/sing-box/dns/transport/fakeip" "github.com/sagernet/sing-box/dns/transport/hosts" "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/dns/transport/split" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/anytls" @@ -110,6 +111,7 @@ func DNSTransportRegistry() *dns.TransportRegistry { hosts.RegisterTransport(registry) local.RegisterTransport(registry) fakeip.RegisterTransport(registry) + split.RegisterTransport(registry) registerQUICTransports(registry) registerDHCPTransport(registry) diff --git a/option/dns.go b/option/dns.go index f303b894..54c1eb0f 100644 --- a/option/dns.go +++ b/option/dns.go @@ -387,3 +387,7 @@ type DHCPDNSServerOptions struct { LocalDNSServerOptions Interface string `json:"interface,omitempty"` } + +type SplitDNSServerOptions struct { + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` +} diff --git a/release/config/sing-box-split-dns.xml b/release/config/sing-box-split-dns.xml new file mode 100644 index 00000000..77f5d96a --- /dev/null +++ b/release/config/sing-box-split-dns.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/release/config/sing-box.rules b/release/config/sing-box.rules new file mode 100644 index 00000000..668b2640 --- /dev/null +++ b/release/config/sing-box.rules @@ -0,0 +1,8 @@ +polkit.addRule(function(action, subject) { + if ((action.id == "org.freedesktop.resolve1.set-domains" || + action.id == "org.freedesktop.resolve1.set-default-route" || + action.id == "org.freedesktop.resolve1.set-dns-servers") && + subject.user == "sing-box") { + return polkit.Result.YES; + } +}); diff --git a/release/config/sing-box.service b/release/config/sing-box.service index 388d7250..f003f844 100644 --- a/release/config/sing-box.service +++ b/release/config/sing-box.service @@ -4,6 +4,8 @@ Documentation=https://sing-box.sagernet.org After=network.target nss-lookup.target network-online.target [Service] +User=sing-box +StateDirectory=sing-box CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH ExecStart=/usr/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run diff --git a/release/config/sing-box.sysusers b/release/config/sing-box.sysusers new file mode 100644 index 00000000..ad98c100 --- /dev/null +++ b/release/config/sing-box.sysusers @@ -0,0 +1 @@ +u! sing-box - "sing-box Service" diff --git a/release/config/sing-box@.service b/release/config/sing-box@.service index 38866457..726bbee7 100644 --- a/release/config/sing-box@.service +++ b/release/config/sing-box@.service @@ -4,6 +4,8 @@ Documentation=https://sing-box.sagernet.org After=network.target nss-lookup.target network-online.target [Service] +User=sing-box +StateDirectory=sing-box-%i CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH ExecStart=/usr/bin/sing-box -D /var/lib/sing-box-%i -c /etc/sing-box/%i.json run