diff --git a/adapter/tailscale.go b/adapter/tailscale.go index 35809a542..22f48e62b 100644 --- a/adapter/tailscale.go +++ b/adapter/tailscale.go @@ -2,8 +2,18 @@ package adapter import "context" -type TailscaleStatusProvider interface { +type TailscaleEndpoint interface { SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error + StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error +} + +type TailscalePingResult struct { + LatencyMs float64 + IsDirect bool + Endpoint string + DERPRegionID int32 + DERPRegionCode string + Error string } type TailscaleEndpointStatus struct { @@ -12,8 +22,15 @@ type TailscaleEndpointStatus struct { NetworkName string MagicDNSSuffix string Self *TailscalePeer - Users map[int64]*TailscaleUser - Peers []*TailscalePeer + UserGroups []*TailscaleUserGroup +} + +type TailscaleUserGroup struct { + UserID int64 + LoginName string + DisplayName string + ProfilePicURL string + Peers []*TailscalePeer } type TailscalePeer struct { @@ -30,10 +47,3 @@ type TailscalePeer struct { UserID int64 KeyExpiry int64 } - -type TailscaleUser struct { - ID int64 - LoginName string - DisplayName string - ProfilePicURL string -} diff --git a/daemon/started_service.go b/daemon/started_service.go index 3b7709ff4..aa15c7bec 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -1085,31 +1085,6 @@ func (s *StartedService) GetStartedAt(ctx context.Context, empty *emptypb.Empty) return &StartedAt{StartedAt: s.startedAt.UnixMilli()}, nil } -func (s *StartedService) ListOutbounds(ctx context.Context, _ *emptypb.Empty) (*OutboundList, error) { - s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return nil, os.ErrInvalid - } - boxService := s.instance - s.serviceAccess.RUnlock() - historyStorage := boxService.urlTestHistoryStorage - outbounds := boxService.instance.Outbound().Outbounds() - var list OutboundList - for _, ob := range outbounds { - item := &GroupItem{ - Tag: ob.Tag(), - Type: ob.Type(), - } - if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { - item.UrlTestTime = history.Time.Unix() - item.UrlTestDelay = int32(history.Delay) - } - list.Outbounds = append(list.Outbounds, item) - } - return &list, nil -} - func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.ServerStreamingServer[OutboundList]) error { err := s.waitForStarted(server.Context()) if err != nil { @@ -1129,9 +1104,8 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server boxService := s.instance s.serviceAccess.RUnlock() historyStorage := boxService.urlTestHistoryStorage - outbounds := boxService.instance.Outbound().Outbounds() var list OutboundList - for _, ob := range outbounds { + for _, ob := range boxService.instance.Outbound().Outbounds() { item := &GroupItem{ Tag: ob.Tag(), Type: ob.Type(), @@ -1142,6 +1116,17 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server } list.Outbounds = append(list.Outbounds, item) } + for _, ep := range boxService.instance.Endpoint().Endpoints() { + item := &GroupItem{ + Tag: ep.Tag(), + Type: ep.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) + } err = server.Send(&list) if err != nil { return err @@ -1308,14 +1293,14 @@ func (s *StartedService) SubscribeTailscaleStatus( type tailscaleEndpoint struct { tag string - provider adapter.TailscaleStatusProvider + provider adapter.TailscaleEndpoint } var endpoints []tailscaleEndpoint for _, endpoint := range endpointManager.Endpoints() { if endpoint.Type() != C.TypeTailscale { continue } - provider, loaded := endpoint.(adapter.TailscaleStatusProvider) + provider, loaded := endpoint.(adapter.TailscaleEndpoint) if !loaded { continue } @@ -1339,7 +1324,7 @@ func (s *StartedService) SubscribeTailscaleStatus( var waitGroup sync.WaitGroup for _, endpoint := range endpoints { waitGroup.Add(1) - go func(tag string, provider adapter.TailscaleStatusProvider) { + go func(tag string, provider adapter.TailscaleEndpoint) { defer waitGroup.Done() _ = provider.SubscribeTailscaleStatus(ctx, func(endpointStatus *adapter.TailscaleEndpointStatus) { select { @@ -1355,12 +1340,16 @@ func (s *StartedService) SubscribeTailscaleStatus( close(updates) }() + var tags []string statuses := make(map[string]*adapter.TailscaleEndpointStatus, len(endpoints)) for update := range updates { + if _, exists := statuses[update.tag]; !exists { + tags = append(tags, update.tag) + } statuses[update.tag] = update.status protoEndpoints := make([]*TailscaleEndpointStatus, 0, len(statuses)) - for tag, endpointStatus := range statuses { - protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, endpointStatus)) + for _, tag := range tags { + protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag])) } sendErr := server.Send(&TailscaleStatusUpdate{ Endpoints: protoEndpoints, @@ -1373,27 +1362,19 @@ func (s *StartedService) SubscribeTailscaleStatus( } func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus) *TailscaleEndpointStatus { - userGroupMap := make(map[int64]*TailscaleUserGroup) - for userID, user := range s.Users { - userGroupMap[userID] = &TailscaleUserGroup{ - UserID: userID, - LoginName: user.LoginName, - DisplayName: user.DisplayName, - ProfilePicURL: user.ProfilePicURL, + userGroups := make([]*TailscaleUserGroup, len(s.UserGroups)) + for i, group := range s.UserGroups { + peers := make([]*TailscalePeer, len(group.Peers)) + for j, peer := range group.Peers { + peers[j] = tailscalePeerToProto(peer) } - } - for _, peer := range s.Peers { - protoPeer := tailscalePeerToProto(peer) - group, loaded := userGroupMap[peer.UserID] - if !loaded { - group = &TailscaleUserGroup{UserID: peer.UserID} - userGroupMap[peer.UserID] = group + userGroups[i] = &TailscaleUserGroup{ + UserID: group.UserID, + LoginName: group.LoginName, + DisplayName: group.DisplayName, + ProfilePicURL: group.ProfilePicURL, + Peers: peers, } - group.Peers = append(group.Peers, protoPeer) - } - userGroups := make([]*TailscaleUserGroup, 0, len(userGroupMap)) - for _, group := range userGroupMap { - userGroups = append(userGroups, group) } result := &TailscaleEndpointStatus{ EndpointTag: tag, @@ -1425,6 +1406,65 @@ func tailscalePeerToProto(peer *adapter.TailscalePeer) *TailscalePeer { } } +func (s *StartedService) StartTailscalePing( + request *TailscalePingRequest, + server grpc.ServerStreamingServer[TailscalePingResponse], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + if endpointManager == nil { + return status.Error(codes.FailedPrecondition, "endpoint manager not available") + } + + var provider adapter.TailscaleEndpoint + if request.EndpointTag != "" { + endpoint, loaded := endpointManager.Get(request.EndpointTag) + if !loaded { + return status.Error(codes.NotFound, "endpoint not found: "+request.EndpointTag) + } + if endpoint.Type() != C.TypeTailscale { + return status.Error(codes.InvalidArgument, "endpoint is not Tailscale: "+request.EndpointTag) + } + pingProvider, loaded := endpoint.(adapter.TailscaleEndpoint) + if !loaded { + return status.Error(codes.FailedPrecondition, "endpoint does not support ping") + } + provider = pingProvider + } else { + for _, endpoint := range endpointManager.Endpoints() { + if endpoint.Type() != C.TypeTailscale { + continue + } + pingProvider, loaded := endpoint.(adapter.TailscaleEndpoint) + if loaded { + provider = pingProvider + break + } + } + if provider == nil { + return status.Error(codes.NotFound, "no Tailscale endpoint found") + } + } + + return provider.StartTailscalePing(server.Context(), request.PeerIP, func(result *adapter.TailscalePingResult) { + _ = server.Send(&TailscalePingResponse{ + LatencyMs: result.LatencyMs, + IsDirect: result.IsDirect, + Endpoint: result.Endpoint, + DerpRegionID: result.DERPRegionID, + DerpRegionCode: result.DERPRegionCode, + Error: result.Error, + }) + }) +} + func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() { } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 402b01013..289069608 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -2584,6 +2584,142 @@ func (x *TailscalePeer) GetKeyExpiry() int64 { return 0 } +type TailscalePingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + PeerIP string `protobuf:"bytes,2,opt,name=peerIP,proto3" json:"peerIP,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscalePingRequest) Reset() { + *x = TailscalePingRequest{} + mi := &file_daemon_started_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscalePingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscalePingRequest) ProtoMessage() {} + +func (x *TailscalePingRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscalePingRequest.ProtoReflect.Descriptor instead. +func (*TailscalePingRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{35} +} + +func (x *TailscalePingRequest) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *TailscalePingRequest) GetPeerIP() string { + if x != nil { + return x.PeerIP + } + return "" +} + +type TailscalePingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + LatencyMs float64 `protobuf:"fixed64,1,opt,name=latencyMs,proto3" json:"latencyMs,omitempty"` + IsDirect bool `protobuf:"varint,2,opt,name=isDirect,proto3" json:"isDirect,omitempty"` + Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + DerpRegionID int32 `protobuf:"varint,4,opt,name=derpRegionID,proto3" json:"derpRegionID,omitempty"` + DerpRegionCode string `protobuf:"bytes,5,opt,name=derpRegionCode,proto3" json:"derpRegionCode,omitempty"` + Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TailscalePingResponse) Reset() { + *x = TailscalePingResponse{} + mi := &file_daemon_started_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TailscalePingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TailscalePingResponse) ProtoMessage() {} + +func (x *TailscalePingResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TailscalePingResponse.ProtoReflect.Descriptor instead. +func (*TailscalePingResponse) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{36} +} + +func (x *TailscalePingResponse) GetLatencyMs() float64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +func (x *TailscalePingResponse) GetIsDirect() bool { + if x != nil { + return x.IsDirect + } + return false +} + +func (x *TailscalePingResponse) GetEndpoint() string { + if x != nil { + return x.Endpoint + } + return "" +} + +func (x *TailscalePingResponse) GetDerpRegionID() int32 { + if x != nil { + return x.DerpRegionID + } + return 0 +} + +func (x *TailscalePingResponse) GetDerpRegionCode() string { + if x != nil { + return x.DerpRegionCode + } + return "" +} + +func (x *TailscalePingResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + type Log_Message struct { state protoimpl.MessageState `protogen:"open.v1"` Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` @@ -2594,7 +2730,7 @@ type Log_Message struct { func (x *Log_Message) Reset() { *x = Log_Message{} - mi := &file_daemon_started_service_proto_msgTypes[35] + mi := &file_daemon_started_service_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2606,7 +2742,7 @@ func (x *Log_Message) String() string { func (*Log_Message) ProtoMessage() {} func (x *Log_Message) ProtoReflect() protoreflect.Message { - mi := &file_daemon_started_service_proto_msgTypes[35] + mi := &file_daemon_started_service_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2839,7 +2975,17 @@ const file_daemon_started_service_proto_rawDesc = "" + "\arxBytes\x18\t \x01(\x03R\arxBytes\x12\x18\n" + "\atxBytes\x18\n" + " \x01(\x03R\atxBytes\x12\x1c\n" + - "\tkeyExpiry\x18\v \x01(\x03R\tkeyExpiry*U\n" + + "\tkeyExpiry\x18\v \x01(\x03R\tkeyExpiry\"P\n" + + "\x14TailscalePingRequest\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x16\n" + + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\"\xcf\x01\n" + + "\x15TailscalePingResponse\x12\x1c\n" + + "\tlatencyMs\x18\x01 \x01(\x01R\tlatencyMs\x12\x1a\n" + + "\bisDirect\x18\x02 \x01(\bR\bisDirect\x12\x1a\n" + + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\"\n" + + "\fderpRegionID\x18\x04 \x01(\x05R\fderpRegionID\x12&\n" + + "\x0ederpRegionCode\x18\x05 \x01(\tR\x0ederpRegionCode\x12\x14\n" + + "\x05error\x18\x06 \x01(\tR\x05error*U\n" + "\bLogLevel\x12\t\n" + "\x05PANIC\x10\x00\x12\t\n" + "\x05FATAL\x10\x01\x12\t\n" + @@ -2851,7 +2997,7 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x13ConnectionEventType\x12\x18\n" + "\x14CONNECTION_EVENT_NEW\x10\x00\x12\x1b\n" + "\x17CONNECTION_EVENT_UPDATE\x10\x01\x12\x1b\n" + - "\x17CONNECTION_EVENT_CLOSED\x10\x022\x83\x10\n" + + "\x17CONNECTION_EVENT_CLOSED\x10\x022\x99\x10\n" + "\x0eStartedService\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" + @@ -2875,12 +3021,12 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x0fCloseConnection\x12\x1e.daemon.CloseConnectionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + "\x13CloseAllConnections\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12M\n" + "\x15GetDeprecatedWarnings\x12\x16.google.protobuf.Empty\x1a\x1a.daemon.DeprecatedWarnings\"\x00\x12;\n" + - "\fGetStartedAt\x12\x16.google.protobuf.Empty\x1a\x11.daemon.StartedAt\"\x00\x12?\n" + - "\rListOutbounds\x12\x16.google.protobuf.Empty\x1a\x14.daemon.OutboundList\"\x00\x12F\n" + + "\fGetStartedAt\x12\x16.google.protobuf.Empty\x1a\x11.daemon.StartedAt\"\x00\x12F\n" + "\x12SubscribeOutbounds\x12\x16.google.protobuf.Empty\x1a\x14.daemon.OutboundList\"\x000\x01\x12d\n" + "\x17StartNetworkQualityTest\x12!.daemon.NetworkQualityTestRequest\x1a\".daemon.NetworkQualityTestProgress\"\x000\x01\x12F\n" + "\rStartSTUNTest\x12\x17.daemon.STUNTestRequest\x1a\x18.daemon.STUNTestProgress\"\x000\x01\x12U\n" + - "\x18SubscribeTailscaleStatus\x12\x16.google.protobuf.Empty\x1a\x1d.daemon.TailscaleStatusUpdate\"\x000\x01B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" + "\x18SubscribeTailscaleStatus\x12\x16.google.protobuf.Empty\x1a\x1d.daemon.TailscaleStatusUpdate\"\x000\x01\x12U\n" + + "\x12StartTailscalePing\x12\x1c.daemon.TailscalePingRequest\x1a\x1d.daemon.TailscalePingResponse\"\x000\x01B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" var ( file_daemon_started_service_proto_rawDescOnce sync.Once @@ -2896,7 +3042,7 @@ func file_daemon_started_service_proto_rawDescGZIP() []byte { var ( file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 4) - file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 36) + file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 38) file_daemon_started_service_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ConnectionEventType)(0), // 1: daemon.ConnectionEventType @@ -2937,14 +3083,16 @@ var ( (*TailscaleEndpointStatus)(nil), // 36: daemon.TailscaleEndpointStatus (*TailscaleUserGroup)(nil), // 37: daemon.TailscaleUserGroup (*TailscalePeer)(nil), // 38: daemon.TailscalePeer - (*Log_Message)(nil), // 39: daemon.Log.Message - (*emptypb.Empty)(nil), // 40: google.protobuf.Empty + (*TailscalePingRequest)(nil), // 39: daemon.TailscalePingRequest + (*TailscalePingResponse)(nil), // 40: daemon.TailscalePingResponse + (*Log_Message)(nil), // 41: daemon.Log.Message + (*emptypb.Empty)(nil), // 42: google.protobuf.Empty } ) var file_daemon_started_service_proto_depIdxs = []int32{ 2, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type - 39, // 1: daemon.Log.messages:type_name -> daemon.Log.Message + 41, // 1: daemon.Log.messages:type_name -> daemon.Log.Message 0, // 2: daemon.DefaultLogLevel.level:type_name -> daemon.LogLevel 11, // 3: daemon.Groups.group:type_name -> daemon.Group 12, // 4: daemon.Group.items:type_name -> daemon.GroupItem @@ -2960,62 +3108,62 @@ var file_daemon_started_service_proto_depIdxs = []int32{ 37, // 14: daemon.TailscaleEndpointStatus.userGroups:type_name -> daemon.TailscaleUserGroup 38, // 15: daemon.TailscaleUserGroup.peers:type_name -> daemon.TailscalePeer 0, // 16: daemon.Log.Message.level:type_name -> daemon.LogLevel - 40, // 17: daemon.StartedService.StopService:input_type -> google.protobuf.Empty - 40, // 18: daemon.StartedService.ReloadService:input_type -> google.protobuf.Empty - 40, // 19: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty - 40, // 20: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty - 40, // 21: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty - 40, // 22: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty + 42, // 17: daemon.StartedService.StopService:input_type -> google.protobuf.Empty + 42, // 18: daemon.StartedService.ReloadService:input_type -> google.protobuf.Empty + 42, // 19: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty + 42, // 20: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty + 42, // 21: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty + 42, // 22: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty 6, // 23: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest - 40, // 24: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty - 40, // 25: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty - 40, // 26: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty + 42, // 24: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty + 42, // 25: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty + 42, // 26: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty 16, // 27: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode 13, // 28: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest 14, // 29: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest 15, // 30: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest - 40, // 31: daemon.StartedService.GetSystemProxyStatus:input_type -> google.protobuf.Empty + 42, // 31: daemon.StartedService.GetSystemProxyStatus:input_type -> google.protobuf.Empty 19, // 32: daemon.StartedService.SetSystemProxyEnabled:input_type -> daemon.SetSystemProxyEnabledRequest 20, // 33: daemon.StartedService.TriggerDebugCrash:input_type -> daemon.DebugCrashRequest - 40, // 34: daemon.StartedService.TriggerOOMReport:input_type -> google.protobuf.Empty + 42, // 34: daemon.StartedService.TriggerOOMReport:input_type -> google.protobuf.Empty 21, // 35: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest 26, // 36: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest - 40, // 37: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty - 40, // 38: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty - 40, // 39: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty - 40, // 40: daemon.StartedService.ListOutbounds:input_type -> google.protobuf.Empty - 40, // 41: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty - 31, // 42: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest - 33, // 43: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest - 40, // 44: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty - 40, // 45: daemon.StartedService.StopService:output_type -> google.protobuf.Empty - 40, // 46: daemon.StartedService.ReloadService:output_type -> google.protobuf.Empty + 42, // 37: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty + 42, // 38: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty + 42, // 39: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty + 42, // 40: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty + 31, // 41: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest + 33, // 42: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest + 42, // 43: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty + 39, // 44: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest + 42, // 45: daemon.StartedService.StopService:output_type -> google.protobuf.Empty + 42, // 46: daemon.StartedService.ReloadService:output_type -> google.protobuf.Empty 4, // 47: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus 7, // 48: daemon.StartedService.SubscribeLog:output_type -> daemon.Log 8, // 49: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel - 40, // 50: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty + 42, // 50: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty 9, // 51: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status 10, // 52: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups 17, // 53: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus 16, // 54: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode - 40, // 55: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty - 40, // 56: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty - 40, // 57: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty - 40, // 58: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty + 42, // 55: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty + 42, // 56: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty + 42, // 57: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty + 42, // 58: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty 18, // 59: daemon.StartedService.GetSystemProxyStatus:output_type -> daemon.SystemProxyStatus - 40, // 60: daemon.StartedService.SetSystemProxyEnabled:output_type -> google.protobuf.Empty - 40, // 61: daemon.StartedService.TriggerDebugCrash:output_type -> google.protobuf.Empty - 40, // 62: daemon.StartedService.TriggerOOMReport:output_type -> google.protobuf.Empty + 42, // 60: daemon.StartedService.SetSystemProxyEnabled:output_type -> google.protobuf.Empty + 42, // 61: daemon.StartedService.TriggerDebugCrash:output_type -> google.protobuf.Empty + 42, // 62: daemon.StartedService.TriggerOOMReport:output_type -> google.protobuf.Empty 23, // 63: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents - 40, // 64: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty - 40, // 65: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty + 42, // 64: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty + 42, // 65: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty 27, // 66: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings 29, // 67: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt - 30, // 68: daemon.StartedService.ListOutbounds:output_type -> daemon.OutboundList - 30, // 69: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList - 32, // 70: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 34, // 71: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress - 35, // 72: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 30, // 68: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList + 32, // 69: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 34, // 70: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress + 35, // 71: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 40, // 72: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse 45, // [45:73] is the sub-list for method output_type 17, // [17:45] is the sub-list for method input_type 17, // [17:17] is the sub-list for extension type_name @@ -3034,7 +3182,7 @@ func file_daemon_started_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc)), NumEnums: 4, - NumMessages: 36, + NumMessages: 38, NumExtensions: 0, NumServices: 1, }, diff --git a/daemon/started_service.proto b/daemon/started_service.proto index f2531f08d..2c3140a91 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -35,11 +35,11 @@ service StartedService { rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {} rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {} - rpc ListOutbounds(google.protobuf.Empty) returns (OutboundList) {} rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {} rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {} rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {} rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {} + rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {} } message ServiceStatus { @@ -315,3 +315,17 @@ message TailscalePeer { int64 txBytes = 10; int64 keyExpiry = 11; } + +message TailscalePingRequest { + string endpointTag = 1; + string peerIP = 2; +} + +message TailscalePingResponse { + double latencyMs = 1; + bool isDirect = 2; + string endpoint = 3; + int32 derpRegionID = 4; + string derpRegionCode = 5; + string error = 6; +} diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go index af8024035..967757f1a 100644 --- a/daemon/started_service_grpc.pb.go +++ b/daemon/started_service_grpc.pb.go @@ -38,11 +38,11 @@ const ( StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" - StartedService_ListOutbounds_FullMethodName = "/daemon.StartedService/ListOutbounds" 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" ) // StartedServiceClient is the client API for StartedService service. @@ -72,11 +72,11 @@ type StartedServiceClient interface { CloseAllConnections(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) GetDeprecatedWarnings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DeprecatedWarnings, error) GetStartedAt(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StartedAt, error) - ListOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OutboundList, error) SubscribeOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutboundList], error) StartNetworkQualityTest(ctx context.Context, in *NetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[NetworkQualityTestProgress], error) StartSTUNTest(ctx context.Context, in *STUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[STUNTestProgress], error) SubscribeTailscaleStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscaleStatusUpdate], error) + StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error) } type startedServiceClient struct { @@ -371,16 +371,6 @@ func (c *startedServiceClient) GetStartedAt(ctx context.Context, in *emptypb.Emp return out, nil } -func (c *startedServiceClient) ListOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OutboundList, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(OutboundList) - err := c.cc.Invoke(ctx, StartedService_ListOutbounds_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - func (c *startedServiceClient) SubscribeOutbounds(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutboundList], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[6], StartedService_SubscribeOutbounds_FullMethodName, cOpts...) @@ -457,6 +447,25 @@ func (c *startedServiceClient) SubscribeTailscaleStatus(ctx context.Context, in // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeTailscaleStatusClient = grpc.ServerStreamingClient[TailscaleStatusUpdate] +func (c *startedServiceClient) StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[10], StartedService_StartTailscalePing_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[TailscalePingRequest, TailscalePingResponse]{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_StartTailscalePingClient = grpc.ServerStreamingClient[TailscalePingResponse] + // StartedServiceServer is the server API for StartedService service. // All implementations must embed UnimplementedStartedServiceServer // for forward compatibility. @@ -484,11 +493,11 @@ type StartedServiceServer interface { CloseAllConnections(context.Context, *emptypb.Empty) (*emptypb.Empty, error) GetDeprecatedWarnings(context.Context, *emptypb.Empty) (*DeprecatedWarnings, error) GetStartedAt(context.Context, *emptypb.Empty) (*StartedAt, error) - ListOutbounds(context.Context, *emptypb.Empty) (*OutboundList, error) SubscribeOutbounds(*emptypb.Empty, grpc.ServerStreamingServer[OutboundList]) error StartNetworkQualityTest(*NetworkQualityTestRequest, grpc.ServerStreamingServer[NetworkQualityTestProgress]) error StartSTUNTest(*STUNTestRequest, grpc.ServerStreamingServer[STUNTestProgress]) error SubscribeTailscaleStatus(*emptypb.Empty, grpc.ServerStreamingServer[TailscaleStatusUpdate]) error + StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error mustEmbedUnimplementedStartedServiceServer() } @@ -591,10 +600,6 @@ func (UnimplementedStartedServiceServer) GetStartedAt(context.Context, *emptypb. return nil, status.Error(codes.Unimplemented, "method GetStartedAt not implemented") } -func (UnimplementedStartedServiceServer) ListOutbounds(context.Context, *emptypb.Empty) (*OutboundList, error) { - return nil, status.Error(codes.Unimplemented, "method ListOutbounds not implemented") -} - func (UnimplementedStartedServiceServer) SubscribeOutbounds(*emptypb.Empty, grpc.ServerStreamingServer[OutboundList]) error { return status.Error(codes.Unimplemented, "method SubscribeOutbounds not implemented") } @@ -610,6 +615,10 @@ func (UnimplementedStartedServiceServer) StartSTUNTest(*STUNTestRequest, grpc.Se func (UnimplementedStartedServiceServer) SubscribeTailscaleStatus(*emptypb.Empty, grpc.ServerStreamingServer[TailscaleStatusUpdate]) error { return status.Error(codes.Unimplemented, "method SubscribeTailscaleStatus not implemented") } + +func (UnimplementedStartedServiceServer) StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error { + return status.Error(codes.Unimplemented, "method StartTailscalePing not implemented") +} func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {} func (UnimplementedStartedServiceServer) testEmbeddedByValue() {} @@ -1003,24 +1012,6 @@ func _StartedService_GetStartedAt_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } -func _StartedService_ListOutbounds_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).ListOutbounds(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: StartedService_ListOutbounds_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(StartedServiceServer).ListOutbounds(ctx, req.(*emptypb.Empty)) - } - return interceptor(ctx, in, info, handler) -} - func _StartedService_SubscribeOutbounds_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(emptypb.Empty) if err := stream.RecvMsg(m); err != nil { @@ -1065,6 +1056,17 @@ func _StartedService_SubscribeTailscaleStatus_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_SubscribeTailscaleStatusServer = grpc.ServerStreamingServer[TailscaleStatusUpdate] +func _StartedService_StartTailscalePing_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(TailscalePingRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).StartTailscalePing(m, &grpc.GenericServerStream[TailscalePingRequest, TailscalePingResponse]{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_StartTailscalePingServer = grpc.ServerStreamingServer[TailscalePingResponse] + // 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) @@ -1140,10 +1142,6 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetStartedAt", Handler: _StartedService_GetStartedAt_Handler, }, - { - MethodName: "ListOutbounds", - Handler: _StartedService_ListOutbounds_Handler, - }, }, Streams: []grpc.StreamDesc{ { @@ -1196,6 +1194,11 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ Handler: _StartedService_SubscribeTailscaleStatus_Handler, ServerStreams: true, }, + { + StreamName: "StartTailscalePing", + Handler: _StartedService_StartTailscalePing_Handler, + ServerStreams: true, + }, }, Metadata: "daemon/started_service.proto", } diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index cc908b847..5223bf7e0 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -14,8 +14,10 @@ import ( E "github.com/sagernet/sing/common/exceptions" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" ) @@ -626,16 +628,6 @@ func (c *CommandClient) SetGroupExpand(groupTag string, isExpand bool) error { return err } -func (c *CommandClient) ListOutbounds() (OutboundGroupItemIterator, error) { - return callWithResult(c, func(client daemon.StartedServiceClient) (OutboundGroupItemIterator, error) { - list, err := client.ListOutbounds(context.Background(), &emptypb.Empty{}) - if err != nil { - return nil, err - } - return outboundGroupItemListFromGRPC(list), nil - }) -} - func (c *CommandClient) StartNetworkQualityTest(configURL string, outboundTag string, serial bool, maxRuntimeSeconds int32, http3 bool, handler NetworkQualityTestHandler) error { client, err := c.getClientForCall() if err != nil { @@ -736,9 +728,37 @@ func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) for { event, recvErr := stream.Recv() if recvErr != nil { + if status.Code(recvErr) == codes.NotFound { + return nil + } handler.OnError(recvErr.Error()) return recvErr } handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(event)) } } + +func (c *CommandClient) StartTailscalePing(endpointTag string, peerIP string, handler TailscalePingHandler) error { + client, err := c.getClientForCall() + if err != nil { + return err + } + if c.standalone { + defer c.closeConnection() + } + stream, err := client.StartTailscalePing(context.Background(), &daemon.TailscalePingRequest{ + EndpointTag: endpointTag, + PeerIP: peerIP, + }) + if err != nil { + return err + } + for { + event, recvErr := stream.Recv() + if recvErr != nil { + handler.OnError(recvErr.Error()) + return recvErr + } + handler.OnPingResult(tailscalePingResultFromGRPC(event)) + } +} diff --git a/experimental/libbox/command_types_tailscale_ping.go b/experimental/libbox/command_types_tailscale_ping.go new file mode 100644 index 000000000..666789d00 --- /dev/null +++ b/experimental/libbox/command_types_tailscale_ping.go @@ -0,0 +1,28 @@ +package libbox + +import "github.com/sagernet/sing-box/daemon" + +type TailscalePingResult struct { + LatencyMs float64 + IsDirect bool + Endpoint string + DERPRegionID int32 + DERPRegionCode string + Error string +} + +type TailscalePingHandler interface { + OnPingResult(result *TailscalePingResult) + OnError(message string) +} + +func tailscalePingResultFromGRPC(response *daemon.TailscalePingResponse) *TailscalePingResult { + return &TailscalePingResult{ + LatencyMs: response.LatencyMs, + IsDirect: response.IsDirect, + Endpoint: response.Endpoint, + DERPRegionID: response.DerpRegionID, + DERPRegionCode: response.DerpRegionCode, + Error: response.Error, + } +} diff --git a/protocol/tailscale/hostinfo_tvos.go b/protocol/tailscale/hostinfo_tvos.go new file mode 100644 index 000000000..d8e391bb5 --- /dev/null +++ b/protocol/tailscale/hostinfo_tvos.go @@ -0,0 +1,16 @@ +//go:build with_gvisor && tvos + +package tailscale + +import ( + _ "unsafe" + + "github.com/sagernet/tailscale/types/lazy" +) + +//go:linkname isAppleTV github.com/sagernet/tailscale/version.isAppleTV +var isAppleTV lazy.SyncValue[bool] + +func init() { + isAppleTV.Set(true) +} diff --git a/protocol/tailscale/ping.go b/protocol/tailscale/ping.go new file mode 100644 index 000000000..8bb0476b2 --- /dev/null +++ b/protocol/tailscale/ping.go @@ -0,0 +1,55 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "net/netip" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/tailscale/ipn/ipnstate" + "github.com/sagernet/tailscale/tailcfg" +) + +func (t *Endpoint) StartTailscalePing(ctx context.Context, peerIP string, fn func(*adapter.TailscalePingResult)) error { + ip, err := netip.ParseAddr(peerIP) + if err != nil { + return err + } + localClient, err := t.server.LocalClient() + if err != nil { + return err + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + result, pingErr := localClient.Ping(ctx, ip, tailcfg.PingDisco) + if ctx.Err() != nil { + return ctx.Err() + } + if pingErr != nil { + fn(&adapter.TailscalePingResult{ + Error: pingErr.Error(), + }) + } else { + fn(convertPingResult(result)) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func convertPingResult(result *ipnstate.PingResult) *adapter.TailscalePingResult { + return &adapter.TailscalePingResult{ + LatencyMs: result.LatencySeconds * 1000, + IsDirect: result.Endpoint != "", + Endpoint: result.Endpoint, + DERPRegionID: int32(result.DERPRegionID), + DERPRegionCode: result.DERPRegionCode, + Error: result.Err, + } +} diff --git a/protocol/tailscale/status.go b/protocol/tailscale/status.go index af6ce1039..a4d14ee14 100644 --- a/protocol/tailscale/status.go +++ b/protocol/tailscale/status.go @@ -4,14 +4,14 @@ package tailscale import ( "context" + "slices" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/tailscale/ipn" "github.com/sagernet/tailscale/ipn/ipnstate" - "github.com/sagernet/tailscale/tailcfg" ) -var _ adapter.TailscaleStatusProvider = (*Endpoint)(nil) +var _ adapter.TailscaleEndpoint = (*Endpoint)(nil) func (t *Endpoint) SubscribeTailscaleStatus(ctx context.Context, fn func(*adapter.TailscaleEndpointStatus)) error { localBackend := t.server.ExportLocalBackend() @@ -46,13 +46,35 @@ func convertTailscaleStatus(status *ipnstate.Status) *adapter.TailscaleEndpointS if status.Self != nil { result.Self = convertTailscalePeer(status.Self) } - result.Users = make(map[int64]*adapter.TailscaleUser, len(status.User)) - for userID, profile := range status.User { - result.Users[int64(userID)] = convertTailscaleUser(userID, profile) + groupIndex := make(map[int64]*adapter.TailscaleUserGroup) + for _, peerKey := range status.Peers() { + peer := status.Peer[peerKey] + userID := int64(peer.UserID) + group, loaded := groupIndex[userID] + if !loaded { + group = &adapter.TailscaleUserGroup{ + UserID: userID, + } + if profile, hasProfile := status.User[peer.UserID]; hasProfile { + group.LoginName = profile.LoginName + group.DisplayName = profile.DisplayName + group.ProfilePicURL = profile.ProfilePicURL + } + groupIndex[userID] = group + result.UserGroups = append(result.UserGroups, group) + } + group.Peers = append(group.Peers, convertTailscalePeer(peer)) } - result.Peers = make([]*adapter.TailscalePeer, 0, len(status.Peer)) - for _, peer := range status.Peer { - result.Peers = append(result.Peers, convertTailscalePeer(peer)) + for _, group := range result.UserGroups { + slices.SortStableFunc(group.Peers, func(a, b *adapter.TailscalePeer) int { + if a.Online != b.Online { + if a.Online { + return -1 + } + return 1 + } + return 0 + }) } return result } @@ -81,12 +103,3 @@ func convertTailscalePeer(peer *ipnstate.PeerStatus) *adapter.TailscalePeer { KeyExpiry: keyExpiry, } } - -func convertTailscaleUser(id tailcfg.UserID, profile tailcfg.UserProfile) *adapter.TailscaleUser { - return &adapter.TailscaleUser{ - ID: int64(id), - LoginName: profile.LoginName, - DisplayName: profile.DisplayName, - ProfilePicURL: profile.ProfilePicURL, - } -}