tools: Network Quality & STUN

This commit is contained in:
世界
2026-04-08 14:44:14 +08:00
parent 6ff8ce7823
commit 7405cadc0b
19 changed files with 3799 additions and 86 deletions
+1
View File
@@ -6,4 +6,5 @@ const (
CommandGroup
CommandClashMode
CommandConnections
CommandOutbounds
)
+117
View File
@@ -47,6 +47,7 @@ type CommandClientHandler interface {
WriteLogs(messageList LogIterator)
WriteStatus(message *StatusMessage)
WriteGroups(message OutboundGroupIterator)
WriteOutbounds(message OutboundGroupItemIterator)
InitializeClashMode(modeList StringIterator, currentMode string)
UpdateClashMode(newMode string)
WriteConnectionEvents(events *ConnectionEvents)
@@ -243,6 +244,8 @@ func (c *CommandClient) dispatchCommands() error {
go c.handleClashModeStream()
case CommandConnections:
go c.handleConnectionsStream()
case CommandOutbounds:
go c.handleOutboundsStream()
default:
return E.New("unknown command: ", command)
}
@@ -456,6 +459,25 @@ func (c *CommandClient) handleConnectionsStream() {
}
}
func (c *CommandClient) handleOutboundsStream() {
client, ctx := c.getStreamContext()
stream, err := client.SubscribeOutbounds(ctx, &emptypb.Empty{})
if err != nil {
c.handler.Disconnected(err.Error())
return
}
for {
list, err := stream.Recv()
if err != nil {
c.handler.Disconnected(err.Error())
return
}
c.handler.WriteOutbounds(outboundGroupItemListFromGRPC(list))
}
}
func (c *CommandClient) SelectOutbound(groupTag string, outboundTag string) error {
_, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) {
return client.SelectOutbound(context.Background(), &daemon.SelectOutboundRequest{
@@ -603,3 +625,98 @@ func (c *CommandClient) SetGroupExpand(groupTag string, isExpand bool) error {
})
return err
}
func (c *CommandClient) ListOutbounds() (OutboundGroupItemIterator, error) {
return callWithResult(c, func(client daemon.StartedServiceClient) (OutboundGroupItemIterator, error) {
list, err := client.ListOutbounds(context.Background(), &emptypb.Empty{})
if err != nil {
return nil, err
}
return outboundGroupItemListFromGRPC(list), nil
})
}
func (c *CommandClient) StartNetworkQualityTest(configURL string, outboundTag string, serial bool, maxRuntimeSeconds int32, http3 bool, handler NetworkQualityTestHandler) error {
client, err := c.getClientForCall()
if err != nil {
return err
}
if c.standalone {
defer c.closeConnection()
}
stream, err := client.StartNetworkQualityTest(context.Background(), &daemon.NetworkQualityTestRequest{
ConfigURL: configURL,
OutboundTag: outboundTag,
Serial: serial,
MaxRuntimeSeconds: maxRuntimeSeconds,
Http3: http3,
})
if err != nil {
return err
}
for {
event, recvErr := stream.Recv()
if recvErr != nil {
handler.OnError(recvErr.Error())
return recvErr
}
if event.IsFinal {
if event.Error != "" {
handler.OnError(event.Error)
} else {
handler.OnResult(&NetworkQualityResult{
DownloadCapacity: event.DownloadCapacity,
UploadCapacity: event.UploadCapacity,
DownloadRPM: event.DownloadRPM,
UploadRPM: event.UploadRPM,
IdleLatencyMs: event.IdleLatencyMs,
DownloadCapacityAccuracy: event.DownloadCapacityAccuracy,
UploadCapacityAccuracy: event.UploadCapacityAccuracy,
DownloadRPMAccuracy: event.DownloadRPMAccuracy,
UploadRPMAccuracy: event.UploadRPMAccuracy,
})
}
return nil
}
handler.OnProgress(networkQualityProgressFromGRPC(event))
}
}
func (c *CommandClient) StartSTUNTest(server string, outboundTag string, handler STUNTestHandler) error {
client, err := c.getClientForCall()
if err != nil {
return err
}
if c.standalone {
defer c.closeConnection()
}
stream, err := client.StartSTUNTest(context.Background(), &daemon.STUNTestRequest{
Server: server,
OutboundTag: outboundTag,
})
if err != nil {
return err
}
for {
event, recvErr := stream.Recv()
if recvErr != nil {
handler.OnError(recvErr.Error())
return recvErr
}
if event.IsFinal {
if event.Error != "" {
handler.OnError(event.Error)
} else {
handler.OnResult(&STUNTestResult{
ExternalAddr: event.ExternalAddr,
LatencyMs: event.LatencyMs,
NATMapping: event.NatMapping,
NATFiltering: event.NatFiltering,
NATTypeSupported: event.NatTypeSupported,
})
}
return nil
}
handler.OnProgress(stunTestProgressFromGRPC(event))
}
}
+16
View File
@@ -339,6 +339,22 @@ func outboundGroupIteratorFromGRPC(groups *daemon.Groups) OutboundGroupIterator
return newIterator(libboxGroups)
}
func outboundGroupItemListFromGRPC(list *daemon.OutboundList) OutboundGroupItemIterator {
if list == nil || len(list.Outbounds) == 0 {
return newIterator([]*OutboundGroupItem{})
}
var items []*OutboundGroupItem
for _, ob := range list.Outbounds {
items = append(items, &OutboundGroupItem{
Tag: ob.Tag,
Type: ob.Type,
URLTestTime: ob.UrlTestTime,
URLTestDelay: ob.UrlTestDelay,
})
}
return newIterator(items)
}
func connectionFromGRPC(conn *daemon.Connection) Connection {
var processInfo *ProcessInfo
if conn.ProcessInfo != nil {
+51
View File
@@ -0,0 +1,51 @@
package libbox
import "github.com/sagernet/sing-box/daemon"
type NetworkQualityProgress struct {
Phase int32
DownloadCapacity int64
UploadCapacity int64
DownloadRPM int32
UploadRPM int32
IdleLatencyMs int32
ElapsedMs int64
DownloadCapacityAccuracy int32
UploadCapacityAccuracy int32
DownloadRPMAccuracy int32
UploadRPMAccuracy int32
}
type NetworkQualityResult struct {
DownloadCapacity int64
UploadCapacity int64
DownloadRPM int32
UploadRPM int32
IdleLatencyMs int32
DownloadCapacityAccuracy int32
UploadCapacityAccuracy int32
DownloadRPMAccuracy int32
UploadRPMAccuracy int32
}
type NetworkQualityTestHandler interface {
OnProgress(progress *NetworkQualityProgress)
OnResult(result *NetworkQualityResult)
OnError(message string)
}
func networkQualityProgressFromGRPC(event *daemon.NetworkQualityTestProgress) *NetworkQualityProgress {
return &NetworkQualityProgress{
Phase: event.Phase,
DownloadCapacity: event.DownloadCapacity,
UploadCapacity: event.UploadCapacity,
DownloadRPM: event.DownloadRPM,
UploadRPM: event.UploadRPM,
IdleLatencyMs: event.IdleLatencyMs,
ElapsedMs: event.ElapsedMs,
DownloadCapacityAccuracy: event.DownloadCapacityAccuracy,
UploadCapacityAccuracy: event.UploadCapacityAccuracy,
DownloadRPMAccuracy: event.DownloadRPMAccuracy,
UploadRPMAccuracy: event.UploadRPMAccuracy,
}
}
+35
View File
@@ -0,0 +1,35 @@
package libbox
import "github.com/sagernet/sing-box/daemon"
type STUNTestProgress struct {
Phase int32
ExternalAddr string
LatencyMs int32
NATMapping int32
NATFiltering int32
}
type STUNTestResult struct {
ExternalAddr string
LatencyMs int32
NATMapping int32
NATFiltering int32
NATTypeSupported bool
}
type STUNTestHandler interface {
OnProgress(progress *STUNTestProgress)
OnResult(result *STUNTestResult)
OnError(message string)
}
func stunTestProgressFromGRPC(event *daemon.STUNTestProgress) *STUNTestProgress {
return &STUNTestProgress{
Phase: event.Phase,
ExternalAddr: event.ExternalAddr,
LatencyMs: event.LatencyMs,
NATMapping: event.NatMapping,
NATFiltering: event.NatFiltering,
}
}
+74
View File
@@ -0,0 +1,74 @@
package libbox
import (
"context"
"time"
"github.com/sagernet/sing-box/common/networkquality"
)
type NetworkQualityTest struct {
ctx context.Context
cancel context.CancelFunc
}
func NewNetworkQualityTest() *NetworkQualityTest {
ctx, cancel := context.WithCancel(context.Background())
return &NetworkQualityTest{ctx: ctx, cancel: cancel}
}
func (t *NetworkQualityTest) Start(configURL string, serial bool, maxRuntimeSeconds int32, http3 bool, handler NetworkQualityTestHandler) {
go func() {
httpClient := networkquality.NewHTTPClient(nil)
defer httpClient.CloseIdleConnections()
measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(nil, http3)
if err != nil {
handler.OnError(err.Error())
return
}
result, err := networkquality.Run(networkquality.Options{
ConfigURL: configURL,
HTTPClient: httpClient,
NewMeasurementClient: measurementClientFactory,
Serial: serial,
MaxRuntime: time.Duration(maxRuntimeSeconds) * time.Second,
Context: t.ctx,
OnProgress: func(p networkquality.Progress) {
handler.OnProgress(&NetworkQualityProgress{
Phase: int32(p.Phase),
DownloadCapacity: p.DownloadCapacity,
UploadCapacity: p.UploadCapacity,
DownloadRPM: p.DownloadRPM,
UploadRPM: p.UploadRPM,
IdleLatencyMs: p.IdleLatencyMs,
ElapsedMs: p.ElapsedMs,
DownloadCapacityAccuracy: int32(p.DownloadCapacityAccuracy),
UploadCapacityAccuracy: int32(p.UploadCapacityAccuracy),
DownloadRPMAccuracy: int32(p.DownloadRPMAccuracy),
UploadRPMAccuracy: int32(p.UploadRPMAccuracy),
})
},
})
if err != nil {
handler.OnError(err.Error())
return
}
handler.OnResult(&NetworkQualityResult{
DownloadCapacity: result.DownloadCapacity,
UploadCapacity: result.UploadCapacity,
DownloadRPM: result.DownloadRPM,
UploadRPM: result.UploadRPM,
IdleLatencyMs: result.IdleLatencyMs,
DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy),
UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy),
DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy),
UploadRPMAccuracy: int32(result.UploadRPMAccuracy),
})
}()
}
func (t *NetworkQualityTest) Cancel() {
t.cancel()
}
+52
View File
@@ -9,6 +9,8 @@ import (
"strings"
"time"
"github.com/sagernet/sing-box/common/networkquality"
"github.com/sagernet/sing-box/common/stun"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental/locale"
"github.com/sagernet/sing-box/log"
@@ -129,6 +131,56 @@ func FormatDuration(duration int64) string {
return log.FormatDuration(time.Duration(duration) * time.Millisecond)
}
func FormatBitrate(bps int64) string {
return networkquality.FormatBitrate(bps)
}
const NetworkQualityDefaultConfigURL = networkquality.DefaultConfigURL
const NetworkQualityDefaultMaxRuntimeSeconds = int32(networkquality.DefaultMaxRuntime / time.Second)
const (
NetworkQualityAccuracyLow = int32(networkquality.AccuracyLow)
NetworkQualityAccuracyMedium = int32(networkquality.AccuracyMedium)
NetworkQualityAccuracyHigh = int32(networkquality.AccuracyHigh)
)
const (
NetworkQualityPhaseIdle = int32(networkquality.PhaseIdle)
NetworkQualityPhaseDownload = int32(networkquality.PhaseDownload)
NetworkQualityPhaseUpload = int32(networkquality.PhaseUpload)
NetworkQualityPhaseDone = int32(networkquality.PhaseDone)
)
const STUNDefaultServer = stun.DefaultServer
const (
STUNPhaseBinding = int32(stun.PhaseBinding)
STUNPhaseNATMapping = int32(stun.PhaseNATMapping)
STUNPhaseNATFiltering = int32(stun.PhaseNATFiltering)
STUNPhaseDone = int32(stun.PhaseDone)
)
const (
NATMappingEndpointIndependent = int32(stun.NATMappingEndpointIndependent)
NATMappingAddressDependent = int32(stun.NATMappingAddressDependent)
NATMappingAddressAndPortDependent = int32(stun.NATMappingAddressAndPortDependent)
)
const (
NATFilteringEndpointIndependent = int32(stun.NATFilteringEndpointIndependent)
NATFilteringAddressDependent = int32(stun.NATFilteringAddressDependent)
NATFilteringAddressAndPortDependent = int32(stun.NATFilteringAddressAndPortDependent)
)
func FormatNATMapping(value int32) string {
return stun.NATMapping(value).String()
}
func FormatNATFiltering(value int32) string {
return stun.NATFiltering(value).String()
}
func ProxyDisplayType(proxyType string) string {
return C.ProxyDisplayName(proxyType)
}
+50
View File
@@ -0,0 +1,50 @@
package libbox
import (
"context"
"github.com/sagernet/sing-box/common/stun"
)
type STUNTest struct {
ctx context.Context
cancel context.CancelFunc
}
func NewSTUNTest() *STUNTest {
ctx, cancel := context.WithCancel(context.Background())
return &STUNTest{ctx: ctx, cancel: cancel}
}
func (t *STUNTest) Start(server string, handler STUNTestHandler) {
go func() {
result, err := stun.Run(stun.Options{
Server: server,
Context: t.ctx,
OnProgress: func(p stun.Progress) {
handler.OnProgress(&STUNTestProgress{
Phase: int32(p.Phase),
ExternalAddr: p.ExternalAddr,
LatencyMs: p.LatencyMs,
NATMapping: int32(p.NATMapping),
NATFiltering: int32(p.NATFiltering),
})
},
})
if err != nil {
handler.OnError(err.Error())
return
}
handler.OnResult(&STUNTestResult{
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
NATMapping: int32(result.NATMapping),
NATFiltering: int32(result.NATFiltering),
NATTypeSupported: result.NATTypeSupported,
})
}()
}
func (t *STUNTest) Cancel() {
t.cancel()
}