diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d1df76efb..82649bcf0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -66,6 +66,8 @@ jobs: dockerfile: Web/Resgrid.Web.Tts/Dockerfile - image: resgridllc/resgridworkersconsole dockerfile: Workers/Resgrid.Workers.Console/Dockerfile + - image: resgridllc/resgridtrackergateway + dockerfile: Workers/Resgrid.TrackerGateway/Dockerfile steps: - uses: actions/checkout@v4 @@ -145,6 +147,7 @@ jobs: .replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '') .replace(/^(#+\s*)(Pull Request Description|PR Description)\s*$/gim, '$1Summary') .replace(/Pull Request Description|PR Description/gi, 'Summary') + .replace(/\bPR\b/g, 'Release') .trim() || 'No release notes provided.'; fs.writeFileSync('release_notes.md', body); } else { diff --git a/Core/Resgrid.Config/ChatConfig.cs b/Core/Resgrid.Config/ChatConfig.cs index eefa92a41..95a0b2498 100644 --- a/Core/Resgrid.Config/ChatConfig.cs +++ b/Core/Resgrid.Config/ChatConfig.cs @@ -14,6 +14,8 @@ public static class ChatConfig public static string NovuUnitApnsProviderId = "unit-apns"; public static string NovuResponderFcmProviderId = "respond-firebase-cloud-messaging"; public static string NovuResponderApnsProviderId = "respond-apns"; + public static string NovuICFcmProviderId = "ic-firebase-cloud-messaging"; + public static string NovuICApnsProviderId = "ic-apns"; public static string NovuDispatchUnitWorkflowId = "unit-dispatch"; public static string NovuDispatchUserWorkflowId = "user-dispatch"; public static string NovuMessageUserWorkflowId = "user-message"; diff --git a/Core/Resgrid.Config/UnitTrackingConfig.cs b/Core/Resgrid.Config/UnitTrackingConfig.cs index dbc8ad946..19b6646e8 100644 --- a/Core/Resgrid.Config/UnitTrackingConfig.cs +++ b/Core/Resgrid.Config/UnitTrackingConfig.cs @@ -14,6 +14,10 @@ public static class UnitTrackingConfig public static int DefaultLocationRetentionDays = 90; public static int MinimumLocationRetentionDays = 1; public static int MaximumLocationRetentionDays = 3650; + public static bool LocationRetentionWorkerEnabled = false; + public static int LocationRetentionBatchSize = 1000; + public static int LocationRetentionMaxRowsPerRun = 100000; + public static int LocationRetentionHourUtc = 4; public static int PerDeviceRequestsPerMinute = 120; public static int PerDeviceRecordsPerMinute = 1200; public static int UnknownEndpointRequestsPerMinute = 30; @@ -39,8 +43,14 @@ public static class UnitTrackingConfig public static int TeltonikaTcpPort = 5027; public static int TeltonikaUdpPort = 5027; public static bool EnableQueclink = false; + public static bool EnableQueclinkTcp = true; + public static bool EnableQueclinkUdp = false; public static bool EnableGt06 = false; + public static bool EnableGt06Tcp = true; + public static bool EnableGt06Udp = false; public static bool EnableTeltonika = false; + public static bool EnableTeltonikaTcp = true; + public static bool EnableTeltonikaUdp = false; public static bool RawDiagnosticCaptureEnabled = false; } } diff --git a/Core/Resgrid.Model/Providers/Models/INovuProvider.cs b/Core/Resgrid.Model/Providers/Models/INovuProvider.cs index 452c120fd..7be64f3c0 100644 --- a/Core/Resgrid.Model/Providers/Models/INovuProvider.cs +++ b/Core/Resgrid.Model/Providers/Models/INovuProvider.cs @@ -19,6 +19,19 @@ public interface INovuProvider /// True if the subscriber was created successfully; otherwise, false. Task CreateUserSubscriber(string userId, string code, int departmentId, string email, string firstName, string lastName); + /// + /// Creates a Novu subscriber for an IC (Incident Command) app user. The IC app uses a distinct subscriber + /// id ({code}_IC_User_{userId}) so its Novu inbox is separate from the Responder app's. + /// + /// The unique identifier of the user. + /// The department code prefix. + /// The department the user belongs to. + /// The user's email address. + /// The user's first name. + /// The user's last name. + /// True if the subscriber was created successfully; otherwise, false. + Task CreateICUserSubscriber(string userId, string code, int departmentId, string email, string firstName, string lastName); + /// /// Creates a Novu subscriber for a unit (device or group). /// @@ -66,6 +79,24 @@ public interface INovuProvider /// True if the token was updated successfully; otherwise, false. Task UpdateUserSubscriberApns(string userId, string code, string token); + /// + /// Updates the Firebase Cloud Messaging (FCM) token for an IC app user subscriber. + /// + /// The unique identifier of the user. + /// The department code prefix. + /// The FCM token to associate with the user. + /// True if the token was updated successfully; otherwise, false. + Task UpdateICUserSubscriberFcm(string userId, string code, string token); + + /// + /// Updates the Apple Push Notification Service (APNS) token for an IC app user subscriber. + /// + /// The unique identifier of the user. + /// The department code prefix. + /// The APNS token to associate with the user. + /// True if the token was updated successfully; otherwise, false. + Task UpdateICUserSubscriberApns(string userId, string code, string token); + /// /// Sends a dispatch notification to a unit. /// @@ -94,4 +125,16 @@ Task SendUnitDispatch(string title, string body, int unitId, string depCod Task SendUserMessage(string title, string body, string userId, string depCode, string eventCode, string type); Task SendUserNotification(string title, string body, string userId, string depCode, string eventCode, string type); + + /// + /// Sends a notification to an IC app user subscriber ({depCode}_IC_User_{userId}) via the user-notification workflow. + /// + /// The notification title. + /// The notification body content. + /// The unique identifier of the user to notify. + /// The department code. + /// The event code associated with the notification. + /// The type of notification. + /// True if the notification was sent successfully; otherwise, false. + Task SendICUserNotification(string title, string body, string userId, string depCode, string eventCode, string type); } diff --git a/Core/Resgrid.Model/PushUri.cs b/Core/Resgrid.Model/PushUri.cs index 6414e2d26..bf59db2c3 100644 --- a/Core/Resgrid.Model/PushUri.cs +++ b/Core/Resgrid.Model/PushUri.cs @@ -51,6 +51,14 @@ public class PushUri : IEntity [ProtoMember(7)] public int DepartmentId { get; set; } + /// + /// Source app of the registration (e.g. "IC" for the Incident Command app). Routes the Novu push + /// credential update to the app-specific subscriber; null/empty means the default Responder app. + /// + [NotMapped] + [ProtoMember(10)] + public string Source { get; set; } + [Required] [ProtoMember(6)] public DateTime CreatedOn { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs new file mode 100644 index 000000000..26f12c3ed --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUnitLocationRetentionRepository + { + Task DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs index ba3dd0cc6..3e9e401bc 100644 --- a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs +++ b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System; +using System.Threading; namespace Resgrid.Model.Repositories { @@ -12,5 +14,10 @@ public interface IUnitLocationsDocRepository Task GetByOldIdAsync(string id); Task InsertAsync(UnitsLocation location); Task UpdateAsync(UnitsLocation location); + Task DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs index 94d537f6c..9bdea7042 100644 --- a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs +++ b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs @@ -1,4 +1,6 @@ using System.Threading.Tasks; +using System; +using System.Threading; namespace Resgrid.Model.Repositories { @@ -7,5 +9,10 @@ public interface IUnitLocationsMongoRepository Task EnsureIndexesAsync(); Task InsertAsync(UnitsLocation location); Task UpdateAsync(UnitsLocation location); + Task DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Resgrid.Model.csproj b/Core/Resgrid.Model/Resgrid.Model.csproj index 152dd04a0..9ef49185e 100644 --- a/Core/Resgrid.Model/Resgrid.Model.csproj +++ b/Core/Resgrid.Model/Resgrid.Model.csproj @@ -4,6 +4,7 @@ Debug;Release;Docker + Audio\Emergency_call.caf @@ -92,4 +93,4 @@ - \ No newline at end of file + diff --git a/Core/Resgrid.Model/Services/ICommunicationService.cs b/Core/Resgrid.Model/Services/ICommunicationService.cs index a0bfe6f27..078839161 100644 --- a/Core/Resgrid.Model/Services/ICommunicationService.cs +++ b/Core/Resgrid.Model/Services/ICommunicationService.cs @@ -66,9 +66,10 @@ Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string /// The department number. /// The title. /// The profile. + /// When true, the push goes to the user's IC app subscriber (separate Novu inbox) instead of the Responder one. /// Task<System.Boolean>. - Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, - string title = "Notification", UserProfile profile = null); + Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, + string title = "Notification", UserProfile profile = null, bool sendToICApp = false); /// /// Sends the chat. diff --git a/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs index 7e8e18576..27b64ed90 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -30,5 +31,12 @@ Task NotifyLaneLeadChangedAsync(int departmentId, int callId, string laneName, b /// Notifies every active user and unit on the incident (plus both commanders) that command was transferred. Task NotifyCommandTransferredAsync(IncidentCommand command, string fromUserId, string toUserId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Sends a free-form message from a department member directly to specific command users (e.g. the + /// incident commander and deputies). The sender's display name is prepended to the body so recipients + /// know who it came from on every channel. + /// + Task SendMessageToCommandAsync(int departmentId, string senderUserId, string title, string body, IEnumerable recipientUserIds, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/IPushService.cs b/Core/Resgrid.Model/Services/IPushService.cs index 6c68d441d..c426879c7 100644 --- a/Core/Resgrid.Model/Services/IPushService.cs +++ b/Core/Resgrid.Model/Services/IPushService.cs @@ -12,6 +12,7 @@ public interface IPushService Task UnRegister(PushUri pushUri); void UnRegisterNotificationOnly(PushUri pushUri); Task PushNotification(StandardPushMessage message, string userId, UserProfile profile = null); + Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null); Task RegisterUnit(PushUri pushUri); Task UnRegisterUnit(PushUri pushUri); Task PushChat(StandardPushMessage message, string userId, UserProfile profile = null); diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs index fcbd37ede..c39f0d42e 100644 --- a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs +++ b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -11,5 +12,10 @@ Task AcceptAsync( AuthenticatedTrackingSource source, IReadOnlyCollection positions, CancellationToken cancellationToken = default); + + Task AcceptHeartbeatAsync( + AuthenticatedTrackingSource source, + DateTime receivedOnUtc, + CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json b/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json new file mode 100644 index 000000000..dbb3e7fe9 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json @@ -0,0 +1,448 @@ +{ + "profiles": [ + { + "key": "generic-https", + "manufacturerKey": "generic", + "manufacturerName": "Generic", + "model": "Resgrid JSON", + "transportType": "NativeHttps", + "protocolKey": "resgrid-json", + "payloadAdapterKey": "resgrid-json-v1", + "decoderVariant": "resgrid-json-v1", + "supportedTransports": [ "Https" ], + "certifiedTransports": [ "Https" ], + "certificationStatus": "Certified", + "protocolDocumentVersion": "resgrid-json-v1", + "identifierRequired": false, + "isSelectable": true, + "supportedAuthModes": [ "Bearer", "Basic", "CustomHeader", "CapabilityPath" ], + "setupSummary": "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.", + "retryExpectation": "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery." + }, + { + "key": "traccar-forwarder", + "manufacturerKey": "traccar", + "manufacturerName": "Traccar", + "model": "Position Forwarding", + "transportType": "ProtocolGateway", + "protocolKey": "traccar", + "payloadAdapterKey": "traccar-json-v1", + "decoderVariant": "traccar-json-v1", + "supportedTransports": [ "Https" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "6.14.5", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [ "Bearer", "Basic", "CustomHeader", "CapabilityPath" ], + "setupSummary": "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.", + "retryExpectation": "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures." + }, + { + "key": "teltonika-fmc920", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC920", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26", + "ioMapKey": "teltonika-fmb-03-29-common", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmm920", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM920", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26", + "ioMapKey": "teltonika-fmb-03-29-common", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmc130", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC130", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26", + "ioMapKey": "teltonika-fmb-03-29-common", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmm130", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM130", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26", + "ioMapKey": "teltonika-fmb-03-29-common", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmc003", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC003", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26", + "ioMapKey": "teltonika-fmb-03-29-common", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "queclink-gv57mg", + "manufacturerKey": "queclink", + "manufacturerName": "Queclink", + "model": "GV57MG", + "transportType": "NativeTcpUdp", + "protocolKey": "queclink-attrack", + "decoderVariant": "gl200-text-bounded", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the GV57MG to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.", + "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response." + }, + { + "key": "queclink-gv350mg", + "manufacturerKey": "queclink", + "manufacturerName": "Queclink", + "model": "GV350MG", + "transportType": "NativeTcpUdp", + "protocolKey": "queclink-attrack", + "decoderVariant": "gl200-text-bounded", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the GV350MG to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.", + "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response." + }, + { + "key": "queclink-gv500ma", + "manufacturerKey": "queclink", + "manufacturerName": "Queclink", + "model": "GV500MA", + "transportType": "NativeTcpUdp", + "protocolKey": "queclink-attrack", + "decoderVariant": "gl200-text-bounded", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the GV500MA to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.", + "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response." + }, + { + "key": "jimi-vl103m", + "manufacturerKey": "jimi", + "manufacturerName": "Jimi IoT", + "model": "VL103M", + "transportType": "NativeTcpUdp", + "protocolKey": "gt06", + "decoderVariant": "gt06-vl103-bounded", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Traccar v6.14.5 gt06 behavior at 5c5e710d; model certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the VL103M to send the bounded GT06 login, heartbeat/status, and location messages over TCP using its 15-digit IMEI identifier.", + "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial." + }, + { + "key": "jimi-jm-vl03", + "manufacturerKey": "jimi", + "manufacturerName": "Jimi IoT", + "model": "JM-VL03", + "transportType": "NativeTcpUdp", + "protocolKey": "gt06", + "decoderVariant": "gt06-jm-vl03-a0-bounded", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "Traccar v6.14.5 gt06 behavior at 5c5e710d; model certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Configure the JM-VL03 to send the bounded GT06 login, heartbeat/status, and JM-VL03 A0 location messages over TCP using its 15-digit IMEI identifier.", + "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial." + }, + { + "key": "teltonika-fmm003", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM003", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmc125", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC125", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmm125", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM125", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmc150", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC150", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmm150", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM150", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmc230", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMC230", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "teltonika-fmm230", + "manufacturerKey": "teltonika", + "manufacturerName": "Teltonika", + "model": "FMM230", + "transportType": "NativeTcpUdp", + "protocolKey": "teltonika-codec8", + "decoderVariant": "codec8-or-8e", + "supportedTransports": [ "Tcp", "Udp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.", + "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count." + }, + { + "key": "jimi-jm-vl01", + "manufacturerKey": "jimi", + "manufacturerName": "Jimi IoT", + "model": "JM-VL01", + "transportType": "NativeTcpUdp", + "protocolKey": "gt06", + "decoderVariant": "gt06-jm-vl01-unverified", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.", + "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial." + }, + { + "key": "jimi-jm-vl02", + "manufacturerKey": "jimi", + "manufacturerName": "Jimi IoT", + "model": "JM-VL02", + "transportType": "NativeTcpUdp", + "protocolKey": "gt06", + "decoderVariant": "gt06-jm-vl02-unverified", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.", + "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial." + }, + { + "key": "jimi-jm-vl04", + "manufacturerKey": "jimi", + "manufacturerName": "Jimi IoT", + "model": "JM-VL04", + "transportType": "NativeTcpUdp", + "protocolKey": "gt06", + "decoderVariant": "gt06-jm-vl04-unverified", + "supportedTransports": [ "Tcp" ], + "certifiedTransports": [], + "certificationStatus": "Candidate", + "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending", + "identifierRequired": true, + "isSelectable": false, + "supportedAuthModes": [], + "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.", + "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial." + } + ], + "ioMaps": [ + { + "key": "teltonika-fmb-03-29-common", + "protocolKey": "teltonika-codec8", + "protocolDocumentVersion": "FMB.Ver.03.29.00 and newer; reviewed 2026-07-26", + "mappings": [ + { + "avlId": 182, + "valueBytes": 2, + "target": "Hdop", + "multiplier": 0.1, + "minimumRawValue": 0, + "maximumRawValue": 500 + }, + { + "avlId": 66, + "valueBytes": 2, + "target": "ExternalPowerVolts", + "multiplier": 0.001, + "minimumRawValue": 0, + "maximumRawValue": 65535 + }, + { + "avlId": 239, + "valueBytes": 1, + "target": "Ignition", + "multiplier": 1, + "minimumRawValue": 0, + "maximumRawValue": 1 + }, + { + "avlId": 240, + "valueBytes": 1, + "target": "IsMoving", + "multiplier": 1, + "minimumRawValue": 0, + "maximumRawValue": 1 + } + ] + } + ] +} diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs new file mode 100644 index 000000000..6acba3ecf --- /dev/null +++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Resgrid.Model.Tracking +{ + public static class UnitTrackingCatalog + { + private const string CatalogResourceSuffix = + ".Tracking.Catalog.unit-tracking-catalog.json"; + + private static readonly Lazy Catalog = + new(LoadAndValidate); + + public static IReadOnlyCollection Profiles => + Catalog.Value.Profiles; + + public static IReadOnlyCollection IoMaps => + Catalog.Value.IoMaps; + + public static UnitTrackingCatalogProfile GetProfile(string profileKey) + { + if (string.IsNullOrWhiteSpace(profileKey)) + return null; + + return Profiles.FirstOrDefault(profile => + string.Equals( + profile.Key, + profileKey.Trim(), + StringComparison.OrdinalIgnoreCase)); + } + + public static UnitTrackingIoMap GetIoMap(string ioMapKey) + { + if (string.IsNullOrWhiteSpace(ioMapKey)) + return null; + + return IoMaps.FirstOrDefault(ioMap => + string.Equals( + ioMap.Key, + ioMapKey.Trim(), + StringComparison.OrdinalIgnoreCase)); + } + + private static UnitTrackingCatalogDocument LoadAndValidate() + { + var assembly = typeof(UnitTrackingCatalog).Assembly; + var resourceName = assembly + .GetManifestResourceNames() + .SingleOrDefault(name => + name.EndsWith( + CatalogResourceSuffix, + StringComparison.Ordinal)); + if (resourceName == null) + { + throw new InvalidOperationException( + "The embedded unit tracking catalog was not found."); + } + + using var stream = assembly.GetManifestResourceStream( + resourceName); + if (stream == null) + { + throw new InvalidOperationException( + "The embedded unit tracking catalog could not be opened."); + } + + using var reader = new StreamReader(stream); + UnitTrackingCatalogDocument document; + try + { + document = JsonConvert.DeserializeObject< + UnitTrackingCatalogDocument>( + reader.ReadToEnd(), + new JsonSerializerSettings + { + MissingMemberHandling = + MissingMemberHandling.Error, + Converters = + { + new StringEnumConverter() + } + }); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "The embedded unit tracking catalog is invalid.", + ex); + } + + Validate(document); + document.Profiles = + document.Profiles.ToList().AsReadOnly(); + document.IoMaps = + document.IoMaps.ToList().AsReadOnly(); + foreach (var profile in document.Profiles) + { + profile.SupportedTransports = + profile.SupportedTransports.ToList().AsReadOnly(); + profile.CertifiedTransports = + profile.CertifiedTransports.ToList().AsReadOnly(); + profile.SupportedAuthModes = + profile.SupportedAuthModes.ToList().AsReadOnly(); + } + foreach (var ioMap in document.IoMaps) + { + ioMap.Mappings = + ioMap.Mappings.ToList().AsReadOnly(); + } + + return document; + } + + private static void Validate( + UnitTrackingCatalogDocument document) + { + if (document?.Profiles == null || + document.Profiles.Count == 0) + { + throw new InvalidOperationException( + "The unit tracking catalog must contain profiles."); + } + if (document.IoMaps == null) + { + throw new InvalidOperationException( + "The unit tracking catalog must contain an ioMaps collection."); + } + + ValidateUniqueKeys( + document.Profiles.Select(profile => profile?.Key), + "profile"); + ValidateUniqueKeys( + document.IoMaps.Select(ioMap => ioMap?.Key), + "I/O map"); + + foreach (var ioMap in document.IoMaps) + ValidateIoMap(ioMap); + foreach (var profile in document.Profiles) + ValidateProfile(profile, document.IoMaps); + } + + private static void ValidateProfile( + UnitTrackingCatalogProfile profile, + IReadOnlyCollection ioMaps) + { + if (profile == null) + throw new InvalidOperationException( + "The unit tracking catalog contains a null profile."); + + RequireText(profile.Key, "profile key"); + RequireCanonicalKey(profile.Key, "profile key"); + RequireText( + profile.ManufacturerKey, + $"manufacturer key for '{profile.Key}'"); + RequireCanonicalKey( + profile.ManufacturerKey, + $"manufacturer key for '{profile.Key}'"); + RequireText( + profile.ManufacturerName, + $"manufacturer name for '{profile.Key}'"); + RequireText( + profile.Model, + $"model for '{profile.Key}'"); + RequireText( + profile.ProtocolKey, + $"protocol key for '{profile.Key}'"); + RequireCanonicalKey( + profile.ProtocolKey, + $"protocol key for '{profile.Key}'"); + RequireText( + profile.DecoderVariant, + $"decoder variant for '{profile.Key}'"); + RequireCanonicalKey( + profile.DecoderVariant, + $"decoder variant for '{profile.Key}'"); + RequireText( + profile.ProtocolDocumentVersion, + $"protocol document version for '{profile.Key}'"); + RequireText( + profile.SetupSummary, + $"setup summary for '{profile.Key}'"); + RequireText( + profile.RetryExpectation, + $"retry expectation for '{profile.Key}'"); + + if (!Enum.IsDefined(profile.TransportType) || + profile.TransportType == + UnitTrackingTransportType.Unknown) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' has an invalid transport type."); + } + if (!Enum.IsDefined(profile.CertificationStatus) || + profile.CertificationStatus == + UnitTrackingCertificationStatus.Unknown) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' has an invalid certification status."); + } + if (profile.SupportedTransports == null || + profile.SupportedTransports.Count == 0) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' must declare supported transports."); + } + if (profile.CertifiedTransports == null || + profile.SupportedAuthModes == null) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' contains a null collection."); + } + if (profile.SupportedAuthModes.Any(mode => + !Enum.IsDefined(mode) || + mode == UnitTrackingAuthMode.Unknown) || + profile.SupportedAuthModes.Distinct().Count() != + profile.SupportedAuthModes.Count) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' declares an invalid authentication mode."); + } + if (!string.IsNullOrWhiteSpace( + profile.PayloadAdapterKey)) + { + RequireCanonicalKey( + profile.PayloadAdapterKey, + $"payload adapter key for '{profile.Key}'"); + } + + ValidateTransports( + profile.Key, + profile.SupportedTransports); + ValidateTransports( + profile.Key, + profile.CertifiedTransports); + if (profile.CertifiedTransports.Any(transport => + !profile.SupportedTransports.Contains( + transport, + StringComparer.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' certifies an unsupported transport."); + } + if (profile.CertificationStatus == + UnitTrackingCertificationStatus.Certified && + profile.CertifiedTransports.Count == 0) + { + throw new InvalidOperationException( + $"Certified profile '{profile.Key}' must declare a certified transport."); + } + if (profile.CertificationStatus != + UnitTrackingCertificationStatus.Certified && + profile.CertifiedTransports.Count != 0) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' cannot certify transports before profile certification."); + } + if (profile.IsSelectable && + profile.CertificationStatus != + UnitTrackingCertificationStatus.Certified) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' cannot be selectable before certification."); + } + + var nativeProfile = + profile.TransportType == + UnitTrackingTransportType.NativeTcpUdp; + if (nativeProfile && + profile.SupportedTransports.Any(transport => + !string.Equals( + transport, + "Tcp", + StringComparison.OrdinalIgnoreCase) && + !string.Equals( + transport, + "Udp", + StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"Native profile '{profile.Key}' declares a non-socket transport."); + } + if (!nativeProfile && + string.IsNullOrWhiteSpace( + profile.PayloadAdapterKey)) + { + throw new InvalidOperationException( + $"Non-native profile '{profile.Key}' must declare a payload adapter."); + } + + if (string.IsNullOrWhiteSpace(profile.IoMapKey)) + return; + + RequireCanonicalKey( + profile.IoMapKey, + $"I/O map key for '{profile.Key}'"); + var ioMap = ioMaps.SingleOrDefault(candidate => + string.Equals( + candidate.Key, + profile.IoMapKey, + StringComparison.OrdinalIgnoreCase)); + if (ioMap == null) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' references an unknown I/O map."); + } + if (!string.Equals( + ioMap.ProtocolKey, + profile.ProtocolKey, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Profile '{profile.Key}' references an I/O map for another protocol."); + } + } + + private static void ValidateIoMap( + UnitTrackingIoMap ioMap) + { + if (ioMap == null) + throw new InvalidOperationException( + "The unit tracking catalog contains a null I/O map."); + + RequireText(ioMap.Key, "I/O map key"); + RequireCanonicalKey(ioMap.Key, "I/O map key"); + RequireText( + ioMap.ProtocolKey, + $"protocol key for I/O map '{ioMap.Key}'"); + RequireCanonicalKey( + ioMap.ProtocolKey, + $"protocol key for I/O map '{ioMap.Key}'"); + RequireText( + ioMap.ProtocolDocumentVersion, + $"protocol document version for I/O map '{ioMap.Key}'"); + if (ioMap.Mappings == null || + ioMap.Mappings.Count == 0) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' must contain mappings."); + } + if (ioMap.Mappings.GroupBy(mapping => mapping.AvlId) + .Any(group => group.Count() > 1)) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains a duplicate AVL ID."); + } + if (ioMap.Mappings.GroupBy(mapping => mapping.Target) + .Any(group => group.Count() > 1)) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains a duplicate canonical target."); + } + + foreach (var mapping in ioMap.Mappings) + { + if (mapping.AvlId < 0 || + mapping.AvlId > ushort.MaxValue) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains an invalid AVL ID."); + } + if (mapping.ValueBytes != 1 && + mapping.ValueBytes != 2 && + mapping.ValueBytes != 4 && + mapping.ValueBytes != 8) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains an invalid value width."); + } + if (!Enum.IsDefined(mapping.Target) || + mapping.Target == + UnitTrackingIoTarget.Unknown) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains an invalid target."); + } + if (mapping.Multiplier <= 0) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains an invalid multiplier."); + } + var maximumValue = + mapping.ValueBytes == 8 + ? ulong.MaxValue + : (1UL << (mapping.ValueBytes * 8)) - 1; + if (!mapping.MinimumRawValue.HasValue || + !mapping.MaximumRawValue.HasValue || + mapping.MinimumRawValue.Value > + mapping.MaximumRawValue.Value || + mapping.MaximumRawValue.Value > + maximumValue) + { + throw new InvalidOperationException( + $"I/O map '{ioMap.Key}' contains an invalid raw value range."); + } + } + } + + private static void ValidateUniqueKeys( + IEnumerable keys, + string itemType) + { + if (keys.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException( + $"The unit tracking catalog contains a {itemType} without a key."); + } + if (keys.GroupBy( + key => key, + StringComparer.OrdinalIgnoreCase) + .Any(group => group.Count() > 1)) + { + throw new InvalidOperationException( + $"The unit tracking catalog contains a duplicate {itemType} key."); + } + } + + private static void ValidateTransports( + string profileKey, + IReadOnlyCollection transports) + { + if (transports.Any(transport => + !string.Equals( + transport, + "Https", + StringComparison.Ordinal) && + !string.Equals( + transport, + "Tcp", + StringComparison.Ordinal) && + !string.Equals( + transport, + "Udp", + StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Profile '{profileKey}' declares an invalid transport."); + } + if (transports.Distinct( + StringComparer.OrdinalIgnoreCase) + .Count() != transports.Count) + { + throw new InvalidOperationException( + $"Profile '{profileKey}' declares a duplicate transport."); + } + } + + private static void RequireText( + string value, + string fieldName) + { + if (string.IsNullOrWhiteSpace(value) || + !string.Equals( + value, + value.Trim(), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The unit tracking catalog has an invalid {fieldName}."); + } + } + + private static void RequireCanonicalKey( + string value, + string fieldName) + { + if (value.StartsWith( + "-", + StringComparison.Ordinal) || + value.EndsWith( + "-", + StringComparison.Ordinal) || + value.Contains( + "--", + StringComparison.Ordinal) || + value.Any(character => + !(character >= 'a' && character <= 'z') && + !(character >= '0' && character <= '9') && + character != '-')) + { + throw new InvalidOperationException( + $"The unit tracking catalog has a non-canonical {fieldName}."); + } + } + + private sealed class UnitTrackingCatalogDocument + { + public IReadOnlyCollection Profiles { get; set; } + public IReadOnlyCollection IoMaps { get; set; } + } + } +} diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs index f53abe061..f34ae862e 100644 --- a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs +++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs @@ -12,7 +12,14 @@ public sealed class UnitTrackingCatalogProfile public UnitTrackingTransportType TransportType { get; set; } public string ProtocolKey { get; set; } public string PayloadAdapterKey { get; set; } + public string DecoderVariant { get; set; } + public IReadOnlyCollection SupportedTransports { get; set; } = + Array.Empty(); + public IReadOnlyCollection CertifiedTransports { get; set; } = + Array.Empty(); public UnitTrackingCertificationStatus CertificationStatus { get; set; } + public string ProtocolDocumentVersion { get; set; } + public string IoMapKey { get; set; } public bool IdentifierRequired { get; set; } public bool IsSelectable { get; set; } public IReadOnlyCollection SupportedAuthModes { get; set; } = diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs b/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs new file mode 100644 index 000000000..4dcf4ef44 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Tracking +{ + public enum UnitTrackingIoTarget + { + Unknown = 0, + Hdop = 1, + BatteryPercent = 2, + ExternalPowerVolts = 3, + SignalPercent = 4, + Ignition = 5, + IsMoving = 6 + } + + public sealed class UnitTrackingIoMap + { + public string Key { get; set; } + public string ProtocolKey { get; set; } + public string ProtocolDocumentVersion { get; set; } + public IReadOnlyCollection Mappings { get; set; } = + Array.Empty(); + } + + public sealed class UnitTrackingIoMapping + { + public int AvlId { get; set; } + public int ValueBytes { get; set; } + public UnitTrackingIoTarget Target { get; set; } + public decimal Multiplier { get; set; } = 1m; + public ulong? MinimumRawValue { get; set; } + public ulong? MaximumRawValue { get; set; } + } +} diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs b/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs new file mode 100644 index 000000000..7b21e9cc6 --- /dev/null +++ b/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; + +namespace Resgrid.Model.Tracking +{ + public static class UnitTrackingSourceNetworkPolicy + { + public static bool IsAllowed( + IPAddress remoteAddress, + string allowedSourceCidrs) + { + if (string.IsNullOrWhiteSpace(allowedSourceCidrs)) + return true; + if (remoteAddress == null) + return false; + + var ranges = allowedSourceCidrs.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries); + return ranges.Length > 0 && + ranges.Any(range => Contains(range, remoteAddress)); + } + + private static bool Contains( + string range, + IPAddress candidate) + { + var parts = range.Split( + '/', + 2, + StringSplitOptions.TrimEntries); + if (!IPAddress.TryParse(parts[0], out var network)) + return false; + + var candidateAddress = NormalizeFamily( + candidate, + network.AddressFamily); + if (candidateAddress == null) + return false; + + var networkBytes = network.GetAddressBytes(); + var candidateBytes = candidateAddress.GetAddressBytes(); + var maximumPrefix = networkBytes.Length * 8; + var prefix = maximumPrefix; + if (parts.Length == 2 && + (!int.TryParse(parts[1], out prefix) || + prefix < 0 || + prefix > maximumPrefix)) + return false; + + var fullBytes = prefix / 8; + var remainingBits = prefix % 8; + for (var index = 0; index < fullBytes; index++) + { + if (networkBytes[index] != candidateBytes[index]) + return false; + } + + if (remainingBits == 0) + return true; + + var mask = (byte)(0xff << (8 - remainingBits)); + return (networkBytes[fullBytes] & mask) == + (candidateBytes[fullBytes] & mask); + } + + private static IPAddress NormalizeFamily( + IPAddress address, + AddressFamily targetFamily) + { + if (address.AddressFamily == targetFamily) + return address; + + if (targetFamily == AddressFamily.InterNetwork && + address.IsIPv4MappedToIPv6) + return address.MapToIPv4(); + + if (targetFamily == AddressFamily.InterNetworkV6 && + address.AddressFamily == AddressFamily.InterNetwork) + return address.MapToIPv6(); + + return null; + } + } +} diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 3ae3818fa..61767159b 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -566,7 +566,7 @@ public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit disp return true; } - public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null) + public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) return false; @@ -615,7 +615,10 @@ public async Task SendNotificationAsync(string userId, int departmentId, s try { - await _pushService.PushNotification(spm, userId, profile); + if (sendToICApp) + await _pushService.PushICNotification(spm, userId, profile); + else + await _pushService.PushNotification(spm, userId, profile); } catch (Exception ex) diff --git a/Core/Resgrid.Services/IncidentCommandNotificationService.cs b/Core/Resgrid.Services/IncidentCommandNotificationService.cs index e375f14b4..33b91faef 100644 --- a/Core/Resgrid.Services/IncidentCommandNotificationService.cs +++ b/Core/Resgrid.Services/IncidentCommandNotificationService.cs @@ -114,6 +114,33 @@ await BroadcastToIncidentAsync(command.DepartmentId, command.CallId, "Command Tr new List { fromUserId, toUserId }, cancellationToken); } + public async Task SendMessageToCommandAsync(int departmentId, string senderUserId, string title, string body, IEnumerable recipientUserIds, CancellationToken cancellationToken = default(CancellationToken)) + { + var senderName = await ResolveDisplayNameAsync(senderUserId, null) ?? "A department member"; + var fullBody = $"From {senderName}: {body}"; + + var recipients = (recipientUserIds ?? Enumerable.Empty()) + .Where(u => !string.IsNullOrWhiteSpace(u)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var recipientUserId in recipients) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await SendToUserAsync(recipientUserId, departmentId, title, fullBody); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + } + /// Routes one message to the resource behind an assignment: own-department users and units only. private async Task NotifyResourceAsync(ResourceAssignment assignment, string title, string body, CancellationToken cancellationToken) { @@ -195,7 +222,8 @@ private async Task SendToUserAsync(string userId, int departmentId, string title var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId); var profile = await _userProfileService.GetProfileByUserIdAsync(userId); - await _communicationService.SendNotificationAsync(userId, departmentId, body, departmentNumber, department, title, profile); + // Command notifications target the IC app's Novu subscriber so they land in the IC inbox, not the Responder one. + await _communicationService.SendNotificationAsync(userId, departmentId, body, departmentNumber, department, title, profile, true); } private async Task SendToUnitAsync(int unitId, int departmentId, int callId, string title, string body) diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index 021fff284..9c59a2bad 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -36,14 +36,23 @@ public async Task Register(PushUri pushUri) return false; var code = pushUri.PushLocation; + // IC app registrations target the IC-specific Novu subscriber, keeping its inbox/push separate from the Responder app. + var isICApp = string.Equals(pushUri.Source, "IC", StringComparison.OrdinalIgnoreCase); + + if (isICApp) + await EnsureICUserSubscriber(pushUri, code); // 1) iOS -> APNS if (pushUri.PlatformType == (int)Platforms.iOS) - return await _novuProvider.UpdateUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId); + return isICApp + ? await _novuProvider.UpdateICUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId) + : await _novuProvider.UpdateUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId); // 2) Android -> FCM if (pushUri.PlatformType == (int)Platforms.Android) - return await _novuProvider.UpdateUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId); + return isICApp + ? await _novuProvider.UpdateICUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId) + : await _novuProvider.UpdateUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId); // 3) TODO: Web Push (other platforms) return false; @@ -56,6 +65,20 @@ public async Task UnRegister(PushUri pushUri) return true; } + private async Task EnsureICUserSubscriber(PushUri pushUri, string code) + { + try + { + var profile = await _userProfileService.GetProfileByUserIdAsync(pushUri.UserId); + await _novuProvider.CreateICUserSubscriber(pushUri.UserId, code, pushUri.DepartmentId, + profile?.MembershipEmail, profile?.FirstName, profile?.LastName); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + } + } + public async Task RegisterUnit(PushUri pushUri) { if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation)) @@ -156,6 +179,35 @@ public async Task PushNotification(StandardPushMessage message, string use return true; } + /// + /// Pushes a notification to the user's IC app (distinct Novu subscriber) only — no legacy Azure path, + /// so devices running just the Responder app aren't alerted for IC-only events. + /// + public async Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null) + { + if (message == null) + return false; + + if (profile == null) + profile = await _userProfileService.GetProfileByUserIdAsync(userId); + + if (profile != null && profile.SendNotificationPush) + { + string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification); + + try + { + if (!string.IsNullOrWhiteSpace(message.DepartmentCode)) + await _novuProvider.SendICUserNotification(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("N{0}", message.MessageId), soundType); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + } + } + return true; + } + public async Task PushChat(StandardPushMessage message, string userId, UserProfile profile = null) { if (message == null) diff --git a/Core/Resgrid.Services/UnitTrackingCatalogService.cs b/Core/Resgrid.Services/UnitTrackingCatalogService.cs index 4e3441948..a052311b9 100644 --- a/Core/Resgrid.Services/UnitTrackingCatalogService.cs +++ b/Core/Resgrid.Services/UnitTrackingCatalogService.cs @@ -1,9 +1,6 @@ -using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Resgrid.Model; using Resgrid.Model.Services; using Resgrid.Model.Tracking; @@ -11,64 +8,12 @@ namespace Resgrid.Services { public class UnitTrackingCatalogService : IUnitTrackingCatalogService { - private static readonly IReadOnlyCollection Profiles = - new[] - { - new UnitTrackingCatalogProfile - { - Key = "generic-https", - ManufacturerKey = "generic", - ManufacturerName = "Generic", - Model = "Resgrid JSON", - TransportType = UnitTrackingTransportType.NativeHttps, - ProtocolKey = "resgrid-json", - PayloadAdapterKey = "resgrid-json-v1", - CertificationStatus = UnitTrackingCertificationStatus.Certified, - IdentifierRequired = false, - IsSelectable = true, - SupportedAuthModes = new[] - { - UnitTrackingAuthMode.Bearer, - UnitTrackingAuthMode.Basic, - UnitTrackingAuthMode.CustomHeader, - UnitTrackingAuthMode.CapabilityPath - }, - SetupSummary = - "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.", - RetryExpectation = - "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery." - }, - new UnitTrackingCatalogProfile - { - Key = "traccar-forwarder", - ManufacturerKey = "traccar", - ManufacturerName = "Traccar", - Model = "Position Forwarding", - TransportType = UnitTrackingTransportType.ProtocolGateway, - ProtocolKey = "traccar", - PayloadAdapterKey = "traccar-json-v1", - CertificationStatus = UnitTrackingCertificationStatus.Candidate, - IdentifierRequired = true, - IsSelectable = false, - SupportedAuthModes = new[] - { - UnitTrackingAuthMode.Bearer, - UnitTrackingAuthMode.Basic, - UnitTrackingAuthMode.CustomHeader, - UnitTrackingAuthMode.CapabilityPath - }, - SetupSummary = - "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.", - RetryExpectation = - "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures." - } - }; - public Task> GetProfilesAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(Profiles); + return Task.FromResult( + UnitTrackingCatalog.Profiles); } public Task GetProfileAsync( @@ -76,9 +21,8 @@ public Task GetProfileAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var profile = Profiles.FirstOrDefault(item => - string.Equals(item.Key, profileKey?.Trim(), StringComparison.OrdinalIgnoreCase)); - return Task.FromResult(profile); + return Task.FromResult( + UnitTrackingCatalog.GetProfile(profileKey)); } } } diff --git a/Core/Resgrid.Services/UnitTrackingIngressService.cs b/Core/Resgrid.Services/UnitTrackingIngressService.cs index 01d7e8b5f..27cb5de90 100644 --- a/Core/Resgrid.Services/UnitTrackingIngressService.cs +++ b/Core/Resgrid.Services/UnitTrackingIngressService.cs @@ -134,6 +134,67 @@ await TryUpdateDeviceStatusAsync( }; } + public async Task AcceptHeartbeatAsync( + AuthenticatedTrackingSource source, + DateTime receivedOnUtc, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var receivedOn = EnsureUtc( + receivedOnUtc == default + ? DateTime.UtcNow + : receivedOnUtc); + + if (!IsEnabled(source?.Device)) + return Invalid( + receivedOn, + "The tracking binding is not enabled."); + + var device = source.Device; + var unit = await _unitsService.GetUnitByIdAsync( + device.UnitId); + if (unit == null || + unit.DepartmentId != device.DepartmentId) + { + await TryUpdateDeviceStatusAsync( + device, + receivedOn, + null, + "tenant-binding-invalid", + cancellationToken); + return Invalid( + receivedOn, + "The tracking binding is invalid."); + } + + if (!IdentifierMatches( + device.DeviceIdentifier, + source.ReportedDeviceIdentifier)) + { + await TryUpdateDeviceStatusAsync( + device, + receivedOn, + null, + "identifier-mismatch", + cancellationToken); + return Invalid( + receivedOn, + "The reported device identifier does not match the binding."); + } + + await TryUpdateDeviceStatusAsync( + device, + receivedOn, + null, + null, + cancellationToken); + return new TrackingIngressResult + { + Status = TrackingIngressStatus.Accepted, + ReceivedOn = receivedOn + }; + } + private NormalizationResult NormalizeAll( IReadOnlyCollection positions, int retentionDays) diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index 8b38f2462..6d426c085 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -9,8 +9,8 @@ services: - resgrid.env restart: always networks: - - rgmain: - ipv4_address: 172.16.193.51 + rgmain: + ipv4_address: 172.16.193.51 depends_on: - api - events @@ -31,8 +31,8 @@ services: - resgrid.env restart: always networks: - - rgmain: - ipv4_address: 172.16.193.52 + rgmain: + ipv4_address: 172.16.193.52 depends_on: - events - db @@ -52,8 +52,8 @@ services: - resgrid.env restart: always networks: - - rgmain: - ipv4_address: 172.16.193.53 + rgmain: + ipv4_address: 172.16.193.53 depends_on: - db - redis @@ -69,8 +69,8 @@ services: - resgrid.env restart: always networks: - - rgmain: - ipv4_address: 172.16.193.54 + rgmain: + ipv4_address: 172.16.193.54 depends_on: - db - redis @@ -81,12 +81,44 @@ services: - WAIT_AFTER=90 - WAIT_TIMEOUT=180 + tracker-gateway: + profiles: ["tracking"] + image: "resgridllc/resgridtrackergateway:0.6.70" + env_file: + - resgrid.env + restart: always + ports: + - "5004:5004/tcp" + - "5004:5004/udp" + - "5023:5023/tcp" + - "5023:5023/udp" + - "5027:5027/tcp" + - "5027:5027/udp" + networks: + rgmain: + ipv4_address: 172.16.193.60 + depends_on: + - db + - redis + - rabbitmq + environment: + - WAIT_HOSTS=db:1433,redis:6379,rabbitmq:15672 + - WAIT_TIMEOUT=300 + # LocalXpose tunneling for the raw TCP/UDP ingest ports (https://localxpose.io); disabled by default. + - "LOCALXPOSE_ENABLED=${LOCALXPOSE_ENABLED:-false}" + - "LOCALXPOSE_ACCESS_TOKEN=${LOCALXPOSE_ACCESS_TOKEN:-}" + - "LOCALXPOSE_REGION=${LOCALXPOSE_REGION:-us}" + - "LOCALXPOSE_TARGET_HOST=${LOCALXPOSE_TARGET_HOST:-localhost}" + - "LOCALXPOSE_TCP_PORTS=${LOCALXPOSE_TCP_PORTS:-5004 5023 5027}" + - "LOCALXPOSE_UDP_PORTS=${LOCALXPOSE_UDP_PORTS:-}" + - "LOCALXPOSE_RESERVED_ENDPOINTS=${LOCALXPOSE_RESERVED_ENDPOINTS:-}" + db: ports: - "5157:1433" networks: - - rgmain: - ipv4_address: 172.16.193.55 + rgmain: + ipv4_address: 172.16.193.55 build: ./db environment: - SA_PASSWORD=Resgrid123!! @@ -110,8 +142,8 @@ services: - "5158:6379" restart: always networks: - - rgmain: - ipv4_address: 172.16.193.56 + rgmain: + ipv4_address: 172.16.193.56 rabbitmq: image: rabbitmq:3-management @@ -123,8 +155,8 @@ services: - "5159:5672" restart: always networks: - - rgmain: - ipv4_address: 172.16.193.57 + rgmain: + ipv4_address: 172.16.193.57 elk: image: sebp/elk @@ -134,8 +166,8 @@ services: - "5165:5044" restart: always networks: - - rgmain: - ipv4_address: 172.16.193.58 + rgmain: + ipv4_address: 172.16.193.58 mongodb: image: mongo:4.4.18 @@ -143,8 +175,8 @@ services: - 27017:27017 restart: always networks: - - rgmain: - ipv4_address: 172.16.193.59 + rgmain: + ipv4_address: 172.16.193.59 environment: - MONGO_INITDB_DATABASE=resgrid - MONGO_INITDB_ROOT_USERNAME=resgridUser @@ -178,4 +210,4 @@ networks: ipam: driver: default config: - - subnet: 172.16.193.0/24 \ No newline at end of file + - subnet: 172.16.193.0/24 diff --git a/Docker/monitoring/grafana/resgrid-tracker-gateway.json b/Docker/monitoring/grafana/resgrid-tracker-gateway.json new file mode 100644 index 000000000..8f73e390b --- /dev/null +++ b/Docker/monitoring/grafana/resgrid-tracker-gateway.json @@ -0,0 +1,508 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "Prometheus data source containing tracker gateway and RabbitMQ metrics.", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "min(resgrid_tracker_gateway_ready)", + "refId": "A" + } + ], + "title": "Gateway ready", + "type": "stat" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(resgrid_tracker_gateway_listeners_expected - resgrid_tracker_gateway_listeners_bound)", + "refId": "A" + } + ], + "title": "Missing listeners", + "type": "stat" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "decimals": 1, + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "red", + "value": 95 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "expr": "100 * sum(resgrid_tracking_connections_current) / clamp_min(sum(resgrid_tracker_gateway_connections_limit), 1)", + "refId": "A" + } + ], + "title": "Connection capacity", + "type": "gauge" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "sum(rabbitmq_detailed_queue_messages_ready{queue=~\"^(DEV|QA|ST)?unitlocation-v2(test)?\\\\.dead$\"})", + "refId": "A" + } + ], + "title": "Unit-location dead letters", + "type": "stat" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "msgps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (transport, protocol, outcome) (rate(resgrid_tracking_ingress_messages_total[$__rate_interval]))", + "legendFormat": "{{transport}} / {{protocol}} / {{outcome}}", + "refId": "A" + } + ], + "title": "Native ingress rate", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + } + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "p95 {{transport}}", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.50, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "p50 {{transport}}", + "refId": "B" + } + ], + "title": "Canonical ingress duration", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 13 + }, + "id": 7, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (protocol, reason) (rate(resgrid_tracking_parse_failures_total[$__rate_interval]))", + "legendFormat": "{{protocol}} / {{reason}}", + "refId": "A" + } + ], + "title": "Parser failures", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 13 + }, + "id": 8, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (transport, reason) (rate(resgrid_tracking_auth_failures_total[$__rate_interval]))", + "legendFormat": "{{transport}} / {{reason}}", + "refId": "A" + } + ], + "title": "Mapping and source-policy failures", + "type": "timeseries" + }, + { + "datasource": "${DS_PROMETHEUS}", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + } + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 13 + }, + "id": 9, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_ready{queue=~\"^(DEV|QA|ST)?unitlocation-v2(test)?\\\\.(retry|dead)$\"})", + "legendFormat": "{{queue}}", + "refId": "A" + } + ], + "title": "Retry and dead-letter queue depth", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "resgrid", + "tracking", + "gateway" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Resgrid Tracker Gateway", + "uid": "resgrid-tracker-gateway", + "version": 1, + "weekStart": "" +} diff --git a/Docker/monitoring/tracker-gateway-alerts.yml b/Docker/monitoring/tracker-gateway-alerts.yml new file mode 100644 index 000000000..1141f6e2d --- /dev/null +++ b/Docker/monitoring/tracker-gateway-alerts.yml @@ -0,0 +1,113 @@ +groups: + - name: resgrid-tracker-gateway + rules: + - alert: ResgridTrackerGatewayNotReady + expr: resgrid_tracker_gateway_ready == 0 + for: 2m + labels: + severity: critical + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracker gateway is not ready + description: One or more configured native listeners are not ready. + + - alert: ResgridTrackerGatewayListenerMismatch + expr: resgrid_tracker_gateway_listeners_expected - resgrid_tracker_gateway_listeners_bound > 0 + for: 2m + labels: + severity: critical + service: resgrid-tracker-gateway + annotations: + summary: A required Resgrid tracking listener is not bound + description: The number of bound native listeners is below the configured listener count. + + - alert: ResgridTrackerGatewayConnectionSaturation + expr: sum(resgrid_tracking_connections_current) / clamp_min(sum(resgrid_tracker_gateway_connections_limit), 1) > 0.80 + for: 10m + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracker gateway is approaching its connection limit + description: More than 80 percent of configured global session capacity has been used for ten minutes. + + - alert: ResgridTrackerGatewayAdmissionRejections + expr: sum(increase(resgrid_tracking_connections_total{outcome="admission-rejected"}[5m])) > 10 + for: 5m + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracker gateway is rejecting connections + description: Global or per-IP admission limits rejected more than ten sessions in five minutes. + + - alert: ResgridTrackerGatewayPublishFailures + expr: sum(rate(resgrid_tracking_ingress_messages_total{outcome="unavailable"}[5m])) / clamp_min(sum(rate(resgrid_tracking_ingress_messages_total[5m])), 0.001) > 0.05 + for: 5m + labels: + severity: critical + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracking ingress publish failures are elevated + description: More than five percent of native messages have been unavailable for five minutes. + + - alert: ResgridTrackerGatewayPublishLatency + expr: histogram_quantile(0.95, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[10m]))) > 0.5 + for: 10m + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracking queue publish latency is high + description: The p95 canonical ingress duration has exceeded 500 milliseconds for ten minutes. + + - alert: ResgridTrackerGatewayParserFailureSpike + expr: sum by (protocol) (rate(resgrid_tracking_parse_failures_total[5m])) > 1 + for: 5m + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracking parser failures are elevated + description: A bounded protocol parser is rejecting more than one frame per second. + + - alert: ResgridTrackerGatewayAuthenticationFailureSpike + expr: sum by (transport) (rate(resgrid_tracking_auth_failures_total[5m])) > 1 + for: 5m + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracking authentication failures are elevated + description: Unknown-device, mapping, or source-policy failures exceed one per second. + + - alert: ResgridTrackerGatewayForcedShutdown + expr: sum(increase(resgrid_tracking_shutdown_forced_total[15m])) > 0 + labels: + severity: warning + service: resgrid-tracker-gateway + annotations: + summary: Resgrid tracker gateway exhausted its graceful drain deadline + description: At least one listener required a forced shutdown during the last fifteen minutes. + + - name: resgrid-unit-location-queues + rules: + - alert: ResgridUnitLocationDeadLetters + expr: sum(rabbitmq_detailed_queue_messages_ready{queue=~"^(DEV|QA|ST)?unitlocation-v2(test)?\\.dead$"}) > 0 + for: 2m + labels: + severity: critical + service: resgrid-unit-location-worker + annotations: + summary: Resgrid v2 unit-location dead letters are present + description: Inspect and resolve dead-lettered hardware-location events before replay. + + - alert: ResgridUnitLocationRetryBacklog + expr: sum(rabbitmq_detailed_queue_messages_ready{queue=~"^(DEV|QA|ST)?unitlocation-v2(test)?\\.retry$"}) > 1000 + for: 10m + labels: + severity: warning + service: resgrid-unit-location-worker + annotations: + summary: Resgrid v2 unit-location retry backlog is growing + description: More than one thousand hardware-location events have remained in retry queues for ten minutes. diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs index 913c64144..6eeae70e7 100644 --- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs @@ -81,6 +81,13 @@ public async Task CreateUserSubscriber(string userId, string code, int dep return await CreateSubscriber($"{code}_User_{userId}", departmentId, email, firstName, lastName, null); } + public async Task CreateICUserSubscriber(string userId, string code, int departmentId, string email, + string firstName, string lastName) + { + // The IC app gets its own subscriber id so its Novu inbox is separate from the Responder app's. + return await CreateSubscriber($"{code}_IC_User_{userId}", departmentId, email, firstName, lastName, null); + } + public async Task CreateUnitSubscriber(int unitId, string code, int departmentId, string unitName, string deviceId) { var data = new List(); @@ -225,6 +232,16 @@ public async Task UpdateUserSubscriberApns(string userId, string code, str return await UpdateSubscriberApns($"{code}_User_{userId}", token, ChatConfig.NovuResponderApnsProviderId, null); } + public async Task UpdateICUserSubscriberFcm(string userId, string code, string token) + { + return await UpdateSubscriberFcm($"{code}_IC_User_{userId}", token, ChatConfig.NovuICFcmProviderId); + } + + public async Task UpdateICUserSubscriberApns(string userId, string code, string token) + { + return await UpdateSubscriberApns($"{code}_IC_User_{userId}", token, ChatConfig.NovuICApnsProviderId, null); + } + public async Task UpdateUnitSubscriberFcm(int unitId, string code, string token) { return await UpdateSubscriberFcm($"{code}_Unit_{unitId}", token, ChatConfig.NovuUnitFcmProviderId); @@ -364,6 +381,11 @@ public async Task SendUserNotification(string title, string body, string u return await SendNotification(title, body, $"{depCode}_User_{userId}", eventCode, type, false, 0, null, ChatConfig.NovuNotificationUserWorkflowId, GetSoundFileNameFromType(type)); } + public async Task SendICUserNotification(string title, string body, string userId, string depCode, string eventCode, string type) + { + return await SendNotification(title, body, $"{depCode}_IC_User_{userId}", eventCode, type, false, 0, null, ChatConfig.NovuNotificationUserWorkflowId, GetSoundFileNameFromType(type)); + } + #region Private Push Helpers private string GetSoundFileNameFromType(string type) diff --git a/Providers/Resgrid.Providers.Tracking/NOTICE.md b/Providers/Resgrid.Providers.Tracking/NOTICE.md index c494ba6ed..0fd495576 100644 --- a/Providers/Resgrid.Providers.Tracking/NOTICE.md +++ b/Providers/Resgrid.Providers.Tracking/NOTICE.md @@ -3,7 +3,10 @@ ## Traccar The Resgrid `traccar-json-v1` interoperability adapter was independently implemented -against Traccar's public JSON position-forwarding contract. +against Traccar's public JSON position-forwarding contract. The bounded +`queclink-attrack` and `gt06` protocol modules are C# adaptations informed by the +pinned Traccar decoders, frame handlers, and tests. Their interoperability fixtures +include sanitized packets copied from the pinned Traccar test suite. - Project: Traccar GPS Tracking System - Repository: https://github.com/traccar/traccar @@ -12,5 +15,6 @@ against Traccar's public JSON position-forwarding contract. - License: Apache License 2.0 - Copyright: Anton Tananaev and Traccar contributors -No Traccar Java source is compiled into or redistributed with Resgrid. See -`PROVENANCE.md` for the exact files reviewed and the adaptation record. +No Traccar Java source is compiled into Resgrid. The repository root `LICENSE` contains +the Apache License 2.0. See `PROVENANCE.md` for the exact files reviewed, adapted +behavior, copied fixtures, and certification limits. diff --git a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md index 50e382171..fc40429c6 100644 --- a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md +++ b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md @@ -43,3 +43,181 @@ contract. No Java implementation code was copied or ported. The mapping intentio independently generated, sanitized fixture representing a long-tail SinoTrack ST-901 decoded by Traccar's `h02` protocol. It contains no upstream or customer packet data and does not constitute physical-hardware certification. + +## `teltonika-codec8` + +### Public material reviewed + +- Official protocol documentation: + https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols +- Official Wave 1 model parameter tables: + - https://wiki.teltonika-gps.com/view/FMC920_Teltonika_Data_Sending_Parameters_ID + - https://wiki.teltonika-gps.com/view/FMM920_Teltonika_Data_Sending_Parameters_ID + - https://wiki.teltonika-gps.com/view/FMC130_Teltonika_Data_Sending_Parameters_ID + - https://wiki.teltonika-gps.com/view/FMM130_Teltonika_Data_Sending_Parameters_ID + - https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID +- Reviewed: 2026-07-26 + +### Adaptation record + +The Resgrid module is an independent C# implementation of the public Teltonika data +sending protocol. No vendor implementation code was copied or ported. The initial +bounded scope: + +- accepts the 15-digit TCP IMEI login and emits the documented binary accept/reject + response; +- accepts the UDP channel wrapper with its declared length, channel packet ID, marker, + AVL packet ID, and embedded 15-digit IMEI; +- parses Codec8 (`0x08`) and Codec8 Extended (`0x8E`) AVL record arrays; +- validates the preamble, big-endian data length, CRC-16/IBM, codec, record counts, + coordinates, timestamps, priority, angle, and bounded I/O-element structure; +- maps codec-defined GPS fields to canonical tracking positions; +- loads the five Wave 1 profiles and their I/O map from the validated embedded catalog; +- registers seven WP11 family candidates against the same module without assigning + those models an I/O map; +- retains only fixed-width numeric I/O values allowlisted by that catalog, then applies + the selected model profile only after IMEI authentication; +- maps AVL 182 to HDOP with multiplier `0.1`, AVL 66 to external power volts with + multiplier `0.001`, AVL 239 to ignition, and AVL 240 to movement, enforcing the + documented raw ranges; +- ignores all other I/O values and discards the bounded parser metadata before canonical + ingress; +- derives deterministic SHA-256 event fingerprints from each raw AVL record; +- emits the TCP four-byte accepted-record count or UDP channel response with matching + packet IDs only when canonical ingress accepts the complete packet. + +The FMC920, FMM920, FMC130, FMM130, and FMC003 Wave 1 profiles and the FMM003, FMC125, +FMM125, FMC150, FMM150, FMC230, and FMM230 WP11 profiles remain non-selectable +`Candidate` entries with no certified transports. The WP11 entries are catalog-only +family-placement hypotheses and deliberately have no model I/O map. Captured +model/firmware fixtures, exact firmware pins, model mapping review, and physical-device +certification are not part of this documentation-derived pass. The test-only TCP and +UDP simulators are generated from the public packet layout and are not certification +evidence. + +### Fixtures + +`Tests/Resgrid.Tracking.Tests/Data/Teltonika/` contains independently generated minimal +Codec8 and Codec8 Extended TCP packets, their UDP channel wrappers, and a synthetic +nonzero-I/O Codec8 packet covering the four allowlisted values. They are based on the +public field layout, contain no vendor or customer packet data, and do not constitute +physical-hardware certification. The test-only TCP simulator uses these fixtures to +exercise login, fragmented current/buffered batches, duplicate resend, CRC rejection, +mid-frame disconnect, and ACK timing relative to canonical ingress confirmation. The +UDP simulator covers wrapper construction, matching response IDs and record counts, +buffered/duplicate datagrams, malformed count rejection, and the same ACK ordering. + +## `queclink-attrack` + +### Pin + +- Upstream repository: https://github.com/traccar/traccar +- Release: `v6.14.5` +- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9` +- Commit date: 2026-06-18 +- License: Apache License 2.0 + +### Upstream source reviewed + +- `src/main/java/org/traccar/protocol/Gl200Protocol.java` +- `src/main/java/org/traccar/protocol/Gl200FrameDecoder.java` +- `src/main/java/org/traccar/protocol/Gl200ProtocolDecoder.java` +- `src/main/java/org/traccar/protocol/Gl200TextProtocolDecoder.java` +- `src/main/java/org/traccar/protocol/Gl200BinaryProtocolDecoder.java` +- `src/test/java/org/traccar/protocol/Gl200TextProtocolDecoderTest.java` +- `src/test/java/org/traccar/protocol/Gl200BinaryProtocolDecoderTest.java` + +All source-file references are pinned to commit +`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. + +### Adaptation record + +The Resgrid module is a bounded C# adaptation informed by the pinned Traccar GL200 +implementation. No Traccar Java source is compiled into Resgrid. The initial scope: + +- accepts printable ASCII `+RESP` and `+BUFF` reports terminated by `$` or NUL, with + strict frame, field-count, field-length, protocol-version, and 15-digit IMEI bounds; +- accepts only an explicit allowlist of position, status, ignition, and alarm report + types and rejects unknown report types instead of guessing a layout; +- recognizes the bounded GL200 location tuple and maps timestamp, coordinates, speed, + heading, altitude, HDOP, movement, ignition, battery, external power, and selected + alarms into the canonical tracking model; +- accepts `+ACK:GTHBD` heartbeat reports and emits the matching `+SACK:GTHBD` only after + canonical ingress accepts the heartbeat; +- leaves position acknowledgements disabled because their behavior is + configuration-dependent in the pinned implementation; +- derives deterministic SHA-256 event fingerprints from the raw report and position + index; +- supports TCP only. UDP and GL200 binary framing are deliberately not registered. + +The GV57MG, GV350MG, and GV500MA profiles remain non-selectable `Candidate` entries +with no certified transports. Current manufacturer documents, captured +model/firmware packets, and physical-device certification remain required before any +profile or transport is promoted. + +### Fixtures + +`Tests/Resgrid.Tracking.Tests/Data/Queclink/` contains sanitized ASCII messages copied +from the pinned `Gl200TextProtocolDecoderTest`. They exercise live and buffered +positions, ignition, and heartbeat/response behavior. They are upstream +interoperability fixtures, not captured packets from the three target devices and not +certification evidence. The test-only TCP simulator uses them to exercise fragmented +and duplicate reports plus heartbeat response behavior through the real listener. + +## `gt06` + +### Pin + +- Upstream repository: https://github.com/traccar/traccar +- Release: `v6.14.5` +- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9` +- Commit date: 2026-06-18 +- License: Apache License 2.0 + +### Upstream source reviewed + +- `src/main/java/org/traccar/protocol/Gt06Protocol.java` +- `src/main/java/org/traccar/protocol/Gt06FrameDecoder.java` +- `src/main/java/org/traccar/protocol/Gt06ProtocolDecoder.java` +- `src/test/java/org/traccar/protocol/Gt06ProtocolDecoderTest.java` + +All source-file references are pinned to commit +`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. + +### Adaptation record + +The Resgrid module is a bounded C# adaptation informed by the pinned Traccar GT06 +implementation. No Traccar Java source is compiled into Resgrid. The initial scope: + +- accepts short `0x7878` and extended `0x7979` frames with bounded declared lengths, + `0x0D0A` terminators, serial numbers, and CRC-16/X25 validation; +- requires a valid BCD-encoded 15-digit IMEI login before all other messages; +- accepts the bounded login, heartbeat/status, GPS/LBS `0x22`, GPS/LBS status/alarm + `0x16`, and JM-VL03 `0xA0` location layouts selected for this implementation; +- maps timestamp, coordinates, fix validity, satellites, speed, heading, movement, + ignition, battery, signal, and selected model-aware alarms into the canonical model; +- emits CRC-protected acknowledgements with the original header form, protocol number, + and serial only after login or complete canonical-ingress acceptance; +- derives deterministic SHA-256 event fingerprints from the complete raw frame; +- rejects unrecognized message types rather than attempting universal GT06-clone + detection; +- supports TCP only. UDP is deliberately not registered. + +The VL103M and JM-VL03 Wave 1 profiles and JM-VL01, JM-VL02, and JM-VL04 WP11 profiles +remain non-selectable `Candidate` entries with no certified transports. The WP11 +decoder-variant names are explicitly marked unverified; they do not add accepted +message layouts or claim that a sibling uses one of the tested Wave 1 layouts. Current +manufacturer documents, captured model/firmware packets, exact variant dispatch, and +physical-device certification remain required before any profile or transport is +promoted. + +### Fixtures + +`Tests/Resgrid.Tracking.Tests/Data/Gt06/` contains sanitized binary packets copied from +the pinned `Gt06ProtocolDecoderTest`. They cover login, a standard GPS/LBS location, +the JM-VL03-associated `0xA0` layout, and heartbeat/status framing. A generated +extended-header form exercises bounded framing and exact response construction. These +are upstream interoperability fixtures, not captured packets from the target devices +and not certification evidence. The test-only TCP simulator exercises fragmented +login/location frames and verifies that the gateway defers the response until +canonical ingress confirms acceptance. diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs new file mode 100644 index 000000000..e610691ab --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Providers.Tracking.Protocols.Gt06 +{ + internal static class Gt06Crc16 + { + public static ushort Compute( + ReadOnlySpan data) + { + ushort crc = 0xFFFF; + foreach (var value in data) + { + crc ^= value; + for (var bit = 0; + bit < 8; + bit++) + { + crc = (crc & 1) != 0 + ? (ushort)((crc >> 1) ^ 0x8408) + : (ushort)(crc >> 1); + } + } + + return (ushort)~crc; + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs new file mode 100644 index 000000000..edb8ee397 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Providers.Tracking.Protocols.Gt06 +{ + public sealed class Gt06ProtocolModule : + ITrackingProtocolModule + { + private static readonly IReadOnlySet + Transports = new HashSet + { + TrackingSocketTransport.Tcp + }; + + public string ProtocolKey => + TrackingProtocolKeys.Gt06; + + public IReadOnlySet + SupportedTransports => Transports; + + public ITrackingProtocolSession CreateSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.Transport != + TrackingSocketTransport.Tcp) + { + throw new NotSupportedException( + "The bounded GT06/Jimi module supports TCP only."); + } + + return new Gt06ProtocolSession(context); + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs new file mode 100644 index 000000000..407b17eb9 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs @@ -0,0 +1,726 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols.Gt06 +{ + public sealed class Gt06ProtocolSession : + ITrackingProtocolSession, + ITrackingProtocolPositionEnricher + { + private const ushort ShortHeader = 0x7878; + private const ushort ExtendedHeader = 0x7979; + private const ushort Tail = 0x0D0A; + private const int MinimumDeclaredLength = 5; + private const int GpsPayloadLength = 18; + + private const byte LoginType = 0x01; + private const byte StatusType = 0x13; + private const byte HeartbeatType = 0x23; + private const byte GpsLbsStatusType = 0x16; + private const byte GpsLbsType = 0x22; + private const byte JmVl03GpsType = 0xA0; + + private static readonly IReadOnlySet + PositionTypes = new HashSet + { + GpsLbsStatusType, + GpsLbsType, + JmVl03GpsType + }; + + private static readonly IReadOnlySet + CommonStatusPositionTypes = + new HashSet + { + GpsLbsStatusType + }; + + private readonly int _maximumFrameBytes; + private bool _loginAccepted; + + public Gt06ProtocolSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.MaxFrameBytes <= 0) + throw new ArgumentOutOfRangeException( + nameof(context.MaxFrameBytes)); + + _maximumFrameBytes = context.MaxFrameBytes; + } + + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + if (input.Length < 3) + return NeedMore(input); + + var prefix = input.Slice( + 0, + Math.Min(input.Length, 4)) + .ToArray(); + var header = BinaryPrimitives + .ReadUInt16BigEndian(prefix); + var extended = header == ExtendedHeader; + if (header != ShortHeader && + !extended) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "header-invalid"); + } + + var lengthFieldBytes = extended ? 2 : 1; + if (input.Length < + 2 + lengthFieldBytes) + return NeedMore(input); + var declaredLength = extended + ? BinaryPrimitives.ReadUInt16BigEndian( + prefix.AsSpan(2, 2)) + : prefix[2]; + if (declaredLength < + MinimumDeclaredLength) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "length-invalid"); + } + + var frameLength = + 2 + + lengthFieldBytes + + declaredLength + + 2; + if (frameLength > _maximumFrameBytes) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "frame-too-large"); + } + if (input.Length < frameLength) + return NeedMore(input); + + var consumed = input.GetPosition( + frameLength); + var frame = input.Slice( + 0, + frameLength) + .ToArray(); + if (BinaryPrimitives.ReadUInt16BigEndian( + frame.AsSpan( + frame.Length - 2, + 2)) != Tail) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "tail-invalid"); + } + + var crcOffset = frame.Length - 4; + var expectedCrc = + BinaryPrimitives.ReadUInt16BigEndian( + frame.AsSpan(crcOffset, 2)); + var actualCrc = Gt06Crc16.Compute( + frame.AsSpan( + 2, + crcOffset - 2)); + if (actualCrc != expectedCrc) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "crc-invalid"); + } + + var typeOffset = + 2 + lengthFieldBytes; + var serialOffset = frame.Length - 6; + if (serialOffset <= typeOffset) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "payload-length-invalid"); + } + + var type = frame[typeOffset]; + var serial = BinaryPrimitives + .ReadUInt16BigEndian( + frame.AsSpan( + serialOffset, + 2)); + var payload = frame.AsSpan( + typeOffset + 1, + serialOffset - + typeOffset - + 1); + var acknowledgementToken = + CreateAcknowledgementToken( + extended, + type, + serial); + + if (type == LoginType) + { + if (!TryReadImei( + payload, + out var imei)) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "imei-invalid"); + } + + return Message( + consumed, + ProtocolParseStatus.Login, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Login, + ExternalIdentifier = imei, + AcknowledgementToken = + acknowledgementToken, + RequiresResponse = true + }); + } + + if (!_loginAccepted) + { + return Terminal( + consumed, + ProtocolParseStatus.CloseSession, + "login-required"); + } + + if (type == StatusType || + type == HeartbeatType) + { + return Message( + consumed, + ProtocolParseStatus.Heartbeat, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Heartbeat, + AcknowledgementToken = + acknowledgementToken, + RequiresResponse = true + }); + } + + if (!PositionTypes.Contains(type)) + { + return Terminal( + consumed, + ProtocolParseStatus.Unsupported, + "message-type-unsupported"); + } + + if (!TryParsePosition( + type, + payload, + out var position, + out var metadata)) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "gps-payload-invalid"); + } + + position.EventId = CreateEventId(frame); + return Message( + consumed, + ProtocolParseStatus.Positions, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Positions, + Positions = + new[] { position }, + ProtocolData = + new Gt06ProtocolData(metadata), + AcknowledgementToken = + acknowledgementToken, + RequiresResponse = true + }); + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (acceptance == null) + throw new ArgumentNullException(nameof(acceptance)); + + var accepted = + acceptance.Status == + TrackingAcceptanceStatus.Accepted; + if (message.MessageType == + ProtocolMessageType.Login) + _loginAccepted = accepted; + + if (!accepted || + message.AcknowledgementToken.Length != 4) + return ReadOnlyMemory.Empty; + if (message.MessageType == + ProtocolMessageType.Positions && + (message.Positions == null || + acceptance.AcceptedPositions != + message.Positions.Count)) + { + return ReadOnlyMemory.Empty; + } + + var token = + message.AcknowledgementToken.Span; + return BuildAcknowledgement( + token[0] == 1, + token[1], + BinaryPrimitives.ReadUInt16BigEndian( + token.Slice(2, 2))); + } + + public void EnrichPositions( + ProtocolMessage message, + UnitTrackingDevice device) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (device == null) + throw new ArgumentNullException(nameof(device)); + if (message.ProtocolData is not + Gt06ProtocolData protocolData || + message.Positions == null || + message.Positions.Count != 1) + return; + + var position = message.Positions.Single(); + var alarmCode = MapAlarm( + protocolData.Metadata.Alarm, + string.Equals( + device.ModelKey, + "jimi-vl103m", + StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(alarmCode)) + position.AlarmCode = alarmCode; + + var profile = UnitTrackingCatalog.GetProfile( + device.ModelKey); + if (profile == null || + !string.Equals( + profile.ProtocolKey, + TrackingProtocolKeys.Gt06, + StringComparison.OrdinalIgnoreCase) || + !string.Equals( + device.ProtocolKey, + TrackingProtocolKeys.Gt06, + StringComparison.OrdinalIgnoreCase)) + return; + + var levelEncoded = string.Equals( + profile.DecoderVariant, + "gt06-vl103-bounded", + StringComparison.OrdinalIgnoreCase) || + string.Equals( + profile.DecoderVariant, + "gt06-jm-vl03-a0-bounded", + StringComparison.OrdinalIgnoreCase); + var nativeEncoded = string.Equals( + profile.DecoderVariant, + "gt06-jm-vl01-unverified", + StringComparison.OrdinalIgnoreCase) || + string.Equals( + profile.DecoderVariant, + "gt06-jm-vl02-unverified", + StringComparison.OrdinalIgnoreCase) || + string.Equals( + profile.DecoderVariant, + "gt06-jm-vl04-unverified", + StringComparison.OrdinalIgnoreCase); + if (protocolData.Metadata.Battery.HasValue) + { + position.BatteryPercent = levelEncoded + ? BatteryLevelToPercent( + protocolData.Metadata.Battery.Value) + : nativeEncoded + ? BatteryNativeToPercent( + protocolData.Metadata.Battery.Value) + : null; + } + if (protocolData.Metadata.Signal.HasValue) + { + position.SignalPercent = levelEncoded + ? SignalLevelToPercent( + protocolData.Metadata.Signal.Value) + : nativeEncoded + ? SignalNativeToPercent( + protocolData.Metadata.Signal.Value) + : null; + } + } + + private static bool TryParsePosition( + byte type, + ReadOnlySpan payload, + out CanonicalTrackingPosition position, + out Gt06PositionMetadata metadata) + { + position = null; + metadata = new Gt06PositionMetadata(); + if (payload.Length < GpsPayloadLength || + !TryReadTimestamp( + payload.Slice(0, 6), + out var timestamp)) + return false; + + var satellites = payload[6] & 0x0F; + var latitude = + BinaryPrimitives.ReadUInt32BigEndian( + payload.Slice(7, 4)) / + 1800000m; + var longitude = + BinaryPrimitives.ReadUInt32BigEndian( + payload.Slice(11, 4)) / + 1800000m; + var speedKilometersPerHour = payload[15]; + var flags = + BinaryPrimitives.ReadUInt16BigEndian( + payload.Slice(16, 2)); + if ((flags & (1 << 10)) == 0) + latitude = -latitude; + if ((flags & (1 << 11)) != 0) + longitude = -longitude; + if (latitude < -90m || + latitude > 90m || + longitude < -180m || + longitude > 180m) + return false; + + position = new CanonicalTrackingPosition + { + TimestampUtc = timestamp, + ReceivedOnUtc = DateTime.UtcNow, + Latitude = latitude, + Longitude = longitude, + SpeedMetersPerSecond = + speedKilometersPerHour / 3.6m, + HeadingDegrees = flags & 0x03FF, + Satellites = satellites, + IsMoving = + speedKilometersPerHour > 0, + TimestampSource = + TrackingTimestampSource.Device, + IsValidFix = + (flags & (1 << 12)) != 0 + }; + if ((flags & (1 << 14)) != 0) + { + position.Ignition = + (flags & (1 << 15)) != 0; + } + + if (type == JmVl03GpsType && + payload.Length >= 36) + { + position.Ignition = payload[33] > 0; + } + else if (CommonStatusPositionTypes.Contains( + type) && + payload.Length >= 30) + { + var status = payload[26]; + position.Ignition = + (status & (1 << 1)) != 0; + metadata.Battery = payload[27]; + metadata.Signal = payload[28]; + metadata.Alarm = payload[29]; + position.AlarmCode = + MapStatusAlarm(status); + } + else if (payload.Length == 29) + { + position.Ignition = payload[26] > 0; + } + + return true; + } + + private static decimal? BatteryLevelToPercent( + byte value) + { + return value <= 6 + ? value * 100m / 6m + : (decimal?)null; + } + + private static int? SignalLevelToPercent( + byte value) + { + return value <= 4 + ? value * 25 + : (int?)null; + } + + private static decimal? BatteryNativeToPercent( + byte value) + { + return value <= 100 + ? value + : (decimal?)null; + } + + private static int? SignalNativeToPercent( + byte value) + { + return value <= 100 + ? value + : (int?)null; + } + + private static string MapStatusAlarm(byte status) + { + return ((status >> 3) & 0x07) switch + { + 1 => "vibration", + 2 => "powerCut", + 3 => "lowBattery", + 4 => "sos", + 6 => "geofence", + 7 => "removing", + _ => null + }; + } + + private static string MapAlarm( + byte? value, + bool modelVl) + { + return value switch + { + 0x01 => "sos", + 0x02 => "powerCut", + 0x03 => "vibration", + 0x04 => "geofenceEnter", + 0x05 => "geofenceExit", + 0x06 => "overspeed", + 0x09 => modelVl ? "tow" : "vibration", + 0x0E or 0x0F => "lowBattery", + 0x11 => "powerOff", + 0x0C or 0x13 or 0x25 => + "tampering", + 0x14 => "door", + 0x19 => "lowBattery", + 0x1A or 0x27 => "braking", + 0x1B or 0x2A or 0x2B or 0x2E => + "cornering", + 0x23 => "fallDown", + 0x26 => "acceleration", + 0x2C => "accident", + 0x30 => modelVl ? "braking" : "jamming", + _ => null + }; + } + + private static bool TryReadImei( + ReadOnlySpan payload, + out string imei) + { + imei = null; + if (payload.Length < 8) + return false; + + var bcd = Convert.ToHexString( + payload.Slice(0, 8)) + .ToLowerInvariant(); + if (bcd.Length != 16 || + bcd[0] != '0') + return false; + + var candidate = bcd.Substring(1); + if (candidate.Any( + value => + value < '0' || + value > '9')) + return false; + + imei = candidate; + return true; + } + + private static bool TryReadTimestamp( + ReadOnlySpan value, + out DateTime timestamp) + { + timestamp = default; + if (value.Length != 6) + return false; + + try + { + timestamp = new DateTime( + 2000 + value[0], + value[1], + value[2], + value[3], + value[4], + value[5], + DateTimeKind.Utc); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static byte[] CreateAcknowledgementToken( + bool extended, + byte type, + ushort serial) + { + var token = new byte[4]; + token[0] = extended ? (byte)1 : (byte)0; + token[1] = type; + BinaryPrimitives.WriteUInt16BigEndian( + token.AsSpan(2), + serial); + return token; + } + + private static byte[] BuildAcknowledgement( + bool extended, + byte type, + ushort serial) + { + var lengthFieldBytes = extended ? 2 : 1; + var response = new byte[ + 2 + + lengthFieldBytes + + MinimumDeclaredLength + + 2]; + BinaryPrimitives.WriteUInt16BigEndian( + response, + extended + ? ExtendedHeader + : ShortHeader); + if (extended) + { + BinaryPrimitives.WriteUInt16BigEndian( + response.AsSpan(2, 2), + MinimumDeclaredLength); + } + else + { + response[2] = + MinimumDeclaredLength; + } + + var typeOffset = + 2 + lengthFieldBytes; + response[typeOffset] = type; + BinaryPrimitives.WriteUInt16BigEndian( + response.AsSpan( + typeOffset + 1, + 2), + serial); + var crcOffset = response.Length - 4; + BinaryPrimitives.WriteUInt16BigEndian( + response.AsSpan(crcOffset, 2), + Gt06Crc16.Compute( + response.AsSpan( + 2, + crcOffset - 2))); + BinaryPrimitives.WriteUInt16BigEndian( + response.AsSpan( + response.Length - 2, + 2), + Tail); + return response; + } + + private static string CreateEventId( + ReadOnlySpan frame) + { + return "gt06:" + + Convert.ToHexString( + SHA256.HashData(frame)) + .ToLowerInvariant(); + } + + private static ProtocolParseResult NeedMore( + ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = + ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + private static ProtocolParseResult Message( + SequencePosition consumed, + ProtocolParseStatus status, + ProtocolMessage message) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + Message = message + }; + } + + private static ProtocolParseResult Terminal( + SequencePosition consumed, + ProtocolParseStatus status, + string reasonCode) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + ReasonCode = reasonCode + }; + } + } + + internal sealed class Gt06ProtocolData + { + public Gt06ProtocolData( + Gt06PositionMetadata metadata) + { + Metadata = metadata ?? + throw new ArgumentNullException( + nameof(metadata)); + } + + public Gt06PositionMetadata Metadata { get; } + } + + internal sealed class Gt06PositionMetadata + { + public byte? Alarm { get; set; } + public byte? Battery { get; set; } + public byte? Signal { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs b/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs new file mode 100644 index 000000000..812556b79 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Resgrid.Providers.Tracking.Protocols +{ + public interface ITrackingProtocolModuleRegistry + { + IReadOnlyCollection Modules { get; } + + bool TryResolve( + string protocolKey, + TrackingSocketTransport transport, + out ITrackingProtocolModule module); + + ITrackingProtocolModule Resolve( + string protocolKey, + TrackingSocketTransport transport); + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs new file mode 100644 index 000000000..45edccd40 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Providers.Tracking.Protocols.Queclink +{ + public sealed class QueclinkProtocolModule : + ITrackingProtocolModule + { + private static readonly IReadOnlySet + Transports = new HashSet + { + TrackingSocketTransport.Tcp + }; + + public string ProtocolKey => + TrackingProtocolKeys.Queclink; + + public IReadOnlySet + SupportedTransports => Transports; + + public ITrackingProtocolSession CreateSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.Transport != + TrackingSocketTransport.Tcp) + { + throw new NotSupportedException( + "The bounded Queclink @Track module supports TCP only."); + } + + return new QueclinkProtocolSession(context); + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs new file mode 100644 index 000000000..e1970d123 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs @@ -0,0 +1,669 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols.Queclink +{ + public sealed class QueclinkProtocolSession : + ITrackingProtocolSession + { + private const int MaximumFields = 128; + private const int MaximumFieldLength = 256; + private const int MaximumPositionsPerFrame = 16; + private const decimal MaximumSpeedKilometersPerHour = + 1000m; + + private static readonly IReadOnlySet + LocationReportTypes = + new HashSet( + new[] + { + "CTN", "DOG", "ERI", "FRI", + "GEO", "HBM", "IDL", "IGF", + "IGL", "IGN", "JDR", "JDS", + "NMR", "RTL", "SOS", "SPD", + "STR", "STT", "SWG", "TEM", + "TMP", "TOW", "VGF", "VGN" + }, + StringComparer.Ordinal); + + private static readonly IReadOnlySet + StatusReportTypes = + new HashSet( + new[] + { + "BPL", "DIS", "EPF", "EPN", + "INF", "MPF", "MPN", "PFA", + "PNA", "VER" + }, + StringComparer.Ordinal); + + private static readonly IReadOnlyDictionary + AlarmCodes = + new Dictionary( + StringComparer.Ordinal) + { + ["BPL"] = "lowBattery", + ["EPF"] = "powerCut", + ["EPN"] = "powerRestored", + ["HBM"] = "harshMotion", + ["IDL"] = "idle", + ["JDR"] = "jamming", + ["JDS"] = "jamming", + ["MPF"] = "powerCut", + ["MPN"] = "powerRestored", + ["PFA"] = "powerOff", + ["PNA"] = "powerOn", + ["SOS"] = "sos", + ["SPD"] = "overspeed", + ["STT"] = "movement", + ["SWG"] = "geofence", + ["TEM"] = "temperature", + ["TMP"] = "temperature", + ["TOW"] = "tow" + }; + + private readonly int _maximumFrameBytes; + + public QueclinkProtocolSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.MaxFrameBytes <= 0) + throw new ArgumentOutOfRangeException( + nameof(context.MaxFrameBytes)); + + _maximumFrameBytes = context.MaxFrameBytes; + } + + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + if (input.IsEmpty) + return NeedMore(input); + + var frameLength = FindFrameLength(input); + if (frameLength == 0) + { + return input.Length >= _maximumFrameBytes + ? Terminal( + input.End, + ProtocolParseStatus.Malformed, + "frame-too-large") + : NeedMore(input); + } + if (frameLength > _maximumFrameBytes) + { + return Terminal( + input.GetPosition(frameLength), + ProtocolParseStatus.Malformed, + "frame-too-large"); + } + + var consumed = input.GetPosition(frameLength); + var frame = input.Slice(0, frameLength).ToArray(); + var contentLength = frame.Length - 1; + while (contentLength > 0 && + (frame[contentLength - 1] == (byte)'\r' || + frame[contentLength - 1] == (byte)'\n')) + { + contentLength--; + } + + if (contentLength == 0 || + !IsPrintableAscii( + frame.AsSpan(0, contentLength))) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "ascii-frame-invalid"); + } + + var sentence = Encoding.ASCII.GetString( + frame, + 0, + contentLength); + var fields = sentence.Split( + ',', + StringSplitOptions.None); + if (fields.Length < 3 || + fields.Length > MaximumFields || + fields.Any( + field => + field.Length > + MaximumFieldLength)) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "field-count-invalid"); + } + + if (!TryReadHeader( + fields[0], + out var prefix, + out var reportType) || + !IsProtocolVersion(fields[1]) || + !IsImei(fields[2])) + { + return Terminal( + consumed, + ProtocolParseStatus.Malformed, + "header-invalid"); + } + + if (prefix == "ACK" && + reportType == "HBD") + { + var acknowledgement = + Encoding.ASCII.GetBytes( + $"+SACK:GTHBD,{fields[1]},{fields[^1]}$"); + return Message( + consumed, + ProtocolParseStatus.Heartbeat, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Heartbeat, + ExternalIdentifier = fields[2], + AcknowledgementToken = + acknowledgement, + RequiresResponse = true + }); + } + + if (prefix != "RESP" && + prefix != "BUFF") + { + return Terminal( + consumed, + ProtocolParseStatus.Unsupported, + "prefix-unsupported"); + } + if (!LocationReportTypes.Contains(reportType) && + !StatusReportTypes.Contains(reportType)) + { + return Terminal( + consumed, + ProtocolParseStatus.Unsupported, + "report-type-unsupported"); + } + + var positions = ParsePositions( + fields, + reportType, + frame.AsSpan(0, contentLength)); + if (positions.Count == 0) + { + return Message( + consumed, + ProtocolParseStatus.Heartbeat, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Heartbeat, + ExternalIdentifier = fields[2], + RequiresResponse = false + }); + } + + return Message( + consumed, + ProtocolParseStatus.Positions, + new ProtocolMessage + { + MessageType = + ProtocolMessageType.Positions, + ExternalIdentifier = fields[2], + Positions = positions, + RequiresResponse = false + }); + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (acceptance == null) + throw new ArgumentNullException(nameof(acceptance)); + if (message.MessageType != + ProtocolMessageType.Heartbeat || + acceptance.Status != + TrackingAcceptanceStatus.Accepted) + { + return ReadOnlyMemory.Empty; + } + + return message.AcknowledgementToken; + } + + private static IReadOnlyCollection + ParsePositions( + IReadOnlyList fields, + string reportType, + ReadOnlySpan rawFrame) + { + var positions = + new List(); + var receivedOn = DateTime.UtcNow; + for (var index = 3; + index + 6 < fields.Count - 1 && + positions.Count < MaximumPositionsPerFrame; + index++) + { + if (!TryParseLocation( + fields, + index, + receivedOn, + out var position)) + { + continue; + } + + ApplyReportMetadata( + position, + reportType, + fields, + index); + position.EventId = CreateEventId( + rawFrame, + positions.Count); + positions.Add(position); + index += 6; + } + + return positions; + } + + private static bool TryParseLocation( + IReadOnlyList fields, + int index, + DateTime receivedOn, + out CanonicalTrackingPosition position) + { + position = null; + if (!TryParseOptionalDecimal( + fields[index], + 0m, + 99m, + out var hdop) || + !TryParseOptionalDecimal( + fields[index + 1], + 0m, + MaximumSpeedKilometersPerHour, + out var speed) || + !TryParseOptionalDecimal( + fields[index + 2], + 0m, + 360m, + out var heading) || + !TryParseOptionalDecimal( + fields[index + 3], + -20000m, + 100000m, + out var altitude) || + !TryParseCoordinate( + fields[index + 4], + -180m, + 180m, + out var longitude) || + !TryParseCoordinate( + fields[index + 5], + -90m, + 90m, + out var latitude) || + !TryParseTimestamp( + fields[index + 6], + out var timestamp)) + { + return false; + } + + position = new CanonicalTrackingPosition + { + TimestampUtc = timestamp, + ReceivedOnUtc = receivedOn, + Latitude = latitude, + Longitude = longitude, + Hdop = hdop, + AltitudeMeters = altitude, + SpeedMetersPerSecond = + speed.HasValue + ? speed.Value / 3.6m + : null, + HeadingDegrees = heading, + IsMoving = + speed.HasValue + ? speed.Value > 0m + : null, + TimestampSource = + TrackingTimestampSource.Device, + IsValidFix = true + }; + return true; + } + + private static void ApplyReportMetadata( + CanonicalTrackingPosition position, + string reportType, + IReadOnlyList fields, + int locationIndex) + { + if (AlarmCodes.TryGetValue( + reportType, + out var alarmCode)) + position.AlarmCode = alarmCode; + + if (reportType == "IGN" || + reportType == "VGN") + position.Ignition = true; + else if (reportType == "IGF" || + reportType == "VGF") + position.Ignition = false; + + if (reportType == "STT" || + reportType == "HBM") + position.IsMoving = true; + + for (var index = locationIndex - 1; + index >= 3 && + index >= locationIndex - 5; + index--) + { + if (!int.TryParse( + fields[index], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var millivolts) || + millivolts <= 10 || + millivolts > 100000) + continue; + + position.ExternalPowerVolts = + millivolts / 1000m; + break; + } + + for (var index = locationIndex + 7; + index + 1 < fields.Count - 1; + index++) + { + if (!IsDeviceStatus(fields[index + 1]) || + !int.TryParse( + fields[index], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var battery) || + battery < 0 || + battery > 100) + { + continue; + } + + position.BatteryPercent = battery; + ApplyDeviceStatus( + position, + fields[index + 1]); + break; + } + } + + private static void ApplyDeviceStatus( + CanonicalTrackingPosition position, + string status) + { + if (status.Length != 6 || + !int.TryParse( + status.Substring(0, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out var ignition)) + return; + + if ((ignition & (1 << 4)) != 0) + position.Ignition = false; + else if ((ignition & (1 << 5)) != 0) + position.Ignition = true; + } + + private static bool TryReadHeader( + string header, + out string prefix, + out string reportType) + { + prefix = null; + reportType = null; + if (header?.Length == 10 && + header.StartsWith( + "+ACK:GT", + StringComparison.Ordinal)) + { + prefix = "ACK"; + reportType = header.Substring(7, 3); + return true; + } + if (header?.Length != 11 || + header[0] != '+' || + header[5] != ':' || + header[6] != 'G' || + header[7] != 'T') + return false; + + prefix = header.Substring(1, 4); + reportType = header.Substring(8, 3); + return prefix == "RESP" || + prefix == "BUFF"; + } + + private static bool IsProtocolVersion( + string value) + { + return value != null && + (value.Length == 6 || + value.Length == 10) && + value.All(IsHexCharacter); + } + + private static bool IsImei(string value) + { + return value?.Length == 15 && + value.All( + character => + character >= '0' && + character <= '9'); + } + + private static bool IsDeviceStatus(string value) + { + return value != null && + (value.Length == 2 || + value.Length == 6) && + value.All(IsHexCharacter); + } + + private static bool IsHexCharacter(char value) + { + return value >= '0' && + value <= '9' || + value >= 'A' && + value <= 'F' || + value >= 'a' && + value <= 'f'; + } + + private static bool TryParseOptionalDecimal( + string value, + decimal minimum, + decimal maximum, + out decimal? parsed) + { + parsed = null; + if (string.IsNullOrEmpty(value)) + return true; + if (!TryParseRequiredDecimal( + value, + minimum, + maximum, + out var required)) + return false; + + parsed = required; + return true; + } + + private static bool TryParseRequiredDecimal( + string value, + decimal minimum, + decimal maximum, + out decimal parsed) + { + return decimal.TryParse( + value, + NumberStyles.AllowLeadingSign | + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out parsed) && + parsed >= minimum && + parsed <= maximum; + } + + private static bool TryParseCoordinate( + string value, + decimal minimum, + decimal maximum, + out decimal parsed) + { + parsed = 0; + var separator = value?.LastIndexOf('.') ?? -1; + if (separator <= 0 || + value.Length - separator - 1 != 6) + return false; + + for (var index = separator + 1; + index < value.Length; + index++) + { + if (value[index] < '0' || + value[index] > '9') + return false; + } + + return TryParseRequiredDecimal( + value, + minimum, + maximum, + out parsed); + } + + private static bool TryParseTimestamp( + string value, + out DateTime timestamp) + { + return DateTime.TryParseExact( + value, + "yyyyMMddHHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | + DateTimeStyles.AdjustToUniversal, + out timestamp); + } + + private static bool IsPrintableAscii( + ReadOnlySpan value) + { + foreach (var item in value) + { + if (item < 0x20 || + item > 0x7E) + return false; + } + + return true; + } + + private static long FindFrameLength( + ReadOnlySequence input) + { + var reader = new SequenceReader(input); + long length = 0; + while (reader.TryRead(out var value)) + { + length++; + if (value == (byte)'$' || + value == 0) + return length; + } + + return 0; + } + + private static string CreateEventId( + ReadOnlySpan frame, + int positionIndex) + { + using var hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + hash.AppendData(frame); + Span indexBytes = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian( + indexBytes, + positionIndex); + hash.AppendData(indexBytes); + return "queclink:" + + Convert.ToHexString( + hash.GetHashAndReset()) + .ToLowerInvariant(); + } + + private static ProtocolParseResult NeedMore( + ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = + ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + private static ProtocolParseResult Message( + SequencePosition consumed, + ProtocolParseStatus status, + ProtocolMessage message) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + Message = message + }; + } + + private static ProtocolParseResult Terminal( + SequencePosition consumed, + ProtocolParseStatus status, + string reasonCode) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + ReasonCode = reasonCode + }; + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs new file mode 100644 index 000000000..358afe61f --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Providers.Tracking.Protocols.Teltonika +{ + public sealed class TeltonikaCodec8ProtocolModule : + ITrackingProtocolModule + { + public const string Key = "teltonika-codec8"; + + private static readonly IReadOnlySet + Transports = new HashSet + { + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp + }; + + public string ProtocolKey => Key; + public IReadOnlySet + SupportedTransports => Transports; + + public ITrackingProtocolSession CreateSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.Transport == + TrackingSocketTransport.Tcp) + { + return new TeltonikaCodec8ProtocolSession( + context); + } + if (context.Transport == + TrackingSocketTransport.Udp) + { + return new TeltonikaCodec8UdpProtocolSession( + context); + } + + throw new NotSupportedException( + "Teltonika Codec8/8E supports TCP and UDP."); + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs new file mode 100644 index 000000000..04d16f30f --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs @@ -0,0 +1,738 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols.Teltonika +{ + public sealed class TeltonikaCodec8ProtocolSession : + ITrackingProtocolSession, + ITrackingProtocolPositionEnricher + { + private const byte Codec8 = 0x08; + private const byte Codec8Extended = 0x8E; + private const int ImeiLength = 15; + private const int AvlHeaderLength = 8; + private const int AvlCrcLength = 4; + private const int MinimumDataFieldLength = 3; + private const int MaximumIoElementsPerRecord = 1024; + private const int MaximumVariableIoValueBytes = 4096; + private const decimal CoordinateScale = 10000000m; + + private readonly int _maximumFrameBytes; + private bool _loginAccepted; + + public TeltonikaCodec8ProtocolSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.MaxFrameBytes <= 0) + throw new ArgumentOutOfRangeException( + nameof(context.MaxFrameBytes)); + + _maximumFrameBytes = context.MaxFrameBytes; + } + + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + return _loginAccepted + ? ParseAvlPacket(input) + : ParseLogin(input); + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (acceptance == null) + throw new ArgumentNullException(nameof(acceptance)); + + if (message.MessageType == ProtocolMessageType.Login) + { + _loginAccepted = + acceptance.Status == + TrackingAcceptanceStatus.Accepted; + return new byte[] + { + _loginAccepted ? (byte)0x01 : (byte)0x00 + }; + } + + if (message.MessageType != + ProtocolMessageType.Positions) + return ReadOnlyMemory.Empty; + + var acceptedRecords = 0; + if (acceptance.Status == + TrackingAcceptanceStatus.Accepted && + message.Positions != null && + acceptance.AcceptedPositions == + message.Positions.Count) + { + acceptedRecords = + acceptance.AcceptedPositions; + } + + var response = new byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian( + response, + acceptedRecords); + return response; + } + + public void EnrichPositions( + ProtocolMessage message, + UnitTrackingDevice device) + { + TeltonikaIoMapper.EnrichPositions( + message, + device); + } + + private ProtocolParseResult ParseLogin( + ReadOnlySequence input) + { + if (input.Length < sizeof(ushort)) + return NeedMore(input); + + var reader = new SequenceReader(input); + if (!TryReadUInt16( + ref reader, + out var imeiLength)) + return NeedMore(input); + if (imeiLength != ImeiLength) + { + return Terminal( + input, + input.End, + ProtocolParseStatus.Malformed, + "imei-length-invalid"); + } + if (reader.Remaining < imeiLength) + return NeedMore(input); + + var imeiBytes = input + .Slice(reader.Position, imeiLength) + .ToArray(); + for (var index = 0; + index < imeiBytes.Length; + index++) + { + if (imeiBytes[index] < (byte)'0' || + imeiBytes[index] > (byte)'9') + { + return Terminal( + input, + input.GetPosition( + sizeof(ushort) + imeiLength), + ProtocolParseStatus.Malformed, + "imei-invalid"); + } + } + + var consumed = input.GetPosition( + sizeof(ushort) + imeiLength); + return new ProtocolParseResult + { + Status = ProtocolParseStatus.Login, + Consumed = consumed, + Examined = consumed, + Message = new ProtocolMessage + { + MessageType = + ProtocolMessageType.Login, + ExternalIdentifier = + Encoding.ASCII.GetString( + imeiBytes), + RequiresResponse = true + } + }; + } + + private ProtocolParseResult ParseAvlPacket( + ReadOnlySequence input) + { + if (input.Length < AvlHeaderLength) + return NeedMore(input); + + var headerReader = + new SequenceReader(input); + if (!TryReadUInt32( + ref headerReader, + out var preamble) || + !TryReadUInt32( + ref headerReader, + out var dataFieldLength)) + return NeedMore(input); + if (preamble != 0) + { + return Terminal( + input, + input.End, + ProtocolParseStatus.Malformed, + "preamble-invalid"); + } + if (dataFieldLength < + MinimumDataFieldLength) + { + return Terminal( + input, + input.End, + ProtocolParseStatus.Malformed, + "data-length-invalid"); + } + + var frameLength = + (long)AvlHeaderLength + + dataFieldLength + + AvlCrcLength; + if (frameLength > _maximumFrameBytes) + { + return Terminal( + input, + input.End, + ProtocolParseStatus.Malformed, + "frame-too-large"); + } + if (input.Length < frameLength) + return NeedMore(input); + + var consumed = input.GetPosition( + frameLength); + var dataField = input.Slice( + AvlHeaderLength, + dataFieldLength); + if (!TryReadExpectedCrc( + input, + dataFieldLength, + out var expectedCrc) || + TeltonikaCrc16.Compute(dataField) != + expectedCrc) + { + return Terminal( + input, + consumed, + ProtocolParseStatus.Malformed, + "crc-invalid"); + } + + var avlData = ParseDataField(dataField); + if (avlData.Status != + ProtocolParseStatus.Positions) + { + return Terminal( + input, + consumed, + avlData.Status, + avlData.ReasonCode); + } + + return new ProtocolParseResult + { + Status = ProtocolParseStatus.Positions, + Consumed = consumed, + Examined = consumed, + Message = new ProtocolMessage + { + MessageType = + ProtocolMessageType.Positions, + Positions = avlData.Positions, + ProtocolData = avlData.ProtocolData, + AcknowledgementToken = + new byte[] { avlData.RecordCount }, + RequiresResponse = true + } + }; + } + + internal static TeltonikaAvlDataParseResult + ParseDataField( + ReadOnlySequence dataField) + { + var dataReader = + new SequenceReader(dataField); + if (!dataReader.TryRead(out var codec)) + { + return TeltonikaAvlDataParseResult.Failure( + ProtocolParseStatus.Malformed, + "codec-required"); + } + if (codec != Codec8 && + codec != Codec8Extended) + { + return TeltonikaAvlDataParseResult.Failure( + ProtocolParseStatus.Unsupported, + "codec-unsupported"); + } + if (!dataReader.TryRead( + out var recordCount) || + recordCount == 0) + { + return TeltonikaAvlDataParseResult.Failure( + ProtocolParseStatus.Malformed, + "record-count-invalid"); + } + + var positions = + new List( + recordCount); + var ioRecords = + new List>( + recordCount); + var receivedOn = DateTime.UtcNow; + for (var recordIndex = 0; + recordIndex < recordCount; + recordIndex++) + { + var recordStart = dataReader.Position; + if (!TryReadPosition( + ref dataReader, + codec, + receivedOn, + out var position, + out var ioValues)) + { + return TeltonikaAvlDataParseResult + .Failure( + ProtocolParseStatus.Malformed, + "record-invalid"); + } + + position.EventId = CreateEventId( + codec, + dataField.Slice( + recordStart, + dataReader.Position)); + positions.Add(position); + ioRecords.Add(ioValues); + } + + if (!dataReader.TryRead( + out var repeatedRecordCount) || + repeatedRecordCount != recordCount || + dataReader.Remaining != 0) + { + return TeltonikaAvlDataParseResult.Failure( + ProtocolParseStatus.Malformed, + "record-count-mismatch"); + } + + return new TeltonikaAvlDataParseResult + { + Status = ProtocolParseStatus.Positions, + Positions = positions, + ProtocolData = + new TeltonikaProtocolData(ioRecords), + RecordCount = recordCount + }; + } + + private static bool TryReadPosition( + ref SequenceReader reader, + byte codec, + DateTime receivedOn, + out CanonicalTrackingPosition position, + out IReadOnlyDictionary ioValues) + { + position = null; + ioValues = null; + if (!reader.TryReadBigEndian( + out long timestampMilliseconds) || + timestampMilliseconds < 0 || + !reader.TryRead(out var priority) || + priority > 2 || + !reader.TryReadBigEndian( + out int longitudeValue) || + !reader.TryReadBigEndian( + out int latitudeValue) || + !reader.TryReadBigEndian( + out short altitude) || + !TryReadUInt16( + ref reader, + out var angle) || + angle > 360 || + !reader.TryRead(out var satellites) || + !TryReadUInt16( + ref reader, + out var speedKilometersPerHour)) + return false; + + DateTime timestamp; + try + { + timestamp = DateTimeOffset + .FromUnixTimeMilliseconds( + timestampMilliseconds) + .UtcDateTime; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + + var longitude = + longitudeValue / CoordinateScale; + var latitude = + latitudeValue / CoordinateScale; + if (longitude < -180m || + longitude > 180m || + latitude < -90m || + latitude > 90m) + return false; + + if (!TryReadIoElements( + ref reader, + codec, + out ioValues)) + return false; + + position = new CanonicalTrackingPosition + { + TimestampUtc = timestamp, + ReceivedOnUtc = receivedOn, + Latitude = latitude, + Longitude = longitude, + AltitudeMeters = altitude, + SpeedMetersPerSecond = + speedKilometersPerHour / 3.6m, + HeadingDegrees = angle, + Satellites = satellites, + TimestampSource = + TrackingTimestampSource.Device, + IsValidFix = satellites > 0 + }; + return true; + } + + private static bool TryReadIoElements( + ref SequenceReader reader, + byte codec, + out IReadOnlyDictionary ioValues) + { + ioValues = null; + var extended = codec == + Codec8Extended; + if (!TryReadCount( + ref reader, + extended, + out _) || + !TryReadCount( + ref reader, + extended, + out var totalCount) || + totalCount > + MaximumIoElementsPerRecord) + return false; + + var capturedValues = + new Dictionary(); + var parsedCount = 0; + if (!TryReadFixedIoGroup( + ref reader, + extended, + 1, + capturedValues, + ref parsedCount) || + !TryReadFixedIoGroup( + ref reader, + extended, + 2, + capturedValues, + ref parsedCount) || + !TryReadFixedIoGroup( + ref reader, + extended, + 4, + capturedValues, + ref parsedCount) || + !TryReadFixedIoGroup( + ref reader, + extended, + 8, + capturedValues, + ref parsedCount)) + return false; + + if (extended) + { + if (!TryReadUInt16( + ref reader, + out var variableCount) || + parsedCount + variableCount > + MaximumIoElementsPerRecord) + return false; + + for (var index = 0; + index < variableCount; + index++) + { + if (!TryReadUInt16( + ref reader, + out _) || + !TryReadUInt16( + ref reader, + out var valueLength) || + valueLength > + MaximumVariableIoValueBytes || + !TryAdvance( + ref reader, + valueLength)) + return false; + } + + parsedCount += variableCount; + } + + if (parsedCount != totalCount) + return false; + + ioValues = capturedValues; + return true; + } + + private static bool TryReadFixedIoGroup( + ref SequenceReader reader, + bool extended, + int valueLength, + IDictionary capturedValues, + ref int parsedCount) + { + if (!TryReadCount( + ref reader, + extended, + out var count) || + parsedCount + count > + MaximumIoElementsPerRecord) + return false; + + for (var index = 0; + index < count; + index++) + { + if (!TryReadIoId( + ref reader, + extended, + out var avlId) || + !TryReadUnsigned( + ref reader, + valueLength, + out var rawValue)) + return false; + + if (TeltonikaIoMapper.IsAllowlisted( + avlId, + valueLength)) + capturedValues[avlId] = rawValue; + } + + parsedCount += count; + return true; + } + + private static bool TryReadIoId( + ref SequenceReader reader, + bool extended, + out int avlId) + { + if (!extended) + { + if (!reader.TryRead(out var byteId)) + { + avlId = 0; + return false; + } + + avlId = byteId; + return true; + } + + if (!TryReadUInt16( + ref reader, + out var shortId)) + { + avlId = 0; + return false; + } + + avlId = shortId; + return true; + } + + private static bool TryReadUnsigned( + ref SequenceReader reader, + int length, + out ulong value) + { + value = 0; + for (var index = 0; + index < length; + index++) + { + if (!reader.TryRead(out var next)) + return false; + value = (value << 8) | next; + } + + return true; + } + + private static bool TryReadCount( + ref SequenceReader reader, + bool extended, + out int count) + { + count = 0; + if (!extended) + { + if (!reader.TryRead(out var byteCount)) + return false; + count = byteCount; + return true; + } + + if (!TryReadUInt16( + ref reader, + out var shortCount)) + return false; + count = shortCount; + return true; + } + + private static bool TryReadExpectedCrc( + ReadOnlySequence input, + uint dataFieldLength, + out ushort expectedCrc) + { + var crcReader = new SequenceReader( + input.Slice( + AvlHeaderLength + + dataFieldLength, + AvlCrcLength)); + if (!TryReadUInt32( + ref crcReader, + out var crcValue) || + crcValue > ushort.MaxValue) + { + expectedCrc = 0; + return false; + } + + expectedCrc = (ushort)crcValue; + return true; + } + + private static bool TryReadUInt16( + ref SequenceReader reader, + out ushort value) + { + if (!reader.TryReadBigEndian( + out short signedValue)) + { + value = 0; + return false; + } + + value = unchecked((ushort)signedValue); + return true; + } + + private static bool TryReadUInt32( + ref SequenceReader reader, + out uint value) + { + if (!reader.TryReadBigEndian( + out int signedValue)) + { + value = 0; + return false; + } + + value = unchecked((uint)signedValue); + return true; + } + + private static bool TryAdvance( + ref SequenceReader reader, + int length) + { + if (length < 0 || + reader.Remaining < length) + return false; + + reader.Advance(length); + return true; + } + + private static string CreateEventId( + byte codec, + ReadOnlySequence record) + { + using var hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + hash.AppendData(new[] { codec }); + foreach (var segment in record) + hash.AppendData(segment.Span); + return "teltonika:" + + Convert.ToHexString( + hash.GetHashAndReset()) + .ToLowerInvariant(); + } + + private static ProtocolParseResult NeedMore( + ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = + ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + private static ProtocolParseResult Terminal( + ReadOnlySequence input, + SequencePosition consumed, + ProtocolParseStatus status, + string reasonCode) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + ReasonCode = reasonCode + }; + } + } + + internal sealed class TeltonikaAvlDataParseResult + { + public ProtocolParseStatus Status { get; set; } + public IReadOnlyCollection + Positions { get; set; } = + Array.Empty(); + public byte RecordCount { get; set; } + public TeltonikaProtocolData ProtocolData { get; set; } + public string ReasonCode { get; set; } + + public static TeltonikaAvlDataParseResult Failure( + ProtocolParseStatus status, + string reasonCode) + { + return new TeltonikaAvlDataParseResult + { + Status = status, + ReasonCode = reasonCode + }; + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs new file mode 100644 index 000000000..b77293b4b --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs @@ -0,0 +1,259 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Text; +using Resgrid.Model; + +namespace Resgrid.Providers.Tracking.Protocols.Teltonika +{ + public sealed class TeltonikaCodec8UdpProtocolSession : + ITrackingProtocolSession, + ITrackingProtocolPositionEnricher + { + private const int ImeiLength = 15; + private const int DatagramLengthFieldBytes = 2; + private const int MinimumDataFieldLength = 3; + private const int MinimumPacketLength = + 2 + + 1 + + 1 + + 2 + + ImeiLength + + MinimumDataFieldLength; + private const byte ChannelMarker = 0x01; + private const ushort ResponsePacketLength = 5; + + private readonly int _maximumFrameBytes; + + public TeltonikaCodec8UdpProtocolSession( + TrackingSessionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (context.MaxFrameBytes <= 0) + throw new ArgumentOutOfRangeException( + nameof(context.MaxFrameBytes)); + + _maximumFrameBytes = context.MaxFrameBytes; + } + + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + if (input.Length < + DatagramLengthFieldBytes) + return NeedMore(input); + + var reader = new SequenceReader(input); + if (!TryReadUInt16( + ref reader, + out var packetLength)) + return NeedMore(input); + + var frameLength = + (long)DatagramLengthFieldBytes + + packetLength; + if (frameLength > _maximumFrameBytes) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "frame-too-large"); + } + if (input.Length < frameLength) + return NeedMore(input); + if (input.Length != frameLength) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "datagram-length-mismatch"); + } + if (packetLength < MinimumPacketLength) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "data-length-invalid"); + } + + if (!TryReadUInt16( + ref reader, + out var channelPacketId) || + !reader.TryRead(out var channelMarker) || + channelMarker != ChannelMarker || + !reader.TryRead(out var avlPacketId) || + !TryReadUInt16( + ref reader, + out var imeiLength)) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "udp-header-invalid"); + } + if (imeiLength != ImeiLength || + reader.Remaining < + imeiLength + + MinimumDataFieldLength) + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "imei-length-invalid"); + } + + var imeiBytes = input + .Slice( + reader.Position, + imeiLength) + .ToArray(); + for (var index = 0; + index < imeiBytes.Length; + index++) + { + if (imeiBytes[index] < (byte)'0' || + imeiBytes[index] > (byte)'9') + { + return Terminal( + input.End, + ProtocolParseStatus.Malformed, + "imei-invalid"); + } + } + + reader.Advance(imeiLength); + var avlData = + TeltonikaCodec8ProtocolSession + .ParseDataField( + input.Slice( + reader.Position)); + if (avlData.Status != + ProtocolParseStatus.Positions) + { + return Terminal( + input.End, + avlData.Status, + avlData.ReasonCode); + } + + var acknowledgementToken = new byte[3]; + BinaryPrimitives.WriteUInt16BigEndian( + acknowledgementToken, + channelPacketId); + acknowledgementToken[2] = avlPacketId; + return new ProtocolParseResult + { + Status = ProtocolParseStatus.Positions, + Consumed = input.End, + Examined = input.End, + Message = new ProtocolMessage + { + MessageType = + ProtocolMessageType.Positions, + ExternalIdentifier = + Encoding.ASCII.GetString( + imeiBytes), + Positions = avlData.Positions, + ProtocolData = avlData.ProtocolData, + AcknowledgementToken = + acknowledgementToken, + RequiresResponse = true + } + }; + } + + public void EnrichPositions( + ProtocolMessage message, + UnitTrackingDevice device) + { + TeltonikaIoMapper.EnrichPositions( + message, + device); + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (acceptance == null) + throw new ArgumentNullException(nameof(acceptance)); + if (message.MessageType != + ProtocolMessageType.Positions || + message.AcknowledgementToken.Length != 3) + return ReadOnlyMemory.Empty; + + byte acceptedRecords = 0; + if (acceptance.Status == + TrackingAcceptanceStatus.Accepted && + message.Positions != null && + acceptance.AcceptedPositions == + message.Positions.Count && + acceptance.AcceptedPositions <= + byte.MaxValue) + { + acceptedRecords = + (byte)acceptance.AcceptedPositions; + } + + var response = new byte[ + DatagramLengthFieldBytes + + ResponsePacketLength]; + BinaryPrimitives.WriteUInt16BigEndian( + response, + ResponsePacketLength); + message.AcknowledgementToken.Span + .Slice(0, 2) + .CopyTo( + response.AsSpan(2, 2)); + response[4] = ChannelMarker; + response[5] = + message.AcknowledgementToken.Span[2]; + response[6] = acceptedRecords; + return response; + } + + private static bool TryReadUInt16( + ref SequenceReader reader, + out ushort value) + { + if (!reader.TryReadBigEndian( + out short signedValue)) + { + value = 0; + return false; + } + + value = unchecked((ushort)signedValue); + return true; + } + + private static ProtocolParseResult NeedMore( + ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = + ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + private static ProtocolParseResult Terminal( + SequencePosition consumed, + ProtocolParseStatus status, + string reasonCode) + { + return new ProtocolParseResult + { + Status = status, + Consumed = consumed, + Examined = consumed, + ReasonCode = reasonCode + }; + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs new file mode 100644 index 000000000..1789e530a --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs @@ -0,0 +1,33 @@ +using System.Buffers; + +namespace Resgrid.Providers.Tracking.Protocols.Teltonika +{ + internal static class TeltonikaCrc16 + { + private const ushort Polynomial = 0xA001; + + public static ushort Compute( + ReadOnlySequence data) + { + ushort crc = 0; + foreach (var segment in data) + { + foreach (var value in segment.Span) + { + crc ^= value; + for (var bit = 0; + bit < 8; + bit++) + { + crc = (crc & 1) != 0 + ? (ushort)((crc >> 1) ^ + Polynomial) + : (ushort)(crc >> 1); + } + } + } + + return crc; + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs new file mode 100644 index 000000000..d70043095 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols.Teltonika +{ + internal static class TeltonikaIoMapper + { + private const string ProtocolKey = + "teltonika-codec8"; + + private static readonly Lazy< + HashSet<(int AvlId, int ValueBytes)>> + Allowlist = new(CreateAllowlist); + + public static bool IsAllowlisted( + int avlId, + int valueBytes) + { + return Allowlist.Value.Contains( + (avlId, valueBytes)); + } + + public static void EnrichPositions( + ProtocolMessage message, + UnitTrackingDevice device) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (device == null) + throw new ArgumentNullException(nameof(device)); + if (message.MessageType != + ProtocolMessageType.Positions || + message.ProtocolData is not + TeltonikaProtocolData protocolData) + return; + + var profile = UnitTrackingCatalog.GetProfile( + device.ModelKey); + if (profile == null || + !string.Equals( + profile.ProtocolKey, + ProtocolKey, + StringComparison.OrdinalIgnoreCase) || + !string.Equals( + device.ProtocolKey, + ProtocolKey, + StringComparison.OrdinalIgnoreCase)) + return; + + var ioMap = UnitTrackingCatalog.GetIoMap( + profile.IoMapKey); + if (ioMap == null || + !string.Equals( + ioMap.ProtocolKey, + ProtocolKey, + StringComparison.OrdinalIgnoreCase)) + return; + + var positions = + message.Positions?.ToList() ?? + new List(); + if (positions.Count != protocolData.Records.Count) + { + throw new InvalidOperationException( + "Teltonika I/O metadata does not match the decoded position count."); + } + + for (var index = 0; + index < positions.Count; + index++) + { + foreach (var mapping in ioMap.Mappings) + { + if (protocolData.Records[index] + .TryGetValue( + mapping.AvlId, + out var rawValue)) + { + Apply( + positions[index], + mapping, + rawValue); + } + } + } + } + + private static HashSet<(int AvlId, int ValueBytes)> + CreateAllowlist() + { + return UnitTrackingCatalog.IoMaps + .Where(ioMap => string.Equals( + ioMap.ProtocolKey, + ProtocolKey, + StringComparison.OrdinalIgnoreCase)) + .SelectMany(ioMap => ioMap.Mappings) + .Select(mapping => + (mapping.AvlId, mapping.ValueBytes)) + .ToHashSet(); + } + + private static void Apply( + CanonicalTrackingPosition position, + UnitTrackingIoMapping mapping, + ulong rawValue) + { + if ((mapping.MinimumRawValue.HasValue && + rawValue < mapping.MinimumRawValue.Value) || + (mapping.MaximumRawValue.HasValue && + rawValue > mapping.MaximumRawValue.Value)) + return; + + var value = rawValue * mapping.Multiplier; + switch (mapping.Target) + { + case UnitTrackingIoTarget.Hdop: + position.Hdop = value; + break; + case UnitTrackingIoTarget.BatteryPercent: + if (value >= 0m && value <= 100m) + position.BatteryPercent = value; + break; + case UnitTrackingIoTarget.ExternalPowerVolts: + if (value >= 0m) + position.ExternalPowerVolts = value; + break; + case UnitTrackingIoTarget.SignalPercent: + if (value >= 0m && + value <= 100m && + decimal.Truncate(value) == value) + position.SignalPercent = (int)value; + break; + case UnitTrackingIoTarget.Ignition: + if (rawValue <= 1) + position.Ignition = + rawValue == 1; + break; + case UnitTrackingIoTarget.IsMoving: + if (rawValue <= 1) + position.IsMoving = + rawValue == 1; + break; + } + } + } + + internal sealed class TeltonikaProtocolData + { + public TeltonikaProtocolData( + IReadOnlyList> + records) + { + Records = records ?? + throw new ArgumentNullException(nameof(records)); + } + + public IReadOnlyList> + Records { get; } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs new file mode 100644 index 000000000..27113e0f2 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols +{ + public static class TrackingProtocolCatalogValidator + { + public static void Validate( + ITrackingProtocolModuleRegistry moduleRegistry) + { + if (moduleRegistry == null) + throw new ArgumentNullException( + nameof(moduleRegistry)); + + var requirements = UnitTrackingCatalog.Profiles + .Where(profile => + profile.TransportType == + UnitTrackingTransportType.NativeTcpUdp) + .SelectMany(profile => + profile.SupportedTransports.Select( + transport => new + { + profile.ProtocolKey, + Transport = ParseTransport( + profile.Key, + transport) + })) + .Distinct() + .ToList(); + + foreach (var requirement in requirements) + { + if (!moduleRegistry.TryResolve( + requirement.ProtocolKey, + requirement.Transport, + out _)) + { + throw new InvalidOperationException( + $"The unit tracking catalog references unregistered protocol '{requirement.ProtocolKey}' over {requirement.Transport}."); + } + } + } + + private static TrackingSocketTransport ParseTransport( + string profileKey, + string transport) + { + if (string.Equals( + transport, + "Tcp", + StringComparison.Ordinal)) + return TrackingSocketTransport.Tcp; + if (string.Equals( + transport, + "Udp", + StringComparison.Ordinal)) + return TrackingSocketTransport.Udp; + + throw new InvalidOperationException( + $"Native unit tracking profile '{profileKey}' has an invalid socket transport."); + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs new file mode 100644 index 000000000..d31c7dd14 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs @@ -0,0 +1,107 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Resgrid.Model; +using Resgrid.Model.Tracking; + +namespace Resgrid.Providers.Tracking.Protocols +{ + public static class TrackingProtocolContract + { + public const int Version = 1; + } + + public enum TrackingSocketTransport + { + Unknown = 0, + Tcp = 1, + Udp = 2 + } + + public enum ProtocolParseStatus + { + NeedMoreData = 0, + Login = 1, + Heartbeat = 2, + Positions = 3, + Malformed = 4, + Unsupported = 5, + CloseSession = 6 + } + + public enum ProtocolMessageType + { + Unknown = 0, + Login = 1, + Heartbeat = 2, + Positions = 3 + } + + public enum TrackingAcceptanceStatus + { + Accepted = 0, + Rejected = 1, + Unavailable = 2 + } + + public sealed class TrackingSessionContext + { + public string SessionId { get; set; } + public TrackingSocketTransport Transport { get; set; } + public EndPoint RemoteEndPoint { get; set; } + public DateTime ConnectedOnUtc { get; set; } + public int MaxFrameBytes { get; set; } + public CancellationToken CancellationToken { get; set; } + } + + public sealed class ProtocolMessage + { + public ProtocolMessageType MessageType { get; set; } + public string ExternalIdentifier { get; set; } + public IReadOnlyCollection Positions { get; set; } = + Array.Empty(); + public ReadOnlyMemory AcknowledgementToken { get; set; } + public bool RequiresResponse { get; set; } + public object ProtocolData { get; set; } + } + + public sealed class ProtocolParseResult + { + public ProtocolParseStatus Status { get; set; } + public SequencePosition Consumed { get; set; } + public SequencePosition Examined { get; set; } + public ProtocolMessage Message { get; set; } + public string ReasonCode { get; set; } + } + + public sealed class TrackingAcceptance + { + public TrackingAcceptanceStatus Status { get; set; } + public int AcceptedPositions { get; set; } + public string ReasonCode { get; set; } + } + + public interface ITrackingProtocolModule + { + string ProtocolKey { get; } + IReadOnlySet SupportedTransports { get; } + ITrackingProtocolSession CreateSession(TrackingSessionContext context); + } + + public interface ITrackingProtocolSession + { + ProtocolParseResult Parse(ref ReadOnlySequence input); + ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance); + } + + public interface ITrackingProtocolPositionEnricher + { + void EnrichPositions( + ProtocolMessage message, + UnitTrackingDevice device); + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs new file mode 100644 index 000000000..6409645ee --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Providers.Tracking.Protocols +{ + public static class TrackingProtocolKeys + { + public const string Queclink = "queclink-attrack"; + public const string Gt06 = "gt06"; + public const string Teltonika = "teltonika-codec8"; + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs new file mode 100644 index 000000000..4669e5b4d --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Providers.Tracking.Protocols +{ + public class TrackingProtocolModuleRegistry : ITrackingProtocolModuleRegistry + { + private readonly Dictionary _modulesByKey; + + public TrackingProtocolModuleRegistry(IEnumerable modules) + { + var registeredModules = (modules ?? Enumerable.Empty()) + .Where(module => module != null) + .ToList(); + _modulesByKey = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + foreach (var module in registeredModules) + { + if (string.IsNullOrWhiteSpace(module.ProtocolKey)) + throw new InvalidOperationException("Tracking protocol modules must declare a protocol key."); + if (module.SupportedTransports == null || module.SupportedTransports.Count == 0) + { + throw new InvalidOperationException( + $"Tracking protocol module '{module.ProtocolKey}' must declare at least one transport."); + } + if (module.SupportedTransports.Any( + transport => transport != TrackingSocketTransport.Tcp && + transport != TrackingSocketTransport.Udp)) + { + throw new InvalidOperationException( + $"Tracking protocol module '{module.ProtocolKey}' declares an unsupported transport."); + } + + var protocolKey = module.ProtocolKey.Trim(); + if (!_modulesByKey.TryAdd(protocolKey, module)) + { + throw new InvalidOperationException( + $"Tracking protocol module '{protocolKey}' is registered more than once."); + } + } + + Modules = registeredModules.AsReadOnly(); + } + + public IReadOnlyCollection Modules { get; } + + public bool TryResolve( + string protocolKey, + TrackingSocketTransport transport, + out ITrackingProtocolModule module) + { + module = null; + if (string.IsNullOrWhiteSpace(protocolKey) || + transport == TrackingSocketTransport.Unknown || + !_modulesByKey.TryGetValue(protocolKey.Trim(), out var registeredModule) || + !registeredModule.SupportedTransports.Contains(transport)) + return false; + + module = registeredModule; + return true; + } + + public ITrackingProtocolModule Resolve( + string protocolKey, + TrackingSocketTransport transport) + { + if (TryResolve(protocolKey, transport, out var module)) + return module; + + throw new KeyNotFoundException( + $"No tracking protocol module is registered for '{protocolKey}' over {transport}."); + } + } +} diff --git a/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj b/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj new file mode 100644 index 000000000..eb3045298 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj @@ -0,0 +1,17 @@ + + + net9.0 + Resgrid.Providers.Tracking + Resgrid.Providers.Tracking + Debug;Release;Docker + + + + + + + + + + + diff --git a/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs b/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs new file mode 100644 index 000000000..0182ee5d0 --- /dev/null +++ b/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs @@ -0,0 +1,27 @@ +using Autofac; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using Resgrid.Providers.Tracking.Protocols.Queclink; +using Resgrid.Providers.Tracking.Protocols.Teltonika; + +namespace Resgrid.Providers.Tracking +{ + public class TrackingProviderModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs index a88748b06..dac07c3bc 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs @@ -15,6 +15,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs new file mode 100644 index 000000000..4a14acf4b --- /dev/null +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.Repositories; + +namespace Resgrid.Repositories.NoSqlRepository +{ + public sealed class UnitLocationRetentionRepository : + IUnitLocationRetentionRepository + { + private readonly Lazy + _postgresRepository; + private readonly Lazy + _mongoRepository; + + public UnitLocationRetentionRepository( + Lazy postgresRepository, + Lazy mongoRepository) + { + _postgresRepository = + postgresRepository ?? + throw new ArgumentNullException( + nameof(postgresRepository)); + _mongoRepository = + mongoRepository ?? + throw new ArgumentNullException( + nameof(mongoRepository)); + } + + public Task DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default) + { + if (DataConfig.DocDatabaseType == + DatabaseTypes.Postgres) + { + return _postgresRepository.Value + .DeleteHardwareLocationsBeforeAsync( + departmentId, + cutoffUtc, + batchSize, + cancellationToken); + } + + if (DataConfig.DocDatabaseType == + DatabaseTypes.MongoDb) + { + return _mongoRepository.Value + .DeleteHardwareLocationsBeforeAsync( + departmentId, + cutoffUtc, + batchSize, + cancellationToken); + } + + throw new InvalidOperationException( + $"Document database type '{DataConfig.DocDatabaseType}' does not support unit-location retention."); + } + } +} diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs index 7cc4dc80c..42613feca 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Model.Repositories; @@ -192,6 +193,58 @@ public async Task UpdateAsync(UnitsLocation location) } } + public async Task + DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default) + { + if (departmentId <= 0) + throw new ArgumentOutOfRangeException( + nameof(departmentId)); + if (cutoffUtc.Kind != DateTimeKind.Utc) + { + throw new ArgumentException( + "The retention cutoff must be UTC.", + nameof(cutoffUtc)); + } + if (batchSize <= 0) + throw new ArgumentOutOfRangeException( + nameof(batchSize)); + + using var connection = new NpgsqlConnection( + Config.DataConfig.DocumentConnectionString); + await connection.OpenAsync(cancellationToken); + var command = new Dapper.CommandDefinition( + @"WITH expired AS + ( + SELECT id + FROM public.unitlocations + WHERE departmentid = @departmentId + AND sourcetype = @sourceType + AND ""timestamp"" < @cutoff + ORDER BY ""timestamp"", id + LIMIT @batchSize + FOR UPDATE SKIP LOCKED + ) + DELETE FROM public.unitlocations AS locations + USING expired + WHERE locations.id = expired.id;", + new + { + departmentId, + sourceType = + (int)UnitLocationSourceType + .HardwareTracker, + cutoff = + ToPostgresTimestamp(cutoffUtc), + batchSize + }, + cancellationToken: cancellationToken); + return await connection.ExecuteAsync(command); + } + private static DateTime ToPostgresTimestamp(DateTime value) { return DateTime.SpecifyKind(value, DateTimeKind.Unspecified); diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs index 36ccd9eac..1b67b1962 100644 --- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs +++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; using Resgrid.Config; @@ -54,6 +55,58 @@ public async Task UpdateAsync(UnitsLocation location) return UnitLocationWriteResult.Inserted(location); } + public async Task + DeleteHardwareLocationsBeforeAsync( + int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken = default) + { + if (departmentId <= 0) + throw new ArgumentOutOfRangeException( + nameof(departmentId)); + if (cutoffUtc.Kind != DateTimeKind.Utc) + { + throw new ArgumentException( + "The retention cutoff must be UTC.", + nameof(cutoffUtc)); + } + if (batchSize <= 0) + throw new ArgumentOutOfRangeException( + nameof(batchSize)); + + await EnsureIndexesAsync().WaitAsync( + cancellationToken); + var filter = + Builders.Filter.And( + Builders.Filter.Eq( + location => location.DepartmentId, + departmentId), + Builders.Filter.Eq( + location => location.SourceType, + (int)UnitLocationSourceType + .HardwareTracker), + Builders.Filter.Lt( + location => location.Timestamp, + cutoffUtc)); + var ids = await _collection + .Find(filter) + .SortBy(location => location.Timestamp) + .ThenBy(location => location.Id) + .Limit(batchSize) + .Project(location => location.Id) + .ToListAsync(cancellationToken); + if (ids.Count == 0) + return 0; + + var result = await _collection.DeleteManyAsync( + Builders.Filter.In( + location => location.Id, + ids), + cancellationToken); + return checked((int)result.DeletedCount); + } + public Task EnsureIndexesAsync() { lock (_indexLock) diff --git a/Resgrid.sln b/Resgrid.sln index 390f6643b..47e72b976 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -108,6 +108,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Providers.Chatbot", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quidjibo.SqlServer", "Workers\Support\Quidjibo.SqlServer\Quidjibo.SqlServer.csproj", "{93931385-3360-455F-B051-CF7B56C0ED17}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Tracking", "Providers\Resgrid.Providers.Tracking\Resgrid.Providers.Tracking.csproj", "{97858451-3ECB-4E11-943B-EEDAFC7F1677}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.TrackerGateway", "Workers\Resgrid.TrackerGateway\Resgrid.TrackerGateway.csproj", "{F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Tracking.Tests", "Tests\Resgrid.Tracking.Tests\Resgrid.Tracking.Tests.csproj", "{BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Azure|Any CPU = Azure|Any CPU @@ -1534,6 +1540,114 @@ Global {93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x86.Build.0 = Debug|Any CPU {93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x64.ActiveCfg = Debug|Any CPU {93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x64.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|Any CPU.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x86.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x86.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x64.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x64.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x86.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x86.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x64.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x64.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x86.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x86.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x64.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x64.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|Any CPU.Build.0 = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x86.ActiveCfg = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x86.Build.0 = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x64.ActiveCfg = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x64.Build.0 = Docker|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|Any CPU.Build.0 = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x86.ActiveCfg = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x86.Build.0 = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x64.ActiveCfg = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x64.Build.0 = Release|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|Any CPU.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x86.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x86.Build.0 = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x64.ActiveCfg = Debug|Any CPU + {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x64.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|Any CPU.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x86.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x86.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x64.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x64.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x86.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x86.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x64.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x64.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x86.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x64.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|Any CPU.Build.0 = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x86.ActiveCfg = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x86.Build.0 = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x64.ActiveCfg = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x64.Build.0 = Docker|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|Any CPU.Build.0 = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x86.ActiveCfg = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x86.Build.0 = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x64.ActiveCfg = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x64.Build.0 = Release|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|Any CPU.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x86.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x86.Build.0 = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x64.ActiveCfg = Debug|Any CPU + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x64.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|Any CPU.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x86.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x86.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x64.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x64.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x86.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x86.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x64.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x64.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x86.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x64.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|Any CPU.Build.0 = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x86.ActiveCfg = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x86.Build.0 = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x64.ActiveCfg = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x64.Build.0 = Docker|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|Any CPU.Build.0 = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x86.ActiveCfg = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x86.Build.0 = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x64.ActiveCfg = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x64.Build.0 = Release|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|Any CPU.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x86.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x86.Build.0 = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x64.ActiveCfg = Debug|Any CPU + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1579,6 +1693,9 @@ Global {C2D3E4F5-A6B7-8901-BCDE-F12345678901} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} {D3E4F5A6-B7C8-9012-CDEF-012345678902} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} {93931385-3360-455F-B051-CF7B56C0ED17} = {89331D76-C527-479D-8F30-8033A04C625F} + {97858451-3ECB-4E11-943B-EEDAFC7F1677} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} + {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} + {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE} = {D2D96CD8-CD7D-414D-8B33-A6C363B40C8D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF} diff --git a/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs new file mode 100644 index 000000000..f84171e4d --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Repositories.NoSqlRepository; + +namespace Resgrid.Tests.Repositories +{ + [TestFixture] + [NonParallelizable] + public class UnitLocationRetentionRepositoryTests + { + private DatabaseTypes _originalDocumentDatabaseType; + + [SetUp] + public void SetUp() + { + _originalDocumentDatabaseType = + DataConfig.DocDatabaseType; + } + + [TearDown] + public void TearDown() + { + DataConfig.DocDatabaseType = + _originalDocumentDatabaseType; + } + + [Test] + public async Task DeleteHardwareLocationsBeforeAsync_WithPostgres_RoutesOnlyToPostgres() + { + // Arrange + DataConfig.DocDatabaseType = + DatabaseTypes.Postgres; + var cutoffUtc = new DateTime( + 2026, + 6, + 1, + 0, + 0, + 0, + DateTimeKind.Utc); + var postgres = + new Mock(); + postgres + .Setup(repository => + repository + .DeleteHardwareLocationsBeforeAsync( + 7, + cutoffUtc, + 100, + It.IsAny())) + .ReturnsAsync(12); + var mongo = + new Mock( + MockBehavior.Strict); + var repository = + new UnitLocationRetentionRepository( + new Lazy( + () => postgres.Object), + new Lazy( + () => mongo.Object)); + + // Act + var deleted = + await repository + .DeleteHardwareLocationsBeforeAsync( + 7, + cutoffUtc, + 100); + + // Assert + deleted.Should().Be(12); + mongo.VerifyNoOtherCalls(); + } + + [Test] + public async Task DeleteHardwareLocationsBeforeAsync_WithMongo_RoutesOnlyToMongo() + { + // Arrange + DataConfig.DocDatabaseType = + DatabaseTypes.MongoDb; + var cutoffUtc = new DateTime( + 2026, + 6, + 1, + 0, + 0, + 0, + DateTimeKind.Utc); + var postgres = + new Mock( + MockBehavior.Strict); + var mongo = + new Mock(); + mongo + .Setup(repository => + repository + .DeleteHardwareLocationsBeforeAsync( + 7, + cutoffUtc, + 100, + It.IsAny())) + .ReturnsAsync(9); + var repository = + new UnitLocationRetentionRepository( + new Lazy( + () => postgres.Object), + new Lazy( + () => mongo.Object)); + + // Act + var deleted = + await repository + .DeleteHardwareLocationsBeforeAsync( + 7, + cutoffUtc, + 100); + + // Assert + deleted.Should().Be(9); + postgres.VerifyNoOtherCalls(); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs new file mode 100644 index 000000000..377ff38fa --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Repositories.NoSqlRepository; + +namespace Resgrid.Tests.Repositories +{ + [TestFixture] + [NonParallelizable] + public class UnitLocationRetentionStoreIntegrationTests + { + private const string PostgresConnectionEnvironmentVariable = + "RESGRID_TRACKING_POSTGRES_DOCUMENT_CONNECTION"; + private const string MongoConnectionEnvironmentVariable = + "RESGRID_TRACKING_MONGODB_CONNECTION"; + private const string MongoDatabaseEnvironmentVariable = + "RESGRID_TRACKING_MONGODB_DATABASE"; + + private string _originalDocumentConnectionString; + private string _originalMongoConnectionString; + private string _originalMongoDatabaseName; + + [SetUp] + public void SetUp() + { + _originalDocumentConnectionString = + DataConfig.DocumentConnectionString; + _originalMongoConnectionString = + DataConfig.NoSqlConnectionString; + _originalMongoDatabaseName = + DataConfig.NoSqlDatabaseName; + } + + [TearDown] + public void TearDown() + { + DataConfig.DocumentConnectionString = + _originalDocumentConnectionString; + DataConfig.NoSqlConnectionString = + _originalMongoConnectionString; + DataConfig.NoSqlDatabaseName = + _originalMongoDatabaseName; + } + + [Test] + [Explicit( + "Requires a migrated PostgreSQL document test database.")] + [Category("LiveTrackingStore")] + public async Task PostgresRetention_LiveStore_DeletesOnlyBoundedExpiredHardwareRows() + { + var connectionString = + RequireEnvironmentVariable( + PostgresConnectionEnvironmentVariable); + var connectionBuilder = + new NpgsqlConnectionStringBuilder( + connectionString); + RequireTestDatabase(connectionBuilder.Database); + DataConfig.DocumentConnectionString = + connectionString; + + var fixture = CreateFixture(); + var repository = + new UnitLocationsDocRepository(); + try + { + foreach (var location in fixture.Locations) + await repository.InsertAsync(location); + + var deleted = + await repository + .DeleteHardwareLocationsBeforeAsync( + fixture.DepartmentId, + fixture.CutoffUtc, + batchSize: 1); + var secondBatch = + await repository + .DeleteHardwareLocationsBeforeAsync( + fixture.DepartmentId, + fixture.CutoffUtc, + batchSize: 1); + var remaining = + await GetPostgresEventIdsAsync( + connectionString, + fixture.EventPrefix); + + deleted.Should().Be(1); + secondBatch.Should().Be(0); + remaining.Should().BeEquivalentTo( + fixture.ExpectedRemainingEventIds); + } + finally + { + await DeletePostgresFixtureAsync( + connectionString, + fixture.EventPrefix); + } + } + + [Test] + [Explicit( + "Requires a dedicated MongoDB tracking test database.")] + [Category("LiveTrackingStore")] + public async Task MongoRetention_LiveStore_DeletesOnlyBoundedExpiredHardwareDocuments() + { + var connectionString = + RequireEnvironmentVariable( + MongoConnectionEnvironmentVariable); + var databaseName = + RequireEnvironmentVariable( + MongoDatabaseEnvironmentVariable); + RequireTestDatabase(databaseName); + DataConfig.NoSqlConnectionString = + connectionString; + DataConfig.NoSqlDatabaseName = + databaseName; + + var fixture = CreateFixture(); + var client = new MongoClient(connectionString); + var collection = client + .GetDatabase(databaseName) + .GetCollection( + "unitLocations"); + var eventFilter = EventPrefixFilter( + fixture.EventPrefix); + var repository = + new UnitLocationsMongoRepository(); + try + { + foreach (var location in fixture.Locations) + await repository.InsertAsync(location); + + var deleted = + await repository + .DeleteHardwareLocationsBeforeAsync( + fixture.DepartmentId, + fixture.CutoffUtc, + batchSize: 1); + var secondBatch = + await repository + .DeleteHardwareLocationsBeforeAsync( + fixture.DepartmentId, + fixture.CutoffUtc, + batchSize: 1); + var remaining = await collection + .Find(eventFilter) + .Project(location => location.EventId) + .ToListAsync(); + + deleted.Should().Be(1); + secondBatch.Should().Be(0); + remaining.Should().BeEquivalentTo( + fixture.ExpectedRemainingEventIds); + } + finally + { + await collection.DeleteManyAsync( + eventFilter); + } + } + + private static RetentionFixture CreateFixture() + { + var eventPrefix = + "retention-integration-" + + Guid.NewGuid().ToString("N"); + var departmentId = Random.Shared.Next( + 1000000, + 1500000); + var otherDepartmentId = + departmentId + 1500000; + var unitId = departmentId; + var otherUnitId = otherDepartmentId; + var now = DateTime.UtcNow; + var oldTimestamp = now.AddDays(-60); + var currentTimestamp = now.AddDays(-5); + var oldHardwareEvent = + eventPrefix + "-old-hardware"; + var oldUnitAppEvent = + eventPrefix + "-old-unit-app"; + var currentHardwareEvent = + eventPrefix + "-current-hardware"; + var otherDepartmentEvent = + eventPrefix + "-other-department"; + + return new RetentionFixture( + eventPrefix, + departmentId, + now.AddDays(-30), + new[] + { + CreateLocation( + oldHardwareEvent, + departmentId, + unitId, + oldTimestamp, + UnitLocationSourceType.HardwareTracker), + CreateLocation( + oldUnitAppEvent, + departmentId, + unitId, + oldTimestamp, + UnitLocationSourceType.UnitApp), + CreateLocation( + currentHardwareEvent, + departmentId, + unitId, + currentTimestamp, + UnitLocationSourceType.HardwareTracker), + CreateLocation( + otherDepartmentEvent, + otherDepartmentId, + otherUnitId, + oldTimestamp, + UnitLocationSourceType.HardwareTracker) + }, + new[] + { + oldUnitAppEvent, + currentHardwareEvent, + otherDepartmentEvent + }); + } + + private static UnitsLocation CreateLocation( + string eventId, + int departmentId, + int unitId, + DateTime timestampUtc, + UnitLocationSourceType sourceType) + { + return new UnitsLocation + { + EventId = eventId, + DepartmentId = departmentId, + UnitId = unitId, + Timestamp = timestampUtc, + ReceivedOn = timestampUtc, + SourceType = (int)sourceType, + SourceId = eventId, + SourcePriority = 100, + Latitude = 47.6062m, + Longitude = -122.3321m + }; + } + + private static string RequireEnvironmentVariable( + string name) + { + var value = + Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + Assert.Fail( + $"Environment variable '{name}' is required."); + } + + return value; + } + + private static void RequireTestDatabase( + string databaseName) + { + if (string.IsNullOrWhiteSpace(databaseName) || + (!databaseName.EndsWith( + "_test", + StringComparison.OrdinalIgnoreCase) && + !databaseName.EndsWith( + "_integration", + StringComparison.OrdinalIgnoreCase))) + { + Assert.Fail( + "Live retention tests require a database name ending in '_test' or '_integration'."); + } + } + + private static FilterDefinition + EventPrefixFilter(string eventPrefix) + { + return Builders.Filter.Regex( + location => location.EventId, + new BsonRegularExpression( + "^" + Regex.Escape(eventPrefix))); + } + + private static async Task> + GetPostgresEventIdsAsync( + string connectionString, + string eventPrefix) + { + var eventIds = new List(); + await using var connection = + new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + @"SELECT eventid + FROM public.unitlocations + WHERE eventid LIKE @pattern + ORDER BY eventid;", + connection); + command.Parameters.AddWithValue( + "pattern", + eventPrefix + "%"); + await using var reader = + await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + eventIds.Add(reader.GetString(0)); + return eventIds; + } + + private static async Task DeletePostgresFixtureAsync( + string connectionString, + string eventPrefix) + { + await using var connection = + new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + @"DELETE FROM public.unitlocations + WHERE eventid LIKE @pattern;", + connection); + command.Parameters.AddWithValue( + "pattern", + eventPrefix + "%"); + await command.ExecuteNonQueryAsync(); + } + + private sealed class RetentionFixture + { + public RetentionFixture( + string eventPrefix, + int departmentId, + DateTime cutoffUtc, + IReadOnlyCollection locations, + IReadOnlyCollection + expectedRemainingEventIds) + { + EventPrefix = eventPrefix; + DepartmentId = departmentId; + CutoffUtc = cutoffUtc; + Locations = locations; + ExpectedRemainingEventIds = + expectedRemainingEventIds; + } + + public string EventPrefix { get; } + public int DepartmentId { get; } + public DateTime CutoffUtc { get; } + public IReadOnlyCollection + Locations + { get; } + public IReadOnlyCollection + ExpectedRemainingEventIds + { get; } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs index 40b2f8812..604c3175d 100644 --- a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs @@ -1,8 +1,10 @@ +using System; using System.Linq; using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Model.Tracking; using Resgrid.Services; namespace Resgrid.Tests.Services @@ -21,7 +23,29 @@ public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdap // Assert profiles.Select(profile => profile.Key) - .Should().BeEquivalentTo("generic-https", "traccar-forwarder"); + .Should().BeEquivalentTo( + "generic-https", + "traccar-forwarder", + "teltonika-fmc920", + "teltonika-fmm920", + "teltonika-fmc130", + "teltonika-fmm130", + "teltonika-fmc003", + "queclink-gv57mg", + "queclink-gv350mg", + "queclink-gv500ma", + "jimi-vl103m", + "jimi-jm-vl03", + "teltonika-fmm003", + "teltonika-fmc125", + "teltonika-fmm125", + "teltonika-fmc150", + "teltonika-fmm150", + "teltonika-fmc230", + "teltonika-fmm230", + "jimi-jm-vl01", + "jimi-jm-vl02", + "jimi-jm-vl04"); profiles.Single(profile => profile.Key == "generic-https") .CertificationStatus.Should().Be(UnitTrackingCertificationStatus.Certified); profiles.Single(profile => profile.Key == "traccar-forwarder") @@ -35,8 +59,179 @@ public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdap profiles.Single(profile => profile.Key == "traccar-forwarder") .SetupSummary.Should().Contain("v6.14.5"); profiles.Should().OnlyContain(profile => - !string.IsNullOrWhiteSpace(profile.PayloadAdapterKey) && - profile.SupportedAuthModes.Count > 0); + !string.IsNullOrWhiteSpace(profile.ProtocolKey) && + !string.IsNullOrWhiteSpace(profile.DecoderVariant) && + !string.IsNullOrWhiteSpace(profile.ProtocolDocumentVersion) && + profile.SupportedTransports.Count > 0); + profiles.Select(profile => profile.Key) + .Should().OnlyHaveUniqueItems(); + } + + [Test] + public async Task GetProfilesAsync_TeltonikaWaveOne_RemainsCandidateWithDataDrivenIoMap() + { + // Arrange + var service = new UnitTrackingCatalogService(); + var waveOneKeys = new[] + { + "teltonika-fmc920", + "teltonika-fmm920", + "teltonika-fmc130", + "teltonika-fmm130", + "teltonika-fmc003" + }; + + // Act + var profiles = await service.GetProfilesAsync(); + var teltonika = profiles + .Where(profile => waveOneKeys.Contains( + profile.Key, + StringComparer.Ordinal)) + .ToList(); + + // Assert + teltonika.Should().HaveCount(5); + teltonika.Select(profile => profile.Model) + .Should().BeEquivalentTo( + "FMC920", + "FMM920", + "FMC130", + "FMM130", + "FMC003"); + teltonika.Should().OnlyContain(profile => + profile.TransportType == + UnitTrackingTransportType.NativeTcpUdp && + profile.ProtocolKey == "teltonika-codec8" && + profile.DecoderVariant == "codec8-or-8e" && + profile.IoMapKey == + "teltonika-fmb-03-29-common" && + profile.CertificationStatus == + UnitTrackingCertificationStatus.Candidate && + !profile.IsSelectable && + profile.CertifiedTransports.Count == 0 && + profile.SupportedAuthModes.Count == 0); + teltonika.Should().OnlyContain(profile => + profile.SupportedTransports + .OrderBy(transport => transport) + .SequenceEqual( + new[] { "Tcp", "Udp" })); + var ioMap = UnitTrackingCatalog.GetIoMap( + "teltonika-fmb-03-29-common"); + ioMap.Should().NotBeNull(); + ioMap.Mappings.Select(mapping => mapping.AvlId) + .Should().BeEquivalentTo( + new[] { 66, 182, 239, 240 }); + } + + [Test] + public async Task GetProfilesAsync_WaveTwo_RemainsUnselectableWithoutUnverifiedMappings() + { + // Arrange + var service = new UnitTrackingCatalogService(); + var waveTwoKeys = new[] + { + "teltonika-fmm003", + "teltonika-fmc125", + "teltonika-fmm125", + "teltonika-fmc150", + "teltonika-fmm150", + "teltonika-fmc230", + "teltonika-fmm230", + "jimi-jm-vl01", + "jimi-jm-vl02", + "jimi-jm-vl04" + }; + + // Act + var profiles = await service.GetProfilesAsync(); + var waveTwo = profiles + .Where(profile => waveTwoKeys.Contains( + profile.Key, + StringComparer.Ordinal)) + .ToList(); + + // Assert + waveTwo.Should().HaveCount(waveTwoKeys.Length); + waveTwo.Select(profile => profile.Key) + .Should().BeEquivalentTo(waveTwoKeys); + waveTwo.Should().OnlyContain(profile => + profile.CertificationStatus == + UnitTrackingCertificationStatus.Candidate && + !profile.IsSelectable && + profile.CertifiedTransports.Count == 0 && + profile.SupportedAuthModes.Count == 0 && + string.IsNullOrWhiteSpace(profile.IoMapKey) && + profile.ProtocolDocumentVersion.Contains( + "certification pending", + StringComparison.Ordinal)); + waveTwo.Where(profile => + profile.ManufacturerKey == "teltonika") + .Should().OnlyContain(profile => + profile.ProtocolKey == "teltonika-codec8" && + profile.DecoderVariant == "codec8-or-8e" && + profile.SupportedTransports + .OrderBy(transport => transport) + .SequenceEqual( + new[] { "Tcp", "Udp" })); + waveTwo.Where(profile => + profile.ManufacturerKey == "jimi") + .Should().OnlyContain(profile => + profile.ProtocolKey == "gt06" && + profile.DecoderVariant.EndsWith( + "-unverified", + StringComparison.Ordinal) && + profile.SupportedTransports.SequenceEqual( + new[] { "Tcp" })); + } + + [Test] + public async Task GetProfilesAsync_QueclinkAndJimiWaveOne_RemainTcpCandidates() + { + // Arrange + var service = new UnitTrackingCatalogService(); + + // Act + var profiles = await service.GetProfilesAsync(); + var candidates = profiles + .Where(profile => + profile.Key == "queclink-gv57mg" || + profile.Key == "queclink-gv350mg" || + profile.Key == "queclink-gv500ma" || + profile.Key == "jimi-vl103m" || + profile.Key == "jimi-jm-vl03") + .ToList(); + + // Assert + candidates.Select(profile => profile.Key) + .Should().BeEquivalentTo( + "queclink-gv57mg", + "queclink-gv350mg", + "queclink-gv500ma", + "jimi-vl103m", + "jimi-jm-vl03"); + candidates.Should().OnlyContain(profile => + profile.TransportType == + UnitTrackingTransportType.NativeTcpUdp && + profile.CertificationStatus == + UnitTrackingCertificationStatus.Candidate && + !profile.IsSelectable && + profile.CertifiedTransports.Count == 0 && + profile.SupportedAuthModes.Count == 0 && + profile.SupportedTransports.SequenceEqual( + new[] { "Tcp" })); + candidates.Where(profile => + profile.ManufacturerKey == "queclink") + .Should().OnlyContain(profile => + profile.ProtocolKey == "queclink-attrack" && + profile.DecoderVariant == + "gl200-text-bounded"); + candidates.Where(profile => + profile.ManufacturerKey == "jimi") + .Should().OnlyContain(profile => + profile.ProtocolKey == "gt06" && + profile.DecoderVariant.StartsWith( + "gt06-", + StringComparison.Ordinal)); } [Test] diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs index 56fd00ec9..6986341b6 100644 --- a/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs @@ -214,6 +214,63 @@ public async Task AcceptAsync_ConfirmedPublishFailure_ReturnsUnavailable() result.Accepted.Should().Be(0); } + [Test] + public async Task AcceptHeartbeatAsync_ValidBinding_UpdatesLastSeenWithoutPublishing() + { + // Arrange + var source = Source(); + source.ReportedDeviceIdentifier = "DEVICE-1234"; + + // Act + var result = await _service.AcceptHeartbeatAsync( + source, + _receivedOn); + + // Assert + result.Status.Should().Be( + TrackingIngressStatus.Accepted); + result.ReceivedOn.Should().Be(_receivedOn); + source.Device.LastSeenOn.Should().Be(_receivedOn); + source.Device.LastStatus.Should().Be( + (int)UnitTrackingDeviceStatus.Online); + _eventProvider.Verify( + provider => + provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task AcceptHeartbeatAsync_TenantBindingMismatch_RejectsHeartbeat() + { + // Arrange + _unitsService + .Setup(service => + service.GetUnitByIdAsync(UnitId)) + .ReturnsAsync( + new Unit + { + UnitId = UnitId, + DepartmentId = 999 + }); + + // Act + var result = await _service.AcceptHeartbeatAsync( + Source(), + _receivedOn); + + // Assert + result.Status.Should().Be( + TrackingIngressStatus.Invalid); + _eventProvider.Verify( + provider => + provider.EnqueueUnitLocationEventsAsync( + It.IsAny>(), + It.IsAny()), + Times.Never); + } + private AuthenticatedTrackingSource Source() { return new AuthenticatedTrackingSource diff --git a/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs b/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs new file mode 100644 index 000000000..136245605 --- /dev/null +++ b/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Tests.Workers +{ + [TestFixture] + public class UnitTrackingRetentionLogicTests + { + private bool _originalEnabled; + private int _originalBatchSize; + private int _originalMaximumRows; + + [SetUp] + public void SetUp() + { + _originalEnabled = + UnitTrackingConfig + .LocationRetentionWorkerEnabled; + _originalBatchSize = + UnitTrackingConfig + .LocationRetentionBatchSize; + _originalMaximumRows = + UnitTrackingConfig + .LocationRetentionMaxRowsPerRun; + } + + [TearDown] + public void TearDown() + { + UnitTrackingConfig + .LocationRetentionWorkerEnabled = + _originalEnabled; + UnitTrackingConfig + .LocationRetentionBatchSize = + _originalBatchSize; + UnitTrackingConfig + .LocationRetentionMaxRowsPerRun = + _originalMaximumRows; + } + + [Test] + public async Task Process_WhenDisabled_DoesNotReadOrDeleteLocations() + { + // Arrange + UnitTrackingConfig + .LocationRetentionWorkerEnabled = false; + var departments = + new Mock( + MockBehavior.Strict); + var settings = + new Mock( + MockBehavior.Strict); + var retention = + new Mock( + MockBehavior.Strict); + var logic = new UnitTrackingRetentionLogic( + departments.Object, + settings.Object, + retention.Object); + + // Act + var result = await logic.Process( + CancellationToken.None); + + // Assert + result.Item1.Should().BeTrue(); + result.Item2.Should().Contain("disabled"); + departments.VerifyNoOtherCalls(); + settings.VerifyNoOtherCalls(); + retention.VerifyNoOtherCalls(); + } + + [Test] + public async Task Process_WithDepartmentSettings_UsesBoundedPerDepartmentCutoffs() + { + // Arrange + UnitTrackingConfig + .LocationRetentionWorkerEnabled = true; + UnitTrackingConfig + .LocationRetentionBatchSize = 2; + UnitTrackingConfig + .LocationRetentionMaxRowsPerRun = 10; + var runUtc = new DateTime( + 2026, + 7, + 26, + 12, + 0, + 0, + DateTimeKind.Utc); + var departments = + new Mock(); + departments + .Setup(repository => repository.GetAllAsync()) + .ReturnsAsync( + new[] + { + new Department { DepartmentId = 2 }, + new Department { DepartmentId = 1 }, + new Department { DepartmentId = 1 }, + new Department { DepartmentId = 0 } + }); + var settings = + new Mock(); + settings + .Setup(service => + service + .GetHardwareTrackingLocationRetentionDaysAsync( + It.IsAny(), + false)) + .ReturnsAsync( + (int departmentId, bool bypassCache) => + departmentId == 1 ? 30 : 60); + var calls = + new List(); + var results = new Queue( + new[] { 2, 0, 1 }); + var retention = + new Mock(); + retention + .Setup(repository => + repository + .DeleteHardwareLocationsBeforeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken) => + calls.Add( + new RetentionCall( + departmentId, + cutoffUtc, + batchSize))) + .ReturnsAsync(() => results.Dequeue()); + var logic = new UnitTrackingRetentionLogic( + departments.Object, + settings.Object, + retention.Object); + + // Act + var result = await logic.Process( + CancellationToken.None, + runUtc); + + // Assert + result.Item1.Should().BeTrue(); + result.Item2.Should().Contain( + "deleted 3 hardware location(s) across 2 department(s)"); + calls.Should().Equal( + new RetentionCall( + 1, + runUtc.AddDays(-30), + 2), + new RetentionCall( + 1, + runUtc.AddDays(-30), + 2), + new RetentionCall( + 2, + runUtc.AddDays(-60), + 2)); + } + + [Test] + public async Task Process_WhenMaximumRowsReached_StopsBeforeNextDepartment() + { + // Arrange + UnitTrackingConfig + .LocationRetentionWorkerEnabled = true; + UnitTrackingConfig + .LocationRetentionBatchSize = 2; + UnitTrackingConfig + .LocationRetentionMaxRowsPerRun = 3; + var departments = + new Mock(); + departments + .Setup(repository => repository.GetAllAsync()) + .ReturnsAsync( + new[] + { + new Department { DepartmentId = 1 }, + new Department { DepartmentId = 2 } + }); + var settings = + new Mock(); + settings + .Setup(service => + service + .GetHardwareTrackingLocationRetentionDaysAsync( + 1, + false)) + .ReturnsAsync(30); + var requestedBatchSizes = new List(); + var results = new Queue( + new[] { 2, 1 }); + var retention = + new Mock(); + retention + .Setup(repository => + repository + .DeleteHardwareLocationsBeforeAsync( + 1, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (int departmentId, + DateTime cutoffUtc, + int batchSize, + CancellationToken cancellationToken) => + requestedBatchSizes.Add(batchSize)) + .ReturnsAsync(() => results.Dequeue()); + var logic = new UnitTrackingRetentionLogic( + departments.Object, + settings.Object, + retention.Object); + + // Act + var result = await logic.Process( + CancellationToken.None, + new DateTime( + 2026, + 7, + 26, + 12, + 0, + 0, + DateTimeKind.Utc)); + + // Assert + result.Item1.Should().BeTrue(); + result.Item2.Should().Contain( + "deleted 3 hardware location(s) across 1 department(s)"); + requestedBatchSizes.Should().Equal(2, 1); + settings.Verify( + service => + service + .GetHardwareTrackingLocationRetentionDaysAsync( + 2, + false), + Times.Never); + } + + private readonly record struct RetentionCall( + int DepartmentId, + DateTime CutoffUtc, + int BatchSize); + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md b/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md new file mode 100644 index 000000000..8055a4922 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md @@ -0,0 +1,8 @@ +# GT06 fixtures + +These sanitized binary messages are copied from the Apache-licensed Traccar v6.14.5 +`Gt06ProtocolDecoderTest` at commit +`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. They cover login, a standard +GPS/LBS frame, the JM-VL03-associated `0xA0` layout, and status/heartbeat framing. +They are upstream interoperability fixtures, not captured target-device evidence and +not certification evidence. diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex new file mode 100644 index 000000000..5a1057abb --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex @@ -0,0 +1 @@ +78780A13C40604000201298F5B0D0A diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex new file mode 100644 index 000000000..7fd9a22bd --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex @@ -0,0 +1 @@ +787829A0170704112226CF0163FE7C0420F6F000091302D402000091290000000007186B8F01030001460D010D0A diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex new file mode 100644 index 000000000..ea4eabc1a --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex @@ -0,0 +1 @@ +787822220F0C1D023305C9027AC8180C46586000140001CC00287D001F71000001000820860D0A diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex new file mode 100644 index 000000000..92ae7e24e --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex @@ -0,0 +1 @@ +78780D01086471700328358100093F040D0A diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md b/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md new file mode 100644 index 000000000..c840467cd --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md @@ -0,0 +1,7 @@ +# Queclink fixtures + +These sanitized ASCII messages are copied from the Apache-licensed Traccar v6.14.5 +`Gl200TextProtocolDecoderTest` at commit +`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. They exercise common `+RESP`, +`+BUFF`, ignition, and heartbeat behavior. They are upstream interoperability fixtures, +not captured evidence from the three target devices and not certification evidence. diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt new file mode 100644 index 000000000..eed8a4ea1 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt @@ -0,0 +1 @@ ++BUFF:GTERI,358803,862364030261132,gv200,00000002,,10,1,1,43.4,160,592.2,46.723488,24.590880,20240817131155,0420,0001,01AE,393D,00,19273.0,,,12,,05,00,2,0,20240817131205,C81A$ diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt new file mode 100644 index 000000000..d336c10e2 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt @@ -0,0 +1 @@ ++RESP:GTFRI,DF0200,868487004353181,cv100,14051,10,1,0,0.0,0,264.1,114.015515,22.537178,20210608064328,0460,0001,25F8,061A7D02,,0.0,,,,100,21,,,,20210608144354,32DB$ diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt new file mode 100644 index 000000000..581d61c18 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt @@ -0,0 +1 @@ ++RESP:GTIGN,BD0221,861778061565387,CV200,,0,140944,0,0.0,358,17.6,-2.223488,51.882342,20251222020123,0234,0015,6093,000F830E,,,5810.9,20251222070035,20251222070035,C60E$ diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt new file mode 100644 index 000000000..10b7943db --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt @@ -0,0 +1 @@ ++ACK:GTHBD,1A0401,135790246811220,,20100214093254,11F0$ diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md new file mode 100644 index 000000000..eab60369d --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md @@ -0,0 +1,26 @@ +# Teltonika generated protocol fixtures + +These fixtures were independently generated from the public Teltonika Codec8/8E packet +structure documented at: + +https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols + +They contain no customer or captured device data and do not constitute hardware or +firmware certification. + +- `codec8-location.hex`: one Codec8 record for San Francisco at + `2024-01-02T03:04:05Z`, 15 m altitude, 90° heading, 8 satellites, and 36 km/h. +- `codec8e-location.hex`: one Codec8 Extended record for Berlin at the same timestamp, + 34 m altitude, 270° heading, 11 satellites, and 72 km/h. +- `codec8-udp-location.hex`: the Codec8 data array above wrapped in the documented UDP + channel using channel packet ID `0xCAFE`, AVL packet ID `0x05`, and the generated + test IMEI. +- `codec8e-udp-location.hex`: the Codec8 Extended data array above wrapped in the + documented UDP channel using channel packet ID `0xBEEF`, AVL packet ID `0x07`, and + the same generated test IMEI. +- `codec8-io-location.hex`: one generated Codec8 record with the shared Wave 1 + allowlisted values for HDOP, external voltage, ignition, and movement. + +The original Codec8 and Codec8 Extended records deliberately contain zero I/O elements. +The I/O fixture validates only the documented shared Wave 1 map and does not represent +captured model/firmware evidence. diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex new file mode 100644 index 000000000..f17a7d7d1 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex @@ -0,0 +1 @@ +000000000000002B08010000018CC820D88801B70848301683FE08000F005A080024EF0402EF01F001024230D4B6000C000001000071FF diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex new file mode 100644 index 000000000..fdd276179 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex @@ -0,0 +1 @@ +000000000000002108010000018CC820D88801B70848301683FE08000F005A0800240000000000000100000930 diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex new file mode 100644 index 000000000..9c8e70f11 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex @@ -0,0 +1 @@ +0036CAFE0105000F33353633303730343234343130313308010000018CC820D88801B70848301683FE08000F005A08002400000000000001 diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex new file mode 100644 index 000000000..78b227cdf --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex @@ -0,0 +1 @@ +00000000000000298E010000018CC820D8880207FD70D01F4DEA800022010E0B00480000000000000000000000000000010000BC2D diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex new file mode 100644 index 000000000..2ab8be16b --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex @@ -0,0 +1 @@ +003EBEEF0107000F3335363330373034323434313031338E010000018CC820D8880207FD70D01F4DEA800022010E0B0048000000000000000000000000000001 diff --git a/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs b/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs new file mode 100644 index 000000000..2dd92f2d0 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NUnit.Framework; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.Tracking.Tests.Health +{ + [TestFixture] + public class TrackingGatewayHealthCheckTests + { + [Test] + public async Task CheckHealthAsync_RequiredListenerNotBound_IsUnhealthy() + { + // Arrange + var definition = new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + 5000); + var state = new TrackingGatewayReadinessState(); + state.Initialize(new TrackingListenerPlan(new[] { definition })); + var healthCheck = new TrackingGatewayReadinessHealthCheck(state); + + // Act + var result = await healthCheck.CheckHealthAsync( + new HealthCheckContext()); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Test] + public async Task CheckHealthAsync_AllRequiredListenersBound_IsHealthy() + { + // Arrange + var definition = new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + 5000); + var state = new TrackingGatewayReadinessState(); + state.Initialize(new TrackingListenerPlan(new[] { definition })); + state.MarkBound(definition); + var healthCheck = new TrackingGatewayReadinessHealthCheck(state); + + // Act + var result = await healthCheck.CheckHealthAsync( + new HealthCheckContext()); + + // Assert + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Test] + public void MetricsWriter_ReadinessSnapshot_EmitsBoundedGatewayGauges() + { + // Arrange + var snapshot = new TrackingGatewayReadinessSnapshot( + expectedListeners: 2, + boundListeners: 1, + isReady: false, + hasFailure: false); + + // Act + var metrics = TrackingGatewayMetricsWriter.Write(snapshot); + + // Assert + metrics.Should().Contain( + "resgrid_tracker_gateway_listeners_expected 2"); + metrics.Should().Contain( + "resgrid_tracker_gateway_listeners_bound 1"); + metrics.Should().Contain( + "resgrid_tracker_gateway_ready 0"); + metrics.Should().NotContain( + "resgrid_tracker_gateway_connections_limit"); + } + + [Test] + public void MetricsWriter_ConfiguredConnectionLimit_EmitsCapacityGauge() + { + // Arrange + var snapshot = new TrackingGatewayReadinessSnapshot( + expectedListeners: 1, + boundListeners: 1, + isReady: true, + hasFailure: false); + + // Act + var metrics = TrackingGatewayMetricsWriter.Write( + snapshot, + connectionLimit: 2500); + + // Assert + metrics.Should().Contain( + "resgrid_tracker_gateway_connections_limit 2500"); + } + + [Test] + public void MetricsWriter_TrackingActivity_EmitsBoundedLabelsAndHistograms() + { + // Arrange + var definition = new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + 5000); + var activity = new TrackingGatewayMetrics(); + activity.ConnectionStarted(definition); + activity.ConnectionCompleted( + definition, + "completed"); + activity.RecordIngressMessage( + definition, + new ProtocolMessage + { + MessageType = ProtocolMessageType.Positions, + Positions = + new List + { + new CanonicalTrackingPosition() + } + }, + new TrackingAcceptance + { + Status = TrackingAcceptanceStatus.Accepted, + AcceptedPositions = 1 + }); + activity.RecordParseFailure( + definition.ProtocolKey, + "device-specific-parser-reason"); + activity.RecordAuthFailure( + definition.Transport, + "device-specific-auth-reason"); + activity.ObserveQueuePublishDuration( + definition.Transport, + TimeSpan.FromMilliseconds(50)); + activity.ObserveFrameBytes( + definition.ProtocolKey, + 128); + activity.ObserveSessionDuration( + definition.ProtocolKey, + TimeSpan.FromSeconds(2)); + + // Act + var metrics = TrackingGatewayMetricsWriter.Write( + new TrackingGatewayReadinessSnapshot( + expectedListeners: 1, + boundListeners: 1, + isReady: true, + hasFailure: false), + activity); + + // Assert + metrics.Should().Contain( + "resgrid_tracking_connections_current{protocol=\"synthetic-v1\",transport=\"tcp\"} 0"); + metrics.Should().Contain( + "resgrid_tracking_connections_total{protocol=\"synthetic-v1\",outcome=\"completed\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_ingress_messages_total{transport=\"tcp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_positions_total{transport=\"tcp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_parse_failures_total{protocol=\"synthetic-v1\",reason=\"other\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_auth_failures_total{transport=\"tcp\",reason=\"other\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_queue_publish_duration_seconds_count{transport=\"tcp\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_frame_bytes_count{protocol=\"synthetic-v1\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_session_duration_seconds_count{protocol=\"synthetic-v1\"} 1"); + metrics.Should().NotContain( + "device-specific"); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs b/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs new file mode 100644 index 000000000..7f9bd5c83 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs @@ -0,0 +1,479 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.Tracking.Tests.Hosting +{ + [TestFixture] + public class TrackingListenerPlanBuilderTests + { + [Test] + public void Build_NativeGatewayDisabled_ReturnsEmptyPlan() + { + // Arrange + var settings = CreateSettings( + trackingEnabled: false, + nativeGatewayEnabled: false, + credentialPepper: null, + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + true, + 5000) + }); + var registry = new TrackingProtocolModuleRegistry( + Array.Empty()); + var factory = new SupportingListenerFactory(); + + // Act + var plan = new TrackingListenerPlanBuilder().Build( + settings, + registry, + factory); + + // Assert + plan.Listeners.Should().BeEmpty(); + } + + [Test] + public void Build_NativeGatewayEnabledWithoutMasterSwitch_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + trackingEnabled: false, + protocols: Array.Empty()); + var registry = new TrackingProtocolModuleRegistry( + Array.Empty()); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .Which.Errors.Should().Contain( + error => error.Contains( + "UnitTrackingConfig.Enabled", + StringComparison.Ordinal)); + } + + [Test] + public void Build_NativeGatewayEnabledWithoutPepper_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + credentialPepper: " ", + protocols: Array.Empty()); + var registry = new TrackingProtocolModuleRegistry( + Array.Empty()); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .Which.Errors.Should().Contain( + error => error.Contains( + "CredentialPepper", + StringComparison.Ordinal)); + } + + [Test] + public void Build_FrameLimitAboveSafetyCeiling_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + maxFrameBytes: 1024 * 1024 + 1, + protocols: + Array.Empty()); + var registry = new TrackingProtocolModuleRegistry( + Array.Empty()); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .Which.Errors.Should().Contain( + error => error.Contains( + "MaxFrameBytes", + StringComparison.Ordinal)); + } + + [Test] + public void Build_RegisteredTcpModule_PlansOnlyCertifiedTransport() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + false, + 0) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp) + }); + + // Act + var plan = new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + plan.Listeners.Should().ContainSingle(); + plan.Listeners.Single().Transport.Should().Be( + TrackingSocketTransport.Tcp); + plan.Listeners.Single().Port.Should().Be(5000); + } + + [Test] + public void Build_EnabledTransportNotSupportedByModule_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + false, + 5000, + true, + 5001) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp) + }); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*does not support enabled transport 'Udp'*"); + } + + [Test] + public void Build_EnabledProtocolWithoutTransport_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + false, + 5000, + false, + 5001) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp) + }); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*must enable at least one transport*"); + } + + [Test] + public void Build_TcpAndUdpUseSamePort_AllowsTransportSpecificBindings() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + true, + 5000) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp) + }); + + // Act + var plan = new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + plan.Listeners.Should().HaveCount(2); + plan.Listeners.Select(listener => listener.Transport) + .Should() + .BeEquivalentTo(new[] + { + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp + }); + } + + [Test] + public void Build_EnabledProtocolWithoutModule_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + true, + 5000) + }); + var registry = new TrackingProtocolModuleRegistry( + Array.Empty()); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*No tracking protocol module is registered*"); + } + + [Test] + public void Build_DuplicateTcpPort_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-a", + true, + true, + 5000, + false, + 5001), + new TrackingProtocolListenerSettings( + "synthetic-b", + true, + true, + 5000, + false, + 5002) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-a", + TrackingSocketTransport.Tcp), + new TestProtocolModule( + "synthetic-b", + TrackingSocketTransport.Tcp) + }); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*more than one Tcp listener*"); + } + + [Test] + public void Build_HealthPortCollidesWithTcpListener_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + internalHealthPort: 5000, + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + false, + 5001) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp) + }); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new SupportingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*InternalHealthPort cannot share*"); + } + + [Test] + public void Build_ListenerImplementationUnavailable_RejectsConfiguration() + { + // Arrange + var settings = CreateSettings( + protocols: new[] + { + new TrackingProtocolListenerSettings( + "synthetic-v1", + true, + true, + 5000, + false, + 5001) + }); + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Tcp) + }); + + // Act + Action act = () => new TrackingListenerPlanBuilder().Build( + settings, + registry, + new UnavailableTrackingListenerFactory()); + + // Assert + act.Should().Throw() + .WithMessage("*No socket listener implementation is registered*"); + } + + private static TrackingGatewaySettings CreateSettings( + bool trackingEnabled = true, + bool nativeGatewayEnabled = true, + string credentialPepper = "test-pepper", + int maxFrameBytes = 65536, + int internalHealthPort = 8080, + IEnumerable protocols = null) + { + return new TrackingGatewaySettings( + trackingEnabled, + nativeGatewayEnabled, + credentialPepper, + tcpIdleTimeoutSeconds: 300, + maxFrameBytes, + maxConnections: 5000, + maxConnectionsPerIp: 100, + gracefulShutdownSeconds: 30, + internalHealthPort, + protocols); + } + + private sealed class SupportingListenerFactory : + ITrackingListenerFactory + { + public bool Supports(TrackingListenerDefinition definition) + { + return true; + } + + public ITrackingListener Create( + TrackingListenerDefinition definition) + { + throw new NotSupportedException(); + } + } + + private sealed class TestProtocolModule : ITrackingProtocolModule + { + public TestProtocolModule( + string protocolKey, + params TrackingSocketTransport[] supportedTransports) + { + ProtocolKey = protocolKey; + SupportedTransports = + new HashSet(supportedTransports); + } + + public string ProtocolKey { get; } + public IReadOnlySet SupportedTransports { get; } + + public ITrackingProtocolSession CreateSession( + TrackingSessionContext context) + { + return new TestProtocolSession(); + } + } + + private sealed class TestProtocolSession : ITrackingProtocolSession + { + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + return ReadOnlyMemory.Empty; + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Listeners/TrackingConnectionAdmissionTests.cs b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingConnectionAdmissionTests.cs new file mode 100644 index 000000000..4340871e4 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingConnectionAdmissionTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.Tracking.Tests.Listeners +{ + [TestFixture] + public class TrackingConnectionAdmissionTests + { + [Test] + public void TryAcquire_GlobalLimitReached_RejectsAdditionalConnection() + { + // Arrange + var admission = new TrackingConnectionAdmission( + maximumConnections: 2, + maximumConnectionsPerIp: 2); + + // Act + var firstAccepted = admission.TryAcquire( + IPAddress.Parse("192.0.2.1"), + out var firstLease); + var secondAccepted = admission.TryAcquire( + IPAddress.Parse("192.0.2.2"), + out var secondLease); + var thirdAccepted = admission.TryAcquire( + IPAddress.Parse("192.0.2.3"), + out var thirdLease); + + // Assert + firstAccepted.Should().BeTrue(); + secondAccepted.Should().BeTrue(); + thirdAccepted.Should().BeFalse(); + thirdLease.Should().BeNull(); + admission.CurrentConnections.Should().Be(2); + + firstLease.Dispose(); + secondLease.Dispose(); + } + + [Test] + public void TryAcquire_Ipv4AndMappedIpv6Address_EnforcesOnePerIpLimit() + { + // Arrange + var admission = new TrackingConnectionAdmission( + maximumConnections: 2, + maximumConnectionsPerIp: 1); + var ipv4 = IPAddress.Parse("192.0.2.10"); + var mappedIpv6 = ipv4.MapToIPv6(); + + // Act + var firstAccepted = admission.TryAcquire( + ipv4, + out var firstLease); + var mappedAccepted = admission.TryAcquire( + mappedIpv6, + out var mappedLease); + + // Assert + firstAccepted.Should().BeTrue(); + mappedAccepted.Should().BeFalse(); + mappedLease.Should().BeNull(); + + firstLease.Dispose(); + } + + [Test] + public void Dispose_ActiveLease_ReleasesAdmissionCapacityOnce() + { + // Arrange + var admission = new TrackingConnectionAdmission( + maximumConnections: 1, + maximumConnectionsPerIp: 1); + admission.TryAcquire( + IPAddress.Loopback, + out var firstLease); + + // Act + firstLease.Dispose(); + firstLease.Dispose(); + var acceptedAgain = admission.TryAcquire( + IPAddress.Loopback, + out var secondLease); + + // Assert + acceptedAgain.Should().BeTrue(); + admission.CurrentConnections.Should().Be(1); + + secondLease.Dispose(); + admission.CurrentConnections.Should().Be(0); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Listeners/TrackingEndpointMaskerTests.cs b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingEndpointMaskerTests.cs new file mode 100644 index 000000000..84cacb72f --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingEndpointMaskerTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.Tracking.Tests.Listeners +{ + [TestFixture] + public class TrackingEndpointMaskerTests + { + [Test] + public void Mask_Ipv4Endpoint_ZerosHostOctet() + { + // Arrange + var endpoint = new IPEndPoint( + IPAddress.Parse("192.0.2.45"), + 41234); + + // Act + var masked = TrackingEndpointMasker.Mask(endpoint); + + // Assert + masked.Should().Be("192.0.2.0:41234"); + } + + [Test] + public void Mask_Ipv6Endpoint_ZerosHostHalf() + { + // Arrange + var endpoint = new IPEndPoint( + IPAddress.Parse("2001:db8:1234:5678:90ab:cdef:1234:5678"), + 41234); + + // Act + var masked = TrackingEndpointMasker.Mask(endpoint); + + // Assert + masked.Should().Be("[2001:db8:1234:5678::]:41234"); + } + + [Test] + public void Mask_NonIpEndpoint_DoesNotRenderRawValue() + { + // Arrange + var endpoint = new DnsEndPoint( + "sensitive-device.example", + 41234); + + // Act + var masked = TrackingEndpointMasker.Mask(endpoint); + + // Assert + masked.Should().Be("unknown"); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Listeners/TrackingListenerSupervisorTests.cs b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingListenerSupervisorTests.cs new file mode 100644 index 000000000..ae16042d2 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingListenerSupervisorTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.Tracking.Tests.Listeners +{ + [TestFixture] + public class TrackingListenerSupervisorTests + { + [Test] + public async Task StartAndStopAsync_ListenersBindAndStop_UpdatesReadiness() + { + // Arrange + var plan = new TrackingListenerPlan(new[] + { + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + 5000), + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Udp, + 5000) + }); + var settings = new TrackingGatewaySettings( + trackingEnabled: true, + nativeGatewayEnabled: true, + credentialPepper: "test-pepper", + tcpIdleTimeoutSeconds: 300, + maxFrameBytes: 65536, + maxConnections: 5000, + maxConnectionsPerIp: 100, + gracefulShutdownSeconds: 5, + internalHealthPort: 8080, + protocols: Array.Empty()); + var factory = new TestTrackingListenerFactory(); + var readiness = new TrackingGatewayReadinessState(); + var supervisor = new TrackingListenerSupervisor( + plan, + factory, + settings, + readiness, + NullLogger.Instance); + + // Act + await supervisor.StartAsync(CancellationToken.None); + await factory.AllStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + // Assert + readiness.GetSnapshot().IsReady.Should().BeTrue(); + readiness.GetSnapshot().BoundListeners.Should().Be(2); + + // Act + await supervisor.StopAsync(CancellationToken.None); + + // Assert + readiness.GetSnapshot().IsReady.Should().BeFalse(); + readiness.GetSnapshot().BoundListeners.Should().Be(0); + factory.Listeners.Should().OnlyContain(listener => listener.StopCount == 1); + } + + private sealed class TestTrackingListenerFactory : + ITrackingListenerFactory + { + private readonly object _syncRoot = new object(); + + public List Listeners { get; } = + new List(); + public TaskCompletionSource AllStarted { get; } = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + public bool Supports(TrackingListenerDefinition definition) + { + return true; + } + + public ITrackingListener Create( + TrackingListenerDefinition definition) + { + var listener = new TestTrackingListener( + definition, + OnStarted); + lock (_syncRoot) + { + Listeners.Add(listener); + } + + return listener; + } + + private void OnStarted() + { + lock (_syncRoot) + { + if (Listeners.Count == 2 && + Listeners.TrueForAll(listener => listener.IsBound)) + AllStarted.TrySetResult(); + } + } + } + + private sealed class TestTrackingListener : ITrackingListener + { + private readonly Action _onStarted; + private readonly TaskCompletionSource _completion = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + public TestTrackingListener( + TrackingListenerDefinition definition, + Action onStarted) + { + Definition = definition; + _onStarted = onStarted; + } + + public TrackingListenerDefinition Definition { get; } + public bool IsBound { get; private set; } + public Task Completion => _completion.Task; + public int StopCount { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) + { + IsBound = true; + _onStarted(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + StopCount++; + IsBound = false; + _completion.TrySetResult(); + return Task.CompletedTask; + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Listeners/TrackingSocketListenerTests.cs b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingSocketListenerTests.cs new file mode 100644 index 000000000..54df3e26d --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Listeners/TrackingSocketListenerTests.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; +using Resgrid.TrackerGateway.Sessions; + +namespace Resgrid.Tracking.Tests.Listeners +{ + [TestFixture] + public class TrackingSocketListenerTests + { + [Test] + public async Task TcpListener_LoopbackFrame_DelegatesAndReturnsResponse() + { + // Arrange + var port = GetAvailableTcpPort(); + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new TestTransportSessionHandler + { + TcpHandler = async ( + definition, + stream, + remoteEndPoint, + cancellationToken) => + { + var request = new byte[4]; + await stream.ReadExactlyAsync( + request, + cancellationToken); + received.TrySetResult(request); + await stream.WriteAsync( + Encoding.ASCII.GetBytes("pong"), + cancellationToken); + } + }; + var settings = CreateSettings(); + var listener = CreateFactory(settings, handler).Create( + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + port)); + + // Act + await listener.StartAsync(CancellationToken.None); + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes("ping")); + var response = new byte[4]; + await client.GetStream().ReadExactlyAsync(response); + + // Assert + listener.IsBound.Should().BeTrue(); + (await received.Task.WaitAsync(TimeSpan.FromSeconds(2))) + .Should() + .Equal(Encoding.ASCII.GetBytes("ping")); + response.Should().Equal(Encoding.ASCII.GetBytes("pong")); + + await listener.StopAsync(CancellationToken.None); + listener.IsBound.Should().BeFalse(); + } + + [Test] + public async Task TcpListener_StopRequested_DrainsActiveSessionBeforeClosing() + { + // Arrange + var port = GetAvailableTcpPort(); + var sessionStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSession = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new TestTransportSessionHandler + { + TcpHandler = async ( + definition, + stream, + remoteEndPoint, + cancellationToken) => + { + var request = new byte[1]; + await stream.ReadExactlyAsync( + request, + cancellationToken); + sessionStarted.TrySetResult(); + await releaseSession.Task.WaitAsync(cancellationToken); + } + }; + var settings = CreateSettings(); + var admission = new TrackingConnectionAdmission(settings); + var listener = CreateFactory( + settings, + handler, + admission).Create( + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Tcp, + port)); + await listener.StartAsync(CancellationToken.None); + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + await client.GetStream().WriteAsync(new byte[] { 1 }); + await sessionStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + // Act + using var shutdownCancellation = + new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var stopTask = listener.StopAsync( + shutdownCancellation.Token); + + // Assert + stopTask.IsCompleted.Should().BeFalse(); + admission.CurrentConnections.Should().Be(1); + listener.IsBound.Should().BeFalse(); + + releaseSession.TrySetResult(); + await stopTask; + admission.CurrentConnections.Should().Be(0); + listener.IsBound.Should().BeFalse(); + } + + [Test] + public async Task UdpListener_LoopbackDatagram_DelegatesAndReturnsResponse() + { + // Arrange + var port = GetAvailableUdpPort(); + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new TestTransportSessionHandler + { + UdpHandler = ( + definition, + datagram, + remoteEndPoint, + cancellationToken) => + { + received.TrySetResult(datagram.ToArray()); + return Task.FromResult>( + Encoding.ASCII.GetBytes("pong")); + } + }; + var settings = CreateSettings(); + var listener = CreateFactory(settings, handler).Create( + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Udp, + port)); + + // Act + await listener.StartAsync(CancellationToken.None); + using var client = new UdpClient(AddressFamily.InterNetwork); + client.Connect(IPAddress.Loopback, port); + await client.SendAsync( + Encoding.ASCII.GetBytes("ping"), + 4); + using var responseCancellation = + new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var response = await client.ReceiveAsync( + responseCancellation.Token); + + // Assert + listener.IsBound.Should().BeTrue(); + (await received.Task.WaitAsync(TimeSpan.FromSeconds(2))) + .Should() + .Equal(Encoding.ASCII.GetBytes("ping")); + response.Buffer.Should().Equal( + Encoding.ASCII.GetBytes("pong")); + + await listener.StopAsync(CancellationToken.None); + listener.IsBound.Should().BeFalse(); + } + + [Test] + public async Task UdpListener_DatagramExceedsFrameLimit_DropsWithoutDelegating() + { + // Arrange + var port = GetAvailableUdpPort(); + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new TestTransportSessionHandler + { + UdpHandler = ( + definition, + datagram, + remoteEndPoint, + cancellationToken) => + { + received.TrySetResult(); + return Task.FromResult(ReadOnlyMemory.Empty); + } + }; + var settings = CreateSettings(maxFrameBytes: 4); + var listener = CreateFactory(settings, handler).Create( + new TrackingListenerDefinition( + "synthetic-v1", + TrackingSocketTransport.Udp, + port)); + await listener.StartAsync(CancellationToken.None); + using var client = new UdpClient(AddressFamily.InterNetwork); + client.Connect(IPAddress.Loopback, port); + + // Act + await client.SendAsync(new byte[5], 5); + var completed = await Task.WhenAny( + received.Task, + Task.Delay(TimeSpan.FromMilliseconds(200))); + + // Assert + completed.Should().NotBeSameAs(received.Task); + + await listener.StopAsync(CancellationToken.None); + } + + private static TrackingSocketListenerFactory CreateFactory( + TrackingGatewaySettings settings, + ITrackingTransportSessionHandler handler, + TrackingConnectionAdmission admission = null) + { + return new TrackingSocketListenerFactory( + settings, + admission ?? new TrackingConnectionAdmission(settings), + handler, + new TrackingGatewayMetrics(), + NullLoggerFactory.Instance); + } + + private static TrackingGatewaySettings CreateSettings( + int maxFrameBytes = 65536) + { + return new TrackingGatewaySettings( + trackingEnabled: true, + nativeGatewayEnabled: true, + credentialPepper: "test-pepper", + tcpIdleTimeoutSeconds: 300, + maxFrameBytes, + maxConnections: 10, + maxConnectionsPerIp: 5, + gracefulShutdownSeconds: 5, + internalHealthPort: 8080, + protocols: Array.Empty()); + } + + private static int GetAvailableTcpPort() + { + var listener = new TcpListener( + IPAddress.Loopback, + 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static int GetAvailableUdpPort() + { + using var client = new UdpClient( + new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)client.Client.LocalEndPoint).Port; + } + + private sealed class TestTransportSessionHandler : + ITrackingTransportSessionHandler + { + public Func< + TrackingListenerDefinition, + Stream, + EndPoint, + CancellationToken, + Task> TcpHandler { get; set; } = + (definition, stream, remoteEndPoint, cancellationToken) => + Task.CompletedTask; + + public Func< + TrackingListenerDefinition, + ReadOnlyMemory, + EndPoint, + CancellationToken, + Task>> UdpHandler { get; set; } = + (definition, datagram, remoteEndPoint, cancellationToken) => + Task.FromResult(ReadOnlyMemory.Empty); + + public Task HandleTcpAsync( + TrackingListenerDefinition definition, + Stream stream, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + return TcpHandler( + definition, + stream, + remoteEndPoint, + cancellationToken); + } + + public Task> HandleUdpAsync( + TrackingListenerDefinition definition, + ReadOnlyMemory datagram, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + return UdpHandler( + definition, + datagram, + remoteEndPoint, + cancellationToken); + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/Gt06ProtocolModuleTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/Gt06ProtocolModuleTests.cs new file mode 100644 index 000000000..ed6ade27b --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/Gt06ProtocolModuleTests.cs @@ -0,0 +1,621 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using UnitTrackingDevice = Resgrid.Model.UnitTrackingDevice; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class Gt06ProtocolModuleTests + { + [Test] + public void Module_TransportContract_AdvertisesTcpOnly() + { + // Arrange + var module = new Gt06ProtocolModule(); + + // Act + var transports = module.SupportedTransports; + + // Assert + module.ProtocolKey.Should().Be("gt06"); + transports.Should().BeEquivalentTo( + new[] { TrackingSocketTransport.Tcp }); + var createUdp = () => module.CreateSession( + CreateContext( + TrackingSocketTransport.Udp)); + createUdp.Should() + .Throw(); + } + + [Test] + public void Parse_FragmentedLogin_ReturnsImeiAndExactAcceptedResponse() + { + // Arrange + var session = CreateSession(); + var login = Fixture("login.hex"); + var fragment = new ReadOnlySequence( + login.AsMemory(0, 7)); + + // Act + var incomplete = session.Parse(ref fragment); + var input = new ReadOnlySequence(login); + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted)); + + // Assert + incomplete.Status.Should().Be( + ProtocolParseStatus.NeedMoreData); + result.Status.Should().Be( + ProtocolParseStatus.Login); + result.Message.ExternalIdentifier.Should() + .Be("864717003283581"); + response.ToArray().Should().Equal( + Convert.FromHexString( + "78780501000955940D0A")); + } + + [Test] + public void Parse_StandardLocationFixture_MapsCanonicalPositionAndAck() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + Fixture("location-standard.hex")); + + // Act + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 1)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + var position = + result.Message.Positions.Single(); + position.TimestampUtc.Should().Be( + new DateTime( + 2015, + 12, + 29, + 2, + 51, + 5, + DateTimeKind.Utc)); + position.Latitude.Should().BeApproximately( + 23.1116933333333m, + 0.000000000001m); + position.Longitude.Should().BeApproximately( + 114.409297777778m, + 0.000000000001m); + position.IsValidFix.Should().BeTrue(); + position.EventId.Should() + .StartWith("gt06:"); + response.ToArray().Should().Equal( + Convert.FromHexString( + "787805220008A8420D0A")); + } + + [Test] + public void Parse_JmVl03A0Fixture_MapsGpsAndIgnition() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + Fixture( + "location-jm-vl03-a0.hex")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + var position = + result.Message.Positions.Single(); + position.TimestampUtc.Should().Be( + new DateTime( + 2023, + 7, + 4, + 17, + 34, + 38, + DateTimeKind.Utc)); + position.Latitude.Should().BeApproximately( + -12.9613488888889m, + 0.000000000001m); + position.Longitude.Should().BeApproximately( + -38.4829066666667m, + 0.000000000001m); + position.Ignition.Should().BeTrue(); + } + + [Test] + public void Parse_Heartbeat_BuildsResponseOnlyAfterAcceptance() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + Fixture("heartbeat.hex")); + var result = session.Parse(ref input); + + // Act + var accepted = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted)); + var unavailable = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Unavailable)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Heartbeat); + accepted.ToArray().Should().Equal( + Convert.FromHexString( + "7878051301295D630D0A")); + unavailable.IsEmpty.Should().BeTrue(); + } + + [Test] + public void Parse_ExtendedHeaderLocation_MapsAndAcknowledgesExtendedFrame() + { + // Arrange + var session = AuthenticatedSession(); + var shortFrame = + Fixture("location-standard.hex"); + var extendedFrame = ToExtended(shortFrame); + var input = new ReadOnlySequence( + extendedFrame); + + // Act + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 1)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + response.ToArray().Should().Equal( + Convert.FromHexString( + "797900052200089BEB0D0A")); + } + + [Test] + public void Parse_InvalidCrc_ReturnsMalformed() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture( + "location-standard.hex"); + frame[12] ^= 0x01; + var input = new ReadOnlySequence(frame); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "crc-invalid"); + } + + [Test] + public void Parse_PositionBeforeLogin_ClosesSession() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture("location-standard.hex")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.CloseSession); + result.ReasonCode.Should().Be( + "login-required"); + } + + [Test] + public void Parse_UnrecognizedCloneMessage_ReturnsUnsupported() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + BuildShortFrame( + 0x44, + Array.Empty(), + 0x0102)); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Unsupported); + result.ReasonCode.Should().Be( + "message-type-unsupported"); + } + + [TestCase(0x12)] + [TestCase(0x31)] + public void Parse_UncertifiedPositionVariant_ReturnsUnsupported( + byte messageType) + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + BuildShortFrame( + messageType, + Array.Empty(), + 0x0102)); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Unsupported); + result.ReasonCode.Should().Be( + "message-type-unsupported"); + } + + [Test] + public void Parse_CoalescedDuplicateFrames_ConsumesOneAndKeepsStableEventId() + { + // Arrange + var session = AuthenticatedSession(); + var frame = + Fixture("location-standard.hex"); + var input = new ReadOnlySequence( + frame.Concat(frame).ToArray()); + + // Act + var first = session.Parse(ref input); + var consumed = ConsumedLength(input, first); + input = input.Slice(first.Consumed); + var second = session.Parse(ref input); + + // Assert + consumed.Should().Be(frame.Length); + first.Message.Positions.Single().EventId + .Should() + .Be(second.Message.Positions + .Single() + .EventId); + } + + [Test] + public void EnrichPositions_Vl103Alarm_UsesModelSpecificMapping() + { + // Arrange + var session = AuthenticatedSession(); + var frame = StatusLocationFrame( + alarm: 0x09); + var input = new ReadOnlySequence(frame); + var result = session.Parse(ref input); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "jimi-vl103m", + ProtocolKey = "gt06" + }); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Single() + .AlarmCode.Should() + .Be("tow"); + } + + [Test] + public void EnrichPositions_BoundedVariant_DecodesLevelBatteryAndSignal() + { + // Arrange + var session = AuthenticatedSession(); + var frame = StatusLocationFrame( + alarm: 0x00, + battery: 6, + signal: 4); + var input = new ReadOnlySequence(frame); + var result = session.Parse(ref input); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "jimi-vl103m", + ProtocolKey = "gt06" + }); + + // Assert + var position = + result.Message.Positions.Single(); + position.BatteryPercent.Should().Be(100m); + position.SignalPercent.Should().Be(100); + } + + [Test] + public void EnrichPositions_BoundedVariant_DiscardsInvalidLevelValues() + { + // Arrange + var session = AuthenticatedSession(); + var frame = StatusLocationFrame( + alarm: 0x00, + battery: 7, + signal: 5); + var input = new ReadOnlySequence(frame); + var result = session.Parse(ref input); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "jimi-vl103m", + ProtocolKey = "gt06" + }); + + // Assert + var position = + result.Message.Positions.Single(); + position.BatteryPercent.Should().BeNull(); + position.SignalPercent.Should().BeNull(); + } + + [Test] + public void EnrichPositions_UnverifiedVariant_PreservesNativeLowPercentages() + { + // Arrange + var session = AuthenticatedSession(); + var frame = StatusLocationFrame( + alarm: 0x00, + battery: 6, + signal: 4); + var input = new ReadOnlySequence(frame); + var result = session.Parse(ref input); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "jimi-jm-vl01", + ProtocolKey = "gt06" + }); + + // Assert + var position = + result.Message.Positions.Single(); + position.BatteryPercent.Should().Be(6m); + position.SignalPercent.Should().Be(4); + } + + [Test] + public void EnrichPositions_UnregisteredModel_LeavesBatteryAndSignalUnset() + { + // Arrange + var session = AuthenticatedSession(); + var frame = StatusLocationFrame( + alarm: 0x00, + battery: 6, + signal: 4); + var input = new ReadOnlySequence(frame); + var result = session.Parse(ref input); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "unknown-model", + ProtocolKey = "gt06" + }); + + // Assert + var position = + result.Message.Positions.Single(); + position.BatteryPercent.Should().BeNull(); + position.SignalPercent.Should().BeNull(); + } + + private static ITrackingProtocolSession + AuthenticatedSession() + { + var session = CreateSession(); + var loginInput = + new ReadOnlySequence( + Fixture("login.hex")); + var login = session.Parse(ref loginInput); + session.BuildResponse( + login.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted)); + return session; + } + + private static ITrackingProtocolSession + CreateSession() + { + return new Gt06ProtocolModule() + .CreateSession( + CreateContext( + TrackingSocketTransport.Tcp)); + } + + private static TrackingSessionContext CreateContext( + TrackingSocketTransport transport) + { + return new TrackingSessionContext + { + SessionId = "gt06-test", + Transport = transport, + ConnectedOnUtc = DateTime.UtcNow, + MaxFrameBytes = 65536 + }; + } + + private static TrackingAcceptance Acceptance( + TrackingAcceptanceStatus status, + int acceptedPositions = 0) + { + return new TrackingAcceptance + { + Status = status, + AcceptedPositions = acceptedPositions + }; + } + + private static byte[] Fixture(string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Gt06", + fileName); + return Convert.FromHexString( + File.ReadAllText(path).Trim()); + } + + private static byte[] ToExtended( + byte[] shortFrame) + { + var declaredLength = shortFrame[2]; + var extended = new byte[ + shortFrame.Length + 1]; + BinaryPrimitives.WriteUInt16BigEndian( + extended, + 0x7979); + BinaryPrimitives.WriteUInt16BigEndian( + extended.AsSpan(2, 2), + declaredLength); + shortFrame.AsSpan( + 3, + shortFrame.Length - 3) + .CopyTo(extended.AsSpan(4)); + RewriteCrc(extended); + return extended; + } + + private static byte[] StatusLocationFrame( + byte alarm, + byte battery = 6, + byte signal = 4) + { + var standard = + Fixture("location-standard.hex"); + var gps = standard.AsSpan(4, 18) + .ToArray(); + var payload = new byte[30]; + gps.CopyTo(payload, 0); + payload[26] = 0x02; + payload[27] = battery; + payload[28] = signal; + payload[29] = alarm; + return BuildShortFrame( + 0x16, + payload, + 0x0008); + } + + private static byte[] BuildShortFrame( + byte type, + byte[] payload, + ushort serial) + { + var declaredLength = + 1 + payload.Length + 2 + 2; + var frame = new byte[ + declaredLength + 5]; + BinaryPrimitives.WriteUInt16BigEndian( + frame, + 0x7878); + frame[2] = checked((byte)declaredLength); + frame[3] = type; + payload.CopyTo(frame, 4); + BinaryPrimitives.WriteUInt16BigEndian( + frame.AsSpan( + frame.Length - 6, + 2), + serial); + BinaryPrimitives.WriteUInt16BigEndian( + frame.AsSpan( + frame.Length - 2, + 2), + 0x0D0A); + RewriteCrc(frame); + return frame; + } + + private static void RewriteCrc(byte[] frame) + { + var crcOffset = frame.Length - 4; + BinaryPrimitives.WriteUInt16BigEndian( + frame.AsSpan(crcOffset, 2), + CrcX25( + frame.AsSpan( + 2, + crcOffset - 2))); + } + + private static ushort CrcX25( + ReadOnlySpan data) + { + ushort crc = 0xFFFF; + foreach (var value in data) + { + crc ^= value; + for (var bit = 0; + bit < 8; + bit++) + { + crc = (crc & 1) != 0 + ? (ushort)((crc >> 1) ^ 0x8408) + : (ushort)(crc >> 1); + } + } + + return (ushort)~crc; + } + + private static long ConsumedLength( + ReadOnlySequence input, + ProtocolParseResult result) + { + return input.Slice( + 0, + result.Consumed) + .Length; + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/QueclinkProtocolModuleTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/QueclinkProtocolModuleTests.cs new file mode 100644 index 000000000..30daafb99 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/QueclinkProtocolModuleTests.cs @@ -0,0 +1,290 @@ +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Queclink; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class QueclinkProtocolModuleTests + { + [Test] + public void Module_TransportContract_AdvertisesTcpOnly() + { + // Arrange + var module = new QueclinkProtocolModule(); + + // Act + var transports = module.SupportedTransports; + + // Assert + module.ProtocolKey.Should().Be( + "queclink-attrack"); + transports.Should().BeEquivalentTo( + new[] { TrackingSocketTransport.Tcp }); + var createUdp = () => module.CreateSession( + CreateContext( + TrackingSocketTransport.Udp)); + createUdp.Should() + .Throw(); + } + + [Test] + public void Parse_GtFriFixture_MapsCanonicalPosition() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture("gtfri-live.txt")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.ExternalIdentifier.Should() + .Be("868487004353181"); + result.Message.RequiresResponse.Should() + .BeFalse(); + var position = + result.Message.Positions.Single(); + position.TimestampUtc.Should().Be( + new DateTime( + 2021, + 6, + 8, + 6, + 43, + 28, + DateTimeKind.Utc)); + position.Longitude.Should().Be( + 114.015515m); + position.Latitude.Should().Be( + 22.537178m); + position.AltitudeMeters.Should() + .Be(264.1m); + position.ExternalPowerVolts.Should() + .Be(14.051m); + position.BatteryPercent.Should().Be(100m); + position.EventId.Should() + .StartWith("queclink:"); + } + + [Test] + public void Parse_BufferedGteriFixture_MapsBufferedPosition() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture("gteri-buffered.txt")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.ExternalIdentifier.Should() + .Be("862364030261132"); + var position = + result.Message.Positions.Single(); + position.TimestampUtc.Should().Be( + new DateTime( + 2024, + 8, + 17, + 13, + 11, + 55, + DateTimeKind.Utc)); + position.Longitude.Should().Be( + 46.723488m); + position.Latitude.Should().Be( + 24.590880m); + position.SpeedMetersPerSecond.Should() + .BeApproximately( + 43.4m / 3.6m, + 0.000001m); + } + + [Test] + public void Parse_IgnitionFixture_MapsIgnition() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture("gtign-live.txt")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Single() + .Ignition.Should() + .BeTrue(); + } + + [Test] + public void Parse_Heartbeat_BuildsResponseOnlyAfterAcceptance() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture("heartbeat.txt")); + var result = session.Parse(ref input); + + // Act + var accepted = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted)); + var rejected = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Rejected)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Heartbeat); + result.Message.ExternalIdentifier.Should() + .Be("135790246811220"); + Encoding.ASCII.GetString(accepted.Span) + .Should() + .Be("+SACK:GTHBD,1A0401,11F0$"); + rejected.IsEmpty.Should().BeTrue(); + } + + [Test] + public void Parse_FragmentedAndCoalescedFrames_ConsumesOneFrameAtATime() + { + // Arrange + var session = CreateSession(); + var frame = Fixture("gtfri-live.txt"); + var fragment = new ReadOnlySequence( + frame.AsMemory(0, frame.Length - 1)); + var coalesced = new ReadOnlySequence( + frame.Concat(frame).ToArray()); + + // Act + var incomplete = session.Parse(ref fragment); + var first = session.Parse(ref coalesced); + var consumed = ConsumedLength( + coalesced, + first); + coalesced = coalesced.Slice( + first.Consumed); + var second = session.Parse(ref coalesced); + + // Assert + incomplete.Status.Should().Be( + ProtocolParseStatus.NeedMoreData); + consumed.Should().Be(frame.Length); + first.Status.Should().Be( + ProtocolParseStatus.Positions); + second.Status.Should().Be( + ProtocolParseStatus.Positions); + first.Message.Positions.Single().EventId + .Should() + .Be(second.Message.Positions + .Single() + .EventId); + } + + [Test] + public void Parse_UnknownReportType_ReturnsUnsupported() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Encoding.ASCII.GetBytes( + "+RESP:GTXYZ,DF0200,868487004353181,20210608064328,0001$")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Unsupported); + result.ReasonCode.Should().Be( + "report-type-unsupported"); + } + + [Test] + public void Parse_NonDigitImei_ReturnsMalformed() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Encoding.ASCII.GetBytes( + "+ACK:GTHBD,1A0401,13579024681122A,,20100214093254,11F0$")); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "header-invalid"); + } + + private static ITrackingProtocolSession + CreateSession() + { + return new QueclinkProtocolModule() + .CreateSession( + CreateContext( + TrackingSocketTransport.Tcp)); + } + + private static TrackingSessionContext CreateContext( + TrackingSocketTransport transport) + { + return new TrackingSessionContext + { + SessionId = "queclink-test", + Transport = transport, + ConnectedOnUtc = DateTime.UtcNow, + MaxFrameBytes = 65536 + }; + } + + private static TrackingAcceptance Acceptance( + TrackingAcceptanceStatus status) + { + return new TrackingAcceptance + { + Status = status + }; + } + + private static byte[] Fixture(string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Queclink", + fileName); + return Encoding.ASCII.GetBytes( + File.ReadAllText(path).Trim()); + } + + private static long ConsumedLength( + ReadOnlySequence input, + ProtocolParseResult result) + { + return input.Slice( + 0, + result.Consumed) + .Length; + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8ProtocolModuleTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8ProtocolModuleTests.cs new file mode 100644 index 000000000..d95b78849 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8ProtocolModuleTests.cs @@ -0,0 +1,691 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Teltonika; +using UnitTrackingDevice = Resgrid.Model.UnitTrackingDevice; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class TeltonikaCodec8ProtocolModuleTests + { + private const string Imei = "356307042441013"; + + [Test] + public void Module_TransportContract_AdvertisesTcpAndUdp() + { + // Arrange + var module = + new TeltonikaCodec8ProtocolModule(); + + // Act + var transports = module.SupportedTransports; + + // Assert + module.ProtocolKey.Should().Be( + "teltonika-codec8"); + transports.Should().BeEquivalentTo( + new[] + { + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp + }); + module.CreateSession( + CreateContext( + TrackingSocketTransport.Udp)) + .Should() + .BeOfType< + TeltonikaCodec8UdpProtocolSession>(); + var createUnknown = () => module.CreateSession( + CreateContext( + TrackingSocketTransport.Unknown)); + createUnknown.Should() + .Throw(); + } + + [Test] + public void Parse_FragmentedLogin_WaitsThenReturnsImei() + { + // Arrange + var session = CreateSession(); + var login = LoginBytes(); + var fragment = new ReadOnlySequence( + login.AsMemory(0, 8)); + + // Act + var incomplete = session.Parse( + ref fragment); + var completeInput = + new ReadOnlySequence(login); + var complete = session.Parse( + ref completeInput); + + // Assert + incomplete.Status.Should().Be( + ProtocolParseStatus.NeedMoreData); + complete.Status.Should().Be( + ProtocolParseStatus.Login); + complete.Message.ExternalIdentifier + .Should() + .Be(Imei); + complete.Message.RequiresResponse + .Should() + .BeTrue(); + ConsumedLength( + completeInput, + complete) + .Should() + .Be(login.Length); + } + + [TestCase( + TrackingAcceptanceStatus.Accepted, + (byte)0x01)] + [TestCase( + TrackingAcceptanceStatus.Rejected, + (byte)0x00)] + public void BuildResponse_LoginDecision_UsesOneBinaryByte( + TrackingAcceptanceStatus status, + byte expected) + { + // Arrange + var session = CreateSession(); + var login = ParseLogin(session); + + // Act + var response = session.BuildResponse( + login.Message, + Acceptance(status)); + + // Assert + response.ToArray() + .Should() + .Equal(new[] { expected }); + } + + [Test] + public void Parse_Codec8GoldenFrame_MapsCanonicalPosition() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + var input = new ReadOnlySequence( + frame); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Should() + .ContainSingle(); + var position = + result.Message.Positions.Single(); + position.TimestampUtc.Should().Be( + new DateTime( + 2024, + 1, + 2, + 3, + 4, + 5, + DateTimeKind.Utc)); + position.Longitude.Should().Be( + -122.4194m); + position.Latitude.Should().Be( + 37.7749m); + position.AltitudeMeters.Should().Be(15m); + position.HeadingDegrees.Should().Be(90m); + position.Satellites.Should().Be(8); + position.SpeedMetersPerSecond + .Should() + .Be(10m); + position.IsValidFix.Should().BeTrue(); + position.EventId.Should() + .StartWith("teltonika:"); + position.EventId.Should().HaveLength(74); + } + + [Test] + public void Parse_NegativeAltitude_PreservesBelowSeaLevelValue() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + BinaryPrimitives.WriteInt16BigEndian( + frame.AsSpan(27, 2), + -12); + RewriteCrc(frame); + var input = new ReadOnlySequence( + frame); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Single() + .AltitudeMeters.Should() + .Be(-12m); + } + + [Test] + public void Parse_Codec8ExtendedGoldenFrame_MapsCanonicalPosition() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture( + "codec8e-location.hex"); + var input = Segmented( + frame, + 13, + 31); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + var position = + result.Message.Positions.Single(); + position.Longitude.Should().Be(13.405m); + position.Latitude.Should().Be(52.52m); + position.AltitudeMeters.Should().Be(34m); + position.HeadingDegrees.Should().Be(270m); + position.Satellites.Should().Be(11); + position.SpeedMetersPerSecond + .Should() + .Be(20m); + position.IsValidFix.Should().BeTrue(); + } + + [Test] + public void Parse_NonEmptyCodec8AndExtendedIoStructures_AcceptsRecords() + { + // Arrange + var codec8Session = AuthenticatedSession(); + var codec8Input = + new ReadOnlySequence( + FrameWithIo( + "codec8-location.hex", + "01040101FF0102123401031234567801040102030405060708")); + var extendedSession = AuthenticatedSession(); + var extendedInput = + new ReadOnlySequence( + FrameWithIo( + "codec8e-location.hex", + "0001000500010001FF0001000212340001000312345678000100040102030405060708000100050003AABBCC")); + + // Act + var codec8 = codec8Session.Parse( + ref codec8Input); + var extended = extendedSession.Parse( + ref extendedInput); + + // Assert + codec8.Status.Should().Be( + ProtocolParseStatus.Positions); + codec8.Message.Positions.Should() + .ContainSingle(); + extended.Status.Should().Be( + ProtocolParseStatus.Positions); + extended.Message.Positions.Should() + .ContainSingle(); + } + + [Test] + public void EnrichPositions_WaveOneProfile_MapsOnlyAllowlistedIoValues() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + Fixture("codec8-io-location.hex")); + var result = session.Parse(ref input); + var position = + result.Message.Positions.Single(); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = + "teltonika-fmc920", + ProtocolKey = + "teltonika-codec8" + }); + + // Assert + position.Hdop.Should().Be(1.2m); + position.ExternalPowerVolts.Should() + .Be(12.5m); + position.Ignition.Should().BeTrue(); + position.IsMoving.Should().BeTrue(); + position.BatteryPercent.Should().BeNull(); + position.SignalPercent.Should().BeNull(); + } + + [Test] + public void EnrichPositions_UnregisteredModel_DoesNotApplyFamilyIoMap() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + Fixture("codec8-io-location.hex")); + var result = session.Parse(ref input); + var position = + result.Message.Positions.Single(); + + // Act + ((ITrackingProtocolPositionEnricher)session) + .EnrichPositions( + result.Message, + new UnitTrackingDevice + { + ModelKey = "unknown-model", + ProtocolKey = + "teltonika-codec8" + }); + + // Assert + position.ExternalPowerVolts.Should() + .BeNull(); + position.Ignition.Should().BeNull(); + position.IsMoving.Should().BeNull(); + } + + [Test] + public void Parse_MultiRecordPacket_ReturnsEveryRecordAndCountAcknowledgement() + { + // Arrange + var session = AuthenticatedSession(); + var input = new ReadOnlySequence( + MultiRecordFrame( + "codec8-location.hex")); + + // Act + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 2)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Should() + .HaveCount(2); + response.ToArray().Should().Equal( + new byte[] { 0, 0, 0, 2 }); + } + + [Test] + public void Parse_FragmentedAndCoalescedFrames_ConsumesOneCompleteFrameAtATime() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + var fragment = new ReadOnlySequence( + frame.AsMemory(0, frame.Length - 1)); + var coalesced = new ReadOnlySequence( + frame.Concat(frame).ToArray()); + + // Act + var incomplete = session.Parse( + ref fragment); + var first = session.Parse( + ref coalesced); + var firstConsumed = + ConsumedLength(coalesced, first); + coalesced = coalesced.Slice( + first.Consumed); + var second = session.Parse( + ref coalesced); + + // Assert + incomplete.Status.Should().Be( + ProtocolParseStatus.NeedMoreData); + firstConsumed + .Should() + .Be(frame.Length); + first.Status.Should().Be( + ProtocolParseStatus.Positions); + second.Status.Should().Be( + ProtocolParseStatus.Positions); + first.Message.Positions.Single().EventId + .Should() + .Be(second.Message.Positions + .Single() + .EventId); + } + + [Test] + public void Parse_InvalidCrc_ReturnsMalformed() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + frame[20] ^= 0x01; + var input = new ReadOnlySequence( + frame); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "crc-invalid"); + } + + [Test] + public void Parse_MismatchedRecordCounts_ReturnsMalformed() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + frame[8 + dataLength - 1] = 2; + RewriteCrc(frame); + var input = new ReadOnlySequence( + frame); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "record-count-mismatch"); + } + + [Test] + public void BuildResponse_PartialOrUnavailablePositionAcceptance_ReturnsZero() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + var input = new ReadOnlySequence( + frame); + var positions = session.Parse( + ref input); + + // Act + var partial = session.BuildResponse( + positions.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 0)); + var unavailable = session.BuildResponse( + positions.Message, + Acceptance( + TrackingAcceptanceStatus.Unavailable)); + + // Assert + partial.ToArray().Should().Equal( + new byte[] { 0, 0, 0, 0 }); + unavailable.ToArray().Should().Equal( + new byte[] { 0, 0, 0, 0 }); + } + + [Test] + public void BuildResponse_FullPositionAcceptance_ReturnsFourByteRecordCount() + { + // Arrange + var session = AuthenticatedSession(); + var frame = Fixture("codec8-location.hex"); + var input = new ReadOnlySequence( + frame); + var positions = session.Parse( + ref input); + + // Act + var response = session.BuildResponse( + positions.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 1)); + + // Assert + response.ToArray().Should().Equal( + new byte[] { 0, 0, 0, 1 }); + } + + private static ITrackingProtocolSession + AuthenticatedSession() + { + var session = CreateSession(); + var login = ParseLogin(session); + session.BuildResponse( + login.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted)); + return session; + } + + private static ProtocolParseResult ParseLogin( + ITrackingProtocolSession session) + { + var input = new ReadOnlySequence( + LoginBytes()); + return session.Parse(ref input); + } + + private static ITrackingProtocolSession + CreateSession() + { + return new TeltonikaCodec8ProtocolModule() + .CreateSession( + CreateContext( + TrackingSocketTransport.Tcp)); + } + + private static TrackingSessionContext CreateContext( + TrackingSocketTransport transport) + { + return new TrackingSessionContext + { + SessionId = "teltonika-test", + Transport = transport, + ConnectedOnUtc = DateTime.UtcNow, + MaxFrameBytes = 65536 + }; + } + + private static TrackingAcceptance Acceptance( + TrackingAcceptanceStatus status, + int acceptedPositions = 0) + { + return new TrackingAcceptance + { + Status = status, + AcceptedPositions = acceptedPositions + }; + } + + private static byte[] LoginBytes() + { + return Convert.FromHexString( + "000F333536333037303432343431303133"); + } + + private static byte[] Fixture( + string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Teltonika", + fileName); + return Convert.FromHexString( + File.ReadAllText(path).Trim()); + } + + private static long ConsumedLength( + ReadOnlySequence input, + ProtocolParseResult result) + { + return input.Slice( + 0, + result.Consumed) + .Length; + } + + private static ReadOnlySequence Segmented( + byte[] source, + params int[] splitOffsets) + { + var offsets = new[] { 0 } + .Concat(splitOffsets) + .Concat(new[] { source.Length }) + .ToArray(); + TestSequenceSegment first = null; + TestSequenceSegment last = null; + for (var index = 0; + index < offsets.Length - 1; + index++) + { + var segment = + new TestSequenceSegment( + source.AsMemory( + offsets[index], + offsets[index + 1] - + offsets[index])); + if (first == null) + first = segment; + if (last != null) + last.SetNext(segment); + last = segment; + } + + return new ReadOnlySequence( + first, + 0, + last, + last.Memory.Length); + } + + private static byte[] FrameWithIo( + string fixtureName, + string ioHex) + { + var frame = Fixture(fixtureName); + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + var originalData = frame.AsSpan( + 8, + dataLength); + var io = Convert.FromHexString(ioHex); + var data = new byte[ + 2 + + 24 + + io.Length + + 1]; + data[0] = originalData[0]; + data[1] = 1; + originalData.Slice(2, 24).CopyTo( + data.AsSpan(2)); + io.CopyTo(data, 26); + data[^1] = 1; + return BuildFrame(data); + } + + private static byte[] MultiRecordFrame( + string fixtureName) + { + var frame = Fixture(fixtureName); + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + var originalData = frame.AsSpan( + 8, + dataLength); + var record = originalData.Slice( + 2, + originalData.Length - 3); + var data = new byte[ + 2 + + (record.Length * 2) + + 1]; + data[0] = originalData[0]; + data[1] = 2; + record.CopyTo(data.AsSpan(2)); + record.CopyTo( + data.AsSpan(2 + record.Length)); + data[^1] = 2; + return BuildFrame(data); + } + + private static byte[] BuildFrame(byte[] data) + { + var frame = new byte[8 + data.Length + 4]; + BinaryPrimitives.WriteUInt32BigEndian( + frame.AsSpan(4, 4), + (uint)data.Length); + data.CopyTo(frame, 8); + RewriteCrc(frame); + return frame; + } + + private static void RewriteCrc(byte[] frame) + { + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + ushort crc = 0; + for (var offset = 8; + offset < 8 + dataLength; + offset++) + { + crc ^= frame[offset]; + for (var bit = 0; + bit < 8; + bit++) + { + crc = (crc & 1) != 0 + ? (ushort)((crc >> 1) ^ 0xA001) + : (ushort)(crc >> 1); + } + } + + BinaryPrimitives.WriteUInt32BigEndian( + frame.AsSpan( + 8 + dataLength, + 4), + crc); + } + + private sealed class TestSequenceSegment : + ReadOnlySequenceSegment + { + public TestSequenceSegment( + ReadOnlyMemory memory) + { + Memory = memory; + } + + public void SetNext( + TestSequenceSegment next) + { + next.RunningIndex = + RunningIndex + Memory.Length; + Next = next; + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8UdpProtocolSessionTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8UdpProtocolSessionTests.cs new file mode 100644 index 000000000..6a72b6e36 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/TeltonikaCodec8UdpProtocolSessionTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Buffers; +using System.IO; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Teltonika; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class TeltonikaCodec8UdpProtocolSessionTests + { + private const string Imei = "356307042441013"; + + [Test] + public void Parse_Codec8GoldenDatagram_MapsPositionAndBuildsAcceptedResponse() + { + // Arrange + var session = CreateSession(); + var datagram = Fixture( + "codec8-udp-location.hex"); + var input = new ReadOnlySequence( + datagram); + + // Act + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 1)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.ExternalIdentifier.Should() + .Be(Imei); + result.Message.Positions.Should() + .ContainSingle() + .Which.Longitude.Should() + .Be(-122.4194m); + response.ToArray().Should().Equal( + Convert.FromHexString( + "0005CAFE010501")); + ConsumedLength(input, result).Should() + .Be(datagram.Length); + } + + [Test] + public void Parse_Codec8ExtendedGoldenDatagram_PartialAcceptanceReturnsZero() + { + // Arrange + var session = CreateSession(); + var input = new ReadOnlySequence( + Fixture( + "codec8e-udp-location.hex")); + + // Act + var result = session.Parse(ref input); + var response = session.BuildResponse( + result.Message, + Acceptance( + TrackingAcceptanceStatus.Accepted, + acceptedPositions: 0)); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Positions); + result.Message.Positions.Should() + .ContainSingle() + .Which.Latitude.Should() + .Be(52.52m); + response.ToArray().Should().Equal( + Convert.FromHexString( + "0005BEEF010700")); + } + + [Test] + public void Parse_DeclaredLengthDoesNotMatchDatagram_ReturnsMalformed() + { + // Arrange + var session = CreateSession(); + var datagram = Fixture( + "codec8-udp-location.hex"); + datagram[1]--; + var input = new ReadOnlySequence( + datagram); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "datagram-length-mismatch"); + } + + [Test] + public void Parse_InvalidChannelMarker_ReturnsMalformed() + { + // Arrange + var session = CreateSession(); + var datagram = Fixture( + "codec8-udp-location.hex"); + datagram[4] = 0; + var input = new ReadOnlySequence( + datagram); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "udp-header-invalid"); + } + + [Test] + public void Parse_NonDigitImei_ReturnsMalformed() + { + // Arrange + var session = CreateSession(); + var datagram = Fixture( + "codec8-udp-location.hex"); + datagram[9] = (byte)'A'; + var input = new ReadOnlySequence( + datagram); + + // Act + var result = session.Parse(ref input); + + // Assert + result.Status.Should().Be( + ProtocolParseStatus.Malformed); + result.ReasonCode.Should().Be( + "imei-invalid"); + } + + private static ITrackingProtocolSession + CreateSession() + { + return new TeltonikaCodec8ProtocolModule() + .CreateSession( + new TrackingSessionContext + { + SessionId = + "teltonika-udp-test", + Transport = + TrackingSocketTransport.Udp, + ConnectedOnUtc = + DateTime.UtcNow, + MaxFrameBytes = 65536 + }); + } + + private static TrackingAcceptance Acceptance( + TrackingAcceptanceStatus status, + int acceptedPositions = 0) + { + return new TrackingAcceptance + { + Status = status, + AcceptedPositions = acceptedPositions + }; + } + + private static byte[] Fixture( + string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Teltonika", + fileName); + return Convert.FromHexString( + File.ReadAllText(path).Trim()); + } + + private static long ConsumedLength( + ReadOnlySequence input, + ProtocolParseResult result) + { + return input.Slice( + 0, + result.Consumed) + .Length; + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolCatalogValidatorTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolCatalogValidatorTests.cs new file mode 100644 index 000000000..9cbdd383e --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolCatalogValidatorTests.cs @@ -0,0 +1,56 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using Resgrid.Providers.Tracking.Protocols.Queclink; +using Resgrid.Providers.Tracking.Protocols.Teltonika; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class TrackingProtocolCatalogValidatorTests + { + [Test] + public void Validate_RegisteredNativeModulesSupportCatalogTransports_Completes() + { + // Arrange + var registry = + new TrackingProtocolModuleRegistry( + new ITrackingProtocolModule[] + { + new TeltonikaCodec8ProtocolModule(), + new QueclinkProtocolModule(), + new Gt06ProtocolModule() + }); + + // Act + Action act = () => + TrackingProtocolCatalogValidator.Validate( + registry); + + // Assert + act.Should().NotThrow(); + } + + [Test] + public void Validate_NativeCatalogModuleMissing_RejectsStartup() + { + // Arrange + var registry = + new TrackingProtocolModuleRegistry( + Array.Empty()); + + // Act + Action act = () => + TrackingProtocolCatalogValidator.Validate( + registry); + + // Assert + act.Should() + .Throw() + .WithMessage( + "*teltonika-codec8*"); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolContractTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolContractTests.cs new file mode 100644 index 000000000..bf011bbbe --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolContractTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using Autofac; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using Resgrid.Providers.Tracking.Protocols.Queclink; +using Resgrid.Providers.Tracking.Protocols.Teltonika; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class TrackingProtocolContractTests + { + [Test] + public void ContractVersion_CurrentContract_IsVersionOne() + { + TrackingProtocolContract.Version.Should().Be(1); + } + + [Test] + public void Registry_MatchingProtocolAndTransport_ResolvesModule() + { + // Arrange + var module = new TestProtocolModule("synthetic-v1", TrackingSocketTransport.Tcp); + var registry = new TrackingProtocolModuleRegistry(new[] { module }); + + // Act + var resolved = registry.Resolve(" SYNTHETIC-V1 ", TrackingSocketTransport.Tcp); + + // Assert + resolved.Should().BeSameAs(module); + } + + [Test] + public void Registry_UnsupportedTransport_DoesNotResolveModule() + { + // Arrange + var registry = new TrackingProtocolModuleRegistry(new[] + { + new TestProtocolModule("synthetic-v1", TrackingSocketTransport.Tcp) + }); + + // Act + var resolved = registry.TryResolve( + "synthetic-v1", + TrackingSocketTransport.Udp, + out var module); + + // Assert + resolved.Should().BeFalse(); + module.Should().BeNull(); + } + + [Test] + public void Registry_DuplicateProtocolKeys_RejectsAmbiguousRegistration() + { + // Arrange + var modules = new ITrackingProtocolModule[] + { + new TestProtocolModule("synthetic-v1", TrackingSocketTransport.Tcp), + new TestProtocolModule("SYNTHETIC-V1", TrackingSocketTransport.Udp) + }; + + // Act + Action act = () => new TrackingProtocolModuleRegistry(modules); + + // Assert + act.Should().Throw() + .WithMessage("*registered more than once*"); + } + + [Test] + public void Registry_ModuleWithoutTransport_RejectsRegistration() + { + // Arrange + var module = new TestProtocolModule("synthetic-v1"); + + // Act + Action act = () => new TrackingProtocolModuleRegistry(new[] { module }); + + // Assert + act.Should().Throw() + .WithMessage("*at least one transport*"); + } + + [Test] + public void Registry_ModuleWithUnknownTransport_RejectsRegistration() + { + // Arrange + var module = new TestProtocolModule( + "synthetic-v1", + TrackingSocketTransport.Unknown); + + // Act + Action act = () => new TrackingProtocolModuleRegistry(new[] { module }); + + // Assert + act.Should().Throw() + .WithMessage("*unsupported transport*"); + } + + [Test] + public void TrackingProviderModule_ResolvesRegisteredProtocolModules() + { + // Arrange + var builder = new ContainerBuilder(); + builder.RegisterModule(new TrackingProviderModule()); + + // Act + using var container = builder.Build(); + var registry = container.Resolve(); + + // Assert + registry.Modules.Should().HaveCount(3); + registry.Resolve( + TrackingProtocolKeys.Queclink, + TrackingSocketTransport.Tcp) + .Should() + .BeOfType(); + registry.Resolve( + TrackingProtocolKeys.Gt06, + TrackingSocketTransport.Tcp) + .Should() + .BeOfType(); + registry.Resolve( + TeltonikaCodec8ProtocolModule.Key, + TrackingSocketTransport.Tcp) + .Should() + .BeOfType(); + registry.Resolve( + TeltonikaCodec8ProtocolModule.Key, + TrackingSocketTransport.Udp) + .Should() + .BeOfType(); + } + + private sealed class TestProtocolModule : ITrackingProtocolModule + { + public TestProtocolModule( + string protocolKey, + params TrackingSocketTransport[] supportedTransports) + { + ProtocolKey = protocolKey; + SupportedTransports = new HashSet(supportedTransports); + } + + public string ProtocolKey { get; } + public IReadOnlySet SupportedTransports { get; } + + public ITrackingProtocolSession CreateSession(TrackingSessionContext context) + { + return new TestProtocolSession(); + } + } + + private sealed class TestProtocolSession : ITrackingProtocolSession + { + public ProtocolParseResult Parse(ref ReadOnlySequence input) + { + return new ProtocolParseResult + { + Status = ProtocolParseStatus.NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + return ReadOnlyMemory.Empty; + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolParserFuzzTests.cs b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolParserFuzzTests.cs new file mode 100644 index 000000000..fc048f3bc --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Protocols/TrackingProtocolParserFuzzTests.cs @@ -0,0 +1,244 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using Resgrid.Providers.Tracking.Protocols.Queclink; +using Resgrid.Providers.Tracking.Protocols.Teltonika; + +namespace Resgrid.Tracking.Tests.Protocols +{ + [TestFixture] + public class TrackingProtocolParserFuzzTests + { + private const string IterationsEnvironmentVariable = + "RESGRID_TRACKING_FUZZ_ITERATIONS"; + private const int DefaultIterations = 500; + private const int MaximumIterations = 50000; + private const int MaximumInputBytes = 4096; + private const int MaximumFrameBytes = 65536; + + [Test] + public void Parse_TeltonikaTcpBoundedRandomPackets_DoesNotEscapeContract() + { + Run( + iteration => CreateSession( + new TeltonikaCodec8ProtocolModule(), + TrackingSocketTransport.Tcp, + iteration), + seed: 72001, + ShapeTeltonikaTcp); + } + + [Test] + public void Parse_TeltonikaUdpBoundedRandomPackets_DoesNotEscapeContract() + { + Run( + iteration => CreateSession( + new TeltonikaCodec8ProtocolModule(), + TrackingSocketTransport.Udp, + iteration), + seed: 72002, + ShapeTeltonikaUdp); + } + + [Test] + public void Parse_QueclinkBoundedRandomPackets_DoesNotEscapeContract() + { + Run( + iteration => CreateSession( + new QueclinkProtocolModule(), + TrackingSocketTransport.Tcp, + iteration), + seed: 72003, + ShapeQueclink); + } + + [Test] + public void Parse_Gt06BoundedRandomPackets_DoesNotEscapeContract() + { + Run( + iteration => CreateSession( + new Gt06ProtocolModule(), + TrackingSocketTransport.Tcp, + iteration), + seed: 72004, + ShapeGt06); + } + + private static void Run( + Func sessionFactory, + int seed, + Action shape) + { + var random = new Random(seed); + var iterations = GetIterationCount(); + for (var iteration = 0; + iteration < iterations; + iteration++) + { + var payload = new byte[ + random.Next(1, MaximumInputBytes + 1)]; + random.NextBytes(payload); + if ((iteration & 1) == 1) + shape(payload, iteration); + + var input = + new ReadOnlySequence(payload); + ProtocolParseResult result; + try + { + result = sessionFactory(iteration) + .Parse(ref input); + } + catch (Exception ex) + { + Assert.Fail( + $"Parser escaped its contract at iteration {iteration}: {ex}"); + return; + } + + result.Should().NotBeNull( + $"iteration {iteration} must return a parse result"); + Enum.IsDefined(result.Status).Should().BeTrue( + $"iteration {iteration} must return a known status"); + AssertSequenceBounds( + input, + result, + iteration); + } + } + + private static ITrackingProtocolSession CreateSession( + ITrackingProtocolModule module, + TrackingSocketTransport transport, + int iteration) + { + return module.CreateSession( + new TrackingSessionContext + { + SessionId = $"fuzz-{module.ProtocolKey}-{iteration}", + Transport = transport, + ConnectedOnUtc = DateTime.UtcNow, + MaxFrameBytes = MaximumFrameBytes + }); + } + + private static void AssertSequenceBounds( + ReadOnlySequence input, + ProtocolParseResult result, + int iteration) + { + long consumed; + long examined; + try + { + consumed = input.Slice( + 0, + result.Consumed) + .Length; + examined = input.Slice( + 0, + result.Examined) + .Length; + } + catch (ArgumentOutOfRangeException ex) + { + Assert.Fail( + $"Parser returned an out-of-range sequence position at iteration {iteration}: {ex}"); + return; + } + + consumed.Should().BeGreaterThanOrEqualTo(0); + examined.Should().BeGreaterThanOrEqualTo(consumed); + examined.Should().BeLessThanOrEqualTo(input.Length); + } + + private static int GetIterationCount() + { + var configured = Environment.GetEnvironmentVariable( + IterationsEnvironmentVariable); + return int.TryParse(configured, out var iterations) && + iterations > 0 + ? Math.Min(iterations, MaximumIterations) + : DefaultIterations; + } + + private static void ShapeTeltonikaTcp( + byte[] payload, + int iteration) + { + if (payload.Length < 12) + return; + + payload.AsSpan(0, 4).Clear(); + BinaryPrimitives.WriteUInt32BigEndian( + payload.AsSpan(4, 4), + (uint)(payload.Length - 12)); + payload[8] = (iteration & 2) == 0 + ? (byte)0x08 + : (byte)0x8E; + } + + private static void ShapeTeltonikaUdp( + byte[] payload, + int iteration) + { + if (payload.Length < 24) + return; + + BinaryPrimitives.WriteUInt16BigEndian( + payload, + checked((ushort)(payload.Length - 2))); + payload[4] = 0x01; + payload[5] = (byte)(iteration % 256); + BinaryPrimitives.WriteUInt16BigEndian( + payload.AsSpan(6, 2), + 15); + for (var index = 0; index < 15; index++) + payload[8 + index] = (byte)('0' + ((iteration + index) % 10)); + } + + private static void ShapeQueclink( + byte[] payload, + int iteration) + { + if (payload.Length < 8) + return; + + for (var index = 0; + index < payload.Length; + index++) + { + payload[index] = + (byte)(' ' + (payload[index] % 95)); + } + + "+RESP:"u8.CopyTo(payload); + payload[^1] = (byte)'$'; + if ((iteration & 2) == 0) + payload[6] = (byte)'G'; + } + + private static void ShapeGt06( + byte[] payload, + int iteration) + { + if (payload.Length < 10) + return; + + payload[0] = 0x78; + payload[1] = 0x78; + payload[2] = checked((byte)Math.Min( + byte.MaxValue, + payload.Length - 5)); + payload[3] = (iteration & 2) == 0 + ? (byte)0x22 + : (byte)0x16; + payload[^2] = 0x0D; + payload[^1] = 0x0A; + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Resgrid.Tracking.Tests.csproj b/Tests/Resgrid.Tracking.Tests/Resgrid.Tracking.Tests.csproj new file mode 100644 index 000000000..55548f020 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Resgrid.Tracking.Tests.csproj @@ -0,0 +1,28 @@ + + + net9.0 + Debug;Release;Docker + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/Tests/Resgrid.Tracking.Tests/Sessions/TrackingSessionGenerationRegistryTests.cs b/Tests/Resgrid.Tracking.Tests/Sessions/TrackingSessionGenerationRegistryTests.cs new file mode 100644 index 000000000..c141eac11 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Sessions/TrackingSessionGenerationRegistryTests.cs @@ -0,0 +1,61 @@ +using System.Threading; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.TrackerGateway.Sessions; + +namespace Resgrid.Tracking.Tests.Sessions +{ + [TestFixture] + public class TrackingSessionGenerationRegistryTests + { + [Test] + public void Activate_NewerSession_CancelsAndSupersedesOlderGeneration() + { + // Arrange + var registry = new TrackingSessionGenerationRegistry(); + using var firstCancellation = new CancellationTokenSource(); + using var secondCancellation = new CancellationTokenSource(); + using var first = registry.Activate( + "device-1", + firstCancellation); + + // Act + using var second = registry.Activate( + "device-1", + secondCancellation); + + // Assert + firstCancellation.IsCancellationRequested.Should().BeTrue(); + secondCancellation.IsCancellationRequested.Should().BeFalse(); + first.IsCurrent.Should().BeFalse(); + second.IsCurrent.Should().BeTrue(); + second.Generation.Should().BeGreaterThan(first.Generation); + registry.ActiveCount.Should().Be(1); + } + + [Test] + public void Dispose_StaleLease_DoesNotRemoveCurrentGeneration() + { + // Arrange + var registry = new TrackingSessionGenerationRegistry(); + using var firstCancellation = new CancellationTokenSource(); + using var secondCancellation = new CancellationTokenSource(); + var first = registry.Activate( + "device-1", + firstCancellation); + var second = registry.Activate( + "device-1", + secondCancellation); + + // Act + first.Dispose(); + + // Assert + second.IsCurrent.Should().BeTrue(); + registry.ActiveCount.Should().Be(1); + + second.Dispose(); + registry.ActiveCount.Should().Be(0); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Sessions/TrackingTransportSessionHandlerTests.cs b/Tests/Resgrid.Tracking.Tests/Sessions/TrackingTransportSessionHandlerTests.cs new file mode 100644 index 000000000..1dbfb44c4 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Sessions/TrackingTransportSessionHandlerTests.cs @@ -0,0 +1,1798 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.Providers.Tracking.Protocols.Gt06; +using Resgrid.Providers.Tracking.Protocols.Queclink; +using Resgrid.Providers.Tracking.Protocols.Teltonika; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; +using Resgrid.TrackerGateway.Sessions; +using Resgrid.Tracking.Tests.Tools.Gt06; +using Resgrid.Tracking.Tests.Tools.Queclink; +using Resgrid.Tracking.Tests.Tools.Teltonika; + +namespace Resgrid.Tracking.Tests.Sessions +{ + [TestFixture] + public class TrackingTransportSessionHandlerTests + { + private const string ProtocolKey = "synthetic-v1"; + private const string TeltonikaImei = + "356307042441013"; + private const string QueclinkImei = + "868487004353181"; + private const string Gt06Imei = + "864717003283581"; + + [Test] + public async Task TcpSession_FragmentedFrames_WaitsForIngressBeforeAcknowledgement() + { + // Arrange + var ingressStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIngress = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var host = CreateHost(); + host.Ingress.OnAccept = async ( + source, + positions, + cancellationToken) => + { + ingressStarted.TrySetResult(); + return await releaseIngress.Task.WaitAsync( + cancellationToken); + }; + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + using var reader = CreateReader( + client.GetStream()); + + // Act + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes("L|dev")); + await Task.Delay(50); + + // Assert + host.Authentication.ProtocolLookupCount + .Should() + .Be(0); + + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes("ice-1\n")); + (await ReadLineAsync(reader)) + .Should() + .Be("ACK|Login|0"); + + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes( + "H|device-1\n")); + (await ReadLineAsync(reader)) + .Should() + .Be("ACK|Heartbeat|0"); + host.Ingress.HeartbeatCount + .Should() + .Be(1); + + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes( + "P|device-1|event-1\n")); + await ingressStarted.Task.WaitAsync( + TimeSpan.FromSeconds(2)); + var positionAck = reader + .ReadLineAsync() + .WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Delay(50); + positionAck.IsCompleted.Should().BeFalse(); + + releaseIngress.TrySetResult( + AcceptedIngress(1)); + (await positionAck) + .Should() + .Be("ACK|Positions|1"); + host.Ingress.LastSource + .ReportedDeviceIdentifier + .Should() + .Be("device-1"); + host.Authentication.ProtocolLookupCount + .Should() + .Be(3); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaTcpSession_LoginAndPosition_ReturnsProtocolAcknowledgements() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + var stream = client.GetStream(); + var imei = Encoding.ASCII.GetBytes( + TeltonikaImei); + var login = new byte[imei.Length + 2]; + BinaryPrimitives.WriteUInt16BigEndian( + login, + (ushort)imei.Length); + imei.CopyTo(login, 2); + + // Act + await stream.WriteAsync(login); + var loginResponse = await ReadBytesAsync( + stream, + 1); + await stream.WriteAsync( + TeltonikaFixture( + "codec8-io-location.hex")); + var positionResponse = + await ReadBytesAsync( + stream, + 4); + + // Assert + loginResponse.Should().Equal( + new byte[] { 0x01 }); + BinaryPrimitives.ReadUInt32BigEndian( + positionResponse) + .Should() + .Be(1); + host.Authentication.ProtocolLookupCount + .Should() + .Be(2); + host.Ingress.AcceptCount.Should().Be(1); + host.Ingress.LastSource + .ReportedDeviceIdentifier + .Should() + .Be(TeltonikaImei); + var position = host.Ingress.LastPositions + .Should() + .ContainSingle() + .Which; + position.EventId.Should() + .StartWith("teltonika:"); + position.Hdop.Should().Be(1.2m); + position.ExternalPowerVolts.Should() + .Be(12.5m); + position.Ignition.Should().BeTrue(); + position.IsMoving.Should().BeTrue(); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaTcpSimulator_FragmentedBufferedBatchAndDuplicate_ReturnsStableAcknowledgements() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 2048, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await TeltonikaTcpSimulator + .ConnectAndLoginAsync( + IPAddress.Loopback, + port, + TeltonikaImei); + var currentTimestamp = + new DateTime( + 2026, + 7, + 26, + 16, + 30, + 0, + DateTimeKind.Utc); + var bufferedTimestamp = + currentTimestamp.AddHours(-4); + var batch = + TeltonikaFixtureBuilder + .BuildTimestampedBatch( + TeltonikaFixture( + "codec8-io-location.hex"), + currentTimestamp, + bufferedTimestamp); + + // Act + var firstAcknowledgement = + await simulator.SendFrameAsync( + batch, + fragmentSize: 3); + var firstPositions = + host.Ingress.LastPositions + .ToArray(); + var duplicateAcknowledgement = + await simulator.SendFrameAsync(batch); + var duplicatePositions = + host.Ingress.LastPositions + .ToArray(); + + // Assert + firstAcknowledgement.Should().Be(2); + duplicateAcknowledgement.Should().Be(2); + firstPositions.Select( + position => position.TimestampUtc) + .Should() + .Equal( + currentTimestamp, + bufferedTimestamp); + duplicatePositions.Select( + position => position.EventId) + .Should() + .Equal( + firstPositions.Select( + position => position.EventId)); + host.Ingress.AcceptCount.Should().Be(2); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaTcpSimulator_Acknowledgement_WaitsForIngressConfirmation() + { + // Arrange + var ingressStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIngress = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + host.Ingress.OnAccept = async ( + source, + positions, + cancellationToken) => + { + ingressStarted.TrySetResult(); + return await releaseIngress.Task.WaitAsync( + cancellationToken); + }; + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await TeltonikaTcpSimulator + .ConnectAndLoginAsync( + IPAddress.Loopback, + port, + TeltonikaImei); + + // Act + var acknowledgement = + simulator.SendFrameAsync( + TeltonikaFixture( + "codec8-io-location.hex")); + await ingressStarted.Task.WaitAsync( + TimeSpan.FromSeconds(2)); + + // Assert + acknowledgement.IsCompleted.Should() + .BeFalse(); + releaseIngress.TrySetResult( + AcceptedIngress(1)); + (await acknowledgement).Should().Be(1); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaTcpSimulator_CorruptCrc_ClosesWithoutAcknowledgement() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await TeltonikaTcpSimulator + .ConnectAndLoginAsync( + IPAddress.Loopback, + port, + TeltonikaImei); + var corrupted = + TeltonikaFixtureBuilder.CorruptCrc( + TeltonikaFixture( + "codec8-io-location.hex")); + + // Act + Func act = async () => + await simulator.SendFrameAsync( + corrupted); + + // Assert + await act.Should() + .ThrowAsync(); + host.Ingress.AcceptCount.Should().Be(0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaTcpSimulator_DisconnectMidFrame_DoesNotReachIngress() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await TeltonikaTcpSimulator + .ConnectAndLoginAsync( + IPAddress.Loopback, + port, + TeltonikaImei); + var frame = TeltonikaFixture( + "codec8-io-location.hex"); + + // Act + await simulator.DisconnectMidFrameAsync( + frame, + frame.Length / 2); + await WaitUntilAsync( + () => host.GenerationRegistry + .ActiveCount == 0); + + // Assert + host.Ingress.AcceptCount.Should().Be(0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task QueclinkTcpSimulator_FragmentedDuplicateAndHeartbeat_ReachesIngressAndAcknowledgesHeartbeat() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 2048, + module: new QueclinkProtocolModule(), + deviceIdentifier: QueclinkImei, + modelKey: "queclink-gv57mg"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await QueclinkTcpSimulator + .ConnectAsync( + IPAddress.Loopback, + port); + var frame = QueclinkFixture( + "gtfri-live.txt"); + + // Act + await simulator.SendFrameAsync( + frame, + fragmentSize: 7); + await WaitUntilAsync( + () => host.Ingress.AcceptCount == 1); + var firstEventId = + host.Ingress.LastPositions + .Single() + .EventId; + await simulator.SendFrameAsync(frame); + await WaitUntilAsync( + () => host.Ingress.AcceptCount == 2); + var duplicateEventId = + host.Ingress.LastPositions + .Single() + .EventId; + var heartbeat = Encoding.ASCII.GetBytes( + Encoding.ASCII.GetString( + QueclinkFixture( + "heartbeat.txt")) + .Replace( + "135790246811220", + QueclinkImei, + StringComparison.Ordinal)); + await simulator.SendFrameAsync(heartbeat); + var heartbeatResponse = + await simulator.ReadResponseAsync(); + + // Assert + duplicateEventId.Should().Be(firstEventId); + Encoding.ASCII.GetString( + heartbeatResponse) + .Should() + .Be("+SACK:GTHBD,1A0401,11F0$"); + host.Ingress.HeartbeatCount.Should().Be(1); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task Gt06TcpSimulator_LoginAndLocation_AcknowledgementWaitsForIngress() + { + // Arrange + var ingressStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIngress = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var host = CreateHost( + maxFrameBytes: 1024, + module: new Gt06ProtocolModule(), + deviceIdentifier: Gt06Imei, + modelKey: "jimi-jm-vl03"); + host.Ingress.OnAccept = async ( + source, + positions, + cancellationToken) => + { + ingressStarted.TrySetResult(); + return await releaseIngress.Task.WaitAsync( + cancellationToken); + }; + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + await using var simulator = + await Gt06TcpSimulator + .ConnectAsync( + IPAddress.Loopback, + port); + var loginResponse = + await simulator.SendFrameAsync( + Gt06Fixture("login.hex"), + fragmentSize: 3); + + // Act + var locationResponse = + simulator.SendFrameAsync( + Gt06Fixture( + "location-jm-vl03-a0.hex"), + fragmentSize: 5); + await ingressStarted.Task.WaitAsync( + TimeSpan.FromSeconds(2)); + + // Assert + loginResponse.Should().Equal( + Convert.FromHexString( + "78780501000955940D0A")); + locationResponse.IsCompleted.Should() + .BeFalse(); + releaseIngress.TrySetResult( + AcceptedIngress(1)); + (await locationResponse).Should().Equal( + Convert.FromHexString( + "787805A00146A3B40D0A")); + host.Ingress.LastPositions.Single() + .Ignition.Should() + .BeTrue(); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaUdpSimulator_BufferedBatchAndDuplicate_ReturnsMatchingAcknowledgements() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 2048, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableUdpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Udp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var simulator = + new TeltonikaUdpSimulator( + IPAddress.Loopback, + port); + var currentTimestamp = + new DateTime( + 2026, + 7, + 26, + 17, + 0, + 0, + DateTimeKind.Utc); + var bufferedTimestamp = + currentTimestamp.AddHours(-6); + var batch = + TeltonikaFixtureBuilder + .BuildTimestampedBatch( + TeltonikaFixture( + "codec8-io-location.hex"), + currentTimestamp, + bufferedTimestamp); + var datagram = + TeltonikaFixtureBuilder.WrapUdp( + batch, + TeltonikaImei, + 0xCAFE, + 0x05); + + // Act + var firstAcknowledgement = + await simulator.SendDatagramAsync( + datagram); + var firstPositions = + host.Ingress.LastPositions + .ToArray(); + var duplicateAcknowledgement = + await simulator.SendDatagramAsync( + datagram); + var duplicatePositions = + host.Ingress.LastPositions + .ToArray(); + + // Assert + firstAcknowledgement.ChannelPacketId + .Should() + .Be(0xCAFE); + firstAcknowledgement.AvlPacketId + .Should() + .Be(0x05); + firstAcknowledgement.AcceptedRecords + .Should() + .Be(2); + duplicateAcknowledgement.AcceptedRecords + .Should() + .Be(2); + firstPositions.Select( + position => position.TimestampUtc) + .Should() + .Equal( + currentTimestamp, + bufferedTimestamp); + duplicatePositions.Select( + position => position.EventId) + .Should() + .Equal( + firstPositions.Select( + position => position.EventId)); + host.Ingress.AcceptCount.Should().Be(2); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaUdpSimulator_Acknowledgement_WaitsForIngressConfirmation() + { + // Arrange + var ingressStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIngress = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + host.Ingress.OnAccept = async ( + source, + positions, + cancellationToken) => + { + ingressStarted.TrySetResult(); + return await releaseIngress.Task.WaitAsync( + cancellationToken); + }; + var port = GetAvailableUdpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Udp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var simulator = + new TeltonikaUdpSimulator( + IPAddress.Loopback, + port); + var datagram = + TeltonikaFixtureBuilder.WrapUdp( + TeltonikaFixture( + "codec8-io-location.hex"), + TeltonikaImei, + 0xBEEF, + 0x07); + + // Act + var acknowledgement = + simulator.SendDatagramAsync( + datagram); + await ingressStarted.Task.WaitAsync( + TimeSpan.FromSeconds(2)); + + // Assert + acknowledgement.IsCompleted.Should() + .BeFalse(); + releaseIngress.TrySetResult( + AcceptedIngress(1)); + (await acknowledgement) + .AcceptedRecords.Should() + .Be(1); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaUdpSimulator_InvalidRecordCount_ReturnsNoAcknowledgement() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei, + modelKey: "teltonika-fmc920"); + var port = GetAvailableUdpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Udp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var simulator = + new TeltonikaUdpSimulator( + IPAddress.Loopback, + port); + var datagram = + TeltonikaFixtureBuilder.WrapUdp( + TeltonikaFixture( + "codec8-io-location.hex"), + TeltonikaImei, + 0xCAFE, + 0x05); + datagram[^1] = 2; + using var responseCancellation = + new CancellationTokenSource( + TimeSpan.FromMilliseconds(250)); + + // Act + await simulator.WriteDatagramAsync( + datagram); + Func act = async () => + await simulator + .ReadAcknowledgementAsync( + responseCancellation.Token); + + // Assert + await act.Should() + .ThrowAsync(); + host.Ingress.AcceptCount.Should().Be(0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TeltonikaUdpSession_Position_ReturnsChannelAcknowledgement() + { + // Arrange + using var host = CreateHost( + maxFrameBytes: 1024, + module: new TeltonikaCodec8ProtocolModule(), + deviceIdentifier: TeltonikaImei); + var port = GetAvailableUdpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Udp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new UdpClient( + AddressFamily.InterNetwork); + client.Connect( + IPAddress.Loopback, + port); + + // Act + await client.SendAsync( + TeltonikaFixture( + "codec8-udp-location.hex")); + using var responseCancellation = + new CancellationTokenSource( + TimeSpan.FromSeconds(2)); + var response = await client.ReceiveAsync( + responseCancellation.Token); + + // Assert + response.Buffer.Should().Equal( + Convert.FromHexString( + "0005CAFE010501")); + host.Authentication.ProtocolLookupCount + .Should() + .Be(1); + host.Ingress.AcceptCount.Should().Be(1); + host.Ingress.LastSource + .ReportedDeviceIdentifier + .Should() + .Be(TeltonikaImei); + host.Ingress.LastPositions.Should() + .ContainSingle() + .Which.EventId.Should() + .StartWith("teltonika:"); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TcpSession_IngressUnavailable_DoesNotSendSuccessAndCloses() + { + // Arrange + using var host = CreateHost(); + host.Ingress.OnAccept = ( + source, + positions, + cancellationToken) => + Task.FromResult( + new TrackingIngressResult + { + Status = + TrackingIngressStatus.Unavailable + }); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + using var reader = CreateReader( + client.GetStream()); + await LoginAsync(client, reader); + + // Act + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes( + "P|device-1|event-1\n")); + + // Assert + (await ReadLineAsync(reader)) + .Should() + .Be( + "NACK|Unavailable|ingress-unavailable"); + (await WaitForCloseAsync(reader)) + .Should() + .BeTrue(); + host.Module.Acceptances + .Should() + .Contain(acceptance => + acceptance.Status == + TrackingAcceptanceStatus.Unavailable); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TcpSession_SourceOutsideAllowedCidrs_RejectsBeforeIngress() + { + // Arrange + using var host = CreateHost( + allowedSourceCidrs: "10.0.0.0/8"); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + using var reader = CreateReader( + client.GetStream()); + + // Act + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes( + "L|device-1\n")); + + // Assert + (await ReadLineAsync(reader)) + .Should() + .Be( + "NACK|Rejected|source-not-allowed"); + host.Ingress.AcceptCount.Should().Be(0); + (await WaitForCloseAsync(reader)) + .Should() + .BeTrue(); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TcpSession_FrameReachesLimitWithoutBoundary_ClosesWithoutAuthentication() + { + // Arrange + using var host = CreateHost(maxFrameBytes: 16); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + using var reader = CreateReader( + client.GetStream()); + + // Act + await client.GetStream().WriteAsync( + new byte[16]); + + // Assert + (await WaitForCloseAsync(reader)) + .Should() + .BeTrue(); + host.Authentication.ProtocolLookupCount + .Should() + .Be(0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TcpSession_NoDataBeforeIdleTimeout_ClosesSession() + { + // Arrange + using var host = CreateHost( + tcpIdleTimeoutSeconds: 1); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync( + IPAddress.Loopback, + port); + using var reader = CreateReader( + client.GetStream()); + + // Act + var closed = await WaitForCloseAsync( + reader, + TimeSpan.FromSeconds(3)); + + // Assert + closed.Should().BeTrue(); + host.GenerationRegistry.ActiveCount + .Should() + .Be(0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task TcpSession_NewerAuthenticatedReconnect_ClosesStaleSession() + { + // Arrange + using var host = CreateHost(); + var port = GetAvailableTcpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Tcp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var firstClient = new TcpClient(); + await firstClient.ConnectAsync( + IPAddress.Loopback, + port); + using var firstReader = CreateReader( + firstClient.GetStream()); + await LoginAsync( + firstClient, + firstReader); + host.GenerationRegistry.ActiveCount + .Should() + .Be(1); + + // Act + using var secondClient = new TcpClient(); + await secondClient.ConnectAsync( + IPAddress.Loopback, + port); + using var secondReader = CreateReader( + secondClient.GetStream()); + await LoginAsync( + secondClient, + secondReader); + + // Assert + (await WaitForCloseAsync(firstReader)) + .Should() + .BeTrue(); + host.GenerationRegistry.ActiveCount + .Should() + .Be(1); + + secondClient.Dispose(); + await WaitUntilAsync( + () => host.GenerationRegistry.ActiveCount == 0); + } + finally + { + await StopAsync(listener); + } + } + + [Test] + public async Task UdpSession_Position_WaitsForIngressBeforeAcknowledgement() + { + // Arrange + var ingressStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIngress = + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var host = CreateHost(); + host.Ingress.OnAccept = async ( + source, + positions, + cancellationToken) => + { + ingressStarted.TrySetResult(); + return await releaseIngress.Task.WaitAsync( + cancellationToken); + }; + var port = GetAvailableUdpPort(); + var listener = host.CreateListener( + TrackingSocketTransport.Udp, + port); + await listener.StartAsync(CancellationToken.None); + + try + { + using var client = new UdpClient( + AddressFamily.InterNetwork); + client.Connect( + IPAddress.Loopback, + port); + await client.SendAsync( + Encoding.ASCII.GetBytes( + "L|device-1\n")); + using (var loginCancellation = + new CancellationTokenSource( + TimeSpan.FromSeconds(2))) + { + var loginResponse = await client.ReceiveAsync( + loginCancellation.Token); + Encoding.ASCII + .GetString(loginResponse.Buffer) + .Should() + .Be("ACK|Login|0\n"); + } + + await client.SendAsync( + Encoding.ASCII.GetBytes( + "P|device-1|event-1\n")); + using var responseCancellation = + new CancellationTokenSource( + TimeSpan.FromSeconds(2)); + var responseTask = client + .ReceiveAsync( + responseCancellation.Token) + .AsTask(); + + // Act + await ingressStarted.Task.WaitAsync( + TimeSpan.FromSeconds(2)); + await Task.Delay(50); + + // Assert + responseTask.IsCompleted.Should().BeFalse(); + + releaseIngress.TrySetResult( + AcceptedIngress(1)); + var response = await responseTask; + Encoding.ASCII + .GetString(response.Buffer) + .Should() + .Be("ACK|Positions|1\n"); + host.GenerationRegistry.ActiveCount + .Should() + .Be(0); + var metrics = + TrackingGatewayMetricsWriter.Write( + new TrackingGatewayReadinessSnapshot( + expectedListeners: 1, + boundListeners: 1, + isReady: true, + hasFailure: false), + host.Metrics); + metrics.Should().Contain( + "resgrid_tracking_ingress_messages_total{transport=\"udp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 2"); + metrics.Should().Contain( + "resgrid_tracking_positions_total{transport=\"udp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_queue_publish_duration_seconds_count{transport=\"udp\"} 1"); + metrics.Should().Contain( + "resgrid_tracking_frame_bytes_count{protocol=\"synthetic-v1\"} 2"); + } + finally + { + await StopAsync(listener); + } + } + + private static TestHost CreateHost( + int tcpIdleTimeoutSeconds = 5, + int maxFrameBytes = 256, + string allowedSourceCidrs = null, + ITrackingProtocolModule module = null, + string deviceIdentifier = "device-1", + string modelKey = null) + { + return new TestHost( + tcpIdleTimeoutSeconds, + maxFrameBytes, + allowedSourceCidrs, + module, + deviceIdentifier, + modelKey); + } + + private static async Task LoginAsync( + TcpClient client, + StreamReader reader) + { + await client.GetStream().WriteAsync( + Encoding.ASCII.GetBytes( + "L|device-1\n")); + (await ReadLineAsync(reader)) + .Should() + .Be("ACK|Login|0"); + } + + private static async Task ReadLineAsync( + StreamReader reader) + { + return await reader + .ReadLineAsync() + .WaitAsync(TimeSpan.FromSeconds(2)); + } + + private static async Task ReadBytesAsync( + Stream stream, + int count) + { + var buffer = new byte[count]; + var offset = 0; + using var cancellation = + new CancellationTokenSource( + TimeSpan.FromSeconds(2)); + while (offset < buffer.Length) + { + var read = await stream.ReadAsync( + buffer.AsMemory( + offset, + buffer.Length - offset), + cancellation.Token); + if (read == 0) + throw new EndOfStreamException(); + offset += read; + } + + return buffer; + } + + private static byte[] TeltonikaFixture( + string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Teltonika", + fileName); + return Convert.FromHexString( + System.IO.File.ReadAllText(path).Trim()); + } + + private static byte[] QueclinkFixture( + string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Queclink", + fileName); + return Encoding.ASCII.GetBytes( + System.IO.File.ReadAllText(path).Trim()); + } + + private static byte[] Gt06Fixture( + string fileName) + { + var path = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "Data", + "Gt06", + fileName); + return Convert.FromHexString( + System.IO.File.ReadAllText(path).Trim()); + } + + private static async Task WaitForCloseAsync( + StreamReader reader, + TimeSpan? timeout = null) + { + try + { + var line = await reader + .ReadLineAsync() + .WaitAsync( + timeout ?? + TimeSpan.FromSeconds(2)); + return line == null; + } + catch (IOException) + { + return true; + } + catch (SocketException) + { + return true; + } + } + + private static async Task WaitUntilAsync( + Func condition) + { + var timeout = DateTime.UtcNow.AddSeconds(2); + while (!condition() && + DateTime.UtcNow < timeout) + await Task.Delay(20); + + condition().Should().BeTrue(); + } + + private static StreamReader CreateReader( + Stream stream) + { + return new StreamReader( + stream, + Encoding.ASCII, + detectEncodingFromByteOrderMarks: false, + bufferSize: 256, + leaveOpen: true); + } + + private static TrackingIngressResult AcceptedIngress( + int accepted) + { + return new TrackingIngressResult + { + Status = TrackingIngressStatus.Accepted, + Accepted = accepted, + ReceivedOn = DateTime.UtcNow + }; + } + + private static async Task StopAsync( + ITrackingListener listener) + { + using var cancellation = + new CancellationTokenSource( + TimeSpan.FromSeconds(2)); + await listener.StopAsync( + cancellation.Token); + } + + private static int GetAvailableTcpPort() + { + var listener = new TcpListener( + IPAddress.Loopback, + 0); + listener.Start(); + try + { + return ((IPEndPoint) + listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static int GetAvailableUdpPort() + { + using var client = new UdpClient( + new IPEndPoint( + IPAddress.Loopback, + 0)); + return ((IPEndPoint) + client.Client.LocalEndPoint).Port; + } + + private sealed class TestHost : IDisposable + { + private readonly IContainer _container; + private readonly TrackingGatewaySettings _settings; + private readonly TrackingSocketListenerFactory _factory; + private readonly string _protocolKey; + + public TestHost( + int tcpIdleTimeoutSeconds, + int maxFrameBytes, + string allowedSourceCidrs, + ITrackingProtocolModule module, + string deviceIdentifier, + string modelKey) + { + var selectedModule = + module ?? + new SyntheticProtocolModule(); + _protocolKey = + selectedModule.ProtocolKey; + var device = new UnitTrackingDevice + { + UnitTrackingDeviceId = "tracking-device-1", + ModelKey = modelKey, + ProtocolKey = _protocolKey, + DeviceIdentifier = deviceIdentifier, + IsEnabled = true, + AllowedSourceCidrs = allowedSourceCidrs + }; + Authentication = + new TestAuthenticationService(device); + Ingress = new TestIngressService(); + Module = + selectedModule as + SyntheticProtocolModule; + GenerationRegistry = + new TrackingSessionGenerationRegistry(); + Metrics = new TrackingGatewayMetrics(); + _settings = new TrackingGatewaySettings( + trackingEnabled: true, + nativeGatewayEnabled: true, + credentialPepper: "test-pepper", + tcpIdleTimeoutSeconds, + maxFrameBytes, + maxConnections: 20, + maxConnectionsPerIp: 10, + gracefulShutdownSeconds: 2, + internalHealthPort: 8080, + protocols: + Array.Empty< + TrackingProtocolListenerSettings>()); + + var builder = new ContainerBuilder(); + builder.RegisterInstance(Authentication) + .As(); + builder.RegisterInstance(Ingress) + .As(); + _container = builder.Build(); + + var moduleRegistry = + new TrackingProtocolModuleRegistry( + new[] { selectedModule }); + var handler = + new TrackingTransportSessionHandler( + _settings, + moduleRegistry, + _container, + GenerationRegistry, + Metrics, + NullLogger< + TrackingTransportSessionHandler> + .Instance); + _factory = new TrackingSocketListenerFactory( + _settings, + new TrackingConnectionAdmission( + _settings), + handler, + Metrics, + NullLoggerFactory.Instance); + } + + public TestAuthenticationService Authentication + { get; } + public TestIngressService Ingress { get; } + public SyntheticProtocolModule Module { get; } + public TrackingSessionGenerationRegistry + GenerationRegistry { get; } + public TrackingGatewayMetrics Metrics { get; } + + public ITrackingListener CreateListener( + TrackingSocketTransport transport, + int port) + { + return _factory.Create( + new TrackingListenerDefinition( + _protocolKey, + transport, + port)); + } + + public void Dispose() + { + _container.Dispose(); + } + } + + private sealed class TestAuthenticationService : + IUnitTrackingAuthenticationService + { + private readonly UnitTrackingDevice _device; + private int _protocolLookupCount; + + public TestAuthenticationService( + UnitTrackingDevice device) + { + _device = device; + } + + public int ProtocolLookupCount => + Volatile.Read(ref _protocolLookupCount); + + public UnitTrackingGeneratedCredential + GenerateCredential() + { + throw new NotSupportedException(); + } + + public string ComputeSecretHash(string token) + { + throw new NotSupportedException(); + } + + public bool VerifySecret( + string token, + string storedHash) + { + throw new NotSupportedException(); + } + + public Task + AuthenticateAsync( + string token, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task + GetEnabledDeviceByEndpointIdAsync( + string deviceId, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task + GetEnabledDeviceByProtocolIdentifierAsync( + string protocolKey, + string deviceIdentifier, + CancellationToken cancellationToken = default) + { + cancellationToken + .ThrowIfCancellationRequested(); + Interlocked.Increment( + ref _protocolLookupCount); + var matches = + string.Equals( + protocolKey, + _device.ProtocolKey, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + deviceIdentifier, + _device.DeviceIdentifier, + StringComparison.Ordinal); + return Task.FromResult( + matches ? _device : null); + } + + public Task> + GetActiveCredentialsForDeviceAsync( + string deviceId, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult< + IReadOnlyCollection< + UnitTrackingCredential>>( + Array.Empty< + UnitTrackingCredential>()); + } + + public Task InvalidateCredentialAsync( + string secretHash) + { + return Task.CompletedTask; + } + + public Task InvalidateDeviceAsync( + UnitTrackingDevice device) + { + return Task.CompletedTask; + } + } + + private sealed class TestIngressService : + IUnitTrackingIngressService + { + private int _acceptCount; + private int _heartbeatCount; + + public Func< + AuthenticatedTrackingSource, + IReadOnlyCollection< + CanonicalTrackingPosition>, + CancellationToken, + Task> OnAccept + { get; set; } = + (source, positions, cancellationToken) => + Task.FromResult( + AcceptedIngress( + positions.Count)); + + public int AcceptCount => + Volatile.Read(ref _acceptCount); + public int HeartbeatCount => + Volatile.Read(ref _heartbeatCount); + public AuthenticatedTrackingSource LastSource + { get; private set; } + public IReadOnlyCollection< + CanonicalTrackingPosition> + LastPositions { get; private set; } + + public Task AcceptAsync( + AuthenticatedTrackingSource source, + IReadOnlyCollection< + CanonicalTrackingPosition> positions, + CancellationToken cancellationToken = default) + { + Interlocked.Increment( + ref _acceptCount); + LastSource = source; + LastPositions = positions; + return OnAccept( + source, + positions, + cancellationToken); + } + + public Task + AcceptHeartbeatAsync( + AuthenticatedTrackingSource source, + DateTime receivedOnUtc, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment( + ref _heartbeatCount); + LastSource = source; + return Task.FromResult( + new TrackingIngressResult + { + Status = + TrackingIngressStatus.Accepted, + ReceivedOn = receivedOnUtc + }); + } + } + + private sealed class SyntheticProtocolModule : + ITrackingProtocolModule + { + public string ProtocolKey => + TrackingTransportSessionHandlerTests + .ProtocolKey; + public IReadOnlySet + SupportedTransports { get; } = + new HashSet + { + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp + }; + public ConcurrentQueue + Acceptances { get; } = + new ConcurrentQueue< + TrackingAcceptance>(); + + public ITrackingProtocolSession CreateSession( + TrackingSessionContext context) + { + return new SyntheticProtocolSession( + Acceptances); + } + } + + private sealed class SyntheticProtocolSession : + ITrackingProtocolSession + { + private readonly ConcurrentQueue< + TrackingAcceptance> _acceptances; + + public SyntheticProtocolSession( + ConcurrentQueue< + TrackingAcceptance> acceptances) + { + _acceptances = acceptances; + } + + public ProtocolParseResult Parse( + ref ReadOnlySequence input) + { + var reader = + new SequenceReader(input); + if (!reader.TryReadTo( + out ReadOnlySequence line, + (byte)'\n', + advancePastDelimiter: true)) + { + return new ProtocolParseResult + { + Status = + ProtocolParseStatus + .NeedMoreData, + Consumed = input.Start, + Examined = input.End + }; + } + + var text = Encoding.ASCII.GetString( + line.ToArray()); + var parts = text.Split('|'); + var result = new ProtocolParseResult + { + Consumed = reader.Position, + Examined = reader.Position + }; + + if (parts.Length == 2 && + parts[0] == "L") + { + result.Status = + ProtocolParseStatus.Login; + result.Message = Message( + ProtocolMessageType.Login, + parts[1], + null); + return result; + } + + if (parts.Length == 2 && + parts[0] == "H") + { + result.Status = + ProtocolParseStatus.Heartbeat; + result.Message = Message( + ProtocolMessageType.Heartbeat, + parts[1], + null); + return result; + } + + if (parts.Length == 3 && + parts[0] == "P") + { + result.Status = + ProtocolParseStatus.Positions; + result.Message = Message( + ProtocolMessageType.Positions, + parts[1], + parts[2]); + return result; + } + + result.Status = + ProtocolParseStatus.Malformed; + return result; + } + + public ReadOnlyMemory BuildResponse( + ProtocolMessage message, + TrackingAcceptance acceptance) + { + _acceptances.Enqueue( + new TrackingAcceptance + { + Status = acceptance.Status, + AcceptedPositions = + acceptance.AcceptedPositions, + ReasonCode = + acceptance.ReasonCode + }); + var response = + acceptance.Status == + TrackingAcceptanceStatus.Accepted + ? $"ACK|{message.MessageType}|{acceptance.AcceptedPositions}\n" + : $"NACK|{acceptance.Status}|{acceptance.ReasonCode}\n"; + return Encoding.ASCII.GetBytes( + response); + } + + private static ProtocolMessage Message( + ProtocolMessageType messageType, + string identifier, + string eventId) + { + var positions = + messageType == + ProtocolMessageType.Positions + ? new[] + { + new CanonicalTrackingPosition + { + EventId = eventId, + TimestampUtc = + DateTime.UtcNow, + ReceivedOnUtc = + DateTime.UtcNow, + Latitude = 47.61m, + Longitude = -122.33m, + IsValidFix = true + } + } + : Array.Empty< + CanonicalTrackingPosition>(); + return new ProtocolMessage + { + MessageType = messageType, + ExternalIdentifier = identifier, + Positions = positions, + AcknowledgementToken = + eventId == null + ? ReadOnlyMemory.Empty + : Encoding.ASCII.GetBytes( + eventId), + RequiresResponse = true + }; + } + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Sessions/UnitTrackingSourceNetworkPolicyTests.cs b/Tests/Resgrid.Tracking.Tests/Sessions/UnitTrackingSourceNetworkPolicyTests.cs new file mode 100644 index 000000000..161da6455 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Sessions/UnitTrackingSourceNetworkPolicyTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Tracking; + +namespace Resgrid.Tracking.Tests.Sessions +{ + [TestFixture] + public class UnitTrackingSourceNetworkPolicyTests + { + [TestCase("10.20.30.40", "10.0.0.0/8", true)] + [TestCase("192.168.1.10", "10.0.0.0/8", false)] + [TestCase("2001:db8::10", "2001:db8::/32", true)] + public void IsAllowed_AddressAndCanonicalRanges_ReturnsExpected( + string address, + string ranges, + bool expected) + { + // Act + var allowed = UnitTrackingSourceNetworkPolicy.IsAllowed( + IPAddress.Parse(address), + ranges); + + // Assert + allowed.Should().Be(expected); + } + + [Test] + public void IsAllowed_MappedIpv4Address_MatchesIpv4Range() + { + // Arrange + var mappedAddress = IPAddress.Parse( + "::ffff:10.20.30.40"); + + // Act + var allowed = UnitTrackingSourceNetworkPolicy.IsAllowed( + mappedAddress, + "10.0.0.0/8"); + + // Assert + allowed.Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Tools/Gt06/Gt06TcpSimulator.cs b/Tests/Resgrid.Tracking.Tests/Tools/Gt06/Gt06TcpSimulator.cs new file mode 100644 index 000000000..4e04ce71e --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Tools/Gt06/Gt06TcpSimulator.cs @@ -0,0 +1,199 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Tracking.Tests.Tools.Gt06 +{ + internal sealed class Gt06TcpSimulator : + IAsyncDisposable + { + private static readonly TimeSpan OperationTimeout = + TimeSpan.FromSeconds(2); + + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private bool _disposed; + + private Gt06TcpSimulator(TcpClient client) + { + _client = client; + _stream = client.GetStream(); + } + + public static async Task + ConnectAsync( + IPAddress address, + int port, + CancellationToken cancellationToken = default) + { + if (address == null) + throw new ArgumentNullException(nameof(address)); + + var client = new TcpClient( + address.AddressFamily); + try + { + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + await client.ConnectAsync( + address, + port, + operationCancellation.Token); + return new Gt06TcpSimulator(client); + } + catch + { + client.Dispose(); + throw; + } + } + + public async Task SendFrameAsync( + ReadOnlyMemory frame, + int fragmentSize = 0, + CancellationToken cancellationToken = default) + { + await WriteFrameAsync( + frame, + fragmentSize, + cancellationToken); + return await ReadResponseAsync( + cancellationToken); + } + + public async Task WriteFrameAsync( + ReadOnlyMemory frame, + int fragmentSize = 0, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (frame.IsEmpty) + throw new ArgumentException( + "A GT06 frame is required.", + nameof(frame)); + if (fragmentSize < 0) + throw new ArgumentOutOfRangeException( + nameof(fragmentSize)); + + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + var size = fragmentSize == 0 + ? frame.Length + : fragmentSize; + for (var offset = 0; + offset < frame.Length; + offset += size) + { + var count = Math.Min( + size, + frame.Length - offset); + await _stream.WriteAsync( + frame.Slice(offset, count), + operationCancellation.Token); + if (fragmentSize > 0) + await Task.Yield(); + } + } + + public async Task ReadResponseAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + var prefix = new byte[3]; + await ReadExactlyAsync( + prefix, + operationCancellation.Token); + var header = BinaryPrimitives + .ReadUInt16BigEndian(prefix); + var extended = header == 0x7979; + if (header != 0x7878 && + !extended) + { + throw new InvalidDataException( + "The GT06 response header is invalid."); + } + + var lengthFieldBytes = extended ? 2 : 1; + var responsePrefix = extended + ? new byte[4] + : prefix; + if (extended) + { + prefix.CopyTo(responsePrefix, 0); + await ReadExactlyAsync( + responsePrefix.AsMemory(3, 1), + operationCancellation.Token); + } + + var declaredLength = extended + ? BinaryPrimitives.ReadUInt16BigEndian( + responsePrefix.AsSpan(2, 2)) + : responsePrefix[2]; + var response = new byte[ + 2 + + lengthFieldBytes + + declaredLength + + 2]; + responsePrefix.CopyTo(response, 0); + await ReadExactlyAsync( + response.AsMemory(responsePrefix.Length), + operationCancellation.Token); + return response; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + await _stream.DisposeAsync(); + _client.Dispose(); + } + + private async Task ReadExactlyAsync( + Memory destination, + CancellationToken cancellationToken) + { + var offset = 0; + while (offset < destination.Length) + { + var read = await _stream.ReadAsync( + destination.Slice(offset), + cancellationToken); + if (read == 0) + throw new EndOfStreamException(); + offset += read; + } + } + + private static CancellationTokenSource + CreateOperationCancellation( + CancellationToken cancellationToken) + { + var operationCancellation = + CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken); + operationCancellation.CancelAfter( + OperationTimeout); + return operationCancellation; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException( + nameof(Gt06TcpSimulator)); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Tools/Queclink/QueclinkTcpSimulator.cs b/Tests/Resgrid.Tracking.Tests/Tools/Queclink/QueclinkTcpSimulator.cs new file mode 100644 index 000000000..046683a02 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Tools/Queclink/QueclinkTcpSimulator.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Tracking.Tests.Tools.Queclink +{ + internal sealed class QueclinkTcpSimulator : + IAsyncDisposable + { + private static readonly TimeSpan OperationTimeout = + TimeSpan.FromSeconds(2); + + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private bool _disposed; + + private QueclinkTcpSimulator(TcpClient client) + { + _client = client; + _stream = client.GetStream(); + } + + public static async Task + ConnectAsync( + IPAddress address, + int port, + CancellationToken cancellationToken = default) + { + if (address == null) + throw new ArgumentNullException(nameof(address)); + + var client = new TcpClient( + address.AddressFamily); + try + { + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + await client.ConnectAsync( + address, + port, + operationCancellation.Token); + return new QueclinkTcpSimulator(client); + } + catch + { + client.Dispose(); + throw; + } + } + + public async Task SendFrameAsync( + ReadOnlyMemory frame, + int fragmentSize = 0, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (frame.IsEmpty) + throw new ArgumentException( + "A Queclink frame is required.", + nameof(frame)); + if (fragmentSize < 0) + throw new ArgumentOutOfRangeException( + nameof(fragmentSize)); + + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + var size = fragmentSize == 0 + ? frame.Length + : fragmentSize; + for (var offset = 0; + offset < frame.Length; + offset += size) + { + var count = Math.Min( + size, + frame.Length - offset); + await _stream.WriteAsync( + frame.Slice(offset, count), + operationCancellation.Token); + if (fragmentSize > 0) + await Task.Yield(); + } + } + + public async Task ReadResponseAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + using var response = new MemoryStream(); + var buffer = new byte[1]; + while (response.Length < 1024) + { + var read = await _stream.ReadAsync( + buffer, + operationCancellation.Token); + if (read == 0) + throw new EndOfStreamException(); + + response.WriteByte(buffer[0]); + if (buffer[0] == (byte)'$') + return response.ToArray(); + } + + throw new InvalidDataException( + "The Queclink response exceeded the simulator limit."); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + await _stream.DisposeAsync(); + _client.Dispose(); + } + + private static CancellationTokenSource + CreateOperationCancellation( + CancellationToken cancellationToken) + { + var operationCancellation = + CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken); + operationCancellation.CancelAfter( + OperationTimeout); + return operationCancellation; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException( + nameof(QueclinkTcpSimulator)); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaTcpSimulator.cs b/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaTcpSimulator.cs new file mode 100644 index 000000000..d137eef13 --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaTcpSimulator.cs @@ -0,0 +1,449 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Tracking.Tests.Tools.Teltonika +{ + internal sealed class TeltonikaTcpSimulator : + IAsyncDisposable + { + private static readonly TimeSpan OperationTimeout = + TimeSpan.FromSeconds(2); + + private readonly TcpClient _client; + private readonly NetworkStream _stream; + private bool _disposed; + + private TeltonikaTcpSimulator(TcpClient client) + { + _client = client; + _stream = client.GetStream(); + } + + public static async Task + ConnectAndLoginAsync( + IPAddress address, + int port, + string imei, + CancellationToken cancellationToken = default) + { + if (address == null) + throw new ArgumentNullException(nameof(address)); + if (string.IsNullOrWhiteSpace(imei) || + imei.Length != 15) + { + throw new ArgumentException( + "A Teltonika IMEI must contain exactly 15 digits.", + nameof(imei)); + } + + foreach (var character in imei) + { + if (character < '0' || character > '9') + { + throw new ArgumentException( + "A Teltonika IMEI must contain only digits.", + nameof(imei)); + } + } + + var client = new TcpClient( + address.AddressFamily); + try + { + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + await client.ConnectAsync( + address, + port, + operationCancellation.Token); + var simulator = + new TeltonikaTcpSimulator(client); + await simulator.LoginAsync( + imei, + operationCancellation.Token); + return simulator; + } + catch + { + client.Dispose(); + throw; + } + } + + public async Task SendFrameAsync( + ReadOnlyMemory frame, + int fragmentSize = 0, + CancellationToken cancellationToken = default) + { + await WriteFrameAsync( + frame, + fragmentSize, + cancellationToken); + return await ReadAcceptedRecordCountAsync( + cancellationToken); + } + + public async Task WriteFrameAsync( + ReadOnlyMemory frame, + int fragmentSize = 0, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (frame.IsEmpty) + throw new ArgumentException( + "A Teltonika frame is required.", + nameof(frame)); + if (fragmentSize < 0) + throw new ArgumentOutOfRangeException( + nameof(fragmentSize)); + + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + var size = fragmentSize == 0 + ? frame.Length + : fragmentSize; + for (var offset = 0; + offset < frame.Length; + offset += size) + { + var count = Math.Min( + size, + frame.Length - offset); + await _stream.WriteAsync( + frame.Slice(offset, count), + operationCancellation.Token); + if (fragmentSize > 0) + await Task.Yield(); + } + } + + public async Task + ReadAcceptedRecordCountAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var response = new byte[4]; + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + await ReadExactlyAsync( + response, + operationCancellation.Token); + return BinaryPrimitives.ReadUInt32BigEndian( + response); + } + + public async Task DisconnectMidFrameAsync( + ReadOnlyMemory frame, + int bytesToSend, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (bytesToSend <= 0 || + bytesToSend >= frame.Length) + { + throw new ArgumentOutOfRangeException( + nameof(bytesToSend), + "The partial frame length must be within the frame."); + } + + await WriteFrameAsync( + frame.Slice(0, bytesToSend), + cancellationToken: cancellationToken); + await DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + await _stream.DisposeAsync(); + _client.Dispose(); + } + + private async Task LoginAsync( + string imei, + CancellationToken cancellationToken) + { + var imeiBytes = Encoding.ASCII.GetBytes(imei); + var login = new byte[imeiBytes.Length + 2]; + BinaryPrimitives.WriteUInt16BigEndian( + login, + (ushort)imeiBytes.Length); + imeiBytes.CopyTo(login, 2); + await _stream.WriteAsync( + login, + cancellationToken); + + var response = new byte[1]; + await ReadExactlyAsync( + response, + cancellationToken); + if (response[0] != 0x01) + { + throw new InvalidDataException( + "The gateway rejected the Teltonika IMEI login."); + } + } + + private async Task ReadExactlyAsync( + Memory destination, + CancellationToken cancellationToken) + { + var offset = 0; + while (offset < destination.Length) + { + var read = await _stream.ReadAsync( + destination.Slice(offset), + cancellationToken); + if (read == 0) + throw new EndOfStreamException(); + offset += read; + } + } + + private static CancellationTokenSource + CreateOperationCancellation( + CancellationToken cancellationToken) + { + var operationCancellation = + CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken); + operationCancellation.CancelAfter( + OperationTimeout); + return operationCancellation; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException( + nameof(TeltonikaTcpSimulator)); + } + } + + internal static class TeltonikaFixtureBuilder + { + public static byte[] BuildTimestampedBatch( + byte[] singleRecordFrame, + params DateTime[] timestampsUtc) + { + if (singleRecordFrame == null) + { + throw new ArgumentNullException( + nameof(singleRecordFrame)); + } + if (timestampsUtc == null || + timestampsUtc.Length == 0 || + timestampsUtc.Length > byte.MaxValue) + { + throw new ArgumentException( + "At least one timestamp and no more than 255 timestamps are required.", + nameof(timestampsUtc)); + } + + var data = GetData(singleRecordFrame); + if (data.Length < 4 || + data[1] != 1 || + data[^1] != 1) + { + throw new ArgumentException( + "The template must contain exactly one AVL record.", + nameof(singleRecordFrame)); + } + + var record = data.AsSpan(2, data.Length - 3); + if (record.Length < sizeof(long)) + { + throw new ArgumentException( + "The AVL record is too short to contain a timestamp.", + nameof(singleRecordFrame)); + } + + var batchData = new byte[ + 2 + + (record.Length * timestampsUtc.Length) + + 1]; + batchData[0] = data[0]; + batchData[1] = (byte)timestampsUtc.Length; + for (var index = 0; + index < timestampsUtc.Length; + index++) + { + var destination = batchData.AsSpan( + 2 + (record.Length * index), + record.Length); + record.CopyTo(destination); + var timestamp = timestampsUtc[index]; + if (timestamp.Kind != DateTimeKind.Utc) + { + throw new ArgumentException( + "Simulator timestamps must be UTC.", + nameof(timestampsUtc)); + } + + BinaryPrimitives.WriteInt64BigEndian( + destination, + new DateTimeOffset(timestamp) + .ToUnixTimeMilliseconds()); + } + + batchData[^1] = + (byte)timestampsUtc.Length; + return BuildFrame(batchData); + } + + public static byte[] CorruptCrc(byte[] frame) + { + if (frame == null) + throw new ArgumentNullException(nameof(frame)); + if (frame.Length < 12) + { + throw new ArgumentException( + "The Teltonika frame is too short.", + nameof(frame)); + } + + var corrupted = (byte[])frame.Clone(); + corrupted[^1] ^= 0xFF; + return corrupted; + } + + public static byte[] WrapUdp( + byte[] tcpFrame, + string imei, + ushort channelPacketId, + byte avlPacketId) + { + if (tcpFrame == null) + throw new ArgumentNullException(nameof(tcpFrame)); + if (string.IsNullOrWhiteSpace(imei) || + imei.Length != 15) + { + throw new ArgumentException( + "A Teltonika IMEI must contain exactly 15 digits.", + nameof(imei)); + } + + var imeiBytes = Encoding.ASCII.GetBytes(imei); + foreach (var character in imeiBytes) + { + if (character < (byte)'0' || + character > (byte)'9') + { + throw new ArgumentException( + "A Teltonika IMEI must contain only digits.", + nameof(imei)); + } + } + + var data = GetData(tcpFrame); + var packetLength = + sizeof(ushort) + + sizeof(byte) + + sizeof(byte) + + sizeof(ushort) + + imeiBytes.Length + + data.Length; + if (packetLength > ushort.MaxValue) + { + throw new ArgumentException( + "The Teltonika UDP packet is too large.", + nameof(tcpFrame)); + } + + var datagram = new byte[ + sizeof(ushort) + packetLength]; + BinaryPrimitives.WriteUInt16BigEndian( + datagram, + (ushort)packetLength); + BinaryPrimitives.WriteUInt16BigEndian( + datagram.AsSpan(2, 2), + channelPacketId); + datagram[4] = 0x01; + datagram[5] = avlPacketId; + BinaryPrimitives.WriteUInt16BigEndian( + datagram.AsSpan(6, 2), + (ushort)imeiBytes.Length); + imeiBytes.CopyTo(datagram, 8); + data.CopyTo( + datagram, + 8 + imeiBytes.Length); + return datagram; + } + + private static byte[] GetData(byte[] frame) + { + if (frame.Length < 12) + { + throw new ArgumentException( + "The Teltonika frame is too short.", + nameof(frame)); + } + + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + if (dataLength <= 0 || + dataLength != frame.Length - 12) + { + throw new ArgumentException( + "The Teltonika frame data length is invalid.", + nameof(frame)); + } + + return frame.AsSpan(8, dataLength) + .ToArray(); + } + + private static byte[] BuildFrame(byte[] data) + { + var frame = new byte[8 + data.Length + 4]; + BinaryPrimitives.WriteUInt32BigEndian( + frame.AsSpan(4, 4), + (uint)data.Length); + data.CopyTo(frame, 8); + RewriteCrc(frame); + return frame; + } + + private static void RewriteCrc(byte[] frame) + { + var dataLength = + (int)BinaryPrimitives.ReadUInt32BigEndian( + frame.AsSpan(4, 4)); + ushort crc = 0; + for (var offset = 8; + offset < 8 + dataLength; + offset++) + { + crc ^= frame[offset]; + for (var bit = 0; + bit < 8; + bit++) + { + crc = (crc & 1) != 0 + ? (ushort)((crc >> 1) ^ 0xA001) + : (ushort)(crc >> 1); + } + } + + BinaryPrimitives.WriteUInt32BigEndian( + frame.AsSpan( + 8 + dataLength, + 4), + crc); + } + } +} diff --git a/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaUdpSimulator.cs b/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaUdpSimulator.cs new file mode 100644 index 000000000..af5bb656f --- /dev/null +++ b/Tests/Resgrid.Tracking.Tests/Tools/Teltonika/TeltonikaUdpSimulator.cs @@ -0,0 +1,133 @@ +using System; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Tracking.Tests.Tools.Teltonika +{ + internal sealed class TeltonikaUdpSimulator : + IDisposable + { + private static readonly TimeSpan OperationTimeout = + TimeSpan.FromSeconds(2); + + private readonly UdpClient _client; + private bool _disposed; + + public TeltonikaUdpSimulator( + IPAddress address, + int port) + { + if (address == null) + throw new ArgumentNullException(nameof(address)); + + _client = new UdpClient( + address.AddressFamily); + _client.Connect(address, port); + } + + public async Task + SendDatagramAsync( + ReadOnlyMemory datagram, + CancellationToken cancellationToken = default) + { + await WriteDatagramAsync( + datagram, + cancellationToken); + return await ReadAcknowledgementAsync( + cancellationToken); + } + + public async Task WriteDatagramAsync( + ReadOnlyMemory datagram, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (datagram.IsEmpty) + throw new ArgumentException( + "A Teltonika datagram is required.", + nameof(datagram)); + + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + await _client.SendAsync( + datagram, + operationCancellation.Token); + } + + public async Task + ReadAcknowledgementAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + using var operationCancellation = + CreateOperationCancellation( + cancellationToken); + var response = await _client.ReceiveAsync( + operationCancellation.Token); + if (response.Buffer.Length != 7 || + BinaryPrimitives.ReadUInt16BigEndian( + response.Buffer) != 5 || + response.Buffer[4] != 0x01) + { + throw new InvalidOperationException( + "The gateway returned an invalid Teltonika UDP acknowledgement."); + } + + return new TeltonikaUdpAcknowledgement( + BinaryPrimitives.ReadUInt16BigEndian( + response.Buffer.AsSpan(2, 2)), + response.Buffer[5], + response.Buffer[6]); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _client.Dispose(); + } + + private static CancellationTokenSource + CreateOperationCancellation( + CancellationToken cancellationToken) + { + var operationCancellation = + CancellationTokenSource + .CreateLinkedTokenSource( + cancellationToken); + operationCancellation.CancelAfter( + OperationTimeout); + return operationCancellation; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException( + nameof(TeltonikaUdpSimulator)); + } + } + + internal readonly struct TeltonikaUdpAcknowledgement + { + public TeltonikaUdpAcknowledgement( + ushort channelPacketId, + byte avlPacketId, + byte acceptedRecords) + { + ChannelPacketId = channelPacketId; + AvlPacketId = avlPacketId; + AcceptedRecords = acceptedRecords; + } + + public ushort ChannelPacketId { get; } + public byte AvlPacketId { get; } + public byte AcceptedRecords { get; } + } +} diff --git a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs index a929e99f0..17a2f2576 100644 --- a/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs +++ b/Web/Resgrid.Web.Services/ApplicationCore/UnitTracking/UnitTrackingNetworkPolicy.cs @@ -1,6 +1,5 @@ -using System; -using System.Linq; using System.Net; +using Resgrid.Model.Tracking; namespace Resgrid.Web.Services.ApplicationCore.UnitTracking { @@ -8,65 +7,9 @@ public static class UnitTrackingNetworkPolicy { public static bool IsAllowed(IPAddress remoteAddress, string allowedSourceCidrs) { - if (string.IsNullOrWhiteSpace(allowedSourceCidrs)) - return true; - if (remoteAddress == null) - return false; - - var ranges = allowedSourceCidrs - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return ranges.Length > 0 && ranges.Any(range => Contains(range, remoteAddress)); - } - - private static bool Contains(string range, IPAddress candidate) - { - var parts = range.Split('/', 2, StringSplitOptions.TrimEntries); - if (!IPAddress.TryParse(parts[0], out var network)) - return false; - - var candidateAddress = NormalizeFamily(candidate, network.AddressFamily); - if (candidateAddress == null) - return false; - - var networkBytes = network.GetAddressBytes(); - var candidateBytes = candidateAddress.GetAddressBytes(); - var maximumPrefix = networkBytes.Length * 8; - var prefix = maximumPrefix; - if (parts.Length == 2 && - (!int.TryParse(parts[1], out prefix) || prefix < 0 || prefix > maximumPrefix)) - return false; - - var fullBytes = prefix / 8; - var remainingBits = prefix % 8; - for (var index = 0; index < fullBytes; index++) - { - if (networkBytes[index] != candidateBytes[index]) - return false; - } - - if (remainingBits == 0) - return true; - - var mask = (byte)(0xff << (8 - remainingBits)); - return (networkBytes[fullBytes] & mask) == (candidateBytes[fullBytes] & mask); - } - - private static IPAddress NormalizeFamily( - IPAddress address, - System.Net.Sockets.AddressFamily targetFamily) - { - if (address.AddressFamily == targetFamily) - return address; - - if (targetFamily == System.Net.Sockets.AddressFamily.InterNetwork && - address.IsIPv4MappedToIPv6) - return address.MapToIPv4(); - - if (targetFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && - address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - return address.MapToIPv6(); - - return null; + return UnitTrackingSourceNetworkPolicy.IsAllowed( + remoteAddress, + allowedSourceCidrs); } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DevicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DevicesController.cs index 25edcd8d2..60ac402ae 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DevicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DevicesController.cs @@ -165,6 +165,7 @@ public async Task> RegisterDevice([FromBody push.DeviceId = registrationInput.Token; push.Uuid = registrationInput.DeviceUuid; push.DepartmentId = DepartmentId; + push.Source = registrationInput.Source; CqrsEvent registerUnitPushEvent = new CqrsEvent(); registerUnitPushEvent.Type = (int)CqrsEventTypes.PushRegistration; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs index accc5517d..7cd2ed47b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentCommandController.cs @@ -7,6 +7,7 @@ using Resgrid.Web.Services.Filters; using Resgrid.Web.Services.Helpers; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Mime; @@ -27,10 +28,13 @@ public class IncidentCommandController : V4AuthenticatedApiControllerbase { #region Members and Constructors private readonly IIncidentCommandService _incidentCommandService; + private readonly IIncidentCommandNotificationService _incidentCommandNotificationService; - public IncidentCommandController(IIncidentCommandService incidentCommandService) + public IncidentCommandController(IIncidentCommandService incidentCommandService, + IIncidentCommandNotificationService incidentCommandNotificationService) { _incidentCommandService = incidentCommandService; + _incidentCommandNotificationService = incidentCommandNotificationService; } #endregion Members and Constructors @@ -227,6 +231,54 @@ public IncidentCommandController(IIncidentCommandService incidentCommandService) return result; } + /// + /// Sends a free-form message from the caller directly to the incident's current commander (and, when + /// IncludeDeputies is set, any assigned Deputy Incident Commanders) over their configured notification + /// channels. Only usable on an active command with a current commander. + /// + [HttpPost("SendMessageToCommand")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_Update)] + public async Task> SendMessageToCommand([FromBody] ICModels.SendMessageToCommandInput input) + { + if (input == null || input.CallId <= 0 || string.IsNullOrWhiteSpace(input.Body)) + return BadRequest(); + + var result = new ICModels.SendMessageToCommandResult(); + var command = await _incidentCommandService.GetCommandForCallAsync(DepartmentId, input.CallId); + + if (command == null || command.Status != (int)IncidentCommandStatus.Active || string.IsNullOrWhiteSpace(command.CurrentCommanderUserId)) + { + result.Status = ResponseHelper.NotFound; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + var recipients = new List { command.CurrentCommanderUserId }; + + if (input.IncludeDeputies) + { + var roles = await _incidentCommandService.GetIncidentRolesAsync(DepartmentId, input.CallId); + if (roles != null) + { + recipients.AddRange(roles + .Where(r => r.RoleType == (int)IncidentRoleType.DeputyIncidentCommander && r.RemovedOn == null && !string.IsNullOrWhiteSpace(r.UserId)) + .Select(r => r.UserId)); + } + } + + var targets = recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var title = string.IsNullOrWhiteSpace(input.Title) ? "Incident Message" : input.Title.Trim(); + + await _incidentCommandNotificationService.SendMessageToCommandAsync(DepartmentId, UserId, title, input.Body.Trim(), targets, CancellationToken.None); + + result.Data = targets.Count; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + /// Closes command on an incident. [HttpPut("CloseCommand/{incidentCommandId}")] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs index 97ad39239..91d5130fe 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitTrackingDevicesController.cs @@ -566,8 +566,15 @@ private static UnitTrackingCatalogProfileData MapProfile( TransportTypeName = profile.TransportType.ToString(), ProtocolKey = profile.ProtocolKey, PayloadAdapterKey = profile.PayloadAdapterKey, + DecoderVariant = profile.DecoderVariant, + SupportedTransports = + profile.SupportedTransports, + CertifiedTransports = + profile.CertifiedTransports, CertificationStatus = (int)profile.CertificationStatus, CertificationStatusName = profile.CertificationStatus.ToString(), + ProtocolDocumentVersion = + profile.ProtocolDocumentVersion, IdentifierRequired = profile.IdentifierRequired, IsSelectable = profile.IsSelectable, SupportedAuthModes = profile.SupportedAuthModes diff --git a/Web/Resgrid.Web.Services/Models/v4/Device/PushRegistrationInput.cs b/Web/Resgrid.Web.Services/Models/v4/Device/PushRegistrationInput.cs index 847f0183f..ed5396b94 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Device/PushRegistrationInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Device/PushRegistrationInput.cs @@ -29,5 +29,10 @@ public class PushRegistrationInput /// Prefix /// public string Prefix { get; set; } + + /// + /// Source app of the registration (e.g. "IC" for the Incident Command app). Null/empty means the default Responder app. + /// + public string Source { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs index 5b9fb320a..e2d837c84 100644 --- a/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/IncidentCommand/IncidentCommandModels.cs @@ -72,6 +72,16 @@ public class ReopenCommandInput public string Reason { get; set; } } + /// Input to send a free-form message directly to the incident commander (and optionally deputies). + public class SendMessageToCommandInput + { + public int CallId { get; set; } + public string Title { get; set; } + public string Body { get; set; } + /// Also deliver to assigned Deputy Incident Commanders, not just the current commander. + public bool IncludeDeputies { get; set; } + } + /// /// Input to update core incident metadata and the ICP/HQ, Staging, and Rehab locations. Null fields are /// left unchanged; empty strings clear. A location whose text is set while its coordinates are blank is @@ -156,6 +166,12 @@ public class CommandTransferResult : StandardApiResponseV4Base public Resgrid.Model.CommandTransfer Data { get; set; } } + /// Result of a send-to-command message; Data is the number of recipients the message was targeted at (attempted, not confirmed delivered — sends are best-effort over each recipient's configured channels). + public class SendMessageToCommandResult : StandardApiResponseV4Base + { + public int Data { get; set; } + } + public class CommandNodeResult : StandardApiResponseV4Base { public Resgrid.Model.CommandStructureNode Data { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs index 0f5b23dc4..6df71ba0b 100644 --- a/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/UnitTracking/UnitTrackingAdminModels.cs @@ -14,8 +14,14 @@ public sealed class UnitTrackingCatalogProfileData public string TransportTypeName { get; set; } public string ProtocolKey { get; set; } public string PayloadAdapterKey { get; set; } + public string DecoderVariant { get; set; } + public IReadOnlyCollection SupportedTransports { get; set; } = + Array.Empty(); + public IReadOnlyCollection CertifiedTransports { get; set; } = + Array.Empty(); public int CertificationStatus { get; set; } public string CertificationStatusName { get; set; } + public string ProtocolDocumentVersion { get; set; } public bool IdentifierRequired { get; set; } public bool IsSelectable { get; set; } public IReadOnlyCollection SupportedAuthModes { get; set; } = Array.Empty(); diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index a49d2d825..ce23e9947 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1050,6 +1050,13 @@ Transfers command to another user. + + + Sends a free-form message from the caller directly to the incident's current commander (and, when + IncludeDeputies is set, any assigned Deputy Incident Commanders) over their configured notification + channels. Only usable on an active command with a current commander. + + Closes command on an incident. @@ -7159,6 +7166,11 @@ Prefix + + + Source app of the registration (e.g. "IC" for the Incident Command app). Null/empty means the default Responder app. + + Depicts a request to register for push notifications @@ -7639,6 +7651,12 @@ Input to reopen a previously closed command, with the caller's reason for reopening. + + Input to send a free-form message directly to the incident commander (and optionally deputies). + + + Also deliver to assigned Deputy Incident Commanders, not just the current commander. + Input to update core incident metadata and the ICP/HQ, Staging, and Rehab locations. Null fields are @@ -7679,6 +7697,9 @@ List-card summaries for the department's incident commands (active only or incl. closed). + + Result of a send-to-command message; Data is the number of recipients the message was targeted at (attempted, not confirmed delivered — sends are best-effort over each recipient's configured channels). + Human-readable requirements notice. On Status=failure: why the assignment was rejected (forced diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml index a71e2f882..972fabadb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml @@ -38,7 +38,7 @@
  • @commonLocalizer["Links"]
  • @commonLocalizer["Notifications"]
  • @*
  • Weather Alerts
  • *@ - @*
  • @commonLocalizer["Commands"]
  • *@ +
  • @commonLocalizer["Commands"]
  • @commonLocalizer["BigBoard"]
  • @commonLocalizer["Dispatch"]
  • diff --git a/Workers/Resgrid.TrackerGateway/Bootstrapper.cs b/Workers/Resgrid.TrackerGateway/Bootstrapper.cs new file mode 100644 index 000000000..e126de87d --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Bootstrapper.cs @@ -0,0 +1,23 @@ +using Autofac; +using Resgrid.Providers.Bus; +using Resgrid.Providers.Bus.Rabbit; +using Resgrid.Providers.Cache; +using Resgrid.Providers.Tracking; +using Resgrid.Repositories.DataRepository; +using Resgrid.Services; + +namespace Resgrid.TrackerGateway +{ + public static class Bootstrapper + { + public static void ConfigureContainer(ContainerBuilder builder) + { + builder.RegisterModule(new DataModule()); + builder.RegisterModule(new ServicesModule()); + builder.RegisterModule(new CacheProviderModule()); + builder.RegisterModule(new BusModule()); + builder.RegisterModule(new RabbitBusModule()); + builder.RegisterModule(new TrackingProviderModule()); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Dockerfile b/Workers/Resgrid.TrackerGateway/Dockerfile new file mode 100644 index 000000000..286b7a92e --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Dockerfile @@ -0,0 +1,78 @@ +# syntax=docker/dockerfile:1.7 + +ARG BUILD_VERSION=3.5.0 + +FROM dhi.io/aspnetcore:9.0.16-debian13@sha256:961647e80202ce33fc06472dda4e7ae2d2bc56d819aee6742602f70047b13dc7 AS base +ARG BUILD_VERSION +WORKDIR /app +EXPOSE 8080/tcp +EXPOSE 5004/tcp 5004/udp +EXPOSE 5023/tcp 5023/udp +EXPOSE 5027/tcp 5027/udp + +FROM dhi.io/dotnet:9.0.314-sdk-debian13@sha256:a3acd51de0af79878e26292b3053aab513c6ca4476ddb2f9f11adb9c04aa7c89 AS build +ARG BUILD_VERSION +WORKDIR /src + +COPY ["Workers/Resgrid.TrackerGateway/Resgrid.TrackerGateway.csproj", "Workers/Resgrid.TrackerGateway/"] +COPY ["Core/Resgrid.Config/Resgrid.Config.csproj", "Core/Resgrid.Config/"] +COPY ["Core/Resgrid.Framework/Resgrid.Framework.csproj", "Core/Resgrid.Framework/"] +COPY ["Core/Resgrid.Model/Resgrid.Model.csproj", "Core/Resgrid.Model/"] +COPY ["Core/Resgrid.Services/Resgrid.Services.csproj", "Core/Resgrid.Services/"] +COPY ["Providers/Resgrid.Providers.AddressVerification/Resgrid.Providers.AddressVerification.csproj", "Providers/Resgrid.Providers.AddressVerification/"] +COPY ["Providers/Resgrid.Providers.Bus/Resgrid.Providers.Bus.csproj", "Providers/Resgrid.Providers.Bus/"] +COPY ["Providers/Resgrid.Providers.Bus.Rabbit/Resgrid.Providers.Bus.Rabbit.csproj", "Providers/Resgrid.Providers.Bus.Rabbit/"] +COPY ["Providers/Resgrid.Providers.Cache/Resgrid.Providers.Cache.csproj", "Providers/Resgrid.Providers.Cache/"] +COPY ["Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj", "Providers/Resgrid.Providers.Email/"] +COPY ["Providers/Resgrid.Providers.Geo/Resgrid.Providers.Geo.csproj", "Providers/Resgrid.Providers.Geo/"] +COPY ["Providers/Resgrid.Providers.Marketing/Resgrid.Providers.Marketing.csproj", "Providers/Resgrid.Providers.Marketing/"] +COPY ["Providers/Resgrid.Providers.Migrations/Resgrid.Providers.Migrations.csproj", "Providers/Resgrid.Providers.Migrations/"] +COPY ["Providers/Resgrid.Providers.MigrationsPg/Resgrid.Providers.MigrationsPg.csproj", "Providers/Resgrid.Providers.MigrationsPg/"] +COPY ["Providers/Resgrid.Providers.Number/Resgrid.Providers.Number.csproj", "Providers/Resgrid.Providers.Number/"] +COPY ["Providers/Resgrid.Providers.Pdf/Resgrid.Providers.Pdf.csproj", "Providers/Resgrid.Providers.Pdf/"] +COPY ["Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj", "Providers/Resgrid.Providers.Tracking/"] +COPY ["Providers/Resgrid.Providers.Voip/Resgrid.Providers.Voip.csproj", "Providers/Resgrid.Providers.Voip/"] +COPY ["Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj", "Repositories/Resgrid.Repositories.DataRepository/"] +RUN dotnet restore "Workers/Resgrid.TrackerGateway/Resgrid.TrackerGateway.csproj" + +COPY . . +WORKDIR "/src/Workers/Resgrid.TrackerGateway" + +FROM build AS publish +ARG BUILD_VERSION +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ + && apt-get install -y --reinstall --no-install-recommends tzdata \ + && test -f /usr/share/zoneinfo/America/New_York \ + && test -f /usr/share/zoneinfo/Asia/Kolkata \ + && rm -rf /var/lib/apt/lists/* +RUN dotnet publish "Resgrid.TrackerGateway.csproj" -c Release -o /app/publish -p:Version=${BUILD_VERSION} +ADD --checksum=sha256:2241be671073520e028b2f12df1e9ef0419014cffb5670b7a80b2080804be17d https://github.com/ufoscout/docker-compose-wait/releases/download/2.12.1/wait /app/publish/wait +# LocalXpose CLI (https://localxpose.io) used to expose the TCP/UDP ingest ports; checksum tracks the current linux/amd64 artifact. +ADD --checksum=sha256:b13e6914288e6c7d66744578e68b4eeacab5d629cd8fe3fb13863665d0a1ac95 https://api.localxpose.io/api/downloads/loclx-linux-amd64.zip /tmp/loclx.zip +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ + && apt-get install -y --no-install-recommends unzip \ + && unzip /tmp/loclx.zip -d /app/publish \ + && rm -f /tmp/loclx.zip \ + && rm -rf /var/lib/apt/lists/* \ + && cp /src/Workers/Resgrid.TrackerGateway/localxpose-entrypoint.sh /app/publish/localxpose-entrypoint.sh \ + && chmod +x /app/publish/wait /app/publish/loclx /app/publish/localxpose-entrypoint.sh + +FROM base AS final +WORKDIR /app +COPY --from=publish /usr/share/zoneinfo /usr/share/zoneinfo +# Hardened runtime has no shell; copy dash for the entrypoint and the CA bundle for loclx TLS. +COPY --from=publish /usr/bin/dash /usr/bin/dash +COPY --from=publish /etc/ssl/certs /etc/ssl/certs +ENV TZ=Etc/UTC +# Writable home for loclx state when tunnels are enabled (hardened runtime user has none). +ENV HOME=/tmp +COPY --from=publish /app/publish . +ENV WAIT_COMMAND="dotnet Resgrid.TrackerGateway.dll" +ENV LOCALXPOSE_ENABLED=false \ + LOCALXPOSE_REGION=us \ + LOCALXPOSE_TARGET_HOST=localhost \ + LOCALXPOSE_TCP_PORTS="5004 5023 5027" \ + LOCALXPOSE_UDP_PORTS="" \ + LOCALXPOSE_RESERVED_ENDPOINTS="" \ + LOCALXPOSE_RESTART_DELAY=5 +ENTRYPOINT ["./localxpose-entrypoint.sh"] diff --git a/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayLivenessHealthCheck.cs b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayLivenessHealthCheck.cs new file mode 100644 index 000000000..acdd41136 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayLivenessHealthCheck.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Resgrid.TrackerGateway.Health +{ + public sealed class TrackingGatewayLivenessHealthCheck : IHealthCheck + { + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + return Task.FromResult( + HealthCheckResult.Healthy("Tracker gateway process is running.")); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetrics.cs b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetrics.cs new file mode 100644 index 000000000..bd9753744 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetrics.cs @@ -0,0 +1,694 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Health +{ + public sealed class TrackingGatewayMetrics + { + private static readonly double[] QueuePublishDurationBuckets = + { + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1, + 2.5, + 5, + 10 + }; + + private static readonly double[] FrameByteBuckets = + { + 64, + 128, + 256, + 512, + 1024, + 4096, + 16384, + 65536, + 262144, + 1048576 + }; + + private static readonly double[] SessionDurationBuckets = + { + 1, + 5, + 15, + 30, + 60, + 300, + 900, + 1800, + 3600 + }; + + private readonly ConcurrentDictionary + _currentConnections = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _connections = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _ingressMessages = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _positions = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _parseFailures = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _authFailures = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _forcedShutdowns = new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _queuePublishDurations = + new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _frameBytes = + new ConcurrentDictionary(); + private readonly ConcurrentDictionary + _sessionDurations = + new ConcurrentDictionary(); + + public void ConnectionStarted( + TrackingListenerDefinition definition) + { + var key = new MetricKey( + definition.ProtocolKey, + Transport(definition.Transport)); + _currentConnections.AddOrUpdate( + key, + 1, + (_, current) => current + 1); + } + + public void ConnectionCompleted( + TrackingListenerDefinition definition, + string outcome) + { + var currentKey = new MetricKey( + definition.ProtocolKey, + Transport(definition.Transport)); + _currentConnections.AddOrUpdate( + currentKey, + 0, + (_, current) => Math.Max(0, current - 1)); + Increment( + _connections, + new MetricKey( + definition.ProtocolKey, + ConnectionOutcome(outcome))); + } + + public void ConnectionRejected( + TrackingListenerDefinition definition) + { + Increment( + _connections, + new MetricKey( + definition.ProtocolKey, + "admission-rejected")); + } + + public void RecordIngressMessage( + TrackingListenerDefinition definition, + ProtocolMessage message, + TrackingAcceptance acceptance) + { + var outcome = AcceptanceOutcome(acceptance); + Increment( + _ingressMessages, + new MetricKey( + Transport(definition.Transport), + definition.ProtocolKey, + outcome)); + + if (message?.MessageType != ProtocolMessageType.Positions) + return; + + var positionCount = acceptance?.Status == + TrackingAcceptanceStatus.Accepted + ? acceptance.AcceptedPositions + : message.Positions?.Count ?? 0; + if (positionCount <= 0) + return; + + Increment( + _positions, + new MetricKey( + Transport(definition.Transport), + definition.ProtocolKey, + outcome), + positionCount); + } + + public void RecordParseFailure( + string protocolKey, + string reason) + { + Increment( + _parseFailures, + new MetricKey( + protocolKey, + ParseFailureReason(reason))); + } + + public void RecordAuthFailure( + TrackingSocketTransport transport, + string reason) + { + Increment( + _authFailures, + new MetricKey( + Transport(transport), + AuthFailureReason(reason))); + } + + public void ObserveQueuePublishDuration( + TrackingSocketTransport transport, + TimeSpan duration) + { + Observe( + _queuePublishDurations, + new MetricKey(Transport(transport)), + QueuePublishDurationBuckets, + duration.TotalSeconds); + } + + public void ObserveFrameBytes( + string protocolKey, + int frameBytes) + { + if (frameBytes < 0) + return; + + Observe( + _frameBytes, + new MetricKey(protocolKey), + FrameByteBuckets, + frameBytes); + } + + public void ObserveSessionDuration( + string protocolKey, + TimeSpan duration) + { + Observe( + _sessionDurations, + new MetricKey(protocolKey), + SessionDurationBuckets, + duration.TotalSeconds); + } + + public void RecordForcedShutdown( + TrackingListenerDefinition definition) + { + Increment( + _forcedShutdowns, + new MetricKey( + definition.ProtocolKey, + Transport(definition.Transport))); + } + + internal void AppendPrometheus(StringBuilder builder) + { + AppendMetricHeader( + builder, + "resgrid_tracking_connections_current", + "Current admitted tracking sessions.", + "gauge"); + AppendSamples( + builder, + "resgrid_tracking_connections_current", + _currentConnections, + "protocol", + "transport"); + + AppendMetricHeader( + builder, + "resgrid_tracking_connections_total", + "Tracking session attempts by terminal outcome.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_connections_total", + _connections, + "protocol", + "outcome"); + + AppendMetricHeader( + builder, + "resgrid_tracking_ingress_messages_total", + "Parsed tracking messages by canonical ingress outcome.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_ingress_messages_total", + _ingressMessages, + "transport", + "protocol", + "outcome"); + + AppendMetricHeader( + builder, + "resgrid_tracking_positions_total", + "Tracking positions by canonical ingress outcome.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_positions_total", + _positions, + "transport", + "protocol", + "outcome"); + + AppendMetricHeader( + builder, + "resgrid_tracking_parse_failures_total", + "Tracking protocol parse failures by bounded reason.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_parse_failures_total", + _parseFailures, + "protocol", + "reason"); + + AppendMetricHeader( + builder, + "resgrid_tracking_auth_failures_total", + "Native tracking mapping and source-policy failures.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_auth_failures_total", + _authFailures, + "transport", + "reason"); + + AppendHistogram( + builder, + "resgrid_tracking_queue_publish_duration_seconds", + "Canonical position ingress duration before acknowledgement.", + _queuePublishDurations, + "transport"); + AppendHistogram( + builder, + "resgrid_tracking_frame_bytes", + "Complete native tracking frame size in bytes.", + _frameBytes, + "protocol"); + AppendHistogram( + builder, + "resgrid_tracking_session_duration_seconds", + "Native TCP tracking session duration.", + _sessionDurations, + "protocol"); + + AppendMetricHeader( + builder, + "resgrid_tracking_shutdown_forced_total", + "Listener shutdowns that exhausted the graceful drain deadline.", + "counter"); + AppendSamples( + builder, + "resgrid_tracking_shutdown_forced_total", + _forcedShutdowns, + "protocol", + "transport"); + } + + private static void Increment( + ConcurrentDictionary counters, + MetricKey key, + long value = 1) + { + counters.AddOrUpdate( + key, + value, + (_, current) => current + value); + } + + private static void Observe( + ConcurrentDictionary histograms, + MetricKey key, + double[] buckets, + double value) + { + histograms.GetOrAdd( + key, + _ => new TrackingHistogram(buckets)) + .Observe(Math.Max(0, value)); + } + + private static string Transport( + TrackingSocketTransport transport) + { + return transport switch + { + TrackingSocketTransport.Tcp => "tcp", + TrackingSocketTransport.Udp => "udp", + _ => "unknown" + }; + } + + private static string AcceptanceOutcome( + TrackingAcceptance acceptance) + { + return acceptance?.Status switch + { + TrackingAcceptanceStatus.Accepted => "accepted", + TrackingAcceptanceStatus.Rejected => "rejected", + TrackingAcceptanceStatus.Unavailable => "unavailable", + _ => "unknown" + }; + } + + private static string ConnectionOutcome(string outcome) + { + return outcome switch + { + "completed" => "completed", + "cancelled" => "cancelled", + "failed" => "failed", + _ => "unknown" + }; + } + + private static string ParseFailureReason(string reason) + { + return reason switch + { + "close-session" => "close-session", + "frame-too-large" => "frame-too-large", + "incomplete-datagram" => "incomplete-datagram", + "invalid-result" => "invalid-result", + "malformed" => "malformed", + "parser-exception" => "parser-exception", + "unsupported" => "unsupported", + _ => "other" + }; + } + + private static string AuthFailureReason(string reason) + { + return reason switch + { + "device-not-found" => "device-not-found", + "identifier-changed" => "identifier-changed", + "identifier-required" => "identifier-required", + "mapping-unavailable" => "mapping-unavailable", + "source-not-allowed" => "source-not-allowed", + _ => "other" + }; + } + + private static void AppendMetricHeader( + StringBuilder builder, + string name, + string help, + string type) + { + builder.Append("# HELP "); + builder.Append(name); + builder.Append(' '); + builder.AppendLine(help); + builder.Append("# TYPE "); + builder.Append(name); + builder.Append(' '); + builder.AppendLine(type); + } + + private static void AppendSamples( + StringBuilder builder, + string name, + ConcurrentDictionary samples, + params string[] labelNames) + { + foreach (var sample in samples + .OrderBy(item => item.Key.SortKey, + StringComparer.Ordinal)) + { + AppendSamplePrefix( + builder, + name, + sample.Key, + labelNames); + builder.AppendLine( + sample.Value.ToString( + CultureInfo.InvariantCulture)); + } + } + + private static void AppendHistogram( + StringBuilder builder, + string name, + string help, + ConcurrentDictionary histograms, + params string[] labelNames) + { + AppendMetricHeader( + builder, + name, + help, + "histogram"); + foreach (var histogram in histograms + .OrderBy(item => item.Key.SortKey, + StringComparer.Ordinal)) + { + var snapshot = histogram.Value.GetSnapshot(); + long cumulativeCount = 0; + for (var index = 0; + index < snapshot.Buckets.Length; + index++) + { + cumulativeCount += snapshot.BucketCounts[index]; + AppendHistogramBucketPrefix( + builder, + name, + histogram.Key, + labelNames, + snapshot.Buckets[index].ToString( + "0.#################", + CultureInfo.InvariantCulture)); + builder.AppendLine( + cumulativeCount.ToString( + CultureInfo.InvariantCulture)); + } + + cumulativeCount += + snapshot.BucketCounts[snapshot.Buckets.Length]; + AppendHistogramBucketPrefix( + builder, + name, + histogram.Key, + labelNames, + "+Inf"); + builder.AppendLine( + cumulativeCount.ToString( + CultureInfo.InvariantCulture)); + + AppendSamplePrefix( + builder, + name + "_sum", + histogram.Key, + labelNames); + builder.AppendLine( + snapshot.Sum.ToString( + "G17", + CultureInfo.InvariantCulture)); + AppendSamplePrefix( + builder, + name + "_count", + histogram.Key, + labelNames); + builder.AppendLine( + snapshot.Count.ToString( + CultureInfo.InvariantCulture)); + } + } + + private static void AppendHistogramBucketPrefix( + StringBuilder builder, + string name, + MetricKey key, + IReadOnlyList labelNames, + string upperBound) + { + builder.Append(name); + builder.Append("_bucket{"); + AppendLabels( + builder, + key, + labelNames); + if (labelNames.Count > 0) + builder.Append(','); + builder.Append("le=\""); + builder.Append(upperBound); + builder.Append("\"} "); + } + + private static void AppendSamplePrefix( + StringBuilder builder, + string name, + MetricKey key, + IReadOnlyList labelNames) + { + builder.Append(name); + if (labelNames.Count > 0) + { + builder.Append('{'); + AppendLabels( + builder, + key, + labelNames); + builder.Append('}'); + } + + builder.Append(' '); + } + + private static void AppendLabels( + StringBuilder builder, + MetricKey key, + IReadOnlyList labelNames) + { + for (var index = 0; + index < labelNames.Count; + index++) + { + if (index > 0) + builder.Append(','); + builder.Append(labelNames[index]); + builder.Append("=\""); + builder.Append(EscapeLabel(key.Values[index])); + builder.Append('"'); + } + } + + private static string EscapeLabel(string value) + { + return (value ?? string.Empty) + .Replace("\\", "\\\\") + .Replace("\n", "\\n") + .Replace("\"", "\\\""); + } + + private readonly struct MetricKey : IEquatable + { + public MetricKey(params string[] values) + { + Values = values ?? Array.Empty(); + SortKey = string.Join( + "\u001f", + Values); + } + + public string[] Values { get; } + public string SortKey { get; } + + public bool Equals(MetricKey other) + { + return Values.SequenceEqual( + other.Values, + StringComparer.Ordinal); + } + + public override bool Equals(object obj) + { + return obj is MetricKey other && + Equals(other); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var value in Values) + { + hash.Add( + value, + StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + } + + private sealed class TrackingHistogram + { + private readonly object _syncRoot = new object(); + private readonly double[] _buckets; + private readonly long[] _bucketCounts; + private long _count; + private double _sum; + + public TrackingHistogram(double[] buckets) + { + _buckets = buckets; + _bucketCounts = + new long[buckets.Length + 1]; + } + + public void Observe(double value) + { + lock (_syncRoot) + { + var bucketIndex = _buckets.Length; + for (var index = 0; + index < _buckets.Length; + index++) + { + if (value <= _buckets[index]) + { + bucketIndex = index; + break; + } + } + + _bucketCounts[bucketIndex]++; + _count++; + _sum += value; + } + } + + public TrackingHistogramSnapshot GetSnapshot() + { + lock (_syncRoot) + { + return new TrackingHistogramSnapshot( + _buckets, + (long[])_bucketCounts.Clone(), + _count, + _sum); + } + } + } + + private sealed class TrackingHistogramSnapshot + { + public TrackingHistogramSnapshot( + double[] buckets, + long[] bucketCounts, + long count, + double sum) + { + Buckets = buckets; + BucketCounts = bucketCounts; + Count = count; + Sum = sum; + } + + public double[] Buckets { get; } + public IReadOnlyList BucketCounts { get; } + public long Count { get; } + public double Sum { get; } + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetricsWriter.cs b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetricsWriter.cs new file mode 100644 index 000000000..e99823d9c --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayMetricsWriter.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text; + +namespace Resgrid.TrackerGateway.Health +{ + public static class TrackingGatewayMetricsWriter + { + public static string Write( + TrackingGatewayReadinessSnapshot snapshot, + TrackingGatewayMetrics metrics = null, + int? connectionLimit = null) + { + var builder = new StringBuilder(); + builder.AppendLine( + "# HELP resgrid_tracker_gateway_listeners_expected Required tracking socket listeners."); + builder.AppendLine( + "# TYPE resgrid_tracker_gateway_listeners_expected gauge"); + builder.Append("resgrid_tracker_gateway_listeners_expected "); + builder.AppendLine( + snapshot.ExpectedListeners.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine( + "# HELP resgrid_tracker_gateway_listeners_bound Currently bound tracking socket listeners."); + builder.AppendLine( + "# TYPE resgrid_tracker_gateway_listeners_bound gauge"); + builder.Append("resgrid_tracker_gateway_listeners_bound "); + builder.AppendLine( + snapshot.BoundListeners.ToString(CultureInfo.InvariantCulture)); + builder.AppendLine( + "# HELP resgrid_tracker_gateway_ready Whether all required listeners are ready."); + builder.AppendLine("# TYPE resgrid_tracker_gateway_ready gauge"); + builder.Append("resgrid_tracker_gateway_ready "); + builder.AppendLine(snapshot.IsReady ? "1" : "0"); + if (connectionLimit > 0) + { + builder.AppendLine( + "# HELP resgrid_tracker_gateway_connections_limit Configured global tracking session admission limit."); + builder.AppendLine( + "# TYPE resgrid_tracker_gateway_connections_limit gauge"); + builder.Append( + "resgrid_tracker_gateway_connections_limit "); + builder.AppendLine( + connectionLimit.Value.ToString( + CultureInfo.InvariantCulture)); + } + metrics?.AppendPrometheus(builder); + return builder.ToString(); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessHealthCheck.cs b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessHealthCheck.cs new file mode 100644 index 000000000..3120079d3 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessHealthCheck.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Resgrid.TrackerGateway.Health +{ + public sealed class TrackingGatewayReadinessHealthCheck : IHealthCheck + { + private readonly TrackingGatewayReadinessState _readiness; + + public TrackingGatewayReadinessHealthCheck( + TrackingGatewayReadinessState readiness) + { + _readiness = readiness; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var snapshot = _readiness.GetSnapshot(); + var data = new Dictionary + { + ["expectedListeners"] = snapshot.ExpectedListeners, + ["boundListeners"] = snapshot.BoundListeners + }; + + if (snapshot.IsReady) + { + return Task.FromResult( + HealthCheckResult.Healthy( + "All required tracking listeners are bound.", + data)); + } + + return Task.FromResult( + HealthCheckResult.Unhealthy( + "One or more required tracking listeners are not ready.", + data: data)); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessState.cs b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessState.cs new file mode 100644 index 000000000..558eae547 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Health/TrackingGatewayReadinessState.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Health +{ + public sealed class TrackingGatewayReadinessSnapshot + { + public TrackingGatewayReadinessSnapshot( + int expectedListeners, + int boundListeners, + bool isReady, + bool hasFailure) + { + ExpectedListeners = expectedListeners; + BoundListeners = boundListeners; + IsReady = isReady; + HasFailure = hasFailure; + } + + public int ExpectedListeners { get; } + public int BoundListeners { get; } + public bool IsReady { get; } + public bool HasFailure { get; } + } + + public sealed class TrackingGatewayReadinessState + { + private readonly object _syncRoot = new object(); + private readonly HashSet _expectedListenerKeys = + new HashSet(); + private readonly HashSet _boundListenerKeys = + new HashSet(); + private bool _hasFailure; + private bool _stopping; + + public void Initialize(TrackingListenerPlan plan) + { + lock (_syncRoot) + { + _expectedListenerKeys.Clear(); + _boundListenerKeys.Clear(); + _hasFailure = false; + _stopping = false; + + foreach (var listener in plan.Listeners) + _expectedListenerKeys.Add(listener.Key); + } + } + + public void MarkBound(TrackingListenerDefinition definition) + { + lock (_syncRoot) + { + if (_expectedListenerKeys.Contains(definition.Key)) + _boundListenerKeys.Add(definition.Key); + } + } + + public void MarkStopped(TrackingListenerDefinition definition) + { + lock (_syncRoot) + { + _boundListenerKeys.Remove(definition.Key); + } + } + + public void MarkFailed() + { + lock (_syncRoot) + { + _hasFailure = true; + } + } + + public void MarkStopping() + { + lock (_syncRoot) + { + _stopping = true; + } + } + + public TrackingGatewayReadinessSnapshot GetSnapshot() + { + lock (_syncRoot) + { + var isReady = !_hasFailure && + !_stopping && + _boundListenerKeys.Count == _expectedListenerKeys.Count; + return new TrackingGatewayReadinessSnapshot( + _expectedListenerKeys.Count, + _boundListenerKeys.Count, + isReady, + _hasFailure); + } + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewayConfigurationException.cs b/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewayConfigurationException.cs new file mode 100644 index 000000000..e68787985 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewayConfigurationException.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.TrackerGateway.Hosting +{ + public sealed class TrackingGatewayConfigurationException : InvalidOperationException + { + public TrackingGatewayConfigurationException(IEnumerable errors) + : base(BuildMessage(errors)) + { + Errors = (errors ?? Array.Empty()).ToList().AsReadOnly(); + } + + public IReadOnlyCollection Errors { get; } + + private static string BuildMessage(IEnumerable errors) + { + var errorList = (errors ?? Array.Empty()).ToList(); + if (errorList.Count == 0) + return "Tracker gateway configuration is invalid."; + + return $"Tracker gateway configuration is invalid: {string.Join(" ", errorList)}"; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewaySettings.cs b/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewaySettings.cs new file mode 100644 index 000000000..5e171dc8b --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Hosting/TrackingGatewaySettings.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using Resgrid.Config; +using Resgrid.Providers.Tracking.Protocols; + +namespace Resgrid.TrackerGateway.Hosting +{ + public sealed class TrackingProtocolListenerSettings + { + public TrackingProtocolListenerSettings( + string protocolKey, + bool enabled, + bool tcpEnabled, + int tcpPort, + bool udpEnabled, + int udpPort) + { + ProtocolKey = protocolKey; + Enabled = enabled; + TcpEnabled = tcpEnabled; + TcpPort = tcpPort; + UdpEnabled = udpEnabled; + UdpPort = udpPort; + } + + public string ProtocolKey { get; } + public bool Enabled { get; } + public bool TcpEnabled { get; } + public int TcpPort { get; } + public bool UdpEnabled { get; } + public int UdpPort { get; } + + public bool IsEnabled(TrackingSocketTransport transport) + { + switch (transport) + { + case TrackingSocketTransport.Tcp: + return TcpEnabled; + case TrackingSocketTransport.Udp: + return UdpEnabled; + default: + return false; + } + } + + public int GetPort(TrackingSocketTransport transport) + { + switch (transport) + { + case TrackingSocketTransport.Tcp: + return TcpPort; + case TrackingSocketTransport.Udp: + return UdpPort; + default: + throw new ArgumentOutOfRangeException( + nameof(transport), + transport, + "Only TCP and UDP listener ports are supported."); + } + } + } + + public sealed class TrackingGatewaySettings + { + public TrackingGatewaySettings( + bool trackingEnabled, + bool nativeGatewayEnabled, + string credentialPepper, + int tcpIdleTimeoutSeconds, + int maxFrameBytes, + int maxConnections, + int maxConnectionsPerIp, + int gracefulShutdownSeconds, + int internalHealthPort, + IEnumerable protocols) + { + TrackingEnabled = trackingEnabled; + NativeGatewayEnabled = nativeGatewayEnabled; + CredentialPepper = credentialPepper; + TcpIdleTimeoutSeconds = tcpIdleTimeoutSeconds; + MaxFrameBytes = maxFrameBytes; + MaxConnections = maxConnections; + MaxConnectionsPerIp = maxConnectionsPerIp; + GracefulShutdownSeconds = gracefulShutdownSeconds; + InternalHealthPort = internalHealthPort; + Protocols = new List( + protocols ?? Array.Empty()) + .AsReadOnly(); + } + + public bool TrackingEnabled { get; } + public bool NativeGatewayEnabled { get; } + public string CredentialPepper { get; } + public int TcpIdleTimeoutSeconds { get; } + public int MaxFrameBytes { get; } + public int MaxConnections { get; } + public int MaxConnectionsPerIp { get; } + public int GracefulShutdownSeconds { get; } + public int InternalHealthPort { get; } + public IReadOnlyCollection Protocols { get; } + + public static TrackingGatewaySettings FromCurrentConfig() + { + return new TrackingGatewaySettings( + UnitTrackingConfig.Enabled, + UnitTrackingConfig.NativeGatewayEnabled, + UnitTrackingConfig.CredentialPepper, + UnitTrackingConfig.TcpIdleTimeoutSeconds, + UnitTrackingConfig.MaxFrameBytes, + UnitTrackingConfig.MaxConnections, + UnitTrackingConfig.MaxConnectionsPerIp, + UnitTrackingConfig.GracefulShutdownSeconds, + UnitTrackingConfig.InternalHealthPort, + new[] + { + new TrackingProtocolListenerSettings( + TrackingProtocolKeys.Queclink, + UnitTrackingConfig.EnableQueclink, + UnitTrackingConfig.EnableQueclinkTcp, + UnitTrackingConfig.QueclinkTcpPort, + UnitTrackingConfig.EnableQueclinkUdp, + UnitTrackingConfig.QueclinkUdpPort), + new TrackingProtocolListenerSettings( + TrackingProtocolKeys.Gt06, + UnitTrackingConfig.EnableGt06, + UnitTrackingConfig.EnableGt06Tcp, + UnitTrackingConfig.Gt06TcpPort, + UnitTrackingConfig.EnableGt06Udp, + UnitTrackingConfig.Gt06UdpPort), + new TrackingProtocolListenerSettings( + TrackingProtocolKeys.Teltonika, + UnitTrackingConfig.EnableTeltonika, + UnitTrackingConfig.EnableTeltonikaTcp, + UnitTrackingConfig.TeltonikaTcpPort, + UnitTrackingConfig.EnableTeltonikaUdp, + UnitTrackingConfig.TeltonikaUdpPort) + }); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerDefinition.cs b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerDefinition.cs new file mode 100644 index 000000000..88dd257f4 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerDefinition.cs @@ -0,0 +1,49 @@ +using System; +using Resgrid.Providers.Tracking.Protocols; + +namespace Resgrid.TrackerGateway.Hosting +{ + public sealed class TrackingListenerDefinition : IEquatable + { + public TrackingListenerDefinition( + string protocolKey, + TrackingSocketTransport transport, + int port) + { + ProtocolKey = protocolKey; + Transport = transport; + Port = port; + } + + public string ProtocolKey { get; } + public TrackingSocketTransport Transport { get; } + public int Port { get; } + public string Key => $"{ProtocolKey}:{Transport}:{Port}"; + + public bool Equals(TrackingListenerDefinition other) + { + return other != null && + string.Equals(ProtocolKey, other.ProtocolKey, StringComparison.OrdinalIgnoreCase) && + Transport == other.Transport && + Port == other.Port; + } + + public override bool Equals(object obj) + { + return Equals(obj as TrackingListenerDefinition); + } + + public override int GetHashCode() + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(ProtocolKey ?? string.Empty), + Transport, + Port); + } + + public override string ToString() + { + return $"{ProtocolKey} {Transport} port {Port}"; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlan.cs b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlan.cs new file mode 100644 index 000000000..9336b86bb --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlan.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.TrackerGateway.Hosting +{ + public sealed class TrackingListenerPlan + { + public TrackingListenerPlan(IEnumerable listeners) + { + Listeners = new List( + listeners ?? Array.Empty()) + .AsReadOnly(); + } + + public IReadOnlyCollection Listeners { get; } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlanBuilder.cs b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlanBuilder.cs new file mode 100644 index 000000000..ef809de42 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Hosting/TrackingListenerPlanBuilder.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.TrackerGateway.Hosting +{ + public sealed class TrackingListenerPlanBuilder + { + private const int MaximumFrameBytes = 1024 * 1024; + + public TrackingListenerPlan Build( + TrackingGatewaySettings settings, + ITrackingProtocolModuleRegistry moduleRegistry, + ITrackingListenerFactory listenerFactory) + { + if (settings == null) + throw new ArgumentNullException(nameof(settings)); + if (moduleRegistry == null) + throw new ArgumentNullException(nameof(moduleRegistry)); + if (listenerFactory == null) + throw new ArgumentNullException(nameof(listenerFactory)); + + var errors = new List(); + var definitions = new List(); + + ValidatePort(settings.InternalHealthPort, "InternalHealthPort", errors); + + if (!settings.NativeGatewayEnabled) + { + ThrowIfInvalid(errors); + return new TrackingListenerPlan(definitions); + } + + if (!settings.TrackingEnabled) + { + errors.Add( + "UnitTrackingConfig.Enabled must be true when NativeGatewayEnabled is true."); + } + + if (string.IsNullOrWhiteSpace(settings.CredentialPepper)) + { + errors.Add( + "UnitTrackingConfig.CredentialPepper must be supplied through secret management."); + } + + ValidatePositive( + settings.TcpIdleTimeoutSeconds, + "TcpIdleTimeoutSeconds", + errors); + ValidateRange( + settings.MaxFrameBytes, + 1, + MaximumFrameBytes, + "MaxFrameBytes", + errors); + ValidatePositive(settings.MaxConnections, "MaxConnections", errors); + ValidatePositive( + settings.MaxConnectionsPerIp, + "MaxConnectionsPerIp", + errors); + ValidatePositive( + settings.GracefulShutdownSeconds, + "GracefulShutdownSeconds", + errors); + + if (settings.MaxConnectionsPerIp > settings.MaxConnections) + { + errors.Add( + "UnitTrackingConfig.MaxConnectionsPerIp cannot exceed MaxConnections."); + } + + var enabledProtocols = settings.Protocols + .Where(protocol => protocol != null && protocol.Enabled) + .ToList(); + if (enabledProtocols.Count == 0) + { + errors.Add( + "At least one native tracking protocol must be enabled."); + } + + foreach (var protocol in enabledProtocols) + { + var enabledTransports = new[] + { + TrackingSocketTransport.Tcp, + TrackingSocketTransport.Udp + }.Where(protocol.IsEnabled).ToList(); + if (enabledTransports.Count == 0) + { + errors.Add( + $"Tracking protocol '{protocol.ProtocolKey}' must enable at least one transport."); + continue; + } + + var module = moduleRegistry.Modules.SingleOrDefault( + candidate => string.Equals( + candidate.ProtocolKey.Trim(), + protocol.ProtocolKey, + StringComparison.OrdinalIgnoreCase)); + if (module == null) + { + errors.Add( + $"No tracking protocol module is registered for '{protocol.ProtocolKey}'."); + continue; + } + + foreach (var transport in enabledTransports) + { + if (!module.SupportedTransports.Contains(transport)) + { + errors.Add( + $"Tracking protocol module '{protocol.ProtocolKey}' does not support enabled transport '{transport}'."); + continue; + } + + var port = protocol.GetPort(transport); + var portName = $"{protocol.ProtocolKey} {transport} port"; + if (!ValidatePort(port, portName, errors)) + continue; + + var definition = new TrackingListenerDefinition( + protocol.ProtocolKey, + transport, + port); + if (!listenerFactory.Supports(definition)) + { + errors.Add( + $"No socket listener implementation is registered for {definition}."); + continue; + } + + definitions.Add(definition); + } + } + + foreach (var duplicate in definitions + .GroupBy(definition => new + { + definition.Transport, + definition.Port + }) + .Where(group => group.Count() > 1)) + { + errors.Add( + $"Port {duplicate.Key.Port} is assigned to more than one {duplicate.Key.Transport} listener."); + } + + if (definitions.Any( + definition => definition.Transport == TrackingSocketTransport.Tcp && + definition.Port == settings.InternalHealthPort)) + { + errors.Add( + "InternalHealthPort cannot share a port with a TCP tracking listener."); + } + + ThrowIfInvalid(errors); + return new TrackingListenerPlan(definitions); + } + + private static void ValidatePositive( + int value, + string fieldName, + ICollection errors) + { + if (value <= 0) + errors.Add($"UnitTrackingConfig.{fieldName} must be greater than zero."); + } + + private static void ValidateRange( + int value, + int minimum, + int maximum, + string fieldName, + ICollection errors) + { + if (value < minimum || value > maximum) + { + errors.Add( + $"UnitTrackingConfig.{fieldName} must be between {minimum} and {maximum}."); + } + } + + private static bool ValidatePort( + int port, + string fieldName, + ICollection errors) + { + if (port >= 1 && port <= 65535) + return true; + + errors.Add($"{fieldName} must be between 1 and 65535."); + return false; + } + + private static void ThrowIfInvalid(IReadOnlyCollection errors) + { + if (errors.Count > 0) + throw new TrackingGatewayConfigurationException(errors); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListener.cs b/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListener.cs new file mode 100644 index 000000000..7794551ca --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListener.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Listeners +{ + public interface ITrackingListener + { + TrackingListenerDefinition Definition { get; } + bool IsBound { get; } + Task Completion { get; } + + Task StartAsync(CancellationToken cancellationToken); + Task StopAsync(CancellationToken cancellationToken); + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListenerFactory.cs b/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListenerFactory.cs new file mode 100644 index 000000000..3da9ab1fb --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/ITrackingListenerFactory.cs @@ -0,0 +1,10 @@ +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Listeners +{ + public interface ITrackingListenerFactory + { + bool Supports(TrackingListenerDefinition definition); + ITrackingListener Create(TrackingListenerDefinition definition); + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/TcpTrackingListener.cs b/Workers/Resgrid.TrackerGateway/Listeners/TcpTrackingListener.cs new file mode 100644 index 000000000..14c6effba --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/TcpTrackingListener.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Sessions; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class TcpTrackingListener : ITrackingListener + { + private readonly TrackingGatewaySettings _settings; + private readonly TrackingConnectionAdmission _connectionAdmission; + private readonly ITrackingTransportSessionHandler _sessionHandler; + private readonly TrackingGatewayMetrics _metrics; + private readonly ILogger _logger; + private readonly ConcurrentDictionary + _activeConnections = + new ConcurrentDictionary(); + private CancellationTokenSource _acceptCancellation; + private CancellationTokenSource _sessionCancellation; + private Socket _listenerSocket; + private Task _completion = Task.CompletedTask; + private long _connectionSequence; + private int _isBound; + private int _started; + private int _stopping; + + public TcpTrackingListener( + TrackingListenerDefinition definition, + TrackingGatewaySettings settings, + TrackingConnectionAdmission connectionAdmission, + ITrackingTransportSessionHandler sessionHandler, + TrackingGatewayMetrics metrics, + ILogger logger) + { + Definition = definition ?? + throw new ArgumentNullException(nameof(definition)); + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); + _connectionAdmission = connectionAdmission ?? + throw new ArgumentNullException(nameof(connectionAdmission)); + _sessionHandler = sessionHandler ?? + throw new ArgumentNullException(nameof(sessionHandler)); + _metrics = metrics ?? + throw new ArgumentNullException(nameof(metrics)); + _logger = logger ?? + throw new ArgumentNullException(nameof(logger)); + } + + public TrackingListenerDefinition Definition { get; } + public bool IsBound => Volatile.Read(ref _isBound) != 0; + public Task Completion => _completion; + + public Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.Exchange(ref _started, 1) != 0) + throw new InvalidOperationException("The TCP listener has already started."); + + _acceptCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _sessionCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var listenerSocket = new Socket( + AddressFamily.InterNetworkV6, + SocketType.Stream, + ProtocolType.Tcp) + { + DualMode = true + }; + + try + { + listenerSocket.Bind( + new IPEndPoint(IPAddress.IPv6Any, Definition.Port)); + listenerSocket.Listen( + Math.Min(_settings.MaxConnections, 512)); + _listenerSocket = listenerSocket; + Volatile.Write(ref _isBound, 1); + _completion = AcceptLoopAsync( + listenerSocket, + _acceptCancellation.Token); + return Task.CompletedTask; + } + catch + { + listenerSocket.Dispose(); + _acceptCancellation.Dispose(); + _sessionCancellation.Dispose(); + throw; + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _stopping, 1) != 0) + return; + + _acceptCancellation?.Cancel(); + _listenerSocket?.Dispose(); + Volatile.Write(ref _isBound, 0); + + try + { + await _completion; + } + catch (OperationCanceledException) + when (_acceptCancellation?.IsCancellationRequested == true) + { + } + + var activeConnections = GetActiveConnectionSnapshot(); + var drainTask = Task.WhenAll( + activeConnections.Select(connection => connection.Completion)); + try + { + await drainTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + "TCP listener shutdown timed out for {ProtocolKey} on port {Port}; closing {ConnectionCount} active sessions.", + Definition.ProtocolKey, + Definition.Port, + activeConnections.Count); + _metrics.RecordForcedShutdown(Definition); + _sessionCancellation?.Cancel(); + foreach (var connection in activeConnections) + connection.Socket.Dispose(); + } + finally + { + _sessionCancellation?.Cancel(); + _listenerSocket?.Dispose(); + Volatile.Write(ref _isBound, 0); + } + } + + private async Task AcceptLoopAsync( + Socket listenerSocket, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + Socket connectionSocket; + try + { + connectionSocket = + await listenerSocket.AcceptAsync(cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (ObjectDisposedException) + when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (SocketException) + when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (SocketException ex) + { + _logger.LogWarning( + ex, + "TCP tracking accept failed transiently for {ProtocolKey} on port {Port}; continuing to accept.", + Definition.ProtocolKey, + Definition.Port); + continue; + } + + TrackingConnectionLease admissionLease = null; + EndPoint remoteEndPoint = null; + try + { + connectionSocket.NoDelay = true; + remoteEndPoint = connectionSocket.RemoteEndPoint; + var remoteAddress = (remoteEndPoint as IPEndPoint)?.Address; + if (!_connectionAdmission.TryAcquire( + remoteAddress, + out admissionLease)) + { + _logger.LogDebug( + "TCP tracking connection rejected by admission limits from {RemoteEndpoint}.", + TrackingEndpointMasker.Mask(remoteEndPoint)); + _metrics.ConnectionRejected(Definition); + connectionSocket.Dispose(); + continue; + } + + var connectionId = + Interlocked.Increment(ref _connectionSequence); + var activeConnection = + new ActiveConnection(connectionSocket); + _activeConnections.TryAdd(connectionId, activeConnection); + _metrics.ConnectionStarted(Definition); + _ = ProcessConnectionAsync( + connectionId, + activeConnection, + admissionLease, + remoteEndPoint, + _sessionCancellation.Token); + } + catch (SocketException ex) + { + _logger.LogDebug( + ex, + "TCP tracking connection setup failed for {ProtocolKey} from {RemoteEndpoint}; closing the connection.", + Definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + admissionLease?.Dispose(); + connectionSocket.Dispose(); + } + catch (ObjectDisposedException ex) + { + _logger.LogDebug( + ex, + "TCP tracking connection setup failed for {ProtocolKey} from {RemoteEndpoint}; closing the connection.", + Definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + admissionLease?.Dispose(); + connectionSocket.Dispose(); + } + } + } + + private async Task ProcessConnectionAsync( + long connectionId, + ActiveConnection activeConnection, + TrackingConnectionLease admissionLease, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + var startedTimestamp = Stopwatch.GetTimestamp(); + var connectionOutcome = "completed"; + try + { + using var stream = new NetworkStream( + activeConnection.Socket, + ownsSocket: false); + await _sessionHandler.HandleTcpAsync( + Definition, + stream, + remoteEndPoint, + cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + connectionOutcome = "cancelled"; + } + catch (Exception ex) + { + connectionOutcome = "failed"; + _logger.LogWarning( + ex, + "TCP tracking session failed for {ProtocolKey} from {RemoteEndpoint}.", + Definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + } + finally + { + activeConnection.Socket.Dispose(); + admissionLease.Dispose(); + _activeConnections.TryRemove(connectionId, out _); + _metrics.ConnectionCompleted( + Definition, + connectionOutcome); + _metrics.ObserveSessionDuration( + Definition.ProtocolKey, + Stopwatch.GetElapsedTime(startedTimestamp)); + activeConnection.CompletionSource.TrySetResult(); + } + } + + private IReadOnlyCollection + GetActiveConnectionSnapshot() + { + return _activeConnections.Values.ToList(); + } + + private sealed class ActiveConnection + { + public ActiveConnection(Socket socket) + { + Socket = socket; + CompletionSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + public Socket Socket { get; } + public TaskCompletionSource CompletionSource { get; } + public Task Completion => CompletionSource.Task; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/TrackingConnectionAdmission.cs b/Workers/Resgrid.TrackerGateway/Listeners/TrackingConnectionAdmission.cs new file mode 100644 index 000000000..20ad8fb90 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/TrackingConnectionAdmission.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class TrackingConnectionAdmission + { + private readonly object _syncRoot = new object(); + private readonly int _maximumConnections; + private readonly int _maximumConnectionsPerIp; + private readonly Dictionary _connectionsByAddress = + new Dictionary(); + private int _connectionCount; + + public TrackingConnectionAdmission(TrackingGatewaySettings settings) + : this( + settings?.MaxConnections ?? + throw new ArgumentNullException(nameof(settings)), + settings.MaxConnectionsPerIp) + { + } + + public TrackingConnectionAdmission( + int maximumConnections, + int maximumConnectionsPerIp) + { + if (maximumConnections <= 0) + throw new ArgumentOutOfRangeException(nameof(maximumConnections)); + if (maximumConnectionsPerIp <= 0 || + maximumConnectionsPerIp > maximumConnections) + { + throw new ArgumentOutOfRangeException( + nameof(maximumConnectionsPerIp)); + } + + _maximumConnections = maximumConnections; + _maximumConnectionsPerIp = maximumConnectionsPerIp; + } + + public int CurrentConnections + { + get + { + lock (_syncRoot) + { + return _connectionCount; + } + } + } + + public bool TryAcquire( + IPAddress remoteAddress, + out TrackingConnectionLease lease) + { + lease = null; + if (remoteAddress == null) + return false; + + var normalizedAddress = Normalize(remoteAddress); + lock (_syncRoot) + { + _connectionsByAddress.TryGetValue( + normalizedAddress, + out var addressCount); + if (_connectionCount >= _maximumConnections || + addressCount >= _maximumConnectionsPerIp) + return false; + + _connectionCount++; + _connectionsByAddress[normalizedAddress] = addressCount + 1; + } + + lease = new TrackingConnectionLease( + () => Release(normalizedAddress)); + return true; + } + + private void Release(IPAddress remoteAddress) + { + lock (_syncRoot) + { + if (!_connectionsByAddress.TryGetValue( + remoteAddress, + out var addressCount)) + return; + + if (addressCount <= 1) + _connectionsByAddress.Remove(remoteAddress); + else + _connectionsByAddress[remoteAddress] = addressCount - 1; + + if (_connectionCount > 0) + _connectionCount--; + } + } + + private static IPAddress Normalize(IPAddress address) + { + return address.IsIPv4MappedToIPv6 + ? address.MapToIPv4() + : address; + } + } + + public sealed class TrackingConnectionLease : IDisposable + { + private Action _release; + + internal TrackingConnectionLease(Action release) + { + _release = release; + } + + public void Dispose() + { + Interlocked.Exchange(ref _release, null)?.Invoke(); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/TrackingEndpointMasker.cs b/Workers/Resgrid.TrackerGateway/Listeners/TrackingEndpointMasker.cs new file mode 100644 index 000000000..e7b6ecd71 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/TrackingEndpointMasker.cs @@ -0,0 +1,34 @@ +using System; +using System.Net; +using System.Net.Sockets; + +namespace Resgrid.TrackerGateway.Listeners +{ + public static class TrackingEndpointMasker + { + public static string Mask(EndPoint endPoint) + { + if (endPoint is not IPEndPoint ipEndPoint) + return "unknown"; + + var address = ipEndPoint.Address.IsIPv4MappedToIPv6 + ? ipEndPoint.Address.MapToIPv4() + : ipEndPoint.Address; + var addressBytes = address.GetAddressBytes(); + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + addressBytes[3] = 0; + return $"{new IPAddress(addressBytes)}:{ipEndPoint.Port}"; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + Array.Clear(addressBytes, 8, 8); + return $"[{new IPAddress(addressBytes)}]:{ipEndPoint.Port}"; + } + + return "unknown"; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/TrackingListenerSupervisor.cs b/Workers/Resgrid.TrackerGateway/Listeners/TrackingListenerSupervisor.cs new file mode 100644 index 000000000..05b85c1cb --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/TrackingListenerSupervisor.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class TrackingListenerSupervisor : BackgroundService + { + private readonly TrackingListenerPlan _plan; + private readonly ITrackingListenerFactory _listenerFactory; + private readonly TrackingGatewaySettings _settings; + private readonly TrackingGatewayReadinessState _readiness; + private readonly ILogger _logger; + private readonly object _listenerSyncRoot = new object(); + private readonly List _listeners = + new List(); + private int _stopping; + + public TrackingListenerSupervisor( + TrackingListenerPlan plan, + ITrackingListenerFactory listenerFactory, + TrackingGatewaySettings settings, + TrackingGatewayReadinessState readiness, + ILogger logger) + { + _plan = plan; + _listenerFactory = listenerFactory; + _settings = settings; + _readiness = readiness; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _readiness.Initialize(_plan); + if (_plan.Listeners.Count == 0) + { + _logger.LogInformation( + "Tracker gateway is running without native socket listeners."); + return; + } + + try + { + var listeners = _plan.Listeners + .Select(_listenerFactory.Create) + .ToList(); + lock (_listenerSyncRoot) + { + _listeners.AddRange(listeners); + } + + foreach (var listener in listeners) + { + await listener.StartAsync(stoppingToken); + if (!listener.IsBound) + { + throw new InvalidOperationException( + $"Tracking listener did not report a bound state after startup: {listener.Definition}."); + } + + _readiness.MarkBound(listener.Definition); + _logger.LogInformation( + "Tracking listener bound for {ProtocolKey} over {Transport} on port {Port}.", + listener.Definition.ProtocolKey, + listener.Definition.Transport, + listener.Definition.Port); + } + + await Task.WhenAll(listeners.Select(listener => listener.Completion)); + if (Volatile.Read(ref _stopping) == 0 && + !stoppingToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "A tracking listener stopped unexpectedly."); + } + } + catch (OperationCanceledException) + when (stoppingToken.IsCancellationRequested || + Volatile.Read(ref _stopping) != 0) + { + } + catch (Exception ex) + { + _readiness.MarkFailed(); + _logger.LogError(ex, "Tracker gateway listener supervisor failed."); + throw; + } + finally + { + foreach (var listener in GetListenerSnapshot()) + _readiness.MarkStopped(listener.Definition); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Exchange(ref _stopping, 1); + _readiness.MarkStopping(); + + using var shutdownCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + shutdownCancellation.CancelAfter( + TimeSpan.FromSeconds(_settings.GracefulShutdownSeconds)); + + try + { + var listeners = GetListenerSnapshot(); + for (var index = listeners.Count - 1; index >= 0; index--) + { + try + { + await listeners[index].StopAsync( + shutdownCancellation.Token); + _readiness.MarkStopped( + listeners[index].Definition); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Tracking listener failed to stop cleanly for {ProtocolKey} over {Transport} on port {Port}; continuing shutdown.", + listeners[index].Definition.ProtocolKey, + listeners[index].Definition.Transport, + listeners[index].Definition.Port); + } + } + } + finally + { + await base.StopAsync(shutdownCancellation.Token); + } + } + + private IReadOnlyList GetListenerSnapshot() + { + lock (_listenerSyncRoot) + { + return _listeners.ToList(); + } + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/TrackingSocketListenerFactory.cs b/Workers/Resgrid.TrackerGateway/Listeners/TrackingSocketListenerFactory.cs new file mode 100644 index 000000000..993d9876c --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/TrackingSocketListenerFactory.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.Extensions.Logging; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Sessions; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class TrackingSocketListenerFactory : ITrackingListenerFactory + { + private readonly TrackingGatewaySettings _settings; + private readonly TrackingConnectionAdmission _connectionAdmission; + private readonly ITrackingTransportSessionHandler _sessionHandler; + private readonly TrackingGatewayMetrics _metrics; + private readonly ILoggerFactory _loggerFactory; + + public TrackingSocketListenerFactory( + TrackingGatewaySettings settings, + TrackingConnectionAdmission connectionAdmission, + ITrackingTransportSessionHandler sessionHandler, + TrackingGatewayMetrics metrics, + ILoggerFactory loggerFactory) + { + _settings = settings; + _connectionAdmission = connectionAdmission; + _sessionHandler = sessionHandler; + _metrics = metrics; + _loggerFactory = loggerFactory; + } + + public bool Supports(TrackingListenerDefinition definition) + { + return definition != null && + (definition.Transport == TrackingSocketTransport.Tcp || + definition.Transport == TrackingSocketTransport.Udp); + } + + public ITrackingListener Create(TrackingListenerDefinition definition) + { + if (!Supports(definition)) + { + throw new NotSupportedException( + $"No socket listener supports {definition}."); + } + + return definition.Transport switch + { + TrackingSocketTransport.Tcp => new TcpTrackingListener( + definition, + _settings, + _connectionAdmission, + _sessionHandler, + _metrics, + _loggerFactory.CreateLogger()), + TrackingSocketTransport.Udp => new UdpTrackingListener( + definition, + _settings, + _connectionAdmission, + _sessionHandler, + _metrics, + _loggerFactory.CreateLogger()), + _ => throw new NotSupportedException( + $"No socket listener supports {definition}.") + }; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/UdpTrackingListener.cs b/Workers/Resgrid.TrackerGateway/Listeners/UdpTrackingListener.cs new file mode 100644 index 000000000..f24f59a80 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/UdpTrackingListener.cs @@ -0,0 +1,350 @@ +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Sessions; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class UdpTrackingListener : ITrackingListener + { + private readonly TrackingGatewaySettings _settings; + private readonly TrackingConnectionAdmission _connectionAdmission; + private readonly ITrackingTransportSessionHandler _sessionHandler; + private readonly TrackingGatewayMetrics _metrics; + private readonly ILogger _logger; + private readonly ConcurrentDictionary + _activeDatagrams = + new ConcurrentDictionary(); + private CancellationTokenSource _receiveCancellation; + private CancellationTokenSource _sessionCancellation; + private Socket _listenerSocket; + private Task _completion = Task.CompletedTask; + private long _datagramSequence; + private int _isBound; + private int _started; + private int _stopping; + + public UdpTrackingListener( + TrackingListenerDefinition definition, + TrackingGatewaySettings settings, + TrackingConnectionAdmission connectionAdmission, + ITrackingTransportSessionHandler sessionHandler, + TrackingGatewayMetrics metrics, + ILogger logger) + { + Definition = definition ?? + throw new ArgumentNullException(nameof(definition)); + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); + _connectionAdmission = connectionAdmission ?? + throw new ArgumentNullException(nameof(connectionAdmission)); + _sessionHandler = sessionHandler ?? + throw new ArgumentNullException(nameof(sessionHandler)); + _metrics = metrics ?? + throw new ArgumentNullException(nameof(metrics)); + _logger = logger ?? + throw new ArgumentNullException(nameof(logger)); + } + + public TrackingListenerDefinition Definition { get; } + public bool IsBound => Volatile.Read(ref _isBound) != 0; + public Task Completion => _completion; + + public Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.Exchange(ref _started, 1) != 0) + throw new InvalidOperationException("The UDP listener has already started."); + + _receiveCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _sessionCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var listenerSocket = new Socket( + AddressFamily.InterNetworkV6, + SocketType.Dgram, + ProtocolType.Udp) + { + DualMode = true + }; + + try + { + DisableConnectionResetReporting(listenerSocket); + listenerSocket.Bind( + new IPEndPoint(IPAddress.IPv6Any, Definition.Port)); + _listenerSocket = listenerSocket; + Volatile.Write(ref _isBound, 1); + _completion = ReceiveLoopAsync( + listenerSocket, + _receiveCancellation.Token); + return Task.CompletedTask; + } + catch + { + listenerSocket.Dispose(); + _receiveCancellation.Dispose(); + _sessionCancellation.Dispose(); + throw; + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref _stopping, 1) != 0) + return; + + _receiveCancellation?.Cancel(); + + try + { + await _completion; + } + catch (OperationCanceledException) + when (_receiveCancellation?.IsCancellationRequested == true) + { + } + + var activeDatagrams = GetActiveDatagramSnapshot(); + var drainTask = Task.WhenAll( + activeDatagrams.Select(datagram => datagram.Completion)); + try + { + await drainTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + "UDP listener shutdown timed out for {ProtocolKey} on port {Port}; canceling {DatagramCount} in-flight datagrams.", + Definition.ProtocolKey, + Definition.Port, + activeDatagrams.Count); + _metrics.RecordForcedShutdown(Definition); + _sessionCancellation?.Cancel(); + } + finally + { + _sessionCancellation?.Cancel(); + _listenerSocket?.Dispose(); + Volatile.Write(ref _isBound, 0); + } + } + + private async Task ReceiveLoopAsync( + Socket listenerSocket, + CancellationToken cancellationToken) + { + var receiveBuffer = ArrayPool.Shared.Rent( + _settings.MaxFrameBytes + 1); + try + { + while (!cancellationToken.IsCancellationRequested) + { + SocketReceiveFromResult result; + try + { + result = await listenerSocket.ReceiveFromAsync( + receiveBuffer.AsMemory( + 0, + _settings.MaxFrameBytes + 1), + SocketFlags.None, + new IPEndPoint(IPAddress.IPv6Any, 0), + cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (SocketException ex) + when (ex.SocketErrorCode == SocketError.MessageSize) + { + _logger.LogDebug( + "UDP tracking datagram exceeded the configured frame limit for {ProtocolKey}.", + Definition.ProtocolKey); + _metrics.RecordParseFailure( + Definition.ProtocolKey, + "frame-too-large"); + continue; + } + catch (SocketException ex) + { + _logger.LogDebug( + ex, + "UDP tracking receive failed transiently for {ProtocolKey} on port {Port} ({SocketError}); continuing to receive.", + Definition.ProtocolKey, + Definition.Port, + ex.SocketErrorCode); + continue; + } + + if (result.ReceivedBytes <= 0) + continue; + if (result.ReceivedBytes > _settings.MaxFrameBytes) + { + _logger.LogDebug( + "UDP tracking datagram exceeded the configured frame limit for {ProtocolKey} from {RemoteEndpoint}.", + Definition.ProtocolKey, + TrackingEndpointMasker.Mask(result.RemoteEndPoint)); + _metrics.RecordParseFailure( + Definition.ProtocolKey, + "frame-too-large"); + continue; + } + + var remoteAddress = + (result.RemoteEndPoint as IPEndPoint)?.Address; + if (!_connectionAdmission.TryAcquire( + remoteAddress, + out var admissionLease)) + { + _logger.LogDebug( + "UDP tracking datagram rejected by admission limits from {RemoteEndpoint}.", + TrackingEndpointMasker.Mask(result.RemoteEndPoint)); + _metrics.ConnectionRejected(Definition); + continue; + } + + var datagram = receiveBuffer + .AsMemory(0, result.ReceivedBytes) + .ToArray(); + var datagramId = + Interlocked.Increment(ref _datagramSequence); + var activeDatagram = new ActiveDatagram(); + _activeDatagrams.TryAdd(datagramId, activeDatagram); + _metrics.ConnectionStarted(Definition); + _ = ProcessDatagramAsync( + datagramId, + activeDatagram, + admissionLease, + datagram, + result.RemoteEndPoint, + _sessionCancellation.Token); + } + } + finally + { + ArrayPool.Shared.Return( + receiveBuffer, + clearArray: true); + } + } + + private void DisableConnectionResetReporting( + Socket socket) + { + if (!OperatingSystem.IsWindows()) + return; + + try + { + socket.IOControl( + -1744830452, + new byte[4], + null); + } + catch (SocketException ex) + { + _logger.LogDebug( + ex, + "UDP connection-reset reporting could not be disabled for {ProtocolKey} on port {Port}.", + Definition.ProtocolKey, + Definition.Port); + } + catch (PlatformNotSupportedException) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task ProcessDatagramAsync( + long datagramId, + ActiveDatagram activeDatagram, + TrackingConnectionLease admissionLease, + ReadOnlyMemory datagram, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + var connectionOutcome = "completed"; + try + { + var response = await _sessionHandler.HandleUdpAsync( + Definition, + datagram, + remoteEndPoint, + cancellationToken); + if (!response.IsEmpty) + { + if (response.Length > _settings.MaxFrameBytes) + { + _logger.LogWarning( + "UDP tracking response exceeded the configured frame limit for {ProtocolKey}; response was dropped.", + Definition.ProtocolKey); + } + else + { + await _listenerSocket.SendToAsync( + response, + SocketFlags.None, + remoteEndPoint, + cancellationToken); + } + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + connectionOutcome = "cancelled"; + } + catch (Exception ex) + { + connectionOutcome = "failed"; + _logger.LogWarning( + ex, + "UDP tracking datagram failed for {ProtocolKey} from {RemoteEndpoint}.", + Definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + } + finally + { + admissionLease.Dispose(); + _activeDatagrams.TryRemove(datagramId, out _); + _metrics.ConnectionCompleted( + Definition, + connectionOutcome); + activeDatagram.CompletionSource.TrySetResult(); + } + } + + private IReadOnlyCollection + GetActiveDatagramSnapshot() + { + return _activeDatagrams.Values.ToList(); + } + + private sealed class ActiveDatagram + { + public ActiveDatagram() + { + CompletionSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + public TaskCompletionSource CompletionSource { get; } + public Task Completion => CompletionSource.Task; + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Listeners/UnavailableTrackingListenerFactory.cs b/Workers/Resgrid.TrackerGateway/Listeners/UnavailableTrackingListenerFactory.cs new file mode 100644 index 000000000..bdcfb6999 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Listeners/UnavailableTrackingListenerFactory.cs @@ -0,0 +1,19 @@ +using System; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Listeners +{ + public sealed class UnavailableTrackingListenerFactory : ITrackingListenerFactory + { + public bool Supports(TrackingListenerDefinition definition) + { + return false; + } + + public ITrackingListener Create(TrackingListenerDefinition definition) + { + throw new InvalidOperationException( + $"No socket listener implementation is registered for {definition}."); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Program.cs b/Workers/Resgrid.TrackerGateway/Program.cs new file mode 100644 index 000000000..98bbd551e --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Program.cs @@ -0,0 +1,98 @@ +using System.Linq; +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Config; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; +using Resgrid.TrackerGateway.Sessions; + +var builder = WebApplication.CreateBuilder(args); + +ConfigProcessor.LoadAndProcessConfig( + builder.Configuration["AppOptions:ConfigPath"]); +ConfigProcessor.LoadAndProcessEnvVariables( + builder.Configuration.AsEnumerable()); + +var gatewaySettings = TrackingGatewaySettings.FromCurrentConfig(); + +builder.Host.UseServiceProviderFactory( + new AutofacServiceProviderFactory()); +builder.Host.ConfigureContainer( + (_, containerBuilder) => Bootstrapper.ConfigureContainer(containerBuilder)); +builder.WebHost.ConfigureKestrel( + options => options.ListenAnyIP(gatewaySettings.InternalHealthPort)); + +builder.Services.AddSingleton(gatewaySettings); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + serviceProvider => + { + var moduleRegistry = serviceProvider + .GetRequiredService(); + TrackingProtocolCatalogValidator.Validate( + moduleRegistry); + return serviceProvider + .GetRequiredService() + .Build( + serviceProvider.GetRequiredService(), + moduleRegistry, + serviceProvider.GetRequiredService()); + }); +builder.Services.AddHostedService(); +builder.Services.AddHealthChecks() + .AddCheck( + "tracker_gateway_live", + tags: new[] { "live" }) + .AddCheck( + "tracker_gateway_ready", + tags: new[] { "ready" }); + +var app = builder.Build(); + +var listenerPlan = app.Services.GetRequiredService(); +app.Services.GetRequiredService() + .Initialize(listenerPlan); + +app.MapHealthChecks( + "/health/live", + new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live") + }); +app.MapHealthChecks( + "/health/ready", + new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("ready") + }); +app.MapGet( + "/metrics", + (TrackingGatewayReadinessState readiness, + TrackingGatewayMetrics metrics, + TrackingGatewaySettings settings) => Results.Text( + TrackingGatewayMetricsWriter.Write( + readiness.GetSnapshot(), + metrics, + settings.MaxConnections), + "text/plain; version=0.0.4")); + +app.Run(); + +public partial class Program; diff --git a/Workers/Resgrid.TrackerGateway/Resgrid.TrackerGateway.csproj b/Workers/Resgrid.TrackerGateway/Resgrid.TrackerGateway.csproj new file mode 100644 index 000000000..a0f4e3115 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Resgrid.TrackerGateway.csproj @@ -0,0 +1,21 @@ + + + net9.0 + Resgrid.TrackerGateway + Resgrid.TrackerGateway + Debug;Release;Docker + + + + + + + + + + + + + + + diff --git a/Workers/Resgrid.TrackerGateway/Sessions/ITrackingTransportSessionHandler.cs b/Workers/Resgrid.TrackerGateway/Sessions/ITrackingTransportSessionHandler.cs new file mode 100644 index 000000000..d20750e41 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Sessions/ITrackingTransportSessionHandler.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.TrackerGateway.Hosting; + +namespace Resgrid.TrackerGateway.Sessions +{ + public interface ITrackingTransportSessionHandler + { + Task HandleTcpAsync( + TrackingListenerDefinition definition, + Stream stream, + EndPoint remoteEndPoint, + CancellationToken cancellationToken); + + Task> HandleUdpAsync( + TrackingListenerDefinition definition, + ReadOnlyMemory datagram, + EndPoint remoteEndPoint, + CancellationToken cancellationToken); + } +} diff --git a/Workers/Resgrid.TrackerGateway/Sessions/TrackingSessionGenerationRegistry.cs b/Workers/Resgrid.TrackerGateway/Sessions/TrackingSessionGenerationRegistry.cs new file mode 100644 index 000000000..a1dfca336 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Sessions/TrackingSessionGenerationRegistry.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; + +namespace Resgrid.TrackerGateway.Sessions +{ + public sealed class TrackingSessionGenerationRegistry + { + private readonly ConcurrentDictionary + _activeSessions = + new ConcurrentDictionary( + StringComparer.Ordinal); + private long _generationSequence; + + public int ActiveCount => _activeSessions.Count; + + public TrackingSessionGenerationLease Activate( + string deviceId, + CancellationTokenSource sessionCancellation) + { + if (string.IsNullOrWhiteSpace(deviceId)) + throw new ArgumentNullException(nameof(deviceId)); + if (sessionCancellation == null) + throw new ArgumentNullException(nameof(sessionCancellation)); + + var normalizedDeviceId = deviceId.Trim(); + var activeSession = new ActiveSession( + Interlocked.Increment(ref _generationSequence), + sessionCancellation); + + while (true) + { + if (!_activeSessions.TryGetValue( + normalizedDeviceId, + out var previous)) + { + if (_activeSessions.TryAdd( + normalizedDeviceId, + activeSession)) + { + return new TrackingSessionGenerationLease( + this, + normalizedDeviceId, + activeSession); + } + + continue; + } + + if (!_activeSessions.TryUpdate( + normalizedDeviceId, + activeSession, + previous)) + continue; + + try + { + previous.Cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // Expected race: the displaced session's CTS was already disposed by its own + // teardown after it was replaced in the dictionary; nothing left to cancel. + } + + return new TrackingSessionGenerationLease( + this, + normalizedDeviceId, + activeSession); + } + } + + internal bool IsCurrent( + string deviceId, + ActiveSession session) + { + return _activeSessions.TryGetValue( + deviceId, + out var current) && + ReferenceEquals(current, session); + } + + internal void Release( + string deviceId, + ActiveSession session) + { + ((ICollection>) + _activeSessions).Remove( + new KeyValuePair( + deviceId, + session)); + } + + internal sealed class ActiveSession + { + public ActiveSession( + long generation, + CancellationTokenSource cancellation) + { + Generation = generation; + Cancellation = cancellation; + } + + public long Generation { get; } + public CancellationTokenSource Cancellation { get; } + } + } + + public sealed class TrackingSessionGenerationLease : IDisposable + { + private readonly TrackingSessionGenerationRegistry _registry; + private readonly string _deviceId; + private readonly TrackingSessionGenerationRegistry.ActiveSession _session; + private int _disposed; + + internal TrackingSessionGenerationLease( + TrackingSessionGenerationRegistry registry, + string deviceId, + TrackingSessionGenerationRegistry.ActiveSession session) + { + _registry = registry; + _deviceId = deviceId; + _session = session; + } + + public long Generation => _session.Generation; + + public bool IsCurrent => + Volatile.Read(ref _disposed) == 0 && + _registry.IsCurrent(_deviceId, _session); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + _registry.Release(_deviceId, _session); + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/Sessions/TrackingTransportSessionHandler.cs b/Workers/Resgrid.TrackerGateway/Sessions/TrackingTransportSessionHandler.cs new file mode 100644 index 000000000..98e687212 --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/Sessions/TrackingTransportSessionHandler.cs @@ -0,0 +1,987 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Microsoft.Extensions.Logging; +using Resgrid.Model.Services; +using Resgrid.Model.Tracking; +using Resgrid.Providers.Tracking.Protocols; +using Resgrid.TrackerGateway.Health; +using Resgrid.TrackerGateway.Hosting; +using Resgrid.TrackerGateway.Listeners; + +namespace Resgrid.TrackerGateway.Sessions +{ + public sealed class TrackingTransportSessionHandler : + ITrackingTransportSessionHandler + { + private readonly TrackingGatewaySettings _settings; + private readonly ITrackingProtocolModuleRegistry _moduleRegistry; + private readonly ILifetimeScope _lifetimeScope; + private readonly TrackingSessionGenerationRegistry _generationRegistry; + private readonly TrackingGatewayMetrics _metrics; + private readonly ILogger _logger; + + public TrackingTransportSessionHandler( + TrackingGatewaySettings settings, + ITrackingProtocolModuleRegistry moduleRegistry, + ILifetimeScope lifetimeScope, + TrackingSessionGenerationRegistry generationRegistry, + TrackingGatewayMetrics metrics, + ILogger logger) + { + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); + _moduleRegistry = moduleRegistry ?? + throw new ArgumentNullException(nameof(moduleRegistry)); + _lifetimeScope = lifetimeScope ?? + throw new ArgumentNullException(nameof(lifetimeScope)); + _generationRegistry = generationRegistry ?? + throw new ArgumentNullException(nameof(generationRegistry)); + _metrics = metrics ?? + throw new ArgumentNullException(nameof(metrics)); + _logger = logger ?? + throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleTcpAsync( + TrackingListenerDefinition definition, + Stream stream, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + if (definition == null) + throw new ArgumentNullException(nameof(definition)); + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (definition.Transport != TrackingSocketTransport.Tcp) + { + throw new ArgumentException( + "The TCP session handler requires a TCP listener definition.", + nameof(definition)); + } + + using var sessionCancellation = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + var sessionToken = sessionCancellation.Token; + var session = CreateProtocolSession( + definition, + remoteEndPoint, + sessionToken); + if (session == null) + return; + + using var scope = _lifetimeScope.BeginLifetimeScope(); + var authenticationService = + scope.Resolve(); + var ingressService = + scope.Resolve(); + var state = new TrackingSessionState(); + using var buffer = new PooledTrackingBuffer( + _settings.MaxFrameBytes); + + try + { + while (!sessionToken.IsCancellationRequested) + { + if (!buffer.EnsureWriteCapacity()) + { + LogFrameLimit(definition, remoteEndPoint); + return; + } + + var readLength = await ReadWithIdleTimeoutAsync( + stream, + buffer.WriteMemory, + sessionToken); + if (readLength <= 0) + return; + + buffer.Advance(readLength); + while (buffer.Length > 0) + { + var input = new ReadOnlySequence( + buffer.WrittenMemory); + var parseResult = Parse( + session, + ref input, + definition, + remoteEndPoint); + if (parseResult == null) + return; + + if (!TryGetSequenceOffsets( + input, + parseResult, + out var consumedLength, + out _)) + { + LogInvalidModuleResult( + definition, + remoteEndPoint); + return; + } + + if (parseResult.Status != + ProtocolParseStatus.NeedMoreData && + consumedLength > 0) + { + _metrics.ObserveFrameBytes( + definition.ProtocolKey, + consumedLength); + } + + if (parseResult.Status == + ProtocolParseStatus.NeedMoreData) + { + buffer.Consume(consumedLength); + if (!buffer.EnsureWriteCapacity()) + { + LogFrameLimit( + definition, + remoteEndPoint); + return; + } + + break; + } + + if (parseResult.Status == + ProtocolParseStatus.Malformed || + parseResult.Status == + ProtocolParseStatus.Unsupported || + parseResult.Status == + ProtocolParseStatus.CloseSession) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + ParseFailureReason( + parseResult.Status)); + _logger.LogDebug( + "TCP tracking session closed after protocol parse status {ParseStatus} for {ProtocolKey} from {RemoteEndpoint}.", + parseResult.Status, + definition.ProtocolKey, + TrackingEndpointMasker.Mask( + remoteEndPoint)); + return; + } + + if (consumedLength <= 0 || + !IsMessageValid(parseResult)) + { + LogInvalidModuleResult( + definition, + remoteEndPoint); + return; + } + + var outcome = await HandleMessageAsync( + definition, + session, + parseResult.Message, + remoteEndPoint, + state, + sessionCancellation, + authenticationService, + ingressService, + useGeneration: true, + sessionToken); + _metrics.RecordIngressMessage( + definition, + parseResult.Message, + outcome.Acceptance); + if (state.Generation != null && + !state.Generation.IsCurrent) + return; + + if (!TryBuildResponse( + session, + parseResult.Message, + outcome.Acceptance, + definition, + remoteEndPoint, + out var response)) + return; + + if (!response.IsEmpty) + { + await stream.WriteAsync( + response, + sessionToken); + } + + buffer.Consume(consumedLength); + if (outcome.CloseAfterResponse) + return; + } + } + } + catch (OperationCanceledException) + when (sessionCancellation.IsCancellationRequested) + { + } + finally + { + state.Generation?.Dispose(); + } + } + + public async Task> HandleUdpAsync( + TrackingListenerDefinition definition, + ReadOnlyMemory datagram, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + if (definition == null) + throw new ArgumentNullException(nameof(definition)); + if (definition.Transport != TrackingSocketTransport.Udp) + { + throw new ArgumentException( + "The UDP session handler requires a UDP listener definition.", + nameof(definition)); + } + if (datagram.IsEmpty || + datagram.Length > _settings.MaxFrameBytes) + { + if (datagram.Length > _settings.MaxFrameBytes) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + "frame-too-large"); + } + + return ReadOnlyMemory.Empty; + } + + _metrics.ObserveFrameBytes( + definition.ProtocolKey, + datagram.Length); + + using var sessionCancellation = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + var sessionToken = sessionCancellation.Token; + var session = CreateProtocolSession( + definition, + remoteEndPoint, + sessionToken); + if (session == null) + return ReadOnlyMemory.Empty; + + using var scope = _lifetimeScope.BeginLifetimeScope(); + var authenticationService = + scope.Resolve(); + var ingressService = + scope.Resolve(); + var input = new ReadOnlySequence(datagram); + var parseResult = Parse( + session, + ref input, + definition, + remoteEndPoint); + if (parseResult == null || + parseResult.Status == + ProtocolParseStatus.NeedMoreData || + parseResult.Status == + ProtocolParseStatus.Malformed || + parseResult.Status == + ProtocolParseStatus.Unsupported || + parseResult.Status == + ProtocolParseStatus.CloseSession) + { + if (parseResult != null) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + parseResult.Status == + ProtocolParseStatus.NeedMoreData + ? "incomplete-datagram" + : ParseFailureReason( + parseResult.Status)); + } + + return ReadOnlyMemory.Empty; + } + + if (!TryGetSequenceOffsets( + input, + parseResult, + out var consumedLength, + out _) || + consumedLength != datagram.Length || + !IsMessageValid(parseResult)) + { + LogInvalidModuleResult( + definition, + remoteEndPoint); + return ReadOnlyMemory.Empty; + } + + var outcome = await HandleMessageAsync( + definition, + session, + parseResult.Message, + remoteEndPoint, + new TrackingSessionState(), + sessionCancellation, + authenticationService, + ingressService, + useGeneration: false, + sessionToken); + _metrics.RecordIngressMessage( + definition, + parseResult.Message, + outcome.Acceptance); + return TryBuildResponse( + session, + parseResult.Message, + outcome.Acceptance, + definition, + remoteEndPoint, + out var response) + ? response + : ReadOnlyMemory.Empty; + } + + private ITrackingProtocolSession CreateProtocolSession( + TrackingListenerDefinition definition, + EndPoint remoteEndPoint, + CancellationToken cancellationToken) + { + try + { + var module = _moduleRegistry.Resolve( + definition.ProtocolKey, + definition.Transport); + return module.CreateSession( + new TrackingSessionContext + { + SessionId = Guid.NewGuid().ToString("N"), + Transport = definition.Transport, + RemoteEndPoint = remoteEndPoint, + ConnectedOnUtc = DateTime.UtcNow, + MaxFrameBytes = _settings.MaxFrameBytes, + CancellationToken = cancellationToken + }); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Unable to create tracking protocol session for {ProtocolKey} over {Transport} from {RemoteEndpoint}.", + definition.ProtocolKey, + definition.Transport, + TrackingEndpointMasker.Mask(remoteEndPoint)); + return null; + } + } + + private ProtocolParseResult Parse( + ITrackingProtocolSession session, + ref ReadOnlySequence input, + TrackingListenerDefinition definition, + EndPoint remoteEndPoint) + { + try + { + var parseResult = session.Parse(ref input); + if (parseResult == null) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + "invalid-result"); + } + + return parseResult; + } + catch (Exception ex) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + "parser-exception"); + _logger.LogWarning( + ex, + "Tracking protocol parser failed for {ProtocolKey} over {Transport} from {RemoteEndpoint}.", + definition.ProtocolKey, + definition.Transport, + TrackingEndpointMasker.Mask(remoteEndPoint)); + return null; + } + } + + private async Task ReadWithIdleTimeoutAsync( + Stream stream, + Memory buffer, + CancellationToken sessionToken) + { + using var idleCancellation = + CancellationTokenSource.CreateLinkedTokenSource( + sessionToken); + idleCancellation.CancelAfter( + TimeSpan.FromSeconds( + _settings.TcpIdleTimeoutSeconds)); + + try + { + return await stream.ReadAsync( + buffer, + idleCancellation.Token); + } + catch (OperationCanceledException) + when (!sessionToken.IsCancellationRequested && + idleCancellation.IsCancellationRequested) + { + return -1; + } + } + + private async Task HandleMessageAsync( + TrackingListenerDefinition definition, + ITrackingProtocolSession session, + ProtocolMessage message, + EndPoint remoteEndPoint, + TrackingSessionState state, + CancellationTokenSource sessionCancellation, + IUnitTrackingAuthenticationService authenticationService, + IUnitTrackingIngressService ingressService, + bool useGeneration, + CancellationToken cancellationToken) + { + var sourceResult = await ResolveSourceAsync( + definition, + message, + remoteEndPoint, + state, + sessionCancellation, + authenticationService, + useGeneration, + cancellationToken); + if (sourceResult.Status != + TrackingAcceptanceStatus.Accepted) + { + _metrics.RecordAuthFailure( + definition.Transport, + sourceResult.ReasonCode); + return new MessageHandlingOutcome( + sourceResult, + closeAfterResponse: true); + } + + if (message.MessageType == + ProtocolMessageType.Login) + { + return new MessageHandlingOutcome( + Accepted(0), + closeAfterResponse: false); + } + + if (message.MessageType == + ProtocolMessageType.Heartbeat) + { + try + { + var heartbeatResult = + await ingressService.AcceptHeartbeatAsync( + state.Source, + DateTime.UtcNow, + cancellationToken); + return FromIngressResult( + heartbeatResult); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Canonical tracking heartbeat acceptance failed for {ProtocolKey} from {RemoteEndpoint}.", + definition.ProtocolKey, + TrackingEndpointMasker.Mask( + remoteEndPoint)); + return new MessageHandlingOutcome( + Unavailable("ingress-unavailable"), + closeAfterResponse: true); + } + } + + if (message.MessageType != + ProtocolMessageType.Positions) + { + return new MessageHandlingOutcome( + Rejected("message-type-invalid"), + closeAfterResponse: true); + } + + try + { + if (session is + ITrackingProtocolPositionEnricher enricher) + { + enricher.EnrichPositions( + message, + state.Source.Device); + } + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Tracking profile enrichment failed for {ProtocolKey} from {RemoteEndpoint}.", + definition.ProtocolKey, + TrackingEndpointMasker.Mask( + remoteEndPoint)); + return new MessageHandlingOutcome( + Unavailable("profile-enrichment-failed"), + closeAfterResponse: true); + } + finally + { + message.ProtocolData = null; + } + + TrackingIngressResult ingressResult; + var publishStartedTimestamp = + Stopwatch.GetTimestamp(); + try + { + ingressResult = await ingressService.AcceptAsync( + state.Source, + message.Positions, + cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Canonical tracking ingress failed for {ProtocolKey} from {RemoteEndpoint}.", + definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + return new MessageHandlingOutcome( + Unavailable("ingress-unavailable"), + closeAfterResponse: true); + } + finally + { + _metrics.ObserveQueuePublishDuration( + definition.Transport, + Stopwatch.GetElapsedTime( + publishStartedTimestamp)); + } + + return FromIngressResult(ingressResult); + } + + private async Task ResolveSourceAsync( + TrackingListenerDefinition definition, + ProtocolMessage message, + EndPoint remoteEndPoint, + TrackingSessionState state, + CancellationTokenSource sessionCancellation, + IUnitTrackingAuthenticationService authenticationService, + bool useGeneration, + CancellationToken cancellationToken) + { + var identifier = + string.IsNullOrWhiteSpace(message.ExternalIdentifier) + ? state.Source?.ReportedDeviceIdentifier + : message.ExternalIdentifier.Trim(); + if (string.IsNullOrWhiteSpace(identifier)) + return Rejected("identifier-required"); + + Resgrid.Model.UnitTrackingDevice device; + try + { + device = await authenticationService + .GetEnabledDeviceByProtocolIdentifierAsync( + definition.ProtocolKey, + identifier, + cancellationToken); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Native tracking device mapping failed for {ProtocolKey} from {RemoteEndpoint}.", + definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + return Unavailable("mapping-unavailable"); + } + + if (device == null || + string.IsNullOrWhiteSpace( + device.UnitTrackingDeviceId)) + return Rejected("device-not-found"); + + if (state.Source?.Device != null && + !string.Equals( + state.Source.Device.UnitTrackingDeviceId, + device.UnitTrackingDeviceId, + StringComparison.Ordinal)) + return Rejected("identifier-changed"); + + var remoteAddress = + (remoteEndPoint as IPEndPoint)?.Address; + if (!UnitTrackingSourceNetworkPolicy.IsAllowed( + remoteAddress, + device.AllowedSourceCidrs)) + return Rejected("source-not-allowed"); + + if (useGeneration && state.Generation == null) + { + state.Generation = _generationRegistry.Activate( + device.UnitTrackingDeviceId, + sessionCancellation); + } + + state.Source = new AuthenticatedTrackingSource + { + Device = device, + ReportedDeviceIdentifier = identifier + }; + return Accepted(0); + } + + private bool TryBuildResponse( + ITrackingProtocolSession session, + ProtocolMessage message, + TrackingAcceptance acceptance, + TrackingListenerDefinition definition, + EndPoint remoteEndPoint, + out ReadOnlyMemory response) + { + response = ReadOnlyMemory.Empty; + if (!message.RequiresResponse) + return true; + + try + { + response = session.BuildResponse( + message, + acceptance); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Tracking response generation failed for {ProtocolKey} over {Transport} from {RemoteEndpoint}.", + definition.ProtocolKey, + definition.Transport, + TrackingEndpointMasker.Mask(remoteEndPoint)); + return false; + } + + if (response.Length > _settings.MaxFrameBytes) + { + _logger.LogWarning( + "Tracking response exceeded the configured frame limit for {ProtocolKey} over {Transport}; response was dropped.", + definition.ProtocolKey, + definition.Transport); + response = ReadOnlyMemory.Empty; + return false; + } + + if (response.IsEmpty && + acceptance.Status == + TrackingAcceptanceStatus.Accepted) + { + _logger.LogWarning( + "Tracking protocol module returned an empty required acceptance response for {ProtocolKey} over {Transport}.", + definition.ProtocolKey, + definition.Transport); + return false; + } + + return true; + } + + private bool IsMessageValid( + ProtocolParseResult parseResult) + { + if (parseResult.Message == null || + parseResult.Message.AcknowledgementToken.Length > + _settings.MaxFrameBytes) + return false; + + return parseResult.Status switch + { + ProtocolParseStatus.Login => + parseResult.Message.MessageType == + ProtocolMessageType.Login, + ProtocolParseStatus.Heartbeat => + parseResult.Message.MessageType == + ProtocolMessageType.Heartbeat, + ProtocolParseStatus.Positions => + parseResult.Message.MessageType == + ProtocolMessageType.Positions && + parseResult.Message.Positions != null, + _ => false + }; + } + + private static bool TryGetSequenceOffsets( + ReadOnlySequence input, + ProtocolParseResult parseResult, + out int consumedLength, + out int examinedLength) + { + consumedLength = 0; + examinedLength = 0; + if (parseResult == null) + return false; + + try + { + var consumed = input + .Slice(0, parseResult.Consumed) + .Length; + var examined = input + .Slice(0, parseResult.Examined) + .Length; + if (consumed > int.MaxValue || + examined > int.MaxValue || + consumed > examined) + return false; + + consumedLength = (int)consumed; + examinedLength = (int)examined; + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static TrackingAcceptance Accepted( + int acceptedPositions) + { + return new TrackingAcceptance + { + Status = TrackingAcceptanceStatus.Accepted, + AcceptedPositions = acceptedPositions + }; + } + + private static TrackingAcceptance Rejected( + string reasonCode) + { + return new TrackingAcceptance + { + Status = TrackingAcceptanceStatus.Rejected, + ReasonCode = reasonCode + }; + } + + private static TrackingAcceptance Unavailable( + string reasonCode) + { + return new TrackingAcceptance + { + Status = TrackingAcceptanceStatus.Unavailable, + ReasonCode = reasonCode + }; + } + + private static MessageHandlingOutcome FromIngressResult( + TrackingIngressResult ingressResult) + { + if (ingressResult == null || + ingressResult.Status == + TrackingIngressStatus.Unavailable) + { + return new MessageHandlingOutcome( + Unavailable("ingress-unavailable"), + closeAfterResponse: true); + } + + if (ingressResult.Status != + TrackingIngressStatus.Accepted) + { + return new MessageHandlingOutcome( + Rejected("ingress-invalid"), + closeAfterResponse: true); + } + + return new MessageHandlingOutcome( + Accepted(ingressResult.Accepted), + closeAfterResponse: false); + } + + private void LogFrameLimit( + TrackingListenerDefinition definition, + EndPoint remoteEndPoint) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + "frame-too-large"); + _logger.LogDebug( + "TCP tracking frame exceeded the configured limit for {ProtocolKey} from {RemoteEndpoint}.", + definition.ProtocolKey, + TrackingEndpointMasker.Mask(remoteEndPoint)); + } + + private void LogInvalidModuleResult( + TrackingListenerDefinition definition, + EndPoint remoteEndPoint) + { + _metrics.RecordParseFailure( + definition.ProtocolKey, + "invalid-result"); + _logger.LogWarning( + "Tracking protocol module returned an invalid parse result for {ProtocolKey} over {Transport} from {RemoteEndpoint}.", + definition.ProtocolKey, + definition.Transport, + TrackingEndpointMasker.Mask(remoteEndPoint)); + } + + private static string ParseFailureReason( + ProtocolParseStatus status) + { + return status switch + { + ProtocolParseStatus.Malformed => "malformed", + ProtocolParseStatus.Unsupported => "unsupported", + ProtocolParseStatus.CloseSession => "close-session", + _ => "invalid-result" + }; + } + + private sealed class TrackingSessionState + { + public AuthenticatedTrackingSource Source { get; set; } + public TrackingSessionGenerationLease Generation { get; set; } + } + + private sealed class MessageHandlingOutcome + { + public MessageHandlingOutcome( + TrackingAcceptance acceptance, + bool closeAfterResponse) + { + Acceptance = acceptance; + CloseAfterResponse = closeAfterResponse; + } + + public TrackingAcceptance Acceptance { get; } + public bool CloseAfterResponse { get; } + } + + private sealed class PooledTrackingBuffer : IDisposable + { + private const int InitialBufferBytes = 4096; + + private readonly int _maximumLength; + private byte[] _buffer; + private int _capacity; + private int _length; + + public PooledTrackingBuffer(int maximumLength) + { + if (maximumLength <= 0) + throw new ArgumentOutOfRangeException( + nameof(maximumLength)); + + _maximumLength = maximumLength; + _capacity = Math.Min( + InitialBufferBytes, + maximumLength); + _buffer = ArrayPool.Shared.Rent( + _capacity); + } + + public int Length => _length; + public ReadOnlyMemory WrittenMemory => + _buffer.AsMemory(0, _length); + public Memory WriteMemory => + _buffer.AsMemory( + _length, + _capacity - _length); + + public bool EnsureWriteCapacity() + { + if (_length < _capacity) + return true; + if (_capacity >= _maximumLength) + return false; + + var nextCapacity = + _capacity <= _maximumLength / 2 + ? _capacity * 2 + : _maximumLength; + var replacement = + ArrayPool.Shared.Rent( + nextCapacity); + Buffer.BlockCopy( + _buffer, + 0, + replacement, + 0, + _length); + ArrayPool.Shared.Return( + _buffer, + clearArray: true); + _buffer = replacement; + _capacity = nextCapacity; + return true; + } + + public void Advance(int count) + { + if (count < 0 || + count > _capacity - _length) + throw new ArgumentOutOfRangeException( + nameof(count)); + + _length += count; + } + + public void Consume(int count) + { + if (count < 0 || count > _length) + throw new ArgumentOutOfRangeException( + nameof(count)); + if (count == 0) + return; + + var remainingLength = _length - count; + if (remainingLength > 0) + Buffer.BlockCopy( + _buffer, + count, + _buffer, + 0, + remainingLength); + _length = remainingLength; + } + + public void Dispose() + { + var buffer = Interlocked.Exchange( + ref _buffer, + null); + if (buffer != null) + ArrayPool.Shared.Return( + buffer, + clearArray: true); + } + } + } +} diff --git a/Workers/Resgrid.TrackerGateway/localxpose-entrypoint.sh b/Workers/Resgrid.TrackerGateway/localxpose-entrypoint.sh new file mode 100644 index 000000000..7f6d3419e --- /dev/null +++ b/Workers/Resgrid.TrackerGateway/localxpose-entrypoint.sh @@ -0,0 +1,84 @@ +#!/usr/bin/dash +set -u + +LOCLX_BIN="${LOCLX_BIN:-/app/loclx}" +CONFIG_FILE="${LOCALXPOSE_CONFIG_FILE:-/tmp/localxpose-tunnels.yaml}" +REGION="${LOCALXPOSE_REGION:-us}" +TARGET_HOST="${LOCALXPOSE_TARGET_HOST:-localhost}" +RESTART_DELAY="${LOCALXPOSE_RESTART_DELAY:-5}" + +lookup_reserved() { + _pairs="${LOCALXPOSE_RESERVED_ENDPOINTS:-}" + _oldifs=$IFS + IFS=',' + for _pair in $_pairs; do + case "$_pair" in + "$1="*) + printf '%s' "${_pair#*=}" + IFS=$_oldifs + return 0 + ;; + esac + done + IFS=$_oldifs + return 0 +} + +enabled=$(printf '%s' "${LOCALXPOSE_ENABLED:-false}" | tr '[:upper:]' '[:lower:]') + +if [ "$enabled" = "true" ] || [ "$enabled" = "1" ] || [ "$enabled" = "yes" ]; then + if [ -z "${LOCALXPOSE_ACCESS_TOKEN:-}" ]; then + echo "[localxpose] enabled but LOCALXPOSE_ACCESS_TOKEN is empty; tunnels disabled." >&2 + elif [ ! -x "$LOCLX_BIN" ]; then + echo "[localxpose] $LOCLX_BIN is missing or not executable; tunnels disabled." >&2 + else + export ACCESS_TOKEN="$LOCALXPOSE_ACCESS_TOKEN" + export LX_ACCESS_TOKEN="$LOCALXPOSE_ACCESS_TOKEN" + + : > "$CONFIG_FILE" + tcp_count=0 + for port in ${LOCALXPOSE_TCP_PORTS:-5004 5023 5027}; do + reserved=$(lookup_reserved "$port") + { + printf 'tcp-%s:\n' "$port" + printf ' type: tcp\n' + printf ' region: %s\n' "$REGION" + printf ' to: %s:%s\n' "$TARGET_HOST" "$port" + if [ -n "$reserved" ]; then + printf ' reserved_endpoint: %s\n' "$reserved" + fi + printf '\n' + } >> "$CONFIG_FILE" + tcp_count=$((tcp_count + 1)) + done + + if [ "$tcp_count" -gt 0 ]; then + echo "[localxpose] starting $tcp_count TCP tunnel(s) from $CONFIG_FILE (region: $REGION)" + ( + while true; do + "$LOCLX_BIN" tunnel -r config -f "$CONFIG_FILE" + echo "[localxpose] TCP tunnel process exited; restarting in ${RESTART_DELAY}s." >&2 + sleep "$RESTART_DELAY" + done + ) & + fi + + for port in ${LOCALXPOSE_UDP_PORTS:-}; do + reserved=$(lookup_reserved "$port") + echo "[localxpose] starting UDP tunnel for ${TARGET_HOST}:${port} (region: $REGION)" + ( + while true; do + if [ -n "$reserved" ]; then + "$LOCLX_BIN" tunnel -r udp --port "$port" --to "${TARGET_HOST}:${port}" --reserved-endpoint "$reserved" + else + "$LOCLX_BIN" tunnel -r udp --port "$port" --to "${TARGET_HOST}:${port}" + fi + echo "[localxpose] UDP tunnel for port ${port} exited; restarting in ${RESTART_DELAY}s." >&2 + sleep "$RESTART_DELAY" + done + ) & + done + fi +fi + +exec ./wait diff --git a/Workers/Resgrid.Workers.Console/Commands/UnitTrackingRetentionCommand.cs b/Workers/Resgrid.Workers.Console/Commands/UnitTrackingRetentionCommand.cs new file mode 100644 index 000000000..082945133 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/UnitTrackingRetentionCommand.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class UnitTrackingRetentionCommand : + IQuidjiboCommand + { + public UnitTrackingRetentionCommand(int id) + { + Id = id; + } + + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata + { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 4e7ffa675..6e5e5d830 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -430,6 +430,28 @@ await Client.ScheduleAsync("PAR Evaluation", Cron.MinuteIntervals(1), stoppingToken); + if (UnitTrackingConfig.LocationRetentionWorkerEnabled) + { + var retentionHour = + UnitTrackingConfig + .LocationRetentionHourUtc >= 0 && + UnitTrackingConfig + .LocationRetentionHourUtc <= 23 + ? UnitTrackingConfig + .LocationRetentionHourUtc + : 4; + + _logger.Log( + LogLevel.Information, + "Scheduling Unit Tracking Location Retention"); + await Client.ScheduleAsync( + "Unit Tracking Location Retention", + new Commands + .UnitTrackingRetentionCommand(24), + Cron.Daily(retentionHour, 30), + stoppingToken); + } + if (SystemBehaviorConfig.Utf8CleanupEnabled) { var utf8CleanupHour = SystemBehaviorConfig.Utf8CleanupHourUtc >= 0 && SystemBehaviorConfig.Utf8CleanupHourUtc <= 23 diff --git a/Workers/Resgrid.Workers.Console/Tasks/UnitTrackingRetentionTask.cs b/Workers/Resgrid.Workers.Console/Tasks/UnitTrackingRetentionTask.cs new file mode 100644 index 000000000..fa171a012 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/UnitTrackingRetentionTask.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + public sealed class UnitTrackingRetentionTask : + IQuidjiboHandler + { + private readonly ILogger _logger; + + public UnitTrackingRetentionTask(ILogger logger) + { + _logger = logger ?? + throw new ArgumentNullException(nameof(logger)); + } + + public string Name => + "Unit Tracking Location Retention"; + public int Priority => 1; + + public async Task ProcessAsync( + UnitTrackingRetentionCommand command, + IQuidjiboProgress progress, + CancellationToken cancellationToken) + { + progress?.Report( + 1, + $"Starting the {Name} Task"); + try + { + var logic = + new UnitTrackingRetentionLogic(); + var result = await logic.Process( + cancellationToken); + if (!result.Item1) + throw new InvalidOperationException( + result.Item2); + + _logger.LogInformation( + "UnitTrackingRetention::{Summary}", + result.Item2); + progress?.Report( + 100, + $"Finishing the {Name} Task"); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/UnitTrackingRetentionLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/UnitTrackingRetentionLogic.cs new file mode 100644 index 000000000..faf3bb460 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/UnitTrackingRetentionLogic.cs @@ -0,0 +1,173 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + public sealed class UnitTrackingRetentionLogic + { + private const int DefaultBatchSize = 1000; + private const int DefaultMaximumRowsPerRun = 100000; + + private readonly IDepartmentsRepository + _departmentsRepository; + private readonly IDepartmentSettingsService + _departmentSettingsService; + private readonly IUnitLocationRetentionRepository + _retentionRepository; + + public UnitTrackingRetentionLogic() + : this( + Bootstrapper.GetKernel() + .Resolve(), + Bootstrapper.GetKernel() + .Resolve(), + Bootstrapper.GetKernel() + .Resolve()) + { + } + + public UnitTrackingRetentionLogic( + IDepartmentsRepository departmentsRepository, + IDepartmentSettingsService departmentSettingsService, + IUnitLocationRetentionRepository retentionRepository) + { + _departmentsRepository = + departmentsRepository ?? + throw new ArgumentNullException( + nameof(departmentsRepository)); + _departmentSettingsService = + departmentSettingsService ?? + throw new ArgumentNullException( + nameof(departmentSettingsService)); + _retentionRepository = + retentionRepository ?? + throw new ArgumentNullException( + nameof(retentionRepository)); + } + + public async Task> Process( + CancellationToken cancellationToken, + DateTime? utcNow = null) + { + if (!UnitTrackingConfig + .LocationRetentionWorkerEnabled) + { + return new Tuple( + true, + "Unit tracking location retention is disabled by configuration."); + } + + try + { + var runUtc = utcNow ?? DateTime.UtcNow; + if (runUtc.Kind != DateTimeKind.Utc) + { + throw new ArgumentException( + "The retention run timestamp must be UTC.", + nameof(utcNow)); + } + + var batchSize = + UnitTrackingConfig + .LocationRetentionBatchSize > 0 + ? UnitTrackingConfig + .LocationRetentionBatchSize + : DefaultBatchSize; + var maximumRows = + UnitTrackingConfig + .LocationRetentionMaxRowsPerRun > 0 + ? UnitTrackingConfig + .LocationRetentionMaxRowsPerRun + : DefaultMaximumRowsPerRun; + var departments = await _departmentsRepository + .GetAllAsync() + .WaitAsync(cancellationToken); + var departmentIds = departments? + .Where( + department => + department != null && + department.DepartmentId > 0) + .Select( + department => + department.DepartmentId) + .Distinct() + .OrderBy( + departmentId => + departmentId) + .ToList() ?? + new System.Collections.Generic.List(); + + var totalDeleted = 0; + var processedDepartments = 0; + foreach (var departmentId in departmentIds) + { + cancellationToken + .ThrowIfCancellationRequested(); + if (totalDeleted >= maximumRows) + break; + + var retentionDays = + await _departmentSettingsService + .GetHardwareTrackingLocationRetentionDaysAsync( + departmentId) + .WaitAsync(cancellationToken); + var cutoffUtc = + runUtc.AddDays(-retentionDays); + processedDepartments++; + + while (totalDeleted < maximumRows) + { + cancellationToken + .ThrowIfCancellationRequested(); + var requestedBatchSize = Math.Min( + batchSize, + maximumRows - totalDeleted); + var deleted = + await _retentionRepository + .DeleteHardwareLocationsBeforeAsync( + departmentId, + cutoffUtc, + requestedBatchSize, + cancellationToken); + if (deleted < 0 || + deleted > requestedBatchSize) + { + throw new InvalidOperationException( + "The unit-location retention repository returned an invalid deletion count."); + } + + totalDeleted += deleted; + if (deleted < requestedBatchSize) + break; + } + } + + var summary = + $"Unit tracking retention deleted {totalDeleted} hardware location(s) across {processedDepartments} department(s)."; + Logging.LogInfo(summary); + return new Tuple( + true, + summary); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex); + return new Tuple( + false, + ex.ToString()); + } + } + } +}