Compare commits

...

10 commits

Author SHA1 Message Date
Niko Marmeladkov
a3404e463f
feat: add universal user_manager service for external auth & traffic tracking
Some checks are pending
Build / Calculate version (push) Waiting to run
Build / Build binary (push) Blocked by required conditions
Build / Build Darwin binaries (push) Blocked by required conditions
Build / Build Windows binaries (push) Blocked by required conditions
Build / Build Android (push) Blocked by required conditions
Build / Publish Android (push) Blocked by required conditions
Build / Build Apple clients (push) Blocked by required conditions
Build / Upload builds (push) Blocked by required conditions
- New UserManager service with HTTPS auth, credential sync, traffic tracking, and kick API
- Integrates into all 10 protocol inbounds (hysteria2, tuic, vless, vmess, trojan,
  shadowsocks, http, socks, mixed, naive)
- Each inbound auto-detects user_manager from service context, falls back to static config
- Auth server contract: POST /api/auth, GET /api/credentials, POST /api/traffic
2026-06-18 15:49:49 +03:00
世界
17852ccaaa
Bump version 2026-06-17 19:05:27 +08:00
世界
03572bc845
documentation: Add USB/IP server and client 2026-06-17 18:53:38 +08:00
世界
a9fe3f386e
Add USB/IP support for macOS 2026-06-17 14:56:29 +08:00
世界
1933c19064
Add USB/IP service 2026-06-16 18:15:54 +08:00
世界
5de7c678e1
Fix remote control when Clash server is unavailable 2026-06-15 20:07:59 +08:00
世界
82476702ea
Add dashboard support for API service 2026-06-13 23:00:53 +08:00
世界
326615d1a3
documentation: Fix release message 2026-06-13 17:58:45 +08:00
世界
fc25cedc25
Bump version 2026-06-13 15:50:09 +08:00
世界
c688b61c3b
Improve remote rule-set update 2026-06-13 15:50:09 +08:00
78 changed files with 6103 additions and 419 deletions

View file

@ -46,7 +46,6 @@ type ConnectionRouterEx interface {
type RuleSet interface {
Name() string
StartContext(ctx context.Context, startContext *HTTPStartContext) error
PostStart() error
Metadata() RuleSetMetadata
ExtractIPSet() []*netipx.IPSet
IncRef()

15
adapter/usbip.go Normal file
View file

@ -0,0 +1,15 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package adapter
import (
"context"
"github.com/sagernet/sing-usbip"
)
type USBIPDynamicServer interface {
AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error)
RemoveDevice(busID string)
SubscribeDevices(ctx context.Context, listener func([]usbip.ControlDeviceInfo))
}

7
adapter/usbip_stub.go Normal file
View file

@ -0,0 +1,7 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package adapter
type USBIPDynamicServer interface {
usbipNotIncluded()
}

23
adapter/user_manager.go Normal file
View file

@ -0,0 +1,23 @@
package adapter
import "context"
type UserTraffic struct {
Tx int64 `json:"tx"`
Rx int64 `json:"rx"`
}
type UserInfo struct {
Username string
Credential string
}
type UserManager interface {
Service
Authenticate(ctx context.Context, protocol string, credential string, addr string) (username string, err error)
GetCredentials(protocol string) ([]UserInfo, error)
ReportTraffic(username string, tx int64, rx int64)
GetTraffic(username string) (tx int64, rx int64)
ListTraffic() map[string]UserTraffic
KickUser(username string)
}

View file

@ -63,7 +63,7 @@ func init() {
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "badlinkname", "tfogo_checklinkname0")
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
// memcTags = append(memcTags, "with_tailscale")
sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird")

View file

@ -33,9 +33,12 @@ const (
TypeCCM = "ccm"
TypeOCM = "ocm"
TypeOOMKiller = "oom-killer"
TypeUSBIPServer = "usbip-server"
TypeUSBIPClient = "usbip-client"
TypeHysteriaRealm = "hysteria-realm"
TypeACME = "acme"
TypeCloudflareOriginCA = "cloudflare-origin-ca"
TypeUserManager = "user_manager"
)
const (

View file

@ -5,6 +5,9 @@ import (
"time"
"unsafe"
"github.com/sagernet/sing-box/service/oomkiller"
"github.com/sagernet/sing/common/memory"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
@ -13,19 +16,22 @@ import (
var _ ManagedServiceServer = (*ManagedService)(nil)
type ManagedService struct {
handler ManagedHandler
debug bool
handler ManagedHandler
debug bool
oomReporter oomkiller.OOMReporter
}
type ManagedServiceOptions struct {
Handler ManagedHandler
Debug bool
Handler ManagedHandler
Debug bool
OOMReporter oomkiller.OOMReporter
}
func NewManagedService(options ManagedServiceOptions) *ManagedService {
return &ManagedService{
handler: options.Handler,
debug: options.Debug,
handler: options.Handler,
debug: options.Debug,
oomReporter: options.OOMReporter,
}
}
@ -80,5 +86,12 @@ func (s *ManagedService) TriggerDebugCrash(ctx context.Context, request *DebugCr
return &emptypb.Empty{}, nil
}
func (s *ManagedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
if s.oomReporter == nil {
return nil, status.Error(codes.Unavailable, "OOM reporter not available")
}
return &emptypb.Empty{}, s.oomReporter.WriteReport(memory.Total())
}
func (s *ManagedService) mustEmbedUnimplementedManagedServiceServer() {
}

View file

@ -218,13 +218,14 @@ const file_daemon_managed_service_proto_rawDesc = "" +
"\x04Type\x12\x06\n" +
"\x02GO\x10\x00\x12\n" +
"\n" +
"\x06NATIVE\x10\x012\x80\x03\n" +
"\x06NATIVE\x10\x012\xc6\x03\n" +
"\x0eManagedService\x12=\n" +
"\vStopService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12?\n" +
"\rReloadService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12K\n" +
"\x14GetSystemProxyStatus\x12\x16.google.protobuf.Empty\x1a\x19.daemon.SystemProxyStatus\"\x00\x12W\n" +
"\x15SetSystemProxyEnabled\x12$.daemon.SetSystemProxyEnabledRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n" +
"\x11TriggerDebugCrash\x12\x19.daemon.DebugCrashRequest\x1a\x16.google.protobuf.Empty\"\x00B%Z#github.com/sagernet/sing-box/daemonb\x06proto3"
"\x11TriggerDebugCrash\x12\x19.daemon.DebugCrashRequest\x1a\x16.google.protobuf.Empty\"\x00\x12D\n" +
"\x10TriggerOOMReport\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00B%Z#github.com/sagernet/sing-box/daemonb\x06proto3"
var (
file_daemon_managed_service_proto_rawDescOnce sync.Once
@ -257,13 +258,15 @@ var file_daemon_managed_service_proto_depIdxs = []int32{
4, // 3: daemon.ManagedService.GetSystemProxyStatus:input_type -> google.protobuf.Empty
2, // 4: daemon.ManagedService.SetSystemProxyEnabled:input_type -> daemon.SetSystemProxyEnabledRequest
3, // 5: daemon.ManagedService.TriggerDebugCrash:input_type -> daemon.DebugCrashRequest
4, // 6: daemon.ManagedService.StopService:output_type -> google.protobuf.Empty
4, // 7: daemon.ManagedService.ReloadService:output_type -> google.protobuf.Empty
1, // 8: daemon.ManagedService.GetSystemProxyStatus:output_type -> daemon.SystemProxyStatus
4, // 9: daemon.ManagedService.SetSystemProxyEnabled:output_type -> google.protobuf.Empty
4, // 10: daemon.ManagedService.TriggerDebugCrash:output_type -> google.protobuf.Empty
6, // [6:11] is the sub-list for method output_type
1, // [1:6] is the sub-list for method input_type
4, // 6: daemon.ManagedService.TriggerOOMReport:input_type -> google.protobuf.Empty
4, // 7: daemon.ManagedService.StopService:output_type -> google.protobuf.Empty
4, // 8: daemon.ManagedService.ReloadService:output_type -> google.protobuf.Empty
1, // 9: daemon.ManagedService.GetSystemProxyStatus:output_type -> daemon.SystemProxyStatus
4, // 10: daemon.ManagedService.SetSystemProxyEnabled:output_type -> google.protobuf.Empty
4, // 11: daemon.ManagedService.TriggerDebugCrash:output_type -> google.protobuf.Empty
4, // 12: daemon.ManagedService.TriggerOOMReport:output_type -> google.protobuf.Empty
7, // [7:13] is the sub-list for method output_type
1, // [1:7] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name

View file

@ -12,6 +12,7 @@ service ManagedService {
rpc GetSystemProxyStatus(google.protobuf.Empty) returns(SystemProxyStatus) {}
rpc SetSystemProxyEnabled(SetSystemProxyEnabledRequest) returns(google.protobuf.Empty) {}
rpc TriggerDebugCrash(DebugCrashRequest) returns(google.protobuf.Empty) {}
rpc TriggerOOMReport(google.protobuf.Empty) returns(google.protobuf.Empty) {}
}
message SystemProxyStatus {

View file

@ -20,6 +20,7 @@ const (
ManagedService_GetSystemProxyStatus_FullMethodName = "/daemon.ManagedService/GetSystemProxyStatus"
ManagedService_SetSystemProxyEnabled_FullMethodName = "/daemon.ManagedService/SetSystemProxyEnabled"
ManagedService_TriggerDebugCrash_FullMethodName = "/daemon.ManagedService/TriggerDebugCrash"
ManagedService_TriggerOOMReport_FullMethodName = "/daemon.ManagedService/TriggerOOMReport"
)
// ManagedServiceClient is the client API for ManagedService service.
@ -31,6 +32,7 @@ type ManagedServiceClient interface {
GetSystemProxyStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SystemProxyStatus, error)
SetSystemProxyEnabled(ctx context.Context, in *SetSystemProxyEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
TriggerDebugCrash(ctx context.Context, in *DebugCrashRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
type managedServiceClient struct {
@ -91,6 +93,16 @@ func (c *managedServiceClient) TriggerDebugCrash(ctx context.Context, in *DebugC
return out, nil
}
func (c *managedServiceClient) TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, ManagedService_TriggerOOMReport_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ManagedServiceServer is the server API for ManagedService service.
// All implementations must embed UnimplementedManagedServiceServer
// for forward compatibility.
@ -100,6 +112,7 @@ type ManagedServiceServer interface {
GetSystemProxyStatus(context.Context, *emptypb.Empty) (*SystemProxyStatus, error)
SetSystemProxyEnabled(context.Context, *SetSystemProxyEnabledRequest) (*emptypb.Empty, error)
TriggerDebugCrash(context.Context, *DebugCrashRequest) (*emptypb.Empty, error)
TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error)
mustEmbedUnimplementedManagedServiceServer()
}
@ -129,6 +142,10 @@ func (UnimplementedManagedServiceServer) SetSystemProxyEnabled(context.Context,
func (UnimplementedManagedServiceServer) TriggerDebugCrash(context.Context, *DebugCrashRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method TriggerDebugCrash not implemented")
}
func (UnimplementedManagedServiceServer) TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method TriggerOOMReport not implemented")
}
func (UnimplementedManagedServiceServer) mustEmbedUnimplementedManagedServiceServer() {}
func (UnimplementedManagedServiceServer) testEmbeddedByValue() {}
@ -240,6 +257,24 @@ func _ManagedService_TriggerDebugCrash_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler)
}
func _ManagedService_TriggerOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ManagedServiceServer).TriggerOOMReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ManagedService_TriggerOOMReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ManagedServiceServer).TriggerOOMReport(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
// ManagedService_ServiceDesc is the grpc.ServiceDesc for ManagedService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -267,6 +302,10 @@ var ManagedService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TriggerDebugCrash",
Handler: _ManagedService_TriggerDebugCrash_Handler,
},
{
MethodName: "TriggerOOMReport",
Handler: _ManagedService_TriggerOOMReport_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "daemon/managed_service.proto",

View file

@ -17,7 +17,6 @@ import (
"github.com/sagernet/sing-box/experimental/deprecated"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/protocol/group"
"github.com/sagernet/sing-box/service/oomkiller"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/batch"
"github.com/sagernet/sing/common/memory"
@ -32,7 +31,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
)
const APIVersion = 1
const APIVersion = 2
var _ StartedServiceServer = (*StartedService)(nil)
@ -510,7 +509,7 @@ func (s *StartedService) GetClashModeStatus(ctx context.Context, empty *emptypb.
clashServer := s.instance.clashServer
s.serviceAccess.RUnlock()
if clashServer == nil {
return nil, status.Error(codes.Unimplemented, "clash mode not available")
return nil, status.Error(codes.NotFound, "clash mode not available")
}
return &ClashModeStatus{
ModeList: clashServer.ModeList(),
@ -537,7 +536,7 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se
clashServer := s.instance.clashServer
if clashServer == nil {
s.serviceAccess.RUnlock()
return status.Error(codes.Unimplemented, "clash mode not available")
return status.Error(codes.NotFound, "clash mode not available")
}
message := &ClashMode{Mode: clashServer.Mode()}
s.serviceAccess.RUnlock()
@ -566,7 +565,7 @@ func (s *StartedService) SetClashMode(ctx context.Context, request *ClashMode) (
clashServer := s.instance.clashServer
s.serviceAccess.RUnlock()
if clashServer == nil {
return nil, status.Error(codes.Unimplemented, "clash mode not available")
return nil, status.Error(codes.NotFound, "clash mode not available")
}
clashServer.SetMode(request.Mode)
return &emptypb.Empty{}, nil
@ -669,18 +668,6 @@ func (s *StartedService) SetGroupExpand(ctx context.Context, request *SetGroupEx
return &emptypb.Empty{}, nil
}
func (s *StartedService) TriggerOOMReport(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
instance := s.Instance()
if instance == nil {
return nil, status.Error(codes.FailedPrecondition, "service not started")
}
reporter := service.FromContext[oomkiller.OOMReporter](instance.ctx)
if reporter == nil {
return nil, status.Error(codes.Unavailable, "OOM reporter not available")
}
return &emptypb.Empty{}, reporter.WriteReport(memory.Total())
}
func (s *StartedService) SubscribeConnections(request *SubscribeConnectionsRequest, server grpc.ServerStreamingServer[ConnectionEvents]) error {
err := s.waitForStarted(server.Context())
if err != nil {

File diff suppressed because it is too large Load diff

View file

@ -22,8 +22,6 @@ service StartedService {
rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {}
rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {}
rpc TriggerOOMReport(google.protobuf.Empty) returns(google.protobuf.Empty) {}
rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {}
rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {}
rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {}
@ -38,6 +36,8 @@ service StartedService {
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {}
rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {}
}
message Version {
@ -389,3 +389,130 @@ message TailscaleSSHExit {
message TailscaleSSHError {
string message = 1;
}
message USBProviderMessage {
oneof message {
USBDeviceAttach attach = 1;
USBDeviceDetach detach = 2;
USBURBResponse urbResponse = 3;
}
}
message USBServerMessage {
oneof message {
USBDeviceReady ready = 1;
USBURBRequest urbRequest = 2;
USBEndpointAbort abort = 3;
USBError error = 4;
}
}
message USBDeviceDescriptor {
string deviceId = 1;
uint32 busNum = 2;
uint32 devNum = 3;
uint32 speed = 4;
uint32 vendorId = 5;
uint32 productId = 6;
uint32 bcdDevice = 7;
uint32 deviceClass = 8;
uint32 deviceSubClass = 9;
uint32 deviceProtocol = 10;
uint32 configurationValue = 11;
uint32 numConfigurations = 12;
repeated USBInterface interfaces = 13;
string serial = 14;
string product = 15;
}
message USBDeviceAttach {
string serverTag = 1;
USBDeviceDescriptor descriptor = 2;
}
message USBInterface {
uint32 interfaceClass = 1;
uint32 interfaceSubClass = 2;
uint32 interfaceProtocol = 3;
}
message USBDeviceDetach {
string deviceId = 1;
}
message USBDeviceReady {
string deviceId = 1;
string busId = 2;
}
message USBURBRequest {
string deviceId = 1;
uint64 seq = 2;
uint32 endpoint = 3;
bool directionIn = 4;
uint32 transferFlags = 5;
bytes setup = 6;
uint32 transferBufferLength = 7;
bytes outData = 8;
int32 numberOfPackets = 9;
int32 startFrame = 10;
int32 interval = 11;
repeated USBIsoPacket isoPackets = 12;
}
message USBURBResponse {
string deviceId = 1;
uint64 seq = 2;
int32 status = 3;
int32 actualLength = 4;
bytes inData = 5;
repeated USBIsoPacket isoPackets = 6;
}
message USBIsoPacket {
int32 offset = 1;
int32 length = 2;
int32 actualLength = 3;
int32 status = 4;
}
message USBEndpointAbort {
string deviceId = 1;
uint32 endpoint = 2;
}
message USBError {
string deviceId = 1;
string message = 2;
}
message USBIPServerStatusUpdate {
repeated USBIPServerStatus servers = 1;
}
message USBIPServerStatus {
string serverTag = 1;
repeated USBSharedDevice devices = 2;
}
message USBSharedDevice {
USBDeviceDescriptor descriptor = 1;
string busId = 2;
string stableId = 3;
USBBackend backend = 4;
USBDeviceState state = 5;
}
enum USBDeviceState {
USB_DEVICE_STATE_IDLE = 0;
USB_DEVICE_STATE_ATTACHED = 1;
USB_DEVICE_STATE_UNAVAILABLE = 2;
}
enum USBBackend {
USB_BACKEND_UNSPECIFIED = 0;
USB_BACKEND_LINUX_SYSFS = 1;
USB_BACKEND_DYNAMIC = 2;
USB_BACKEND_DARWIN_IOKIT = 3;
USB_BACKEND_WINDOWS_VBOXUSB = 4;
}

View file

@ -15,33 +15,34 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion"
StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus"
StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog"
StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel"
StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs"
StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus"
StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups"
StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus"
StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode"
StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode"
StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest"
StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound"
StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand"
StartedService_TriggerOOMReport_FullMethodName = "/daemon.StartedService/TriggerOOMReport"
StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections"
StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection"
StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections"
StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings"
StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt"
StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds"
StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest"
StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest"
StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus"
StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing"
StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode"
StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout"
StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession"
StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion"
StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus"
StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog"
StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel"
StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs"
StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus"
StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups"
StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus"
StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode"
StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode"
StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest"
StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound"
StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand"
StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections"
StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection"
StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections"
StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings"
StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt"
StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds"
StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest"
StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest"
StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus"
StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing"
StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode"
StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout"
StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession"
StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices"
StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus"
)
// StartedServiceClient is the client API for StartedService service.
@ -61,7 +62,6 @@ type StartedServiceClient interface {
URLTest(ctx context.Context, in *URLTestRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
SelectOutbound(ctx context.Context, in *SelectOutboundRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
SetGroupExpand(ctx context.Context, in *SetGroupExpandRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error)
SubscribeConnections(ctx context.Context, in *SubscribeConnectionsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConnectionEvents], error)
CloseConnection(ctx context.Context, in *CloseConnectionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
CloseAllConnections(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error)
@ -75,6 +75,8 @@ type StartedServiceClient interface {
SetTailscaleExitNode(ctx context.Context, in *SetTailscaleExitNodeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
TailscaleLogout(ctx context.Context, in *TailscaleLogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
StartTailscaleSSHSession(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage], error)
ProvideUSBDevices(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage], error)
SubscribeUSBIPServerStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[USBIPServerStatusUpdate], error)
}
type startedServiceClient struct {
@ -260,16 +262,6 @@ func (c *startedServiceClient) SetGroupExpand(ctx context.Context, in *SetGroupE
return out, nil
}
func (c *startedServiceClient) TriggerOOMReport(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, StartedService_TriggerOOMReport_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *startedServiceClient) SubscribeConnections(ctx context.Context, in *SubscribeConnectionsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ConnectionEvents], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[5], StartedService_SubscribeConnections_FullMethodName, cOpts...)
@ -457,6 +449,38 @@ func (c *startedServiceClient) StartTailscaleSSHSession(ctx context.Context, opt
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_StartTailscaleSSHSessionClient = grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage]
func (c *startedServiceClient) ProvideUSBDevices(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[12], StartedService_ProvideUSBDevices_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[USBProviderMessage, USBServerMessage]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_ProvideUSBDevicesClient = grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage]
func (c *startedServiceClient) SubscribeUSBIPServerStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[USBIPServerStatusUpdate], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[13], StartedService_SubscribeUSBIPServerStatus_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[emptypb.Empty, USBIPServerStatusUpdate]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_SubscribeUSBIPServerStatusClient = grpc.ServerStreamingClient[USBIPServerStatusUpdate]
// StartedServiceServer is the server API for StartedService service.
// All implementations must embed UnimplementedStartedServiceServer
// for forward compatibility.
@ -474,7 +498,6 @@ type StartedServiceServer interface {
URLTest(context.Context, *URLTestRequest) (*emptypb.Empty, error)
SelectOutbound(context.Context, *SelectOutboundRequest) (*emptypb.Empty, error)
SetGroupExpand(context.Context, *SetGroupExpandRequest) (*emptypb.Empty, error)
TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error)
SubscribeConnections(*SubscribeConnectionsRequest, grpc.ServerStreamingServer[ConnectionEvents]) error
CloseConnection(context.Context, *CloseConnectionRequest) (*emptypb.Empty, error)
CloseAllConnections(context.Context, *emptypb.Empty) (*emptypb.Empty, error)
@ -488,6 +511,8 @@ type StartedServiceServer interface {
SetTailscaleExitNode(context.Context, *SetTailscaleExitNodeRequest) (*emptypb.Empty, error)
TailscaleLogout(context.Context, *TailscaleLogoutRequest) (*emptypb.Empty, error)
StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error
ProvideUSBDevices(grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error
SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error
mustEmbedUnimplementedStartedServiceServer()
}
@ -550,10 +575,6 @@ func (UnimplementedStartedServiceServer) SetGroupExpand(context.Context, *SetGro
return nil, status.Error(codes.Unimplemented, "method SetGroupExpand not implemented")
}
func (UnimplementedStartedServiceServer) TriggerOOMReport(context.Context, *emptypb.Empty) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method TriggerOOMReport not implemented")
}
func (UnimplementedStartedServiceServer) SubscribeConnections(*SubscribeConnectionsRequest, grpc.ServerStreamingServer[ConnectionEvents]) error {
return status.Error(codes.Unimplemented, "method SubscribeConnections not implemented")
}
@ -605,6 +626,14 @@ func (UnimplementedStartedServiceServer) TailscaleLogout(context.Context, *Tails
func (UnimplementedStartedServiceServer) StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error {
return status.Error(codes.Unimplemented, "method StartTailscaleSSHSession not implemented")
}
func (UnimplementedStartedServiceServer) ProvideUSBDevices(grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error {
return status.Error(codes.Unimplemented, "method ProvideUSBDevices not implemented")
}
func (UnimplementedStartedServiceServer) SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error {
return status.Error(codes.Unimplemented, "method SubscribeUSBIPServerStatus not implemented")
}
func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {}
func (UnimplementedStartedServiceServer) testEmbeddedByValue() {}
@ -825,24 +854,6 @@ func _StartedService_SetGroupExpand_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _StartedService_TriggerOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StartedServiceServer).TriggerOOMReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: StartedService_TriggerOOMReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StartedServiceServer).TriggerOOMReport(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _StartedService_SubscribeConnections_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SubscribeConnectionsRequest)
if err := stream.RecvMsg(m); err != nil {
@ -1024,6 +1035,24 @@ func _StartedService_StartTailscaleSSHSession_Handler(srv interface{}, stream gr
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_StartTailscaleSSHSessionServer = grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]
func _StartedService_ProvideUSBDevices_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(StartedServiceServer).ProvideUSBDevices(&grpc.GenericServerStream[USBProviderMessage, USBServerMessage]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_ProvideUSBDevicesServer = grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]
func _StartedService_SubscribeUSBIPServerStatus_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(emptypb.Empty)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(StartedServiceServer).SubscribeUSBIPServerStatus(m, &grpc.GenericServerStream[emptypb.Empty, USBIPServerStatusUpdate]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type StartedService_SubscribeUSBIPServerStatusServer = grpc.ServerStreamingServer[USBIPServerStatusUpdate]
// StartedService_ServiceDesc is the grpc.ServiceDesc for StartedService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1063,10 +1092,6 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetGroupExpand",
Handler: _StartedService_SetGroupExpand_Handler,
},
{
MethodName: "TriggerOOMReport",
Handler: _StartedService_TriggerOOMReport_Handler,
},
{
MethodName: "CloseConnection",
Handler: _StartedService_CloseConnection_Handler,
@ -1154,6 +1179,17 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "ProvideUSBDevices",
Handler: _StartedService_ProvideUSBDevices_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "SubscribeUSBIPServerStatus",
Handler: _StartedService_SubscribeUSBIPServerStatus_Handler,
ServerStreams: true,
},
},
Metadata: "daemon/started_service.proto",
}

View file

@ -0,0 +1,470 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package daemon
import (
"context"
"io"
"sync"
"sync/atomic"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-usbip"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/service"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)
func (s *StartedService) ProvideUSBDevices(server grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error {
ctx := server.Context()
err := s.waitForStarted(ctx)
if err != nil {
return err
}
s.serviceAccess.RLock()
instance := s.instance
s.serviceAccess.RUnlock()
if instance == nil {
return E.New("service not started")
}
serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx)
if serviceManager == nil {
return E.New("missing service manager")
}
sessionCtx, cancel := context.WithCancel(ctx)
defer cancel()
var sendAccess sync.Mutex
send := func(message *USBServerMessage) error {
sendAccess.Lock()
defer sendAccess.Unlock()
return server.Send(message)
}
var devicesAccess sync.Mutex
devices := make(map[string]*usbProvidedDevice)
defer func() {
devicesAccess.Lock()
for _, device := range devices {
device.close()
}
devicesAccess.Unlock()
}()
for {
message, recvErr := server.Recv()
if recvErr != nil {
if recvErr == io.EOF {
return nil
}
return recvErr
}
switch body := message.GetMessage().(type) {
case *USBProviderMessage_Attach:
attach := body.Attach
deviceID := attach.GetDescriptor_().GetDeviceId()
device, addErr := addUSBDevice(sessionCtx, serviceManager, send, attach)
if addErr != nil {
_ = send(&USBServerMessage{Message: &USBServerMessage_Error{Error: &USBError{
DeviceId: deviceID,
Message: addErr.Error(),
}}})
continue
}
devicesAccess.Lock()
previous, replaced := devices[deviceID]
devices[deviceID] = device
devicesAccess.Unlock()
if replaced {
previous.close()
}
_ = send(&USBServerMessage{Message: &USBServerMessage_Ready{Ready: &USBDeviceReady{
DeviceId: deviceID,
BusId: device.busID,
}}})
case *USBProviderMessage_Detach:
deviceID := body.Detach.GetDeviceId()
devicesAccess.Lock()
device, found := devices[deviceID]
if found {
delete(devices, deviceID)
}
devicesAccess.Unlock()
if found {
device.close()
}
case *USBProviderMessage_UrbResponse:
response := body.UrbResponse
devicesAccess.Lock()
device, found := devices[response.GetDeviceId()]
devicesAccess.Unlock()
if found {
device.deliver(response)
}
}
}
}
func (s *StartedService) SubscribeUSBIPServerStatus(
_ *emptypb.Empty,
server grpc.ServerStreamingServer[USBIPServerStatusUpdate],
) error {
err := s.waitForStarted(server.Context())
if err != nil {
return err
}
s.serviceAccess.RLock()
instance := s.instance
s.serviceAccess.RUnlock()
if instance == nil {
return E.New("service not started")
}
serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx)
if serviceManager == nil {
return status.Error(codes.FailedPrecondition, "service manager not available")
}
type usbipServer struct {
tag string
provider adapter.USBIPDynamicServer
}
var servers []usbipServer
for _, serverService := range serviceManager.Services() {
provider, isDynamic := serverService.(adapter.USBIPDynamicServer)
if !isDynamic {
continue
}
servers = append(servers, usbipServer{tag: serverService.Tag(), provider: provider})
}
if len(servers) == 0 {
return status.Error(codes.NotFound, "no usbip-server found")
}
type taggedStatus struct {
tag string
devices []usbip.ControlDeviceInfo
}
updates := make(chan taggedStatus, len(servers))
ctx, cancel := context.WithCancel(server.Context())
defer cancel()
var waitGroup sync.WaitGroup
for _, srv := range servers {
// sing-usbip invokes the SubscribeDevices listener while holding the
// ledger's broadcast lock, so it must never block.
latest := make(chan []usbip.ControlDeviceInfo, 1)
waitGroup.Add(1)
go func(provider adapter.USBIPDynamicServer) {
defer waitGroup.Done()
provider.SubscribeDevices(ctx, func(devices []usbip.ControlDeviceInfo) {
sendLatestUSBSnapshot(latest, devices)
})
}(srv.provider)
waitGroup.Add(1)
go func(tag string) {
defer waitGroup.Done()
for {
select {
case <-ctx.Done():
return
case devices := <-latest:
select {
case updates <- taggedStatus{tag: tag, devices: devices}:
case <-ctx.Done():
return
}
}
}
}(srv.tag)
}
go func() {
waitGroup.Wait()
close(updates)
}()
var tags []string
deviceStates := make(map[string][]usbip.ControlDeviceInfo, len(servers))
for update := range updates {
if _, exists := deviceStates[update.tag]; !exists {
tags = append(tags, update.tag)
}
deviceStates[update.tag] = update.devices
protoServers := make([]*USBIPServerStatus, 0, len(deviceStates))
for _, tag := range tags {
protoServers = append(protoServers, &USBIPServerStatus{
ServerTag: tag,
Devices: usbSharedDevicesToProto(deviceStates[tag]),
})
}
sendErr := server.Send(&USBIPServerStatusUpdate{Servers: protoServers})
if sendErr != nil {
return sendErr
}
}
return nil
}
func sendLatestUSBSnapshot(slot chan []usbip.ControlDeviceInfo, devices []usbip.ControlDeviceInfo) {
select {
case slot <- devices:
return
default:
}
select {
case <-slot:
default:
}
select {
case slot <- devices:
default:
}
}
func usbSharedDevicesToProto(devices []usbip.ControlDeviceInfo) []*USBSharedDevice {
if len(devices) == 0 {
return nil
}
out := make([]*USBSharedDevice, 0, len(devices))
for _, device := range devices {
interfaces := make([]*USBInterface, 0, len(device.Interfaces))
for _, deviceInterface := range device.Interfaces {
interfaces = append(interfaces, &USBInterface{
InterfaceClass: uint32(deviceInterface.Class),
InterfaceSubClass: uint32(deviceInterface.SubClass),
InterfaceProtocol: uint32(deviceInterface.Protocol),
})
}
out = append(out, &USBSharedDevice{
Descriptor_: &USBDeviceDescriptor{
DeviceId: device.BusID,
BusNum: device.BusNum,
DevNum: device.DevNum,
Speed: device.Speed,
VendorId: uint32(device.VendorID),
ProductId: uint32(device.ProductID),
BcdDevice: uint32(device.BCDDevice),
DeviceClass: uint32(device.DeviceClass),
DeviceSubClass: uint32(device.DeviceSubClass),
DeviceProtocol: uint32(device.DeviceProtocol),
ConfigurationValue: uint32(device.ConfigurationValue),
NumConfigurations: uint32(device.NumConfigurations),
Interfaces: interfaces,
Serial: device.Serial,
Product: device.Product,
},
BusId: device.BusID,
StableId: device.StableID,
Backend: USBBackend(device.Backend),
State: USBDeviceState(device.State),
})
}
return out
}
func addUSBDevice(ctx context.Context, serviceManager adapter.ServiceManager, send func(*USBServerMessage) error, attach *USBDeviceAttach) (*usbProvidedDevice, error) {
serverService, found := serviceManager.Get(attach.GetServerTag())
if !found {
return nil, E.New("usbip-server not found: ", attach.GetServerTag())
}
provider, isDynamic := serverService.(adapter.USBIPDynamicServer)
if !isDynamic {
return nil, E.New("service ", attach.GetServerTag(), " is not a dynamic usbip-server")
}
descriptor := attach.GetDescriptor_()
if descriptor == nil {
return nil, E.New("missing device descriptor")
}
device := &usbProvidedDevice{
deviceID: descriptor.GetDeviceId(),
provider: provider,
send: send,
ctx: ctx,
pending: make(map[uint64]chan *USBURBResponse),
}
entry := usbDeviceEntryFromDescriptor(descriptor)
busID, err := provider.AddDevice(usbip.ProvidedDeviceInfo{Entry: entry}, device)
if err != nil {
return nil, err
}
device.busID = busID
return device, nil
}
func usbDeviceEntryFromDescriptor(descriptor *USBDeviceDescriptor) usbip.DeviceEntry {
deviceID := descriptor.GetDeviceId()
interfaces := usbInterfacesFromProto(descriptor.GetInterfaces())
info := usbip.DeviceInfoTruncated{
BusNum: descriptor.GetBusNum(),
DevNum: descriptor.GetDevNum(),
Speed: descriptor.GetSpeed(),
IDVendor: uint16(descriptor.GetVendorId()),
IDProduct: uint16(descriptor.GetProductId()),
BCDDevice: uint16(descriptor.GetBcdDevice()),
BDeviceClass: uint8(descriptor.GetDeviceClass()),
BDeviceSubClass: uint8(descriptor.GetDeviceSubClass()),
BDeviceProtocol: uint8(descriptor.GetDeviceProtocol()),
BConfigurationValue: uint8(descriptor.GetConfigurationValue()),
BNumConfigurations: uint8(descriptor.GetNumConfigurations()),
BNumInterfaces: uint8(len(interfaces)),
}
copy(info.BusID[:], deviceID)
return usbip.DeviceEntry{
Info: info,
Interfaces: interfaces,
Serial: descriptor.GetSerial(),
Product: descriptor.GetProduct(),
}
}
// sing-usbip calls Submit concurrently across endpoints for a single device.
type usbProvidedDevice struct {
deviceID string
busID string
provider adapter.USBIPDynamicServer
send func(*USBServerMessage) error
ctx context.Context
seq atomic.Uint64
access sync.Mutex
pending map[uint64]chan *USBURBResponse
closed bool
}
func (d *usbProvidedDevice) Submit(request usbip.URBRequest) usbip.URBResponse {
seq := d.seq.Add(1)
responseChan := make(chan *USBURBResponse, 1)
d.access.Lock()
if d.closed {
d.access.Unlock()
return usbip.URBResponse{Error: E.New("device detached")}
}
d.pending[seq] = responseChan
d.access.Unlock()
defer func() {
d.access.Lock()
delete(d.pending, seq)
d.access.Unlock()
}()
directionIn := request.Endpoint&0x80 != 0
message := &USBURBRequest{
DeviceId: d.deviceID,
Seq: seq,
Endpoint: uint32(request.Endpoint),
DirectionIn: directionIn,
TransferFlags: uint32(request.Command.TransferFlags),
Setup: append([]byte(nil), request.Command.Setup[:]...),
TransferBufferLength: uint32(request.Command.TransferBufferLength),
NumberOfPackets: request.Command.NumberOfPackets,
StartFrame: request.Command.StartFrame,
Interval: request.Command.Interval,
IsoPackets: isoPacketsToProto(request.IsoPackets),
}
if !directionIn {
message.OutData = request.Buffer
}
sendErr := d.send(&USBServerMessage{Message: &USBServerMessage_UrbRequest{UrbRequest: message}})
if sendErr != nil {
return usbip.URBResponse{Error: sendErr}
}
select {
case <-d.ctx.Done():
return usbip.URBResponse{Error: d.ctx.Err()}
case response := <-responseChan:
result := usbip.URBResponse{
Status: response.GetStatus(),
ActualLength: response.GetActualLength(),
IsoPackets: isoPacketsFromProto(response.GetIsoPackets()),
}
if directionIn {
result.Buffer = response.GetInData()
}
return result
}
}
func (d *usbProvidedDevice) AbortEndpoint(endpoint uint8) error {
return d.send(&USBServerMessage{Message: &USBServerMessage_Abort{Abort: &USBEndpointAbort{
DeviceId: d.deviceID,
Endpoint: uint32(endpoint),
}}})
}
func (d *usbProvidedDevice) deliver(response *USBURBResponse) {
d.access.Lock()
responseChan, found := d.pending[response.GetSeq()]
d.access.Unlock()
if !found {
return
}
select {
case responseChan <- response:
default:
}
}
func (d *usbProvidedDevice) close() {
d.access.Lock()
if d.closed {
d.access.Unlock()
return
}
d.closed = true
d.access.Unlock()
if d.busID != "" {
d.provider.RemoveDevice(d.busID)
}
}
func usbInterfacesFromProto(interfaces []*USBInterface) []usbip.DeviceInterface {
if len(interfaces) == 0 {
return nil
}
deviceInterfaces := make([]usbip.DeviceInterface, 0, len(interfaces))
for _, deviceInterface := range interfaces {
deviceInterfaces = append(deviceInterfaces, usbip.DeviceInterface{
BInterfaceClass: uint8(deviceInterface.GetInterfaceClass()),
BInterfaceSubClass: uint8(deviceInterface.GetInterfaceSubClass()),
BInterfaceProtocol: uint8(deviceInterface.GetInterfaceProtocol()),
})
}
return deviceInterfaces
}
func isoPacketsToProto(packets []usbip.IsoPacketDescriptor) []*USBIsoPacket {
if len(packets) == 0 {
return nil
}
out := make([]*USBIsoPacket, 0, len(packets))
for _, packet := range packets {
out = append(out, &USBIsoPacket{
Offset: packet.Offset,
Length: packet.Length,
ActualLength: packet.ActualLength,
Status: packet.Status,
})
}
return out
}
func isoPacketsFromProto(packets []*USBIsoPacket) []usbip.IsoPacketDescriptor {
if len(packets) == 0 {
return nil
}
out := make([]usbip.IsoPacketDescriptor, 0, len(packets))
for _, packet := range packets {
out = append(out, usbip.IsoPacketDescriptor{
Offset: packet.GetOffset(),
Length: packet.GetLength(),
ActualLength: packet.GetActualLength(),
Status: packet.GetStatus(),
})
}
return out
}

View file

@ -0,0 +1,18 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package daemon
import (
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
)
func (s *StartedService) ProvideUSBDevices(server grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error {
return status.Error(codes.Unimplemented, "USB/IP is not included in this build, rebuild with -tags with_usbip")
}
func (s *StartedService) SubscribeUSBIPServerStatus(_ *emptypb.Empty, server grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error {
return status.Error(codes.NotFound, "USB/IP is not included in this build, rebuild with -tags with_usbip")
}

View file

@ -2,6 +2,36 @@
icon: material/alert-decagram
---
#### 1.14.0-alpha.32
* Add dashboard support for the API service **1**
* Add USB/IP service **2**
* Fixes and improvements
**1**:
The [sing-box API service](/configuration/service/api/) can now download, update
and serve [sing-box-dashboard](https://github.com/SagerNet/sing-box-dashboard)
directly over its listener, configured via the new
[`dashboard`](/configuration/service/api/#dashboard) option.
**2**:
New [USB/IP Server](/configuration/service/usbip-server/) and
[USB/IP Client](/configuration/service/usbip-client/) services export and import
USB devices over the [USB/IP](https://usbip.sourceforge.net/) protocol, built on
[sing-usbip](https://github.com/SagerNet/sing-usbip), which adds hotplug while
staying interoperable with standard USB/IP. Exporting config-selected local
devices (`provider: default`) runs via the CLI on Linux, Windows, and macOS and
requires elevated privileges (macOS additionally needs a CGO build and disabled
System Integrity Protection). With `provider: dynamic`, devices are instead
supplied at runtime through the API service by the graphical clients on macOS and
Android, or the [sing-box Dashboard](https://github.com/SagerNet/sing-box-dashboard).
#### 1.14.0-alpha.31
* Fixes and improvements
#### 1.14.0-alpha.30
* Introducing sing-box API service **1**
@ -16,16 +46,7 @@ server for observing and controlling the running sing-box instance,
exposing the same interface the graphical clients use locally: service
status, logs, outbound groups (selection and URL tests), Clash mode,
connection tracking, and tools such as network quality tests, STUN
tests, and Tailscale operations. The server also accepts
[gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md)
requests, including the WebSocket transport of
[@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web)
for bidirectional streaming methods, so browsers can connect directly.
Clients authenticate via the
[`secret`](/configuration/service/api/#secret) field; TLS and CORS
options are available. Connection tracking and Clash mode methods
require the [Clash API](/configuration/experimental/clash-api/) to be
configured.
tests, and Tailscale operations.
**2**:

View file

@ -256,7 +256,9 @@ Fragment TLS handshake into multiple TLS records to bypass firewalls.
!!! question "Since sing-box 1.14.0"
==Linux/macOS/Windows only, requires elevated privileges==
!!! quote ""
Only supported on Linux, macOS, and Windows, and requires elevated privileges.
Inject a forged TLS ClientHello carrying this SNI before the real one,
to fool SNI-filtering middleboxes that permit specific hostnames.

View file

@ -248,7 +248,9 @@ UDP 连接超时时间。
!!! question "自 sing-box 1.14.0 起"
==仅 Linux/macOS/Windows需要管理员权限==
!!! quote ""
仅支持 Linux、macOS 和 Windows需要提升的权限。
在真实 ClientHello 之前注入携带本字段所指定 SNI 的伪造 TLS ClientHello
用于欺骗仅放行特定主机名的 SNI 过滤中间盒。

View file

@ -119,7 +119,16 @@ HTTP Client for downloading rule-set.
See [HTTP Client Fields](/configuration/shared/http-client/) for details.
Default transport will be used if empty.
When empty, the default HTTP client is used: the one named by
[`default_http_client`](/configuration/route/#default_http_client), or the first top-level
`http_clients` entry when `default_http_client` is empty.
!!! failure "Implicit default deprecated in sing-box 1.14.0"
When neither `http_clients` nor `default_http_client` is configured, an implicit HTTP
client connecting through the default outbound is used. This implicit default is
deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0; define
`http_clients` instead.
#### update_interval

View file

@ -119,7 +119,13 @@
参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/) 了解详情。
如果为空,将使用默认传输。
留空时使用默认 HTTP 客户端:即由 [`default_http_client`](/zh/configuration/route/#default_http_client)
指定的客户端,或当 `default_http_client` 为空时使用顶级 `http_clients` 的第一项。
!!! failure "隐式默认已在 sing-box 1.14.0 废弃"
`http_clients``default_http_client` 均未配置时,将使用通过默认出站连接的隐式 HTTP 客户端。
该隐式默认已在 sing-box 1.14.0 废弃,并将在 sing-box 1.16.0 移除;请改为定义 `http_clients`
#### update_interval

View file

@ -8,6 +8,10 @@ icon: material/new-box
The sing-box API service is a gRPC server for observing and controlling the running sing-box instance.
It can be accessed by the [sing-box graphical clients](/clients/) for iOS, macOS, and
Android (via the Remote Control feature), or the
[sing-box dashboard](https://github.com/SagerNet/sing-box-dashboard).
The server also accepts [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) requests,
including the WebSocket transport of [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web)
for bidirectional streaming methods.
@ -23,6 +27,13 @@ for bidirectional streaming methods.
"secret": "",
"access_control_allow_origin": [],
"access_control_allow_private_network": false,
"dashboard": {
"enabled": true,
"path": "",
"download_url": "",
"http_client": "", // or {}
"update_interval": ""
},
"tls": {}
}
```
@ -49,9 +60,59 @@ CORS allowed origins, `*` will be used if empty.
Allow access from private network.
#### dashboard
Web dashboard downloaded and served over the API listener at `/dashboard/`; other browser
requests are redirected to it.
!!! info ""
The object can be replaced with a boolean value (equivalent to `{ "enabled": <bool> }`),
or with a string path (equivalent to `{ "enabled": true, "path": "<string>" }`).
##### enabled
Enable the dashboard.
##### path
Directory the dashboard files are stored in.
`dashboard` in the working directory will be used by default.
If the directory is empty, the dashboard is downloaded and an `.etag` file is stored inside
it to skip unchanged updates. A non-empty directory without an `.etag` file is served as-is
and never updated automatically.
##### download_url
Download URL of the dashboard archive (zip).
`https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip` will be used by default.
##### http_client
HTTP client used to download the dashboard, with the same behavior as remote rule-sets.
See [HTTP Client Fields](/configuration/shared/http-client/) for details.
When empty, the default HTTP client is used: the one named by
[`default_http_client`](/configuration/route/#default_http_client), or the first top-level
`http_clients` entry when `default_http_client` is empty.
!!! failure "Implicit default deprecated in sing-box 1.14.0"
When neither `http_clients` nor `default_http_client` is configured, an implicit HTTP
client connecting through the default outbound is used. This implicit default is
deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0; define
`http_clients` instead.
##### update_interval
Update interval of the dashboard.
`1d` will be used by default.
#### tls
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
Connection tracking and Clash mode methods require [Clash API](/configuration/experimental/clash-api/)
to be configured, otherwise they fail with `UNIMPLEMENTED`.

View file

@ -8,6 +8,8 @@ icon: material/new-box
sing-box API 服务是用于观察与控制正在运行的 sing-box 实例的 gRPC 服务器。
它可以由 iOS、macOS 和 Android 上的 [sing-box 图形客户端](/zh/clients/)(通过 Remote Control 功能)或 [sing-box dashboard](https://github.com/SagerNet/sing-box-dashboard) 访问。
服务器同时接受 [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) 请求,
包括用于双向流方法的 [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web) WebSocket 传输。
@ -22,6 +24,13 @@ sing-box API 服务是用于观察与控制正在运行的 sing-box 实例的 gR
"secret": "",
"access_control_allow_origin": [],
"access_control_allow_private_network": false,
"dashboard": {
"enabled": true,
"path": "",
"download_url": "",
"http_client": "", // 或 {}
"update_interval": ""
},
"tls": {}
}
```
@ -38,7 +47,7 @@ API 密钥。
客户端通过标准的 `authorization: Bearer <secret>` gRPC metadata 头认证。
留空则禁用认证。
默认无需认证。
#### access_control_allow_origin
@ -48,9 +57,54 @@ API 密钥。
允许从私有网络访问。
#### dashboard
下载并通过 API 监听器在 `/dashboard/` 提供的 Web 仪表板;其他浏览器请求将被重定向到该路径。
!!! info ""
该对象可以替换为布尔值(等同于 `{ "enabled": <bool> }`
或字符串路径(等同于 `{ "enabled": true, "path": "<string>" }`)。
##### enabled
启用仪表板。
##### path
存放仪表板文件的目录。
默认使用工作目录下的 `dashboard`
如果目录为空,将下载仪表板,并在其中存放 `.etag` 文件以跳过未变更的更新。
非空且不含 `.etag` 文件的目录将按原样提供,且不会自动更新。
##### download_url
仪表板压缩包zip的下载 URL。
默认使用 `https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip`
##### http_client
用于下载仪表板的 HTTP 客户端,行为与远程规则集相同。
参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/)。
留空时使用默认 HTTP 客户端:即由 [`default_http_client`](/zh/configuration/route/#default_http_client)
指定的客户端,或当 `default_http_client` 为空时使用顶级 `http_clients` 的第一项。
!!! failure "隐式默认已在 sing-box 1.14.0 废弃"
`http_clients``default_http_client` 均未配置时,将使用通过默认出站连接的隐式 HTTP 客户端。
该隐式默认已在 sing-box 1.14.0 废弃,并将在 sing-box 1.16.0 移除;请改为定义 `http_clients`
##### update_interval
仪表板的更新间隔。
默认使用 `1d`
#### tls
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
连接跟踪与 Clash 模式方法需要配置 [Clash API](/zh/configuration/experimental/clash-api/),
否则将以 `UNIMPLEMENTED` 失败。

View file

@ -30,6 +30,8 @@ icon: material/new-box
| `ocm` | [OCM](./ocm) |
| `resolved` | [Resolved](./resolved) |
| `ssm-api` | [SSM API](./ssm-api) |
| `usbip-server` | [USB/IP Server](./usbip-server) |
| `usbip-client` | [USB/IP Client](./usbip-client) |
#### tag

View file

@ -30,6 +30,8 @@ icon: material/new-box
| `ocm` | [OCM](./ocm) |
| `resolved` | [Resolved](./resolved) |
| `ssm-api` | [SSM API](./ssm-api) |
| `usbip-server` | [USB/IP Server](./usbip-server) |
| `usbip-client` | [USB/IP Client](./usbip-client) |
#### tag

View file

@ -0,0 +1,78 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.14.0"
# USB/IP Client
USB/IP Client service imports remote USB devices over [USB/IP](https://usbip.sourceforge.net/),
exported by the [USB/IP Server](/configuration/service/usbip-server/).
Available on Linux, Windows, and macOS (macOS requires a build with CGO). Not available on iOS.
The server must be a sing-box (or sing-usbip) server.
### Structure
```json
{
"type": "usbip-client",
... // Dial Fields
"server": "",
"server_port": 0,
"devices": []
}
```
!!! info "Difference from the official USB/IP protocol"
sing-box uses [sing-usbip](https://github.com/SagerNet/sing-usbip), which uses an additional
set of protocols to support enhancements such as hotplug, while remaining interoperable with
the standard USB/IP protocol.
### Dial Fields
See [Dial Fields](/configuration/shared/dial/) for details.
Only `detour` takes effect.
### Fields
#### server
==Required==
The remote `usbip-server` address.
#### server_port
The remote `usbip-server` port. Defaults to `3240`.
#### devices
List of device matches selecting which remote devices to import. If empty, all exported devices
are imported.
Object format:
```json
{
"bus_id": "",
"vendor_id": 0,
"product_id": 0,
"serial": ""
}
```
Object fields:
- `bus_id`: USB bus ID, e.g. `1-2`.
- `vendor_id`: USB vendor ID, as a number.
- `product_id`: USB product ID, as a number.
- `serial`: Device serial number.
Within one object, all specified fields must match; multiple objects are combined as a union. At
least one field is required.

View file

@ -0,0 +1,73 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.14.0 起"
# USB/IP Client
USB/IP Client 服务通过 [USB/IP](https://usbip.sourceforge.net/) 导入由 [USB/IP Server](/zh/configuration/service/usbip-server/) 导出的远程 USB 设备。
可用于 Linux、Windows 和 macOSmacOS 需要使用 CGO 构建)。不支持 iOS。
服务端必须是 sing-box或 sing-usbip服务端。
### 结构
```json
{
"type": "usbip-client",
... // 拨号字段
"server": "",
"server_port": 0,
"devices": []
}
```
!!! info "与官方 USB/IP 协议的区别"
sing-box 使用 [sing-usbip](https://github.com/SagerNet/sing-usbip),它使用一套附加协议来支持热插拔等增强功能,但仍然可以与标准 USB/IP 互操作。
### 拨号字段
参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。
`detour` 生效。
### 字段
#### server
==必填==
远程 `usbip-server` 地址。
#### server_port
远程 `usbip-server` 端口。默认为 `3240`
#### devices
设备匹配列表,用于选择要导入的远程设备。如果为空,则导入所有已导出的设备。
对象格式:
```json
{
"bus_id": "",
"vendor_id": 0,
"product_id": 0,
"serial": ""
}
```
对象字段:
- `bus_id`USB 总线 ID例如 `1-2`
- `vendor_id`USB 供应商 ID为数字。
- `product_id`USB 产品 ID为数字。
- `serial`:设备序列号。
在一个对象内,所有指定的字段都必须匹配;多个对象之间取并集。至少需要一个字段。

View file

@ -0,0 +1,83 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.14.0"
# USB/IP Server
USB/IP Server service exports local USB devices over [USB/IP](https://usbip.sourceforge.net/),
to be imported by the [USB/IP Client](/configuration/service/usbip-client/) or a standard USB/IP
client.
Available on Linux, Windows, and macOS (macOS requires a build with CGO, and exporting devices
requires disabling System Integrity Protection). Not available on iOS.
### Structure
```json
{
"type": "usbip-server",
... // Listen Fields
"provider": "",
"devices": []
}
```
!!! info "Difference from the official USB/IP protocol"
sing-box uses [sing-usbip](https://github.com/SagerNet/sing-usbip), which uses an additional
set of protocols to support enhancements such as hotplug, while remaining interoperable with
the standard USB/IP protocol.
### Listen Fields
See [Listen Fields](/configuration/shared/listen/) for details.
`listen_port` defaults to `3240`.
### Fields
#### provider
The device source provider.
- `default`: Exports the local devices matched by `devices`. The default value.
- `dynamic`: Devices are provided at runtime through a [sing-box API](/configuration/service/api/)
client instead of from configuration, on supported platforms: the sing-box graphical clients on
[macOS](/clients/apple/) and [Android](/clients/android/), and Chromium-based browsers with
[sing-box Dashboard](https://github.com/SagerNet/sing-box-dashboard).
!!! quote ""
The `default` provider is only supported when running directly via the CLI on Linux, Windows,
and macOS, and requires elevated privileges.
#### devices
==Required== with the `default` provider.
List of device matches selecting which local USB devices to export.
Object format:
```json
{
"bus_id": "",
"vendor_id": 0,
"product_id": 0,
"serial": ""
}
```
Object fields:
- `bus_id`: USB bus ID, e.g. `1-2`.
- `vendor_id`: USB vendor ID, as a number.
- `product_id`: USB product ID, as a number.
- `serial`: Device serial number.
Within one object, all specified fields must match; multiple objects are combined as a union. At
least one field is required.

View file

@ -0,0 +1,73 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.14.0 起"
# USB/IP Server
USB/IP Server 服务通过 [USB/IP](https://usbip.sourceforge.net/) 导出本地 USB 设备,供 [USB/IP Client](/zh/configuration/service/usbip-client/) 或标准 USB/IP 客户端导入。
可用于 Linux、Windows 和 macOSmacOS 需要使用 CGO 构建,且导出设备需要禁用系统完整性保护)。不支持 iOS。
### 结构
```json
{
"type": "usbip-server",
... // 监听字段
"provider": "",
"devices": []
}
```
!!! info "与官方 USB/IP 协议的区别"
sing-box 使用 [sing-usbip](https://github.com/SagerNet/sing-usbip),它使用一套附加协议来支持热插拔等增强功能,但仍然可以与标准 USB/IP 互操作。
### 监听字段
参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。
`listen_port` 默认为 `3240`
### 字段
#### provider
设备来源提供者。
- `default`:导出由 `devices` 匹配的本地设备。默认值。
- `dynamic`:设备在运行时通过 [sing-box API](/zh/configuration/service/api/) 客户端提供,而非来自配置文件,支持的平台包括 [macOS](/zh/clients/apple/) 和 [Android](/zh/clients/android/) 上的 sing-box 图形客户端,以及配合 [sing-box Dashboard](https://github.com/SagerNet/sing-box-dashboard) 的基于 Chromium 的浏览器。
!!! quote ""
`default` 提供者仅支持通过 CLI 直接运行在 Linux、Windows 和 macOS 上,并且需要提升的权限。
#### devices
使用 `default` 提供者时 ==必填==。
设备匹配列表,用于选择要导出的本地 USB 设备。
对象格式:
```json
{
"bus_id": "",
"vendor_id": 0,
"product_id": 0,
"serial": ""
}
```
对象字段:
- `bus_id`USB 总线 ID例如 `1-2`
- `vendor_id`USB 供应商 ID为数字。
- `product_id`USB 产品 ID为数字。
- `serial`:设备序列号。
在一个对象内,所有指定的字段都必须匹配;多个对象之间取并集。至少需要一个字段。

View file

@ -678,7 +678,11 @@ Fragment TLS handshake into multiple TLS records to bypass firewalls.
!!! question "Since sing-box 1.14.0"
==Client only, Linux/macOS/Windows only, requires elevated privileges==
==Client only==
!!! quote ""
Only supported on Linux, macOS, and Windows, and requires elevated privileges.
Inject a forged TLS ClientHello carrying a whitelisted SNI before the real one,
to fool SNI-filtering middleboxes that permit specific hostnames.

View file

@ -673,7 +673,11 @@ ECH 配置路径PEM 格式。
!!! question "自 sing-box 1.14.0 起"
==仅客户端,仅 Linux/macOS/Windows需要提权==
==仅客户端==
!!! quote ""
仅支持 Linux、macOS 和 Windows需要提升的权限。
在真实 ClientHello 之前注入一个伪造的、携带白名单 SNI 的 TLS ClientHello
以欺骗基于 SNI 过滤的中间盒放行连接。

View file

@ -456,23 +456,17 @@ func (c *CommandClient) handleClashModeStream() {
modeStatus, err := client.GetClashModeStatus(ctx, &emptypb.Empty{})
if err != nil {
c.handler.Disconnected(E.Cause(err, "get clash mode status").Error())
return
if status.Code(err) != codes.NotFound {
c.handler.Disconnected(E.Cause(err, "get clash mode status").Error())
return
}
modeStatus = &daemon.ClashModeStatus{}
}
if sFixAndroidStack {
go func() {
c.handler.InitializeClashMode(newIterator(modeStatus.ModeList), modeStatus.CurrentMode)
if len(modeStatus.ModeList) == 0 {
c.handler.Disconnected(E.Cause(os.ErrInvalid, "empty clash mode list").Error())
}
}()
go c.handler.InitializeClashMode(newIterator(modeStatus.ModeList), modeStatus.CurrentMode)
} else {
c.handler.InitializeClashMode(newIterator(modeStatus.ModeList), modeStatus.CurrentMode)
if len(modeStatus.ModeList) == 0 {
c.handler.Disconnected(E.Cause(os.ErrInvalid, "empty clash mode list").Error())
return
}
}
if len(modeStatus.ModeList) == 0 {
@ -481,6 +475,9 @@ func (c *CommandClient) handleClashModeStream() {
stream, err := client.SubscribeClashMode(ctx, &emptypb.Empty{})
if err != nil {
if status.Code(err) == codes.NotFound {
return
}
c.handler.Disconnected(E.Cause(err, "subscribe clash mode").Error())
return
}
@ -488,6 +485,9 @@ func (c *CommandClient) handleClashModeStream() {
for {
mode, err := stream.Recv()
if err != nil {
if status.Code(err) == codes.NotFound {
return
}
c.handler.Disconnected(E.Cause(err, "clash mode stream recv").Error())
return
}
@ -673,7 +673,7 @@ func (c *CommandClient) TriggerNativeCrash() error {
}
func (c *CommandClient) TriggerOOMReport() error {
_, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) {
_, err := callManagedWithResult(c, func(ctx context.Context, client daemon.ManagedServiceClient) (*emptypb.Empty, error) {
return client.TriggerOOMReport(ctx, &emptypb.Empty{})
})
if err != nil {
@ -924,6 +924,61 @@ func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler)
return session, nil
}
func (c *CommandClient) SubscribeUSBIPServerStatus(handler USBIPServerStatusHandler) (*USBIPServerStatusSubscription, error) {
client, parentCtx, err := c.getClientForCall()
if err != nil {
return nil, E.Cause(err, "subscribe usbip server status")
}
streamCtx, cancel := context.WithCancel(parentCtx)
session := &USBIPServerStatusSubscription{
streamSession: streamSession{
ctx: streamCtx,
cancel: cancel,
closeDone: make(chan struct{}),
},
}
failStart := func(cause error, message string) (*USBIPServerStatusSubscription, error) {
cancel()
if c.standalone {
c.closeConnection()
}
return nil, E.Cause(cause, message)
}
stream, err := client.SubscribeUSBIPServerStatus(streamCtx, &emptypb.Empty{})
if err != nil {
return failStart(err, "subscribe usbip server status")
}
standalone := c.standalone
go func() {
defer func() {
close(session.closeDone)
if standalone {
c.closeConnection()
}
}()
for {
event, recvErr := stream.Recv()
if recvErr != nil {
if session.ctx.Err() != nil {
return
}
if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable {
return
}
handler.OnError(E.Cause(recvErr, "usbip server status recv").Error())
return
}
handler.OnStatusUpdate(usbipServerStatusUpdateFromGRPC(event))
}
}()
return session, nil
}
func (c *CommandClient) SetTailscaleExitNode(endpointTag string, stableID string) error {
_, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) {
return client.SetTailscaleExitNode(ctx, &daemon.SetTailscaleExitNodeRequest{
@ -1125,3 +1180,59 @@ func (c *CommandClient) StartTailscaleSSHSession(opts *TailscaleSSHOptions, hand
return session, nil
}
func (c *CommandClient) ProvideUSBDevices(handler USBProviderHandler) (*USBProviderSession, error) {
client, parentCtx, err := c.getClientForCall()
if err != nil {
return nil, E.Cause(err, "provide usb devices")
}
streamCtx, cancel := context.WithCancel(parentCtx)
stream, err := client.ProvideUSBDevices(streamCtx)
if err != nil {
cancel()
if c.standalone {
c.closeConnection()
}
return nil, E.Cause(err, "provide usb devices")
}
session := &USBProviderSession{
stream: stream,
ctx: streamCtx,
cancel: cancel,
closeDone: make(chan struct{}),
}
standalone := c.standalone
go func() {
defer close(session.closeDone)
for {
message, recvErr := stream.Recv()
if recvErr == io.EOF {
cancel()
break
}
if recvErr != nil {
handler.OnError("", E.Cause(recvErr, "usb provider recv").Error())
cancel()
break
}
switch payload := message.GetMessage().(type) {
case *daemon.USBServerMessage_Ready:
handler.OnReady(payload.Ready.GetDeviceId(), payload.Ready.GetBusId())
case *daemon.USBServerMessage_UrbRequest:
handler.OnURBRequest(usbURBRequestFromGRPC(payload.UrbRequest))
case *daemon.USBServerMessage_Abort:
handler.OnAbort(payload.Abort.GetDeviceId(), int32(payload.Abort.GetEndpoint()))
case *daemon.USBServerMessage_Error:
handler.OnError(payload.Error.GetDeviceId(), payload.Error.GetMessage())
}
}
if standalone {
c.closeConnection()
}
}()
return session, nil
}

View file

@ -75,8 +75,9 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn
// SystemProxyEnabled: false,
})
server.managedService = daemon.NewManagedService(daemon.ManagedServiceOptions{
Handler: (*platformHandler)(server),
Debug: sDebug,
Handler: (*platformHandler)(server),
Debug: sDebug,
OOMReporter: sOOMReporter,
})
return server, nil
}

View file

@ -0,0 +1,203 @@
package libbox
import (
"context"
"os"
"sync"
"github.com/sagernet/sing-box/daemon"
)
type USBProviderHandler interface {
OnReady(deviceID string, busID string)
OnURBRequest(request *USBURBRequest)
OnAbort(deviceID string, endpoint int32)
OnError(deviceID string, message string)
}
type USBIsoPacket struct {
Offset int32
Length int32
ActualLength int32
Status int32
}
type USBDeviceDescriptor struct {
ServerTag string
DeviceID string
BusNum int32
DevNum int32
Speed int32
VendorID int32
ProductID int32
BCDDevice int32
DeviceClass int32
DeviceSubClass int32
DeviceProtocol int32
ConfigurationValue int32
NumConfigurations int32
Serial string
Product string
interfaces []*daemon.USBInterface
}
func NewUSBDeviceDescriptor(serverTag string, deviceID string) *USBDeviceDescriptor {
return &USBDeviceDescriptor{ServerTag: serverTag, DeviceID: deviceID}
}
func (d *USBDeviceDescriptor) AddInterface(interfaceClass int32, interfaceSubClass int32, interfaceProtocol int32) {
d.interfaces = append(d.interfaces, &daemon.USBInterface{
InterfaceClass: uint32(interfaceClass),
InterfaceSubClass: uint32(interfaceSubClass),
InterfaceProtocol: uint32(interfaceProtocol),
})
}
func (d *USBDeviceDescriptor) toProto() *daemon.USBDeviceAttach {
return &daemon.USBDeviceAttach{
ServerTag: d.ServerTag,
Descriptor_: &daemon.USBDeviceDescriptor{
DeviceId: d.DeviceID,
BusNum: uint32(d.BusNum),
DevNum: uint32(d.DevNum),
Speed: uint32(d.Speed),
VendorId: uint32(d.VendorID),
ProductId: uint32(d.ProductID),
BcdDevice: uint32(d.BCDDevice),
DeviceClass: uint32(d.DeviceClass),
DeviceSubClass: uint32(d.DeviceSubClass),
DeviceProtocol: uint32(d.DeviceProtocol),
ConfigurationValue: uint32(d.ConfigurationValue),
NumConfigurations: uint32(d.NumConfigurations),
Interfaces: d.interfaces,
Serial: d.Serial,
Product: d.Product,
},
}
}
type USBURBRequest struct {
DeviceID string
Seq int64
Endpoint int32
DirectionIn bool
TransferFlags int32
Setup []byte
TransferBufferLength int32
OutData []byte
NumberOfPackets int32
StartFrame int32
Interval int32
isoPackets []*daemon.USBIsoPacket
}
func (r *USBURBRequest) IsoPacketCount() int32 {
return int32(len(r.isoPackets))
}
func (r *USBURBRequest) GetIsoPacket(index int32) *USBIsoPacket {
if index < 0 || int(index) >= len(r.isoPackets) {
return nil
}
packet := r.isoPackets[index]
return &USBIsoPacket{
Offset: packet.GetOffset(),
Length: packet.GetLength(),
ActualLength: packet.GetActualLength(),
Status: packet.GetStatus(),
}
}
func usbURBRequestFromGRPC(request *daemon.USBURBRequest) *USBURBRequest {
return &USBURBRequest{
DeviceID: request.GetDeviceId(),
Seq: int64(request.GetSeq()),
Endpoint: int32(request.GetEndpoint()),
DirectionIn: request.GetDirectionIn(),
TransferFlags: int32(request.GetTransferFlags()),
Setup: request.GetSetup(),
TransferBufferLength: int32(request.GetTransferBufferLength()),
OutData: request.GetOutData(),
NumberOfPackets: request.GetNumberOfPackets(),
StartFrame: request.GetStartFrame(),
Interval: request.GetInterval(),
isoPackets: request.GetIsoPackets(),
}
}
type USBURBResponse struct {
DeviceID string
Seq int64
Status int32
ActualLength int32
InData []byte
isoPackets []*daemon.USBIsoPacket
}
func NewUSBURBResponse(deviceID string, seq int64) *USBURBResponse {
return &USBURBResponse{DeviceID: deviceID, Seq: seq}
}
func (r *USBURBResponse) AddIsoPacket(offset int32, length int32, actualLength int32, status int32) {
r.isoPackets = append(r.isoPackets, &daemon.USBIsoPacket{
Offset: offset,
Length: length,
ActualLength: actualLength,
Status: status,
})
}
func (r *USBURBResponse) toProto() *daemon.USBURBResponse {
return &daemon.USBURBResponse{
DeviceId: r.DeviceID,
Seq: uint64(r.Seq),
Status: r.Status,
ActualLength: r.ActualLength,
InData: r.InData,
IsoPackets: r.isoPackets,
}
}
type USBProviderSession struct {
stream daemon.StartedService_ProvideUSBDevicesClient
ctx context.Context
cancel context.CancelFunc
sendAccess sync.Mutex
closeOnce sync.Once
closeDone chan struct{}
}
func (s *USBProviderSession) send(message *daemon.USBProviderMessage) error {
s.sendAccess.Lock()
defer s.sendAccess.Unlock()
select {
case <-s.ctx.Done():
return os.ErrClosed
default:
}
return s.stream.Send(message)
}
func (s *USBProviderSession) AttachDevice(descriptor *USBDeviceDescriptor) error {
return s.send(&daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_Attach{Attach: descriptor.toProto()}})
}
func (s *USBProviderSession) DetachDevice(deviceID string) error {
return s.send(&daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_Detach{Detach: &daemon.USBDeviceDetach{DeviceId: deviceID}}})
}
func (s *USBProviderSession) SendURBResponse(response *USBURBResponse) error {
return s.send(&daemon.USBProviderMessage{Message: &daemon.USBProviderMessage_UrbResponse{UrbResponse: response.toProto()}})
}
func (s *USBProviderSession) Close() error {
s.closeOnce.Do(func() {
s.cancel()
_ = s.stream.CloseSend()
})
<-s.closeDone
return nil
}

View file

@ -0,0 +1,46 @@
package libbox
type USBLocalDeviceInfo struct {
StableID string
BusID string
Backend int32
BusNum int32
DevNum int32
Speed int32
VendorID int32
ProductID int32
BCDDevice int32
DeviceClass int32
DeviceSubClass int32
DeviceProtocol int32
ConfigurationValue int32
NumConfigurations int32
Serial string
Product string
interfaces []*USBSharedDeviceInterface
}
func (d *USBLocalDeviceInfo) Interfaces() USBSharedDeviceInterfaceIterator {
return newIterator(d.interfaces)
}
type USBLocalDeviceInfoIterator interface {
Next() *USBLocalDeviceInfo
HasNext() bool
}
type USBLocalProvidedDevice struct {
ServerTag string
DeviceID string
LocalDeviceID string
Label string
VendorID int32
ProductID int32
}
type USBLocalProviderHandler interface {
OnDeviceError(serverTag string, deviceID string, message string)
OnSessionError(serverTag string, message string)
OnLocalDevicesChanged()
}

View file

@ -0,0 +1,538 @@
//go:build with_usbip && darwin && !ios && cgo
package libbox
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-usbip"
E "github.com/sagernet/sing/common/exceptions"
)
func (c *CommandClient) NewUSBLocalProvider(handler USBLocalProviderHandler) (*USBLocalProviderManager, error) {
ctx, cancel := context.WithCancel(context.Background())
manager := &USBLocalProviderManager{
client: c,
handler: handler,
ctx: ctx,
cancel: cancel,
sessions: make(map[string]*usbDarwinLocalProviderSession),
devices: make(map[string]*usbDarwinProvidedDevice),
}
err := usbip.WatchLocalDevices(ctx, func() {
manager.detachMissing()
manager.notifyLocalDevicesChanged()
})
if err != nil {
cancel()
return nil, E.Cause(err, "watch local usb devices")
}
return manager, nil
}
type USBLocalProviderManager struct {
client *CommandClient
handler USBLocalProviderHandler
ctx context.Context
cancel context.CancelFunc
counter atomic.Uint64
access sync.Mutex
closed bool
sessions map[string]*usbDarwinLocalProviderSession
devices map[string]*usbDarwinProvidedDevice
}
type usbDarwinLocalProviderSession struct {
tag string
session *USBProviderSession
}
type usbDarwinProvidedDevice struct {
manager *USBLocalProviderManager
session *usbDarwinLocalProviderSession
local usbip.LocalDevice
info *USBLocalProvidedDevice
queueAccess sync.Mutex
queues map[uint8]chan *USBURBRequest
closed bool
closeOnce sync.Once
closeFinished chan struct{}
}
func (m *USBLocalProviderManager) ListDevices() (USBLocalDeviceInfoIterator, error) {
devices, err := usbip.ListLocalDevices()
if err != nil {
return nil, err
}
out := make([]*USBLocalDeviceInfo, 0, len(devices))
for i := range devices {
out = append(out, usbLocalDeviceInfoFromUSBIP(devices[i]))
}
return newIterator(out), nil
}
func (m *USBLocalProviderManager) Attach(serverTag string, localDeviceID string) (*USBLocalProvidedDevice, error) {
if serverTag == "" {
return nil, E.New("missing usbip-server tag")
}
if localDeviceID == "" {
return nil, E.New("missing local USB device id")
}
session, err := m.ensureSession(serverTag)
if err != nil {
return nil, err
}
localDevice, err := usbip.OpenLocalDevice(localDeviceID, false)
if err != nil {
return nil, err
}
deviceID := fmt.Sprintf("local-%d", m.counter.Add(1))
localInfo := usbLocalDeviceInfoFromUSBIP(usbip.LocalDeviceInfo{
StableID: localDevice.StableID(),
Entry: localDevice.Entry(),
})
descriptor := usbDeviceDescriptorFromLocalInfo(serverTag, deviceID, localInfo)
provided := &USBLocalProvidedDevice{
ServerTag: serverTag,
DeviceID: deviceID,
LocalDeviceID: localDevice.StableID(),
Label: localInfo.Product,
VendorID: localInfo.VendorID,
ProductID: localInfo.ProductID,
}
device := &usbDarwinProvidedDevice{
manager: m,
session: session,
local: localDevice,
info: provided,
queues: make(map[uint8]chan *USBURBRequest),
closeFinished: make(chan struct{}),
}
m.access.Lock()
if m.closed {
m.access.Unlock()
_ = localDevice.Close()
return nil, os.ErrClosed
}
m.devices[deviceID] = device
m.access.Unlock()
err = session.session.AttachDevice(descriptor)
if err != nil {
m.removeDevice(deviceID)
device.close(false)
return nil, err
}
return provided, nil
}
func (m *USBLocalProviderManager) Detach(deviceID string) error {
device := m.removeDevice(deviceID)
if device == nil {
return os.ErrNotExist
}
err := device.detach()
device.close(false)
return err
}
func (m *USBLocalProviderManager) Close() error {
m.access.Lock()
if m.closed {
m.access.Unlock()
return nil
}
m.closed = true
devices := make([]*usbDarwinProvidedDevice, 0, len(m.devices))
for _, device := range m.devices {
devices = append(devices, device)
}
m.devices = make(map[string]*usbDarwinProvidedDevice)
sessions := make([]*usbDarwinLocalProviderSession, 0, len(m.sessions))
for _, session := range m.sessions {
sessions = append(sessions, session)
}
m.sessions = make(map[string]*usbDarwinLocalProviderSession)
m.access.Unlock()
m.cancel()
for _, device := range devices {
device.close(false)
}
var err error
for _, session := range sessions {
err = E.Append(err, session.session.Close(), func(err error) error {
return E.Cause(err, "close usb provider session ", session.tag)
})
}
return err
}
func (m *USBLocalProviderManager) ensureSession(serverTag string) (*usbDarwinLocalProviderSession, error) {
m.access.Lock()
if m.closed {
m.access.Unlock()
return nil, os.ErrClosed
}
existing := m.sessions[serverTag]
m.access.Unlock()
if existing != nil {
return existing, nil
}
session, err := m.client.ProvideUSBDevices(&usbDarwinLocalProviderStreamHandler{
manager: m,
serverTag: serverTag,
})
if err != nil {
return nil, err
}
wrapped := &usbDarwinLocalProviderSession{tag: serverTag, session: session}
m.access.Lock()
defer m.access.Unlock()
if m.closed {
_ = session.Close()
return nil, os.ErrClosed
}
if existing = m.sessions[serverTag]; existing != nil {
_ = session.Close()
return existing, nil
}
m.sessions[serverTag] = wrapped
return wrapped, nil
}
func (m *USBLocalProviderManager) removeDevice(deviceID string) *usbDarwinProvidedDevice {
m.access.Lock()
device := m.devices[deviceID]
if device != nil {
delete(m.devices, deviceID)
}
m.access.Unlock()
return device
}
func (m *USBLocalProviderManager) device(deviceID string) *usbDarwinProvidedDevice {
m.access.Lock()
defer m.access.Unlock()
return m.devices[deviceID]
}
func (m *USBLocalProviderManager) detachMissing() {
devices, err := usbip.ListLocalDevices()
if err != nil {
m.notifySessionError("", E.Cause(err, "list local USB devices").Error())
return
}
present := make(map[string]struct{}, len(devices))
for i := range devices {
present[devices[i].StableID] = struct{}{}
}
var stale []*usbDarwinProvidedDevice
m.access.Lock()
for deviceID, device := range m.devices {
if _, ok := present[device.info.LocalDeviceID]; ok {
continue
}
delete(m.devices, deviceID)
stale = append(stale, device)
}
m.access.Unlock()
for _, device := range stale {
_ = device.detach()
device.close(false)
m.notifyDeviceError(device.info.ServerTag, device.info.DeviceID, "local USB device disconnected")
}
}
func (m *USBLocalProviderManager) onURBRequest(request *USBURBRequest) {
device := m.device(request.DeviceID)
if device == nil {
return
}
device.submit(request)
}
func (m *USBLocalProviderManager) onAbort(deviceID string, endpoint int32) {
device := m.device(deviceID)
if device == nil {
return
}
err := device.local.AbortEndpoint(uint8(endpoint))
if err != nil {
m.notifyDeviceError(device.info.ServerTag, deviceID, err.Error())
}
}
func (m *USBLocalProviderManager) onDeviceError(serverTag string, deviceID string, message string) {
device := m.removeDevice(deviceID)
if device != nil {
device.close(false)
}
m.notifyDeviceError(serverTag, deviceID, message)
}
func (m *USBLocalProviderManager) onSessionError(serverTag string, message string) {
var affected []*usbDarwinProvidedDevice
m.access.Lock()
delete(m.sessions, serverTag)
for deviceID, device := range m.devices {
if device.info.ServerTag != serverTag {
continue
}
delete(m.devices, deviceID)
affected = append(affected, device)
}
m.access.Unlock()
for _, device := range affected {
device.close(false)
}
m.notifySessionError(serverTag, message)
}
func (m *USBLocalProviderManager) notifyLocalDevicesChanged() {
if m.handler != nil {
m.handler.OnLocalDevicesChanged()
}
}
func (m *USBLocalProviderManager) notifyDeviceError(serverTag string, deviceID string, message string) {
if m.handler != nil {
m.handler.OnDeviceError(serverTag, deviceID, message)
}
}
func (m *USBLocalProviderManager) notifySessionError(serverTag string, message string) {
if m.handler != nil {
m.handler.OnSessionError(serverTag, message)
}
}
type usbDarwinLocalProviderStreamHandler struct {
manager *USBLocalProviderManager
serverTag string
}
func (h *usbDarwinLocalProviderStreamHandler) OnReady(deviceID string, busID string) {
}
func (h *usbDarwinLocalProviderStreamHandler) OnURBRequest(request *USBURBRequest) {
h.manager.onURBRequest(request)
}
func (h *usbDarwinLocalProviderStreamHandler) OnAbort(deviceID string, endpoint int32) {
h.manager.onAbort(deviceID, endpoint)
}
func (h *usbDarwinLocalProviderStreamHandler) OnError(deviceID string, message string) {
if deviceID == "" {
h.manager.onSessionError(h.serverTag, message)
return
}
h.manager.onDeviceError(h.serverTag, deviceID, message)
}
func (d *usbDarwinProvidedDevice) submit(request *USBURBRequest) {
endpoint := uint8(request.Endpoint)
d.queueAccess.Lock()
if d.closed {
d.queueAccess.Unlock()
d.sendResponse(usbURBErrorResponse(request))
return
}
queue := d.queues[endpoint]
if queue == nil {
queue = make(chan *USBURBRequest, 64)
d.queues[endpoint] = queue
go d.runQueue(queue)
}
select {
case queue <- request:
d.queueAccess.Unlock()
default:
d.queueAccess.Unlock()
d.sendResponse(usbURBErrorResponse(request))
}
}
func (d *usbDarwinProvidedDevice) runQueue(queue <-chan *USBURBRequest) {
for request := range queue {
result := d.local.Submit(usbIPRequestFromLocalProvider(request))
d.sendResponse(usbURBResponseFromUSBIP(request, result))
}
}
func (d *usbDarwinProvidedDevice) detach() error {
return d.session.session.DetachDevice(d.info.DeviceID)
}
func (d *usbDarwinProvidedDevice) close(detach bool) {
d.closeOnce.Do(func() {
defer close(d.closeFinished)
d.queueAccess.Lock()
d.closed = true
for endpoint, queue := range d.queues {
close(queue)
delete(d.queues, endpoint)
}
d.queueAccess.Unlock()
if detach {
_ = d.detach()
}
_ = d.local.Close()
})
<-d.closeFinished
}
func (d *usbDarwinProvidedDevice) sendResponse(response *USBURBResponse) {
err := d.session.session.SendURBResponse(response)
if err != nil {
d.manager.onSessionError(d.info.ServerTag, E.Cause(err, "send USB URB response").Error())
}
}
func usbLocalDeviceInfoFromUSBIP(info usbip.LocalDeviceInfo) *USBLocalDeviceInfo {
entry := info.Entry
interfaces := make([]*USBSharedDeviceInterface, 0, len(entry.Interfaces))
for _, deviceInterface := range entry.Interfaces {
interfaces = append(interfaces, &USBSharedDeviceInterface{
InterfaceClass: int32(deviceInterface.BInterfaceClass),
InterfaceSubClass: int32(deviceInterface.BInterfaceSubClass),
InterfaceProtocol: int32(deviceInterface.BInterfaceProtocol),
})
}
return &USBLocalDeviceInfo{
StableID: info.StableID,
BusID: entry.Info.BusIDString(),
Backend: int32(info.Backend),
BusNum: int32(entry.Info.BusNum),
DevNum: int32(entry.Info.DevNum),
Speed: int32(entry.Info.Speed),
VendorID: int32(entry.Info.IDVendor),
ProductID: int32(entry.Info.IDProduct),
BCDDevice: int32(entry.Info.BCDDevice),
DeviceClass: int32(entry.Info.BDeviceClass),
DeviceSubClass: int32(entry.Info.BDeviceSubClass),
DeviceProtocol: int32(entry.Info.BDeviceProtocol),
ConfigurationValue: int32(entry.Info.BConfigurationValue),
NumConfigurations: int32(entry.Info.BNumConfigurations),
Serial: entry.Serial,
Product: entry.Product,
interfaces: interfaces,
}
}
func usbDeviceDescriptorFromLocalInfo(serverTag string, deviceID string, info *USBLocalDeviceInfo) *USBDeviceDescriptor {
descriptor := &USBDeviceDescriptor{
ServerTag: serverTag,
DeviceID: deviceID,
BusNum: info.BusNum,
DevNum: info.DevNum,
Speed: info.Speed,
VendorID: info.VendorID,
ProductID: info.ProductID,
BCDDevice: info.BCDDevice,
DeviceClass: info.DeviceClass,
DeviceSubClass: info.DeviceSubClass,
DeviceProtocol: info.DeviceProtocol,
ConfigurationValue: info.ConfigurationValue,
NumConfigurations: info.NumConfigurations,
Serial: info.Serial,
Product: info.Product,
}
for _, deviceInterface := range info.interfaces {
descriptor.interfaces = append(descriptor.interfaces, &daemon.USBInterface{
InterfaceClass: uint32(deviceInterface.InterfaceClass),
InterfaceSubClass: uint32(deviceInterface.InterfaceSubClass),
InterfaceProtocol: uint32(deviceInterface.InterfaceProtocol),
})
}
return descriptor
}
func usbIPRequestFromLocalProvider(request *USBURBRequest) usbip.URBRequest {
endpoint := uint8(request.Endpoint)
direction := usbip.USBIPDirOut
if request.DirectionIn {
direction = usbip.USBIPDirIn
}
var setup [8]byte
copy(setup[:], request.Setup)
buffer := request.OutData
if request.DirectionIn {
buffer = make([]byte, max(0, int(request.TransferBufferLength)))
}
isoPackets := make([]usbip.IsoPacketDescriptor, 0, request.IsoPacketCount())
for i := int32(0); i < request.IsoPacketCount(); i++ {
packet := request.GetIsoPacket(i)
if packet == nil {
continue
}
isoPackets = append(isoPackets, usbip.IsoPacketDescriptor{
Offset: packet.Offset,
Length: packet.Length,
ActualLength: packet.ActualLength,
Status: packet.Status,
})
}
command := usbip.SubmitCommand{
Header: usbip.DataHeader{
Command: usbip.CmdSubmit,
SeqNum: uint32(request.Seq),
Direction: direction,
Endpoint: uint32(endpoint & 0x0f),
},
TransferFlags: request.TransferFlags,
TransferBufferLength: request.TransferBufferLength,
StartFrame: request.StartFrame,
NumberOfPackets: request.NumberOfPackets,
Interval: request.Interval,
Setup: setup,
Buffer: buffer,
IsoPackets: isoPackets,
}
return usbip.URBRequest{
Command: command,
Endpoint: endpoint,
Buffer: buffer,
IsoPackets: isoPackets,
}
}
func usbURBResponseFromUSBIP(request *USBURBRequest, result usbip.URBResponse) *USBURBResponse {
if result.Error != nil {
return usbURBErrorResponse(request)
}
response := NewUSBURBResponse(request.DeviceID, request.Seq)
response.Status = result.Status
response.ActualLength = result.ActualLength
for _, packet := range result.IsoPackets {
response.AddIsoPacket(packet.Offset, packet.Length, packet.ActualLength, packet.Status)
}
if request.DirectionIn && len(result.Buffer) > 0 {
if request.NumberOfPackets > 0 {
response.InData = result.Buffer
} else {
actual := int(result.ActualLength)
if actual < 0 {
actual = 0
}
response.InData = result.Buffer[:min(actual, len(result.Buffer))]
}
}
return response
}
func usbURBErrorResponse(request *USBURBRequest) *USBURBResponse {
response := NewUSBURBResponse(request.DeviceID, request.Seq)
response.Status = -5
return response
}

View file

@ -0,0 +1,27 @@
//go:build !with_usbip || !darwin || ios || !cgo
package libbox
import "os"
type USBLocalProviderManager struct{}
func (c *CommandClient) NewUSBLocalProvider(handler USBLocalProviderHandler) (*USBLocalProviderManager, error) {
return nil, os.ErrInvalid
}
func (m *USBLocalProviderManager) ListDevices() (USBLocalDeviceInfoIterator, error) {
return nil, os.ErrInvalid
}
func (m *USBLocalProviderManager) Attach(serverTag string, localDeviceID string) (*USBLocalProvidedDevice, error) {
return nil, os.ErrInvalid
}
func (m *USBLocalProviderManager) Detach(deviceID string) error {
return os.ErrInvalid
}
func (m *USBLocalProviderManager) Close() error {
return os.ErrInvalid
}

View file

@ -0,0 +1,142 @@
package libbox
import "github.com/sagernet/sing-box/daemon"
type USBIPServerStatusUpdate struct {
servers []*USBIPServerStatus
}
func (u *USBIPServerStatusUpdate) Servers() USBIPServerStatusIterator {
return newIterator(u.servers)
}
type USBIPServerStatusIterator interface {
Next() *USBIPServerStatus
HasNext() bool
}
type USBIPServerStatus struct {
ServerTag string
devices []*USBSharedDevice
}
func (s *USBIPServerStatus) Devices() USBSharedDeviceIterator {
return newIterator(s.devices)
}
type USBSharedDeviceIterator interface {
Next() *USBSharedDevice
HasNext() bool
}
const (
USBDeviceStateIdle int32 = iota
USBDeviceStateAttached
USBDeviceStateUnavailable
)
const (
USBBackendUnspecified int32 = iota
USBBackendLinuxSysfs
USBBackendDynamic
USBBackendDarwinIOKit
USBBackendWindowsVBoxUSB
)
type USBSharedDevice struct {
BusID string
StableID string
Backend int32
State int32
DeviceID string
BusNum int32
DevNum int32
Speed int32
VendorID int32
ProductID int32
BCDDevice int32
DeviceClass int32
DeviceSubClass int32
DeviceProtocol int32
ConfigurationValue int32
NumConfigurations int32
Serial string
Product string
interfaces []*USBSharedDeviceInterface
}
func (d *USBSharedDevice) Interfaces() USBSharedDeviceInterfaceIterator {
return newIterator(d.interfaces)
}
type USBSharedDeviceInterfaceIterator interface {
Next() *USBSharedDeviceInterface
HasNext() bool
}
type USBSharedDeviceInterface struct {
InterfaceClass int32
InterfaceSubClass int32
InterfaceProtocol int32
}
type USBIPServerStatusHandler interface {
OnStatusUpdate(status *USBIPServerStatusUpdate)
OnError(message string)
}
type USBIPServerStatusSubscription struct {
streamSession
}
func usbipServerStatusUpdateFromGRPC(update *daemon.USBIPServerStatusUpdate) *USBIPServerStatusUpdate {
servers := make([]*USBIPServerStatus, len(update.Servers))
for i, server := range update.Servers {
servers[i] = usbipServerStatusFromGRPC(server)
}
return &USBIPServerStatusUpdate{servers: servers}
}
func usbipServerStatusFromGRPC(status *daemon.USBIPServerStatus) *USBIPServerStatus {
devices := make([]*USBSharedDevice, len(status.Devices))
for i, device := range status.Devices {
devices[i] = usbSharedDeviceFromGRPC(device)
}
return &USBIPServerStatus{
ServerTag: status.GetServerTag(),
devices: devices,
}
}
func usbSharedDeviceFromGRPC(device *daemon.USBSharedDevice) *USBSharedDevice {
descriptor := device.GetDescriptor_()
interfaces := make([]*USBSharedDeviceInterface, len(descriptor.GetInterfaces()))
for i, deviceInterface := range descriptor.GetInterfaces() {
interfaces[i] = &USBSharedDeviceInterface{
InterfaceClass: int32(deviceInterface.GetInterfaceClass()),
InterfaceSubClass: int32(deviceInterface.GetInterfaceSubClass()),
InterfaceProtocol: int32(deviceInterface.GetInterfaceProtocol()),
}
}
return &USBSharedDevice{
BusID: device.GetBusId(),
StableID: device.GetStableId(),
Backend: int32(device.GetBackend()),
State: int32(device.GetState()),
DeviceID: descriptor.GetDeviceId(),
BusNum: int32(descriptor.GetBusNum()),
DevNum: int32(descriptor.GetDevNum()),
Speed: int32(descriptor.GetSpeed()),
VendorID: int32(descriptor.GetVendorId()),
ProductID: int32(descriptor.GetProductId()),
BCDDevice: int32(descriptor.GetBcdDevice()),
DeviceClass: int32(descriptor.GetDeviceClass()),
DeviceSubClass: int32(descriptor.GetDeviceSubClass()),
DeviceProtocol: int32(descriptor.GetDeviceProtocol()),
ConfigurationValue: int32(descriptor.GetConfigurationValue()),
NumConfigurations: int32(descriptor.GetNumConfigurations()),
Serial: descriptor.GetSerial(),
Product: descriptor.GetProduct(),
interfaces: interfaces,
}
}

1
go.mod
View file

@ -146,6 +146,7 @@ require (
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260516034431-d86a63399c27 // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb
github.com/spf13/pflag v1.0.9 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect

2
go.sum
View file

@ -268,6 +268,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG
github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
github.com/sagernet/sing-tun v0.8.11-0.20260603045801-6e76db79f94a h1:Wuf1SkZL0rISkm4HwNczFLjl8sWUBFGRGLbgZbO8inQ=
github.com/sagernet/sing-tun v0.8.11-0.20260603045801-6e76db79f94a/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0=
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw=
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=

View file

@ -143,6 +143,8 @@ func ServiceRegistry() *service.Registry {
registerCCMService(registry)
registerOCMService(registry)
registerOOMKillerService(registry)
registerUSBIPServices(registry)
registerUserManagerService(registry)
return registry
}

12
include/usbip.go Normal file
View file

@ -0,0 +1,12 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package include
import (
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/service/usbip"
)
func registerUSBIPServices(registry *service.Registry) {
usbip.RegisterService(registry)
}

23
include/usbip_stub.go Normal file
View file

@ -0,0 +1,23 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package include
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
)
func registerUSBIPServices(registry *service.Registry) {
service.Register[option.USBIPServerServiceOptions](registry, C.TypeUSBIPServer, func(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPServerServiceOptions) (adapter.Service, error) {
return nil, E.New(`USB/IP is not included in this build, rebuild with -tags with_usbip (supported on Linux, Windows, and macOS with CGO)`)
})
service.Register[option.USBIPClientServiceOptions](registry, C.TypeUSBIPClient, func(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPClientServiceOptions) (adapter.Service, error) {
return nil, E.New(`USB/IP is not included in this build, rebuild with -tags with_usbip (supported on Linux, Windows, and macOS with CGO)`)
})
}

10
include/usermanager.go Normal file
View file

@ -0,0 +1,10 @@
package include
import (
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/service/usermanager"
)
func registerUserManagerService(registry *service.Registry) {
usermanager.RegisterService(registry)
}

View file

@ -194,6 +194,8 @@ nav:
- CCM: configuration/service/ccm.md
- OCM: configuration/service/ocm.md
- Hysteria Realm: configuration/service/hysteria-realm.md
- USB/IP Server: configuration/service/usbip-server.md
- USB/IP Client: configuration/service/usbip-client.md
markdown_extensions:
- toc:
slugify: !!python/object/apply:pymdownx.slugs.slugify

View file

@ -1,11 +1,50 @@
package option
import "github.com/sagernet/sing/common/json/badoption"
import (
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/json/badoption"
)
type APIServiceOptions struct {
ListenOptions
Secret string `json:"secret,omitempty"`
AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"`
AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"`
Dashboard *APIDashboardOptions `json:"dashboard,omitempty"`
InboundTLSOptionsContainer
}
type _APIDashboardOptions struct {
Enabled bool `json:"enabled,omitempty"`
Path string `json:"path,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
HTTPClient *HTTPClientOptions `json:"http_client,omitempty"`
UpdateInterval badoption.Duration `json:"update_interval,omitempty"`
}
type APIDashboardOptions _APIDashboardOptions
func (o APIDashboardOptions) MarshalJSON() ([]byte, error) {
if o.DownloadURL == "" && o.HTTPClient == nil && o.UpdateInterval == 0 {
if o.Path == "" {
return json.Marshal(o.Enabled)
}
if o.Enabled {
return json.Marshal(o.Path)
}
}
return json.Marshal(_APIDashboardOptions(o))
}
func (o *APIDashboardOptions) UnmarshalJSON(bytes []byte) error {
err := json.Unmarshal(bytes, &o.Enabled)
if err == nil {
return nil
}
err = json.Unmarshal(bytes, &o.Path)
if err == nil {
o.Enabled = true
return nil
}
return json.UnmarshalDisallowUnknownFields(bytes, (*_APIDashboardOptions)(o))
}

69
option/usbip.go Normal file
View file

@ -0,0 +1,69 @@
package option
import (
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/json/badjson"
)
const (
USBIPProviderDefault = "default"
USBIPProviderDynamic = "dynamic"
)
type _USBIPServerServiceOptions struct {
ListenOptions
Provider string `json:"provider,omitempty"`
Options any `json:"-"`
}
type USBIPServerServiceOptions _USBIPServerServiceOptions
func (o USBIPServerServiceOptions) MarshalJSON() ([]byte, error) {
if o.Options == nil {
return json.Marshal((_USBIPServerServiceOptions)(o))
}
return badjson.MarshallObjects((_USBIPServerServiceOptions)(o), o.Options)
}
func (o *USBIPServerServiceOptions) UnmarshalJSON(content []byte) error {
err := json.Unmarshal(content, (*_USBIPServerServiceOptions)(o))
if err != nil {
return err
}
var options any
switch o.Provider {
case "", USBIPProviderDefault:
o.Provider = USBIPProviderDefault
options = new(USBIPDefaultProviderOptions)
case USBIPProviderDynamic:
options = new(USBIPDynamicProviderOptions)
default:
return E.New("unknown usbip provider type: ", o.Provider)
}
err = badjson.UnmarshallExcluded(content, (*_USBIPServerServiceOptions)(o), options)
if err != nil {
return err
}
o.Options = options
return nil
}
type USBIPClientServiceOptions struct {
DialerOptions
ServerOptions
Devices []USBIPDeviceMatch `json:"devices,omitempty"`
}
type USBIPDeviceMatch struct {
BusID string `json:"bus_id,omitempty"`
VendorID uint16 `json:"vendor_id,omitempty"`
ProductID uint16 `json:"product_id,omitempty"`
Serial string `json:"serial,omitempty"`
}
type USBIPDefaultProviderOptions struct {
Devices []USBIPDeviceMatch `json:"devices,omitempty"`
}
type USBIPDynamicProviderOptions struct{}

16
option/user_manager.go Normal file
View file

@ -0,0 +1,16 @@
package option
import (
"github.com/sagernet/sing/common/json/badoption"
)
type UserManagerOptions struct {
AuthServer string `json:"auth_server,omitempty"`
Timeout badoption.Duration `json:"timeout,omitempty"`
CacheTTL badoption.Duration `json:"cache_ttl,omitempty"`
RefreshInterval badoption.Duration `json:"refresh_interval,omitempty"`
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
ReportTraffic bool `json:"report_traffic,omitempty"`
APISecret string `json:"api_secret,omitempty"`
APIListen string `json:"api_listen,omitempty"`
}

65
patches.md Normal file
View file

@ -0,0 +1,65 @@
# Upstream Changes
## UserManager — External Authentication & Traffic Tracking
A new `user_manager` service that turns sing-box into a universal proxy server for external auth servers.
### New files
- `adapter/user_manager.go``UserManager` interface: `Authenticate`, `GetCredentials`, `ReportTraffic`, `GetTraffic`, `ListTraffic`, `KickUser`
- `option/user_manager.go``UserManagerOptions`: `auth_server`, `cache_ttl`, `refresh_interval`, `report_interval`, `report_traffic`, `api_secret`, `api_listen`, `timeout`
- `service/usermanager/manager.go` — full implementation:
- On-demand HTTPS auth (`POST /api/auth`) with in-memory TTL cache
- Periodic credential sync (`GET /api/credentials`) populates per-protocol user lists
- Per-user traffic counters (`atomic.Int64`) reported via `POST /api/traffic`
- `ConnectionTracker` impl wrapping conns with byte counters (`RoutedConnection`/`RoutedPacketConnection`)
- HTTP API on configurable `api_listen`:
- `GET /traffic` — all users traffic
- `GET /traffic/{user}` — single user
- `POST /kick/{user}` — evict user from auth cache
- Bearer token auth on all API endpoints via `api_secret`
- `service/usermanager/registry.go` — service registration
- `include/usermanager.go` — include build hook
- `constant/proxy.go``TypeUserManager` constant
### Modified files — Protocol inbound integration
All 10 protocol inbounds detect the global `user_manager` from service context and use it when present:
| Protocol | Type | Approach |
|----------|------|----------|
| hysteria2 | Service-based | `service.UpdateUsers` with password credentials, 30s refresh loop |
| tuic | Service-based | `service.UpdateUsers` with UUID+password (format `"uuid:password"`), 30s refresh loop |
| vless | Service-based | `service.UpdateUsers` with UUID credentials, 30s refresh loop |
| vmess | Service-based | `service.UpdateUsers` with UUID credentials, 30s refresh loop |
| trojan | Service-based | `service.UpdateUsers` with password credentials, 30s refresh loop |
| shadowsocks (multi) | Service-based | `service.UpdateUsersWithPasswords` with password credentials, 30s refresh loop |
| http | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop |
| socks | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop |
| mixed | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop |
| naive | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop; skips `"missing users"` check when user_manager is configured |
Each inbound falls back to its original static config when no `user_manager` service is registered.
### Credential format per protocol
| Protocol | `UserInfo.Credential` format |
|------------|------------------------------|
| hysteria2 | password |
| tuic | `uuid:password` |
| vless | uuid |
| vmess | uuid |
| trojan | password |
| shadowsocks| password (method from static config) |
| http | password (username from `UserInfo.Username`) |
| socks | password (username from `UserInfo.Username`) |
| mixed | password (username from `UserInfo.Username`) |
| naive | password (username from `UserInfo.Username`) |
### Auth server API contract
The auth server (`auth_server`) must implement:
- `POST /api/auth` — body `{"protocol":"...", "credential":"...", "addr":"..."}`, returns `{"ok":true, "id":"username"}`
- `GET /api/credentials` — returns `{"credentials":[{"protocol":"...", "credential":"...", "username":"..."}]}`
- `POST /api/traffic` (optional, when `report_traffic=true`) — body `{"traffic":{"user1":{"tx":123,"rx":456}}}`

View file

@ -4,6 +4,7 @@ import (
std_bufio "bufio"
"context"
"net"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@ -18,6 +19,7 @@ import (
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/protocol/http"
"github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -33,14 +35,28 @@ type Inbound struct {
listener *listener.Listener
authenticator *auth.Authenticator
tlsConfig tls.ServerConfig
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) {
authenticator := auth.NewAuthenticator(options.Users)
userManager := service.FromContext[adapter.UserManager](ctx)
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeHTTP)
if err == nil && len(creds) > 0 {
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
authenticator = auth.NewAuthenticator(users)
}
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeHTTP, tag),
router: uot.NewRouter(router, logger),
logger: logger,
authenticator: auth.NewAuthenticator(options.Users),
authenticator: authenticator,
userManager: userManager,
}
if options.TLS != nil {
tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{
@ -76,9 +92,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return E.Cause(err, "create TLS config")
}
}
if h.userManager != nil {
go h.credentialLoop()
}
return h.listener.Start()
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeHTTP)
if err != nil || len(creds) == 0 {
continue
}
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
h.authenticator = auth.NewAuthenticator(users)
}
}
func (h *Inbound) Close() error {
return common.Close(
h.listener,

View file

@ -40,6 +40,7 @@ type Inbound struct {
tlsConfig tls.ServerConfig
service *hysteria2.Service[int]
userNameList []string
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2InboundOptions) (adapter.Inbound, error) {
@ -183,17 +184,39 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
if err != nil {
return nil, err
}
userList := make([]int, 0, len(options.Users))
userNameList := make([]string, 0, len(options.Users))
userPasswordList := make([]string, 0, len(options.Users))
for index, user := range options.Users {
userList = append(userList, index)
userNameList = append(userNameList, user.Name)
userPasswordList = append(userPasswordList, user.Password)
userManager := service.FromContext[adapter.UserManager](ctx)
var userList []int
var userNameList []string
var userPasswordList []string
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeHysteria2)
if err == nil && len(creds) > 0 {
userList = make([]int, len(creds))
userNameList = make([]string, len(creds))
userPasswordList = make([]string, len(creds))
for i, c := range creds {
userList[i] = i
userNameList[i] = c.Username
userPasswordList[i] = c.Credential
}
}
} else {
userList = make([]int, 0, len(options.Users))
userNameList = make([]string, 0, len(options.Users))
userPasswordList = make([]string, 0, len(options.Users))
for index, user := range options.Users {
userList = append(userList, index)
userNameList = append(userNameList, user.Name)
userPasswordList = append(userPasswordList, user.Password)
}
}
hysteriaService.UpdateUsers(userList, userPasswordList)
inbound.service = hysteriaService
inbound.userNameList = userNameList
inbound.userManager = userManager
return inbound, nil
}
@ -251,6 +274,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return err
}
}
if h.userManager != nil {
go h.credentialLoop()
}
packetConn, err := h.listener.ListenUDP()
if err != nil {
return err
@ -258,6 +284,27 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return h.service.Start(packetConn)
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeHysteria2)
if err != nil || len(creds) == 0 {
continue
}
userList := make([]int, len(creds))
userNameList := make([]string, len(creds))
userPasswordList := make([]string, len(creds))
for i, c := range creds {
userList[i] = i
userNameList[i] = c.Username
userPasswordList[i] = c.Credential
}
h.service.UpdateUsers(userList, userPasswordList)
h.userNameList = userNameList
}
}
func (h *Inbound) InterfaceUpdated() {
h.service.Reset()
}

View file

@ -22,6 +22,7 @@ import (
"github.com/sagernet/sing/protocol/socks"
"github.com/sagernet/sing/protocol/socks/socks4"
"github.com/sagernet/sing/protocol/socks/socks5"
"github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -38,6 +39,7 @@ type Inbound struct {
authenticator *auth.Authenticator
tlsConfig tls.ServerConfig
udpTimeout time.Duration
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) {
@ -47,12 +49,25 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
} else {
udpTimeout = C.UDPTimeout
}
authenticator := auth.NewAuthenticator(options.Users)
userManager := service.FromContext[adapter.UserManager](ctx)
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeMixed)
if err == nil && len(creds) > 0 {
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
authenticator = auth.NewAuthenticator(users)
}
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeMixed, tag),
router: uot.NewRouter(router, logger),
logger: logger,
authenticator: auth.NewAuthenticator(options.Users),
authenticator: authenticator,
udpTimeout: udpTimeout,
userManager: userManager,
}
if options.TLS != nil {
tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{
@ -88,9 +103,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return E.Cause(err, "create TLS config")
}
}
if h.userManager != nil {
go h.credentialLoop()
}
return h.listener.Start()
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeMixed)
if err != nil || len(creds) == 0 {
continue
}
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
h.authenticator = auth.NewAuthenticator(users)
}
}
func (h *Inbound) Close() error {
return common.Close(
h.listener,

View file

@ -6,6 +6,7 @@ import (
"io"
"net"
"net/http"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@ -24,6 +25,7 @@ import (
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHttp "github.com/sagernet/sing/protocol/http"
"github.com/sagernet/sing/service"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
@ -51,9 +53,22 @@ type Inbound struct {
tlsConfig tls.ServerConfig
httpServer *http.Server
h3Server io.Closer
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveInboundOptions) (adapter.Inbound, error) {
authenticator := auth.NewAuthenticator(options.Users)
userManager := service.FromContext[adapter.UserManager](ctx)
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeNaive)
if err == nil && len(creds) > 0 {
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
authenticator = auth.NewAuthenticator(users)
}
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeNaive, tag),
ctx: ctx,
@ -66,14 +81,15 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
}),
networkIsDefault: options.Network == "",
network: options.Network.Build(),
authenticator: auth.NewAuthenticator(options.Users),
authenticator: authenticator,
userManager: userManager,
}
if common.Contains(inbound.network, N.NetworkUDP) {
if options.TLS == nil || !options.TLS.Enabled {
return nil, E.New("TLS is required for QUIC server")
}
}
if len(options.Users) == 0 {
if len(options.Users) == 0 && userManager == nil {
return nil, E.New("missing users")
}
if options.TLS != nil {
@ -96,6 +112,9 @@ func (n *Inbound) Start(stage adapter.StartStage) error {
return E.Cause(err, "create TLS config")
}
}
if n.userManager != nil {
go n.credentialLoop()
}
if common.Contains(n.network, N.NetworkTCP) {
tcpListener, err := n.listener.ListenTCP()
if err != nil {
@ -138,6 +157,22 @@ func (n *Inbound) Start(stage adapter.StartStage) error {
return nil
}
func (n *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := n.userManager.GetCredentials(C.TypeNaive)
if err != nil || len(creds) == 0 {
continue
}
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
n.authenticator = auth.NewAuthenticator(users)
}
}
func (n *Inbound) Close() error {
return common.Close(
n.listener,

View file

@ -23,6 +23,7 @@ import (
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -35,7 +36,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
} else if options.Managed && (len(options.Users) > 0 || len(options.Destinations) > 0) {
return nil, E.New("users and destinations options are not supported in managed servers")
}
if len(options.Users) > 0 || options.Managed {
userManager := service.FromContext[adapter.UserManager](ctx)
if len(options.Users) > 0 || options.Managed || userManager != nil {
return newMultiInbound(ctx, router, logger, tag, options)
} else if len(options.Destinations) > 0 {
return newRelayInbound(ctx, router, logger, tag, options)

View file

@ -26,6 +26,7 @@ import (
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
sService "github.com/sagernet/sing/service"
)
var (
@ -35,13 +36,15 @@ var (
type MultiInbound struct {
inbound.Adapter
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
service shadowsocks.MultiService[int]
users []option.ShadowsocksUser
tracker adapter.SSMTracker
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
service shadowsocks.MultiService[int]
users []option.ShadowsocksUser
userNameList []string
userManager adapter.UserManager
tracker adapter.SSMTracker
}
func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) {
@ -83,18 +86,35 @@ func newMultiInbound(ctx context.Context, router adapter.Router, logger log.Cont
if err != nil {
return nil, err
}
if len(options.Users) > 0 {
err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Users, func(index int, user option.ShadowsocksUser) int {
userManager := sService.FromContext[adapter.UserManager](ctx)
users := options.Users
if len(users) == 0 && userManager != nil {
creds, uErr := userManager.GetCredentials(C.TypeShadowsocks)
if uErr == nil && len(creds) > 0 {
users = make([]option.ShadowsocksUser, len(creds))
for i, c := range creds {
users[i] = option.ShadowsocksUser{Name: c.Username, Password: c.Credential}
}
}
}
if len(users) > 0 {
err = service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user option.ShadowsocksUser) int {
return index
}), common.Map(options.Users, func(user option.ShadowsocksUser) string {
}), common.Map(users, func(user option.ShadowsocksUser) string {
return user.Password
}))
if err != nil {
return nil, err
}
}
userNameList := make([]string, len(users))
for i, u := range users {
userNameList[i] = u.Name
}
inbound.service = service
inbound.users = options.Users
inbound.users = users
inbound.userNameList = userNameList
inbound.userManager = userManager
inbound.listener = listener.New(listener.Options{
Context: ctx,
Logger: logger,
@ -111,9 +131,33 @@ func (h *MultiInbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
if h.userManager != nil {
go h.credentialLoop()
}
return h.listener.Start()
}
func (h *MultiInbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeShadowsocks)
if err != nil || len(creds) == 0 {
continue
}
userIndices := make([]int, len(creds))
userPasswords := make([]string, len(creds))
userNameList := make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userPasswords[i] = c.Credential
userNameList[i] = c.Username
}
h.service.UpdateUsersWithPasswords(userIndices, userPasswords)
h.userNameList = userNameList
}
}
func (h *MultiInbound) Close() error {
return h.listener.Close()
}
@ -163,7 +207,7 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
if !loaded {
return os.ErrInvalid
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {
@ -186,7 +230,7 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
if !loaded {
return os.ErrInvalid
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {

View file

@ -18,6 +18,7 @@ import (
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/protocol/socks"
"github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -33,6 +34,7 @@ type Inbound struct {
listener *listener.Listener
authenticator *auth.Authenticator
udpTimeout time.Duration
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SocksInboundOptions) (adapter.Inbound, error) {
@ -42,12 +44,25 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
} else {
udpTimeout = C.UDPTimeout
}
authenticator := auth.NewAuthenticator(options.Users)
userManager := service.FromContext[adapter.UserManager](ctx)
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeSOCKS)
if err == nil && len(creds) > 0 {
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
authenticator = auth.NewAuthenticator(users)
}
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeSOCKS, tag),
router: uot.NewRouter(router, logger),
logger: logger,
authenticator: auth.NewAuthenticator(options.Users),
authenticator: authenticator,
udpTimeout: udpTimeout,
userManager: userManager,
}
inbound.listener = listener.New(listener.Options{
Context: ctx,
@ -63,9 +78,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
if h.userManager != nil {
go h.credentialLoop()
}
return h.listener.Start()
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeSOCKS)
if err != nil || len(creds) == 0 {
continue
}
users := make([]auth.User, len(creds))
for i, c := range creds {
users[i] = auth.User{Username: c.Username, Password: c.Credential}
}
h.authenticator = auth.NewAuthenticator(users)
}
}
func (h *Inbound) Close() error {
return h.listener.Close()
}

View file

@ -4,6 +4,7 @@ import (
"context"
"net"
"os"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@ -21,6 +22,7 @@ import (
F "github.com/sagernet/sing/common/format"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
sService "github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -36,6 +38,8 @@ type Inbound struct {
listener *listener.Listener
service *trojan.Service[int]
users []option.TrojanUser
userNameList []string
userManager adapter.UserManager
tlsConfig tls.ServerConfig
fallbackAddr M.Socksaddr
fallbackAddrTLSNextProto map[string]M.Socksaddr
@ -87,13 +91,40 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
fallbackHandler = adapter.NewUpstreamContextHandler(inbound.fallbackConnection, nil)
}
service := trojan.NewService[int](adapter.NewUpstreamContextHandler(inbound.newConnection, inbound.newPacketConnection), fallbackHandler, logger)
err := service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int {
return index
}), common.Map(options.Users, func(it option.TrojanUser) string {
return it.Password
}))
if err != nil {
return nil, err
var err error
userManager := sService.FromContext[adapter.UserManager](ctx)
var userNameList []string
if userManager != nil {
creds, uErr := userManager.GetCredentials(C.TypeTrojan)
if uErr == nil && len(creds) > 0 {
userIndices := make([]int, len(creds))
userPasswords := make([]string, len(creds))
userNameList = make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userPasswords[i] = c.Credential
userNameList[i] = c.Username
}
uErr = service.UpdateUsers(userIndices, userPasswords)
if uErr != nil {
return nil, uErr
}
}
inbound.userManager = userManager
} else {
userNameList = make([]string, len(options.Users))
for i, u := range options.Users {
userNameList[i] = u.Name
}
err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int {
return index
}), common.Map(options.Users, func(it option.TrojanUser) string {
return it.Password
}))
if err != nil {
return nil, err
}
}
if options.Transport != nil {
inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound))
@ -106,6 +137,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
return nil, err
}
inbound.service = service
inbound.userNameList = userNameList
inbound.listener = listener.New(listener.Options{
Context: ctx,
Logger: logger,
@ -126,6 +158,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return E.Cause(err, "create TLS config")
}
}
if h.userManager != nil {
go h.credentialLoop()
}
if h.transport == nil {
return h.listener.Start()
}
@ -156,6 +191,27 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return nil
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeTrojan)
if err != nil || len(creds) == 0 {
continue
}
userIndices := make([]int, len(creds))
userPasswords := make([]string, len(creds))
userNameList := make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userPasswords[i] = c.Credential
userNameList[i] = c.Username
}
h.service.UpdateUsers(userIndices, userPasswords)
h.userNameList = userNameList
}
}
func (h *Inbound) Close() error {
return common.Close(
h.listener,
@ -189,7 +245,7 @@ func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata ada
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {
@ -207,7 +263,7 @@ func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, me
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {

View file

@ -3,6 +3,7 @@ package tuic
import (
"context"
"net"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
@ -20,6 +21,7 @@ import (
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
sService "github.com/sagernet/sing/service"
"github.com/gofrs/uuid/v5"
)
@ -36,6 +38,7 @@ type Inbound struct {
tlsConfig tls.ServerConfig
server *tuic.Service[int]
userNameList []string
userManager adapter.UserManager
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICInboundOptions) (adapter.Inbound, error) {
@ -87,26 +90,61 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
if err != nil {
return nil, err
}
var userList []int
var userNameList []string
var userUUIDList [][16]byte
var userPasswordList []string
for index, user := range options.Users {
if user.UUID == "" {
return nil, E.New("missing uuid for user ", index)
userManager := sService.FromContext[adapter.UserManager](ctx)
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeTUIC)
if err == nil && len(creds) > 0 {
userList := make([]int, 0, len(creds))
userNameList := make([]string, 0, len(creds))
userUUIDList := make([][16]byte, 0, len(creds))
userPasswordList := make([]string, 0, len(creds))
for i, c := range creds {
parts := strings.SplitN(c.Credential, ":", 2)
if len(parts) < 1 || parts[0] == "" {
continue
}
userUUID, pErr := uuid.FromString(parts[0])
if pErr != nil {
continue
}
password := ""
if len(parts) == 2 {
password = parts[1]
}
userList = append(userList, i)
userNameList = append(userNameList, c.Username)
userUUIDList = append(userUUIDList, userUUID)
userPasswordList = append(userPasswordList, password)
}
if len(userList) > 0 {
service.UpdateUsers(userList, userUUIDList, userPasswordList)
inbound.userNameList = userNameList
}
}
userUUID, err := uuid.FromString(user.UUID)
if err != nil {
return nil, E.Cause(err, "invalid uuid for user ", index)
inbound.userManager = userManager
} else {
userList := make([]int, 0, len(options.Users))
userNameList := make([]string, 0, len(options.Users))
userUUIDList := make([][16]byte, 0, len(options.Users))
userPasswordList := make([]string, 0, len(options.Users))
for index, user := range options.Users {
if user.UUID == "" {
return nil, E.New("missing uuid for user ", index)
}
userUUID, err := uuid.FromString(user.UUID)
if err != nil {
return nil, E.Cause(err, "invalid uuid for user ", index)
}
userList = append(userList, index)
userNameList = append(userNameList, user.Name)
userUUIDList = append(userUUIDList, userUUID)
userPasswordList = append(userPasswordList, user.Password)
}
userList = append(userList, index)
userNameList = append(userNameList, user.Name)
userUUIDList = append(userUUIDList, userUUID)
userPasswordList = append(userPasswordList, user.Password)
service.UpdateUsers(userList, userUUIDList, userPasswordList)
inbound.userNameList = userNameList
}
service.UpdateUsers(userList, userUUIDList, userPasswordList)
inbound.server = service
inbound.userNameList = userNameList
return inbound, nil
}
@ -164,6 +202,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return err
}
}
if h.userManager != nil {
go h.credentialLoop()
}
packetConn, err := h.listener.ListenUDP()
if err != nil {
return err
@ -171,6 +212,43 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return h.server.Start(packetConn)
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeTUIC)
if err != nil || len(creds) == 0 {
continue
}
userList := make([]int, 0, len(creds))
userNameList := make([]string, 0, len(creds))
userUUIDList := make([][16]byte, 0, len(creds))
userPasswordList := make([]string, 0, len(creds))
for i, c := range creds {
parts := strings.SplitN(c.Credential, ":", 2)
if len(parts) < 1 || parts[0] == "" {
continue
}
userUUID, pErr := uuid.FromString(parts[0])
if pErr != nil {
continue
}
password := ""
if len(parts) == 2 {
password = parts[1]
}
userList = append(userList, i)
userNameList = append(userNameList, c.Username)
userUUIDList = append(userUUIDList, userUUID)
userPasswordList = append(userPasswordList, password)
}
if len(userList) > 0 {
h.server.UpdateUsers(userList, userUUIDList, userPasswordList)
h.userNameList = userNameList
}
}
}
func (h *Inbound) Close() error {
return common.Close(
h.listener,

View file

@ -4,6 +4,7 @@ import (
"context"
"net"
"os"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@ -25,6 +26,7 @@ import (
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
sService "github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -35,14 +37,16 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
type Inbound struct {
inbound.Adapter
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
users []option.VLESSUser
service *vless.Service[int]
tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
users []option.VLESSUser
userNameList []string
userManager adapter.UserManager
service *vless.Service[int]
tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) {
@ -59,14 +63,40 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
return nil, err
}
service := vless.NewService[int](logger, adapter.NewUpstreamContextHandler(inbound.newConnectionEx, inbound.newPacketConnectionEx))
service.UpdateUsers(common.MapIndexed(inbound.users, func(index int, _ option.VLESSUser) int {
return index
}), common.Map(inbound.users, func(it option.VLESSUser) string {
return it.UUID
}), common.Map(inbound.users, func(it option.VLESSUser) string {
return it.Flow
}))
userManager := sService.FromContext[adapter.UserManager](ctx)
var userNameList []string
if userManager != nil {
creds, err := userManager.GetCredentials(C.TypeVLESS)
if err == nil && len(creds) > 0 {
userIndices := make([]int, len(creds))
userUUIDs := make([]string, len(creds))
userFlows := make([]string, len(creds))
userNameList = make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userUUIDs[i] = c.Credential
userFlows[i] = ""
userNameList[i] = c.Username
}
service.UpdateUsers(userIndices, userUUIDs, userFlows)
}
inbound.userManager = userManager
} else {
userNameList = make([]string, len(inbound.users))
for i, u := range inbound.users {
userNameList[i] = u.Name
}
service.UpdateUsers(common.MapIndexed(inbound.users, func(index int, _ option.VLESSUser) int {
return index
}), common.Map(inbound.users, func(it option.VLESSUser) string {
return it.UUID
}), common.Map(inbound.users, func(it option.VLESSUser) string {
return it.Flow
}))
}
inbound.service = service
inbound.userNameList = userNameList
if options.TLS != nil {
inbound.tlsConfig, err = tls.NewServerWithOptions(tls.ServerOptions{
Context: ctx,
@ -108,6 +138,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return err
}
}
if h.userManager != nil {
go h.credentialLoop()
}
if h.transport == nil {
return h.listener.Start()
}
@ -138,6 +171,29 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return nil
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeVLESS)
if err != nil || len(creds) == 0 {
continue
}
userIndices := make([]int, len(creds))
userUUIDs := make([]string, len(creds))
userFlows := make([]string, len(creds))
userNameList := make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userUUIDs[i] = c.Credential
userFlows[i] = ""
userNameList[i] = c.Username
}
h.service.UpdateUsers(userIndices, userUUIDs, userFlows)
h.userNameList = userNameList
}
}
func (h *Inbound) Close() error {
return common.Close(
h.service,
@ -172,7 +228,7 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {
@ -190,7 +246,7 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {

View file

@ -4,6 +4,7 @@ import (
"context"
"net"
"os"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
@ -26,6 +27,7 @@ import (
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
sService "github.com/sagernet/sing/service"
)
func RegisterInbound(registry *inbound.Registry) {
@ -36,14 +38,16 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
type Inbound struct {
inbound.Adapter
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
service *vmess.Service[int]
users []option.VMessUser
tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
service *vmess.Service[int]
users []option.VMessUser
userNameList []string
userManager adapter.UserManager
tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) {
@ -67,17 +71,46 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
serviceOptions = append(serviceOptions, vmess.ServiceWithDisableHeaderProtection())
}
service := vmess.NewService[int](adapter.NewUpstreamContextHandler(inbound.newConnectionEx, inbound.newPacketConnectionEx), serviceOptions...)
inbound.service = service
err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.VMessUser) int {
return index
}), common.Map(options.Users, func(it option.VMessUser) string {
return it.UUID
}), common.Map(options.Users, func(it option.VMessUser) int {
return it.AlterId
}))
if err != nil {
return nil, err
userManager := sService.FromContext[adapter.UserManager](ctx)
var userNameList []string
if userManager != nil {
creds, uErr := userManager.GetCredentials(C.TypeVMess)
if uErr == nil && len(creds) > 0 {
userIndices := make([]int, len(creds))
userUUIDs := make([]string, len(creds))
userAlterIds := make([]int, len(creds))
userNameList = make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userUUIDs[i] = c.Credential
userAlterIds[i] = 0
userNameList[i] = c.Username
}
uErr = service.UpdateUsers(userIndices, userUUIDs, userAlterIds)
if uErr != nil {
return nil, uErr
}
}
inbound.userManager = userManager
} else {
userNameList = make([]string, len(options.Users))
for i, u := range options.Users {
userNameList[i] = u.Name
}
err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.VMessUser) int {
return index
}), common.Map(options.Users, func(it option.VMessUser) string {
return it.UUID
}), common.Map(options.Users, func(it option.VMessUser) int {
return it.AlterId
}))
if err != nil {
return nil, err
}
}
inbound.service = service
inbound.userNameList = userNameList
if options.TLS != nil {
inbound.tlsConfig, err = tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
if err != nil {
@ -114,6 +147,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return err
}
}
if h.userManager != nil {
go h.credentialLoop()
}
if h.transport == nil {
return h.listener.Start()
}
@ -144,6 +180,29 @@ func (h *Inbound) Start(stage adapter.StartStage) error {
return nil
}
func (h *Inbound) credentialLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
creds, err := h.userManager.GetCredentials(C.TypeVMess)
if err != nil || len(creds) == 0 {
continue
}
userIndices := make([]int, len(creds))
userUUIDs := make([]string, len(creds))
userAlterIds := make([]int, len(creds))
userNameList := make([]string, len(creds))
for i, c := range creds {
userIndices[i] = i
userUUIDs[i] = c.Credential
userAlterIds[i] = 0
userNameList[i] = c.Username
}
h.service.UpdateUsers(userIndices, userUUIDs, userAlterIds)
h.userNameList = userNameList
}
}
func (h *Inbound) Close() error {
return common.Close(
h.service,
@ -178,7 +237,7 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {
@ -196,7 +255,7 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
user := h.users[userIndex].Name
user := h.userNameList[userIndex]
if user == "" {
user = F.ToString(userIndex)
} else {

View file

@ -1 +1 @@
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,badlinkname,tfogo_checklinkname0
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,badlinkname,tfogo_checklinkname0

View file

@ -1 +1 @@
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,badlinkname,tfogo_checklinkname0
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_usbip,badlinkname,tfogo_checklinkname0

View file

@ -1 +1 @@
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,with_usbip,badlinkname,tfogo_checklinkname0

View file

@ -40,6 +40,7 @@ type Router struct {
leaseFiles []string
ruleSets []adapter.RuleSet
ruleSetMap map[string]adapter.RuleSet
ruleSetUpdater *R.RuleSetUpdater
processSearcher process.Searcher
processCache freelru.Cache[processCacheKey, processCacheEntry]
neighborResolver adapter.NeighborResolver
@ -156,6 +157,7 @@ func (r *Router) Start(stage adapter.StartStage) error {
if startContext != nil {
startContext.Close()
}
r.ruleSetUpdater = R.NewRuleSetUpdater(r.ctx, r.ruleSets)
r.network.Initialize(r.ruleSets)
needFindProcess := r.needFindProcess
for _, ruleSet := range r.ruleSets {
@ -201,13 +203,8 @@ func (r *Router) Start(stage adapter.StartStage) error {
return E.Cause(err, "initialize rule[", i, "]")
}
}
for _, ruleSet := range r.ruleSets {
monitor.Start("post start rule_set[", ruleSet.Name(), "]")
err := ruleSet.PostStart()
monitor.Finish()
if err != nil {
return E.Cause(err, "post start rule_set[", ruleSet.Name(), "]")
}
if r.ruleSetUpdater != nil {
r.ruleSetUpdater.Start()
}
r.started = true
return nil
@ -237,6 +234,13 @@ func (r *Router) Close() error {
})
monitor.Finish()
}
if r.ruleSetUpdater != nil {
monitor.Start("close rule-set updater")
err = E.Append(err, r.ruleSetUpdater.Close(), func(err error) error {
return E.Cause(err, "close rule-set updater")
})
monitor.Finish()
}
for i, ruleSet := range r.ruleSets {
monitor.Start("close rule-set[", i, "]")
err = E.Append(err, ruleSet.Close(), func(err error) error {

View file

@ -24,10 +24,6 @@ func (f *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) e
return nil
}
func (f *fakeRuleSet) PostStart() error {
return nil
}
func (f *fakeRuleSet) Metadata() adapter.RuleSetMetadata {
return adapter.RuleSetMetadata{}
}

View file

@ -153,10 +153,6 @@ func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error {
return nil
}
func (s *LocalRuleSet) PostStart() error {
return nil
}
func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata {
s.access.RLock()
defer s.access.RUnlock()

View file

@ -5,7 +5,6 @@ import (
"context"
"io"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
@ -43,7 +42,6 @@ type RemoteRuleSet struct {
metadata adapter.RuleSetMetadata
lastUpdated time.Time
lastEtag string
updateTicker *time.Ticker
cacheFile adapter.CacheFile
pauseManager pause.Manager
callbacks list.List[adapter.RuleSetUpdateCallback]
@ -102,12 +100,6 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.
return E.Cause(err, "initial rule-set: ", s.options.Tag)
}
}
s.updateTicker = time.NewTicker(s.updateInterval)
return nil
}
func (s *RemoteRuleSet) PostStart() error {
go s.loopUpdate()
return nil
}
@ -197,21 +189,6 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error {
return nil
}
func (s *RemoteRuleSet) loopUpdate() {
if time.Since(s.lastUpdated) > s.updateInterval {
s.updateOnce()
}
for {
runtime.GC()
select {
case <-s.ctx.Done():
return
case <-s.updateTicker.C:
s.updateOnce()
}
}
}
func (s *RemoteRuleSet) updateOnce() {
err := s.fetch(s.ctx, false)
if err != nil {
@ -312,9 +289,6 @@ func (s *RemoteRuleSet) resolveTransport() (adapter.HTTPTransport, error) {
func (s *RemoteRuleSet) Close() error {
s.rules = nil
s.cancel()
if s.updateTicker != nil {
s.updateTicker.Stop()
}
return nil
}

View file

@ -0,0 +1,87 @@
package rule
import (
"context"
"runtime"
"time"
"github.com/sagernet/sing-box/adapter"
)
type RuleSetUpdater struct {
ctx context.Context
cancel context.CancelFunc
ruleSets []*RemoteRuleSet
}
func NewRuleSetUpdater(ctx context.Context, ruleSets []adapter.RuleSet) *RuleSetUpdater {
var remoteRuleSets []*RemoteRuleSet
for _, ruleSet := range ruleSets {
remoteRuleSet, isRemote := ruleSet.(*RemoteRuleSet)
if isRemote {
remoteRuleSets = append(remoteRuleSets, remoteRuleSet)
}
}
if len(remoteRuleSets) == 0 {
return nil
}
ctx, cancel := context.WithCancel(ctx)
return &RuleSetUpdater{
ctx: ctx,
cancel: cancel,
ruleSets: remoteRuleSets,
}
}
func (u *RuleSetUpdater) Start() {
go u.loopUpdate()
}
func (u *RuleSetUpdater) Close() error {
u.cancel()
return nil
}
func (u *RuleSetUpdater) loopUpdate() {
nextUpdates := make([]time.Time, len(u.ruleSets))
for i, ruleSet := range u.ruleSets {
nextUpdates[i] = ruleSet.lastUpdated.Add(ruleSet.updateInterval)
}
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-u.ctx.Done():
return
case <-timer.C:
}
now := time.Now()
var updated bool
for i, ruleSet := range u.ruleSets {
if now.Before(nextUpdates[i]) {
continue
}
ruleSet.updateOnce()
nextUpdates[i] = now.Add(ruleSet.updateInterval)
updated = true
}
if updated {
runtime.GC()
}
timer.Reset(waitUntilNext(nextUpdates))
}
}
func waitUntilNext(nextUpdates []time.Time) time.Duration {
next := nextUpdates[0]
for _, nextUpdate := range nextUpdates[1:] {
if nextUpdate.Before(next) {
next = nextUpdate
}
}
wait := time.Until(next)
if wait < 0 {
return 0
}
return wait
}

310
service/api/dashboard.go Normal file
View file

@ -0,0 +1,310 @@
package api
import (
"archive/zip"
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
)
const (
dashboardRoutePrefix = "/dashboard/"
dashboardEtagFileName = ".etag"
defaultDashboardURL = "https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip"
)
type dashboardStatus int
const (
dashboardEmpty dashboardStatus = iota
dashboardManaged
dashboardUserProvided
)
type dashboard struct {
ctx context.Context
cancel context.CancelFunc
logger log.ContextLogger
options option.APIDashboardOptions
path string
url string
updateInterval time.Duration
fileServer http.Handler
httpClient *http.Client
lastEtag string
lastUpdated time.Time
}
func newDashboard(ctx context.Context, logger log.ContextLogger, options option.APIDashboardOptions) *dashboard {
ctx, cancel := context.WithCancel(ctx)
path := options.Path
if path == "" {
path = "dashboard"
}
path = filemanager.BasePath(ctx, os.ExpandEnv(path))
url := options.DownloadURL
if url == "" {
url = defaultDashboardURL
}
updateInterval := 24 * time.Hour
if options.UpdateInterval > 0 {
updateInterval = time.Duration(options.UpdateInterval)
}
return &dashboard{
ctx: ctx,
cancel: cancel,
logger: logger,
options: options,
path: path,
url: url,
updateInterval: updateInterval,
fileServer: http.StripPrefix(dashboardRoutePrefix, http.FileServer(dashboardDir(path))),
}
}
func (d *dashboard) start() error {
transport, err := d.resolveTransport()
if err != nil {
return E.Cause(err, "create dashboard http client")
}
d.httpClient = &http.Client{Transport: transport}
go d.loopUpdate()
return nil
}
func (d *dashboard) close() error {
d.cancel()
if d.httpClient != nil {
d.httpClient.CloseIdleConnections()
}
return nil
}
func (d *dashboard) resolveTransport() (adapter.HTTPTransport, error) {
httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx)
if httpClientManager == nil {
return nil, E.New("missing http client manager in context")
}
if d.options.HTTPClient != nil && !d.options.HTTPClient.IsEmpty() {
return httpClientManager.ResolveTransport(d.ctx, d.logger, *d.options.HTTPClient)
}
defaultTransport := httpClientManager.DefaultTransport()
if defaultTransport == nil {
return nil, E.New("default http client transport is not initialized")
}
return defaultTransport, nil
}
func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) {
if strings.HasPrefix(request.URL.Path, dashboardRoutePrefix) {
d.fileServer.ServeHTTP(writer, request)
return
}
http.Redirect(writer, request, dashboardRoutePrefix, http.StatusFound)
}
func (d *dashboard) loopUpdate() {
status := d.loadState()
if status == dashboardUserProvided {
d.logger.Info("dashboard: serving user-provided files at ", d.path, ", auto-update disabled")
return
}
var nextUpdate time.Time
if status == dashboardManaged {
nextUpdate = d.lastUpdated.Add(d.updateInterval)
}
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-d.ctx.Done():
return
case <-timer.C:
}
now := time.Now()
if !now.Before(nextUpdate) {
err := d.fetch(d.ctx)
if err != nil {
d.logger.Error(E.Cause(err, "update dashboard"))
nextUpdate = now.Add(d.updateInterval)
} else {
nextUpdate = d.lastUpdated.Add(d.updateInterval)
}
}
timer.Reset(max(time.Until(nextUpdate), 0))
}
}
func (d *dashboard) loadState() dashboardStatus {
entries, err := os.ReadDir(d.path)
if err != nil {
return dashboardEmpty
}
if len(entries) == 0 {
return dashboardEmpty
}
etagPath := filepath.Join(d.path, dashboardEtagFileName)
etagBytes, err := os.ReadFile(etagPath)
if err != nil {
return dashboardUserProvided
}
d.lastEtag = strings.TrimSpace(string(etagBytes))
info, err := os.Stat(etagPath)
if err == nil {
d.lastUpdated = info.ModTime()
}
return dashboardManaged
}
func (d *dashboard) fetch(ctx context.Context) error {
d.logger.Info("updating dashboard from URL: ", d.url)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url, nil)
if err != nil {
return err
}
if d.lastEtag != "" {
request.Header.Set("If-None-Match", d.lastEtag)
}
defer d.httpClient.CloseIdleConnections()
response, err := d.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotModified:
d.lastUpdated = time.Now()
err = filemanager.WriteFile(d.ctx, filepath.Join(d.path, dashboardEtagFileName), []byte(d.lastEtag), 0o644)
if err != nil {
d.logger.Warn(E.Cause(err, "save dashboard update time"))
}
d.logger.Info("dashboard: not modified")
return nil
default:
return E.New("unexpected status: ", response.Status)
}
etag := response.Header.Get("Etag")
err = d.extract(response.Body, etag)
if err != nil {
return err
}
d.lastEtag = etag
d.lastUpdated = time.Now()
d.logger.Info("dashboard: updated")
return nil
}
func (d *dashboard) extract(body io.Reader, etag string) error {
tempFile, err := filemanager.CreateTemp(d.ctx, "sing-box-dashboard-*.zip")
if err != nil {
return err
}
tempZipPath := tempFile.Name()
defer os.Remove(tempZipPath)
_, err = io.Copy(tempFile, body)
tempFile.Close()
if err != nil {
return err
}
reader, err := zip.OpenReader(tempZipPath)
if err != nil {
return err
}
defer reader.Close()
tempDir := d.path + ".tmp"
err = filemanager.RemoveAll(d.ctx, tempDir)
if err != nil {
return err
}
err = filemanager.MkdirAll(d.ctx, tempDir, 0o755)
if err != nil {
return err
}
trimDir := zipIsInSingleDirectory(reader.File)
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
pathElements := strings.Split(file.Name, "/")
if trimDir {
pathElements = pathElements[1:]
}
if len(pathElements) == 0 {
continue
}
relativePath := filepath.Join(pathElements...)
if !filepath.IsLocal(relativePath) {
filemanager.RemoveAll(d.ctx, tempDir)
return E.New("invalid dashboard archive entry: ", file.Name)
}
savePath := filepath.Join(tempDir, relativePath)
err = filemanager.MkdirAll(d.ctx, filepath.Dir(savePath), 0o755)
if err != nil {
filemanager.RemoveAll(d.ctx, tempDir)
return err
}
err = extractZipEntry(d.ctx, file, savePath)
if err != nil {
filemanager.RemoveAll(d.ctx, tempDir)
return err
}
}
err = filemanager.WriteFile(d.ctx, filepath.Join(tempDir, dashboardEtagFileName), []byte(etag), 0o644)
if err != nil {
filemanager.RemoveAll(d.ctx, tempDir)
return err
}
err = filemanager.RemoveAll(d.ctx, d.path)
if err != nil {
return err
}
return os.Rename(tempDir, d.path)
}
func extractZipEntry(ctx context.Context, file *zip.File, savePath string) error {
reader, err := file.Open()
if err != nil {
return err
}
defer reader.Close()
writer, err := filemanager.Create(ctx, savePath)
if err != nil {
return err
}
defer writer.Close()
_, err = io.Copy(writer, reader)
return err
}
// GitHub archives wrap every file under a single "<repo>-<branch>/" top-level directory.
func zipIsInSingleDirectory(files []*zip.File) bool {
var dirName string
for _, file := range files {
if file.FileInfo().IsDir() {
continue
}
pathElements := strings.Split(file.Name, "/")
if len(pathElements) < 2 {
return false
}
if dirName == "" {
dirName = pathElements[0]
} else if dirName != pathElements[0] {
return false
}
}
return dirName != ""
}

View file

@ -0,0 +1,18 @@
package api
import "net/http"
type dashboardDir http.Dir
func (d dashboardDir) Open(name string) (http.File, error) {
file, err := http.Dir(d).Open(name)
if err != nil {
return nil, err
}
return &fileWrapper{file}, nil
}
// workaround for #2345 #2596
type fileWrapper struct {
http.File
}

View file

@ -38,6 +38,7 @@ type Service struct {
startedService *daemon.StartedService
grpcServer *grpc.Server
httpServer *http.Server
dashboard *dashboard
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.APIServiceOptions) (adapter.Service, error) {
@ -63,6 +64,9 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio
}
s.tlsConfig = tlsConfig
}
if options.Dashboard != nil && options.Dashboard.Enabled {
s.dashboard = newDashboard(ctx, logger, *options.Dashboard)
}
return s, nil
}
@ -72,8 +76,14 @@ func (s *Service) Start(stage adapter.StartStage) error {
}
s.startedService = daemon.NewAttachedService(s.ctx)
s.grpcServer = daemon.NewServer(s.startedService, s.options.Secret)
if s.dashboard != nil {
err := s.dashboard.start()
if err != nil {
return E.Cause(err, "start dashboard")
}
}
s.httpServer = &http.Server{
Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options), new(http2.Server)),
Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options, s.dashboard), new(http2.Server)),
BaseContext: func(net.Listener) context.Context {
return s.ctx
},
@ -108,6 +118,9 @@ func (s *Service) Start(stage adapter.StartStage) error {
func (s *Service) Close() error {
s.cancel()
if s.dashboard != nil {
s.dashboard.close()
}
if s.httpServer != nil {
s.httpServer.Close()
}

View file

@ -26,14 +26,14 @@ const (
// (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) and gRPC-Web
// streams over WebSocket, wire compatible with the improbable-eng/grpc-web
// client transports.
func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options option.APIServiceOptions) http.Handler {
func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options option.APIServiceOptions, dashboard *dashboard) http.Handler {
allowedOrigins := options.AccessControlAllowOrigin
if len(allowedOrigins) == 0 {
allowedOrigins = []string{"*"}
}
corsHandler := cors.New(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{http.MethodPost, http.MethodOptions},
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout"},
ExposedHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"},
AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork,
@ -42,12 +42,14 @@ func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options o
return corsHandler.Handler(&webBridge{
logger: logger,
grpcServer: grpcServer,
dashboard: dashboard,
})
}
type webBridge struct {
logger log.ContextLogger
grpcServer *grpc.Server
dashboard *dashboard
}
func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@ -59,6 +61,8 @@ func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request)
b.serveWeb(writer, request)
case request.ProtoMajor == 2 && strings.HasPrefix(contentType, contentTypeGRPC):
b.grpcServer.ServeHTTP(writer, request)
case b.dashboard != nil:
b.dashboard.serveHTTP(writer, request)
default:
http.NotFound(writer, request)
}

62
service/usbip/client.go Normal file
View file

@ -0,0 +1,62 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package usbip
import (
"context"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-usbip"
E "github.com/sagernet/sing/common/exceptions"
)
type ClientService struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
inner *usbip.ClientService
}
func NewClientService(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPClientServiceOptions) (adapter.Service, error) {
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: option.DialerOptions{
Detour: options.Detour,
},
RemoteIsDomain: true,
})
if err != nil {
return nil, E.Cause(err, "create dialer")
}
inner, err := usbip.NewClientService(ctx, usbip.ClientOptions{
Logger: logger,
Dialer: serviceDialer,
ServerAddress: options.ServerOptions.Build(),
Devices: toDeviceMatches(options.Devices),
})
if err != nil {
return nil, err
}
return &ClientService{
Adapter: boxService.NewAdapter(C.TypeUSBIPClient, tag),
ctx: ctx,
logger: logger,
inner: inner,
}, nil
}
func (s *ClientService) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
return s.inner.Start()
}
func (s *ClientService) Close() error {
return s.inner.Close()
}

107
service/usbip/server.go Normal file
View file

@ -0,0 +1,107 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package usbip
import (
"context"
"net"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-usbip"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
)
type ServerService struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
inner *usbip.ServerService
}
type dynamicServerService struct {
ServerService
host *usbip.DynamicHost
}
var _ adapter.USBIPDynamicServer = (*dynamicServerService)(nil)
func NewServerService(ctx context.Context, logger log.ContextLogger, tag string, options option.USBIPServerServiceOptions) (adapter.Service, error) {
listenOptions := options.ListenOptions
if listenOptions.ListenPort == 0 {
listenOptions.ListenPort = usbip.DefaultPort
}
boxListener := listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: listenOptions,
})
serverOptions := usbip.ServerOptions{
Logger: logger,
Listen: func(context.Context) (net.Listener, error) {
return boxListener.ListenTCP()
},
}
base := ServerService{
Adapter: boxService.NewAdapter(C.TypeUSBIPServer, tag),
ctx: ctx,
logger: logger,
}
providerType := options.Provider
if providerType == "" {
providerType = option.USBIPProviderDefault
}
switch providerType {
case option.USBIPProviderDefault:
defaultOptions, isDefault := options.Options.(*option.USBIPDefaultProviderOptions)
if isDefault {
serverOptions.Devices = toDeviceMatches(defaultOptions.Devices)
}
inner, err := usbip.NewServerService(ctx, serverOptions)
if err != nil {
return nil, err
}
base.inner = inner
return &base, nil
case option.USBIPProviderDynamic:
host := usbip.NewDynamicHost(logger)
inner, err := usbip.NewDynamicServerService(ctx, serverOptions, host)
if err != nil {
return nil, err
}
base.inner = inner
return &dynamicServerService{ServerService: base, host: host}, nil
default:
return nil, E.New("unknown usbip provider type: ", providerType)
}
}
func (s *ServerService) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
return s.inner.Start()
}
func (s *ServerService) Close() error {
return s.inner.Close()
}
func (s *dynamicServerService) AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error) {
return s.host.AddDevice(info, transport)
}
func (s *dynamicServerService) RemoveDevice(busID string) {
s.host.RemoveDevice(busID)
}
func (s *dynamicServerService) SubscribeDevices(ctx context.Context, listener func([]usbip.ControlDeviceInfo)) {
s.inner.SubscribeDevices(ctx, listener)
}

31
service/usbip/service.go Normal file
View file

@ -0,0 +1,31 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package usbip
import (
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-usbip"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.USBIPServerServiceOptions](registry, C.TypeUSBIPServer, NewServerService)
boxService.Register[option.USBIPClientServiceOptions](registry, C.TypeUSBIPClient, NewClientService)
}
func toDeviceMatches(matches []option.USBIPDeviceMatch) []usbip.DeviceMatch {
if len(matches) == 0 {
return nil
}
deviceMatches := make([]usbip.DeviceMatch, 0, len(matches))
for _, match := range matches {
deviceMatches = append(deviceMatches, usbip.DeviceMatch{
BusID: match.BusID,
VendorID: match.VendorID,
ProductID: match.ProductID,
Serial: match.Serial,
})
}
return deviceMatches
}

3
service/usbip/stub.go Normal file
View file

@ -0,0 +1,3 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package usbip

View file

@ -0,0 +1,662 @@
package usermanager
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)
var _ adapter.UserManager = (*Manager)(nil)
var _ adapter.ConnectionTracker = (*Manager)(nil)
type authCacheEntry struct {
username string
expiresAt time.Time
}
type userConnCounter struct {
username string
tx int64
rx int64
done chan struct{}
}
type Manager struct {
boxService.Adapter
ctx context.Context
cancel context.CancelFunc
logger log.ContextLogger
authServer string
timeout time.Duration
cacheTTL time.Duration
refreshInt time.Duration
reportInt time.Duration
reportTraffic bool
apiSecret string
apiListen string
httpClient *http.Client
access sync.RWMutex
authCache map[string]*authCacheEntry
userCreds map[string]string
userConns map[string]map[*userConnCounter]struct{}
protocolUsers map[string][]adapter.UserInfo
protocolLastSeq map[string]int64
trafficAccess sync.Mutex
traffic map[string]*UserTrafficAtomic
stopRefresh chan struct{}
stopReport chan struct{}
stopAPI chan struct{}
}
type UserTrafficAtomic struct {
Tx atomic.Int64
Rx atomic.Int64
}
func New(ctx context.Context, logger log.ContextLogger, tag string, options option.UserManagerOptions) (*Manager, error) {
ctx, cancel := context.WithCancel(ctx)
m := &Manager{
Adapter: boxService.NewAdapter(C.TypeUserManager, tag),
ctx: ctx,
cancel: cancel,
logger: logger,
authServer: options.AuthServer,
timeout: time.Duration(options.Timeout),
cacheTTL: time.Duration(options.CacheTTL),
refreshInt: time.Duration(options.RefreshInterval),
reportInt: time.Duration(options.ReportInterval),
reportTraffic: options.ReportTraffic,
apiSecret: options.APISecret,
apiListen: options.APIListen,
httpClient: &http.Client{
Timeout: time.Duration(options.Timeout),
},
authCache: make(map[string]*authCacheEntry),
userCreds: make(map[string]string),
userConns: make(map[string]map[*userConnCounter]struct{}),
protocolUsers: make(map[string][]adapter.UserInfo),
protocolLastSeq: make(map[string]int64),
traffic: make(map[string]*UserTrafficAtomic),
stopRefresh: make(chan struct{}),
stopReport: make(chan struct{}),
stopAPI: make(chan struct{}),
}
if m.timeout <= 0 {
m.timeout = 10 * time.Second
}
if m.cacheTTL <= 0 {
m.cacheTTL = 5 * time.Minute
}
if m.refreshInt <= 0 {
m.refreshInt = 30 * time.Second
}
if m.reportInt <= 0 {
m.reportInt = 60 * time.Second
}
return m, nil
}
func (m *Manager) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
m.logger.Info("starting user manager")
if err := m.refreshCredentials(); err != nil {
m.logger.Warn("initial credential fetch failed: ", err)
}
router := service.FromContext[adapter.Router](m.ctx)
if router != nil {
router.AppendTracker(m)
m.logger.Debug("registered as connection tracker")
}
go func() {
ticker := time.NewTicker(m.refreshInt)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := m.refreshCredentials(); err != nil {
m.logger.Warn("credential refresh failed: ", err)
}
case <-m.stopRefresh:
return
}
}
}()
if m.reportTraffic && m.authServer != "" {
go func() {
ticker := time.NewTicker(m.reportInt)
defer ticker.Stop()
select {
case <-time.After(10 * time.Second):
case <-m.stopReport:
return
}
for {
select {
case <-ticker.C:
m.reportTrafficToServer()
case <-m.stopReport:
return
}
}
}()
}
if m.apiListen != "" {
go m.serveAPI()
}
return nil
}
func (m *Manager) Close() error {
m.cancel()
close(m.stopRefresh)
close(m.stopReport)
close(m.stopAPI)
return nil
}
func (m *Manager) Authenticate(ctx context.Context, protocol string, credential string, addr string) (string, error) {
if credential == "" {
return "", E.New("empty credential")
}
m.access.RLock()
entry, cached := m.authCache[credential]
m.access.RUnlock()
if cached && time.Now().Before(entry.expiresAt) {
return entry.username, nil
}
username, err := m.remoteAuth(protocol, credential, addr)
if err != nil {
return "", err
}
m.access.Lock()
m.authCache[credential] = &authCacheEntry{
username: username,
expiresAt: time.Now().Add(m.cacheTTL),
}
m.userCreds[username] = credential
m.access.Unlock()
return username, nil
}
type authRequest struct {
Protocol string `json:"protocol"`
Credential string `json:"credential"`
Addr string `json:"addr,omitempty"`
}
type authResponse struct {
Ok bool `json:"ok"`
ID string `json:"id,omitempty"`
}
func (m *Manager) remoteAuth(protocol string, credential string, addr string) (string, error) {
if m.authServer == "" {
return "", E.New("auth server not configured")
}
req := authRequest{
Protocol: protocol,
Credential: credential,
Addr: addr,
}
data, err := json.Marshal(req)
if err != nil {
return "", E.Cause(err, "marshal auth request")
}
httpReq, err := http.NewRequestWithContext(m.ctx, "POST", m.authServer+"/api/auth", bytes.NewReader(data))
if err != nil {
return "", E.Cause(err, "create auth request")
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(httpReq)
if err != nil {
return "", E.Cause(err, "auth request failed")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden {
return "", E.New("authentication rejected")
}
if resp.StatusCode != http.StatusOK {
return "", E.New("auth server returned ", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", E.Cause(err, "read auth response")
}
var authResp authResponse
if err := json.Unmarshal(body, &authResp); err != nil {
return "", E.Cause(err, "parse auth response")
}
if !authResp.Ok {
return "", E.New("authentication rejected")
}
if authResp.ID == "" {
return "", E.New("auth response missing user ID")
}
return authResp.ID, nil
}
func (m *Manager) GetCredentials(protocol string) ([]adapter.UserInfo, error) {
m.access.RLock()
defer m.access.RUnlock()
users := m.protocolUsers[protocol]
if users == nil {
return nil, nil
}
result := make([]adapter.UserInfo, len(users))
copy(result, users)
return result, nil
}
type credentialsResponse struct {
Credentials []credentialEntry `json:"credentials"`
}
type credentialEntry struct {
Protocol string `json:"protocol"`
Credential string `json:"credential"`
Username string `json:"username"`
}
func (m *Manager) refreshCredentials() error {
if m.authServer == "" {
return nil
}
httpReq, err := http.NewRequestWithContext(m.ctx, "GET", m.authServer+"/api/credentials", nil)
if err != nil {
return err
}
resp, err := m.httpClient.Do(httpReq)
if err != nil {
return E.Cause(err, "fetch credentials")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return E.New("credentials server returned ", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return E.Cause(err, "read credentials response")
}
var credResp credentialsResponse
if err := json.Unmarshal(body, &credResp); err != nil {
return E.Cause(err, "parse credentials response")
}
newCache := make(map[string]*authCacheEntry)
newUserCreds := make(map[string]string)
byProtocol := make(map[string][]adapter.UserInfo)
for _, c := range credResp.Credentials {
newCache[c.Credential] = &authCacheEntry{
username: c.Username,
expiresAt: time.Now().Add(m.cacheTTL),
}
newUserCreds[c.Username] = c.Credential
byProtocol[c.Protocol] = append(byProtocol[c.Protocol], adapter.UserInfo{
Username: c.Username,
Credential: c.Credential,
})
}
m.access.Lock()
for k, v := range newCache {
if _, exists := m.authCache[k]; !exists {
m.authCache[k] = v
}
}
for k, v := range newUserCreds {
m.userCreds[k] = v
}
m.protocolUsers = byProtocol
m.access.Unlock()
return nil
}
func (m *Manager) ReportTraffic(username string, tx int64, rx int64) {
if username == "" {
return
}
m.trafficAccess.Lock()
t, ok := m.traffic[username]
if !ok {
t = &UserTrafficAtomic{}
m.traffic[username] = t
}
m.trafficAccess.Unlock()
if tx > 0 {
t.Tx.Add(tx)
}
if rx > 0 {
t.Rx.Add(rx)
}
}
func (m *Manager) GetTraffic(username string) (int64, int64) {
m.trafficAccess.Lock()
t, ok := m.traffic[username]
m.trafficAccess.Unlock()
if !ok {
return 0, 0
}
return t.Tx.Load(), t.Rx.Load()
}
func (m *Manager) ListTraffic() map[string]adapter.UserTraffic {
m.trafficAccess.Lock()
defer m.trafficAccess.Unlock()
result := make(map[string]adapter.UserTraffic, len(m.traffic))
for username, t := range m.traffic {
result[username] = adapter.UserTraffic{
Tx: t.Tx.Load(),
Rx: t.Rx.Load(),
}
}
return result
}
func (m *Manager) KickUser(username string) {
m.access.Lock()
defer m.access.Unlock()
cred, ok := m.userCreds[username]
if ok {
delete(m.authCache, cred)
delete(m.userCreds, username)
}
for proto, users := range m.protocolUsers {
filtered := make([]adapter.UserInfo, 0, len(users))
for _, u := range users {
if u.Username != username {
filtered = append(filtered, u)
}
}
m.protocolUsers[proto] = filtered
}
m.logger.Info("kicked user ", username)
}
// ConnectionTracker implementation
func (m *Manager) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn {
username := metadata.User
if username == "" {
return conn
}
counter := &userConnCounter{
username: username,
done: make(chan struct{}),
}
m.access.Lock()
if m.userConns[username] == nil {
m.userConns[username] = make(map[*userConnCounter]struct{})
}
m.userConns[username][counter] = struct{}{}
m.access.Unlock()
wrapped := bufio.NewCounterConn(conn,
[]N.CountFunc{func(n int64) { counter.tx += n }},
[]N.CountFunc{func(n int64) { counter.rx += n }},
)
return &trackedConn{
ExtendedConn: wrapped,
onClose: func() {
m.ReportTraffic(username, counter.tx, counter.rx)
m.access.Lock()
if conns, ok := m.userConns[username]; ok {
delete(conns, counter)
if len(conns) == 0 {
delete(m.userConns, username)
}
}
m.access.Unlock()
},
}
}
func (m *Manager) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn {
username := metadata.User
if username == "" {
return conn
}
counter := &userConnCounter{
username: username,
done: make(chan struct{}),
}
m.access.Lock()
if m.userConns[username] == nil {
m.userConns[username] = make(map[*userConnCounter]struct{})
}
m.userConns[username][counter] = struct{}{}
m.access.Unlock()
wrapped := bufio.NewCounterPacketConn(conn,
[]N.CountFunc{func(n int64) { counter.tx += n }},
[]N.CountFunc{func(n int64) { counter.rx += n }},
)
return &trackedPacketConn{
PacketConn: wrapped,
onClose: func() {
m.ReportTraffic(username, counter.tx, counter.rx)
m.access.Lock()
if conns, ok := m.userConns[username]; ok {
delete(conns, counter)
if len(conns) == 0 {
delete(m.userConns, username)
}
}
m.access.Unlock()
},
}
}
type trackedConn struct {
N.ExtendedConn
onClose func()
}
func (c *trackedConn) Close() error {
c.onClose()
return c.ExtendedConn.Close()
}
func (c *trackedConn) Upstream() any {
return c.ExtendedConn
}
func (c *trackedConn) ReaderReplaceable() bool {
return true
}
func (c *trackedConn) WriterReplaceable() bool {
return true
}
type trackedPacketConn struct {
N.PacketConn
onClose func()
}
func (c *trackedPacketConn) Close() error {
c.onClose()
return c.PacketConn.Close()
}
func (c *trackedPacketConn) Upstream() any {
return c.PacketConn
}
func (c *trackedPacketConn) ReaderReplaceable() bool {
return true
}
func (c *trackedPacketConn) WriterReplaceable() bool {
return true
}
// Traffic report
type trafficReport struct {
Traffic map[string]adapter.UserTraffic `json:"traffic"`
}
func (m *Manager) reportTrafficToServer() {
traffic := m.ListTraffic()
if len(traffic) == 0 {
return
}
report := trafficReport{Traffic: traffic}
data, err := json.Marshal(report)
if err != nil {
m.logger.Warn("marshal traffic report: ", err)
return
}
httpReq, err := http.NewRequestWithContext(m.ctx, "POST", m.authServer+"/api/traffic", bytes.NewReader(data))
if err != nil {
m.logger.Warn("create traffic report: ", err)
return
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(httpReq)
if err != nil {
m.logger.Warn("traffic report failed: ", err)
return
}
resp.Body.Close()
m.logger.Debug("traffic reported, users=", len(traffic))
}
// HTTP API
func (m *Manager) serveAPI() {
mux := http.NewServeMux()
mux.HandleFunc("/traffic", m.handleTraffic)
mux.HandleFunc("/traffic/", m.handleTrafficUser)
mux.HandleFunc("/kick/", m.handleKick)
apiSecret := m.apiSecret
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if apiSecret != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + apiSecret
if subtle.ConstantTimeCompare([]byte(auth), []byte(expected)) != 1 {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
mux.ServeHTTP(w, r)
})
server := &http.Server{
Addr: m.apiListen,
Handler: handler,
}
listener, err := net.Listen("tcp", m.apiListen)
if err != nil {
m.logger.Error("api listen: ", err)
return
}
m.logger.Info("user manager API listening on ", m.apiListen)
err = server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
m.logger.Error("api serve: ", err)
}
}
func (m *Manager) handleTraffic(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
traffic := m.ListTraffic()
writeJSON(w, http.StatusOK, map[string]any{"users": traffic})
}
func (m *Manager) handleTrafficUser(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
username := r.URL.Path[len("/traffic/"):]
if username == "" {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
tx, rx := m.GetTraffic(username)
writeJSON(w, http.StatusOK, map[string]any{"tx": tx, "rx": rx})
}
func (m *Manager) handleKick(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
username := r.URL.Path[len("/kick/"):]
if username == "" {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
m.KickUser(username)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (m *Manager) GetCachedUsers() []string {
m.access.RLock()
defer m.access.RUnlock()
users := make([]string, 0, len(m.userCreds))
for u := range m.userCreds {
users = append(users, u)
}
return users
}
var _ = fmt.Sprintf
var _ = io.Discard

View file

@ -0,0 +1,18 @@
package usermanager
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
)
func RegisterService(registry *service.Registry) {
service.Register[option.UserManagerOptions](registry, "user_manager", NewUserManager)
}
func NewUserManager(ctx context.Context, logger log.ContextLogger, tag string, options option.UserManagerOptions) (adapter.Service, error) {
return New(ctx, logger, tag, options)
}