From 82098a8760e4073599539896ab251ac29c817ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 23 Aug 2026 17:45:43 +0800 Subject: [PATCH] Add power report service --- common/dialer/default.go | 82 ++- dns/router.go | 9 + experimental/boxdd/cmd_run.go | 1 + experimental/boxdd/desktop_service.go | 1 + experimental/boxdd/desktop_service.pb.go | 135 +++-- experimental/boxdd/desktop_service.proto | 7 + experimental/boxdd/desktop_service_grpc.pb.go | 234 +++++++++ experimental/boxdd/power_report.go | 184 +++++++ experimental/boxdd/server.go | 26 +- experimental/boxdd/snapshot.go | 1 + experimental/libbox/command_server.go | 28 +- experimental/libbox/monitor.go | 19 + experimental/libbox/oom_report.go | 14 +- experimental/libbox/power_report.go | 35 ++ experimental/libbox/service.go | 2 + experimental/libbox/setup.go | 3 + go.mod | 8 +- go.sum | 16 +- protocol/wireguard/endpoint.go | 1 + service/powerreport/manager.go | 44 ++ service/powerreport/promote.go | 48 ++ service/powerreport/record.go | 94 ++++ service/powerreport/recorder.go | 472 ++++++++++++++++++ service/powerreport/syscall_windows.go | 21 + service/powerreport/sysstats_darwin.go | 123 +++++ service/powerreport/sysstats_darwin_stub.go | 15 + service/powerreport/sysstats_linux.go | 92 ++++ service/powerreport/sysstats_stub.go | 15 + service/powerreport/sysstats_windows.go | 46 ++ service/powerreport/zsyscall_windows.go | 68 +++ transport/wireguard/endpoint.go | 13 + transport/wireguard/endpoint_options.go | 1 + 32 files changed, 1772 insertions(+), 86 deletions(-) create mode 100644 experimental/boxdd/power_report.go create mode 100644 experimental/libbox/power_report.go create mode 100644 service/powerreport/manager.go create mode 100644 service/powerreport/promote.go create mode 100644 service/powerreport/record.go create mode 100644 service/powerreport/recorder.go create mode 100644 service/powerreport/syscall_windows.go create mode 100644 service/powerreport/sysstats_darwin.go create mode 100644 service/powerreport/sysstats_darwin_stub.go create mode 100644 service/powerreport/sysstats_linux.go create mode 100644 service/powerreport/sysstats_stub.go create mode 100644 service/powerreport/sysstats_windows.go create mode 100644 service/powerreport/zsyscall_windows.go diff --git a/common/dialer/default.go b/common/dialer/default.go index 53c04b3d..dbe869bd 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -12,7 +12,9 @@ import ( "github.com/sagernet/sing-box/common/listener" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" @@ -39,6 +41,7 @@ type DefaultDialer struct { autoDetectBindFunc control.Func connectionManager adapter.ConnectionManager networkManager adapter.NetworkManager + powerManager *powerreport.Manager networkStrategy *C.NetworkStrategy defaultNetworkStrategy bool networkType []C.InterfaceType @@ -231,6 +234,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial autoDetectBindFunc: autoDetectBindFunc, connectionManager: connectionManager, networkManager: networkManager, + powerManager: service.FromContext[*powerreport.Manager](ctx), networkStrategy: networkStrategy, defaultNetworkStrategy: defaultNetworkStrategy, networkType: networkType, @@ -262,7 +266,7 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address return nil, E.New("domain not resolved") } if d.networkStrategy == nil { - return d.trackConn(listener.ListenNetworkNamespace[net.Conn](ctx, d.netns, func() (net.Conn, error) { + conn, err := listener.ListenNetworkNamespace[net.Conn](ctx, d.netns, func() (net.Conn, error) { switch N.NetworkName(network) { case N.NetworkUDP: if !address.IsIPv6() { @@ -276,7 +280,8 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address } else { return DialSlowContext(&d.dialer6, ctx, network, address) } - })) + }) + return d.trackConn(ctx, address, conn, err) } else { return d.DialParallelInterface(ctx, network, address, d.networkStrategy, d.networkType, d.fallbackNetworkType, d.networkFallbackDelay) } @@ -327,12 +332,12 @@ func (d *DefaultDialer) DialParallelInterface(ctx context.Context, network strin if !fastFallback && !isPrimary { d.networkLastFallback.Store(time.Now()) } - return d.trackConn(conn, nil) + return d.trackConn(ctx, address, conn, nil) } func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { if d.networkStrategy == nil { - return d.trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](ctx, d.netns, func() (net.PacketConn, error) { + packetConn, err := listener.ListenNetworkNamespace[net.PacketConn](ctx, d.netns, func() (net.PacketConn, error) { listenConfig := d.udpListener if d.autoDetectBindFunc != nil { listenConfig.Control = control.Append(listenConfig.Control, func(network, address string, conn syscall.RawConn) error { @@ -349,7 +354,8 @@ func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksadd } else { return listenConfig.ListenPacket(ctx, N.NetworkUDP, d.udpAddr4) } - })) + }) + return d.trackPacketConn(ctx, destination, packetConn, err) } else { return d.ListenSerialInterfacePacket(ctx, destination, d.networkStrategy, d.networkType, d.fallbackNetworkType, d.networkFallbackDelay) } @@ -393,7 +399,7 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina return nil, err } } - return d.trackPacketConn(packetConn, nil) + return d.trackPacketConn(ctx, destination, packetConn, nil) } func (d *DefaultDialer) UDPListenerControl() (control.Func, bool) { @@ -405,16 +411,68 @@ func (d *DefaultDialer) UDPListenerControl() (control.Func, bool) { return listenerControl, egressEnabled } -func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) { - if d.connectionManager == nil || err != nil { +func (d *DefaultDialer) trackConn(ctx context.Context, destination M.Socksaddr, conn net.Conn, err error) (net.Conn, error) { + if err != nil { return conn, err } - return d.connectionManager.TrackConn(conn), nil + if d.connectionManager != nil { + conn = d.connectionManager.TrackConn(conn) + } + if d.powerManager != nil { + recorder := d.powerManager.Recorder() + if recorder != nil { + recorder.CountConnectionOpened() + attribution := dialAttribution(ctx, destination) + conn = bufio.NewCounterConn(conn, []N.CountFunc{func(n int64) { + recorder.Touch(powerreport.DirectionInbound, int(n), attribution) + }}, []N.CountFunc{func(n int64) { + recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) + }}) + } + } + return conn, nil } -func (d *DefaultDialer) trackPacketConn(conn net.PacketConn, err error) (net.PacketConn, error) { - if d.connectionManager == nil || err != nil { +func (d *DefaultDialer) trackPacketConn(ctx context.Context, destination M.Socksaddr, conn net.PacketConn, err error) (net.PacketConn, error) { + if err != nil { return conn, err } - return d.connectionManager.TrackPacketConn(conn), nil + if d.connectionManager != nil { + conn = d.connectionManager.TrackPacketConn(conn) + } + if d.powerManager != nil { + recorder := d.powerManager.Recorder() + if recorder != nil { + recorder.CountConnectionOpened() + attribution := dialAttribution(ctx, destination) + conn = bufio.NewNetPacketConn(bufio.NewCounterPacketConn(bufio.NewPacketConn(conn), []N.CountFunc{func(n int64) { + recorder.Touch(powerreport.DirectionInbound, int(n), attribution) + }}, []N.CountFunc{func(n int64) { + recorder.Touch(powerreport.DirectionOutbound, int(n), attribution) + }})) + } + } + return conn, nil +} + +func dialAttribution(ctx context.Context, destination M.Socksaddr) *powerreport.Attribution { + metadata := adapter.ContextFrom(ctx) + if metadata == nil { + return &powerreport.Attribution{Destination: destination.String()} + } + attribution := &powerreport.Attribution{ + Domain: metadata.Domain, + Outbound: metadata.Outbound, + } + if metadata.Inbound != "" { + attribution.Inbound = metadata.InboundType + "/" + metadata.Inbound + } else { + attribution.Inbound = metadata.InboundType + } + if metadata.Destination.IsValid() { + attribution.Destination = metadata.Destination.String() + } else { + attribution.Destination = destination.String() + } + return attribution } diff --git a/dns/router.go b/dns/router.go index af37ca29..46355c3f 100644 --- a/dns/router.go +++ b/dns/router.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -39,6 +40,7 @@ type Router struct { logger logger.ContextLogger transport adapter.DNSTransportManager outbound adapter.OutboundManager + powerManager *powerreport.Manager client adapter.DNSClient rawRules []option.DNSRule rules []adapter.DNSRule @@ -57,6 +59,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp logger: logFactory.NewLogger("dns"), transport: service.FromContext[adapter.DNSTransportManager](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), + powerManager: service.FromContext[*powerreport.Manager](ctx), rawRules: make([]option.DNSRule, 0, len(options.Rules)), rules: make([]adapter.DNSRule, 0, len(options.Rules)), defaultDomainStrategy: C.DomainStrategy(options.Strategy), @@ -1053,6 +1056,12 @@ type dnsExchangeContext struct { } func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsExchangeContext, *mDNS.Msg, error) { + if r.powerManager != nil { + recorder := r.powerManager.Recorder() + if recorder != nil { + recorder.CountDNSQuery() + } + } if len(message.Question) != 1 { r.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) return nil, &mDNS.Msg{ diff --git a/experimental/boxdd/cmd_run.go b/experimental/boxdd/cmd_run.go index b054dc1f..ea58b3b0 100644 --- a/experimental/boxdd/cmd_run.go +++ b/experimental/boxdd/cmd_run.go @@ -65,6 +65,7 @@ func prepareWorkingDirectory() error { return err } libbox.PromoteOOMDraft() + libbox.PromotePowerReportDraft() return nil } diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go index 24759838..e9422fec 100644 --- a/experimental/boxdd/desktop_service.go +++ b/experimental/boxdd/desktop_service.go @@ -87,6 +87,7 @@ func (s *desktopService) StartService(ctx context.Context, request *StartService mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit + mergedOptions.PowerReportEnabled = request.Options.PowerReportEnabled } err = s.daemon.startServiceLocked(ctx, identity.UserID, request.ConfigContent, mergedOptions) if err != nil { diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go index bc7a49d8..a9d71975 100644 --- a/experimental/boxdd/desktop_service.pb.go +++ b/experimental/boxdd/desktop_service.pb.go @@ -449,12 +449,13 @@ func (x *StartServiceRequest) GetOptions() *StartOptions { } type StartOptions struct { - state protoimpl.MessageState `protogen:"open.v1"` - OomKillerEnabled bool `protobuf:"varint,1,opt,name=oom_killer_enabled,json=oomKillerEnabled,proto3" json:"oom_killer_enabled,omitempty"` - OomKillerDisabled bool `protobuf:"varint,2,opt,name=oom_killer_disabled,json=oomKillerDisabled,proto3" json:"oom_killer_disabled,omitempty"` - OomMemoryLimit int64 `protobuf:"varint,3,opt,name=oom_memory_limit,json=oomMemoryLimit,proto3" json:"oom_memory_limit,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + OomKillerEnabled bool `protobuf:"varint,1,opt,name=oom_killer_enabled,json=oomKillerEnabled,proto3" json:"oom_killer_enabled,omitempty"` + OomKillerDisabled bool `protobuf:"varint,2,opt,name=oom_killer_disabled,json=oomKillerDisabled,proto3" json:"oom_killer_disabled,omitempty"` + OomMemoryLimit int64 `protobuf:"varint,3,opt,name=oom_memory_limit,json=oomMemoryLimit,proto3" json:"oom_memory_limit,omitempty"` + PowerReportEnabled bool `protobuf:"varint,4,opt,name=power_report_enabled,json=powerReportEnabled,proto3" json:"power_report_enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartOptions) Reset() { @@ -508,6 +509,13 @@ func (x *StartOptions) GetOomMemoryLimit() int64 { return 0 } +func (x *StartOptions) GetPowerReportEnabled() bool { + if x != nil { + return x.PowerReportEnabled + } + return false +} + type ConfigContent struct { state protoimpl.MessageState `protogen:"open.v1"` Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` @@ -1676,11 +1684,12 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\townership\x18\x02 \x01(\x0e2\x18.desktop.DaemonOwnershipR\townership\"m\n" + "\x13StartServiceRequest\x12%\n" + "\x0econfig_content\x18\x01 \x01(\tR\rconfigContent\x12/\n" + - "\aoptions\x18\x02 \x01(\v2\x15.desktop.StartOptionsR\aoptions\"\x96\x01\n" + + "\aoptions\x18\x02 \x01(\v2\x15.desktop.StartOptionsR\aoptions\"\xc8\x01\n" + "\fStartOptions\x12,\n" + "\x12oom_killer_enabled\x18\x01 \x01(\bR\x10oomKillerEnabled\x12.\n" + "\x13oom_killer_disabled\x18\x02 \x01(\bR\x11oomKillerDisabled\x12(\n" + - "\x10oom_memory_limit\x18\x03 \x01(\x03R\x0eoomMemoryLimit\")\n" + + "\x10oom_memory_limit\x18\x03 \x01(\x03R\x0eoomMemoryLimit\x120\n" + + "\x14power_report_enabled\x18\x04 \x01(\bR\x12powerReportEnabled\")\n" + "\rConfigContent\x12\x18\n" + "\acontent\x18\x01 \x01(\tR\acontent\"\xb0\x02\n" + "\x0eProfileContent\x120\n" + @@ -1767,7 +1776,7 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "!INSTALL_UPDATE_RESULT_UNSPECIFIED\x10\x00\x12!\n" + "\x1dINSTALL_UPDATE_RESULT_STARTED\x10\x01\x12)\n" + "%INSTALL_UPDATE_RESULT_SIGNER_MISMATCH\x10\x02\x12#\n" + - "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\x86\r\n" + + "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\xcd\x10\n" + "\x0eDesktopService\x12>\n" + "\rGetDaemonInfo\x12\x16.google.protobuf.Empty\x1a\x13.desktop.DaemonInfo\"\x00\x12@\n" + "\fClaimService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12C\n" + @@ -1786,7 +1795,13 @@ const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + "\x11MarkOOMReportRead\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + "\x0fExportOOMReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12F\n" + "\x0fDeleteOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + - "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12D\n" + + "\x10ListPowerReports\x12\x16.google.protobuf.Empty\x1a\x16.desktop.OOMReportList\"\x00\x12I\n" + + "\x0fReadPowerReport\x12\x19.desktop.OOMReportRequest\x1a\x19.desktop.OOMReportContent\"\x00\x12J\n" + + "\x13MarkPowerReportRead\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12S\n" + + "\x11ExportPowerReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12H\n" + + "\x11DeletePowerReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12I\n" + + "\x15DeleteAllPowerReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x00\x12J\n" + "\x13GetSecuritySettings\x12\x16.google.protobuf.Empty\x1a\x19.desktop.SecuritySettings\"\x00\x12Z\n" + "\x16SetInsecureModeEnabled\x12&.desktop.SetInsecureModeEnabledRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + @@ -1881,50 +1896,62 @@ var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ 23, // 23: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest 22, // 24: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest 31, // 25: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty - 29, // 26: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest - 31, // 27: desktop.DesktopService.GetSecuritySettings:input_type -> google.protobuf.Empty - 27, // 28: desktop.DesktopService.SetInsecureModeEnabled:input_type -> desktop.SetInsecureModeEnabledRequest - 28, // 29: desktop.DesktopService.SetLocale:input_type -> desktop.SetLocaleRequest - 9, // 30: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent - 9, // 31: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent - 31, // 32: desktop.ApplicationService.GenerateConfigSchema:input_type -> google.protobuf.Empty - 10, // 33: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent - 11, // 34: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData - 3, // 35: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest - 4, // 36: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest - 5, // 37: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest - 6, // 38: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo - 31, // 39: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty - 31, // 40: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty - 31, // 41: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty - 12, // 42: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo - 31, // 43: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty - 13, // 44: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList - 17, // 45: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent - 31, // 46: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty - 19, // 47: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive - 31, // 48: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty - 31, // 49: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty - 20, // 50: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList - 24, // 51: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent - 31, // 52: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty - 19, // 53: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive - 31, // 54: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty - 31, // 55: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty - 30, // 56: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse - 26, // 57: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings - 31, // 58: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty - 31, // 59: desktop.DesktopService.SetLocale:output_type -> google.protobuf.Empty - 31, // 60: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty - 9, // 61: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent - 9, // 62: desktop.ApplicationService.GenerateConfigSchema:output_type -> desktop.ConfigContent - 11, // 63: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData - 10, // 64: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent - 31, // 65: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty - 32, // 66: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 33, // 67: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress - 38, // [38:68] is the sub-list for method output_type - 8, // [8:38] is the sub-list for method input_type + 31, // 26: desktop.DesktopService.ListPowerReports:input_type -> google.protobuf.Empty + 22, // 27: desktop.DesktopService.ReadPowerReport:input_type -> desktop.OOMReportRequest + 22, // 28: desktop.DesktopService.MarkPowerReportRead:input_type -> desktop.OOMReportRequest + 23, // 29: desktop.DesktopService.ExportPowerReport:input_type -> desktop.OOMReportExportRequest + 22, // 30: desktop.DesktopService.DeletePowerReport:input_type -> desktop.OOMReportRequest + 31, // 31: desktop.DesktopService.DeleteAllPowerReports:input_type -> google.protobuf.Empty + 29, // 32: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest + 31, // 33: desktop.DesktopService.GetSecuritySettings:input_type -> google.protobuf.Empty + 27, // 34: desktop.DesktopService.SetInsecureModeEnabled:input_type -> desktop.SetInsecureModeEnabledRequest + 28, // 35: desktop.DesktopService.SetLocale:input_type -> desktop.SetLocaleRequest + 9, // 36: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent + 9, // 37: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent + 31, // 38: desktop.ApplicationService.GenerateConfigSchema:input_type -> google.protobuf.Empty + 10, // 39: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 11, // 40: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 3, // 41: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 4, // 42: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 5, // 43: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 6, // 44: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 31, // 45: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 31, // 46: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 31, // 47: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 12, // 48: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 31, // 49: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 13, // 50: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 17, // 51: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 31, // 52: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 19, // 53: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 31, // 54: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 31, // 55: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 20, // 56: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 24, // 57: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 31, // 58: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 19, // 59: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 31, // 60: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 31, // 61: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 20, // 62: desktop.DesktopService.ListPowerReports:output_type -> desktop.OOMReportList + 24, // 63: desktop.DesktopService.ReadPowerReport:output_type -> desktop.OOMReportContent + 31, // 64: desktop.DesktopService.MarkPowerReportRead:output_type -> google.protobuf.Empty + 19, // 65: desktop.DesktopService.ExportPowerReport:output_type -> desktop.CrashReportArchive + 31, // 66: desktop.DesktopService.DeletePowerReport:output_type -> google.protobuf.Empty + 31, // 67: desktop.DesktopService.DeleteAllPowerReports:output_type -> google.protobuf.Empty + 30, // 68: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse + 26, // 69: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings + 31, // 70: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty + 31, // 71: desktop.DesktopService.SetLocale:output_type -> google.protobuf.Empty + 31, // 72: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 9, // 73: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 9, // 74: desktop.ApplicationService.GenerateConfigSchema:output_type -> desktop.ConfigContent + 11, // 75: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 10, // 76: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 31, // 77: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 32, // 78: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 33, // 79: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 44, // [44:80] is the sub-list for method output_type + 8, // [8:44] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto index 484d720f..46f28304 100644 --- a/experimental/boxdd/desktop_service.proto +++ b/experimental/boxdd/desktop_service.proto @@ -25,6 +25,12 @@ service DesktopService { rpc ExportOOMReport(OOMReportExportRequest) returns (CrashReportArchive) {} rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {} rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc ListPowerReports(google.protobuf.Empty) returns (OOMReportList) {} + rpc ReadPowerReport(OOMReportRequest) returns (OOMReportContent) {} + rpc MarkPowerReportRead(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc ExportPowerReport(OOMReportExportRequest) returns (CrashReportArchive) {} + rpc DeletePowerReport(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc DeleteAllPowerReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} rpc InstallUpdate(InstallUpdateRequest) returns (InstallUpdateResponse) {} rpc GetSecuritySettings(google.protobuf.Empty) returns (SecuritySettings) {} rpc SetInsecureModeEnabled(SetInsecureModeEnabledRequest) returns (google.protobuf.Empty) {} @@ -80,6 +86,7 @@ message StartOptions { bool oom_killer_enabled = 1; bool oom_killer_disabled = 2; int64 oom_memory_limit = 3; + bool power_report_enabled = 4; } message ConfigContent { diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go index 6851e4da..40c08a42 100644 --- a/experimental/boxdd/desktop_service_grpc.pb.go +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -35,6 +35,12 @@ const ( DesktopService_ExportOOMReport_FullMethodName = "/desktop.DesktopService/ExportOOMReport" DesktopService_DeleteOOMReport_FullMethodName = "/desktop.DesktopService/DeleteOOMReport" DesktopService_DeleteAllOOMReports_FullMethodName = "/desktop.DesktopService/DeleteAllOOMReports" + DesktopService_ListPowerReports_FullMethodName = "/desktop.DesktopService/ListPowerReports" + DesktopService_ReadPowerReport_FullMethodName = "/desktop.DesktopService/ReadPowerReport" + DesktopService_MarkPowerReportRead_FullMethodName = "/desktop.DesktopService/MarkPowerReportRead" + DesktopService_ExportPowerReport_FullMethodName = "/desktop.DesktopService/ExportPowerReport" + DesktopService_DeletePowerReport_FullMethodName = "/desktop.DesktopService/DeletePowerReport" + DesktopService_DeleteAllPowerReports_FullMethodName = "/desktop.DesktopService/DeleteAllPowerReports" DesktopService_InstallUpdate_FullMethodName = "/desktop.DesktopService/InstallUpdate" DesktopService_GetSecuritySettings_FullMethodName = "/desktop.DesktopService/GetSecuritySettings" DesktopService_SetInsecureModeEnabled_FullMethodName = "/desktop.DesktopService/SetInsecureModeEnabled" @@ -63,6 +69,12 @@ type DesktopServiceClient interface { ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListPowerReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) + ReadPowerReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) + MarkPowerReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExportPowerReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) + DeletePowerReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeleteAllPowerReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) GetSecuritySettings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecuritySettings, error) SetInsecureModeEnabled(ctx context.Context, in *SetInsecureModeEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) @@ -257,6 +269,66 @@ func (c *desktopServiceClient) DeleteAllOOMReports(ctx context.Context, in *empt return out, nil } +func (c *desktopServiceClient) ListPowerReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportList) + err := c.cc.Invoke(ctx, DesktopService_ListPowerReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ReadPowerReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportContent) + err := c.cc.Invoke(ctx, DesktopService_ReadPowerReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) MarkPowerReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_MarkPowerReportRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ExportPowerReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportArchive) + err := c.cc.Invoke(ctx, DesktopService_ExportPowerReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeletePowerReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeletePowerReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteAllPowerReports(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, DesktopService_DeleteAllPowerReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *desktopServiceClient) InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(InstallUpdateResponse) @@ -319,6 +391,12 @@ type DesktopServiceServer interface { ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ListPowerReports(context.Context, *emptypb.Empty) (*OOMReportList, error) + ReadPowerReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) + MarkPowerReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + ExportPowerReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) + DeletePowerReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + DeleteAllPowerReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) GetSecuritySettings(context.Context, *emptypb.Empty) (*SecuritySettings, error) SetInsecureModeEnabled(context.Context, *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) @@ -405,6 +483,30 @@ func (UnimplementedDesktopServiceServer) DeleteAllOOMReports(context.Context, *e return nil, status.Error(codes.Unimplemented, "method DeleteAllOOMReports not implemented") } +func (UnimplementedDesktopServiceServer) ListPowerReports(context.Context, *emptypb.Empty) (*OOMReportList, error) { + return nil, status.Error(codes.Unimplemented, "method ListPowerReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ReadPowerReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) { + return nil, status.Error(codes.Unimplemented, "method ReadPowerReport not implemented") +} + +func (UnimplementedDesktopServiceServer) MarkPowerReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method MarkPowerReportRead not implemented") +} + +func (UnimplementedDesktopServiceServer) ExportPowerReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) { + return nil, status.Error(codes.Unimplemented, "method ExportPowerReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeletePowerReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeletePowerReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteAllPowerReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAllPowerReports not implemented") +} + func (UnimplementedDesktopServiceServer) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) { return nil, status.Error(codes.Unimplemented, "method InstallUpdate not implemented") } @@ -765,6 +867,114 @@ func _DesktopService_DeleteAllOOMReports_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _DesktopService_ListPowerReports_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.(DesktopServiceServer).ListPowerReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ListPowerReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ListPowerReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ReadPowerReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ReadPowerReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ReadPowerReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ReadPowerReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_MarkPowerReportRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).MarkPowerReportRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_MarkPowerReportRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).MarkPowerReportRead(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ExportPowerReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportExportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ExportPowerReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ExportPowerReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ExportPowerReport(ctx, req.(*OOMReportExportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeletePowerReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeletePowerReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeletePowerReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeletePowerReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteAllPowerReports_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.(DesktopServiceServer).DeleteAllPowerReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteAllPowerReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteAllPowerReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _DesktopService_InstallUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InstallUpdateRequest) if err := dec(in); err != nil { @@ -916,6 +1126,30 @@ var DesktopService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteAllOOMReports", Handler: _DesktopService_DeleteAllOOMReports_Handler, }, + { + MethodName: "ListPowerReports", + Handler: _DesktopService_ListPowerReports_Handler, + }, + { + MethodName: "ReadPowerReport", + Handler: _DesktopService_ReadPowerReport_Handler, + }, + { + MethodName: "MarkPowerReportRead", + Handler: _DesktopService_MarkPowerReportRead_Handler, + }, + { + MethodName: "ExportPowerReport", + Handler: _DesktopService_ExportPowerReport_Handler, + }, + { + MethodName: "DeletePowerReport", + Handler: _DesktopService_DeletePowerReport_Handler, + }, + { + MethodName: "DeleteAllPowerReports", + Handler: _DesktopService_DeleteAllPowerReports_Handler, + }, { MethodName: "InstallUpdate", Handler: _DesktopService_InstallUpdate_Handler, diff --git a/experimental/boxdd/power_report.go b/experimental/boxdd/power_report.go new file mode 100644 index 00000000..865f1198 --- /dev/null +++ b/experimental/boxdd/power_report.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + powerReportsDirectoryName = "power_reports" + powerTimelineFileName = "timeline.jsonl" + powerEventsFileName = "events.jsonl" + + powerReportFileContentLimit = 1024 * 1024 +) + +// File order follows the client convention: +// sing-box-for-apple Library/Shared/PowerReportManager.swift (availableFiles). +var powerReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, powerTimelineFileName, powerEventsFileName, goLogFileName} + +func (s *desktopService) ListPowerReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return &OOMReportList{}, nil + } + return nil, err + } + reports := make([]*OOMReportEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + reports = append(reports, &OOMReportEntry{ + Name: entry.Name(), + RecordedAt: reportTime(fullPath, "startedAt").UnixMilli(), + IsRead: reportIsRead(fullPath), + }) + } + sort.Slice(reports, func(i, j int) bool { + return reports[i].RecordedAt > reports[j].RecordedAt + }) + return &OOMReportList{Reports: reports}, nil +} + +func (s *desktopService) ReadPowerReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + files := make([]*OOMReportFile, 0, len(powerReportLeadingFileOrder)) + for _, fileName := range powerReportLeadingFileOrder { + content, readError := readFileTail(filepath.Join(fullPath, fileName), powerReportFileContentLimit) + if readError != nil { + if os.IsNotExist(readError) { + continue + } + return nil, readError + } + files = append(files, &OOMReportFile{ + Name: fileName, + Content: content, + }) + } + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, err + } + profileNames := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") { + continue + } + if slices.Contains(powerReportLeadingFileOrder, name) { + continue + } + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + files = append(files, &OOMReportFile{ + Name: name, + IsProfile: true, + }) + } + return &OOMReportContent{Files: files}, nil +} + +func readFileTail(path string, limit int64) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() <= limit { + return os.ReadFile(path) + } + _, err = file.Seek(info.Size()-limit, 0) + if err != nil { + return nil, err + } + content := make([]byte, 0, limit+4) + content = append(content, "…\n"...) + buffer := make([]byte, limit) + n, err := file.Read(buffer) + if err != nil { + return nil, err + } + return append(content, buffer[:n]...), nil +} + +func (s *desktopService) MarkPowerReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ExportPowerReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt) +} + +func (s *desktopService) DeletePowerReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.RemoveAll(fullPath) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) DeleteAllPowerReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, powerReportsDirectoryName) + if err != nil { + return nil, err + } + err = deleteReportsForUser(reportsDirectory, userID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go index 912e93a1..e7e03cdb 100644 --- a/experimental/boxdd/server.go +++ b/experimental/boxdd/server.go @@ -15,6 +15,7 @@ import ( "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/service/powerreport" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" @@ -30,6 +31,7 @@ type Daemon struct { ctx context.Context logger log.ContextLogger startedService *daemon.StartedService + powerManager *powerreport.Manager server *grpc.Server runtimeWorkingDirectory string lifecycleAccess sync.Mutex @@ -62,6 +64,8 @@ func newDaemon() (*Daemon, error) { }) reporter := libbox.NewOOMReporter(d.startedService) service.MustRegister[oomkiller.OOMReporter](ctx, reporter) + d.powerManager = powerreport.NewManager() + service.MustRegister[*powerreport.Manager](ctx, d.powerManager) managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{ Handler: &managedHandler{d}, Debug: debugEnabled, @@ -195,6 +199,7 @@ func (d *Daemon) configureWorkingDirectoryLocked(directory string) error { return err } libbox.PromoteOOMDraft() + libbox.PromotePowerReportDraft() d.runtimeWorkingDirectory = directory return nil } @@ -207,11 +212,20 @@ func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, con } _ = os.WriteFile(filepath.Join(directory, configSnapshotFileName), []byte(configContent), 0o600) libbox.ReloadSetupOptions(&libbox.SetupOptions{ - OomKillerEnabled: options.OOMKillerEnabled, - OomKillerDisabled: options.OOMKillerDisabled, - OomMemoryLimit: options.OOMMemoryLimit, + OomKillerEnabled: options.OOMKillerEnabled, + OomKillerDisabled: options.OOMKillerDisabled, + OomMemoryLimit: options.OOMMemoryLimit, + PowerReportEnabled: options.PowerReportEnabled, }) d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) + if options.PowerReportEnabled { + err = d.powerManager.Start(libbox.PowerReportOptions(d.startedService)) + if err != nil { + d.logger.Warn("start power report recorder: ", err) + } + } else { + d.powerManager.Close() + } if d.platform != nil { d.platform.SetSystemProxyPreference(options.systemProxyEnabled()) err = d.platform.ResetPlatformOptions() @@ -243,6 +257,8 @@ func (d *Daemon) stopServiceLocked(ownerUserID string) error { return err } } + d.powerManager.Close() + libbox.PromotePowerReportDraft() directory := userWorkingDirectory(ownerUserID) crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) if crashReportError != nil { @@ -252,6 +268,10 @@ func (d *Daemon) stopServiceLocked(ownerUserID string) error { if oomReportError != nil { return oomReportError } + powerReportError := tagUnownedReports(filepath.Join(directory, powerReportsDirectoryName), ownerUserID) + if powerReportError != nil { + return powerReportError + } options.WasRunning = false return saveStartOptions(ownerUserID, options) } diff --git a/experimental/boxdd/snapshot.go b/experimental/boxdd/snapshot.go index e5d20d88..408ba863 100644 --- a/experimental/boxdd/snapshot.go +++ b/experimental/boxdd/snapshot.go @@ -22,6 +22,7 @@ type startOptions struct { OOMKillerEnabled bool `json:"oom_killer_enabled"` OOMKillerDisabled bool `json:"oom_killer_disabled"` OOMMemoryLimit int64 `json:"oom_memory_limit"` + PowerReportEnabled bool `json:"power_report_enabled,omitempty"` SystemProxyEnabled *bool `json:"system_proxy_enabled,omitempty"` } diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index d34299ac..2391b74e 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -15,6 +15,7 @@ import ( "github.com/sagernet/sing-box/daemon" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/service/oomkiller" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" @@ -34,6 +35,7 @@ type CommandServer struct { handler CommandServerHandler platformInterface PlatformInterface platformWrapper *platformInterfaceWrapper + powerManager *powerreport.Manager grpcServer *grpc.Server listener net.Listener endPauseTimer *time.Timer @@ -51,9 +53,12 @@ type CommandServerHandler interface { func NewCommandServer(handler CommandServerHandler, platformInterface PlatformInterface) (*CommandServer, error) { ctx := baseContext(platformInterface) + powerManager := powerreport.NewManager() + service.MustRegister[*powerreport.Manager](ctx, powerManager) platformWrapper := &platformInterfaceWrapper{ - iif: platformInterface, - useProcFS: platformInterface.UseProcFS(), + iif: platformInterface, + useProcFS: platformInterface.UseProcFS(), + powerManager: powerManager, } service.MustRegister[adapter.PlatformInterface](ctx, platformWrapper) server := &CommandServer{ @@ -61,6 +66,7 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn handler: handler, platformInterface: platformInterface, platformWrapper: platformWrapper, + powerManager: powerManager, } server.StartedService = daemon.NewStartedService(daemon.ServiceOptions{ Context: ctx, @@ -84,6 +90,12 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn Debug: sDebug, OOMReporter: reporter, }) + if sPowerReportEnabled { + err := powerManager.Start(PowerReportOptions(server.StartedService)) + if err != nil { + log.StdLogger().Error(E.Cause(err, "start power report recorder")) + } + } return server, nil } @@ -183,6 +195,7 @@ func (s *CommandServer) Close() { } common.Close(s.listener) s.StartedService.Close() + s.powerManager.Close() } type OverrideOptions struct { @@ -193,6 +206,9 @@ type OverrideOptions struct { func (s *CommandServer) StartOrReloadService(configContent string, options *OverrideOptions) error { saveConfigSnapshot(configContent) + if s.powerManager.Recorder() != nil { + copyConfigSnapshot(filepath.Join(sWorkingPath, powerreport.DraftDirectoryName)) + } err := s.StartedService.StartOrReloadService(s.ctx, configContent, &daemon.OverrideOptions{ AutoRedirect: options.AutoRedirect, IncludePackage: iteratorToArray(options.IncludePackage), @@ -233,6 +249,10 @@ func (s *CommandServer) NeedFindProcess() bool { } func (s *CommandServer) Pause() { + recorder := s.powerManager.Recorder() + if recorder != nil { + recorder.RecordPlatformEvent("ne-sleep") + } instance := s.StartedService.Instance() if instance == nil || instance.PauseManager() == nil { return @@ -248,6 +268,10 @@ func (s *CommandServer) Pause() { } func (s *CommandServer) Wake() { + recorder := s.powerManager.Recorder() + if recorder != nil { + recorder.RecordPlatformEvent("ne-wake") + } instance := s.StartedService.Instance() if instance == nil || instance.PauseManager() == nil { return diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index d1f8f938..49f7702a 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -1,6 +1,7 @@ package libbox import ( + "github.com/sagernet/sing-box/service/powerreport" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/control" E "github.com/sagernet/sing/common/exceptions" @@ -69,6 +70,24 @@ func (m *platformDefaultInterfaceMonitor) UpdateDefaultInterface(interfaceName s } func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName string, interfaceIndex32 int32, isExpensive bool, isConstrained bool) { + var recorder *powerreport.Recorder + if m.powerManager != nil { + recorder = m.powerManager.Recorder() + } + if recorder != nil { + networkType := interfaceName + if interfaceIndex32 == -1 { + networkType = "none" + } else { + if isExpensive { + networkType += ",expensive" + } + if isConstrained { + networkType += ",constrained" + } + } + recorder.UpdateNetworkType(networkType) + } m.isExpensive = isExpensive m.isConstrained = isConstrained err := m.networkManager.UpdateInterfaces() diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go index a712f953..01421b47 100644 --- a/experimental/libbox/oom_report.go +++ b/experimental/libbox/oom_report.go @@ -302,16 +302,24 @@ func buildOOMConnection(connection *trafficcontrol.TrackerMetadata) oomConnectio return info } -func writeOOMLog(destPath string, entries []*log.Entry) { +func formatLogEntries(entries []*log.Entry) []byte { if len(entries) == 0 { - return + return nil } var buffer bytes.Buffer for _, entry := range entries { writeWithoutColors(&buffer, entry.Message) buffer.WriteByte('\n') } - writeReportFile(destPath, "go.log", buffer.Bytes()) + return buffer.Bytes() +} + +func writeOOMLog(destPath string, entries []*log.Entry) { + content := formatLogEntries(entries) + if content == nil { + return + } + writeReportFile(destPath, "go.log", content) } func writeWithoutColors(buffer *bytes.Buffer, message string) { diff --git a/experimental/libbox/power_report.go b/experimental/libbox/power_report.go new file mode 100644 index 00000000..39f5f491 --- /dev/null +++ b/experimental/libbox/power_report.go @@ -0,0 +1,35 @@ +//go:build darwin || linux || windows + +package libbox + +import ( + "time" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/service/powerreport" +) + +type powerReportMetadata struct { + reportMetadata + StartedAt string `json:"startedAt"` +} + +func PowerReportOptions(startedService *daemon.StartedService) powerreport.Options { + return powerreport.Options{ + BasePath: sWorkingPath, + Logger: log.StdLogger(), + Metadata: powerReportMetadata{ + reportMetadata: baseReportMetadata(), + StartedAt: time.Now().UTC().Format(time.RFC3339), + }, + OwnerCallback: chownReport, + LogCallback: func() []byte { + return formatLogEntries(startedService.SavedLog()) + }, + } +} + +func PromotePowerReportDraft() { + powerreport.PromoteDraft(sWorkingPath) +} diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 499dcfb3..4cd6045a 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -16,6 +16,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/libbox/internal/procfs" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/service/powerreport" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/control" @@ -29,6 +30,7 @@ type platformInterfaceWrapper struct { iif PlatformInterface useProcFS bool networkManager adapter.NetworkManager + powerManager *powerreport.Manager myTunName string myTunAddress []netip.Addr defaultInterfaceAccess sync.Mutex diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index 8c276130..c12d8771 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -34,6 +34,7 @@ var ( sOOMKillerEnabled bool sOOMKillerDisabled bool sOOMMemoryLimit int64 + sPowerReportEnabled bool ) func init() { @@ -54,6 +55,7 @@ type SetupOptions struct { OomKillerEnabled bool OomKillerDisabled bool OomMemoryLimit int64 + PowerReportEnabled bool } func applySetupOptions(options *SetupOptions) { @@ -80,6 +82,7 @@ func ReloadSetupOptions(options *SetupOptions) { sOOMKillerEnabled = options.OomKillerEnabled sOOMKillerDisabled = options.OomKillerDisabled sOOMMemoryLimit = options.OomMemoryLimit + sPowerReportEnabled = options.PowerReportEnabled if sOOMKillerEnabled { if sOOMMemoryLimit == 0 && C.IsIos { sOOMMemoryLimit = oomkiller.DefaultAppleNetworkExtensionMemoryLimit diff --git a/go.mod b/go.mod index d3f0918a..342d9992 100644 --- a/go.mod +++ b/go.mod @@ -44,13 +44,13 @@ require ( github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf github.com/sagernet/nftables v0.3.0-mod.4 - github.com/sagernet/quic-go v0.61.0-sing-box-mod.4 - github.com/sagernet/sing v0.9.0-beta.3 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.5 + github.com/sagernet/sing v0.9.0-beta.4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 - github.com/sagernet/sing-quic v0.7.0-beta.2 + github.com/sagernet/sing-quic v0.7.0-beta.3 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 @@ -60,7 +60,7 @@ require ( github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.3 - github.com/sagernet/wireguard-go v0.0.5-0.20260810121456-c6c8a831ef70 + github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.0 diff --git a/go.sum b/go.sum index b8098288..a81bcaf1 100644 --- a/go.sum +++ b/go.sum @@ -316,10 +316,10 @@ github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf h1:b/3rm+KvxzuYRc github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= -github.com/sagernet/quic-go v0.61.0-sing-box-mod.4 h1:XoFf8KoBEsamWo3ePlcmS/xs8B7KDurJQfI2U7q/H1c= -github.com/sagernet/quic-go v0.61.0-sing-box-mod.4/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= -github.com/sagernet/sing v0.9.0-beta.3 h1:DH9B94S82daJyJHz2oeirLwa/zh03AJ3xiXLJrM95Fo= -github.com/sagernet/sing v0.9.0-beta.3/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.5 h1:D41e0fUHgdiYV2r4eGF3LgnBWNjYHm781dNW87IDzWc= +github.com/sagernet/quic-go v0.61.0-sing-box-mod.5/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E= +github.com/sagernet/sing v0.9.0-beta.4 h1:DYoEzb3FeCZ9bA66hJmL0NFDXckZpa4oYF/VpbFJEkE= +github.com/sagernet/sing v0.9.0-beta.4/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= @@ -328,8 +328,8 @@ github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df h1:ogyC7 github.com/sagernet/sing-openconnect v0.0.0-20260810065514-53aa8058f8df/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6 h1:ZAvhkor0prJ8KEX9EmTEH+gJ1WlA3ffjS8GZT6KKq9c= github.com/sagernet/sing-openvpn v0.0.0-20260729104525-103eb5fe5eb6/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= -github.com/sagernet/sing-quic v0.7.0-beta.2 h1:qfMz4IyGIj6WhqhrR5ZU+d4Cqppi6RM6BpGXG6phfy8= -github.com/sagernet/sing-quic v0.7.0-beta.2/go.mod h1:Z+ZpYBLJ7WkPPrGSCYFxna4Dos2XrywEy6YH6SY5L6Q= +github.com/sagernet/sing-quic v0.7.0-beta.3 h1:jQA8vurQ2c8uKebdSq5G1N4EnSLpq2l6rEGJ7Dv6hXA= +github.com/sagernet/sing-quic v0.7.0-beta.3/go.mod h1:FPgVlDoSLWJlCjtT8WrFIAll4t2OutSP9nq0Iq74JLg= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= @@ -348,8 +348,8 @@ github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1h github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.3 h1:c7jWyEt7n3WdbDxkc7EqmPQtPT+Hct2l1CtmEDztMuo= github.com/sagernet/tailscale v1.102.1-sing-box-1.14-mod.3/go.mod h1:WLUSOPmTcf7VN9gLCe01qUSIvD+/cKC177neENyZPkI= -github.com/sagernet/wireguard-go v0.0.5-0.20260810121456-c6c8a831ef70 h1:WTVgbkDDGnZqxIUnBsE9QyJz2dtSaeoh9mFwxmQqfgI= -github.com/sagernet/wireguard-go v0.0.5-0.20260810121456-c6c8a831ef70/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= +github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30 h1:Z9QAr893OvR9qDHvxYTsYZhpvqwcc0LWbJ4ECg/eBgg= +github.com/sagernet/wireguard-go v0.0.5-0.20260823125007-8bd032a91a30/go.mod h1:er10sELpmzLXq7S7Pbc1Zsbyapcr+/gxNAHKTo6fzVA= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 1fe5fd2e..958e430e 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -105,6 +105,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL }, })) }, + Tag: tag, Name: options.Name, MTU: options.MTU, Address: options.Address, diff --git a/service/powerreport/manager.go b/service/powerreport/manager.go new file mode 100644 index 00000000..d10b6c0e --- /dev/null +++ b/service/powerreport/manager.go @@ -0,0 +1,44 @@ +package powerreport + +import ( + "sync" + "sync/atomic" +) + +type Manager struct { + access sync.Mutex + recorder atomic.Pointer[Recorder] +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) Start(options Options) error { + m.access.Lock() + defer m.access.Unlock() + if m.recorder.Load() != nil { + return nil + } + recorder := NewRecorder(options) + err := recorder.Start() + if err != nil { + return err + } + m.recorder.Store(recorder) + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + recorder := m.recorder.Swap(nil) + if recorder == nil { + return nil + } + return recorder.Close() +} + +func (m *Manager) Recorder() *Recorder { + return m.recorder.Load() +} diff --git a/service/powerreport/promote.go b/service/powerreport/promote.go new file mode 100644 index 00000000..f1b0f8bb --- /dev/null +++ b/service/powerreport/promote.go @@ -0,0 +1,48 @@ +package powerreport + +import ( + "os" + "path/filepath" + "strconv" +) + +func PromoteDraft(basePath string) { + promoteDirectory(filepath.Join(basePath, DraftDirectoryName), filepath.Join(basePath, ReportsDirectoryName)) +} + +func finalizeDraft(draftPath string) { + promoteDirectory(draftPath, filepath.Join(filepath.Dir(draftPath), ReportsDirectoryName)) +} + +func promoteDirectory(draftPath string, reportsPath string) { + info, err := os.Stat(draftPath) + if err != nil || !info.IsDir() { + return + } + entries, err := os.ReadDir(draftPath) + if err != nil || len(entries) == 0 { + os.RemoveAll(draftPath) + return + } + err = os.MkdirAll(reportsPath, 0o777) + if err != nil { + return + } + destName := info.ModTime().UTC().Format("2006-01-02T15-04-05") + destPath := filepath.Join(reportsPath, destName) + for i := 1; ; i++ { + _, err = os.Stat(destPath) + if os.IsNotExist(err) { + break + } + if i > 1000 { + os.RemoveAll(draftPath) + return + } + destPath = filepath.Join(reportsPath, destName+"-"+strconv.Itoa(i)) + } + err = os.Rename(draftPath, destPath) + if err != nil { + os.RemoveAll(draftPath) + } +} diff --git a/service/powerreport/record.go b/service/powerreport/record.go new file mode 100644 index 00000000..c850f1ca --- /dev/null +++ b/service/powerreport/record.go @@ -0,0 +1,94 @@ +package powerreport + +type Direction uint8 + +const ( + DirectionOutbound Direction = iota + DirectionInbound +) + +func (d Direction) String() string { + if d == DirectionInbound { + return "in" + } + return "out" +} + +type Attribution struct { + Inbound string `json:"inbound,omitempty"` + Domain string `json:"domain,omitempty"` + Destination string `json:"destination,omitempty"` + Outbound string `json:"outbound,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type timelineRow struct { + From string `json:"from"` + To string `json:"to"` + CPUUserMS int64 `json:"cpuUserMS,omitempty"` + CPUSystemMS int64 `json:"cpuSystemMS,omitempty"` + CPUPerformanceMS int64 `json:"cpuPerformanceMS,omitempty"` + CPUGCMS int64 `json:"cpuGCMS,omitempty"` + QoSMS *qosBreakdown `json:"qosMS,omitempty"` + PackageIdleWakeups uint64 `json:"packageIdleWakeups,omitempty"` + InterruptWakeups uint64 `json:"interruptWakeups,omitempty"` + EnergyNanojoules uint64 `json:"energyNJ,omitempty"` + PerformanceEnergyNanojoules uint64 `json:"performanceEnergyNJ,omitempty"` + DiskBytesWritten uint64 `json:"diskWriteBytes,omitempty"` + SleptMS int64 `json:"sleptMS,omitempty"` + Goroutines uint64 `json:"goroutines,omitempty"` + DNSQueries uint64 `json:"dnsQueries,omitempty"` + ConnectionsOpened uint64 `json:"connectionsOpened,omitempty"` + InterfacePackets map[string]uint64 `json:"interfacePackets,omitempty"` + NetworkType string `json:"network,omitempty"` +} + +type qosBreakdown struct { + DefaultMS int64 `json:"default,omitempty"` + MaintenanceMS int64 `json:"maintenance,omitempty"` + BackgroundMS int64 `json:"background,omitempty"` + UtilityMS int64 `json:"utility,omitempty"` + LegacyMS int64 `json:"legacy,omitempty"` + UserInitiatedMS int64 `json:"userInitiated,omitempty"` + UserInteractiveMS int64 `json:"userInteractive,omitempty"` +} + +const ( + eventTypeBreak = "break" + eventTypeNetwork = "network" +) + +type eventRecord struct { + Type string `json:"t"` + At string `json:"at"` + IdleMS int64 `json:"idleMS,omitempty"` + Direction string `json:"direction,omitempty"` + Size int `json:"size,omitempty"` + NetworkType string `json:"network,omitempty"` + By *Attribution `json:"by,omitempty"` +} + +type systemUsage struct { + valid bool + userTime int64 + systemTime int64 + performanceUserTime int64 + performanceSystemTime int64 + qosDefaultTime int64 + qosMaintenanceTime int64 + qosBackgroundTime int64 + qosUtilityTime int64 + qosLegacyTime int64 + qosUserInitiatedTime int64 + qosUserInteractiveTime int64 + packageIdleWakeups uint64 + interruptWakeups uint64 + diskBytesWritten uint64 + energyNanojoules uint64 + performanceEnergyNanojoules uint64 +} + +type interfaceCounters struct { + inPackets uint32 + outPackets uint32 +} diff --git a/service/powerreport/recorder.go b/service/powerreport/recorder.go new file mode 100644 index 00000000..0e389bd9 --- /dev/null +++ b/service/powerreport/recorder.go @@ -0,0 +1,472 @@ +package powerreport + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime/metrics" + "runtime/pprof" + "sync" + "sync/atomic" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +const ( + DraftDirectoryName = "power_draft" + ReportsDirectoryName = "power_reports" + + timelineFileName = "timeline.jsonl" + eventsFileName = "events.jsonl" + metadataFileName = "metadata.json" + logFileName = "go.log" + goroutineProfileFileName = "goroutine.pb.gz" + + defaultGateInterval = 5 * time.Second + defaultSampleInterval = time.Minute + defaultFlushInterval = 15 * time.Minute + defaultFallbackInterval = 10 * time.Minute + + activityRefreshNano = int64(time.Second) + + rowCapacity = 4096 + eventCapacity = 8192 +) + +type Options struct { + BasePath string + Logger logger.Logger + Metadata any + OwnerCallback func(path string) + LogCallback func() []byte + GateInterval time.Duration + SampleInterval time.Duration + FlushInterval time.Duration + FallbackInterval time.Duration +} + +type Recorder struct { + draftPath string + logger logger.Logger + metadata any + ownerCallback func(path string) + logCallback func() []byte + gateNano int64 + sampleNano int64 + flushInterval time.Duration + fallbackInterval time.Duration + baseTime time.Time + + _ [64]byte + lastActivity atomic.Int64 + _ [64]byte + + lastSampleAt atomic.Int64 + pendingBreak atomic.Pointer[breakRecord] + notify chan struct{} + + dnsQueries atomic.Uint64 + connectionsOpened atomic.Uint64 + + access sync.Mutex + networkType string + rows []timelineRow + events []eventRecord + previous previousSample + lastFlushAt time.Time + started bool + closed bool + metricsSamples []metrics.Sample + + done chan struct{} + workerDone chan struct{} +} + +type breakRecord struct { + at time.Time + idleMS int64 + direction Direction + size int + by *Attribution +} + +type previousSample struct { + at time.Time + usage systemUsage + gcSeconds float64 + absoluteTime int64 + continuousTime int64 + interfaces map[string]interfaceCounters + dnsQueries uint64 + connectionsOpened uint64 +} + +func NewRecorder(options Options) *Recorder { + recorderLogger := options.Logger + if recorderLogger == nil { + recorderLogger = logger.NOP() + } + gateInterval := options.GateInterval + if gateInterval == 0 { + gateInterval = defaultGateInterval + } + sampleInterval := options.SampleInterval + if sampleInterval == 0 { + sampleInterval = defaultSampleInterval + } + flushInterval := options.FlushInterval + if flushInterval == 0 { + flushInterval = defaultFlushInterval + } + fallbackInterval := options.FallbackInterval + if fallbackInterval == 0 { + fallbackInterval = defaultFallbackInterval + } + return &Recorder{ + draftPath: filepath.Join(options.BasePath, DraftDirectoryName), + logger: recorderLogger, + metadata: options.Metadata, + ownerCallback: options.OwnerCallback, + logCallback: options.LogCallback, + gateNano: int64(gateInterval), + sampleNano: int64(sampleInterval), + flushInterval: flushInterval, + fallbackInterval: fallbackInterval, + baseTime: time.Now(), + notify: make(chan struct{}, 1), + metricsSamples: []metrics.Sample{ + {Name: "/cpu/classes/gc/total:cpu-seconds"}, + {Name: "/sched/goroutines:goroutines"}, + }, + done: make(chan struct{}), + workerDone: make(chan struct{}), + } +} + +func (r *Recorder) Start() error { + r.access.Lock() + defer r.access.Unlock() + if r.started { + return nil + } + PromoteDraft(filepath.Dir(r.draftPath)) + err := os.MkdirAll(r.draftPath, 0o777) + if err != nil { + return E.Cause(err, "create power report draft directory") + } + r.chown(r.draftPath) + if r.metadata != nil { + metadataContent, marshalErr := json.Marshal(r.metadata) + if marshalErr == nil { + metadataPath := filepath.Join(r.draftPath, metadataFileName) + os.WriteFile(metadataPath, metadataContent, 0o666) + r.chown(metadataPath) + } + } + now := time.Now() + r.resetPreviousLocked(now) + r.lastSampleAt.Store(int64(now.Sub(r.baseTime))) + r.lastFlushAt = now + r.started = true + go r.worker() + return nil +} + +func (r *Recorder) Close() error { + r.access.Lock() + if !r.started || r.closed { + r.access.Unlock() + return nil + } + r.closed = true + r.access.Unlock() + close(r.done) + <-r.workerDone + now := time.Now() + r.access.Lock() + r.consumeBreakLocked() + r.sampleLocked(now) + r.flushLocked(now) + r.access.Unlock() + r.writeGoroutineProfile() + r.writeLog() + finalizeDraft(r.draftPath) + return nil +} + +func (r *Recorder) writeLog() { + if r.logCallback == nil { + return + } + content := r.logCallback() + if len(content) == 0 { + return + } + logPath := filepath.Join(r.draftPath, logFileName) + err := os.WriteFile(logPath, content, 0o666) + if err != nil { + return + } + r.chown(logPath) +} + +// Touch reports one I/O activity: one read or write call, or one batched receive or send +// syscall on paths that batch packets. size is the size of the first packet of the activity +// and characterizes what ended an idle period; it is not accumulated. Volume totals come from +// the sampled interface counters instead. +func (r *Recorder) Touch(direction Direction, size int, by *Attribution) { + nowNano := int64(time.Since(r.baseTime)) + lastNano := r.lastActivity.Load() + if nowNano-lastNano < activityRefreshNano { + return + } + previousNano := r.lastActivity.Swap(nowNano) + if previousNano != 0 && nowNano-previousNano >= r.gateNano { + r.pendingBreak.Store(&breakRecord{ + at: time.Now(), + idleMS: (nowNano - previousNano) / int64(time.Millisecond), + direction: direction, + size: size, + by: by, + }) + r.notifyWorker() + } else if nowNano-r.lastSampleAt.Load() >= r.sampleNano { + r.notifyWorker() + } +} + +func (r *Recorder) CountDNSQuery() { + r.dnsQueries.Add(1) +} + +func (r *Recorder) CountConnectionOpened() { + r.connectionsOpened.Add(1) +} + +func (r *Recorder) RecordPlatformEvent(eventType string) { + now := time.Now() + r.access.Lock() + if !r.started || r.closed { + r.access.Unlock() + return + } + r.events = append(r.events, eventRecord{ + Type: eventType, + At: now.UTC().Format(time.RFC3339), + }) + r.access.Unlock() + r.notifyWorker() +} + +func (r *Recorder) UpdateNetworkType(networkType string) { + now := time.Now() + r.access.Lock() + if r.closed || r.networkType == networkType { + r.access.Unlock() + return + } + r.networkType = networkType + r.events = append(r.events, eventRecord{ + Type: eventTypeNetwork, + At: now.UTC().Format(time.RFC3339), + NetworkType: networkType, + }) + r.access.Unlock() + r.notifyWorker() +} + +func (r *Recorder) notifyWorker() { + select { + case r.notify <- struct{}{}: + default: + } +} + +func (r *Recorder) worker() { + defer close(r.workerDone) + timer := time.NewTimer(r.fallbackInterval) + defer timer.Stop() + for { + select { + case <-r.done: + return + case <-r.notify: + case <-timer.C: + timer.Reset(r.fallbackInterval) + } + r.process() + } +} + +func (r *Recorder) process() { + now := time.Now() + r.access.Lock() + defer r.access.Unlock() + if !r.started || r.closed { + return + } + r.consumeBreakLocked() + nowNano := int64(now.Sub(r.baseTime)) + if nowNano-r.lastSampleAt.Load() >= r.sampleNano { + r.lastSampleAt.Store(nowNano) + r.sampleLocked(now) + } + if now.Sub(r.lastFlushAt) >= r.flushInterval || len(r.rows) >= rowCapacity || len(r.events) >= eventCapacity { + r.flushLocked(now) + } +} + +func (r *Recorder) consumeBreakLocked() { + record := r.pendingBreak.Swap(nil) + if record == nil { + return + } + r.events = append(r.events, eventRecord{ + Type: eventTypeBreak, + At: record.at.UTC().Format(time.RFC3339), + IdleMS: record.idleMS, + Direction: record.direction.String(), + Size: record.size, + NetworkType: r.networkType, + By: record.by, + }) +} + +func (r *Recorder) resetPreviousLocked(now time.Time) { + metrics.Read(r.metricsSamples) + r.previous = previousSample{ + at: now, + usage: readSystemUsage(), + gcSeconds: r.metricsSamples[0].Value.Float64(), + interfaces: readInterfaceCounters(), + dnsQueries: r.dnsQueries.Load(), + connectionsOpened: r.connectionsOpened.Load(), + } + r.previous.absoluteTime, r.previous.continuousTime = readClocks() +} + +func (r *Recorder) sampleLocked(now time.Time) { + previous := r.previous + r.resetPreviousLocked(now) + current := &r.previous + row := timelineRow{ + From: previous.at.UTC().Format(time.RFC3339), + To: now.UTC().Format(time.RFC3339), + CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000), + Goroutines: r.metricsSamples[1].Value.Uint64(), + DNSQueries: current.dnsQueries - previous.dnsQueries, + ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened, + NetworkType: r.networkType, + } + if current.usage.valid && previous.usage.valid { + row.CPUUserMS = (current.usage.userTime - previous.usage.userTime) / int64(time.Millisecond) + row.CPUSystemMS = (current.usage.systemTime - previous.usage.systemTime) / int64(time.Millisecond) + row.CPUPerformanceMS = (current.usage.performanceUserTime - previous.usage.performanceUserTime + + current.usage.performanceSystemTime - previous.usage.performanceSystemTime) / int64(time.Millisecond) + row.PackageIdleWakeups = current.usage.packageIdleWakeups - previous.usage.packageIdleWakeups + row.InterruptWakeups = current.usage.interruptWakeups - previous.usage.interruptWakeups + row.EnergyNanojoules = current.usage.energyNanojoules - previous.usage.energyNanojoules + row.PerformanceEnergyNanojoules = current.usage.performanceEnergyNanojoules - previous.usage.performanceEnergyNanojoules + row.DiskBytesWritten = current.usage.diskBytesWritten - previous.usage.diskBytesWritten + qos := qosBreakdown{ + DefaultMS: (current.usage.qosDefaultTime - previous.usage.qosDefaultTime) / int64(time.Millisecond), + MaintenanceMS: (current.usage.qosMaintenanceTime - previous.usage.qosMaintenanceTime) / int64(time.Millisecond), + BackgroundMS: (current.usage.qosBackgroundTime - previous.usage.qosBackgroundTime) / int64(time.Millisecond), + UtilityMS: (current.usage.qosUtilityTime - previous.usage.qosUtilityTime) / int64(time.Millisecond), + LegacyMS: (current.usage.qosLegacyTime - previous.usage.qosLegacyTime) / int64(time.Millisecond), + UserInitiatedMS: (current.usage.qosUserInitiatedTime - previous.usage.qosUserInitiatedTime) / int64(time.Millisecond), + UserInteractiveMS: (current.usage.qosUserInteractiveTime - previous.usage.qosUserInteractiveTime) / int64(time.Millisecond), + } + if qos != (qosBreakdown{}) { + row.QoSMS = &qos + } + } + if current.absoluteTime != 0 && previous.absoluteTime != 0 && current.absoluteTime >= previous.absoluteTime { + sleptNano := (current.continuousTime - previous.continuousTime) - (current.absoluteTime - previous.absoluteTime) + wallNano := now.Sub(previous.at).Nanoseconds() + if sleptNano > wallNano { + sleptNano = wallNano + } + if sleptNano > 0 { + row.SleptMS = sleptNano / int64(time.Millisecond) + } + } + if len(current.interfaces) > 0 && len(previous.interfaces) > 0 { + interfacePackets := make(map[string]uint64) + for name, counters := range current.interfaces { + previousCounters, found := previous.interfaces[name] + if !found { + continue + } + delta := uint64(counters.inPackets-previousCounters.inPackets) + uint64(counters.outPackets-previousCounters.outPackets) + if delta > 0 { + interfacePackets[name] = delta + } + } + if len(interfacePackets) > 0 { + row.InterfacePackets = interfacePackets + } + } + r.rows = append(r.rows, row) +} + +func (r *Recorder) chown(path string) { + if r.ownerCallback != nil { + r.ownerCallback(path) + } +} + +func (r *Recorder) flushLocked(now time.Time) { + err := appendRecords(r, filepath.Join(r.draftPath, timelineFileName), r.rows) + if err == nil { + r.rows = r.rows[:0] + } else { + r.logger.Error(E.Cause(err, "power report: write timeline")) + if len(r.rows) >= rowCapacity { + r.rows = r.rows[len(r.rows)-rowCapacity/2:] + } + } + err = appendRecords(r, filepath.Join(r.draftPath, eventsFileName), r.events) + if err == nil { + r.events = r.events[:0] + } else { + r.logger.Error(E.Cause(err, "power report: write events")) + if len(r.events) >= eventCapacity { + r.events = r.events[len(r.events)-eventCapacity/2:] + } + } + r.lastFlushAt = now +} + +func appendRecords[T any](r *Recorder, path string, records []T) error { + if len(records) == 0 { + return nil + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + return err + } + defer file.Close() + r.chown(path) + encoder := json.NewEncoder(file) + for _, record := range records { + err = encoder.Encode(record) + if err != nil { + return err + } + } + return nil +} + +func (r *Recorder) writeGoroutineProfile() { + profilePath := filepath.Join(r.draftPath, goroutineProfileFileName) + file, err := os.OpenFile(profilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666) + if err != nil { + return + } + defer file.Close() + r.chown(profilePath) + pprof.Lookup("goroutine").WriteTo(file, 0) +} diff --git a/service/powerreport/syscall_windows.go b/service/powerreport/syscall_windows.go new file mode 100644 index 00000000..4438323e --- /dev/null +++ b/service/powerreport/syscall_windows.go @@ -0,0 +1,21 @@ +package powerreport + +//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go syscall_windows.go + +type processIOCounters struct { + readOperationCount uint64 + writeOperationCount uint64 + otherOperationCount uint64 + readTransferCount uint64 + writeTransferCount uint64 + otherTransferCount uint64 +} + +// https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getprocessiocounters +//sys getProcessIoCounters(process windows.Handle, ioCounters *processIOCounters) (err error) = kernel32.GetProcessIoCounters + +// https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryunbiasedinterrupttime +//sys queryUnbiasedInterruptTime(unbiasedTime *uint64) (err error) = kernel32.QueryUnbiasedInterruptTime + +// https://learn.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryinterrupttime +//sys queryInterruptTime(interruptTime *uint64) = api-ms-win-core-realtime-l1-1-1.QueryInterruptTime diff --git a/service/powerreport/sysstats_darwin.go b/service/powerreport/sysstats_darwin.go new file mode 100644 index 00000000..632d7440 --- /dev/null +++ b/service/powerreport/sysstats_darwin.go @@ -0,0 +1,123 @@ +package powerreport + +/* +#include +#include +#include +#include +#include +#include +#include + +// The iOS SDK does not ship libproc.h; the symbol is exported by libSystem +// on all darwin platforms. +int proc_pid_rusage(int pid, int flavor, rusage_info_t *buffer); +*/ +import "C" + +import ( + "os" + "strings" + "sync" + "unsafe" +) + +var ( + timebaseOnce sync.Once + timebaseNumer int64 + timebaseDenom int64 +) + +func machTimebase() (int64, int64) { + timebaseOnce.Do(func() { + var timebase C.struct_mach_timebase_info + C.mach_timebase_info(&timebase) + timebaseNumer = int64(timebase.numer) + timebaseDenom = int64(timebase.denom) + }) + return timebaseNumer, timebaseDenom +} + +func machToNano(value uint64) int64 { + numer, denom := machTimebase() + if denom == 0 { + return int64(value) + } + return int64(value) * numer / denom +} + +// The time fields of rusage_info are in mach_absolute_time units on arm64, +// not nanoseconds; the header does not document this. +func readSystemUsage() systemUsage { + var info C.struct_rusage_info_v6 + result := C.proc_pid_rusage(C.int(os.Getpid()), C.RUSAGE_INFO_V6, (*C.rusage_info_t)(unsafe.Pointer(&info))) + if result == 0 { + return systemUsage{ + valid: true, + userTime: machToNano(uint64(info.ri_user_time)), + systemTime: machToNano(uint64(info.ri_system_time)), + performanceUserTime: machToNano(uint64(info.ri_user_ptime)), + performanceSystemTime: machToNano(uint64(info.ri_system_ptime)), + qosDefaultTime: machToNano(uint64(info.ri_cpu_time_qos_default)), + qosMaintenanceTime: machToNano(uint64(info.ri_cpu_time_qos_maintenance)), + qosBackgroundTime: machToNano(uint64(info.ri_cpu_time_qos_background)), + qosUtilityTime: machToNano(uint64(info.ri_cpu_time_qos_utility)), + qosLegacyTime: machToNano(uint64(info.ri_cpu_time_qos_legacy)), + qosUserInitiatedTime: machToNano(uint64(info.ri_cpu_time_qos_user_initiated)), + qosUserInteractiveTime: machToNano(uint64(info.ri_cpu_time_qos_user_interactive)), + packageIdleWakeups: uint64(info.ri_pkg_idle_wkups), + interruptWakeups: uint64(info.ri_interrupt_wkups), + diskBytesWritten: uint64(info.ri_diskio_byteswritten), + energyNanojoules: uint64(info.ri_energy_nj), + performanceEnergyNanojoules: uint64(info.ri_penergy_nj), + } + } + var infoV4 C.struct_rusage_info_v4 + result = C.proc_pid_rusage(C.int(os.Getpid()), C.RUSAGE_INFO_V4, (*C.rusage_info_t)(unsafe.Pointer(&infoV4))) + if result != 0 { + return systemUsage{} + } + return systemUsage{ + valid: true, + userTime: machToNano(uint64(infoV4.ri_user_time)), + systemTime: machToNano(uint64(infoV4.ri_system_time)), + qosDefaultTime: machToNano(uint64(infoV4.ri_cpu_time_qos_default)), + qosMaintenanceTime: machToNano(uint64(infoV4.ri_cpu_time_qos_maintenance)), + qosBackgroundTime: machToNano(uint64(infoV4.ri_cpu_time_qos_background)), + qosUtilityTime: machToNano(uint64(infoV4.ri_cpu_time_qos_utility)), + qosLegacyTime: machToNano(uint64(infoV4.ri_cpu_time_qos_legacy)), + qosUserInitiatedTime: machToNano(uint64(infoV4.ri_cpu_time_qos_user_initiated)), + qosUserInteractiveTime: machToNano(uint64(infoV4.ri_cpu_time_qos_user_interactive)), + packageIdleWakeups: uint64(infoV4.ri_pkg_idle_wkups), + interruptWakeups: uint64(infoV4.ri_interrupt_wkups), + diskBytesWritten: uint64(infoV4.ri_diskio_byteswritten), + } +} + +func readClocks() (absoluteTime int64, continuousTime int64) { + return machToNano(uint64(C.mach_absolute_time())), machToNano(uint64(C.mach_continuous_time())) +} + +func readInterfaceCounters() map[string]interfaceCounters { + var list *C.struct_ifaddrs + if C.getifaddrs(&list) != 0 { + return nil + } + defer C.freeifaddrs(list) + result := make(map[string]interfaceCounters) + for entry := list; entry != nil; entry = entry.ifa_next { + if entry.ifa_addr == nil || entry.ifa_addr.sa_family != C.AF_LINK || entry.ifa_data == nil { + continue + } + name := C.GoString(entry.ifa_name) + if !strings.HasPrefix(name, "en") && !strings.HasPrefix(name, "pdp_ip") { + continue + } + data := (*C.struct_if_data)(entry.ifa_data) + result[name] = interfaceCounters{ + inPackets: uint32(data.ifi_ipackets), + outPackets: uint32(data.ifi_opackets), + } + } + return result +} diff --git a/service/powerreport/sysstats_darwin_stub.go b/service/powerreport/sysstats_darwin_stub.go new file mode 100644 index 00000000..43ed3b4e --- /dev/null +++ b/service/powerreport/sysstats_darwin_stub.go @@ -0,0 +1,15 @@ +//go:build darwin && !cgo + +package powerreport + +func readSystemUsage() systemUsage { + panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1") +} + +func readClocks() (absoluteTime int64, continuousTime int64) { + panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1") +} + +func readInterfaceCounters() map[string]interfaceCounters { + panic("power report requires CGO on darwin, rebuild with CGO_ENABLED=1") +} diff --git a/service/powerreport/sysstats_linux.go b/service/powerreport/sysstats_linux.go new file mode 100644 index 00000000..cdc82f14 --- /dev/null +++ b/service/powerreport/sysstats_linux.go @@ -0,0 +1,92 @@ +package powerreport + +import ( + "os" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +func readSystemUsage() systemUsage { + var rusage unix.Rusage + err := unix.Getrusage(unix.RUSAGE_SELF, &rusage) + if err != nil { + return systemUsage{} + } + return systemUsage{ + valid: true, + userTime: rusage.Utime.Nano(), + systemTime: rusage.Stime.Nano(), + diskBytesWritten: readWriteBytes(), + } +} + +func readWriteBytes() uint64 { + content, err := os.ReadFile("/proc/self/io") + if err != nil { + return 0 + } + for line := range strings.SplitSeq(string(content), "\n") { + value, found := strings.CutPrefix(line, "write_bytes: ") + if !found { + continue + } + parsed, parseErr := strconv.ParseUint(value, 10, 64) + if parseErr != nil { + return 0 + } + return parsed + } + return 0 +} + +func readClocks() (absoluteTime int64, continuousTime int64) { + var monotonicTime unix.Timespec + err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &monotonicTime) + if err != nil { + return 0, 0 + } + var bootTime unix.Timespec + err = unix.ClockGettime(unix.CLOCK_BOOTTIME, &bootTime) + if err != nil { + return 0, 0 + } + return monotonicTime.Nano(), bootTime.Nano() +} + +func readInterfaceCounters() map[string]interfaceCounters { + content, err := os.ReadFile("/proc/net/dev") + if err != nil { + return nil + } + lines := strings.Split(string(content), "\n") + if len(lines) <= 2 { + return nil + } + result := make(map[string]interfaceCounters) + for _, line := range lines[2:] { + name, counters, found := strings.Cut(line, ":") + if !found { + continue + } + name = strings.TrimSpace(name) + if name == "lo" || strings.HasPrefix(name, "tun") || strings.HasPrefix(name, "utun") || strings.HasPrefix(name, "dummy") { + continue + } + fields := strings.Fields(counters) + if len(fields) < 10 { + continue + } + inPackets, inErr := strconv.ParseUint(fields[1], 10, 64) + outPackets, outErr := strconv.ParseUint(fields[9], 10, 64) + if inErr != nil || outErr != nil { + continue + } + result[name] = interfaceCounters{ + inPackets: uint32(inPackets), + outPackets: uint32(outPackets), + } + } + return result +} diff --git a/service/powerreport/sysstats_stub.go b/service/powerreport/sysstats_stub.go new file mode 100644 index 00000000..a29d636f --- /dev/null +++ b/service/powerreport/sysstats_stub.go @@ -0,0 +1,15 @@ +//go:build !darwin && !linux && !windows + +package powerreport + +func readSystemUsage() systemUsage { + return systemUsage{} +} + +func readClocks() (absoluteTime int64, continuousTime int64) { + return 0, 0 +} + +func readInterfaceCounters() map[string]interfaceCounters { + return nil +} diff --git a/service/powerreport/sysstats_windows.go b/service/powerreport/sysstats_windows.go new file mode 100644 index 00000000..d2b956a2 --- /dev/null +++ b/service/powerreport/sysstats_windows.go @@ -0,0 +1,46 @@ +package powerreport + +import ( + "golang.org/x/sys/windows" +) + +func filetimeDuration(value windows.Filetime) int64 { + return (int64(value.HighDateTime)<<32 | int64(value.LowDateTime)) * 100 +} + +func readSystemUsage() systemUsage { + var creationTime, exitTime, kernelTime, userTime windows.Filetime + err := windows.GetProcessTimes(windows.CurrentProcess(), &creationTime, &exitTime, &kernelTime, &userTime) + if err != nil { + return systemUsage{} + } + usage := systemUsage{ + valid: true, + userTime: filetimeDuration(userTime), + systemTime: filetimeDuration(kernelTime), + } + var ioCounters processIOCounters + err = getProcessIoCounters(windows.CurrentProcess(), &ioCounters) + if err == nil { + usage.diskBytesWritten = ioCounters.writeTransferCount + } + return usage +} + +func readClocks() (absoluteTime int64, continuousTime int64) { + if procQueryInterruptTime.Find() != nil { + return 0, 0 + } + var unbiasedTime uint64 + err := queryUnbiasedInterruptTime(&unbiasedTime) + if err != nil { + return 0, 0 + } + var interruptTime uint64 + queryInterruptTime(&interruptTime) + return int64(unbiasedTime) * 100, int64(interruptTime) * 100 +} + +func readInterfaceCounters() map[string]interfaceCounters { + return nil +} diff --git a/service/powerreport/zsyscall_windows.go b/service/powerreport/zsyscall_windows.go new file mode 100644 index 00000000..8607d47d --- /dev/null +++ b/service/powerreport/zsyscall_windows.go @@ -0,0 +1,68 @@ +// Code generated by 'go generate'; DO NOT EDIT. + +package powerreport + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modapi_ms_win_core_realtime_l1_1_1 = windows.NewLazySystemDLL("api-ms-win-core-realtime-l1-1-1.dll") + modkernel32 = windows.NewLazySystemDLL("kernel32.dll") + + procQueryInterruptTime = modapi_ms_win_core_realtime_l1_1_1.NewProc("QueryInterruptTime") + procGetProcessIoCounters = modkernel32.NewProc("GetProcessIoCounters") + procQueryUnbiasedInterruptTime = modkernel32.NewProc("QueryUnbiasedInterruptTime") +) + +func queryInterruptTime(interruptTime *uint64) { + syscall.SyscallN(procQueryInterruptTime.Addr(), uintptr(unsafe.Pointer(interruptTime))) + return +} + +func getProcessIoCounters(process windows.Handle, ioCounters *processIOCounters) (err error) { + r1, _, e1 := syscall.SyscallN(procGetProcessIoCounters.Addr(), uintptr(process), uintptr(unsafe.Pointer(ioCounters))) + if r1 == 0 { + err = errnoErr(e1) + } + return +} + +func queryUnbiasedInterruptTime(unbiasedTime *uint64) (err error) { + r1, _, e1 := syscall.SyscallN(procQueryUnbiasedInterruptTime.Addr(), uintptr(unsafe.Pointer(unbiasedTime))) + if r1 == 0 { + err = errnoErr(e1) + } + return +} diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 31b7fdd1..45228443 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/service/powerreport" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" @@ -152,6 +153,18 @@ func (e *Endpoint) Start(postStart bool) error { e.egressPool = tun.NewUDPEgressPool(egressPoolOptions) standardBind.SetEgressProvider(e.egressPool) } + powerManager := service.FromContext[*powerreport.Manager](e.options.Context) + if powerManager != nil { + recorder := powerManager.Recorder() + if recorder != nil { + attribution := &powerreport.Attribution{Endpoint: e.options.Tag} + standardBind.SetIOActivityFuncs(func(size int) { + recorder.Touch(powerreport.DirectionInbound, size, attribution) + }, func(size int) { + recorder.Touch(powerreport.DirectionOutbound, size, attribution) + }) + } + } bind = standardBind } else { var ( diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index 446c569e..006010ae 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -27,6 +27,7 @@ type EndpointOptions struct { EgressPoolOptions tun.UDPEgressPoolOptions Dialer N.Dialer CreateDialer func(interfaceName string) N.Dialer + Tag string Name string MTU uint32 Address []netip.Prefix