Add sing-box API service

This commit is contained in:
世界
2026-06-11 21:11:10 +08:00
parent 1ee3596db9
commit 3a5d654463
36 changed files with 2075 additions and 849 deletions
+10 -4
View File
@@ -5,10 +5,10 @@ import (
"context"
"net"
"net/http"
"runtime"
"runtime/debug"
"time"
"github.com/sagernet/sing-box/experimental/clashapi/trafficontrol"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/ws"
"github.com/sagernet/ws/wsutil"
@@ -28,7 +28,7 @@ func (s *Server) setupMetaAPI(r chi.Router) {
})
r.Mount("/", middleware.Profiler())
}
r.Get("/memory", memory(s.ctx, s.trafficManager))
r.Get("/memory", memory(s.ctx))
r.Mount("/group", groupRouter(s))
r.Mount("/upgrade", upgradeRouter(s))
}
@@ -38,7 +38,13 @@ type Memory struct {
OSLimit uint64 `json:"oslimit"` // maybe we need it in the future
}
func memory(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
func inuseMemory() uint64 {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return memStats.StackInuse + memStats.HeapInuse + memStats.HeapIdle - memStats.HeapReleased
}
func memory(ctx context.Context) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var conn net.Conn
if r.Header.Get("Upgrade") == "websocket" {
@@ -68,7 +74,7 @@ func memory(ctx context.Context, trafficManager *trafficontrol.Manager) func(w h
}
buf.Reset()
inuse := trafficManager.Snapshot().Memory
inuse := inuseMemory()
// make chat.js begin with zero
// this is shit var,but we need output 0 for first time
+91 -20
View File
@@ -8,7 +8,10 @@ import (
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/experimental/clashapi/trafficontrol"
"github.com/sagernet/sing-box/common/trafficcontrol"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing/common"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/ws"
"github.com/sagernet/ws/wsutil"
@@ -18,7 +21,7 @@ import (
"github.com/gofrs/uuid/v5"
)
func connectionRouter(ctx context.Context, network adapter.NetworkManager, trafficManager *trafficontrol.Manager) http.Handler {
func connectionRouter(ctx context.Context, network adapter.NetworkManager, trafficManager *trafficcontrol.Manager) http.Handler {
r := chi.NewRouter()
r.Get("/", getConnections(ctx, trafficManager))
r.Delete("/", closeAllConnections(network, trafficManager))
@@ -26,11 +29,85 @@ func connectionRouter(ctx context.Context, network adapter.NetworkManager, traff
return r
}
func getConnections(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
func connectionsSnapshot(trafficManager *trafficcontrol.Manager) render.M {
uplinkTotal, downlinkTotal := trafficManager.Total()
connections := common.Filter(trafficManager.Connections(), func(metadata *trafficcontrol.TrackerMetadata) bool {
return metadata.OutboundType != C.TypeDNS
})
return render.M{
"downloadTotal": downlinkTotal,
"uploadTotal": uplinkTotal,
"connections": common.Map(connections, func(metadata *trafficcontrol.TrackerMetadata) connectionObject {
return connectionObject(*metadata)
}),
"memory": inuseMemory(),
}
}
type connectionObject trafficcontrol.TrackerMetadata
func (c connectionObject) MarshalJSON() ([]byte, error) {
var inbound string
if c.Metadata.Inbound != "" {
inbound = c.Metadata.InboundType + "/" + c.Metadata.Inbound
} else {
inbound = c.Metadata.InboundType
}
var domain string
if c.Metadata.Domain != "" {
domain = c.Metadata.Domain
} else {
domain = c.Metadata.Destination.Fqdn
}
var processPath string
if c.Metadata.ProcessInfo != nil {
if c.Metadata.ProcessInfo.ProcessPath != "" {
processPath = c.Metadata.ProcessInfo.ProcessPath
} else if len(c.Metadata.ProcessInfo.AndroidPackageNames) > 0 {
processPath = c.Metadata.ProcessInfo.AndroidPackageNames[0]
}
if processPath == "" {
if c.Metadata.ProcessInfo.UserId != -1 {
processPath = F.ToString(c.Metadata.ProcessInfo.UserId)
}
} else if c.Metadata.ProcessInfo.UserName != "" {
processPath = F.ToString(processPath, " (", c.Metadata.ProcessInfo.UserName, ")")
} else if c.Metadata.ProcessInfo.UserId != -1 {
processPath = F.ToString(processPath, " (", c.Metadata.ProcessInfo.UserId, ")")
}
}
var rule string
if c.Rule != nil {
rule = F.ToString(c.Rule, " => ", c.Rule.Action())
} else {
rule = "final"
}
return json.Marshal(map[string]any{
"id": c.ID,
"metadata": map[string]any{
"network": c.Metadata.Network,
"type": inbound,
"sourceIP": c.Metadata.Source.Addr,
"destinationIP": c.Metadata.Destination.Addr,
"sourcePort": F.ToString(c.Metadata.Source.Port),
"destinationPort": F.ToString(c.Metadata.Destination.Port),
"host": domain,
"dnsMode": "normal",
"processPath": processPath,
},
"upload": c.Upload.Load(),
"download": c.Download.Load(),
"start": c.CreatedAt,
"chains": c.Chain,
"rule": rule,
"rulePayload": "",
})
}
func getConnections(ctx context.Context, trafficManager *trafficcontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") != "websocket" {
snapshot := trafficManager.Snapshot()
render.JSON(w, r, snapshot)
render.JSON(w, r, connectionsSnapshot(trafficManager))
return
}
@@ -56,9 +133,9 @@ func getConnections(ctx context.Context, trafficManager *trafficontrol.Manager)
buf := &bytes.Buffer{}
sendSnapshot := func() error {
buf.Reset()
snapshot := trafficManager.Snapshot()
if err := json.NewEncoder(buf).Encode(snapshot); err != nil {
return err
encodeErr := json.NewEncoder(buf).Encode(connectionsSnapshot(trafficManager))
if encodeErr != nil {
return encodeErr
}
return wsutil.WriteServerText(conn, buf.Bytes())
}
@@ -82,26 +159,20 @@ func getConnections(ctx context.Context, trafficManager *trafficontrol.Manager)
}
}
func closeConnection(trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
func closeConnection(trafficManager *trafficcontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
id := uuid.FromStringOrNil(chi.URLParam(r, "id"))
snapshot := trafficManager.Snapshot()
for _, c := range snapshot.Connections {
if id == c.Metadata().ID {
c.Close()
break
}
targetConnection := trafficManager.Connection(id)
if targetConnection != nil {
targetConnection.Close()
}
render.NoContent(w, r)
}
}
func closeAllConnections(network adapter.NetworkManager, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
func closeAllConnections(network adapter.NetworkManager, trafficManager *trafficcontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
snapshot := trafficManager.Snapshot()
for _, c := range snapshot.Connections {
c.Close()
}
trafficManager.CloseAllConnections()
network.ResetNetwork()
render.NoContent(w, r)
}
+7 -22
View File
@@ -14,17 +14,15 @@ import (
"github.com/sagernet/cors"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/trafficcontrol"
"github.com/sagernet/sing-box/common/urltest"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental"
"github.com/sagernet/sing-box/experimental/clashapi/trafficontrol"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/cleanup"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/observable"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
@@ -50,10 +48,9 @@ type Server struct {
endpoint adapter.EndpointManager
logger log.Logger
httpServer *http.Server
trafficManager *trafficontrol.Manager
trafficManager *trafficcontrol.Manager
urlTestHistory adapter.URLTestHistoryStorage
logDebug bool
cleaner *cleanup.Cleaner
mode string
modeList []string
@@ -66,7 +63,10 @@ type Server struct {
}
func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
trafficManager := trafficontrol.NewManager()
trafficManager := service.PtrFromContext[trafficcontrol.Manager](ctx)
if trafficManager == nil {
return nil, E.New("missing traffic manager")
}
chiRouter := chi.NewRouter()
s := &Server{
ctx: ctx,
@@ -86,7 +86,6 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op
externalController: options.ExternalController != "",
externalUIDownloadURL: options.ExternalUIDownloadURL,
externalUIDownloadDetour: options.ExternalUIDownloadDetour,
cleaner: cleanup.Add(trafficManager.Clear),
}
s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx)
if s.urlTestHistory == nil {
@@ -196,9 +195,7 @@ func (s *Server) Start(stage adapter.StartStage) error {
func (s *Server) Close() error {
return common.Close(
common.PtrOrNil(s.httpServer),
s.trafficManager,
s.urlTestHistory,
common.PtrOrNil(s.cleaner),
)
}
@@ -245,18 +242,6 @@ func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage {
return s.urlTestHistory
}
func (s *Server) TrafficManager() *trafficontrol.Manager {
return s.trafficManager
}
func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn {
return trafficontrol.NewTCPTracker(conn, s.trafficManager, metadata, s.outbound, matchedRule, matchOutbound)
}
func (s *Server) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn {
return trafficontrol.NewUDPTracker(conn, s.trafficManager, metadata, s.outbound, matchedRule, matchOutbound)
}
func authentication(serverSecret string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
@@ -309,7 +294,7 @@ type Traffic struct {
Down int64 `json:"down"`
}
func traffic(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
func traffic(ctx context.Context, trafficManager *trafficcontrol.Manager) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var conn net.Conn
if r.Header.Get("Upgrade") == "websocket" {
@@ -1,182 +0,0 @@
package trafficontrol
import (
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/common/compatible"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/observable"
"github.com/sagernet/sing/common/x/list"
"github.com/gofrs/uuid/v5"
)
type ConnectionEventType int
const (
ConnectionEventNew ConnectionEventType = iota
ConnectionEventUpdate
ConnectionEventClosed
)
type ConnectionEvent struct {
Type ConnectionEventType
ID uuid.UUID
Metadata *TrackerMetadata
UplinkDelta int64
DownlinkDelta int64
ClosedAt time.Time
}
const closedConnectionsLimit = 1000
type Manager struct {
uploadTotal atomic.Int64
downloadTotal atomic.Int64
connections compatible.Map[uuid.UUID, Tracker]
closedConnectionsAccess sync.Mutex
closedConnections list.List[TrackerMetadata]
memory uint64
eventSubscriber *observable.Subscriber[ConnectionEvent]
}
func NewManager() *Manager {
return &Manager{}
}
func (m *Manager) SetEventHook(subscriber *observable.Subscriber[ConnectionEvent]) {
m.eventSubscriber = subscriber
}
func (m *Manager) Join(c Tracker) {
metadata := c.Metadata()
m.connections.Store(metadata.ID, c)
if m.eventSubscriber != nil {
m.eventSubscriber.Emit(ConnectionEvent{
Type: ConnectionEventNew,
ID: metadata.ID,
Metadata: metadata,
})
}
}
func (m *Manager) Leave(c Tracker) {
metadata := c.Metadata()
_, loaded := m.connections.LoadAndDelete(metadata.ID)
if loaded {
closedAt := time.Now()
metadata.ClosedAt = closedAt
metadataCopy := *metadata
m.closedConnectionsAccess.Lock()
if m.closedConnections.Len() >= closedConnectionsLimit {
m.closedConnections.PopFront()
}
m.closedConnections.PushBack(metadataCopy)
m.closedConnectionsAccess.Unlock()
if m.eventSubscriber != nil {
m.eventSubscriber.Emit(ConnectionEvent{
Type: ConnectionEventClosed,
ID: metadata.ID,
Metadata: &metadataCopy,
ClosedAt: closedAt,
})
}
}
}
func (m *Manager) PushUploaded(size int64) {
m.uploadTotal.Add(size)
}
func (m *Manager) PushDownloaded(size int64) {
m.downloadTotal.Add(size)
}
func (m *Manager) Total() (up int64, down int64) {
return m.uploadTotal.Load(), m.downloadTotal.Load()
}
func (m *Manager) ConnectionsLen() int {
return m.connections.Len()
}
func (m *Manager) Connections() []*TrackerMetadata {
var connections []*TrackerMetadata
m.connections.Range(func(_ uuid.UUID, value Tracker) bool {
connections = append(connections, value.Metadata())
return true
})
return connections
}
func (m *Manager) ClosedConnections() []*TrackerMetadata {
m.closedConnectionsAccess.Lock()
values := m.closedConnections.Array()
m.closedConnectionsAccess.Unlock()
if len(values) == 0 {
return nil
}
connections := make([]*TrackerMetadata, len(values))
for i := range values {
connections[i] = &values[i]
}
return connections
}
func (m *Manager) Connection(id uuid.UUID) Tracker {
connection, loaded := m.connections.Load(id)
if !loaded {
return nil
}
return connection
}
func (m *Manager) Snapshot() *Snapshot {
var connections []Tracker
m.connections.Range(func(_ uuid.UUID, value Tracker) bool {
if value.Metadata().OutboundType != C.TypeDNS {
connections = append(connections, value)
}
return true
})
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
m.memory = memStats.StackInuse + memStats.HeapInuse + memStats.HeapIdle - memStats.HeapReleased
return &Snapshot{
Upload: m.uploadTotal.Load(),
Download: m.downloadTotal.Load(),
Connections: connections,
Memory: m.memory,
}
}
func (m *Manager) Clear() {
m.closedConnectionsAccess.Lock()
defer m.closedConnectionsAccess.Unlock()
m.closedConnections.Init()
}
type Snapshot struct {
Download int64
Upload int64
Connections []Tracker
Memory uint64
}
func (s *Snapshot) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"downloadTotal": s.Download,
"uploadTotal": s.Upload,
"connections": common.Map(s.Connections, func(t Tracker) *TrackerMetadata { return t.Metadata() }),
"memory": s.Memory,
})
}
@@ -1,254 +0,0 @@
package trafficontrol
import (
"net"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/bufio"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/json"
N "github.com/sagernet/sing/common/network"
"github.com/gofrs/uuid/v5"
)
type TrackerMetadata struct {
ID uuid.UUID
Metadata adapter.InboundContext
CreatedAt time.Time
ClosedAt time.Time
Upload *atomic.Int64
Download *atomic.Int64
Chain []string
Rule adapter.Rule
Outbound string
OutboundType string
}
func (t TrackerMetadata) MarshalJSON() ([]byte, error) {
var inbound string
if t.Metadata.Inbound != "" {
inbound = t.Metadata.InboundType + "/" + t.Metadata.Inbound
} else {
inbound = t.Metadata.InboundType
}
var domain string
if t.Metadata.Domain != "" {
domain = t.Metadata.Domain
} else {
domain = t.Metadata.Destination.Fqdn
}
var processPath string
if t.Metadata.ProcessInfo != nil {
if t.Metadata.ProcessInfo.ProcessPath != "" {
processPath = t.Metadata.ProcessInfo.ProcessPath
} else if len(t.Metadata.ProcessInfo.AndroidPackageNames) > 0 {
processPath = t.Metadata.ProcessInfo.AndroidPackageNames[0]
}
if processPath == "" {
if t.Metadata.ProcessInfo.UserId != -1 {
processPath = F.ToString(t.Metadata.ProcessInfo.UserId)
}
} else if t.Metadata.ProcessInfo.UserName != "" {
processPath = F.ToString(processPath, " (", t.Metadata.ProcessInfo.UserName, ")")
} else if t.Metadata.ProcessInfo.UserId != -1 {
processPath = F.ToString(processPath, " (", t.Metadata.ProcessInfo.UserId, ")")
}
}
var rule string
if t.Rule != nil {
rule = F.ToString(t.Rule, " => ", t.Rule.Action())
} else {
rule = "final"
}
return json.Marshal(map[string]any{
"id": t.ID,
"metadata": map[string]any{
"network": t.Metadata.Network,
"type": inbound,
"sourceIP": t.Metadata.Source.Addr,
"destinationIP": t.Metadata.Destination.Addr,
"sourcePort": F.ToString(t.Metadata.Source.Port),
"destinationPort": F.ToString(t.Metadata.Destination.Port),
"host": domain,
"dnsMode": "normal",
"processPath": processPath,
},
"upload": t.Upload.Load(),
"download": t.Download.Load(),
"start": t.CreatedAt,
"chains": t.Chain,
"rule": rule,
"rulePayload": "",
})
}
type Tracker interface {
Metadata() *TrackerMetadata
Close() error
}
type TCPConn struct {
N.ExtendedConn
metadata TrackerMetadata
manager *Manager
}
func (tt *TCPConn) Metadata() *TrackerMetadata {
return &tt.metadata
}
func (tt *TCPConn) Close() error {
tt.manager.Leave(tt)
return tt.ExtendedConn.Close()
}
func (tt *TCPConn) Upstream() any {
return tt.ExtendedConn
}
func (tt *TCPConn) ReaderReplaceable() bool {
return true
}
func (tt *TCPConn) WriterReplaceable() bool {
return true
}
func NewTCPTracker(conn net.Conn, manager *Manager, metadata adapter.InboundContext, outboundManager adapter.OutboundManager, matchRule adapter.Rule, matchOutbound adapter.Outbound) *TCPConn {
id, _ := uuid.NewV4()
var (
chain []string
next string
outbound string
outboundType string
)
if matchOutbound != nil {
next = matchOutbound.Tag()
} else {
next = outboundManager.Default().Tag()
}
for {
detour, loaded := outboundManager.Outbound(next)
if !loaded {
break
}
chain = append(chain, next)
outbound = detour.Tag()
outboundType = detour.Type()
group, isGroup := detour.(adapter.OutboundGroup)
if !isGroup {
break
}
next = group.Now()
}
upload := new(atomic.Int64)
download := new(atomic.Int64)
tracker := &TCPConn{
ExtendedConn: bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) {
upload.Add(n)
manager.PushUploaded(n)
}}, []N.CountFunc{func(n int64) {
download.Add(n)
manager.PushDownloaded(n)
}}),
metadata: TrackerMetadata{
ID: id,
Metadata: metadata,
CreatedAt: time.Now(),
Upload: upload,
Download: download,
Chain: common.Reverse(chain),
Rule: matchRule,
Outbound: outbound,
OutboundType: outboundType,
},
manager: manager,
}
manager.Join(tracker)
return tracker
}
type UDPConn struct {
N.PacketConn `json:"-"`
metadata TrackerMetadata
manager *Manager
}
func (ut *UDPConn) Metadata() *TrackerMetadata {
return &ut.metadata
}
func (ut *UDPConn) Close() error {
ut.manager.Leave(ut)
return ut.PacketConn.Close()
}
func (ut *UDPConn) Upstream() any {
return ut.PacketConn
}
func (ut *UDPConn) ReaderReplaceable() bool {
return true
}
func (ut *UDPConn) WriterReplaceable() bool {
return true
}
func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata adapter.InboundContext, outboundManager adapter.OutboundManager, matchRule adapter.Rule, matchOutbound adapter.Outbound) *UDPConn {
id, _ := uuid.NewV4()
var (
chain []string
next string
outbound string
outboundType string
)
if matchOutbound != nil {
next = matchOutbound.Tag()
} else {
next = outboundManager.Default().Tag()
}
for {
detour, loaded := outboundManager.Outbound(next)
if !loaded {
break
}
chain = append(chain, next)
outbound = detour.Tag()
outboundType = detour.Type()
group, isGroup := detour.(adapter.OutboundGroup)
if !isGroup {
break
}
next = group.Now()
}
upload := new(atomic.Int64)
download := new(atomic.Int64)
trackerConn := &UDPConn{
PacketConn: bufio.NewCounterPacketConn(conn, []N.CountFunc{func(n int64) {
upload.Add(n)
manager.PushUploaded(n)
}}, []N.CountFunc{func(n int64) {
download.Add(n)
manager.PushDownloaded(n)
}}),
metadata: TrackerMetadata{
ID: id,
Metadata: metadata,
CreatedAt: time.Now(),
Upload: upload,
Download: download,
Chain: common.Reverse(chain),
Rule: matchRule,
Outbound: outbound,
OutboundType: outboundType,
},
manager: manager,
}
manager.Join(trackerConn)
return trackerConn
}
+50 -18
View File
@@ -28,6 +28,7 @@ type CommandClient struct {
grpcClient daemon.StartedServiceClient
grpcManagedClient daemon.ManagedServiceClient
options CommandClientOptions
remote *remoteConnection
ctx context.Context
cancel context.CancelFunc
clientMutex sync.RWMutex
@@ -147,23 +148,41 @@ func networkConnectionFromFileDescriptor(fileDescriptor int32) (net.Conn, error)
return networkConnection, nil
}
func (c *CommandClient) dialWithRetry(target string, contextDialer func(context.Context, string) (net.Conn, error), retryDial bool) (*grpc.ClientConn, daemon.StartedServiceClient, error) {
func localDialOptions(contextDialer func(context.Context, string) (net.Conn, error)) []grpc.DialOption {
options := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(unaryClientAuthInterceptor),
grpc.WithStreamInterceptor(streamClientAuthInterceptor),
}
if contextDialer != nil {
options = append(options, grpc.WithContextDialer(contextDialer))
}
return options
}
// establishConnection dials the command server the client is bound to: the
// local command server (over socket/XPC) or a remote API service.
func (c *CommandClient) establishConnection() (*grpc.ClientConn, daemon.StartedServiceClient, error) {
if c.remote != nil {
return c.dialRemote()
}
target, contextDialer := dialTarget()
return c.dialWithRetry(target, localDialOptions(contextDialer), true)
}
// dialWithRetry connects to the local command server. The retry loop exists to
// wait out the server starting up: WaitForReady keeps the probe redialing and
// the loop reissues it with a growing delay, so a freshly launched extension is
// picked up without surfacing a transient "unavailable" to the UI.
func (c *CommandClient) dialWithRetry(target string, dialOptions []grpc.DialOption, retryDial bool) (*grpc.ClientConn, daemon.StartedServiceClient, error) {
var connection *grpc.ClientConn
var client daemon.StartedServiceClient
var lastError error
for attempt := range commandClientDialAttempts {
if connection == nil {
options := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUnaryInterceptor(unaryClientAuthInterceptor),
grpc.WithStreamInterceptor(streamClientAuthInterceptor),
}
if contextDialer != nil {
options = append(options, grpc.WithContextDialer(contextDialer))
}
var err error
connection, err = grpc.NewClient(target, options...)
connection, err = grpc.NewClient(target, dialOptions...)
if err != nil {
lastError = err
if !retryDial {
@@ -174,8 +193,7 @@ func (c *CommandClient) dialWithRetry(target string, contextDialer func(context.
}
client = daemon.NewStartedServiceClient(connection)
}
waitDuration := commandClientDialDelay(attempt)
ctx, cancel := context.WithTimeout(context.Background(), waitDuration)
ctx, cancel := context.WithTimeout(context.Background(), commandClientDialDelay(attempt))
_, err := client.GetStartedAt(ctx, &emptypb.Empty{}, grpc.WaitForReady(true))
cancel()
if err == nil {
@@ -190,12 +208,27 @@ func (c *CommandClient) dialWithRetry(target string, contextDialer func(context.
return nil, nil, E.Cause(lastError, "probe command server")
}
func (c *CommandClient) dialRemote() (*grpc.ClientConn, daemon.StartedServiceClient, error) {
connection, err := grpc.NewClient(c.remote.target, c.remote.dialOptions...)
if err != nil {
return nil, nil, E.Cause(err, "create remote command client")
}
client := daemon.NewStartedServiceClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), commandClientRemoteProbeTimeout)
defer cancel()
_, err = client.GetStartedAt(ctx, &emptypb.Empty{})
if err != nil {
connection.Close()
return nil, nil, E.Cause(err, "connect to remote server")
}
return connection, client, nil
}
func (c *CommandClient) Connect() error {
c.clientMutex.Lock()
common.Close(common.PtrOrNil(c.grpcConn))
target, contextDialer := dialTarget()
connection, client, err := c.dialWithRetry(target, contextDialer, true)
connection, client, err := c.establishConnection()
if err != nil {
c.clientMutex.Unlock()
return err
@@ -219,9 +252,9 @@ func (c *CommandClient) ConnectWithFD(fd int32) error {
c.clientMutex.Unlock()
return err
}
connection, client, err := c.dialWithRetry("passthrough:///xpc", func(ctx context.Context, _ string) (net.Conn, error) {
connection, client, err := c.dialWithRetry("passthrough:///xpc", localDialOptions(func(ctx context.Context, _ string) (net.Conn, error) {
return networkConnection, nil
}, false)
}), false)
if err != nil {
networkConnection.Close()
c.clientMutex.Unlock()
@@ -283,8 +316,7 @@ func (c *CommandClient) getClientForCall() (daemon.StartedServiceClient, context
return c.grpcClient, c.ctx, nil
}
target, contextDialer := dialTarget()
connection, client, err := c.dialWithRetry(target, contextDialer, true)
connection, client, err := c.establishConnection()
if err != nil {
return nil, nil, E.Cause(err, "get command client")
}
@@ -0,0 +1,101 @@
package libbox
import (
"context"
"crypto/tls"
"net"
"net/url"
"strings"
"time"
E "github.com/sagernet/sing/common/exceptions"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
type RemoteConnectionOptions struct {
URL string
Secret string
}
const commandClientRemoteProbeTimeout = 10 * time.Second
type remoteConnection struct {
target string
dialOptions []grpc.DialOption
}
func newRemoteConnection(options *RemoteConnectionOptions) (*remoteConnection, error) {
if options == nil {
return nil, E.New("missing remote connection options")
}
urlString := options.URL
if !strings.Contains(urlString, "://") {
urlString = "http://" + urlString
}
serverURL, err := url.Parse(urlString)
if err != nil {
return nil, E.Cause(err, "parse server URL")
}
host := serverURL.Hostname()
if host == "" {
return nil, E.New("missing host in server URL: ", options.URL)
}
var (
transportCredentials credentials.TransportCredentials
defaultPort string
)
switch serverURL.Scheme {
case "http":
transportCredentials = insecure.NewCredentials()
defaultPort = "80"
case "https":
transportCredentials = credentials.NewTLS(&tls.Config{ServerName: host})
defaultPort = "443"
default:
return nil, E.New("unsupported server URL scheme: ", serverURL.Scheme, ", expected http or https")
}
port := serverURL.Port()
if port == "" {
port = defaultPort
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
}
if options.Secret != "" {
authorization := "Bearer " + options.Secret
dialOptions = append(dialOptions,
grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), method, req, reply, cc, opts...)
}),
grpc.WithStreamInterceptor(func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), desc, cc, method, opts...)
}),
)
}
return &remoteConnection{
target: net.JoinHostPort(host, port),
dialOptions: dialOptions,
}, nil
}
func NewRemoteCommandClient(handler CommandClientHandler, options *CommandClientOptions, remoteOptions *RemoteConnectionOptions) (*CommandClient, error) {
remote, err := newRemoteConnection(remoteOptions)
if err != nil {
return nil, err
}
client := NewCommandClient(handler, options)
client.remote = remote
return client, nil
}
func NewStandaloneRemoteCommandClient(remoteOptions *RemoteConnectionOptions) (*CommandClient, error) {
remote, err := newRemoteConnection(remoteOptions)
if err != nil {
return nil, err
}
return &CommandClient{remote: remote}, nil
}