Browse Source

platform: Add group interface

世界 2 years ago
parent
commit
9c8565cf21

+ 2 - 2
Makefile

@@ -89,8 +89,8 @@ lib:
 
 lib_install:
 	go get -v -d
-	go install -v github.com/sagernet/gomobile/cmd/[email protected]413023804-244d7ff07035
-	go install -v github.com/sagernet/gomobile/cmd/[email protected]413023804-244d7ff07035
+	go install -v github.com/sagernet/gomobile/cmd/[email protected]701084532-493ee2e45182
+	go install -v github.com/sagernet/gomobile/cmd/[email protected]701084532-493ee2e45182
 
 clean:
 	rm -rf bin dist sing-box

+ 1 - 0
adapter/experimental.go

@@ -31,6 +31,7 @@ type Tracker interface {
 }
 
 type OutboundGroup interface {
+	Outbound
 	Now() string
 	All() []string
 }

+ 2 - 8
adapter/prestart.go

@@ -4,12 +4,6 @@ type PreStarter interface {
 	PreStart() error
 }
 
-func PreStart(starter any) error {
-	if preService, ok := starter.(PreStarter); ok {
-		err := preService.PreStart()
-		if err != nil {
-			return err
-		}
-	}
-	return nil
+type PostStarter interface {
+	PostStart() error
 }

+ 20 - 5
box.go

@@ -211,10 +211,12 @@ func (s *Box) Start() error {
 
 func (s *Box) preStart() error {
 	for serviceName, service := range s.preServices {
-		s.logger.Trace("pre-start ", serviceName)
-		err := adapter.PreStart(service)
-		if err != nil {
-			return E.Cause(err, "pre-starting ", serviceName)
+		if preService, isPreService := service.(adapter.PreStarter); isPreService {
+			s.logger.Trace("pre-start ", serviceName)
+			err := preService.PreStart()
+			if err != nil {
+				return E.Cause(err, "pre-starting ", serviceName)
+			}
 		}
 	}
 	err := s.startOutbounds()
@@ -249,13 +251,26 @@ func (s *Box) start() error {
 			return E.Cause(err, "initialize inbound/", in.Type(), "[", tag, "]")
 		}
 	}
+	return nil
+}
+
+func (s *Box) postStart() error {
 	for serviceName, service := range s.postServices {
 		s.logger.Trace("starting ", service)
-		err = service.Start()
+		err := service.Start()
 		if err != nil {
 			return E.Cause(err, "start ", serviceName)
 		}
 	}
+	for serviceName, service := range s.outbounds {
+		if lateService, isLateService := service.(adapter.PostStarter); isLateService {
+			s.logger.Trace("post-starting ", service)
+			err := lateService.PostStart()
+			if err != nil {
+				return E.Cause(err, "post-start ", serviceName)
+			}
+		}
+	}
 	return nil
 }
 

+ 1 - 1
cmd/internal/build_libbox/main.go

@@ -133,7 +133,7 @@ func buildiOS() {
 		log.Fatal(err)
 	}
 
-	copyPath := filepath.Join("..", "sing-box-for-ios")
+	copyPath := filepath.Join("..", "sing-box-for-apple")
 	if rw.FileExists(copyPath) {
 		targetDir := filepath.Join(copyPath, "Libbox.xcframework")
 		targetDir, _ = filepath.Abs(targetDir)

+ 26 - 2
common/urltest/urltest.go

@@ -10,6 +10,7 @@ import (
 
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/x/list"
 )
 
 type History struct {
@@ -20,6 +21,7 @@ type History struct {
 type HistoryStorage struct {
 	access       sync.RWMutex
 	delayHistory map[string]*History
+	callbacks    list.List[func()]
 }
 
 func NewHistoryStorage() *HistoryStorage {
@@ -28,6 +30,18 @@ func NewHistoryStorage() *HistoryStorage {
 	}
 }
 
+func (s *HistoryStorage) AddListener(listener func()) *list.Element[func()] {
+	s.access.Lock()
+	defer s.access.Unlock()
+	return s.callbacks.PushBack(listener)
+}
+
+func (s *HistoryStorage) RemoveListener(element *list.Element[func()]) {
+	s.access.Lock()
+	defer s.access.Unlock()
+	s.callbacks.Remove(element)
+}
+
 func (s *HistoryStorage) LoadURLTestHistory(tag string) *History {
 	if s == nil {
 		return nil
@@ -39,14 +53,24 @@ func (s *HistoryStorage) LoadURLTestHistory(tag string) *History {
 
 func (s *HistoryStorage) DeleteURLTestHistory(tag string) {
 	s.access.Lock()
-	defer s.access.Unlock()
 	delete(s.delayHistory, tag)
+	s.access.Unlock()
+	s.notifyUpdated()
 }
 
 func (s *HistoryStorage) StoreURLTestHistory(tag string, history *History) {
 	s.access.Lock()
-	defer s.access.Unlock()
 	s.delayHistory[tag] = history
+	s.access.Unlock()
+	s.notifyUpdated()
+}
+
+func (s *HistoryStorage) notifyUpdated() {
+	s.access.RLock()
+	defer s.access.RUnlock()
+	for element := s.callbacks.Front(); element != nil; element = element.Next() {
+		element.Value()
+	}
 }
 
 func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) {

+ 5 - 1
experimental/clashapi/server.go

@@ -23,6 +23,7 @@ import (
 	E "github.com/sagernet/sing/common/exceptions"
 	F "github.com/sagernet/sing/common/format"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service/filemanager"
 	"github.com/sagernet/websocket"
 
@@ -68,13 +69,16 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ
 			Handler: chiRouter,
 		},
 		trafficManager:           trafficManager,
-		urlTestHistory:           urltest.NewHistoryStorage(),
 		mode:                     strings.ToLower(options.DefaultMode),
 		storeSelected:            options.StoreSelected,
 		storeFakeIP:              options.StoreFakeIP,
 		externalUIDownloadURL:    options.ExternalUIDownloadURL,
 		externalUIDownloadDetour: options.ExternalUIDownloadDetour,
 	}
+	server.urlTestHistory = service.PtrFromContext[urltest.HistoryStorage](ctx)
+	if server.urlTestHistory == nil {
+		server.urlTestHistory = urltest.NewHistoryStorage()
+	}
 	if server.mode == "" {
 		server.mode = "rule"
 	}

+ 3 - 1
experimental/libbox/command.go

@@ -3,7 +3,9 @@ package libbox
 const (
 	CommandLog int32 = iota
 	CommandStatus
-	CommandServiceStop
 	CommandServiceReload
 	CommandCloseConnections
+	CommandGroup
+	CommandSelectOutbound
+	CommandURLTest
 )

+ 17 - 3
experimental/libbox/command_client.go

@@ -26,6 +26,13 @@ type CommandClientHandler interface {
 	Disconnected(message string)
 	WriteLog(message string)
 	WriteStatus(message *StatusMessage)
+	WriteGroups(message OutboundGroupIterator)
+}
+
+func NewStandaloneCommandClient(sharedDirectory string) *CommandClient {
+	return &CommandClient{
+		sharedDirectory: sharedDirectory,
+	}
 }
 
 func NewCommandClient(sharedDirectory string, handler CommandClientHandler, options *CommandClientOptions) *CommandClient {
@@ -36,16 +43,16 @@ func NewCommandClient(sharedDirectory string, handler CommandClientHandler, opti
 	}
 }
 
-func clientConnect(sharedDirectory string) (net.Conn, error) {
+func (c *CommandClient) directConnect() (net.Conn, error) {
 	return net.DialUnix("unix", nil, &net.UnixAddr{
-		Name: filepath.Join(sharedDirectory, "command.sock"),
+		Name: filepath.Join(c.sharedDirectory, "command.sock"),
 		Net:  "unix",
 	})
 }
 
 func (c *CommandClient) Connect() error {
 	common.Close(c.conn)
-	conn, err := clientConnect(c.sharedDirectory)
+	conn, err := c.directConnect()
 	if err != nil {
 		return err
 	}
@@ -65,6 +72,13 @@ func (c *CommandClient) Connect() error {
 		}
 		c.handler.Connected()
 		go c.handleStatusConn(conn)
+	case CommandGroup:
+		err = binary.Write(conn, binary.BigEndian, c.options.StatusInterval)
+		if err != nil {
+			return E.Cause(err, "write interval")
+		}
+		c.handler.Connected()
+		go c.handleGroupConn(conn)
 	}
 	return nil
 }

+ 2 - 2
experimental/libbox/command_conntrack.go

@@ -9,8 +9,8 @@ import (
 	"github.com/sagernet/sing-box/common/dialer/conntrack"
 )
 
-func ClientCloseConnections(sharedDirectory string) error {
-	conn, err := clientConnect(sharedDirectory)
+func (c *CommandClient) CloseConnections() error {
+	conn, err := c.directConnect()
 	if err != nil {
 		return err
 	}

+ 228 - 0
experimental/libbox/command_group.go

@@ -0,0 +1,228 @@
+package libbox
+
+import (
+	"encoding/binary"
+	"io"
+	"net"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/urltest"
+	"github.com/sagernet/sing-box/outbound"
+	"github.com/sagernet/sing/common/rw"
+	"github.com/sagernet/sing/service"
+)
+
+type OutboundGroup struct {
+	Tag        string
+	Type       string
+	Selectable bool
+	Selected   string
+	items      []*OutboundGroupItem
+}
+
+func (g *OutboundGroup) GetItems() OutboundGroupItemIterator {
+	return newIterator(g.items)
+}
+
+type OutboundGroupIterator interface {
+	Next() *OutboundGroup
+	HasNext() bool
+}
+
+type OutboundGroupItem struct {
+	Tag          string
+	Type         string
+	URLTestTime  int64
+	URLTestDelay int32
+}
+
+type OutboundGroupItemIterator interface {
+	Next() *OutboundGroupItem
+	HasNext() bool
+}
+
+func (c *CommandClient) handleGroupConn(conn net.Conn) {
+	defer conn.Close()
+
+	for {
+		groups, err := readGroups(conn)
+		if err != nil {
+			c.handler.Disconnected(err.Error())
+			return
+		}
+		c.handler.WriteGroups(groups)
+	}
+}
+
+func (s *CommandServer) handleGroupConn(conn net.Conn) error {
+	defer conn.Close()
+	ctx := connKeepAlive(conn)
+	for {
+		service := s.service
+		if service != nil {
+			err := writeGroups(conn, service)
+			if err != nil {
+				return err
+			}
+		} else {
+			err := binary.Write(conn, binary.BigEndian, uint16(0))
+			if err != nil {
+				return err
+			}
+		}
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-s.urlTestUpdate:
+		}
+	}
+}
+
+func readGroups(reader io.Reader) (OutboundGroupIterator, error) {
+	var groupLength uint16
+	err := binary.Read(reader, binary.BigEndian, &groupLength)
+	if err != nil {
+		return nil, err
+	}
+
+	groups := make([]*OutboundGroup, 0, groupLength)
+	for i := 0; i < int(groupLength); i++ {
+		var group OutboundGroup
+		group.Tag, err = rw.ReadVString(reader)
+		if err != nil {
+			return nil, err
+		}
+
+		group.Type, err = rw.ReadVString(reader)
+		if err != nil {
+			return nil, err
+		}
+
+		err = binary.Read(reader, binary.BigEndian, &group.Selectable)
+		if err != nil {
+			return nil, err
+		}
+
+		group.Selected, err = rw.ReadVString(reader)
+		if err != nil {
+			return nil, err
+		}
+
+		var itemLength uint16
+		err = binary.Read(reader, binary.BigEndian, &itemLength)
+		if err != nil {
+			return nil, err
+		}
+
+		group.items = make([]*OutboundGroupItem, itemLength)
+		for j := 0; j < int(itemLength); j++ {
+			var item OutboundGroupItem
+			item.Tag, err = rw.ReadVString(reader)
+			if err != nil {
+				return nil, err
+			}
+
+			item.Type, err = rw.ReadVString(reader)
+			if err != nil {
+				return nil, err
+			}
+
+			err = binary.Read(reader, binary.BigEndian, &item.URLTestTime)
+			if err != nil {
+				return nil, err
+			}
+
+			err = binary.Read(reader, binary.BigEndian, &item.URLTestDelay)
+			if err != nil {
+				return nil, err
+			}
+
+			group.items[j] = &item
+		}
+		groups = append(groups, &group)
+	}
+	return newIterator(groups), nil
+}
+
+func writeGroups(writer io.Writer, boxService *BoxService) error {
+	historyStorage := service.PtrFromContext[urltest.HistoryStorage](boxService.ctx)
+
+	outbounds := boxService.instance.Router().Outbounds()
+	var iGroups []adapter.OutboundGroup
+	for _, it := range outbounds {
+		if group, isGroup := it.(adapter.OutboundGroup); isGroup {
+			iGroups = append(iGroups, group)
+		}
+	}
+	var groups []OutboundGroup
+	for _, iGroup := range iGroups {
+		var group OutboundGroup
+		group.Tag = iGroup.Tag()
+		group.Type = iGroup.Type()
+		_, group.Selectable = iGroup.(*outbound.Selector)
+		group.Selected = iGroup.Now()
+
+		for _, itemTag := range iGroup.All() {
+			itemOutbound, isLoaded := boxService.instance.Router().Outbound(itemTag)
+			if !isLoaded {
+				continue
+			}
+
+			var item OutboundGroupItem
+			item.Tag = itemTag
+			item.Type = itemOutbound.Type()
+			if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(itemOutbound)); history != nil {
+				item.URLTestTime = history.Time.Unix()
+				item.URLTestDelay = int32(history.Delay)
+			}
+			group.items = append(group.items, &item)
+		}
+		groups = append(groups, group)
+	}
+
+	err := binary.Write(writer, binary.BigEndian, uint16(len(groups)))
+	if err != nil {
+		return err
+	}
+	for _, group := range groups {
+		err = rw.WriteVString(writer, group.Tag)
+		if err != nil {
+			return err
+		}
+		err = rw.WriteVString(writer, group.Type)
+		if err != nil {
+			return err
+		}
+		err = binary.Write(writer, binary.BigEndian, group.Selectable)
+		if err != nil {
+			return err
+		}
+		err = rw.WriteVString(writer, group.Selected)
+		if err != nil {
+			return err
+		}
+		err = binary.Write(writer, binary.BigEndian, uint16(len(group.items)))
+		if err != nil {
+			return err
+		}
+		for _, item := range group.items {
+			err = rw.WriteVString(writer, item.Tag)
+			if err != nil {
+				return err
+			}
+			err = rw.WriteVString(writer, item.Type)
+			if err != nil {
+				return err
+			}
+			err = binary.Write(writer, binary.BigEndian, item.URLTestTime)
+			if err != nil {
+				return err
+			}
+			err = binary.Write(writer, binary.BigEndian, item.URLTestDelay)
+			if err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}

+ 1 - 1
experimental/libbox/command_log.go

@@ -11,7 +11,7 @@ func (s *CommandServer) WriteMessage(message string) {
 	s.subscriber.Emit(message)
 	s.access.Lock()
 	s.savedLines.PushBack(message)
-	if s.savedLines.Len() > 100 {
+	if s.savedLines.Len() > s.maxLines {
 		s.savedLines.Remove(s.savedLines.Front())
 	}
 	s.access.Unlock()

+ 2 - 2
experimental/libbox/command_reload.go

@@ -8,8 +8,8 @@ import (
 	"github.com/sagernet/sing/common/rw"
 )
 
-func ClientServiceReload(sharedDirectory string) error {
-	conn, err := clientConnect(sharedDirectory)
+func (c *CommandClient) ServiceReload() error {
+	conn, err := c.directConnect()
 	if err != nil {
 		return err
 	}

+ 59 - 0
experimental/libbox/command_select.go

@@ -0,0 +1,59 @@
+package libbox
+
+import (
+	"encoding/binary"
+	"net"
+
+	"github.com/sagernet/sing-box/outbound"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/rw"
+)
+
+func (c *CommandClient) SelectOutbound(groupTag string, outboundTag string) error {
+	conn, err := c.directConnect()
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	err = binary.Write(conn, binary.BigEndian, uint8(CommandSelectOutbound))
+	if err != nil {
+		return err
+	}
+	err = rw.WriteVString(conn, groupTag)
+	if err != nil {
+		return err
+	}
+	err = rw.WriteVString(conn, outboundTag)
+	if err != nil {
+		return err
+	}
+	return readError(conn)
+}
+
+func (s *CommandServer) handleSelectOutbound(conn net.Conn) error {
+	defer conn.Close()
+	groupTag, err := rw.ReadVString(conn)
+	if err != nil {
+		return err
+	}
+	outboundTag, err := rw.ReadVString(conn)
+	if err != nil {
+		return err
+	}
+	service := s.service
+	if service == nil {
+		return writeError(conn, E.New("service not ready"))
+	}
+	outboundGroup, isLoaded := service.instance.Router().Outbound(groupTag)
+	if !isLoaded {
+		return writeError(conn, E.New("selector not found: ", groupTag))
+	}
+	selector, isSelector := outboundGroup.(*outbound.Selector)
+	if !isSelector {
+		return writeError(conn, E.New("outbound is not a selector: ", groupTag))
+	}
+	if !selector.SelectOutbound(outboundTag) {
+		return writeError(conn, E.New("outbound not found in selector: ", outboundTag))
+	}
+	return writeError(conn, nil)
+}

+ 39 - 8
experimental/libbox/command_server.go

@@ -7,12 +7,14 @@ import (
 	"path/filepath"
 	"sync"
 
+	"github.com/sagernet/sing-box/common/urltest"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/debug"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/observable"
 	"github.com/sagernet/sing/common/x/list"
+	"github.com/sagernet/sing/service"
 )
 
 type CommandServer struct {
@@ -22,26 +24,51 @@ type CommandServer struct {
 
 	access     sync.Mutex
 	savedLines *list.List[string]
+	maxLines   int
 	subscriber *observable.Subscriber[string]
 	observer   *observable.Observer[string]
+	service    *BoxService
+
+	urlTestListener *list.Element[func()]
+	urlTestUpdate   chan struct{}
 }
 
 type CommandServerHandler interface {
-	ServiceStop() error
 	ServiceReload() error
 }
 
-func NewCommandServer(sharedDirectory string, handler CommandServerHandler) *CommandServer {
+func NewCommandServer(sharedDirectory string, handler CommandServerHandler, maxLines int32) *CommandServer {
 	server := &CommandServer{
-		sockPath:   filepath.Join(sharedDirectory, "command.sock"),
-		handler:    handler,
-		savedLines: new(list.List[string]),
-		subscriber: observable.NewSubscriber[string](128),
+		sockPath:      filepath.Join(sharedDirectory, "command.sock"),
+		handler:       handler,
+		savedLines:    new(list.List[string]),
+		maxLines:      int(maxLines),
+		subscriber:    observable.NewSubscriber[string](128),
+		urlTestUpdate: make(chan struct{}, 1),
 	}
 	server.observer = observable.NewObserver[string](server.subscriber, 64)
 	return server
 }
 
+func (s *CommandServer) SetService(newService *BoxService) {
+	if s.service != nil && s.listener != nil {
+		service.PtrFromContext[urltest.HistoryStorage](s.service.ctx).RemoveListener(s.urlTestListener)
+		s.urlTestListener = nil
+	}
+	s.service = newService
+	if newService != nil {
+		s.urlTestListener = service.PtrFromContext[urltest.HistoryStorage](newService.ctx).AddListener(s.notifyURLTestUpdate)
+	}
+	s.notifyURLTestUpdate()
+}
+
+func (s *CommandServer) notifyURLTestUpdate() {
+	select {
+	case s.urlTestUpdate <- struct{}{}:
+	default:
+	}
+}
+
 func (s *CommandServer) Start() error {
 	os.Remove(s.sockPath)
 	listener, err := net.ListenUnix("unix", &net.UnixAddr{
@@ -92,12 +119,16 @@ func (s *CommandServer) handleConnection(conn net.Conn) error {
 		return s.handleLogConn(conn)
 	case CommandStatus:
 		return s.handleStatusConn(conn)
-	case CommandServiceStop:
-		return s.handleServiceStop(conn)
 	case CommandServiceReload:
 		return s.handleServiceReload(conn)
 	case CommandCloseConnections:
 		return s.handleCloseConnections(conn)
+	case CommandGroup:
+		return s.handleGroupConn(conn)
+	case CommandSelectOutbound:
+		return s.handleSelectOutbound(conn)
+	case CommandURLTest:
+		return s.handleURLTest(conn)
 	default:
 		return E.New("unknown command: ", command)
 	}

+ 39 - 0
experimental/libbox/command_shared.go

@@ -0,0 +1,39 @@
+package libbox
+
+import (
+	"encoding/binary"
+	"io"
+
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/rw"
+)
+
+func readError(reader io.Reader) error {
+	var hasError bool
+	err := binary.Read(reader, binary.BigEndian, &hasError)
+	if err != nil {
+		return err
+	}
+	if hasError {
+		errorMessage, err := rw.ReadVString(reader)
+		if err != nil {
+			return err
+		}
+		return E.New(errorMessage)
+	}
+	return nil
+}
+
+func writeError(writer io.Writer, wErr error) error {
+	err := binary.Write(writer, binary.BigEndian, wErr != nil)
+	if err != nil {
+		return err
+	}
+	if wErr != nil {
+		err = rw.WriteVString(writer, wErr.Error())
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 0 - 48
experimental/libbox/command_stop.go

@@ -1,48 +0,0 @@
-package libbox
-
-import (
-	"encoding/binary"
-	"net"
-	"runtime/debug"
-
-	E "github.com/sagernet/sing/common/exceptions"
-	"github.com/sagernet/sing/common/rw"
-)
-
-func ClientServiceStop(sharedDirectory string) error {
-	conn, err := clientConnect(sharedDirectory)
-	if err != nil {
-		return err
-	}
-	defer conn.Close()
-	err = binary.Write(conn, binary.BigEndian, uint8(CommandServiceStop))
-	if err != nil {
-		return err
-	}
-	var hasError bool
-	err = binary.Read(conn, binary.BigEndian, &hasError)
-	if err != nil {
-		return err
-	}
-	if hasError {
-		errorMessage, err := rw.ReadVString(conn)
-		if err != nil {
-			return err
-		}
-		return E.New(errorMessage)
-	}
-	return nil
-}
-
-func (s *CommandServer) handleServiceStop(conn net.Conn) error {
-	rErr := s.handler.ServiceStop()
-	err := binary.Write(conn, binary.BigEndian, rErr != nil)
-	if err != nil {
-		return err
-	}
-	if rErr != nil {
-		return rw.WriteVString(conn, rErr.Error())
-	}
-	debug.FreeOSMemory()
-	return nil
-}

+ 95 - 0
experimental/libbox/command_urltest.go

@@ -0,0 +1,95 @@
+package libbox
+
+import (
+	"encoding/binary"
+	"net"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/urltest"
+	"github.com/sagernet/sing-box/outbound"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/batch"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/rw"
+)
+
+func (c *CommandClient) URLTest(groupTag string) error {
+	conn, err := c.directConnect()
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	err = binary.Write(conn, binary.BigEndian, uint8(CommandURLTest))
+	if err != nil {
+		return err
+	}
+	err = rw.WriteVString(conn, groupTag)
+	if err != nil {
+		return err
+	}
+	return readError(conn)
+}
+
+func (s *CommandServer) handleURLTest(conn net.Conn) error {
+	defer conn.Close()
+	groupTag, err := rw.ReadVString(conn)
+	if err != nil {
+		return err
+	}
+	service := s.service
+	if service == nil {
+		return nil
+	}
+	abstractOutboundGroup, isLoaded := service.instance.Router().Outbound(groupTag)
+	if !isLoaded {
+		return writeError(conn, E.New("outbound group not found: ", groupTag))
+	}
+	outboundGroup, isOutboundGroup := abstractOutboundGroup.(adapter.OutboundGroup)
+	if !isOutboundGroup {
+		return writeError(conn, E.New("outbound is not a group: ", groupTag))
+	}
+	urlTest, isURLTest := abstractOutboundGroup.(*outbound.URLTest)
+	if isURLTest {
+		go urlTest.CheckOutbounds()
+	} else {
+		var historyStorage *urltest.HistoryStorage
+		if clashServer := service.instance.Router().ClashServer(); clashServer != nil {
+			historyStorage = clashServer.HistoryStorage()
+		} else {
+			return writeError(conn, E.New("Clash API is required for URLTest on non-URLTest group"))
+		}
+
+		outbounds := common.Filter(common.Map(outboundGroup.All(), func(it string) adapter.Outbound {
+			itOutbound, _ := service.instance.Router().Outbound(it)
+			return itOutbound
+		}), func(it adapter.Outbound) bool {
+			if it == nil {
+				return false
+			}
+			_, isGroup := it.(adapter.OutboundGroup)
+			if isGroup {
+				return false
+			}
+			return true
+		})
+		b, _ := batch.New(service.ctx, batch.WithConcurrencyNum[any](10))
+		for _, detour := range outbounds {
+			outboundToTest := detour
+			outboundTag := outboundToTest.Tag()
+			b.Go(outboundTag, func() (any, error) {
+				t, err := urltest.URLTest(service.ctx, "", outboundToTest)
+				if err != nil {
+					historyStorage.DeleteURLTestHistory(outboundTag)
+				} else {
+					historyStorage.StoreURLTestHistory(outboundTag, &urltest.History{
+						Time:  time.Now(),
+						Delay: t,
+					})
+				}
+				return nil, nil
+			})
+		}
+	}
+	return writeError(conn, nil)
+}

+ 3 - 0
experimental/libbox/service.go

@@ -8,6 +8,7 @@ import (
 	"github.com/sagernet/sing-box"
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/process"
+	"github.com/sagernet/sing-box/common/urltest"
 	"github.com/sagernet/sing-box/experimental/libbox/internal/procfs"
 	"github.com/sagernet/sing-box/experimental/libbox/platform"
 	"github.com/sagernet/sing-box/option"
@@ -16,6 +17,7 @@ import (
 	"github.com/sagernet/sing/common/control"
 	E "github.com/sagernet/sing/common/exceptions"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service/filemanager"
 )
 
@@ -32,6 +34,7 @@ func NewService(configContent string, platformInterface PlatformInterface) (*Box
 	}
 	ctx, cancel := context.WithCancel(context.Background())
 	ctx = filemanager.WithDefault(ctx, sBasePath, sTempPath, sUserID, sGroupID)
+	ctx = service.ContextWithPtr(ctx, urltest.NewHistoryStorage())
 	instance, err := box.New(box.Options{
 		Context:           ctx,
 		Options:           options,

+ 1 - 1
go.mod

@@ -21,7 +21,7 @@ require (
 	github.com/oschwald/maxminddb-golang v1.11.0
 	github.com/pires/go-proxyproto v0.7.0
 	github.com/sagernet/cloudflare-tls v0.0.0-20221031050923-d70792f4c3a0
-	github.com/sagernet/gomobile v0.0.0-20230413023804-244d7ff07035
+	github.com/sagernet/gomobile v0.0.0-20230701084532-493ee2e45182
 	github.com/sagernet/gvisor v0.0.0-20230627031050-1ab0276e0dd2
 	github.com/sagernet/quic-go v0.0.0-20230615020047-10f05c797c02
 	github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691

+ 3 - 2
go.sum

@@ -104,8 +104,8 @@ github.com/sagernet/cloudflare-tls v0.0.0-20221031050923-d70792f4c3a0 h1:KyhtFFt
 github.com/sagernet/cloudflare-tls v0.0.0-20221031050923-d70792f4c3a0/go.mod h1:D4SFEOkJK+4W1v86ZhX0jPM0rAL498fyQAChqMtes/I=
 github.com/sagernet/go-tun2socks v1.16.12-0.20220818015926-16cb67876a61 h1:5+m7c6AkmAylhauulqN/c5dnh8/KssrE9c93TQrXldA=
 github.com/sagernet/go-tun2socks v1.16.12-0.20220818015926-16cb67876a61/go.mod h1:QUQ4RRHD6hGGHdFMEtR8T2P6GS6R3D/CXKdaYHKKXms=
-github.com/sagernet/gomobile v0.0.0-20230413023804-244d7ff07035 h1:KttYh6bBhIw8Y6/Ljn7CGwC3CKZn788rzMJmeAKjY+8=
-github.com/sagernet/gomobile v0.0.0-20230413023804-244d7ff07035/go.mod h1:5YE39YkJkCcMsfq1jMKkjsrM2GfBoF9JVWnvU89hmvU=
+github.com/sagernet/gomobile v0.0.0-20230701084532-493ee2e45182 h1:sD5g92IO15RAX2DvA4Cq3Uc7tcgqNWVi8K3VTCI6sEo=
+github.com/sagernet/gomobile v0.0.0-20230701084532-493ee2e45182/go.mod h1:5YE39YkJkCcMsfq1jMKkjsrM2GfBoF9JVWnvU89hmvU=
 github.com/sagernet/gvisor v0.0.0-20230627031050-1ab0276e0dd2 h1:dnkKrzapqtAwjTSWt6hdPrARORfoYvuUczynvRLrueo=
 github.com/sagernet/gvisor v0.0.0-20230627031050-1ab0276e0dd2/go.mod h1:1JUiV7nGuf++YFm9eWZ8q2lrwHmhcUGzptMl/vL1+LA=
 github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97 h1:iL5gZI3uFp0X6EslacyapiRz7LLSJyr4RajF/BhMVyE=
@@ -149,6 +149,7 @@ github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRM
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

+ 3 - 2
log/default.go

@@ -24,8 +24,9 @@ func NewFactory(formatter Formatter, writer io.Writer, platformWriter io.Writer)
 	return &simpleFactory{
 		formatter: formatter,
 		platformFormatter: Formatter{
-			BaseTime:      formatter.BaseTime,
-			DisableColors: C.IsDarwin || C.IsIos,
+			BaseTime:         formatter.BaseTime,
+			DisableColors:    C.IsDarwin || C.IsIos,
+			DisableLineBreak: true,
 		},
 		writer:         writer,
 		platformWriter: platformWriter,

+ 9 - 2
log/format.go

@@ -17,6 +17,7 @@ type Formatter struct {
 	DisableTimestamp bool
 	FullTimestamp    bool
 	TimestampFormat  string
+	DisableLineBreak bool
 }
 
 func (f Formatter) Format(ctx context.Context, level Level, tag string, message string, timestamp time.Time) string {
@@ -76,8 +77,14 @@ func (f Formatter) Format(ctx context.Context, level Level, tag string, message
 	default:
 		message = levelString + "[" + xd(int(timestamp.Sub(f.BaseTime)/time.Second), 4) + "] " + message
 	}
-	if message[len(message)-1] != '\n' {
-		message += "\n"
+	if f.DisableLineBreak {
+		if message[len(message)-1] != '\n' {
+			message = message[:len(message)-1]
+		}
+	} else {
+		if message[len(message)-1] != '\n' {
+			message += "\n"
+		}
 	}
 	return message
 }

+ 3 - 2
log/observable.go

@@ -28,8 +28,9 @@ func NewObservableFactory(formatter Formatter, writer io.Writer, platformWriter
 	factory := &observableFactory{
 		formatter: formatter,
 		platformFormatter: Formatter{
-			BaseTime:      formatter.BaseTime,
-			DisableColors: C.IsDarwin || C.IsIos,
+			BaseTime:         formatter.BaseTime,
+			DisableColors:    C.IsDarwin || C.IsIos,
+			DisableLineBreak: true,
 		},
 		writer:         writer,
 		platformWriter: platformWriter,

+ 12 - 2
outbound/urltest.go

@@ -18,6 +18,7 @@ import (
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/service"
 )
 
 var (
@@ -74,7 +75,11 @@ func (s *URLTest) Start() error {
 		outbounds = append(outbounds, detour)
 	}
 	s.group = NewURLTestGroup(s.ctx, s.router, s.logger, outbounds, s.link, s.interval, s.tolerance)
-	go s.group.CheckOutbounds(false)
+	return nil
+}
+
+func (s *URLTest) PostStart() error {
+	go s.CheckOutbounds()
 	return nil
 }
 
@@ -96,6 +101,10 @@ func (s *URLTest) URLTest(ctx context.Context, link string) (map[string]uint16,
 	return s.group.URLTest(ctx, link)
 }
 
+func (s *URLTest) CheckOutbounds() {
+	s.group.CheckOutbounds(true)
+}
+
 func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
 	s.group.Start()
 	outbound := s.group.Select(network)
@@ -157,7 +166,8 @@ func NewURLTestGroup(ctx context.Context, router adapter.Router, logger log.Logg
 		tolerance = 50
 	}
 	var history *urltest.HistoryStorage
-	if clashServer := router.ClashServer(); clashServer != nil {
+	if history = service.PtrFromContext[urltest.HistoryStorage](ctx); history != nil {
+	} else if clashServer := router.ClashServer(); clashServer != nil {
 		history = clashServer.HistoryStorage()
 	} else {
 		history = urltest.NewHistoryStorage()