476 lines
13 KiB
Go
476 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
minidns "github.com/netkits-dev/mini-dns"
|
|
"github.com/netkits-dev/mini-sing/adapter"
|
|
"github.com/netkits-dev/mini-sing/common/dialer"
|
|
C "github.com/netkits-dev/mini-sing/constant"
|
|
"github.com/netkits-dev/mini-sing/dns"
|
|
"github.com/netkits-dev/mini-sing/include"
|
|
"github.com/netkits-dev/mini-sing/log"
|
|
"github.com/netkits-dev/mini-sing/manager"
|
|
"github.com/netkits-dev/mini-sing/option"
|
|
"github.com/netkits-dev/mini-sing/protocol/direct"
|
|
"github.com/netkits-dev/mini-sing/protocol/tailscale"
|
|
"github.com/netkits-dev/mini-sing/route"
|
|
"github.com/sagernet/sing/common/logger"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
)
|
|
|
|
// DelayEntry records a single delay test result.
|
|
type DelayEntry struct {
|
|
Time time.Time `json:"time"`
|
|
Delay int `json:"delay"`
|
|
}
|
|
|
|
type Box struct {
|
|
createdAt time.Time
|
|
logFactory log.Factory
|
|
logger logger.ContextLogger
|
|
inbound *manager.InboundManager
|
|
outbound *manager.OutboundManager
|
|
connection *route.ConnectionManager
|
|
router *route.Router
|
|
resolver *dns.Resolver
|
|
apiListen string
|
|
apiLn net.Listener
|
|
clashMode string
|
|
modeList []string
|
|
|
|
delayHistory map[string][]DelayEntry // outbound tag -> recent delay results
|
|
delayHistoryMu sync.RWMutex
|
|
}
|
|
|
|
const maxDelayHistory = 10
|
|
|
|
func (b *Box) recordDelay(tag string, delay int) {
|
|
b.delayHistoryMu.Lock()
|
|
defer b.delayHistoryMu.Unlock()
|
|
if b.delayHistory == nil {
|
|
b.delayHistory = make(map[string][]DelayEntry)
|
|
}
|
|
h := b.delayHistory[tag]
|
|
h = append(h, DelayEntry{Time: time.Now(), Delay: delay})
|
|
if len(h) > maxDelayHistory {
|
|
h = h[len(h)-maxDelayHistory:]
|
|
}
|
|
b.delayHistory[tag] = h
|
|
}
|
|
|
|
func (b *Box) getHistory(tag string) []map[string]any {
|
|
b.delayHistoryMu.RLock()
|
|
defer b.delayHistoryMu.RUnlock()
|
|
entries := b.delayHistory[tag]
|
|
result := make([]map[string]any, len(entries))
|
|
for i, e := range entries {
|
|
result[i] = map[string]any{
|
|
"time": e.Time.Format(time.RFC3339),
|
|
"delay": e.Delay,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func NewBox(ctx context.Context, options option.Options) (*Box, error) {
|
|
// Create context with registries
|
|
ctx = include.Context(ctx)
|
|
|
|
inboundRegistry := include.InboundRegistryFromContext(ctx)
|
|
outboundRegistry := include.OutboundRegistryFromContext(ctx)
|
|
|
|
// Set default DNS resolver for outbound domain resolution (avoid TUN loop)
|
|
if options.DNS != nil {
|
|
for _, s := range options.DNS.Servers {
|
|
if s.Tag == "local" || s.Type == "udp" {
|
|
addr := s.Server
|
|
port := s.ServerPort
|
|
if port == 0 { port = 53 }
|
|
dialer.DefaultDNS = fmt.Sprintf("%s:%d", addr, port)
|
|
break
|
|
}
|
|
}
|
|
if dialer.DefaultDNS == "" && options.DNS.Upstream != "" {
|
|
dialer.DefaultDNS = options.DNS.Upstream
|
|
}
|
|
}
|
|
if dialer.DefaultDNS == "" {
|
|
dialer.DefaultDNS = "223.5.5.5:53"
|
|
}
|
|
|
|
// Create log factory
|
|
logFactory := log.NewFactory(options.Log)
|
|
mainLogger := logFactory.Logger()
|
|
mainLogger.Info("default DNS resolver: ", dialer.DefaultDNS)
|
|
|
|
// Create managers
|
|
inboundManager := manager.NewInboundManager(
|
|
logFactory.NewLogger("inbound"),
|
|
inboundRegistry,
|
|
)
|
|
outboundManager := manager.NewOutboundManager(
|
|
logFactory.NewLogger("outbound"),
|
|
outboundRegistry,
|
|
"",
|
|
)
|
|
|
|
// Set default outbound tag from route options
|
|
var routeOptions *option.RouteOptions
|
|
if options.Route != nil {
|
|
routeOptions = options.Route
|
|
}
|
|
|
|
// Put outbound manager in context for detour dialers
|
|
ctx = adapter.ContextWithOutboundManager(ctx, outboundManager)
|
|
|
|
// Create connection manager
|
|
connManager := route.NewConnectionManager(logFactory.NewLogger("connection"))
|
|
|
|
// Create router
|
|
router := route.NewRouter(ctx, logFactory.NewLogger("router"), connManager, routeOptions)
|
|
|
|
// Resolve inbound options and create inbounds
|
|
for i, inboundOptions := range options.Inbounds {
|
|
if err := inboundOptions.ResolveOptions(inboundRegistry); err != nil {
|
|
return nil, err
|
|
}
|
|
options.Inbounds[i] = inboundOptions
|
|
|
|
tag := inboundOptions.Tag
|
|
if tag == "" {
|
|
tag = inboundOptions.Type
|
|
}
|
|
err := inboundManager.Create(
|
|
ctx, router,
|
|
logFactory.NewLogger("inbound/"+tag),
|
|
tag, inboundOptions.Type, inboundOptions.Options,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mainLogger.Info("created inbound ", inboundOptions.Type, "[", tag, "]")
|
|
}
|
|
|
|
// Resolve outbound options and create outbounds
|
|
for i, outboundOptions := range options.Outbounds {
|
|
if err := outboundOptions.ResolveOptions(outboundRegistry); err != nil {
|
|
return nil, err
|
|
}
|
|
options.Outbounds[i] = outboundOptions
|
|
|
|
tag := outboundOptions.Tag
|
|
if tag == "" {
|
|
tag = outboundOptions.Type
|
|
}
|
|
err := outboundManager.Create(
|
|
ctx, router,
|
|
logFactory.NewLogger("outbound/"+tag),
|
|
tag, outboundOptions.Type, outboundOptions.Options,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mainLogger.Info("created outbound ", outboundOptions.Type, "[", tag, "]")
|
|
}
|
|
|
|
// Initialize outbound manager with fallback
|
|
err := outboundManager.Initialize(func() (adapter.Outbound, error) {
|
|
return direct.NewOutbound(ctx, router, logFactory.NewLogger("outbound/direct-default"), "direct-default", option.DirectOutboundOptions{})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load rule sets before initializing rules (rules may reference them)
|
|
if routeOptions != nil && len(routeOptions.RuleSet) > 0 {
|
|
router.RuleSetManager().SetOutbound(outboundManager)
|
|
if err := router.RuleSetManager().LoadAll(routeOptions.RuleSet); err != nil {
|
|
mainLogger.Warn("rule-set load: ", err)
|
|
}
|
|
}
|
|
|
|
// Initialize router with rules
|
|
var rules []option.Rule
|
|
if routeOptions != nil {
|
|
rules = routeOptions.Rules
|
|
}
|
|
err = router.Initialize(outboundManager, rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// When TUN is active, set ProtectFunc to bind direct sockets to physical interface.
|
|
// This prevents DNS upstream connections from being routed back through TUN.
|
|
for _, inbOpts := range options.Inbounds {
|
|
if inbOpts.Type == C.TypeTun {
|
|
if dialer.ProtectFunc == nil {
|
|
defaultIface := detectDefaultInterface()
|
|
if defaultIface != "" {
|
|
mainLogger.Info("TUN detected, binding direct sockets to ", defaultIface)
|
|
dialer.ProtectFunc = makeBindToDeviceFunc(defaultIface)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Build DNS upstreams, groups, and rules from config
|
|
var dnsUpstreams []minidns.UpstreamConfig
|
|
var dnsListen string
|
|
var localSuffixes, localKeywords []string
|
|
// Collect upstream names per tag for group building
|
|
localNames := []string{}
|
|
foreignNames := []string{}
|
|
|
|
if options.DNS != nil {
|
|
dnsListen = options.DNS.Listen
|
|
for i, s := range options.DNS.Servers {
|
|
port := s.ServerPort
|
|
if port == 0 {
|
|
port = 53
|
|
}
|
|
proto := s.Type
|
|
addr := s.Server
|
|
dohURL := ""
|
|
switch proto {
|
|
case "tcp-tls":
|
|
proto = "dot"
|
|
case "https", "doh":
|
|
proto = "doh"
|
|
// For DoH, Server is the full URL (e.g. "https://dns.google/dns-query")
|
|
dohURL = addr
|
|
if !strings.HasPrefix(dohURL, "https://") {
|
|
dohURL = "https://" + addr + "/dns-query"
|
|
}
|
|
case "":
|
|
if strings.HasPrefix(addr, "https://") {
|
|
proto = "doh"
|
|
dohURL = addr
|
|
} else if port == 853 {
|
|
proto = "dot"
|
|
} else {
|
|
proto = "udp"
|
|
}
|
|
}
|
|
name := fmt.Sprintf("%s-%d", s.Tag, i)
|
|
uc := minidns.UpstreamConfig{
|
|
Name: name,
|
|
Addr: addr,
|
|
Port: port,
|
|
Protocol: proto,
|
|
URL: dohURL,
|
|
}
|
|
// Resolve detour: outbound tag → DialFunc
|
|
if s.Detour != "" {
|
|
if detourOut, ok := outboundManager.Outbound(s.Detour); ok {
|
|
outbound := detourOut // bind to local variable for closure
|
|
uc.Dial = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return outbound.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
|
}
|
|
mainLogger.Info("dns upstream [", name, "] detour via outbound [", s.Detour, "]")
|
|
} else {
|
|
mainLogger.Warn("dns upstream [", name, "] detour outbound not found: ", s.Detour)
|
|
}
|
|
}
|
|
dnsUpstreams = append(dnsUpstreams, uc)
|
|
switch s.Tag {
|
|
case "local":
|
|
localNames = append(localNames, name)
|
|
default:
|
|
foreignNames = append(foreignNames, name)
|
|
}
|
|
}
|
|
for _, r := range options.DNS.Rules {
|
|
localSuffixes = append(localSuffixes, r.DomainSuffix...)
|
|
localKeywords = append(localKeywords, r.DomainKeyword...)
|
|
}
|
|
}
|
|
// Default CN domain keywords
|
|
if len(localKeywords) == 0 {
|
|
localKeywords = []string{
|
|
"baidu", "bilibili", "tencent", "taobao", "alibaba", "jd",
|
|
"weixin", "wechat", "douyin", "zhihu", "163", "sohu",
|
|
"sina", "weibo", "qq", "alipay", "meituan", "xiaomi",
|
|
"huawei", "oppo", "vivo", "bytedance",
|
|
}
|
|
localSuffixes = append(localSuffixes, ".cn")
|
|
}
|
|
// Fallback: no DNS servers configured → use defaults
|
|
if len(dnsUpstreams) == 0 {
|
|
dnsUpstreams = []minidns.UpstreamConfig{
|
|
{Name: "local-default", Addr: "223.5.5.5", Port: 53, Protocol: "udp"},
|
|
{Name: "remote-default", Addr: "8.8.8.8", Port: 853, Protocol: "dot"},
|
|
}
|
|
localNames = []string{"local-default"}
|
|
foreignNames = []string{"remote-default"}
|
|
}
|
|
// Build groups: foreign first (default for unmatched), then cn
|
|
var dnsGroups []minidns.GroupConfig
|
|
if len(foreignNames) > 0 {
|
|
dnsGroups = append(dnsGroups, minidns.GroupConfig{Name: "foreign", Upstreams: foreignNames, Strategy: "concurrent"})
|
|
}
|
|
if len(localNames) > 0 {
|
|
dnsGroups = append(dnsGroups, minidns.GroupConfig{Name: "cn", Upstreams: localNames, Strategy: "concurrent"})
|
|
}
|
|
|
|
resolver := dns.NewResolver(dns.ResolverOptions{
|
|
Logger: logFactory.NewLogger("dns"),
|
|
Listen: dnsListen,
|
|
Upstreams: dnsUpstreams,
|
|
Groups: dnsGroups,
|
|
LocalSuffixes: localSuffixes,
|
|
LocalKeywords: localKeywords,
|
|
})
|
|
router.SetDNSCache(resolver.ReverseCache())
|
|
mainLogger.Info("dns resolver: upstreams=", len(dnsUpstreams), " groups=", len(dnsGroups), " rules=", len(localSuffixes)+len(localKeywords))
|
|
|
|
// Inject resolver into TUN inbound for DNS hijack
|
|
for _, inb := range inboundManager.Inbounds() {
|
|
if tunInbound, ok := inb.(interface{ SetResolver(*dns.Resolver) }); ok {
|
|
tunInbound.SetResolver(resolver)
|
|
mainLogger.Info("dns hijack enabled on ", inb.Tag())
|
|
}
|
|
}
|
|
|
|
// Inject tailscale lookup into resolver and router for .ts.net domain resolution
|
|
for _, out := range outboundManager.Outbounds() {
|
|
if tsLookup, ok := out.(dns.TailscaleLookup); ok {
|
|
resolver.SetTailscale(tsLookup)
|
|
router.SetTailscale(tsLookup, out.Tag())
|
|
mainLogger.Info("tailscale DNS lookup enabled via ", out.Tag())
|
|
}
|
|
}
|
|
|
|
// Inject resolver into DNS outbound
|
|
type dnsResolverSetter interface {
|
|
SetResolver(*dns.Resolver)
|
|
}
|
|
for _, out := range outboundManager.Outbounds() {
|
|
if setter, ok := out.(dnsResolverSetter); ok {
|
|
setter.SetResolver(resolver)
|
|
}
|
|
}
|
|
|
|
box := &Box{
|
|
createdAt: time.Now(),
|
|
logFactory: logFactory,
|
|
logger: mainLogger,
|
|
inbound: inboundManager,
|
|
outbound: outboundManager,
|
|
connection: connManager,
|
|
router: router,
|
|
resolver: resolver,
|
|
apiListen: func() string {
|
|
if options.Experimental != nil && options.Experimental.ClashAPI != nil {
|
|
return options.Experimental.ClashAPI.ExternalController
|
|
}
|
|
return ""
|
|
}(),
|
|
clashMode: func() string {
|
|
if options.Experimental != nil && options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.DefaultMode != "" {
|
|
return options.Experimental.ClashAPI.DefaultMode
|
|
}
|
|
return "rule"
|
|
}(),
|
|
modeList: func() []string {
|
|
if options.Experimental != nil && options.Experimental.ClashAPI != nil && len(options.Experimental.ClashAPI.ModeList) > 0 {
|
|
return options.Experimental.ClashAPI.ModeList
|
|
}
|
|
return []string{"rule"}
|
|
}(),
|
|
}
|
|
router.SetClashModeRef(&box.clashMode)
|
|
return box, nil
|
|
}
|
|
|
|
func (b *Box) Start() error {
|
|
b.logFactory.Start()
|
|
b.logger.Info("starting mini-sing...")
|
|
|
|
// Initialize stage
|
|
err := adapter.Start(b.logger, adapter.StartStateInitialize,
|
|
b.connection, b.router, b.outbound, b.inbound,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start stage — outbound first, then inbound
|
|
err = adapter.Start(b.logger, adapter.StartStateStart,
|
|
b.outbound, b.connection, b.router,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = adapter.Start(b.logger, adapter.StartStateStart,
|
|
b.inbound,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// PostStart
|
|
err = adapter.Start(b.logger, adapter.StartStatePostStart,
|
|
b.outbound, b.connection, b.router, b.inbound,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Started
|
|
err = adapter.Start(b.logger, adapter.StartStateStarted,
|
|
b.outbound, b.connection, b.router, b.inbound,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// DNS resolver is embedded in TUN inbound, no separate server needed
|
|
|
|
// Start API server
|
|
if err := b.StartAPI(b.apiListen); err != nil {
|
|
b.logger.Warn("api server: ", err)
|
|
}
|
|
|
|
b.logger.Info("mini-sing started (", time.Since(b.createdAt).Truncate(time.Millisecond), ")")
|
|
return nil
|
|
}
|
|
|
|
func (b *Box) Close() error {
|
|
b.logger.Info("closing mini-sing...")
|
|
if b.apiLn != nil {
|
|
b.apiLn.Close()
|
|
}
|
|
b.inbound.Close()
|
|
b.outbound.Close()
|
|
b.router.Close()
|
|
b.connection.Close()
|
|
if b.resolver != nil {
|
|
b.resolver.Close()
|
|
}
|
|
b.logFactory.Close()
|
|
return nil
|
|
}
|
|
|
|
var _ N.Dialer = (adapter.Outbound)(nil)
|
|
|
|
// detectDefaultInterface returns the name of the default route interface (e.g. "enp1s0").
|
|
func (b *Box) findTailscale() *tailscale.Outbound {
|
|
if b == nil || b.outbound == nil {
|
|
return nil
|
|
}
|
|
for _, out := range b.outbound.Outbounds() {
|
|
if ts, ok := out.(*tailscale.Outbound); ok {
|
|
return ts
|
|
}
|
|
}
|
|
return nil
|
|
}
|