Files
mini-sing/box.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
}