diff --git a/deploy/docker/readme.md b/deploy/docker/readme.md index f65a4fadbc..63cad8b403 100644 --- a/deploy/docker/readme.md +++ b/deploy/docker/readme.md @@ -93,6 +93,6 @@ This setup is certainly experimental because: - Alpine images must be used if you want to run for more than 3 seconds with said limit. The official OPC Publisher images are based on Alpine, but the ones built from the repository do not. - We disable meta data loading which has significant memory overhead. But you might need it. - The publisher publishes 11 nodes with value changes every 1 second. There is no buffering enabled, the messages are immediately dropped after encoding. -- The setup is continously under GC pressure and a good chunk of CPU is used for garbage collection and compression. +- The setup is continuously under GC pressure and a good chunk of CPU is used for garbage collection and compression. - Open Telemetry and Prometheus metrics are disabled as they consume a large amount of memory. - The .net GC is configured to be over aggressive which are not ideal for production scenarios. diff --git a/deploy/iotedge/eflow-setup.json b/deploy/iotedge/eflow-setup.json index 1aaff474ae..0c91ff27c1 100644 --- a/deploy/iotedge/eflow-setup.json +++ b/deploy/iotedge/eflow-setup.json @@ -41,7 +41,7 @@ "restartPolicy": "always", "settings": { "image": "mcr.microsoft.com/iotedge/opc-publisher:latest", - "createOptions": "{\"HostConfig\":{\"Binds\": [\"/tmp/host:/mount\"]},\"User\":\"root\",\"Cmd\":[\"--strict\",\"--pf=/mount/pn.json\",\"--pki=/mount/pki\",\"--cf\",\"--mm=PubSub\",\"--me=Json\",\"--cl=5\",\"--sl\",\"--aa\"]}" + "createOptions": "{\"HostConfig\":{\"Binds\": [\"/tmp/host:/mount\"],\"PortBindings\":{\"443/tcp\":[{\"HostPort\":\"8081\"}]}},\"User\":\"root\",\"Cmd\":[\"--strict\",\"--pf=/mount/pn.json\",\"--pki=/mount/pki\",\"--cf\",\"--mm=PubSub\",\"--me=Json\",\"--cl=5\",\"--sl\",\"--aa\"]}" } }, "opcplc": { diff --git a/docs/opc-publisher/api.md b/docs/opc-publisher/api.md index 371d70888f..6b4259d9a1 100644 --- a/docs/opc-publisher/api.md +++ b/docs/opc-publisher/api.md @@ -862,22 +862,98 @@ This section lists the diagnostics APi provided by OPC Publisher providing name. - -#### GetConnectionDiagnostic + +#### GetActiveConnections +``` +GET /v2/connections +``` + + +##### Description +Get all active connections the publisher is currently managing. + + +##### Responses + +|HTTP Code|Description|Schema| +|---|---|---| +|**200**|The operation was successful.|< [ConnectionModel](definitions.md#connectionmodel) > array| +|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| + + +##### Produces + +* `application/json` +* `application/x-msgpack` + + + +#### GetChannelDiagnostics +``` +GET /v2/diagnostics/channels +``` + + +##### Description +Get channel diagnostic information for all connections. + + +##### Responses + +|HTTP Code|Description|Schema| +|---|---|---| +|**200**|The operation was successful.|< [ChannelDiagnosticModel](definitions.md#channeldiagnosticmodel) > array| +|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| + + +##### Produces + +* `application/json` +* `application/x-msgpack` + + + +#### WatchChannelDiagnostics +``` +GET /v2/diagnostics/channels/watch +``` + + +##### Description +Get channel diagnostic information for all connections. The first set of diagnostics are the diagnostics active for all connections, continue reading to get updates. + + +##### Responses + +|HTTP Code|Description|Schema| +|---|---|---| +|**200**|The operation was successful.|[ChannelDiagnosticModelIAsyncEnumerable](definitions.md#channeldiagnosticmodeliasyncenumerable)| +|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| + + +##### Produces + +* `application/json` +* `application/x-msgpack` + + + +#### GetConnectionDiagnostics ``` GET /v2/diagnostics/connections ``` ##### Description -Get connection diagnostic information for all connections. The first set of diagnostics are the diagnostics active for all connections, continue reading to get updates. +Get diagnostics for all active clients including server and client session diagnostics. ##### Responses |HTTP Code|Description|Schema| |---|---|---| -|**200**|The operation was successful.|[ConnectionDiagnosticModelIAsyncEnumerable](definitions.md#connectiondiagnosticmodeliasyncenumerable)| +|**200**|The operation was successful.|[ConnectionDiagnosticsModelIAsyncEnumerable](definitions.md#connectiondiagnosticsmodeliasyncenumerable)| +|**408**|The operation timed out.|[ProblemDetails](definitions.md#problemdetails)| |**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| @@ -2935,10 +3011,11 @@ Remove the published nodes entry for a specific data set writer in a writer grou ##### Parameters -|Type|Name|Description|Schema| -|---|---|---|---| -|**Path**|**dataSetWriterGroup**
*required*|The writer group name of the entry|string| -|**Path**|**dataSetWriterId**
*required*|The data set writer identifer of the entry|string| +|Type|Name|Description|Schema|Default| +|---|---|---|---|---| +|**Path**|**dataSetWriterGroup**
*required*|The writer group name of the entry|string|| +|**Path**|**dataSetWriterId**
*required*|The data set writer identifer of the entry|string|| +|**Query**|**force**
*optional*|Force delete all writers even if more than one were found. Does not error when none were found.|boolean|`"false"`| ##### Responses diff --git a/docs/opc-publisher/commandline.md b/docs/opc-publisher/commandline.md index 4c67746ceb..e88865e2a8 100644 --- a/docs/opc-publisher/commandline.md +++ b/docs/opc-publisher/commandline.md @@ -554,6 +554,8 @@ Subscription settings interval setting of a subscription created with an OPC UA server. This value is used if an explicit publishing interval was not configured. + When setting `--op=0` the server decides the + lowest publishing interval it can support. Default: `1000`. Also can be set using `DefaultPublishingInterval` environment variable in the form of a duration @@ -570,13 +572,13 @@ Subscription settings Specifies the default number of publishing intervals before a keep alive is returned with the next queued publishing response. - Default: `10`. + Default: `0`. --slt, --lifetimecount, --DefaultLifetimeCount=VALUE Default subscription lifetime count which is a multiple of the keep alive counter and when reached instructs the server to declare the subscription invalid. - Default: `100`. + Default: `0`. --fd, --fetchdisplayname, --FetchOpcNodeDisplayName[=VALUE] Fetches the displayname for the monitored items subscribed if a display name was not specified @@ -594,6 +596,15 @@ Subscription settings queue size was not specified in the configuration. Default: `1` (for backwards compatibility). + --aq, --autosetqueuesize, --AutoSetQueueSizes[=VALUE] + (Experimental) Automatically calculate queue sizes + for monitored items using the subscription + publishing interval and the item's sampling rate + as max(configured queue size, roundup( + publishinginterval / samplinginterval)). + Note that the server might revise the queue size + down if it cannot handle the calculated size. + Default: `false` (disabled). --ndo, --nodiscardold, --DiscardNew[=VALUE] The publisher is using this as default value for the discard old setting of monitored item queue @@ -722,6 +733,14 @@ Subscription settings one per writer group. This setting overrides the `--dsg` option. Default: `False`. + --ipi, --ignorepublishingintervals, --IgnoreConfiguredPublishingIntervals[=VALUE] + Always use the publishing interval provided via + command line argument `--op` and ignore all + publishing interval settings in the + configuration. + Combine with `--op=0` to let the server use the + lowest publishing interval it can support. + Default: `False` (disabled). OPC UA Client configuration --------------------------- diff --git a/docs/opc-publisher/definitions.md b/docs/opc-publisher/definitions.md index 21dec17090..1c6a752af3 100644 --- a/docs/opc-publisher/definitions.md +++ b/docs/opc-publisher/definitions.md @@ -303,6 +303,46 @@ View to browse |**viewId**
*required*|Node of the view to browse
**Minimum length** : `1`|string| + +### ChannelDiagnosticModel +Channel diagnostics model + + +|Name|Description|Schema| +|---|---|---| +|**channelId**
*optional*|The id assigned to the channel that the token
belongs to.|integer (int64)| +|**client**
*optional*||[ChannelKeyModel](definitions.md#channelkeymodel)| +|**connection**
*required*||[ConnectionModel](definitions.md#connectionmodel)| +|**createdAt**
*optional*|When the token was created by the server
(refers to the server's clock).|string (date-time)| +|**lifetime**
*optional*|The lifetime of the token|string (date-span)| +|**localIpAddress**
*optional*|Effective local ip address used for the connection
if connected. Empty if disconnected.|string| +|**localPort**
*optional*|The effective local port used when connected,
null if disconnected.|integer (int32)| +|**remoteIpAddress**
*optional*|Effective remote ip address used for the
connection if connected. Empty if disconnected.|string| +|**remotePort**
*optional*|The effective remote port used when connected,
null if disconnected.|integer (int32)| +|**server**
*optional*||[ChannelKeyModel](definitions.md#channelkeymodel)| +|**sessionCreated**
*optional*|When the session was created.|string (date-time)| +|**sessionId**
*optional*|The session id if connected. Empty if disconnected.|string| +|**timeStamp**
*required*|Timestamp of the diagnostic information|string (date-time)| +|**tokenId**
*optional*|The id assigned to the token.|integer (int64)| + + + +### ChannelDiagnosticModelIAsyncEnumerable +*Type* : object + + + +### ChannelKeyModel +Channel token key model. + + +|Name|Description|Schema| +|---|---|---| +|**iv**
*required*|Iv|< integer (int32) > array| +|**key**
*required*|Key|< integer (int32) > array| +|**sigLen**
*required*|Signature length|integer (int32)| + + ### ConditionHandlingOptionsModel Condition handling options model @@ -314,8 +354,8 @@ Condition handling options model |**updateInterval**
*optional*|Time interval for sending pending interval updates in seconds.|integer (int32)| - -### ConnectionDiagnosticModelIAsyncEnumerable + +### ConnectionDiagnosticsModelIAsyncEnumerable *Type* : object diff --git a/docs/opc-publisher/openapi.json b/docs/opc-publisher/openapi.json index 771bdd1dd7..45bc382e77 100644 --- a/docs/opc-publisher/openapi.json +++ b/docs/opc-publisher/openapi.json @@ -1166,14 +1166,110 @@ } } }, + "/v2/connections": { + "get": { + "tags": [ + "Diagnostics" + ], + "summary": "GetActiveConnections", + "description": "Get all active connections the publisher is currently managing.", + "operationId": "GetActiveConnections", + "produces": [ + "application/json", + "application/x-msgpack" + ], + "responses": { + "200": { + "description": "The operation was successful.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ConnectionModel" + } + } + }, + "500": { + "description": "An unexpected error occurred", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + } + } + } + }, "/v2/diagnostics/connections": { "get": { "tags": [ "Diagnostics" ], - "summary": "GetConnectionDiagnostic", - "description": "Get connection diagnostic information for all connections. The first set of diagnostics are the diagnostics active for all connections, continue reading to get updates.", - "operationId": "GetConnectionDiagnostic", + "summary": "GetConnectionDiagnostics", + "description": "Get diagnostics for all active clients including server and client session diagnostics.", + "operationId": "GetConnectionDiagnostics", + "produces": [ + "application/json", + "application/x-msgpack" + ], + "responses": { + "200": { + "description": "The operation was successful.", + "schema": { + "$ref": "#/definitions/ConnectionDiagnosticsModelIAsyncEnumerable" + } + }, + "408": { + "description": "The operation timed out.", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "500": { + "description": "An unexpected error occurred", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + } + } + } + }, + "/v2/diagnostics/channels": { + "get": { + "tags": [ + "Diagnostics" + ], + "summary": "GetChannelDiagnostics", + "description": "Get channel diagnostic information for all connections.", + "operationId": "GetChannelDiagnostics", + "produces": [ + "application/json", + "application/x-msgpack" + ], + "responses": { + "200": { + "description": "The operation was successful.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ChannelDiagnosticModel" + } + } + }, + "500": { + "description": "An unexpected error occurred", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + } + } + } + }, + "/v2/diagnostics/channels/watch": { + "get": { + "tags": [ + "Diagnostics" + ], + "summary": "WatchChannelDiagnostics", + "description": "Get channel diagnostic information for all connections. The first set of diagnostics are the diagnostics active for all connections, continue reading to get updates.", + "operationId": "WatchChannelDiagnostics", "produces": [ "application/json", "application/x-msgpack" @@ -1182,7 +1278,7 @@ "200": { "description": "The operation was successful.", "schema": { - "$ref": "#/definitions/ConnectionDiagnosticModelIAsyncEnumerable" + "$ref": "#/definitions/ChannelDiagnosticModelIAsyncEnumerable" } }, "500": { @@ -3920,6 +4016,13 @@ "description": "The data set writer identifer of the entry", "required": true, "type": "string" + }, + { + "in": "query", + "name": "force", + "description": "Force delete all writers even if more than one were found. Does not error when none were found.", + "type": "boolean", + "default": false } ], "responses": { @@ -4904,6 +5007,115 @@ }, "additionalProperties": false }, + "ChannelDiagnosticModel": { + "description": "Channel diagnostics model", + "required": [ + "connection", + "timeStamp" + ], + "type": "object", + "properties": { + "timeStamp": { + "format": "date-time", + "description": "Timestamp of the diagnostic information", + "type": "string" + }, + "sessionId": { + "description": "The session id if connected. Empty if disconnected.", + "type": "string" + }, + "sessionCreated": { + "format": "date-time", + "description": "When the session was created.", + "type": "string" + }, + "connection": { + "$ref": "#/definitions/ConnectionModel" + }, + "remoteIpAddress": { + "description": "Effective remote ip address used for the\r\nconnection if connected. Empty if disconnected.", + "type": "string" + }, + "remotePort": { + "format": "int32", + "description": "The effective remote port used when connected,\r\nnull if disconnected.", + "type": "integer" + }, + "localIpAddress": { + "description": "Effective local ip address used for the connection\r\nif connected. Empty if disconnected.", + "type": "string" + }, + "localPort": { + "format": "int32", + "description": "The effective local port used when connected,\r\nnull if disconnected.", + "type": "integer" + }, + "channelId": { + "format": "int64", + "description": "The id assigned to the channel that the token\r\nbelongs to.", + "type": "integer" + }, + "tokenId": { + "format": "int64", + "description": "The id assigned to the token.", + "type": "integer" + }, + "createdAt": { + "format": "date-time", + "description": "When the token was created by the server\r\n(refers to the server's clock).", + "type": "string" + }, + "lifetime": { + "format": "date-span", + "description": "The lifetime of the token", + "type": "string" + }, + "client": { + "$ref": "#/definitions/ChannelKeyModel" + }, + "server": { + "$ref": "#/definitions/ChannelKeyModel" + } + }, + "additionalProperties": false + }, + "ChannelDiagnosticModelIAsyncEnumerable": { + "type": "object", + "additionalProperties": false + }, + "ChannelKeyModel": { + "description": "Channel token key model.", + "required": [ + "iv", + "key", + "sigLen" + ], + "type": "object", + "properties": { + "iv": { + "description": "Iv", + "type": "array", + "items": { + "format": "int32", + "type": "integer" + } + }, + "key": { + "description": "Key", + "type": "array", + "items": { + "format": "int32", + "type": "integer" + } + }, + "sigLen": { + "format": "int32", + "description": "Signature length", + "type": "integer" + } + }, + "additionalProperties": false + }, "ConditionHandlingOptionsModel": { "description": "Condition handling options model", "type": "object", @@ -4921,7 +5133,7 @@ }, "additionalProperties": false }, - "ConnectionDiagnosticModelIAsyncEnumerable": { + "ConnectionDiagnosticsModelIAsyncEnumerable": { "type": "object", "additionalProperties": false }, diff --git a/docs/opc-publisher/readme.md b/docs/opc-publisher/readme.md index 5103a56272..dd8d93951d 100644 --- a/docs/opc-publisher/readme.md +++ b/docs/opc-publisher/readme.md @@ -42,6 +42,8 @@ Here you find information about - [Calling the Direct Methods API](#calling-the-direct-methods-api) - [Calling the API over HTTP](#calling-the-api-over-http) - [JSON encoding](#json-encoding) + - [Node Ids](#node-ids) + - [Browse paths](#browse-paths) - [Discovering OPC UA servers with OPC Publisher](#discovering-opc-ua-servers-with-opc-publisher) - [Discovery Configuration](#discovery-configuration) - [One-time discovery](#one-time-discovery) @@ -423,6 +425,7 @@ The configuration consists a JSON array of [entries](./definitions.md#publishedn { "Id": "string", "ExpandedNodeId": "string", + "BrowsePath": [ "string" ], "AttributeId": "string", "IndexRange": "string", "UseCyclicRead": "boolean", @@ -520,8 +523,9 @@ Each [OpcNode](./definitions.md#opcnodemodel) has the following attributes: | Attribute | Mandatory | Type | Default | Description | | --------- | --------- | ---- | ------- | ----------- | -| `Id` | Yes* | String | N/A | The OPC UA NodeId in the OPC UA server whose data value changes should be published.
Can be specified as NodeId or ExpandedNodeId as per OPC UA specification,
or as ExpandedNodeId IIoT format {NamespaceUi}#{NodeIdentifier}.
**Note*: `Id` field may be omitted when `ExpandedNodeId` is present. | +| `Id` | Yes* | String | N/A | The OPC UA [NodeId](#node-ids) in the OPC UA server whose data value changes should be published.
Can be specified as NodeId or ExpandedNodeId as per OPC UA specification,
or as ExpandedNodeId IIoT format {NamespaceUi}#{NodeIdentifier}.
**Note*: `Id` field may be omitted when `ExpandedNodeId` is present. | | `ExpandedNodeId` | No | String | `null` | Enables backwards compatibility.
Must be specified as ExpandedNodeId as per OPC UA specification.
**Note*: when `ExpandedNodeId` is present `Id` field may be omitted. | +| `BrowsePath` | No | `List` | `null` | The [browse path](#browse-paths) from the Node configured in `Id` to the actual node to monitor.
**Note*: if the node `Id` is not provided, `i=84` (root node) is assumed. | | `AttributeId` | No | String | `Value` | The node attribute to sample in case the node is a variable value (data item).
The allowed values are defined in the OPC UA specification.
Ignored when subscribing to events. | | `IndexRange` | No | String | `null` | The index range of the value to publish.
Value expressed as a numeric range as defined in the OPC UA specification.
Ignored when subscribing to events. | | `OpcSamplingInterval` | No | Integer | `1000` | The sampling interval for the monitored item to be published.
Value expressed in milliseconds.
The value is used as defined in the OPC UA specification.
Ignored when `OpcSamplingIntervalTimespan` is present. | @@ -566,7 +570,7 @@ The following configuration properties of the published nodes entry model apply > IMPORTANT: It is important to set a unique `DataSetWriterGroup` name when configuring the above settings. Not doing so will yield unexpected behavior as all configurations with the same writer group name are collated into a single one with differing settings being clobbered. -A `DataSetWriter` is defined by its `DataSetWriterId` and the effective `DataSetPublishingInterval` of the writer. A group of nodes with the same publishing interval becomes a writer inside a writer group, regardless of using the same `DataSetWriterId`. If the same `DataSetWriterId` is used but with nodes that have different effective publishing intervals, then a postfix string is added to the name to further disambiguate. +A `DataSetWriter` is defined by its `DataSetWriterId` and the effective `DataSetPublishingInterval` of the writer. A group of nodes with the same [publishing interval](#sampling-and-publishing-interval-configuration) becomes a writer inside a writer group, regardless of using the same `DataSetWriterId`. If the same `DataSetWriterId` is used but with nodes that have different effective publishing intervals, then a postfix string is added to the name to further disambiguate. > IMPORTANT: Just like the writer group configuration, it is important to set a unique `DataSetWriterId` name when configuring multiple writers with different settings (publishing interval excluded). Not doing so will yield unexpected behavior as all configurations with the same dataset writer name are collated into a single one with differing settings being clobbered. @@ -589,7 +593,9 @@ The following overview diagram courtesy of the OPC Foundation shows how the serv A subscription is created for each unique `DataSetWriter`. The publishing interval (configured using the `DataSetPublishingInterval` or `OpcPublishingInterval` values) is an attribute of the subscription (hence multiple writers are instantiated if there are multiple different publishing intervals). It defines the cyclic rate at which it collects values from the monitored item queues. Each time it attempts to send a Notification Message to OPC Publisher containing new values or events of its monitored items. -The diagnostics output and metrics contain a `Server queue overflows` instrument which captures the number of data values with overflow bit set and indicates data changes were lost. Increase the `QueueSize` of frequently sampled items until the instrument stays 0. +A default OPC Publisher wide publishing interval can be provided using the [command line option](./commandline.md) (`--op`) which is used when the interval is not configured. The default publishing interval used by OPC Publisher is 1 second. It is also possible to override all publishing intervals configured in the OPC Publisher configuration using the `--ipi` command line option. In this case, if `--op` is not specified a publishing interval of `0` is used, which instructs the server to choose the fastest publishing interval cycle it can manage. This can be useful if you have existing configuration specifying multiple publishing intervals but would like to avoid separate subscriptions to be created for each interval, or just put the server in charge. Note though that the `--npd` command line will still split the data set writer into multiple subscriptions if more nodes than the configured amount are specified. + +The diagnostics output and metrics contain a `Server queue overflows` instrument which captures the number of data values with overflow bit set and indicates data changes were lost. Increase the `QueueSize` of frequently sampled items until the instrument stays `0`. Notifications received by the writers in the writer group inside OPC Publisher are batched and encoded and published to the chosen [transport sink](./transports.md). @@ -760,7 +766,7 @@ In addition you can configure optional [Condition](#condition-handling-options) #### Simple event filter -As highlighted in the example above you can specify namespaces both by using the index or the full name for the namespace. Also look at how the BrowsePath can be configured. +As highlighted in the example above you can specify namespaces both by using the index or the full name for the namespace. Also look at how the [BrowsePath](#browse-paths) can be configured. Here is an example of a configuration file in simple mode: @@ -906,7 +912,7 @@ OPC Publisher allows you to map values and events obtained from the OPC UA addre Specify topic templates at the level of `WriterGroup`, `DataSetWriter` or `Node` as part of the [configuration](#configuration-schema) to configure routing that meets your needs. Topic templates can apply not just to MQTT but to any transport supporting topic or queue name based routing, however, the default templates that apply use the MQTT topic format with `/` path delimiter and escape only MQTT topic reserved characters (using `\x`). -For extra convenience use the automatic routing feature which leverages the OPC UA browse paths inside the address space to automatically create the topic structure. The browse path from the root folder (`i=84`) is used as it maps well with how clients visualize the address space. To use this feature, configure the `DataSetRouting` option in the configuration or set a default on the [command line](./commandline.md). For example when configuring the `UseBrowseNames` option all Events and data changes are routed to topics that match the browse path of the source node effectively mapping the address space into the MQTT topic structure with limited configuration overhead. +For extra convenience use the automatic routing feature which leverages the OPC UA browse paths inside the address space to automatically create the topic structure. The [browse paths](#browse-paths) from the root folder (`i=84`) is used as it maps well with how clients visualize the address space. To use this feature, configure the `DataSetRouting` option in the configuration or set a default on the [command line](./commandline.md). For example when configuring the `UseBrowseNames` option all Events and data changes are routed to topics that match the browse path of the source node effectively mapping the address space into the MQTT topic structure with limited configuration overhead. When publishing value changes to topics best choose a [Message format](./messageformats.md) that has limited overhead, e.g., `SingleRawDataSet` or `SingleDataSetMessage`. @@ -1080,7 +1086,13 @@ curl -H "Authorization: ApiKey 6dee3fd4-0bb2-4fb1-9736-99bb4435f020" https://loc The REST API uses OPC UA JSON reversible encoding as per standard defined in [OPC UA](../readme.md#what-is-opc-ua) specification 1.04, Part 6, with the exception that default scalar values and `null` values are not encoded except when inside of an array. A missing value implies `null` or the default of the scalar data type. -In addition to the standard string encoding using a namespace `Index` (e.g. `ns=4;i=3`) or the `Expanded` format (e.g. `nsu=http://opcfoundation.org/UA/;i=3523`) OPC Publisher also supports the use of `Uri` encoded Node Ids and Qualified Names (see [RFC 3986](http://tools.ietf.org/html/rfc3986)). +All *primitive built-in* values (`integer`, `string`, `int32`, `double`, etc.) and *Arrays* of them can be passed as JSON encoded Variant objects (as per standard) or as JSON Token. The twin module attempts to coerce the JSON Token in the payload to the expected built-in type of the Variable or Input argument. + +The decoder will match JSON variable names case-**in**sensitively. This means you can write a JSON object property name as `"tyPeiD": ""`, `"typeid": ""`, or `"TYPEID": ""` and all are decoded into a OPC UA structure's `"TypeId"` member. + +#### Node Ids + +In addition to the standard string encoding using a namespace `Index` (e.g. `ns=4;i=3`) or the `Expanded` format (e.g. `nsu=http://opcfoundation.org/UA/;i=3523`). OPC Publisher also supports the use of (non-standards compliant) `Uri` encoded Node Ids and Qualified Names (see [RFC 3986](http://tools.ietf.org/html/rfc3986)). ```bash #= @@ -1088,21 +1100,65 @@ In addition to the standard string encoding using a namespace `Index` (e.g. `ns= Examples are: `http://opcfoundation.org/UA/#i=3523` or `http://opcfoundation.org/UA/#s=tag1`. -Qualified Names are encoded as a single string the same way as Node Ids, where the name is the ID element of the URI. Examples of qualified names are in `Uri` format e.g. `http://opcfoundation.org/UA/#Browse%20Name`, in `Expanded` format `nsu=http://microsoft.com/;Browse%20Name` and in `Index` format this would be `3:Browse%20Name`. +While the API supports any input format for node ids and qualified names (e.g., such as in [browse paths](#browse-paths)), you can select the desired output namespace format through the [header in the request](./definitions.md#requestheadermodel) and its property `NamespaceFormat`. You can also set a default on the [command line](./commandline.md) using `--nf`. If the publisher is started in `--strict` the namespace format is `Expanded`, otherwise defaults to `Uri`. -While the API supports any input format for qualified names (e.g., in browse paths) or node ids, you can select the desired output namespace format through the [header in the request](./definitions.md#requestheadermodel) and its property `NamespaceFormat`. You can also set a default on the [command line](./commandline.md) using `--nf`. If the publisher is started in `--strict` the namespace format is `Expanded`, otherwise defaults to `Uri`. +> The use of the `Uri` format is discouraged because it is not standards compliant. The use of the `Index` format is also discouraged as it does not allow configuring stable identifiers (the namespace table can change between sessions or when the server is updated, in which case the index might then point to a different namespace). Use the `Expanded` format when possible. +[browse paths](#browse-paths) -Non Uri namespace Uri's must always be encoded using the `Index` or `Expanded` syntax (e.g. `nsu=taglist;i=3523`). Expanded Node Identifiers should be encoded using the OPC UA `Index` or `Expanded` syntax (e.g. `svu=opc.tcp://test;nsu=http://opcfoundation.org/UA/;i=3523`). In the `Uri` format case the server URI is appended as +Non Uri namespace Uri's must always be encoded using the `Index` or `Expanded` syntax (e.g. `nsu=taglist;i=3523`). Expanded Node Identifiers should be encoded using the OPC UA `Index` or `Expanded` syntax (e.g. `svu=opc.tcp://test;nsu=http://opcfoundation.org/UA/;i=3523`). However, the `Uri` format is also possible. In this case the server URI is appended as ```bash &srv=#= ``` -While not always enforced, ensure you **URL encode** the id value or name of Qualified Names, Node Ids and Expanded Node Ids. +#### Browse paths -All *primitive built-in* values (`integer`, `string`, `int32`, `double`, etc.) and *Arrays* of them can be passed as JSON encoded Variant objects (as per standard) or as JSON Token. The twin module attempts to coerce the JSON Token in the payload to the expected built-in type of the Variable or Input argument. +A browse path is a set of browse names that the server should follow inside the address space to get to a node. The browse name is an attribute of a node and is a qualified name. Qualified Names are encoded as a single string the same way as Node Ids, where the name is the ID element of the URI. Examples of qualified names in `Expanded` format is `nsu=http://microsoft.com/;Browse%20Name`, in `Index` format `3:Browse%20Name` and in `Uri` format `http://opcfoundation.org/UA/#Browse%20Name`. -The decoder will match JSON variable names case-**in**sensitively. This means you can write a JSON object property name as `"tyPeiD": ""`, `"typeid": ""`, or `"TYPEID": ""` and all are decoded into a OPC UA structure's `"TypeId"` member. + The browse path starts by default from the root node. If this is not desired, the starting node id must also be provided. + +The browse path format follows the documented browse path format in the OPC UA reference normative section. In all API and configuration the browse path is a JSON array containing the individual relative path element. The simplest example when using a browse path with browse names in the default namespace is: + +```json +[ "Objects", "Server", "ServerStatus", "CurrentTime" ] +``` + +In this example, the default `References` reference type is assumed linking the objects named by the browse path. The paths are forward traversed. +The relative path element by default must be a browse name formatted using one of the namespace formatting options (`Index`, `Expanded`, or `Uri`), e.g., the following are equivalent: + +```json +[ // Expanded + "Objects", + "nsu=http://opcfoundation.org/UA/Plc/Applications;OpcPlc", + "nsu=http://opcfoundation.org/UA/Plc/Applications;Telemetry", + "nsu=http://opcfoundation.org/UA/Plc/Applications;Fast", + "nsu=http://opcfoundation.org/UA/Plc/Applications;FastUIntScalar1" +], +[ // Index + "Objects", "17:OpcPlc", "17:Telemetry", "17:Fast", "17:FastUIntScalar1" +], +[ // Uri + "Objects", + "http://opcfoundation.org/UA/Plc/Applications#OpcPlc", + "http://opcfoundation.org/UA/Plc/Applications#Telemetry", + "http://opcfoundation.org/UA/Plc/Applications#Fast", + "http://opcfoundation.org/UA/Plc/Applications#FastUIntScalar1" +] +``` + +The first option is the preferred and recommended model because it is the official formatting defined in the specification, and it is stable compared to the second option of using a namespace index, which can point to a different namespace in the namespace table than intended. + +In addition each path element can be prefixed to narrow the reference or describe whether the path follow an inverse reference: + +- `.`: Short for `Aggregates` reference to select a property of a variable. +- `/`: Short for a `HierarchicalReference` reference. +- `#`: Whether to use the explicitly defined reference and do not consider subtypes of the reference type linking the elements. +- `!`: Whether the inverse of the reference should be used +- `<{ReferenceTypeId}>`: An explicit reference type to use, the well known reference types can be specified by name, otherwise use the node id of the reference type. + +If the prefix is a valid character of the browse name it can be escaped by prefixing it with a `&` ampersand character, e.g. `&<, &>, &/, &., &:, &&`. + +> Note that in a filter query string the browse path is not specified as a JSON array but as a concatenation of the elements. In this case a prefix must be used. In addition the target can be then escaped by specifying it with brackets `[` and `]`. However, this only applies to the [query parser API](./api.md#compilequery). ### Discovering OPC UA servers with OPC Publisher diff --git a/samples/Http/GetDiagnostics/GetDiagnostics.cs b/samples/Http/GetDiagnostics/GetDiagnostics.cs new file mode 100644 index 0000000000..44a7e12982 --- /dev/null +++ b/samples/Http/GetDiagnostics/GetDiagnostics.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +using System.Text.Json; +using System.Net.Http.Json; + +using var cts = new CancellationTokenSource(); +_ = Task.Run(() => { Console.ReadKey(); cts.Cancel(); }); + +using var parameters = await Parameters.Parse(args).ConfigureAwait(false); +// Connect to publisher +using var httpClient = parameters.CreateHttpClientWithAuth(); +while (!cts.IsCancellationRequested) +{ + Console.Clear(); + try + { + await foreach (var info in httpClient.GetFromJsonAsAsyncEnumerable( + parameters.OpcPublisher + "/v2/diagnostics/clients", cts.Token)) + { + var str = JsonSerializer.Serialize(info, Parameters.Indented); + Console.WriteLine(str); + } + Console.WriteLine(); + Console.WriteLine("Press key to exit"); + Console.SetCursorPosition(0, 0); + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Console.WriteLine(ex); + } +} diff --git a/samples/Http/GetDiagnostics/GetDiagnostics.csproj b/samples/Http/GetDiagnostics/GetDiagnostics.csproj new file mode 100644 index 0000000000..c3acc35148 --- /dev/null +++ b/samples/Http/GetDiagnostics/GetDiagnostics.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + enable + enable + + + + + + + + + diff --git a/samples/Http/HttpSamples.sln b/samples/Http/HttpSamples.sln index 2bf77381e8..da9cb75dc4 100644 --- a/samples/Http/HttpSamples.sln +++ b/samples/Http/HttpSamples.sln @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WriteReadbackValue", "Write EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SetConfiguration", "SetConfiguration\SetConfiguration.csproj", "{890F70EB-7C90-41F3-A1D0-8CB02B9E72E6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetDiagnostics", "GetDiagnostics\GetDiagnostics.csproj", "{3A16E303-DB2C-4E94-BA51-A41943EADA0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {890F70EB-7C90-41F3-A1D0-8CB02B9E72E6}.Debug|Any CPU.Build.0 = Debug|Any CPU {890F70EB-7C90-41F3-A1D0-8CB02B9E72E6}.Release|Any CPU.ActiveCfg = Release|Any CPU {890F70EB-7C90-41F3-A1D0-8CB02B9E72E6}.Release|Any CPU.Build.0 = Release|Any CPU + {3A16E303-DB2C-4E94-BA51-A41943EADA0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A16E303-DB2C-4E94-BA51-A41943EADA0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A16E303-DB2C-4E94-BA51-A41943EADA0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A16E303-DB2C-4E94-BA51-A41943EADA0B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/Http/Parameters.cs b/samples/Http/Parameters.cs index 64cbdd30d1..af375e0e8e 100644 --- a/samples/Http/Parameters.cs +++ b/samples/Http/Parameters.cs @@ -108,8 +108,8 @@ public static async Task Parse(string[] args) parameters.IoTHubOwnerConnectionString); var twin = await registryManager.GetTwinAsync(deviceId, moduleId).ConfigureAwait(false); parameters.ApiKey = (string)twin.Properties.Reported["__apikey__"]; - parameters.Certificate = new X509Certificate2( - (byte[])twin.Properties.Reported["__certificate__"], parameters.ApiKey); + var cert = (byte[])twin.Properties.Reported["__certificate__"]; + parameters.Certificate = new X509Certificate2(cert, parameters.ApiKey); return parameters; } catch (Exception ex) diff --git a/samples/Netcap/src/Bundle.cs b/samples/Netcap/src/Bundle.cs index 917f7a731a..07b76015c8 100644 --- a/samples/Netcap/src/Bundle.cs +++ b/samples/Netcap/src/Bundle.cs @@ -4,18 +4,14 @@ // ------------------------------------------------------------ namespace Netcap; - -using System; -using SharpPcap; -using SharpPcap.LibPcap; +using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Linq; using System.IO; using System.Text.Json; using System.IO.Compression; -using Microsoft.Extensions.Logging; -using System.Diagnostics; using System.Text; +using System; /// /// Network capture bundle containing everythig to analyze @@ -26,7 +22,7 @@ internal sealed class Bundle /// /// Start of capture /// - public DateTimeOffset Start { get; private set; } + public DateTimeOffset Start { get; } /// /// End of capture @@ -61,8 +57,12 @@ public Bundle(ILogger logger, string folder, /// /// /// + /// + /// /// - public IDisposable CaptureNetworkTraces(Publisher publisher, int index) + public Pcap AddPcap(Publisher publisher, int index, + Pcap.InterfaceType itf = Pcap.InterfaceType.AnyIfAvailable, + HttpClient? hostCaptureClient = null) { if (publisher.PnJson != null) { @@ -70,24 +70,72 @@ public IDisposable CaptureNetworkTraces(Publisher publisher, int index) File.WriteAllText(Path.Combine(_folder, "pn.json"), publisher.PnJson); } - // Create filter + var file = Path.Combine(_folder, $"capture{index}.pcap"); + + // Capture filter // https://www.wireshark.org/docs/man-pages/pcap-filter.html // src or dst host 192.168.80.2 // "ip and tcp and not port 80 and not port 25"; - + // TODO: Filter on src/dst of publisher ip + Pcap pcap; var addresses = publisher.Addresses; - var filter = "src or dst host " + ((addresses.Count == 1) ? addresses.First() : - ("(" + string.Join(" or ", addresses.Select(a => $"{a}")) + ")")); - return new Pcap(this, filter, index); + if (addresses.Count == 0) + { + pcap = new Pcap(_logger, new Pcap.CaptureConfiguration(file, itf, "ip and tcp"), + hostCaptureClient); + } + else + { + var filter = "src or dst host " + ((addresses.Count == 1) ? addresses.First() : + ("(" + string.Join(" or ", addresses.Select(a => $"{a}")) + ")")); + pcap = new Pcap(_logger, new Pcap.CaptureConfiguration(file, itf, filter), + hostCaptureClient); + } + _captures.Add(pcap); + return pcap; } /// - /// Get bundle file + /// Add an extra pcap + /// + /// + public void AddExtraPcap(Pcap pcap) + { + _captures.Add(pcap); + } + + /// + /// Stop bundle file /// /// /// public string GetBundleFile(string? name = null) { + foreach (var capture in _captures) + { + if (capture.Open) + { + capture.Dispose(); + } + if (Path.GetDirectoryName(capture.File) != _folder) + { + var additionalcaptures = Path.Combine(_folder, "extra"); + if (!Directory.Exists(additionalcaptures)) + { + Directory.CreateDirectory(additionalcaptures); + } + File.Copy(capture.File, Path.Combine(additionalcaptures, + Path.GetFileName(capture.File))); + } + if (capture.End > End) + { + End = capture.End.Value; + } + if (capture.Start < Start) + { + End = capture.Start; + } + } var zipFile = Path.Combine(Path.GetTempPath(), name ?? "capture-bundle.zip"); ZipFile.CreateFromDirectory(_folder, zipFile, CompressionLevel.SmallestSize, false, entryNameEncoding: Encoding.UTF8); @@ -150,7 +198,7 @@ await File.WriteAllTextAsync(Path.Combine(_folder, "pn.json"), pnJson) public async Task AddSessionKeysFromDiagnosticsAsync(JsonElement diagnostic, HashSet endpointFilter) { - var diagnosticJson = JsonSerializer.Serialize(diagnostic, Main.Indented); + var diagnosticJson = JsonSerializer.Serialize(diagnostic, Extensions.Indented); if (diagnostic.TryGetProperty("connection", out var conn) && conn.TryGetProperty("endpoint", out var ep) && @@ -161,19 +209,18 @@ public async Task AddSessionKeysFromDiagnosticsAsync(JsonElement diagnostic, remotePortToken.TryGetInt32(out var remotePort) && diagnostic.TryGetProperty("sessionId", out var sessionId) && sessionId.GetString() != null && - diagnostic.TryGetProperty("channelDiagnostics", out var channel) && - channel.TryGetProperty("channelId", out var channelIdToken) && + diagnostic.TryGetProperty("channelId", out var channelIdToken) && channelIdToken.TryGetUInt32(out var channelId) && - channel.TryGetProperty("tokenId", out var tokenIdToken) && + diagnostic.TryGetProperty("tokenId", out var tokenIdToken) && tokenIdToken.TryGetUInt32(out var tokenId) && - channel.TryGetProperty("client", out var clientToken) && + diagnostic.TryGetProperty("client", out var clientToken) && clientToken.TryGetProperty("iv", out var clientIvToken) && clientIvToken.TryGetBytes(out var clientIv) && clientToken.TryGetProperty("key", out var clientKeyToken) && clientKeyToken.TryGetBytes(out var clientKey) && clientToken.TryGetProperty("sigLen", out var clientSigLenToken) && clientSigLenToken.TryGetInt32(out var clientSigLen) && - channel.TryGetProperty("server", out var serverToken) && + diagnostic.TryGetProperty("server", out var serverToken) && serverToken.TryGetProperty("iv", out var serverIvToken) && serverIvToken.TryGetBytes(out var serverIv) && serverToken.TryGetProperty("key", out var serverKeyToken) && @@ -200,166 +247,14 @@ await File.AppendAllTextAsync(Path.Combine(_folder, fileName + ".log"), diagnost } /// - /// Delete bundle + /// Cleanup bundle /// public void Delete() { Directory.Delete(_folder, true); } - /// - /// Tracing - /// - internal sealed class Pcap : IDisposable - { - /// - /// Create pcap - /// - /// - /// - /// - public Pcap(Bundle bundle, string filter, int index) - { - _bundle = bundle; - _filter = filter; - _logger = bundle._logger; - - _logger.LogInformation( - "Using SharpPcap {Version}", SharpPcap.Pcap.SharpPcapVersion); - - if (LibPcapLiveDeviceList.Instance.Count == 0) - { - throw new NotSupportedException("Cannot run capture without devices."); - } - - _writer = new CaptureFileWriterDevice(Path.Combine(_bundle._folder, - $"capture{index}.pcap")); - - _devices = LibPcapLiveDeviceList.New().ToList(); - // Open devices - var open = _devices - .Where(d => - { - try - { - _logger.LogInformation("Opening {Device}...", d); - d.Open(mode: DeviceModes.None, 1000); - } - catch (Exception ex) - { - _logger.LogInformation( - "Failed to open {Device} ({Description}): {Message}", - d.Name, d.Description, ex.Message); - } - return d.Opened; - }) - .ToList(); - - // Try to capture from cooked mode (https://wiki.wireshark.org/SLL) - var linkType = PacketDotNet.LinkLayers.LinuxSll; -#if COOKED_MODE - var capturing = Capture(open.Where(d => d.LinkType == linkType)); - if (capturing.Count == 0) - { - linkType = PacketDotNet.LinkLayers.Null; - capturing = Capture(open.Where(d => - d.LinkType == PacketDotNet.LinkLayers.Ethernet || - d.LinkType == PacketDotNet.LinkLayers.Loop)); - - if (capturing.Count == 0) - { - // Capture from all interfaces that are open - capturing = Capture(open); - } - } -#else - linkType = PacketDotNet.LinkLayers.Null; - var capturing = Capture(open); -#endif - _writer.Open(new DeviceConfiguration - { - LinkLayerType = linkType - }); - - if (capturing.Count != 0) - { - capturing.ForEach(d => - { - d.StartCapture(); - _logger.LogInformation("Capturing {Device} ({Description})...", - d.Name, d.Description); - }); - _logger.LogInformation(" ... to {FileName} ({Filter}).", - _bundle._folder, _filter); - } - else - { - _logger.LogWarning("No capture devices found to capture from."); - } - _bundle.Start = DateTimeOffset.UtcNow; - - List Capture(IEnumerable candidates) - { - var capturing = new List(); - foreach (var device in candidates) - { - try - { - // Open the device for capturing - Debug.Assert(device.Opened); - device.Filter = _filter; - device.OnPacketArrival += (_, e) => _writer.Write(e.GetPacket()); - - capturing.Add(device); - } - catch (Exception ex) - { - _logger.LogError("Failed to capture {Device} ({Description}): {Message}", - device.Name, device.Description, ex.Message); - } - } - return capturing; - } - } - - /// - public void Dispose() - { - try - { - _bundle.End = DateTimeOffset.UtcNow; - _devices.ForEach(d => - { - try - { - d.StopCapture(); - _logger.LogInformation( - "Capturing {Description} completed. ({Statistics}).", - d.Description, d.Statistics.ToString()); - } - catch { } - }); - _logger.LogInformation("Completed capture."); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to complete capture."); - throw; - } - finally - { - _writer.Dispose(); - _devices.ForEach(d => d.Dispose()); - } - } - - private readonly List _devices; - private readonly CaptureFileWriterDevice _writer; - private readonly Bundle _bundle; - private readonly string _filter; - private readonly ILogger _logger; - } - + private readonly List _captures = new(); private readonly ILogger _logger; private readonly string _folder; } diff --git a/samples/Netcap/src/Dockerfile b/samples/Netcap/src/Dockerfile index 94211222e5..810cd91ebf 100644 --- a/samples/Netcap/src/Dockerfile +++ b/samples/Netcap/src/Dockerfile @@ -1,8 +1,10 @@ -#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +ARG BRANCH=main +ARG VERSION +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base RUN DEBIAN_FRONTEND=noninteractive apt update -y && apt install -y libpcap-dev bash net-tools USER root:root +ENV BRANCH=${BRANCH} +ENV VERSION=${VERSION} WORKDIR /app FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build diff --git a/samples/Netcap/src/Extensions.cs b/samples/Netcap/src/Extensions.cs index f37230d9cf..bdf72d0bd9 100644 --- a/samples/Netcap/src/Extensions.cs +++ b/samples/Netcap/src/Extensions.cs @@ -18,7 +18,7 @@ namespace Netcap; internal static partial class Extensions { /// - /// Get property + /// Stop property /// /// /// @@ -44,7 +44,7 @@ internal static partial class Extensions } /// - /// Get tag + /// Stop tag /// /// /// @@ -68,7 +68,7 @@ internal static partial class Extensions } /// - /// Get bytes + /// Stop bytes /// /// /// @@ -105,17 +105,23 @@ public static string FixUpResourceName(string name) } /// - /// Get assembly version + /// Stop assembly version /// /// public static string GetVersion(this Assembly assembly) { + var branch = Environment.GetEnvironmentVariable("BRANCH"); + branch = !string.IsNullOrEmpty(branch) ? "-" + branch : string.Empty; var ver = assembly.GetCustomAttribute()?.Version; if (ver == null || !Version.TryParse(ver, out var assemblyVersion)) { - assemblyVersion = new Version(); + ver = Environment.GetEnvironmentVariable("VERSION"); + if (ver == null || !Version.TryParse(ver, out assemblyVersion)) + { + assemblyVersion = new Version(); + } } - return assemblyVersion.ToString(); + return assemblyVersion + branch; } /// @@ -186,6 +192,8 @@ public static bool IsRunningInContainer() return Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") != null; } + public static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + [GeneratedRegex("[^a-zA-Z0-9-]")] private static partial Regex AlphaNumAndDashOnly(); [GeneratedRegex("[^a-zA-Z0-9]")] diff --git a/samples/Netcap/src/Gateway.cs b/samples/Netcap/src/Gateway.cs index d5fc594ee1..32a8c2d5c3 100644 --- a/samples/Netcap/src/Gateway.cs +++ b/samples/Netcap/src/Gateway.cs @@ -13,16 +13,17 @@ namespace Netcap; using Azure.ResourceManager.ContainerRegistry; using Azure.ResourceManager.IotHub; using Azure.ResourceManager.Resources; +using Azure.ResourceManager.IotHub.Models; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; using Microsoft.Azure.Devices; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using System.Diagnostics; -using System.Runtime.CompilerServices; using Microsoft.Azure.Devices.Common.Exceptions; using Microsoft.Azure.Devices.Shared; -using Azure.ResourceManager.IotHub.Models; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; /// /// Represents and edge gateway that can be accessed from @@ -31,13 +32,13 @@ namespace Netcap; internal sealed record class Gateway { /// - /// Get image + /// Stop image /// /// public NetcapImage Netcap { get; } /// - /// Get storage + /// Stop storage /// /// public NetcapStorage Storage { get; } @@ -70,7 +71,7 @@ public async ValueTask SelectPublisherAsync(string? subscriptionId = null, { while (!ct.IsCancellationRequested) { - // Get target publishers in iot hubs + // Stop target publishers in iot hubs var deployments = await GetPublisherDeploymentsAsync(subscriptionId, netcapMonitored, ct).ToListAsync(ct).ConfigureAwait(false); @@ -144,7 +145,7 @@ await Task.Delay(TimeSpan.FromSeconds(5), } /// - /// Get resource group + /// Stop resource group /// /// /// @@ -161,7 +162,7 @@ public async Task GetResourceGroupAsync( } /// - /// Get storage + /// Stop storage /// /// /// @@ -204,22 +205,68 @@ await registryManager.RemoveConfigurationAsync(_deploymentConfigId, } catch (ConfigurationNotFoundException) { } - var twin = await registryManager.GetTwinAsync(_publisher.DeviceId, + var publisherTwin = await registryManager.GetTwinAsync(_publisher.DeviceId, _publisher.Id, ct).ConfigureAwait(false); + + var hostname = publisherTwin.GetProperty("__hostname__", desired: false); + var port = publisherTwin.GetProperty("__port__", desired: false); + + // Stop the create options of the publisher + var agent = await registryManager.GetTwinAsync(_publisher.DeviceId, + "$edgeAgent", ct).ConfigureAwait(false); + if (agent?.Properties.Desired.Contains("modules") == true) + { + var publisherDeployment = agent.Properties.Desired["modules"][_publisher.Id]; + if (publisherDeployment != null) + { + var settings = publisherDeployment["settings"]; + var options = (string?)settings["createOptions"]; + if (options != null) + { + var createOptions = JObject.Parse(options + .Replace("\\\"", "\"", StringComparison.Ordinal)); + + if (createOptions.TryGetValue("Hostname", out var hn) && + hn.Type == JTokenType.String) + { + hostname = hn.Value(); + } + if (createOptions.TryGetValue("HostConfig", out var cfg) && + cfg is JObject hostConfig && + hostConfig.TryGetValue("PortBindings", out var bd) && + bd is JObject bindings) + { + // Looks like {\"443/tcp\":[{\"HostPort\":\"8081\"}]} + foreach (var pm in bindings) + { + if (pm.Key.StartsWith("443", StringComparison.Ordinal) && + pm.Value is JArray arr && arr.Count > 0 && + arr[0] is JObject o && o.TryGetValue("HostPort", out var p)) + { + port = p.Value(); + hostname = "localhost"; // Connect to host port + } + } + } + } + } + } + await registryManager.AddConfigurationAsync( new Configuration(_deploymentConfigId) { TargetCondition = $"deviceId = '{_publisher.DeviceId}'", Content = new ConfigurationContent { - ModulesContent = Create(_publisher.DeviceId, ncModuleId, + ModulesContent = CreateSidecarDeployment(_publisher.DeviceId, ncModuleId, _publisher.Id, Netcap.LoginServer, Netcap.Username, Netcap.Password, Netcap.Name, Storage.ConnectionString, - twin.GetProperty("__apikey__", desired: false), - twin.GetProperty("__certificate__", desired: false), - twin.GetProperty("__scheme__", desired: false), - twin.GetProperty("__hostname__", desired: false), - twin.GetProperty("__port__", desired: false)) + publisherTwin.GetProperty("__apikey__", desired: false), + publisherTwin.GetProperty("__certificate__", desired: false), + publisherTwin.GetProperty("__scheme__", desired: false), + publisherTwin.GetProperty("__ip__", desired: false), + hostname, + port) } }, ct).ConfigureAwait(false); await registryManager.UpdateTwinAsync(_publisher.DeviceId, _publisher.Id, @@ -229,7 +276,7 @@ await registryManager.UpdateTwinAsync(_publisher.DeviceId, _publisher.Id, { [kDeploymentTag] = _deploymentConfigId } - }, twin.ETag, ct).ConfigureAwait(false); + }, publisherTwin.ETag, ct).ConfigureAwait(false); _logger.LogInformation("Deploying netcap module to {DeviceId}...", _publisher.DeviceId); @@ -319,7 +366,7 @@ internal sealed record class PublisherDeployment(SubscriptionResource Subscripti } /// - /// Get deployments + /// Stop deployments /// /// /// @@ -434,7 +481,7 @@ static async IAsyncEnumerable GetPublishersAsync( } /// - /// Create deployment + /// Create Sidecar Deployment deployment /// /// /// @@ -447,13 +494,15 @@ static async IAsyncEnumerable GetPublishersAsync( /// /// /// + /// /// /// /// - private static IDictionary>? Create(string deviceId, - string netcapModuleId, string publisherModuleId, string server, + private static IDictionary>? CreateSidecarDeployment( + string deviceId, string netcapModuleId, string publisherModuleId, string server, string userName, string password, string image, string storageConnectionString, - string? apiKey, string? certificate, string? scheme, string? hostName, string? port) + string? apiKey, string? certificate, string? scheme, string? ipAddresses, + string? hostName, string? port) { var args = new List { @@ -461,6 +510,11 @@ static async IAsyncEnumerable GetPublishersAsync( "-m", publisherModuleId, "-s", storageConnectionString }; + if (ipAddresses != null) + { + args.Add("-i"); + args.Add(ipAddresses); + } if (apiKey != null) { args.Add("-a"); @@ -483,13 +537,20 @@ static async IAsyncEnumerable GetPublishersAsync( args.Add("-r"); args.Add(url); } - var createOptions = JsonConvert.SerializeObject(new { User = "root", Cmd = args.ToArray(), + NetworkingConfig = new + { + EndpointsConfig = new + { + host = new { } + } + }, HostConfig = new { + NetworkMode = "host", // $"container:{hostName ?? publisherModuleId}", CapAdd = new[] { "NET_ADMIN" } } }).Replace("\"", "\\\"", StringComparison.Ordinal); @@ -547,6 +608,7 @@ internal sealed record class NetcapImage /// Create image /// /// + /// /// public NetcapImage(Gateway gateway, string? branch, ILogger logger) { @@ -572,10 +634,10 @@ public async Task CreateOrUpdateAsync(CancellationToken ct = default) .CreateOrUpdateAsync(WaitUntil.Completed, regName, new ContainerRegistryData(rg.Data.Location, new ContainerRegistrySku(ContainerRegistrySkuName.Basic)) - { - IsAdminUserEnabled = true, - PublicNetworkAccess = ContainerRegistryPublicNetworkAccess.Enabled - }, + { + IsAdminUserEnabled = true, + PublicNetworkAccess = ContainerRegistryPublicNetworkAccess.Enabled + }, ct).ConfigureAwait(false); var registryKeys = await registryResponse.Value.GetCredentialsAsync(ct) .ConfigureAwait(false); @@ -583,7 +645,7 @@ public async Task CreateOrUpdateAsync(CancellationToken ct = default) LoginServer = registryResponse.Value.Data.LoginServer; Username = registryKeys.Value.Username; Password = registryKeys.Value.Passwords[0].Value; - Name = LoginServer + "/netcap:latest"; + Name = LoginServer + "/netcap:" + GetType().Assembly.GetVersion(); _logger.LogInformation("Building Image {Image} ...", Name); // Build the image and push to the registry @@ -619,11 +681,13 @@ async Task LogRunLog(ContainerRegistryRunResource run, CancellationToken ct) try { var client = new BlobClient(new Uri(url.Value.LogLink)); +#pragma warning disable RCS1261 // Resource can be disposed asynchronously using var os = Console.OpenStandardOutput(); using var source = await client.OpenReadAsync(new BlobOpenReadOptions(true) { Position = position }, ct).ConfigureAwait(false); +#pragma warning restore RCS1261 // Resource can be disposed asynchronously position += await source.CopyAsync(os, ct).ConfigureAwait(false); } catch (OperationCanceledException) { } @@ -739,7 +803,7 @@ public async ValueTask CreateOrUpdateAsync(CancellationToken ct) } /// - /// Delete + /// Cleanup /// /// /// diff --git a/samples/Netcap/src/Netcap.cs b/samples/Netcap/src/Netcap.cs index 30e4df7874..b09b3a6f1a 100644 --- a/samples/Netcap/src/Netcap.cs +++ b/samples/Netcap/src/Netcap.cs @@ -8,15 +8,20 @@ namespace Netcap; using Azure.Identity; using Azure.ResourceManager; using CommandLine; +using Microsoft.AspNetCore.Authentication; using Microsoft.Azure.Devices.Client; using Microsoft.Azure.Devices.Common.Exceptions; using Microsoft.Azure.Devices.Shared; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System.Diagnostics; using System.Net; using System.Net.Http.Headers; -using System.Net.Http.Json; +using System.Security.Claims; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text.Encodings.Web; using System.Text.Json; /// @@ -35,67 +40,67 @@ public NetcapException(string message, Exception innerException) } /// -/// Netcap main +/// Netcap application /// internal sealed class Main : IDisposable { [Verb("run", isDefault: true, HelpText = "Run netcap to capture diagnostics.")] public sealed class RunOptions { - [Option('c', nameof(EdgeHubConnectionString), Required = false, - HelpText = "The edge hub connection string that OPC Publisher is using " + - "to bootstrap the rest api." + - "\nDefaults to value of environment variable 'EdgeHubConnectionString'.")] - public string? EdgeHubConnectionString { get; set; } = - Environment.GetEnvironmentVariable(nameof(EdgeHubConnectionString)); - [Option('s', nameof(StorageConnectionString), Required = false, - HelpText = "The storage connection string to use to upload capture bundles." + - "\nDefaults to value of environment variable 'StorageConnectionString'.")] + HelpText = "The storage connection string to use to upload capture bundles.")] public string? StorageConnectionString { get; set; } = Environment.GetEnvironmentVariable(nameof(StorageConnectionString)); [Option('m', nameof(PublisherModuleId), Required = false, - HelpText = "The module id of the opc publisher." + - "\nDefaults to value of environment variable 'PublisherModuleId'.")] + HelpText = "The module id of the opc publisher.")] public string PublisherModuleId { get; set; } = Environment.GetEnvironmentVariable(nameof(PublisherModuleId)) ?? "publisher"; [Option('d', nameof(PublisherDeviceId), Required = false, - HelpText = "The device id of the opc publisher." + - "\nDefaults to value of environment variable 'PublisherDeviceId'.")] + HelpText = "The device id of the opc publisher.")] public string? PublisherDeviceId { get; set; } = Environment.GetEnvironmentVariable(nameof(PublisherDeviceId)); [Option('a', nameof(PublisherRestApiKey), Required = false, - HelpText = "The api key of the opc publisher." + - "\nDefaults to value of environment variable 'PublisherRestApiKey'.")] + HelpText = "The api key of the opc publisher.")] public string? PublisherRestApiKey { get; set; } = Environment.GetEnvironmentVariable(nameof(PublisherRestApiKey)); [Option('p', nameof(PublisherRestCertificate), Required = false, - HelpText = "The tls certificate of the opc publisher." + - "\nDefaults to value of environment variable 'PublisherRestCertificate'.")] + HelpText = "The tls certificate of the opc publisher.")] public string? PublisherRestCertificate { get; set; } [Option('r', nameof(PublisherRestApiEndpoint), Required = false, - HelpText = "The Rest api endpoint of the opc publisher." + - "\nDefaults to value of environment variable 'PublisherRestApiEndpoint'.")] + HelpText = "The Rest api endpoint of the opc publisher.")] public string? PublisherRestApiEndpoint { get; set; } = Environment.GetEnvironmentVariable(nameof(PublisherRestApiEndpoint)); - [Option('e', nameof(OpcServerEndpointUrl), Required = false, - HelpText = "The endpoint of the opc publisher." + - "\nDefaults to value of environment variable 'OpcServerEndpointUrl'.")] - public string? OpcServerEndpointUrl { get; set; } = - Environment.GetEnvironmentVariable(nameof(OpcServerEndpointUrl)); + [Option('i', nameof(PublisherIpAddresses), Required = false, + HelpText = "The endpoint of the opc publisher.")] + public string? PublisherIpAddresses { get; set; } [Option('t', nameof(CaptureDuration), Required = false, - HelpText = "The capture duration until data is uploaded and capture is restarted." + - "\nDefaults to value of environment variable 'CaptureDuration'.")] + HelpText = "The capture duration.")] public TimeSpan? CaptureDuration { get; set; } = TimeSpan.TryParse( Environment.GetEnvironmentVariable(nameof(CaptureDuration)), out var t) ? t : null; + [Option('I', nameof(CaptureInterfaces), Required = false, + HelpText = "The network interfaces to capture from.")] + public Pcap.InterfaceType CaptureInterfaces { get; set; } = Pcap.InterfaceType.AnyIfAvailable; + + [Option('E', nameof(HostCaptureEndpointUrl), Required = false, + HelpText = "The remote capture endpoint to use.")] + public string? HostCaptureEndpointUrl { get; internal set; } + + [Option('C', nameof(HostCaptureCertificate), Required = false, + HelpText = "The remote capture endpoint certificate.")] + public string? HostCaptureCertificate { get; internal set; } + + [Option('A', nameof(HostCaptureApiKey), Required = false, + HelpText = "The remote capture endpoint api key.")] + public string? HostCaptureApiKey { get; internal set; } + public RunOptions() { PublisherRestCertificate = @@ -103,21 +108,27 @@ public RunOptions() } /// - /// Get configuration from twin + /// Stop configuration from twin /// /// /// public void ConfigureFromTwin(Twin twin) { // Set any missing info from the netcap twin - OpcServerEndpointUrl = twin.GetProperty( - nameof(OpcServerEndpointUrl), OpcServerEndpointUrl); + PublisherIpAddresses = twin.GetProperty( + nameof(PublisherIpAddresses), PublisherIpAddresses); PublisherRestApiEndpoint = twin.GetProperty( nameof(PublisherRestApiEndpoint), PublisherRestApiEndpoint); PublisherRestApiKey = twin.GetProperty( nameof(PublisherRestApiKey), PublisherRestApiKey); PublisherRestCertificate = twin.GetProperty( nameof(PublisherRestCertificate), PublisherRestCertificate); + HostCaptureEndpointUrl = twin.GetProperty( + nameof(HostCaptureEndpointUrl), HostCaptureEndpointUrl); + HostCaptureCertificate = twin.GetProperty( + nameof(HostCaptureCertificate), HostCaptureCertificate); + HostCaptureApiKey = twin.GetProperty( + nameof(HostCaptureApiKey), HostCaptureApiKey); var captureDuration = twin.GetProperty(nameof(CaptureDuration)); if (!string.IsNullOrWhiteSpace(captureDuration) && TimeSpan.TryParse(captureDuration, out var duration)) @@ -127,6 +138,11 @@ public void ConfigureFromTwin(Twin twin) } } + [Verb("sidecar", HelpText = "Run netcap as capture side car.")] + public sealed class SidecarOptions + { + } + [Verb("install", HelpText = "Install netcap into a publisher.")] public sealed class InstallOptions { @@ -165,11 +181,11 @@ public sealed class UninstallOptions } /// - /// Create + /// Create netcap application /// public Main() { - _httpClient = new HttpClient(); + _publisherHttpClient = new HttpClient(); _loggerFactory = new LoggerFactory(); _logger = UpdateLogger(); } @@ -178,8 +194,10 @@ public Main() public void Dispose() { _loggerFactory.Dispose(); - _httpClient.Dispose(); - _certificate?.Dispose(); + _publisherHttpClient.Dispose(); + _publisherCertificate?.Dispose(); + _sidecarHttpClient?.Dispose(); + _sidecarCertificate?.Dispose(); } /// @@ -207,6 +225,7 @@ private async ValueTask ParseAsync(string[] args, CancellationToken ct = default Parser.Default.ParseArguments(args) .WithParsed(parsedParams => _run = parsedParams) .WithParsed(parsedParams => _install = parsedParams) + .WithParsed(parsedParams => _sidecar = parsedParams) .WithParsed(parsedParams => _uninstall = parsedParams) .WithNotParsed(errors => { @@ -227,26 +246,19 @@ private async ValueTask ParseAsync(string[] args, CancellationToken ct = default { await UninstallAsync(ct).ConfigureAwait(false); } - else if (!string.IsNullOrWhiteSpace(_run?.EdgeHubConnectionString) || - Environment.GetEnvironmentVariable("IOTEDGE_WORKLOADURI") != null) + else if (_sidecar != null) { - await ConnectAsModuleAsync(ct).ConfigureAwait(false); - - await RunAsync(ct).ConfigureAwait(false); + await RunAsSidecarModuleAsync(ct).ConfigureAwait(false); + } + else if (_run != null) + { + await RunAsModuleAsync(ct).ConfigureAwait(false); } else if (!string.IsNullOrEmpty(iothubConnectionString)) { // NOTE: This is for local testing against IoT Hub - await ConnectAsIoTHubOwnerAsync( + await RunAsIoTHubConnectedModuleAsync( iothubConnectionString, ct).ConfigureAwait(false); - - await RunAsync(ct).ConfigureAwait(false); - } - else if (string.IsNullOrWhiteSpace(_run?.PublisherRestApiKey) && - string.IsNullOrWhiteSpace(_run?.PublisherRestCertificate) && - string.IsNullOrWhiteSpace(_run?.PublisherRestApiEndpoint)) - { - await InstallAsync(ct).ConfigureAwait(false); } } @@ -257,10 +269,7 @@ await ConnectAsIoTHubOwnerAsync( /// private async Task RunAsync(CancellationToken ct = default) { - if (_run == null) - { - _run = new RunOptions(); - } + _run ??= new RunOptions(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); if (!Extensions.IsRunningInContainer()) { @@ -273,13 +282,14 @@ private async Task RunAsync(CancellationToken ct = default) try { // Connect to publisher - var publisher = new Publisher(_loggerFactory.CreateLogger("Publisher"), _httpClient, - _run.OpcServerEndpointUrl); + var publisher = new Publisher(_loggerFactory.CreateLogger("Publisher"), + _publisherHttpClient, _run.PublisherIpAddresses); Storage? uploader = null; if (!string.IsNullOrEmpty(_run.StorageConnectionString)) { - _logger.LogInformation("Uploading to storage of publisher module {DeviceId}/{ModuleId}...", + _logger.LogInformation( + "Uploading to storage of publisher module {DeviceId}/{ModuleId}...", _run.PublisherDeviceId, _run.PublisherModuleId); // TODO: move to seperate task uploader = new Storage(_run.PublisherDeviceId ?? "unknown", _run.PublisherModuleId, @@ -288,7 +298,7 @@ private async Task RunAsync(CancellationToken ct = default) for (var i = 0; !cts.IsCancellationRequested; i++) { - // Get endpoint urls and addresses to monitor if not set + // Stop endpoint urls and addresses to monitor if not set if (!await publisher.TryUpdateEndpointsAsync(cts.Token).ConfigureAwait(false)) { _logger.LogInformation("waiting ....."); @@ -297,39 +307,25 @@ private async Task RunAsync(CancellationToken ct = default) } // Capture traffic for duration + var folder = Path.Combine(Path.GetTempPath(), "capture" + i); + var bundle = new Bundle(_loggerFactory.CreateLogger("Capture"), folder); + +#pragma warning disable CA2000 // Dispose objects before losing scope using var timeoutToken = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); +#pragma warning restore CA2000 // Dispose objects before losing scope + if (uploader != null || _run.CaptureDuration != null) { var duration = _run.CaptureDuration ?? TimeSpan.FromMinutes(10); _logger.LogInformation("Capturing for {Duration}", duration); timeoutToken.CancelAfter(duration); } - var folder = Path.Combine(Path.GetTempPath(), "capture" + i); - var bundle = new Bundle(_loggerFactory.CreateLogger("Capture"), folder); - using (bundle.CaptureNetworkTraces(publisher, i)) + using (bundle.AddPcap(publisher, i, _run.CaptureInterfaces, _sidecarHttpClient)) { - while (!timeoutToken.IsCancellationRequested) - { - // Watch session diagnostics while we capture - try - { - _logger.LogInformation("Monitoring diagnostics at {Url}...", _httpClient.BaseAddress); - await foreach (var diagnostic in _httpClient.GetFromJsonAsAsyncEnumerable( - "v2/diagnostics/connections/watch", - cancellationToken: timeoutToken.Token).ConfigureAwait(false)) - { - await bundle.AddSessionKeysFromDiagnosticsAsync( - diagnostic, publisher.Endpoints).ConfigureAwait(false); - } - _logger.LogInformation("Restart monitoring diagnostics..."); - } - catch (OperationCanceledException) { } // Done - catch (Exception ex) - { - _logger.LogError(ex, "Error monitoring diagnostics - restarting..."); - } - } + await publisher.MonitorChannelsAsync(channelDiagnostics => + bundle.AddSessionKeysFromDiagnosticsAsync(channelDiagnostics, publisher.Endpoints), + timeoutToken.Token).ConfigureAwait(false); } // TODO: move to seperate task @@ -353,10 +349,7 @@ await bundle.AddSessionKeysFromDiagnosticsAsync( /// private async Task InstallAsync(CancellationToken ct = default) { - if (_install == null) - { - _install = new InstallOptions(); - } + _install ??= new InstallOptions(); // Login to azure var armClient = new ArmClient(new DefaultAzureCredential( new DefaultAzureCredentialOptions @@ -369,7 +362,7 @@ private async Task InstallAsync(CancellationToken ct = default) var gateway = new Gateway(armClient, _logger, _install.Branch); try { - // Get publishers + // Stop publishers var found = await gateway.SelectPublisherAsync(_install.SubscriptionId, false, ct).ConfigureAwait(false); if (!found) @@ -397,7 +390,7 @@ private async Task InstallAsync(CancellationToken ct = default) } try { - // Get the logs from the module, when cancelled undeploy + // Stop the logs from the module, when cancelled undeploy var downloader = gateway.GetStorage(); await downloader.DownloadAsync(_install.OutputPath, cts.Token).ConfigureAwait(false); @@ -423,10 +416,7 @@ await downloader.DownloadAsync(_install.OutputPath, /// private async Task UninstallAsync(CancellationToken ct = default) { - if (_uninstall == null) - { - _uninstall = new UninstallOptions(); - } + _uninstall ??= new UninstallOptions(); // Login to azure var armClient = new ArmClient(new DefaultAzureCredential( new DefaultAzureCredentialOptions @@ -440,8 +430,8 @@ private async Task UninstallAsync(CancellationToken ct = default) try { // Select netcap modules - var found = await gateway.SelectPublisherAsync(_uninstall.SubscriptionId, true, - ct).ConfigureAwait(false); + var found = await gateway.SelectPublisherAsync(_uninstall.SubscriptionId, + true, ct).ConfigureAwait(false); if (!found) { return; @@ -449,9 +439,9 @@ private async Task UninstallAsync(CancellationToken ct = default) // Add guard here - // Delete storage account or update if it already exists in the rg + // Cleanup storage account or update if it already exists in the rg // await gateway.Storage.DeleteAsync(ct).ConfigureAwait(false); - // Delete container registry + // Cleanup container registry // await gateway.NetcapException.DeleteAsync(ct).ConfigureAwait(false); // Deploy the module using manifest to device with the chosen publisher @@ -470,83 +460,161 @@ private async Task UninstallAsync(CancellationToken ct = default) /// /// /// - private async ValueTask ConnectAsModuleAsync(CancellationToken ct = default) + private async ValueTask RunAsModuleAsync(CancellationToken ct = default) { - if (_run == null) + if (string.IsNullOrWhiteSpace(_run?.PublisherRestApiKey) && + string.IsNullOrWhiteSpace(_run?.PublisherRestCertificate) && + string.IsNullOrWhiteSpace(_run?.PublisherRestApiEndpoint)) { - _run = new RunOptions(); + await InstallAsync(ct).ConfigureAwait(false); } - if (string.IsNullOrWhiteSpace(_run.EdgeHubConnectionString)) + + _run ??= new RunOptions(); + var moduleClient = await CreateModuleClientAsync().ConfigureAwait(false); + try { - var moduleClient = - await ModuleClient.CreateFromEnvironmentAsync().ConfigureAwait(false); - await using (var _ = moduleClient.ConfigureAwait(false)) + // Call the "GetApiKey" and "GetServerCertificate" methods on the publisher module + await moduleClient.OpenAsync(ct).ConfigureAwait(false); + await moduleClient.UpdateReportedPropertiesAsync(new TwinCollection + { + ["__type__"] = "OpcNetcap", + ["__version__"] = GetType().Assembly.GetVersion() + }, ct).ConfigureAwait(false); + + var twin = await moduleClient.GetTwinAsync(ct).ConfigureAwait(false); + + var deviceId = twin.DeviceId ?? Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID"); + var moduleId = twin.ModuleId ?? Environment.GetEnvironmentVariable("IOTEDGE_MODULEID"); + _run.PublisherModuleId = twin.GetProperty(nameof(_run.PublisherModuleId), + _run.PublisherModuleId); + _run.PublisherDeviceId = deviceId; // Override as we must be in the same device + Debug.Assert(_run.PublisherModuleId != null); + Debug.Assert(_run.PublisherDeviceId != null); + + _run.ConfigureFromTwin(twin); + + _logger.LogInformation( + "Connecting to OPC Publisher Module {PublisherModuleId} on {PublisherDeviceId}...", + _run.PublisherModuleId, _run.PublisherDeviceId); + + if (_run.PublisherRestApiKey == null || _run.PublisherRestCertificate == null) { - await ConnectAsModuleAsync(moduleClient, ct).ConfigureAwait(false); + if (_run.PublisherRestApiKey == null) + { + var apiKeyResponse = await moduleClient.InvokeMethodAsync( + _run.PublisherDeviceId, _run.PublisherModuleId, + new MethodRequest("GetApiKey"), ct).ConfigureAwait(false); + _run.PublisherRestApiKey = + JsonSerializer.Deserialize(apiKeyResponse.Result); + } + if (_run.PublisherRestCertificate == null) + { + var certResponse = await moduleClient.InvokeMethodAsync( + _run.PublisherDeviceId, _run.PublisherModuleId, + new MethodRequest("GetServerCertificate"), ct).ConfigureAwait(false); + _run.PublisherRestCertificate = + JsonSerializer.Deserialize(certResponse.Result); + } } + await CreatePublisherHttpClientAsync().ConfigureAwait(false); + CreateSidecarHttpClientIfRequired(); + await RunAsync(ct).ConfigureAwait(false); } - else + finally { - var moduleClient = - ModuleClient.CreateFromConnectionString(_run.EdgeHubConnectionString); - await using (var _ = moduleClient.ConfigureAwait(false)) - { - await ConnectAsModuleAsync(moduleClient, ct).ConfigureAwait(false); - } + await moduleClient.CloseAsync(ct).ConfigureAwait(false); + await moduleClient.DisposeAsync().ConfigureAwait(false); } + } - async Task ConnectAsModuleAsync(ModuleClient moduleClient, CancellationToken ct) + /// + /// Run the side car providing the host side capture capabilities + /// + /// + /// + private async Task RunAsSidecarModuleAsync(CancellationToken ct = default) + { + _sidecar ??= new SidecarOptions(); + var moduleClient = await CreateModuleClientAsync().ConfigureAwait(false); + try { - // Call the "GetApiKey" and "GetServerCertificate" methods on the publisher module await moduleClient.OpenAsync(ct).ConfigureAwait(false); - try + await moduleClient.UpdateReportedPropertiesAsync(new TwinCollection + { + ["__type__"] = "OpcNetcapSidecar", + ["__version__"] = GetType().Assembly.GetVersion() + }, ct).ConfigureAwait(false); + + var twin = await moduleClient.GetTwinAsync(ct).ConfigureAwait(false); + var cert = twin.GetProperty("__certificate__", desired: false); + var apiKey = twin.GetProperty("__apikey__", desired: false); + if (cert != null && apiKey != null) + { + _sidecarCertificate = new X509Certificate2( + Convert.FromBase64String(cert.Trim()), apiKey); + } + else { + _sidecarCertificate = CreateCertificate(twin); + apiKey = Guid.NewGuid().ToString(); + cert = Convert.ToBase64String(_sidecarCertificate.Export( + X509ContentType.Pfx, apiKey)); await moduleClient.UpdateReportedPropertiesAsync(new TwinCollection { - ["__type__"] = "OpcNetcap", - ["__version__"] = GetType().Assembly.GetVersion() + ["__certificate__"] = cert, + ["__apikey__"] = apiKey }, ct).ConfigureAwait(false); - - var twin = await moduleClient.GetTwinAsync(ct).ConfigureAwait(false); - - var deviceId = twin.DeviceId ?? Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID"); - var moduleId = twin.ModuleId ?? Environment.GetEnvironmentVariable("IOTEDGE_MODULEID"); - _run.PublisherModuleId = twin.GetProperty(nameof(_run.PublisherModuleId), _run.PublisherModuleId); - _run.PublisherDeviceId = deviceId; // Override as we must be in the same device - Debug.Assert(_run.PublisherModuleId != null); - Debug.Assert(_run.PublisherDeviceId != null); - - _run.ConfigureFromTwin(twin); - - _logger.LogInformation( - "Connecting to OPC Publisher Module {PublisherModuleId} on {PublisherDeviceId}...", - _run.PublisherModuleId, _run.PublisherDeviceId); - - if (_run.PublisherRestApiKey == null || _run.PublisherRestCertificate == null) - { - if (_run.PublisherRestApiKey == null) - { - var apiKeyResponse = await moduleClient.InvokeMethodAsync( - _run.PublisherDeviceId, _run.PublisherModuleId, - new MethodRequest("GetApiKey"), ct).ConfigureAwait(false); - _run.PublisherRestApiKey = - JsonSerializer.Deserialize(apiKeyResponse.Result); - } - if (_run.PublisherRestCertificate == null) - { - var certResponse = await moduleClient.InvokeMethodAsync( - _run.PublisherDeviceId, _run.PublisherModuleId, - new MethodRequest("GetServerCertificate"), ct).ConfigureAwait(false); - _run.PublisherRestCertificate = - JsonSerializer.Deserialize(certResponse.Result); - } - } - await CreateHttpClientWithAuthAsync().ConfigureAwait(false); } - finally + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel((_, serverOptions) => + serverOptions.ListenAnyIP(443, + options => options.UseHttps(_sidecarCertificate))); + + builder.Services.AddHttpContextAccessor(); + builder.Services.TryAddSingleton(_sidecar); + builder.Services.AddLogging(builder => builder + .AddSimpleConsole(options => options.SingleLine = true)); + builder.Services.AddAuthentication(nameof(ApiKeyProvider.ApiKey)) + .AddScheme( + nameof(ApiKeyProvider.ApiKey), null); + builder.Services.AddAuthentication(); + + var app = builder.Build(); + app.UseCors(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseHttpsRedirection(); + var server = new Pcap.Server(app, _logger); + await app.RunAsync(ct).ConfigureAwait(false); + } + finally + { + if (moduleClient != null) { await moduleClient.CloseAsync(ct).ConfigureAwait(false); + await moduleClient.DisposeAsync().ConfigureAwait(false); + } + } + + static X509Certificate2 CreateCertificate(Twin twin) + { + var dnsName = Dns.GetHostName(); + using var ecdsa = ECDsa.Create(); + var req = new CertificateRequest("DC=" + dnsName, ecdsa, HashAlgorithmName.SHA256); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(dnsName); + var altDns = twin?.ModuleId ?? twin?.DeviceId; + if (!string.IsNullOrEmpty(altDns) && + !string.Equals(altDns, dnsName, StringComparison.OrdinalIgnoreCase)) + { + san.AddDnsName(altDns); } + req.CertificateExtensions.Add(san.Build()); + var certificate = req.CreateSelfSigned(DateTimeOffset.Now, + DateTimeOffset.Now + TimeSpan.FromDays(90)); + Debug.Assert(certificate.HasPrivateKey); + return certificate; } } @@ -556,19 +624,17 @@ await moduleClient.UpdateReportedPropertiesAsync(new TwinCollection /// /// /// - private async ValueTask ConnectAsIoTHubOwnerAsync( + private async ValueTask RunAsIoTHubConnectedModuleAsync( string iothubConnectionString, CancellationToken ct = default) { string deviceId; var ncModuleId = "netcap"; - if (_run == null) - { - _run = new RunOptions(); - } - if (!string.IsNullOrWhiteSpace(_run.EdgeHubConnectionString)) + _run ??= new RunOptions(); + var edgeHubConnectionString = Environment.GetEnvironmentVariable("EdgeHubConnectionString"); + if (!string.IsNullOrWhiteSpace(edgeHubConnectionString)) { - // Get device and module id from edge hub connection string provided - var ehc = IotHubConnectionStringBuilder.Create(_run.EdgeHubConnectionString); + // Stop device and module id from edge hub connection string provided + var ehc = IotHubConnectionStringBuilder.Create(edgeHubConnectionString); deviceId = ehc.DeviceId; ncModuleId = ehc.ModuleId ?? ncModuleId; } @@ -608,7 +674,7 @@ await rm.AddModuleAsync(new Microsoft.Azure.Devices.Module( } }, twin.ETag, ct).ConfigureAwait(false); - // Get publisher id from twin if not configured + // Stop publisher id from twin if not configured _run.PublisherModuleId = twin.GetProperty(nameof(_run.PublisherModuleId), _run.PublisherModuleId); _run.PublisherDeviceId ??= deviceId; Debug.Assert(_run.PublisherModuleId != null); @@ -641,55 +707,97 @@ await rm.AddModuleAsync(new Microsoft.Azure.Devices.Module( JsonSerializer.Deserialize(certResponse.GetPayloadAsJson()); } } - await CreateHttpClientWithAuthAsync().ConfigureAwait(false); + await CreatePublisherHttpClientAsync().ConfigureAwait(false); + CreateSidecarHttpClientIfRequired(); + await RunAsync(ct).ConfigureAwait(false); } /// - /// Create client + /// Create sidecar client /// /// - private async ValueTask CreateHttpClientWithAuthAsync() + private void CreateSidecarHttpClientIfRequired() + { + if (_run?.HostCaptureEndpointUrl == null || + _run?.HostCaptureApiKey == null || + _run?.HostCaptureCertificate == null) + { + return; + } + + var cert = Convert.FromBase64String( + _run.HostCaptureCertificate.Trim()); + _sidecarCertificate = new X509Certificate2( + cert!, _run.HostCaptureApiKey); + + _sidecarHttpClient?.Dispose(); +#pragma warning disable CA2000 // Dispose objects before losing scope + _sidecarHttpClient = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, cert, _, _) => + { + if (_sidecarCertificate?.Thumbprint != cert?.Thumbprint) + { + _logger.LogWarning( + "Certificate thumbprint mismatch: {Expected} != {Actual}", + _sidecarCertificate?.Thumbprint, cert?.Thumbprint); + return false; + } + return true; + } + }); +#pragma warning restore CA2000 // Dispose objects before losing scope + _sidecarHttpClient.BaseAddress = new Uri(_run.HostCaptureEndpointUrl); + _sidecarHttpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("ApiKey", _run?.HostCaptureApiKey); + } + + /// + /// Create publisher client + /// + /// + private async ValueTask CreatePublisherHttpClientAsync() { if (_run?.PublisherRestApiKey != null) { // Load the certificate of the publisher if not exist if (!string.IsNullOrWhiteSpace(_run?.PublisherRestCertificate) - && _certificate == null) + && _publisherCertificate == null) { try { - _certificate = X509Certificate2.CreateFromPem( + _publisherCertificate = X509Certificate2.CreateFromPem( _run.PublisherRestCertificate.Trim()); } catch { var cert = Convert.FromBase64String( _run.PublisherRestCertificate.Trim()); - _certificate = new X509Certificate2( + _publisherCertificate = new X509Certificate2( cert!, _run.PublisherRestApiKey); } } - _httpClient.Dispose(); + _publisherHttpClient.Dispose(); #pragma warning disable CA2000 // Dispose objects before losing scope - _httpClient = new HttpClient(new HttpClientHandler + _publisherHttpClient = new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => { - if (_certificate?.Thumbprint != cert?.Thumbprint) + if (_publisherCertificate?.Thumbprint != cert?.Thumbprint) { _logger.LogWarning( "Certificate thumbprint mismatch: {Expected} != {Actual}", - _certificate?.Thumbprint, cert?.Thumbprint); + _publisherCertificate?.Thumbprint, cert?.Thumbprint); return false; } return true; } }); #pragma warning restore CA2000 // Dispose objects before losing scope - _httpClient.BaseAddress = + _publisherHttpClient.BaseAddress = await GetOpcPublisherRestEndpoint().ConfigureAwait(false); - _httpClient.DefaultRequestHeaders.Authorization = + _publisherHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("ApiKey", _run?.PublisherRestApiKey); } @@ -716,10 +824,7 @@ async ValueTask GetOpcPublisherRestEndpoint() } catch { host = null; } } - if (host == null) - { - host = "localhost"; - } + host ??= "localhost"; var isLocal = host == null; var uri = new UriBuilder { @@ -747,14 +852,109 @@ private ILogger UpdateLogger() return _loggerFactory.CreateLogger("Netcap"); } + /// + /// Create module client + /// + /// + private async static ValueTask CreateModuleClientAsync() + { + var edgeHubConnectionString = Environment.GetEnvironmentVariable("EdgeHubConnectionString"); + if (!string.IsNullOrWhiteSpace(edgeHubConnectionString)) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + return ModuleClient.CreateFromConnectionString(edgeHubConnectionString); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + return await ModuleClient.CreateFromEnvironmentAsync().ConfigureAwait(false); + } + + /// + /// Injected apikey + /// + /// + public record class ApiKeyProvider(string ApiKey); + + /// + /// Api key authentication handler + /// + internal sealed class ApiKeyHandler : AuthenticationHandler + { + public const string SchemeName = "ApiKey"; + + /// + /// Create authentication handler + /// + /// + /// + /// + /// + /// + public ApiKeyHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder, IHttpContextAccessor context, + ApiKeyProvider apiKeyProvider) : + base(options, logger, encoder) + { + _context = context; + _apiKeyProvider = apiKeyProvider; + } + + /// + protected override Task HandleAuthenticateAsync() + { + var httpContext = _context.HttpContext; + if (httpContext == null) + { + return Task.FromResult(AuthenticateResult.Fail( + "No request.")); + } + + var authorization = httpContext.Request.Headers.Authorization; + if (authorization.Count == 0 || string.IsNullOrEmpty(authorization[0])) + { + return Task.FromResult(AuthenticateResult.Fail( + "Missing Authorization header.")); + } + try + { + var header = AuthenticationHeaderValue.Parse(authorization[0]!); + if (header.Scheme != nameof(ApiKeyProvider.ApiKey)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (_apiKeyProvider.ApiKey != header.Parameter?.Trim()) + { + throw new UnauthorizedAccessException(); + } + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, nameof(ApiKeyProvider.ApiKey)) + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch (Exception ex) + { + return Task.FromResult(AuthenticateResult.Fail(ex)); + } + } + + private readonly IHttpContextAccessor _context; + private readonly ApiKeyProvider _apiKeyProvider; + } - private HttpClient _httpClient; + private HttpClient _publisherHttpClient; + private X509Certificate2? _publisherCertificate; + private HttpClient? _sidecarHttpClient; + private X509Certificate2? _sidecarCertificate; private ILoggerFactory _loggerFactory = null!; private ILogger _logger; - private X509Certificate2? _certificate; private InstallOptions? _install; private UninstallOptions? _uninstall; private RunOptions? _run; - internal static readonly JsonSerializerOptions Indented - = new() { WriteIndented = true }; + private SidecarOptions? _sidecar; } diff --git a/samples/Netcap/src/Netcap.csproj b/samples/Netcap/src/Netcap.csproj index 3214420338..fb0e593cf9 100644 --- a/samples/Netcap/src/Netcap.csproj +++ b/samples/Netcap/src/Netcap.csproj @@ -1,14 +1,19 @@ - + Exe net8.0 enable enable + true + true Linux . + + + diff --git a/samples/Netcap/src/Pcap.cs b/samples/Netcap/src/Pcap.cs new file mode 100644 index 0000000000..c9a0ed0c10 --- /dev/null +++ b/samples/Netcap/src/Pcap.cs @@ -0,0 +1,460 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Netcap; + +using SharpPcap; +using SharpPcap.LibPcap; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Diagnostics; +using System; +using System.Collections.Concurrent; +using System.Net.Http.Json; + +/// +/// Pcap capture +/// +internal sealed class Pcap : IDisposable +{ + /// + /// Pcap configuration + /// + /// + /// + /// + public sealed record class CaptureConfiguration(string File, + InterfaceType InterfaceType, string? Filter = null); + + public enum InterfaceType + { + AnyIfAvailable, + AllButAny, + EthernetOnly + } + + /// + /// Start of capture + /// + public DateTimeOffset Start { get; private set; } + + /// + /// End of capture + /// + public DateTimeOffset? End { get; private set; } + + /// + /// File or handle + /// + public string File => _configuration.File; + + /// + /// Pcap handle is capturing + /// + public bool Open => End == null; + + /// + /// Remote capture + /// + public bool Remote => _remoteCapture != null; + + /// + /// Handle + /// + public int Handle { get; } + + /// + /// Create pcap + /// + /// + /// + /// + public Pcap(ILogger logger, CaptureConfiguration configuration, + HttpClient? hostCaptureClient = null) + { + _configuration = configuration; + _remoteCapture = hostCaptureClient == null ? null : + new Client(hostCaptureClient, logger); + _logger = logger; + + if (_remoteCapture != null) + { + Handle = _remoteCapture.Start(_configuration); + Start = DateTimeOffset.UtcNow; + } + else + { + Handle = Interlocked.Increment(ref _handles); + _logger.LogInformation( + "Using SharpPcap {Version}", SharpPcap.Pcap.SharpPcapVersion); + + if (LibPcapLiveDeviceList.Instance.Count == 0) + { + throw new NetcapException("Cannot run capture without devices."); + } + + _writer = new CaptureFileWriterDevice(File); + _devices = LibPcapLiveDeviceList.New().ToList(); + LocalCaptureStart(); + } + } + + /// + public void Dispose() + { + if (!Open) + { + return; + } + try + { + if (_remoteCapture != null) + { + End = _remoteCapture.Stop(Handle, File, out var start); + Start = start; + } + else + { + End = DateTimeOffset.UtcNow; + LocalCaptureStop(); + } + } + finally + { + _writer?.Dispose(); + _devices?.ForEach(d => d.Dispose()); + } + } + + /// + /// Stop local capture + /// + private void LocalCaptureStop() + { + try + { + _devices?.ForEach(d => + { + try + { + d.StopCapture(); + _logger.LogInformation( + "Capturing {Description} completed. ({Statistics}).", + d.Description, d.Statistics.ToString()); + } + catch { } + }); + if (_writer != null) + { + _logger.LogInformation("Completed capture ({Statistics}).", + _writer.Statistics); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to complete capture."); + throw; + } + } + + /// + /// Start local capture + /// + private void LocalCaptureStart() + { + Debug.Assert(_devices != null); + Debug.Assert(_writer != null); + // Open devices + var open = _devices + .Where(d => + { + try + { + _logger.LogInformation("Opening {Device} in promiscuous mode...", d); + d.Open(mode: DeviceModes.Promiscuous, 1000); + } + catch + { + try + { + _logger.LogInformation("Fall back to normal mode..."); + d.Open(mode: DeviceModes.None, 1000); + } + catch (Exception ex2) + { + _logger.LogInformation( + "Failed to open {Device} ({Description}): {Message}", + d.Name, d.Description, ex2.Message); + } + } + return d.Opened; + }) + .ToList(); + + var capturing = Array.Empty(); + var linkType = PacketDotNet.LinkLayers.Null; + var itf = _configuration.InterfaceType; + if (itf == InterfaceType.AnyIfAvailable) + { + // Try to capture from cooked mode (https://wiki.wireshark.org/SLL) + linkType = PacketDotNet.LinkLayers.LinuxSll; + capturing = Capture(open.Where(d => d.LinkType == linkType)); + if (capturing.Length == 0) + { + itf = InterfaceType.AllButAny; + } + } + if (itf == InterfaceType.EthernetOnly) + { + linkType = PacketDotNet.LinkLayers.Ethernet; + capturing = Capture(open.Where(d => d.LinkType != linkType)); + if (capturing.Length == 0) + { + itf = InterfaceType.AllButAny; + } + } + if (itf == InterfaceType.AllButAny) + { + linkType = PacketDotNet.LinkLayers.Null; + capturing = Capture(open.Where(d => + d.LinkType != PacketDotNet.LinkLayers.LinuxSll)); + } + + if (capturing.Length == 0) + { + // Capture from all interfaces that are open + linkType = PacketDotNet.LinkLayers.Null; + capturing = Capture(open); + } + + _writer.Open(new DeviceConfiguration + { + LinkLayerType = linkType + }); + + if (capturing.Length != 0) + { + foreach (var device in capturing) + { + device.StartCapture(); + _logger.LogInformation("Capturing {Device} ({Description})...", + device.Name, device.Description); + } + _logger.LogInformation(" ... to {FileName} ({Filter}).", + File, _configuration.Filter ?? "No filter"); + } + else + { + _logger.LogWarning("No capture devices found to capture from."); + } + Start = DateTimeOffset.UtcNow; + + LibPcapLiveDevice[] Capture(IEnumerable candidates) + { + var capturing = new List(); + foreach (var device in candidates) + { + try + { + // Open the device for capturing + Debug.Assert(device.Opened); + if (_configuration.Filter != null) + { + device.Filter = _configuration.Filter; + } + device.OnPacketArrival += (_, e) => _writer.Write(e.GetPacket()); + + capturing.Add(device); + } + catch (Exception ex) + { + _logger.LogError( + "Failed to capture {Device} ({Description}): {Message}", + device.Name, device.Description, ex.Message); + } + } + return capturing.ToArray(); + } + } + + /// + /// Remote client + /// + public sealed record Client + { + /// + /// Create client + /// + /// + /// + public Client(HttpClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + /// + /// Start + /// + /// + public int Start(CaptureConfiguration configuration) + { + return StartAsync(configuration).GetAwaiter().GetResult(); + } + + /// + /// Start + /// + /// + /// + public async Task StartAsync(CaptureConfiguration configuration, + CancellationToken ct = default) + { + var response = await _client.PutAsJsonAsync("/", configuration, + ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync( + ct).ConfigureAwait(false); + } + + /// + /// Stop + /// + /// + /// + /// + /// + public DateTimeOffset Stop(int handle, string file, out DateTimeOffset start) + { + var result = StopAsync(handle, file).GetAwaiter().GetResult(); + if (result == null) + { + throw new NetcapException("Failed to stop capture."); + } + start = result.Start; + return result.End; + } + + /// + /// Stop + /// + /// + /// + /// + /// + public async Task StopAsync(int handle, string file, + CancellationToken ct = default) + { + var s = await _client.GetStreamAsync(new Uri($"/{handle}"), + ct).ConfigureAwait(false); + var f = System.IO.File.Create(file); + await using var sd = s.ConfigureAwait(false); + await using var fd = f.ConfigureAwait(false); + await s.CopyToAsync(f, ct).ConfigureAwait(false); + + var response = await _client.PostAsJsonAsync( + $"/", handle, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync( + ct).ConfigureAwait(false); + } + + private readonly HttpClient _client; + private readonly ILogger _logger; + } + + /// + /// Remote server + /// + public sealed record Server + { + /// + /// Create server + /// + /// + /// + public Server(WebApplication app, ILogger logger) + { + _logger = logger; + + app.MapPut("/", CreateAndStart) + .RequireAuthorization(nameof(Main.ApiKeyProvider.ApiKey)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + app.MapGet("/{handle}", GetAndStop) + .RequireAuthorization(nameof(Main.ApiKeyProvider.ApiKey)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + app.MapPost("/", Cleanup) + .RequireAuthorization(nameof(Main.ApiKeyProvider.ApiKey)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + } + + /// + /// Start + /// + /// + internal int CreateAndStart(CaptureConfiguration configuration) + { + var pcap = new Pcap(_logger, configuration); + _captures.TryAdd(pcap.Handle, pcap); + return pcap.Handle; + } + + /// + /// Get file and stop + /// + /// + /// + /// + internal IResult GetAndStop(int handle) + { + if (!_captures.TryGetValue(handle, out var capture)) + { + throw new NetcapException("Capture not found"); + } + capture.Dispose(); + return Results.File(capture.File); + } + + /// + /// get metadata and cleanup + /// + /// + /// + /// + internal CaptureResult Cleanup(int handle) + { + if (!_captures.TryRemove(handle, out var capture)) + { + throw new NetcapException("Capture not found"); + } + capture.Dispose(); + Debug.Assert(capture.End.HasValue); + System.IO.File.Delete(capture.File); + return new CaptureResult(capture.Start, capture.End.Value); + } + + private readonly ConcurrentDictionary _captures = new(); + private readonly ILogger _logger; + } + + /// + /// Capture result + /// + /// + /// + public sealed record class CaptureResult(DateTimeOffset Start, + DateTimeOffset End); + + private static int _handles; + private readonly CaptureConfiguration _configuration; + private readonly Client? _remoteCapture; + private readonly List? _devices; + private readonly CaptureFileWriterDevice? _writer; + private readonly ILogger _logger; +} diff --git a/samples/Netcap/src/Publisher.cs b/samples/Netcap/src/Publisher.cs index dd4bca68e8..8d87432661 100644 --- a/samples/Netcap/src/Publisher.cs +++ b/samples/Netcap/src/Publisher.cs @@ -9,6 +9,7 @@ namespace Netcap; using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Runtime.CompilerServices; using System.Text.Json; /// @@ -19,7 +20,12 @@ internal sealed class Publisher /// /// Endpoint urls /// - public HashSet Endpoints { get; } = new HashSet(); + public HashSet Endpoints { get; } = new(); + + /// + /// Addresses of the publisher on the network + /// + public HashSet Addresses { get; } = new(); /// /// Publisher configuration @@ -27,15 +33,58 @@ internal sealed class Publisher public string? PnJson { get; set; } /// - /// Addresses + /// Create publisher /// - public HashSet Addresses { get; } = new HashSet(); - - public Publisher(ILogger logger, HttpClient httpClient, string? opcServerEndpoint = null) + /// + /// + /// + public Publisher(ILogger logger, HttpClient httpClient, string? publisherIpAddresses) { _logger = logger; _httpClient = httpClient; - _opcServerEndpoint = opcServerEndpoint; + + if (!string.IsNullOrWhiteSpace(publisherIpAddresses)) + { + foreach (var address in publisherIpAddresses.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (IPAddress.TryParse(address, out var ip)) + { + Addresses.Add(ip); + } + } + } + } + + /// + /// Monitor publisher + /// + /// + /// + /// + public async ValueTask MonitorChannelsAsync(Func diagnostics, + CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + // Watch session diagnostics while we capture + try + { + _logger.LogInformation("Monitoring channels at {Url}...", _httpClient.BaseAddress); + await foreach (var diagnostic in _httpClient.GetFromJsonAsAsyncEnumerable( + "v2/diagnostics/channels/watch", + cancellationToken: ct).ConfigureAwait(false)) + { + await diagnostics(diagnostic).ConfigureAwait(false); + } + _logger.LogInformation("Restart monitoring channel diagnostics..."); + } + catch (OperationCanceledException) { } // Done + catch (Exception ex) + { + _logger.LogError(ex, "Error monitoring channel diagnostics - restarting..."); + } + } } /// @@ -50,11 +99,11 @@ public async ValueTask TryUpdateEndpointsAsync(CancellationToken ct) _logger.LogInformation("Retrieving endpoints from publisher on {Url}...", _httpClient.BaseAddress); - // Get and endpoint url to monitor if not set + // Stop and endpoint url to monitor if not set var configuration = await _httpClient.GetFromJsonAsync( "v2/configuration?includeNodes=true", JsonSerializerOptions.Default, ct).ConfigureAwait(false); - PnJson = JsonSerializer.Serialize(configuration, Main.Indented); + PnJson = JsonSerializer.Serialize(configuration, Extensions.Indented); foreach (var endpoint in configuration.GetProperty("endpoints").EnumerateArray()) { var endpointUrl = endpoint.GetProperty("EndpointUrl").GetString(); @@ -64,37 +113,13 @@ public async ValueTask TryUpdateEndpointsAsync(CancellationToken ct) } } - // Narrow endpoints to a single one that was configured - if (_opcServerEndpoint != null) - { - if (!Endpoints.Contains(_opcServerEndpoint)) - { - _logger.LogInformation( - "Desired endpoint {Endpoint} not found in configuration.", - _opcServerEndpoint); - return false; - } - Endpoints.Clear(); - // Select just the endpoint and continue - Endpoints.Add(_opcServerEndpoint); - } - - // Resolve addresses - Addresses.Clear(); - foreach (var e in Endpoints) - { - var uri = new Uri(e, UriKind.Absolute); - var a = await Dns.GetHostAddressesAsync(uri.Host, ct).ConfigureAwait(false); - Addresses.UnionWith(a); - } - - if (Addresses.Count == 0) + if (Endpoints.Count == 0) { - _logger.LogInformation("No addresses found for {Endpoints} - waiting .....", - Endpoints.Count == 0 ? "none" : string.Join(",", Endpoints.ToArray())); + _logger.LogInformation("No endpoints found in configuration - waiting...."); return false; } - _logger.LogInformation("Retrieved endpoints from publisher."); + _logger.LogInformation("Retrieved {Count} endpoints from publisher.", + Endpoints.Count); return true; } catch (Exception ex) @@ -106,5 +131,4 @@ public async ValueTask TryUpdateEndpointsAsync(CancellationToken ct) private readonly ILogger _logger; private readonly HttpClient _httpClient; - private readonly string? _opcServerEndpoint; } diff --git a/samples/Netcap/src/Storage.cs b/samples/Netcap/src/Storage.cs index 48ad6fc2f7..9f1c71d949 100644 --- a/samples/Netcap/src/Storage.cs +++ b/samples/Netcap/src/Storage.cs @@ -5,6 +5,7 @@ namespace Netcap; +using Azure; using Azure.Storage.Blobs; using Azure.Storage.Queues; using Azure.Storage.Blobs.Models; @@ -12,8 +13,6 @@ namespace Netcap; using System.Text.Json; using System.IO; using System.Globalization; -using Microsoft.Azure.Devices.Shared; -using Azure; using System.IO.Compression; /// @@ -113,6 +112,7 @@ await queueClient.DeleteMessageAsync(message.Value.MessageId, } } } + catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, "Error receiving download notification."); @@ -182,7 +182,7 @@ await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, } /// - /// Delete storage + /// Cleanup storage /// /// /// diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ChannelDiagnosticModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ChannelDiagnosticModel.cs index 086fb8bbae..c3aa9f83b6 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ChannelDiagnosticModel.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ChannelDiagnosticModel.cs @@ -9,49 +9,110 @@ namespace Azure.IIoT.OpcUa.Publisher.Models using System.Runtime.Serialization; /// - /// Channel token. Can be used to decrypt encrypted - /// capture files. + /// Channel diagnostics model /// [DataContract] public record class ChannelDiagnosticModel { + /// + /// Timestamp of the diagnostic information + /// + [DataMember(Name = "timeStamp", Order = 1)] + public required DateTimeOffset TimeStamp { get; init; } + + /// + /// The session id if connected. Empty if disconnected. + /// + [DataMember(Name = "sessionId", Order = 2, + EmitDefaultValue = false)] + public string? SessionId { get; init; } + + /// + /// When the session was created. + /// + [DataMember(Name = "sessionCreated", Order = 3, + EmitDefaultValue = false)] + public DateTimeOffset? SessionCreated { get; init; } + + /// + /// The connection information specified by user. + /// + [DataMember(Name = "connection", Order = 4)] + public required ConnectionModel Connection { get; init; } + + /// + /// Effective remote ip address used for the + /// connection if connected. Empty if disconnected. + /// + [DataMember(Name = "remoteIpAddress", Order = 5, + EmitDefaultValue = false)] + public string? RemoteIpAddress { get; init; } + + /// + /// The effective remote port used when connected, + /// null if disconnected. + /// + [DataMember(Name = "remotePort", Order = 6, + EmitDefaultValue = false)] + public int? RemotePort { get; init; } + + /// + /// Effective local ip address used for the connection + /// if connected. Empty if disconnected. + /// + [DataMember(Name = "localIpAddress", Order = 7, + EmitDefaultValue = false)] + public string? LocalIpAddress { get; init; } + + /// + /// The effective local port used when connected, + /// null if disconnected. + /// + [DataMember(Name = "localPort", Order = 8, + EmitDefaultValue = false)] + public int? LocalPort { get; init; } + /// /// The id assigned to the channel that the token /// belongs to. /// - [DataMember(Name = "channelId", Order = 0)] - public required uint ChannelId { get; init; } + [DataMember(Name = "channelId", Order = 9, + EmitDefaultValue = false)] + public uint? ChannelId { get; init; } /// /// The id assigned to the token. /// - [DataMember(Name = "tokenId", Order = 1)] - public required uint TokenId { get; init; } + [DataMember(Name = "tokenId", Order = 10, + EmitDefaultValue = false)] + public uint? TokenId { get; init; } /// /// When the token was created by the server /// (refers to the server's clock). /// - [DataMember(Name = "createdAt", Order = 2)] - public required DateTime CreatedAt { get; init; } + [DataMember(Name = "createdAt", Order = 11, + EmitDefaultValue = false)] + public DateTime? CreatedAt { get; init; } /// /// The lifetime of the token /// - [DataMember(Name = "lifetime", Order = 3)] - public required TimeSpan Lifetime { get; init; } + [DataMember(Name = "lifetime", Order = 12, + EmitDefaultValue = false)] + public TimeSpan? Lifetime { get; init; } /// /// Client keys /// - [DataMember(Name = "client", Order = 4, + [DataMember(Name = "client", Order = 13, EmitDefaultValue = false)] public ChannelKeyModel? Client { get; init; } /// /// Server keys /// - [DataMember(Name = "server", Order = 5, + [DataMember(Name = "server", Order = 14, EmitDefaultValue = false)] public ChannelKeyModel? Server { get; init; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticModel.cs deleted file mode 100644 index 16c07a7e7b..0000000000 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticModel.cs +++ /dev/null @@ -1,82 +0,0 @@ -// ------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. -// ------------------------------------------------------------ - -namespace Azure.IIoT.OpcUa.Publisher.Models -{ - using System; - using System.Runtime.Serialization; - - /// - /// Connection / session diagnostics model - /// - [DataContract] - public record class ConnectionDiagnosticModel - { - /// - /// Timestamp of the diagnostic information - /// - [DataMember(Name = "timeStamp", Order = 0)] - public required DateTimeOffset TimeStamp { get; init; } - - /// - /// The session id if connected. Empty if disconnected. - /// - [DataMember(Name = "sessionId", Order = 1, - EmitDefaultValue = false)] - public string? SessionId { get; init; } - - /// - /// When the session was created. - /// - [DataMember(Name = "sessionCreated", Order = 2, - EmitDefaultValue = false)] - public DateTimeOffset? SessionCreated { get; init; } - - /// - /// Effective remote ip address used for the - /// connection if connected. Empty if disconnected. - /// - [DataMember(Name = "remoteIpAddress", Order = 5, - EmitDefaultValue = false)] - public string? RemoteIpAddress { get; init; } - - /// - /// The effective remote port used when connected, - /// null if disconnected. - /// - [DataMember(Name = "remotePort", Order = 6, - EmitDefaultValue = false)] - public int? RemotePort { get; init; } - - /// - /// Effective local ip address used for the connection - /// if connected. Empty if disconnected. - /// - [DataMember(Name = "localIpAddress", Order = 7, - EmitDefaultValue = false)] - public string? LocalIpAddress { get; init; } - - /// - /// The effective local port used when connected, - /// null if disconnected. - /// - [DataMember(Name = "localPort", Order = 8, - EmitDefaultValue = false)] - public int? LocalPort { get; init; } - - /// - /// Channel diagnostics - /// - [DataMember(Name = "channelDiagnostics", Order = 9, - EmitDefaultValue = false)] - public ChannelDiagnosticModel? ChannelDiagnostics { get; init; } - - /// - /// The connection information specified by user. - /// - [DataMember(Name = "connection", Order = 10)] - public required ConnectionModel Connection { get; init; } - } -} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticsModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticsModel.cs new file mode 100644 index 0000000000..2cdf5cca5c --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ConnectionDiagnosticsModel.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System.Runtime.Serialization; + + /// + /// Connection diagnostics + /// + [DataContract] + public record class ConnectionDiagnosticsModel + { + /// + /// The connection information specified by user. + /// + [DataMember(Name = "connection", Order = 0)] + public required ConnectionModel Connection { get; init; } + + /// + /// The session and subscriptions diagnostics from + /// the server. + /// + [DataMember(Name = "server", Order = 1, + EmitDefaultValue = false)] + public SessionDiagnosticsModel? Server { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/Constants.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/Constants.cs index 3a0923c6b9..f8bbe00937 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/Constants.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/Constants.cs @@ -40,6 +40,11 @@ public static class Constants /// public const string TwinPropertyHostnameKey = "__hostname__"; + /// + /// Adresseses property constant + /// + public const string TwinPropertyIpAddressesKey = "__ip__"; + /// /// Port key constant /// diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceCounterModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceCounterModel.cs new file mode 100644 index 0000000000..d60c6807d9 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceCounterModel.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System.Runtime.Serialization; + + /// + /// Service counters + /// + [DataContract] + public record class ServiceCounterModel + { + /// + /// Total count + /// + [DataMember(Name = "totalCount", Order = 1, + EmitDefaultValue = false)] + public uint TotalCount { get; init; } + + /// + /// Error count + /// + [DataMember(Name = "errorCount", Order = 2, + EmitDefaultValue = false)] + public uint ErrorCount { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/SessionDiagnosticsModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SessionDiagnosticsModel.cs new file mode 100644 index 0000000000..3227e26102 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SessionDiagnosticsModel.cs @@ -0,0 +1,305 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System; + using System.Collections.Generic; + using System.Runtime.Serialization; + + /// + /// Session diagnostics + /// + [DataContract] + public record class SessionDiagnosticsModel + { + /// + /// Session id + /// + [DataMember(Name = "sessionId", Order = 0, + EmitDefaultValue = false)] + public string? SessionId { get; init; } + + /// + /// Session name + /// + [DataMember(Name = "sessionName", Order = 1, + EmitDefaultValue = false)] + public string? SessionName { get; init; } + + /// + /// Server uri + /// + [DataMember(Name = "serverUri", Order = 2, + EmitDefaultValue = false)] + public string? ServerUri { get; init; } + + /// + /// Actual session timeout + /// + [DataMember(Name = "actualSessionTimeout", Order = 3, + EmitDefaultValue = false)] + public double ActualSessionTimeout { get; init; } + + /// + /// Max response message size + /// + [DataMember(Name = "maxResponseMessageSize", Order = 8, + EmitDefaultValue = false)] + public uint MaxResponseMessageSize { get; init; } + + /// + /// Connection established + /// + [DataMember(Name = "connectTime", Order = 9, + EmitDefaultValue = false)] + public DateTime ConnectTime { get; init; } + + /// + /// Last contact + /// + [DataMember(Name = "lastContactTime", Order = 10, + EmitDefaultValue = false)] + public DateTime LastContactTime { get; init; } + + /// + /// Current subscriptions count + /// + [DataMember(Name = "currentSubscriptionsCount", Order = 11, + EmitDefaultValue = false)] + public uint CurrentSubscriptionsCount { get; init; } + + /// + /// Current monitored items count + /// + [DataMember(Name = "currentMonitoredItemsCount", Order = 12, + EmitDefaultValue = false)] + public uint CurrentMonitoredItemsCount { get; init; } + + /// + /// Current publish requests in queue + /// + [DataMember(Name = "currentPublishRequestsInQueue", Order = 13, + EmitDefaultValue = false)] + public uint CurrentPublishRequestsInQueue { get; init; } + + /// + /// Total request count + /// + [DataMember(Name = "totalRequestCount", Order = 14, + EmitDefaultValue = false)] + public ServiceCounterModel? TotalRequestCount { get; init; } + + /// + /// Unauthorized request count + /// + [DataMember(Name = "unauthorizedRequestCount", Order = 15, + EmitDefaultValue = false)] + public uint UnauthorizedRequestCount { get; init; } + + /// + /// Read count + /// + [DataMember(Name = "readCount", Order = 16, + EmitDefaultValue = false)] + public ServiceCounterModel? ReadCount { get; init; } + + /// + /// History read counts + /// + [DataMember(Name = "historyReadCount", Order = 17, + EmitDefaultValue = false)] + public ServiceCounterModel? HistoryReadCount { get; init; } + + /// + /// Write counts + /// + [DataMember(Name = "writeCount", Order = 18, + EmitDefaultValue = false)] + public ServiceCounterModel? WriteCount { get; init; } + + /// + /// History update count + /// + [DataMember(Name = "historyUpdateCount", Order = 19, + EmitDefaultValue = false)] + public ServiceCounterModel? HistoryUpdateCount { get; init; } + + /// + /// Call count + /// + [DataMember(Name = "callCount", Order = 20, + EmitDefaultValue = false)] + public ServiceCounterModel? CallCount { get; init; } + + /// + /// Create monitored item count + /// + [DataMember(Name = "createMonitoredItemsCount", Order = 21, + EmitDefaultValue = false)] + public ServiceCounterModel? CreateMonitoredItemsCount { get; init; } + + /// + /// Modify monitored item counts + /// + [DataMember(Name = "modifyMonitoredItemsCount", Order = 22, + EmitDefaultValue = false)] + public ServiceCounterModel? ModifyMonitoredItemsCount { get; init; } + + /// + /// Set monitoring mode counts + /// + [DataMember(Name = "setMonitoringModeCount", Order = 23, + EmitDefaultValue = false)] + public ServiceCounterModel? SetMonitoringModeCount { get; init; } + + /// + /// Set triggering counts + /// + [DataMember(Name = "setTriggeringCount", Order = 24, + EmitDefaultValue = false)] + public ServiceCounterModel? SetTriggeringCount { get; init; } + + /// + /// Delete monitored items counts + /// + [DataMember(Name = "deleteMonitoredItemsCount", Order = 25, + EmitDefaultValue = false)] + public ServiceCounterModel? DeleteMonitoredItemsCount { get; init; } + + /// + /// Create Subscription count + /// + [DataMember(Name = "createSubscriptionCount", Order = 26, + EmitDefaultValue = false)] + public ServiceCounterModel? CreateSubscriptionCount { get; init; } + + /// + /// Modify subscription count + /// + [DataMember(Name = "modifySubscriptionCount", Order = 27, + EmitDefaultValue = false)] + public ServiceCounterModel? ModifySubscriptionCount { get; init; } + + /// + /// Set publishing mode count + /// + [DataMember(Name = "setPublishingModeCount", Order = 28, + EmitDefaultValue = false)] + public ServiceCounterModel? SetPublishingModeCount { get; init; } + + /// + /// Publish counts + /// + [DataMember(Name = "publishCount", Order = 29, + EmitDefaultValue = false)] + public ServiceCounterModel? PublishCount { get; init; } + + /// + /// Republish count + /// + [DataMember(Name = "republishCount", Order = 30, + EmitDefaultValue = false)] + public ServiceCounterModel? RepublishCount { get; init; } + + /// + /// Transfer subscriptions count + /// + [DataMember(Name = "transferSubscriptionsCount", Order = 31, + EmitDefaultValue = false)] + public ServiceCounterModel? TransferSubscriptionsCount { get; init; } + + /// + /// Delete subscriptions count + /// + [DataMember(Name = "deleteSubscriptionsCount", Order = 32, + EmitDefaultValue = false)] + public ServiceCounterModel? DeleteSubscriptionsCount { get; init; } + + /// + /// Add nodes count + /// + [DataMember(Name = "addNodesCount", Order = 33, + EmitDefaultValue = false)] + public ServiceCounterModel? AddNodesCount { get; init; } + + /// + /// Add References count + /// + [DataMember(Name = "addReferencesCount", Order = 34, + EmitDefaultValue = false)] + public ServiceCounterModel? AddReferencesCount { get; init; } + + /// + /// Delete nodes count + /// + [DataMember(Name = "deleteNodesCount", Order = 35, + EmitDefaultValue = false)] + public ServiceCounterModel? DeleteNodesCount { get; init; } + + /// + /// Delete References count + /// + [DataMember(Name = "deleteReferencesCount", Order = 36, + EmitDefaultValue = false)] + public ServiceCounterModel? DeleteReferencesCount { get; init; } + + /// + /// Browse count + /// + [DataMember(Name = "browseCount", Order = 37, + EmitDefaultValue = false)] + public ServiceCounterModel? BrowseCount { get; init; } + + /// + /// Browse next count + /// + [DataMember(Name = "browseNextCount", Order = 38, + EmitDefaultValue = false)] + public ServiceCounterModel? BrowseNextCount { get; init; } + + /// + /// Translate browse paths to node ids count + /// + [DataMember(Name = "translateBrowsePathsToNodeIdsCount", Order = 39, + EmitDefaultValue = false)] + public ServiceCounterModel? TranslateBrowsePathsToNodeIdsCount { get; init; } + + /// + /// Query first count + /// + [DataMember(Name = "queryFirstCount", Order = 40, + EmitDefaultValue = false)] + public ServiceCounterModel? QueryFirstCount { get; init; } + + /// + /// Query next count + /// + [DataMember(Name = "queryNextCount", Order = 41, + EmitDefaultValue = false)] + public ServiceCounterModel? QueryNextCount { get; init; } + + /// + /// Register nodes count + /// + [DataMember(Name = "registerNodesCount", Order = 42, + EmitDefaultValue = false)] + public ServiceCounterModel? RegisterNodesCount { get; init; } + + /// + /// Unregister nodes count + /// + [DataMember(Name = "unregisterNodesCount", Order = 43, + EmitDefaultValue = false)] + public ServiceCounterModel? UnregisterNodesCount { get; init; } + + /// + /// Subscription diagnostics + /// + [DataMember(Name = "subscriptions", Order = 44, + EmitDefaultValue = false)] + public IReadOnlyList? Subscriptions { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/SubscriptionDiagnosticsModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SubscriptionDiagnosticsModel.cs new file mode 100644 index 0000000000..79d0f8c4f7 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/SubscriptionDiagnosticsModel.cs @@ -0,0 +1,226 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System.Runtime.Serialization; + + /// + /// Subscription diagnostics + /// + [DataContract] + public record class SubscriptionDiagnosticsModel + { + /// + /// Subscription id + /// + [DataMember(Name = "subscriptionId", Order = 0, + EmitDefaultValue = false)] + public uint SubscriptionId { get; init; } + + /// + /// Subscription priority + /// + [DataMember(Name = "priority", Order = 1, + EmitDefaultValue = false)] + public byte Priority { get; init; } + + /// + /// Publishing interval + /// + [DataMember(Name = "publishingInterval", Order = 2, + EmitDefaultValue = false)] + public double PublishingInterval { get; init; } + + /// + /// Max keep alive count + /// + [DataMember(Name = "maxKeepAliveCount", Order = 3, + EmitDefaultValue = false)] + public uint MaxKeepAliveCount { get; init; } + + /// + /// Max lifetime count + /// + [DataMember(Name = "maxLifetimeCount", Order = 4, + EmitDefaultValue = false)] + public uint MaxLifetimeCount { get; init; } + + /// + /// Current keep alive count + /// + [DataMember(Name = "currentKeepAliveCount", Order = 5, + EmitDefaultValue = false)] + public uint CurrentKeepAliveCount { get; init; } + + /// + /// Current lifetime count + /// + [DataMember(Name = "currentLifetimeCount", Order = 6, + EmitDefaultValue = false)] + public uint CurrentLifetimeCount { get; init; } + + /// + /// Max notifications per publish + /// + [DataMember(Name = "maxNotificationsPerPublish", Order = 7, + EmitDefaultValue = false)] + public uint MaxNotificationsPerPublish { get; init; } + + /// + /// Publishing enabled + /// + [DataMember(Name = "publishingEnabled", Order = 8, + EmitDefaultValue = false)] + public bool PublishingEnabled { get; init; } + + /// + /// Modify count + /// + [DataMember(Name = "modifyCount", Order = 9, + EmitDefaultValue = false)] + public uint ModifyCount { get; init; } + + /// + /// Subscription enable count + /// + [DataMember(Name = "enableCount", Order = 10, + EmitDefaultValue = false)] + public uint EnableCount { get; init; } + + /// + /// Disable count + /// + [DataMember(Name = "disableCount", Order = 11, + EmitDefaultValue = false)] + public uint DisableCount { get; init; } + + /// + /// Monitored item count + /// + [DataMember(Name = "monitoredItemCount", Order = 12, + EmitDefaultValue = false)] + public uint MonitoredItemCount { get; init; } + + /// + /// Disabled monitored item count + /// + [DataMember(Name = "disabledMonitoredItemCount", Order = 13, + EmitDefaultValue = false)] + public uint DisabledMonitoredItemCount { get; init; } + + /// + /// Publish request count + /// + [DataMember(Name = "publishRequestCount", Order = 14, + EmitDefaultValue = false)] + public uint PublishRequestCount { get; init; } + + /// + /// Late publish request count + /// + [DataMember(Name = "latePublishRequestCount", Order = 15, + EmitDefaultValue = false)] + public uint LatePublishRequestCount { get; init; } + + /// + /// Data change notifications count + /// + [DataMember(Name = "dataChangeNotificationsCount", Order = 16, + EmitDefaultValue = false)] + public uint DataChangeNotificationsCount { get; init; } + + /// + /// Event notifications count + /// + [DataMember(Name = "eventNotificationsCount", Order = 17, + EmitDefaultValue = false)] + public uint EventNotificationsCount { get; init; } + + /// + /// Total Notifications count + /// + [DataMember(Name = "notificationsCount", Order = 18, + EmitDefaultValue = false)] + public uint NotificationsCount { get; init; } + + /// + /// Unacknowledged message count + /// + [DataMember(Name = "unacknowledgedMessageCount", Order = 19, + EmitDefaultValue = false)] + public uint UnacknowledgedMessageCount { get; init; } + + /// + /// Discarded message count + /// + [DataMember(Name = "discardedMessageCount", Order = 20, + EmitDefaultValue = false)] + public uint DiscardedMessageCount { get; init; } + + /// + /// Next sequence number + /// + [DataMember(Name = "nextSequenceNumber", Order = 21, + EmitDefaultValue = false)] + public uint NextSequenceNumber { get; init; } + + /// + /// Monitoring queue overflow count + /// + [DataMember(Name = "monitoringQueueOverflowCount", Order = 22, + EmitDefaultValue = false)] + public uint MonitoringQueueOverflowCount { get; init; } + + /// + /// Event queue overflow count + /// + [DataMember(Name = "eventQueueOverFlowCount", Order = 23, + EmitDefaultValue = false)] + public uint EventQueueOverFlowCount { get; init; } + + /// + /// Transfer request count + /// + [DataMember(Name = "transferRequestCount", Order = 24, + EmitDefaultValue = false)] + public uint TransferRequestCount { get; init; } + + /// + /// Transferred to alt client count + /// + [DataMember(Name = "transferredToAltClientCount", Order = 25, + EmitDefaultValue = false)] + public uint TransferredToAltClientCount { get; init; } + + /// + /// Transferred to same client count + /// + [DataMember(Name = "transferredToSameClientCount", Order = 26, + EmitDefaultValue = false)] + public uint TransferredToSameClientCount { get; init; } + + /// + /// Publish request count + /// + [DataMember(Name = "republishRequestCount", Order = 27, + EmitDefaultValue = false)] + public uint RepublishRequestCount { get; init; } + + /// + /// Republish message request count + /// + [DataMember(Name = "republishMessageRequestCount", Order = 28, + EmitDefaultValue = false)] + public uint RepublishMessageRequestCount { get; init; } + + /// + /// Republish message count + /// + [DataMember(Name = "republishMessageCount", Order = 29, + EmitDefaultValue = false)] + public uint RepublishMessageCount { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj index a84bf16b66..5ad11d23d4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/BrowsePath.json b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/BrowsePath.json new file mode 100644 index 0000000000..825e33ada6 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/BrowsePath.json @@ -0,0 +1,46 @@ +[ + { + "EndpointUrl": "{{EndpointUrl}}", + "UseSecurity": true, + "OpcNodes": [ + { + "BrowsePath": [ + "Objects", + "Server", + "ServerStatus", + "CurrentTime" + ], + "DisplayName": "ServerTime" + }, + { + "BrowsePath": [ + "Objects", + "Server", + "ServerStatus", + "State" + ], + "DisplayName": "ServerState" + }, + { + "BrowsePath": [ + "Objects", + "17:OpcPlc", + "17:Telemetry", + "17:Fast", + "17:FastUIntScalar1" + ], + "DisplayName": "FastUIntScalar1" + }, + { + "BrowsePath": [ + "Objects", + "nsu=http://opcfoundation.org/UA/Plc/Applications;OpcPlc", + "nsu=http://opcfoundation.org/UA/Plc/Applications;Telemetry", + "nsu=http://opcfoundation.org/UA/Plc/Applications;Fast", + "nsu=http://opcfoundation.org/UA/Plc/Applications;FastUIntScalar2" + ], + "DisplayName": "FastUIntScalar2" + } + ] + } +] diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs index c1f32dadad..e0f455255e 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs @@ -20,7 +20,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Runtime using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Nito.AsyncEx; - using Opc.Ua; using System; using System.Collections.Generic; using System.IO; @@ -28,6 +27,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Runtime using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; + using System.Net; /// /// Publisher module host process @@ -198,7 +198,7 @@ public static void Main(string[] args) } } - deviceId = Utils.GetHostName(); + deviceId = Dns.GetHostName().ToUpperInvariant(); logger.LogInformation("Using '{DeviceId}'", deviceId); moduleId = "publisher"; logger.LogInformation("Using '{ModuleId}'", moduleId); @@ -698,7 +698,8 @@ private async Task RunSampleServerAsync(uint scaleunits, using (var server = new ServerConsoleHost( new ServerFactory(loggerFactory.CreateLogger(), scaleunits) { - LogStatus = false + LogStatus = false, + EnableDiagnostics = true }, loggerFactory.CreateLogger()) { PkiRootPath = Path.Combine(Directory.GetCurrentDirectory(), "server"), diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/DiagnosticsController.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/DiagnosticsController.cs index d6213af0fb..24e5cf02d2 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/DiagnosticsController.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/DiagnosticsController.cs @@ -70,33 +70,73 @@ public DiagnosticsController(IClientDiagnostics diagnostics) [HttpGet("reset")] public async Task ResetAllClientsAsync(CancellationToken ct = default) { - await _diagnostics.ResetAllClientsAsync(ct).ConfigureAwait(false); + await _diagnostics.ResetAllConnectionsAsync(ct).ConfigureAwait(false); + } + + /// + /// GetActiveConnections + /// + /// + /// Get all active connections the publisher is currently managing. + /// + /// + /// The operation was successful. + /// An unexpected error occurred + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpGet("connections")] + public Task> GetActiveConnectionsAsync( + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_diagnostics.ActiveConnections); } /// /// GetConnectionDiagnostics /// /// - /// Get connection diagnostic information for all connections. + /// Get diagnostics for all active clients including server and + /// client session diagnostics. /// /// /// The operation was successful. + /// The operation timed out. /// An unexpected error occurred [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] [HttpGet("diagnostics/connections")] - public Task> GetConnectionDiagnosticsAsync( + public IAsyncEnumerable GetConnectionDiagnosticsAsync( + CancellationToken ct = default) + { + return _diagnostics.GetConnectionDiagnosticsAsync(ct); + } + + /// + /// GetChannelDiagnostics + /// + /// + /// Get channel diagnostic information for all connections. + /// + /// + /// The operation was successful. + /// An unexpected error occurred + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpGet("diagnostics/channels")] + public Task> GetChannelDiagnosticsAsync( CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_diagnostics.Diagnostics); + return Task.FromResult(_diagnostics.ChannelDiagnostics); } /// - /// WatchConnectionDiagnostics + /// WatchChannelDiagnostics /// /// - /// Get connection diagnostic information for all connections. + /// Get channel diagnostic information for all connections. /// The first set of diagnostics are the diagnostics active for /// all connections, continue reading to get updates. /// @@ -105,11 +145,11 @@ public Task> GetConnectionDiagnosticsAs /// An unexpected error occurred [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] - [HttpGet("diagnostics/connections/watch")] - public IAsyncEnumerable WatchConnectionDiagnosticsAsync( + [HttpGet("diagnostics/channels/watch")] + public IAsyncEnumerable WatchChannelDiagnosticsAsync( CancellationToken ct = default) { - return _diagnostics.MonitorAsync(ct); + return _diagnostics.WatchChannelDiagnosticsAsync(ct); } private readonly IClientDiagnostics _diagnostics; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs index 54661e388e..17d4f1915b 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs @@ -276,7 +276,7 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) "Default value in milliseconds to request the servers to sample values. This value is used if an explicit sampling interval for a node was not configured. \nDefault: `1000`.\nAlso can be set using `DefaultSamplingInterval` environment variable in the form of a duration string in the form `[d.]hh:mm:ss[.fffffff]`.\n", (uint i) => this[OpcUaSubscriptionConfig.DefaultSamplingIntervalKey] = TimeSpan.FromMilliseconds(i).ToString() }, { $"op|opcpublishinginterval=|{OpcUaSubscriptionConfig.DefaultPublishingIntervalKey}=", - "Default value in milliseconds for the publishing interval setting of a subscription created with an OPC UA server. This value is used if an explicit publishing interval was not configured.\nDefault: `1000`.\nAlso can be set using `DefaultPublishingInterval` environment variable in the form of a duration string in the form `[d.]hh:mm:ss[.fffffff]`.\n", + "Default value in milliseconds for the publishing interval setting of a subscription created with an OPC UA server. This value is used if an explicit publishing interval was not configured.\nWhen setting `--op=0` the server decides the lowest publishing interval it can support.\nDefault: `1000`.\nAlso can be set using `DefaultPublishingInterval` environment variable in the form of a duration string in the form `[d.]hh:mm:ss[.fffffff]`.\n", (uint i) => this[OpcUaSubscriptionConfig.DefaultPublishingIntervalKey] = TimeSpan.FromMilliseconds(i).ToString() }, { $"eip|immediatepublishing:|{OpcUaSubscriptionConfig.EnableImmediatePublishingKey}:", "By default OPC Publisher will create a subscription with publishing disabled and only enable it after it has filled it with all configured monitored items. Use this setting to create the subscription with publishing already enabled.\nDefault: `false`.\n", @@ -298,9 +298,12 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) { $"fp|fetchpathfromroot:|{OpcUaSubscriptionConfig.FetchOpcBrowsePathFromRootKey}:", "(Experimental) Explicitly disable or enable retrieving relative paths from root for monitored items.\nDefault: `false` (disabled).\n", (bool? b) => this[OpcUaSubscriptionConfig.FetchOpcBrowsePathFromRootKey] = b?.ToString() ?? "True" }, - { $"qs|queuesize=|{OpcUaSubscriptionConfig.DefaultQueueSize}=", + { $"qs|queuesize=|{OpcUaSubscriptionConfig.DefaultQueueSizeKey}=", "Default queue size for all monitored items if queue size was not specified in the configuration.\nDefault: `1` (for backwards compatibility).\n", - (uint u) => this[OpcUaSubscriptionConfig.DefaultQueueSize] = u.ToString(CultureInfo.CurrentCulture) }, + (uint u) => this[OpcUaSubscriptionConfig.DefaultQueueSizeKey] = u.ToString(CultureInfo.CurrentCulture) }, + { $"aq|autosetqueuesize:|{OpcUaSubscriptionConfig.AutoSetQueueSizesKey}:", + "(Experimental) Automatically calculate queue sizes for monitored items using the subscription publishing interval and the item's sampling rate as max(configured queue size, roundup(publishinginterval / samplinginterval)).\nNote that the server might revise the queue size down if it cannot handle the calculated size.\nDefault: `false` (disabled).\n", + (bool? b) => this[OpcUaSubscriptionConfig.AutoSetQueueSizesKey] = b?.ToString() ?? "True" }, { $"ndo|nodiscardold:|{OpcUaSubscriptionConfig.DefaultDiscardNewKey}:", "The publisher is using this as default value for the discard old setting of monitored item queue configuration. Setting to true will ensure that new values are dropped before older ones are drained. \nDefault: `false` (which is the OPC UA default).\n", (bool? b) => this[OpcUaSubscriptionConfig.DefaultDiscardNewKey] = b?.ToString() ?? "True" }, @@ -357,6 +360,9 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) { $"spw|enablesessionperwriter:|{OpcUaSubscriptionConfig.EnableSessionPerDataSetWriterIdKey}:", $"Enable creating a separate session per data set writer instead of the default behavior to create one per writer group.\nThis setting overrides the `--dsg` option.\nDefault: `{OpcUaSubscriptionConfig.EnableSessionPerDataSetWriterIdDefault}`.\n", (bool? b) => this[OpcUaSubscriptionConfig.EnableSessionPerDataSetWriterIdKey] = b?.ToString() ?? "True" }, + { $"ipi|ignorepublishingintervals:|{PublisherConfig.IgnoreConfiguredPublishingIntervalsKey}:", + $"Always use the publishing interval provided via command line argument `--op` and ignore all publishing interval settings in the configuration.\nCombine with `--op=0` to let the server use the lowest publishing interval it can support.\nDefault: `{PublisherConfig.IgnoreConfiguredPublishingIntervalsDefault}` (disabled).\n", + (bool? b) => this[PublisherConfig.IgnoreConfiguredPublishingIntervalsKey] = b?.ToString() ?? "True" }, "", "OPC UA Client configuration", diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj index 389d89547b..3f37b0e32c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj @@ -3,7 +3,7 @@ net8.0 - + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj index 5278e7631e..bc243e294d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/cli/TestServerFactory.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/cli/TestServerFactory.cs index 0dd50c4c3a..9c21bfbd14 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/cli/TestServerFactory.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/cli/TestServerFactory.cs @@ -101,15 +101,20 @@ public static ApplicationConfiguration CreateServerConfiguration( IEnumerable ports, string pkiRootPath, Action configure) { - var extensions = new List { - new MemoryBuffer.MemoryBufferConfiguration { - Buffers = new MemoryBuffer.MemoryBufferInstanceCollection { - new MemoryBuffer.MemoryBufferInstance { + var extensions = new List + { + new MemoryBuffer.MemoryBufferConfiguration + { + Buffers = new MemoryBuffer.MemoryBufferInstanceCollection + { + new MemoryBuffer.MemoryBufferInstance + { Name = "UInt32", TagCount = 10000, DataType = "UInt32" }, - new MemoryBuffer.MemoryBufferInstance { + new MemoryBuffer.MemoryBufferInstance + { Name = "Double", TagCount = 100, DataType = "Double" diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerConsoleHost.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerConsoleHost.cs index faddf569b5..8eff353ce7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerConsoleHost.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerConsoleHost.cs @@ -182,7 +182,8 @@ private async Task StartServerInternalAsync(IEnumerable ports, string pkiRo { ApplicationInstance.MessageDlg = new DummyDialog(); - var config = _factory.CreateServer(ports, pkiRootPath, out _server); + var config = _factory.CreateServer(ports, pkiRootPath, out _server, + configuration => configuration.DiagnosticsEnabled = true); _logger.LogInformation("Server {Instance} created...", this); config.SecurityConfiguration.AutoAcceptUntrustedCertificates = AutoAccept; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs index c27ceb2890..05a39e3a77 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs @@ -61,6 +61,11 @@ public static XmlElementCollection Extensions /// public bool LogStatus { get; set; } + /// + /// Whether to enable diagnostics + /// + public bool EnableDiagnostics { get; set; } + /// /// Server factory /// @@ -103,7 +108,7 @@ public ApplicationConfiguration CreateServer(IEnumerable ports, Action configure) { server = new Server(LogStatus, _nodes, _logger); - return Server.CreateServerConfiguration(ports, pkiRootPath); + return Server.CreateServerConfiguration(ports, pkiRootPath, EnableDiagnostics); } /// @@ -131,19 +136,25 @@ public Server(bool logStatus, IEnumerable nodes, /// /// /// + /// /// public static ApplicationConfiguration CreateServerConfiguration( - IEnumerable ports, string pkiRootPath) + IEnumerable ports, string pkiRootPath, bool enableDiagnostics = false) { - var extensions = new List { - new MemoryBuffer.MemoryBufferConfiguration { - Buffers = new MemoryBuffer.MemoryBufferInstanceCollection { - new MemoryBuffer.MemoryBufferInstance { + var extensions = new List + { + new MemoryBuffer.MemoryBufferConfiguration + { + Buffers = new MemoryBuffer.MemoryBufferInstanceCollection + { + new MemoryBuffer.MemoryBufferInstance + { Name = "UInt32", TagCount = 10000, DataType = "UInt32" }, - new MemoryBuffer.MemoryBufferInstance { + new MemoryBuffer.MemoryBufferInstance + { Name = "Double", TagCount = 100, DataType = "Double" @@ -230,7 +241,7 @@ public static ApplicationConfiguration CreateServerConfiguration( }, NodeManagerSaveFile = "nodes.xml", - DiagnosticsEnabled = false, + DiagnosticsEnabled = enableDiagnostics, ShutdownDelay = 0, // Runtime configuration @@ -284,7 +295,7 @@ public static ApplicationConfiguration CreateServerConfiguration( MaxNotificationQueueSize = 100, MaxNotificationsPerPublish = 1000, MinMetadataSamplingInterval = 1000, - MaxPublishRequestCount = 20, + MaxPublishRequestCount = 8, MaxSubscriptionCount = 30, MaxEventQueueSize = 10000, MinSubscriptionLifetime = 10000, diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/DataSetWriterModelEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/DataSetWriterModelEx.cs index d074a8566e..7c3d2bc95a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/DataSetWriterModelEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/DataSetWriterModelEx.cs @@ -22,12 +22,13 @@ public static class DataSetWriterModelEx /// /// /// + /// /// /// public static SubscriptionModel ToSubscriptionModel( this DataSetWriterModel dataSetWriter, OpcUaSubscriptionOptions configuration, Func configure, string? writerGroupName = null, - bool? fetchBrowsePathFromRootOverride = null) + bool? fetchBrowsePathFromRootOverride = null, bool? ignoreConfiguredPublishingIntervals = null) { if (dataSetWriter.DataSet == null) { @@ -48,7 +49,8 @@ public static SubscriptionModel ToSubscriptionModel( Id = ToSubscriptionId(dataSetWriter, writerGroupName, configuration), MonitoredItems = monitoredItems, Configuration = dataSetWriter.DataSet?.DataSetSource.ToSubscriptionConfigurationModel( - dataSetWriter.DataSet.DataSetMetaData, configuration, fetchBrowsePathFromRootOverride) + dataSetWriter.DataSet.DataSetMetaData, configuration, fetchBrowsePathFromRootOverride, + ignoreConfiguredPublishingIntervals) }; } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs index cb126af61c..8bbd19b845 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs @@ -24,10 +24,12 @@ public static class PublishedDataSetSourceModelEx /// /// /// + /// /// public static SubscriptionConfigurationModel ToSubscriptionConfigurationModel( this PublishedDataSetSourceModel dataSetSource, DataSetMetaDataModel? dataSetMetaData, - OpcUaSubscriptionOptions options, bool? fetchBrowsePathFromRootOverride) + OpcUaSubscriptionOptions options, bool? fetchBrowsePathFromRootOverride, + bool? ignoreConfiguredPublishingIntervals) { return new SubscriptionConfigurationModel { @@ -37,7 +39,8 @@ public static SubscriptionConfigurationModel ToSubscriptionConfigurationModel( ?? options.DefaultLifeTimeCount, KeepAliveCount = dataSetSource.SubscriptionSettings?.MaxKeepAliveCount ?? options.DefaultKeepAliveCount, - PublishingInterval = dataSetSource.SubscriptionSettings?.PublishingInterval + PublishingInterval = ignoreConfiguredPublishingIntervals == true ? + options.DefaultPublishingInterval : dataSetSource.SubscriptionSettings?.PublishingInterval ?? options.DefaultPublishingInterval, UseDeferredAcknoledgements = dataSetSource.SubscriptionSettings?.UseDeferredAcknoledgements ?? options.UseDeferredAcknoledgements, @@ -204,6 +207,7 @@ internal static IEnumerable ToMonitoredItems( // as revisedQueueSize for event monitored items. // QueueSize = options.DefaultQueueSize ?? 0, + AutoSetQueueSize = options.AutoSetQueueSizes ?? false, FetchDataSetFieldName = publishedEvent.ReadEventNameFromNode ?? settings?.ResolveDisplayName ?? options.ResolveDisplayName, @@ -240,8 +244,8 @@ internal static IEnumerable ToMonitoredItems( // 0 the Server returns the default queue size for Event Notifications // as revisedQueueSize for event monitored items. // - QueueSize = publishedEvent.QueueSize - ?? options.DefaultQueueSize ?? 0, + QueueSize = publishedEvent.QueueSize ?? options.DefaultQueueSize ?? 0, + AutoSetQueueSize = options.AutoSetQueueSizes ?? false, AttributeId = null, MonitoringMode = publishedEvent.MonitoringMode, StartNodeId = eventNotifier, @@ -321,6 +325,7 @@ internal static IEnumerable ToMonitoredItems( QueueSize = publishedVariable.ServerQueueSize ?? options.DefaultQueueSize ?? 1, + AutoSetQueueSize = options.AutoSetQueueSizes ?? false, RelativePath = publishedVariable.BrowsePath, AttributeId = publishedVariable.Attribute, IndexRange = publishedVariable.IndexRange, diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/IClientDiagnostics.cs b/src/Azure.IIoT.OpcUa.Publisher/src/IClientDiagnostics.cs index 22a6dd2544..de68e3e17f 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/IClientDiagnostics.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/IClientDiagnostics.cs @@ -17,25 +17,37 @@ namespace Azure.IIoT.OpcUa.Publisher public interface IClientDiagnostics { /// - /// Get diagnostic information of all connections - /// abd return. + /// Get all active connections + /// + IReadOnlyList ActiveConnections { get; } + + /// + /// Get diagnostic information of all channels. /// /// - IReadOnlyList Diagnostics { get; } + IReadOnlyList ChannelDiagnostics { get; } /// /// Reset all connections that are currently running /// /// /// - Task ResetAllClientsAsync(CancellationToken ct = default); + Task ResetAllConnectionsAsync(CancellationToken ct = default); /// /// Watch diagnostic information of all connections. /// /// /// - IAsyncEnumerable MonitorAsync( + IAsyncEnumerable WatchChannelDiagnosticsAsync( + CancellationToken ct = default); + + /// + /// Get connection diagnostics + /// + /// + /// + IAsyncEnumerable GetConnectionDiagnosticsAsync( CancellationToken ct = default); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Parser/RelativePathParser.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Parser/RelativePathParser.cs index b640e74adc..cfef3014b8 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Parser/RelativePathParser.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Parser/RelativePathParser.cs @@ -91,8 +91,7 @@ public static IReadOnlyList AsString(this IEnumerable'); } - var escape = element.TargetName.AsSpan().IndexOfAny( -kAllowedChars) != -1; + var escape = element.TargetName.AsSpan().IndexOfAny(kAllowedChars) != -1; if (escape) { value.Append('['); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs index 1d63726b69..c147c03f7a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherConfig.cs @@ -60,6 +60,7 @@ public sealed class PublisherConfig : PostConfigureOptionBase public const string DebugLogNotificationsWithHeartbeatKey = "DebugLogNotificationsWithHeartbeat"; public const string MaxNodesPerDataSetKey = "MaxNodesPerDataSet"; public const string ScaleTestCountKey = "ScaleTestCount"; + public const string IgnoreConfiguredPublishingIntervalsKey = "IgnoreConfiguredPublishingIntervals"; public const string DisableOpenApiEndpointKey = "DisableOpenApiEndpoint"; public const string DefaultNamespaceFormatKey = "DefaultNamespaceFormat"; public const string MessageTimestampKey = "MessageTimestamp"; @@ -129,6 +130,7 @@ public sealed class PublisherConfig : PostConfigureOptionBase public const int BatchTriggerIntervalLLegacyDefaultMillis = 10 * 1000; public const int DiagnosticsIntervalDefaultMillis = 60 * 1000; public const int ScaleTestCountDefault = 1; + public const bool IgnoreConfiguredPublishingIntervalsDefault = false; public static readonly int UnsecureHttpServerPortDefault = IsContainer ? 80 : 9071; public static readonly int HttpServerPortDefault = IsContainer ? 443 : 9072; #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member @@ -233,6 +235,8 @@ public override void PostConfigure(string? name, PublisherOptions options) MaxNetworkMessageSendQueueSizeDefault); options.DefaultWriterGroupPartitions ??= GetIntOrNull(DefaultWriterGroupPartitionCountKey); + options.IgnoreConfiguredPublishingIntervals ??= GetBoolOrDefault(IgnoreConfiguredPublishingIntervalsKey, + IgnoreConfiguredPublishingIntervalsDefault); if (options.TopicTemplates.Root == null) { diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherOptions.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherOptions.cs index f62cc42830..bc956c4555 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherOptions.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/PublisherOptions.cs @@ -85,7 +85,7 @@ public sealed class PublisherOptions public int? MaxNetworkMessageSize { get; set; } /// - /// Diagnostics interval + /// ChannelDiagnostics interval /// public TimeSpan? DiagnosticsInterval { get; set; } @@ -213,6 +213,11 @@ public sealed class PublisherOptions /// public int? ScaleTestCount { get; set; } + /// + /// Ignore all publishing intervals set in the configuration. + /// + public bool? IgnoreConfiguredPublishingIntervals { get; set; } + /// /// Allow setting or overriding the current api key /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicBuilder.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicBuilder.cs index ab5ec986d9..1575d8887f 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicBuilder.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicBuilder.cs @@ -37,7 +37,7 @@ public string EventsTopic ?? _options.TopicTemplates.Events); /// - /// Diagnostics topic + /// ChannelDiagnostics topic /// public string DiagnosticsTopic => Format(nameof(DiagnosticsTopic), _templates.Diagnostics diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicTemplatesOptions.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicTemplatesOptions.cs index d4a65c7fc4..ca09b1080a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicTemplatesOptions.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Runtime/TopicTemplatesOptions.cs @@ -26,7 +26,7 @@ public sealed record class TopicTemplatesOptions public string? Events { get; set; } /// - /// Diagnostics topic template + /// ChannelDiagnostics topic template /// public string? Diagnostics { get; set; } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs index f33be44b9a..1405254037 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs @@ -318,7 +318,7 @@ public async Task RemoveDataSetWriterEntryAsync(string writerGroupId, public async Task PublishStartAsync(ConnectionModel endpoint, PublishStartRequestModel request, CancellationToken ct = default) { - if (request.Item is null) + if (request.Item?.NodeId is null) { throw new BadRequestException(kNullOrEmptyOpcNodesMessage); } @@ -375,6 +375,11 @@ public async Task PublishBulkAsync(ConnectionModel end { throw new BadRequestException(kNullOrEmptyOpcNodesMessage); } + if ((request.NodesToRemove?.Any(n => n == null) ?? false) || + (request.NodesToAdd?.Any(n => n.NodeId == null) ?? false)) + { + throw new BadRequestException(kNullOrEmptyOpcNodesMessage); + } await _api.WaitAsync(ct).ConfigureAwait(false); try { @@ -416,10 +421,10 @@ public async Task PublishListAsync(ConnectionMod try { var entry = endpoint.ToPublishedNodesEntry(); - var existingGroups = new List(); + var entries = GetCurrentPublishedNodes().ToList(); return new PublishedItemListResponseModel { - Items = GetCurrentPublishedNodes() + Items = entries .Where(n => n.HasSameDataSet(entry)) .SelectMany(n => n.OpcNodes ?? new List()) .Where(n => n.EventFilter == null) // Exclude event filtering @@ -674,6 +679,13 @@ public async Task UnpublishAllNodesAsync(PublishedNodesEntryModel request, public async Task SetConfiguredEndpointsAsync(IReadOnlyList request, CancellationToken ct = default) { + foreach (var entry in request) + { + if (entry.OpcNodes != null) + { + entry.OpcNodes = ValidateNodes(entry.OpcNodes, false); + } + } await _api.WaitAsync(ct).ConfigureAwait(false); try { @@ -725,6 +737,7 @@ public async Task AddOrUpdateEndpointsAsync(IReadOnlyList e.OpcNodes?.Count > 0)) { + ValidateNodes(updateRequest.OpcNodes!, false); // We will add the update request entry and clean up anything else matching. var found = currentNodes.FirstOrDefault(entry => entry.HasSameDataSet(updateRequest)); if (found != null) @@ -818,7 +831,7 @@ public async Task> GetDiagnosticInfoAsync( var result = new List(); if (_diagnostics == null) { - // Diagnostics disabled + // ChannelDiagnostics disabled throw new ResourceInvalidStateException("Diagnostics service is disabled."); } foreach (var nodes in GetCurrentPublishedNodes().GroupBy(k => k.GetUniqueWriterGroupId())) @@ -1152,13 +1165,13 @@ private static IList ValidateNodes(IList opcNodes, var set = new HashSet(); foreach (var node in opcNodes) { - if (string.IsNullOrWhiteSpace(node.Id)) + if (!node.TryGetId(out var id)) { throw new BadRequestException("Node must contain a node ID"); } if (dataSetWriterApiRequirements) { - node.DataSetFieldId ??= node.Id; + node.DataSetFieldId ??= id; set.Add(node.DataSetFieldId); if (node.OpcPublishingInterval != null || node.OpcPublishingIntervalTimespan != null) @@ -1191,6 +1204,8 @@ private static void AddItem(List currentNodes, currentNodes.Add(entry); found = entry; } + found.MessageEncoding = MessageEncoding.Json; + found.MessagingMode = MessagingMode.FullSamples; found.OpcNodes ??= new List(); var node = found.OpcNodes.FirstOrDefault(n => n.Id == item.NodeId); if (node == null) diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs index dd47e96b30..3ccbdee047 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs @@ -128,6 +128,8 @@ public void Dispose() /// public async ValueTask SendRestartAnnouncementAsync(CancellationToken ct) { + var hostAddresses = await GetHostAddressesAsync(ct).ConfigureAwait(false); + // Set runtime state in state stores foreach (var store in _stores) { @@ -139,6 +141,8 @@ public async ValueTask SendRestartAnnouncementAsync(CancellationToken ct) GetType().Assembly.GetReleaseVersion().ToString(); store.State[OpcUa.Constants.TwinPropertyFullVersionKey] = PublisherConfig.Version; + store.State[OpcUa.Constants.TwinPropertyIpAddressesKey] = + hostAddresses; if (_options.Value.HttpServerPort.HasValue) { @@ -184,6 +188,27 @@ public async ValueTask SendRestartAnnouncementAsync(CancellationToken ct) _runtimeState = RuntimeStateEventType.Running; } + /// + /// Get comma seperated host addresses + /// + /// + /// + private async Task GetHostAddressesAsync(CancellationToken ct) + { + try + { + var host = await Dns.GetHostEntryAsync(Dns.GetHostName(), + ct).ConfigureAwait(false); + return host.AddressList.Select(ip => ip.ToString()) + .Aggregate((a, b) => a + ", " + b); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resolve hostname."); + return VariantValue.Null; + } + } + /// /// Update cached api key /// @@ -403,7 +428,7 @@ await events.SendEventAsync(new TopicBuilder(_options.Value).EventsTopic, } /// - /// Diagnostics timer to dump out all diagnostics + /// ChannelDiagnostics timer to dump out all diagnostics /// /// private async Task DiagnosticsOutputTimerAsync(CancellationToken ct) diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs index 027be367f1..984b36f42b 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs @@ -374,7 +374,8 @@ public DataSetWriterSubscription(WriterGroupDataSource outer, _outer._options.Value.DefaultDataSetRouting ?? DataSetRoutingMode.None; _subscriptionInfo = _dataSetWriter.ToSubscriptionModel( _outer._subscriptionConfig.Value, CreateMonitoredItemContext, - outer._writerGroup.Name, _routing != DataSetRoutingMode.None); + outer._writerGroup.Name, _routing != DataSetRoutingMode.None, + outer._options.Value.IgnoreConfiguredPublishingIntervals); DataSetWriterName = _dataSetWriter.DataSetWriterName; for (var index = 1; ; index++) @@ -482,7 +483,8 @@ public void Update(DataSetWriterModel dataSetWriter) _dataSetWriter.DataSetWriterName = DataSetWriterName; _subscriptionInfo = _dataSetWriter.ToSubscriptionModel( _outer._subscriptionConfig.Value, CreateMonitoredItemContext, - _outer._writerGroup.Name, _routing != DataSetRoutingMode.None); + _outer._writerGroup.Name, _routing != DataSetRoutingMode.None, + _outer._options.Value.IgnoreConfiguredPublishingIntervals); var subscription = Subscription; if (subscription == null) diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSession.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSession.cs index c384f9d4f7..98cfea0ac0 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSession.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSession.cs @@ -74,6 +74,14 @@ public interface IOpcUaSession ValueTask GetOperationLimitsAsync( CancellationToken ct = default); + /// + /// Get server diagnostics + /// + /// + /// + ValueTask GetServerDiagnosticAsync( + CancellationToken ct = default); + /// /// Get history capabilities of the server /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionCallbacks.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionCallbacks.cs index 24064d1d97..c1ba0a468f 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionCallbacks.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionCallbacks.cs @@ -46,7 +46,7 @@ public void OnSubscriptionEventReceived( IOpcUaSubscriptionNotification notification); /// - /// Diagnostics for data change notifications + /// ChannelDiagnostics for data change notifications /// /// /// @@ -56,7 +56,7 @@ void OnSubscriptionDataDiagnosticsChange(bool liveData, int valueChanges, int overflow, int heartbeats); /// - /// Diagnostics for data change notifications + /// ChannelDiagnostics for data change notifications /// /// /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/BaseMonitoredItemModel.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/BaseMonitoredItemModel.cs index 4bb193c566..99d3da5613 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/BaseMonitoredItemModel.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/BaseMonitoredItemModel.cs @@ -76,6 +76,11 @@ public string DisplayName /// public uint QueueSize { get; init; } + /// + /// Auto calculate queue size using publishing interval + /// + public bool AutoSetQueueSize { get; init; } + /// /// Discard new values if queue is full /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs index 6acf244ae8..2a3b9ea3f5 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs @@ -269,8 +269,8 @@ public override void PostConfigure(string? name, OpcUaClientOptions options) } } - options.MinPublishRequests ??= GetIntOrDefault(MinPublishRequestsKey); - options.MaxPublishRequests ??= GetIntOrDefault(MaxPublishRequestsKey); + options.MinPublishRequests ??= GetIntOrNull(MinPublishRequestsKey); + options.MaxPublishRequests ??= GetIntOrNull(MaxPublishRequestsKey); options.PublishRequestsPerSubscriptionPercent ??= GetIntOrNull( PublishRequestsPerSubscriptionPercentKey); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionConfig.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionConfig.cs index 5a207bfbfd..2332c17161 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionConfig.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionConfig.cs @@ -34,7 +34,8 @@ public sealed class OpcUaSubscriptionConfig : PostConfigureOptionBase /// Default values /// - public const int DefaultKeepAliveCountDefault = 10; + public const int DefaultKeepAliveCountDefault = 0; #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member public const bool ResolveDisplayNameDefault = false; - public const int DefaultLifetimeCountDefault = 100; + public const int DefaultLifetimeCountDefault = 0; public const int DefaultSamplingIntervalDefaultMillis = 1000; public const int DefaultPublishingIntervalDefaultMillis = 1000; public const int AsyncMetaDataLoadThresholdDefault = 30; @@ -110,9 +111,8 @@ public override void PostConfigure(string? name, OpcUaSubscriptionOptions option { options.DefaultPublishingInterval = GetDurationOrNull(DefaultPublishingIntervalKey) ?? TimeSpan.FromMilliseconds(GetIntOrDefault(DefaultPublishingIntervalKey, - DefaultPublishingIntervalDefaultMillis)); + DefaultPublishingIntervalDefaultMillis)); } - if (options.DefaultMonitoredItemWatchdogTimeout == null) { var watchdogInterval = GetIntOrNull(DefaultMonitoredItemWatchdogSecondsKey); @@ -170,7 +170,8 @@ public override void PostConfigure(string? name, OpcUaSubscriptionOptions option options.DefaultKeyFrameCount ??= (uint?)GetIntOrNull(DefaultKeyFrameCountKey); options.ResolveDisplayName ??= GetBoolOrDefault(FetchOpcNodeDisplayNameKey, ResolveDisplayNameDefault); - options.DefaultQueueSize ??= (uint?)GetIntOrNull(DefaultQueueSize); + options.DefaultQueueSize ??= (uint?)GetIntOrNull(DefaultQueueSizeKey); + options.AutoSetQueueSizes ??= GetBoolOrNull(AutoSetQueueSizesKey); var unsMode = _options.Value.DefaultDataSetRouting ?? DataSetRoutingMode.None; options.FetchOpcBrowsePathFromRoot ??= unsMode != DataSetRoutingMode.None diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionOptions.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionOptions.cs index 88e9d0dae6..4b9cb86a50 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionOptions.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaSubscriptionOptions.cs @@ -122,6 +122,13 @@ public sealed class OpcUaSubscriptionOptions /// public uint? DefaultQueueSize { get; set; } + /// + /// Automatically calculate queue sizes based on the + /// publishing interval and sampling interval as + /// max(1, roundup(subscription pi / si)). + /// + public bool? AutoSetQueueSizes { get; set; } + /// /// Use deferred acnkoledgement (experimental) /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs index 15138dca0d..3a5d596507 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs @@ -28,7 +28,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack.Services using System.Threading.Channels; using System.Threading.Tasks; using Opc.Ua.Extensions; - using Azure.IIoT.OpcUa.Encoders.Schemas.Json; /// /// OPC UA Client based on official ua client reference sample. @@ -114,7 +113,7 @@ internal sealed partial class OpcUaClient : DefaultSessionFactory, IOpcUaClient, /// /// Last diagnostic information on this client /// - internal ConnectionDiagnosticModel LastDiagnostics => _lastDiagnostics; + internal ChannelDiagnosticModel LastDiagnostics => _lastDiagnostics; /// /// No complex type loading ever @@ -189,7 +188,7 @@ public OpcUaClient(ApplicationConfiguration configuration, Meter meter, IMetricsContext metrics, EventHandler? notifier, ReverseConnectManager? reverseConnectManager, - Action diagnosticsCallback, + Action diagnosticsCallback, TimeSpan? maxReconnectPeriod = null, string? sessionName = null) { _timeProvider = timeProvider; @@ -200,7 +199,7 @@ public OpcUaClient(ApplicationConfiguration configuration, _connection = connection.Connection; _diagnosticsCb = diagnosticsCallback; - _lastDiagnostics = new ConnectionDiagnosticModel + _lastDiagnostics = new ChannelDiagnosticModel { Connection = _connection, TimeStamp = _timeProvider.GetUtcNow() @@ -385,6 +384,21 @@ internal Task ResetAsync(CancellationToken ct) return tcs.Task; } + /// + /// Get session diagnostics + /// + /// + /// + internal async Task GetSessionDiagnosticsAsync( + CancellationToken ct = default) + { + if (_session?.Connected == true) + { + return await _session.GetServerDiagnosticAsync(ct).ConfigureAwait(false); + } + return null; + } + /// /// Close client /// @@ -784,7 +798,7 @@ private async Task ManageSessionStateMachineAsync(CancellationToken ct) currentSubscriptions = _session.SubscriptionHandles; // // Equality is through subscriptionidentifer therefore only subscriptions - // that are not yet created inside the session remain in queued state. + // that are not yet createdSubscriptions inside the session remain in queued state. // queuedSubscriptions.ExceptWith(currentSubscriptions); await ApplySubscriptionAsync(currentSubscriptions, queuedSubscriptions, @@ -915,7 +929,7 @@ await ApplySubscriptionAsync(new[] { item }, queuedSubscriptions, currentSubscriptions = _session.SubscriptionHandles; // // Equality is through subscriptionidentifer therefore only subscriptions - // that are not yet created inside the session remain in queued state. + // that are not yet createdSubscriptions inside the session remain in queued state. // queuedSubscriptions.ExceptWith(currentSubscriptions); await ApplySubscriptionAsync(currentSubscriptions, queuedSubscriptions, @@ -1047,7 +1061,7 @@ await Task.WhenAll(subscriptions.Concat(extra).Select(async subscription => } })).ConfigureAwait(false); - EnsureMinimumNumberOfPublishRequestsQueued(); + UpdatePublishRequestCounts(); if (numberOfSubscriptions > 1) { @@ -1122,72 +1136,60 @@ private void LogNamespaceTableChanges(string[] oldTable, string[] newTable) private const int kPublishTimeoutsMultiplier = 3; /// - /// Ensure min publish requests are queued + /// Ensure min publish requests are configured correctly /// - private void EnsureMinimumNumberOfPublishRequestsQueued() + private void UpdatePublishRequestCounts() { var session = _session; if (session == null) { return; } - var created = SubscriptionCount; - if (created == 0) - { - return; - } - var minPublishRequests = MinPublishRequests ?? kMinPublishRequestCount; + var minPublishRequests = MinPublishRequests ?? 0; if (minPublishRequests <= 0) { - minPublishRequests = 1; - } - var percentage = PublishRequestsPerSubscriptionPercent ?? 100; - var desiredRequests = Math.Max(minPublishRequests, - percentage == 100 || percentage <= 0 ? created : - (int)Math.Ceiling(created * (percentage / 100.0))); - if (desiredRequests <= 0) - { - // Dont allow negative or 0 - desiredRequests = minPublishRequests; + minPublishRequests = kMinPublishRequestCount; } - if (!PublishRequestsPerSubscriptionPercent.HasValue || MaxPublishRequests > 0) + var maxPublishRequests = MaxPublishRequests ?? kMaxPublishRequestCount; + if (maxPublishRequests <= 0 || maxPublishRequests > ushort.MaxValue) { - var maxPublishRequests = MaxPublishRequests ?? kMaxPublishRequestCount; - if (maxPublishRequests != 0 && desiredRequests > maxPublishRequests) - { - desiredRequests = maxPublishRequests; - session.MaxPublishRequestCount = desiredRequests; - } + maxPublishRequests = ushort.MaxValue; } - if (_maxPublishRequests.HasValue && desiredRequests > _maxPublishRequests) + + var createdSubscriptions = SubscriptionCount; + if (PublishRequestsPerSubscriptionPercent.HasValue) { - desiredRequests = _maxPublishRequests.Value; - if (desiredRequests < minPublishRequests) + var percentage = PublishRequestsPerSubscriptionPercent ?? 100; + var minPublishOverride = percentage == 100 || percentage <= 0 ? + createdSubscriptions : + (int)Math.Ceiling(createdSubscriptions * (percentage / 100.0)); + if (minPublishRequests < minPublishOverride) { - desiredRequests = minPublishRequests; + minPublishRequests = minPublishOverride; } - session.MaxPublishRequestCount = desiredRequests; } - session.MinPublishRequestCount = desiredRequests; + Debug.Assert(minPublishRequests > 0); + Debug.Assert(maxPublishRequests > 0); - var currently = OutstandingRequestCount; - var additionalRequests = desiredRequests - currently; - if (additionalRequests <= 0) + if (minPublishRequests > maxPublishRequests) { - return; + // Dont allow min to be higher than max + minPublishRequests = maxPublishRequests; } - _logger.LogDebug( - "{Client}: Ensuring publish request count {Count} is {Desired} requests.", - this, currently, desiredRequests); + // + // The stack will choose a value based on the subscription + // count that is between min and max. + // + session.MinPublishRequestCount = minPublishRequests; + session.MaxPublishRequestCount = maxPublishRequests; - // Queue requests - for (var i = 0; i < additionalRequests; i++) + if (createdSubscriptions > 0 && minPublishRequests > OutstandingRequestCount) { - session.BeginPublish(session.OperationTimeout); + session.StartPublishing(session.OperationTimeout, false); } } @@ -1300,7 +1302,7 @@ private async ValueTask TryConnectAsync(CancellationToken ct) session.RenewUserIdentity += (_, _) => userIdentity; - // Assign the created session + // Assign the createdSubscriptions session var isNew = await UpdateSessionAsync(session).ConfigureAwait(false); Debug.Assert(isNew); _logger.LogInformation( @@ -1332,47 +1334,16 @@ private async ValueTask TryConnectAsync(CancellationToken ct) internal void Session_HandlePublishError(ISession session, PublishErrorEventArgs e) #pragma warning restore IDE0060 // Remove unused parameter { - switch (e.Status.Code) + if (session.Connected) { - case StatusCodes.BadTooManyPublishRequests: - if (!session.Connected) - { - return; - } - var limit = GoodPublishRequestCount - 1; - if (MaxPublishRequests.HasValue && limit > MaxPublishRequests) - { - limit = MaxPublishRequests.Value; - } - var minPublishRequests = MinPublishRequests ?? kMinPublishRequestCount; - if (minPublishRequests <= 0) - { - minPublishRequests = 1; - } - if (limit <= minPublishRequests || limit == _maxPublishRequests) - { - break; - } - _maxPublishRequests = limit; - if (session is OpcUaSession s) - { - s.MaxPublishRequestCount = limit; - } - _logger.LogInformation( - "{Client}: Too many publish request error: Limiting number of requests to {Limit}...", - this, _maxPublishRequests.Value); - return; - default: - if (session.Connected) - { - _logger.LogInformation("{Client}: Publish error: {Error} (Actively handled: {Active})...", - this, e.Status, ActivePublishErrorHandling); - break; - } - _logger.LogDebug( - "{Client}: Disconnected - publish error: {Error} (Actively handled: {Active})...", - this, e.Status, ActivePublishErrorHandling); - break; + _logger.LogInformation("{Client}: Publish error: {Error} (Actively handled: {Active})...", + this, e.Status, ActivePublishErrorHandling); + } + else + { + _logger.LogDebug( + "{Client}: Disconnected - publish error: {Error} (Actively handled: {Active})...", + this, e.Status, ActivePublishErrorHandling); } if (!ActivePublishErrorHandling) @@ -1491,7 +1462,7 @@ internal void Session_KeepAlive(ISession session, KeepAliveEventArgs e) } else if (SubscriptionCount > 0 && GoodPublishRequestCount == 0) { - EnsureMinimumNumberOfPublishRequestsQueued(); + UpdatePublishRequestCounts(); } } catch (Exception ex) @@ -1540,22 +1511,19 @@ private async ValueTask UpdateSessionAsync(ISession session) var oldTable = _session?.NamespaceUris.ToArray(); Debug.Assert(_reconnectingSession == null); - if (ReferenceEquals(_session, session)) + var isNewSession = false; + if (!ReferenceEquals(_session, session)) { - // Not a new session - NotifyConnectivityStateChange(EndpointConnectivityState.Ready); - UpdateNamespaceTableAndSessionDiagnostics((OpcUaSession)session, - oldTable); - return false; + await CloseSessionAsync().ConfigureAwait(false); + _session = (OpcUaSession)session; + isNewSession = true; + kSessions.Add(1, _metrics.TagList); } - await CloseSessionAsync().ConfigureAwait(false); - _session = (OpcUaSession)session; - + UpdatePublishRequestCounts(); NotifyConnectivityStateChange(EndpointConnectivityState.Ready); UpdateNamespaceTableAndSessionDiagnostics(_session, oldTable); - kSessions.Add(1, _metrics.TagList); - return true; + return isNewSession; void UpdateNamespaceTableAndSessionDiagnostics(OpcUaSession session, string[]? oldTable) @@ -1600,8 +1568,8 @@ private void UpdateConnectionDiagnosticFromSession(OpcUaSession session) var now = _timeProvider.GetUtcNow(); - var elapsed = now - _lastDiagnostics.TimeStamp; - var lastChannel = _lastDiagnostics.ChannelDiagnostics; + var lastDiagnostics = _lastDiagnostics; + var elapsed = now - lastDiagnostics.TimeStamp; var channelChanged = false; if (token != null) @@ -1613,10 +1581,10 @@ private void UpdateConnectionDiagnosticFromSession(OpcUaSession session) // try again after the remaining lifetime or every second // until it changed unless the token is then later gone. // - channelChanged = !(lastChannel != null && - lastChannel.ChannelId == token.ChannelId && - lastChannel.TokenId == token.TokenId && - lastChannel.CreatedAt == token.CreatedAt); + channelChanged = !(lastDiagnostics != null && + lastDiagnostics.ChannelId == token.ChannelId && + lastDiagnostics.TokenId == token.TokenId && + lastDiagnostics.CreatedAt == token.CreatedAt); var lifetime = TimeSpan.FromMilliseconds(token.Lifetime); if (channelChanged) @@ -1663,7 +1631,7 @@ private void UpdateConnectionDiagnosticFromSession(OpcUaSession session) return; } - _lastDiagnostics = new ConnectionDiagnosticModel + _lastDiagnostics = new ChannelDiagnosticModel { Connection = _connection, TimeStamp = now, @@ -1673,18 +1641,15 @@ private void UpdateConnectionDiagnosticFromSession(OpcUaSession session) RemotePort = remotePort == -1 ? null : remotePort, LocalIpAddress = localIpAddress, LocalPort = localPort == -1 ? null : localPort, - - ChannelDiagnostics = token != null ? new ChannelDiagnosticModel - { - ChannelId = token.ChannelId, - TokenId = token.TokenId, - CreatedAt = token.CreatedAt, - Lifetime = TimeSpan.FromMilliseconds(token.Lifetime), - Client = ToChannelKey(token.ClientInitializationVector, - token.ClientEncryptingKey, token.ClientSigningKey), - Server = ToChannelKey(token.ServerInitializationVector, - token.ServerEncryptingKey, token.ServerSigningKey) - } : null + ChannelId = token?.ChannelId, + TokenId = token?.TokenId, + CreatedAt = token?.CreatedAt, + Lifetime = token == null ? null : + TimeSpan.FromMilliseconds(token.Lifetime), + Client = ToChannelKey(token?.ClientInitializationVector, + token?.ClientEncryptingKey, token?.ClientSigningKey), + Server = ToChannelKey(token?.ServerInitializationVector, + token?.ServerEncryptingKey, token?.ServerSigningKey) }; _diagnosticsCb(_lastDiagnostics); _logger.LogDebug("{Client}: Diagnostics information updated.", this); @@ -2152,11 +2117,10 @@ private void InitializeMetrics() private int _numberOfConnectRetries; private bool _disposed; private int _refCount; - private int? _maxPublishRequests; private int _publishTimeoutCounter; private int _keepAliveCounter; private int _namespaceTableChanges; - private ConnectionDiagnosticModel _lastDiagnostics; + private ChannelDiagnosticModel _lastDiagnostics; private readonly ReverseConnectManager? _reverseConnectManager; private readonly AsyncReaderWriterLock _lock = new(); private readonly ApplicationConfiguration _configuration; @@ -2176,7 +2140,7 @@ private void InitializeMetrics() #pragma warning restore CA2213 // Disposable fields should be disposed private readonly TimeSpan _maxReconnectPeriod; private readonly Channel<(ConnectionEvent, object?)> _channel; - private readonly Action _diagnosticsCb; + private readonly Action _diagnosticsCb; private readonly EventHandler? _notifier; private readonly Dictionary<(string, TimeSpan), Sampler> _samplers = new(); private readonly Dictionary<(string, TimeSpan), Browser> _browsers = new(); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs index e3da6324ac..d1a0369b64 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs @@ -38,11 +38,15 @@ internal sealed class OpcUaClientManager : IOpcUaClientManager, public event EventHandler? OnConnectionStateChange; /// - IReadOnlyList IClientDiagnostics.Diagnostics + IReadOnlyList IClientDiagnostics.ChannelDiagnostics => _clients.Values.Select(c => c.LastDiagnostics).ToList(); + /// + public IReadOnlyList ActiveConnections + => _clients.Keys.Select(c => c.Connection).ToList(); + /// - /// Create client manager + /// Create kv manager /// /// /// @@ -81,7 +85,7 @@ public void CreateSubscription(SubscriptionModel subscription, TimeProvider? timeProvider) { ObjectDisposedException.ThrowIf(_disposed, this); - // Create subscription which will register with callback/client + // Create subscription which will register with callback/kv #pragma warning disable CA2000 // Dispose objects before losing scope _ = new OpcUaSubscription(this, callback, subscription, _options, _loggerFactory, new OpcUaClientTagList( @@ -146,21 +150,46 @@ public async Task TestConnectionAsync( } /// - public Task ResetAllClientsAsync(CancellationToken ct) + public Task ResetAllConnectionsAsync(CancellationToken ct) { return Task.WhenAll(_clients.Values.Select(c => c.ResetAsync(ct)).ToArray()); } /// - public async IAsyncEnumerable MonitorAsync( + public async IAsyncEnumerable GetConnectionDiagnosticsAsync( + [EnumeratorCancellation] CancellationToken ct) + { + foreach (var kv in _clients.ToList()) + { + SessionDiagnosticsModel? server = null; + try + { + server = await kv.Value.GetSessionDiagnosticsAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get diagnostics for client {Name}.", + kv.Value); + } + yield return new ConnectionDiagnosticsModel + { + Connection = kv.Key.Connection, + // Client = kv.Value.Diagnostics, + Server = server + }; + } + } + + /// + public async IAsyncEnumerable WatchChannelDiagnosticsAsync( [EnumeratorCancellation] CancellationToken ct) { - var queue = new AsyncProducerConsumerQueue(); + var queue = new AsyncProducerConsumerQueue(); _listeners.TryAdd(queue, true); try { // Get all items from buffer - var set = new HashSet( + var set = new HashSet( _clients.Values.Select(c => c.LastDiagnostics)); foreach (var item in set) { @@ -474,7 +503,7 @@ private static string CreateDiscoveryUri(string uri, int defaultPort) } /// - /// Load client configuration + /// Load kv configuration /// /// /// @@ -545,7 +574,7 @@ private void OnValidate(CertificateValidator sender, CertificateValidationEventA } /// - /// Get or add new client + /// Get or add new kv /// /// /// @@ -558,9 +587,9 @@ private OpcUaClient GetOrAddClient(ConnectionModel connection) throw _reverseConnectStartException.Value; } - // Find client and if not exists create + // Find kv and if not exists create var id = new ConnectionIdentifier(connection); - // try to get an existing client + // try to get an existing kv var client = _clients.GetOrAdd(id, id => { var client = new OpcUaClient(_configuration.Value, id, _serializer, @@ -632,7 +661,7 @@ private OpcUaClient GetOrAddClient(ConnectionModel connection) /// Called by clients when their connection information changed /// /// - private void OnClientConnectionDiagnosticChange(ConnectionDiagnosticModel model) + private void OnClientConnectionDiagnosticChange(ChannelDiagnosticModel model) { foreach (var listener in _listeners.Keys) { @@ -661,7 +690,7 @@ private void InitializeMetrics() private readonly ReverseConnectManager _reverseConnectManager; private readonly Lazy _reverseConnectStartException; private readonly ConcurrentDictionary< - AsyncProducerConsumerQueue, bool> _listeners = new(); + AsyncProducerConsumerQueue, bool> _listeners = new(); private readonly ConcurrentDictionary _clients = new(); private readonly IMetricsContext _metrics; private readonly Meter _meter = Diagnostics.NewMeter(); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs index 6636e7ffea..6db2f11da5 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs @@ -47,10 +47,6 @@ public Condition(EventMonitoredItemModel template, ?? _snapshotInterval; _lastSentPendingConditions = timeProvider.GetUtcNow(); _conditionHandlingState = new ConditionHandlingState(); - _conditionTimer = new TimerEx(timeProvider); - _conditionTimer.Elapsed += OnConditionTimerElapsed; - _conditionTimer.AutoReset = false; - _conditionTimer.Enabled = true; } /// @@ -68,10 +64,10 @@ private Condition(Condition item, bool copyEventHandlers, _conditionHandlingState = item._conditionHandlingState; _lastSentPendingConditions = item._lastSentPendingConditions; _callback = item._callback; - _conditionTimer = item.CloneTimer(); - if (_conditionTimer != null) + + if (_callback != null) { - _conditionTimer.Elapsed += OnConditionTimerElapsed; + EnableConditionTimer(); } } @@ -101,9 +97,12 @@ public override int GetHashCode() /// public override string ToString() { - return - $"Condition Item '{Template.StartNodeId}' with server id {RemoteId}" + - $" - {(Status?.Created == true ? "" : "not ")}created"; + var str = $"Condition Item '{Template.StartNodeId}'"; + if (RemoteId.HasValue) + { + str += $" with server id {RemoteId} ({(Status?.Created == true ? "" : "not ")}created)"; + } + return str; } /// @@ -111,8 +110,16 @@ protected override void Dispose(bool disposing) { if (disposing) { - var timer = CloneTimer(); - timer?.Dispose(); + lock (_timerLock) + { + _disposed = true; + if (_conditionTimer != null) + { + _conditionTimer.Elapsed -= OnConditionTimerElapsed; + _conditionTimer.Dispose(); + _conditionTimer = null; + } + } } base.Dispose(disposing); } @@ -124,7 +131,7 @@ protected override bool ProcessEventNotification(uint sequenceNumber, DateTimeOf Debug.Assert(Valid); Debug.Assert(Template != null); - if (_conditionTimer == null) + if (_disposed) { return false; } @@ -144,7 +151,7 @@ protected override bool ProcessEventNotification(uint sequenceNumber, DateTimeOf if (eventType == ObjectTypeIds.RefreshStartEventType) { // stop the timers during condition refresh - _conditionTimer.Enabled = false; + DisableConditionTimer(); state.Active.Clear(); _logger.LogDebug("{Item}: Stopped pending alarm handling " + "during condition refresh.", this); @@ -153,8 +160,7 @@ protected override bool ProcessEventNotification(uint sequenceNumber, DateTimeOf else if (eventType == ObjectTypeIds.RefreshEndEventType) { // restart the timers once condition refresh is done. - _conditionTimer.Interval = TimeSpan.FromSeconds(1); - _conditionTimer.Enabled = true; + EnableConditionTimer(); _logger.LogDebug("{Item}: Restarted pending alarm handling " + "after condition refresh.", this); return true; @@ -266,20 +272,15 @@ public override bool TryCompleteChanges(Subscription subscription, ref bool applyChanges, Callback cb) { var result = base.TryCompleteChanges(subscription, ref applyChanges, cb); - if (_conditionTimer == null) - { - return false; - } if (!AttachedToSubscription || !result) { + DisableConditionTimer(); _callback = null; - _conditionTimer.Enabled = false; } else { _callback = cb; - _conditionTimer.Interval = TimeSpan.FromSeconds(1); - _conditionTimer.Enabled = true; + EnableConditionTimer(); } return result; } @@ -302,11 +303,7 @@ protected override async ValueTask GetEventFilterAsync(IOpcUaSessio UpdateFieldNames(session, eventFilter, internalSelectClauses); _conditionHandlingState = conditionHandlingState; - if (_conditionTimer != null) - { - _conditionTimer.Interval = TimeSpan.FromSeconds(1); - _conditionTimer.Enabled = true; - } + EnableConditionTimer(); return eventFilter; } @@ -399,11 +396,7 @@ private void OnConditionTimerElapsed(object? sender, ElapsedEventArgs e) } finally { - if (_conditionTimer != null) - { - _conditionTimer.Interval = TimeSpan.FromSeconds(1); - _conditionTimer.Enabled = true; - } + EnableConditionTimer(); } } @@ -435,18 +428,43 @@ private void SendPendingConditions() } /// - /// Clone the timer + /// Enable timer /// - /// - private TimerEx? CloneTimer() + private void EnableConditionTimer() + { + lock (_timerLock) + { + if (_disposed) + { + return; + } + if (_conditionTimer == null) + { + _conditionTimer = new TimerEx(TimeProvider); + _conditionTimer.AutoReset = false; + _conditionTimer.Elapsed += OnConditionTimerElapsed; + _logger.LogDebug("Re-enabled condition timer."); + } + _conditionTimer.Interval = TimeSpan.FromSeconds(1); + _conditionTimer.Enabled = true; + } + } + + /// + /// Disable timer + /// + private void DisableConditionTimer() { - var timer = _conditionTimer; - _conditionTimer = null; - if (timer != null) + lock (_timerLock) { - timer.Elapsed -= OnConditionTimerElapsed; + if (_conditionTimer != null) + { + _conditionTimer.Elapsed -= OnConditionTimerElapsed; + _conditionTimer.Dispose(); + _conditionTimer = null; + _logger.LogDebug("Disabled condition timer."); + } } - return timer; } private sealed class ConditionHandlingState @@ -479,6 +497,8 @@ private sealed class ConditionHandlingState private int _snapshotInterval; private int _updateInterval; private TimerEx? _conditionTimer; + private object _timerLock = new(); + private bool _disposed; } } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.CyclicRead.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.CyclicRead.cs index 725fed981c..350963bc54 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.CyclicRead.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.CyclicRead.cs @@ -63,7 +63,10 @@ private CyclicRead(CyclicRead item, bool copyEventHandlers, : base(item, copyEventHandlers, copyClientHandle) { _client = item._client; - _sampler = item.CloneSampler(); + if (item._sampling) + { + EnsureSamplerRunning(); + } } /// @@ -77,7 +80,12 @@ public override MonitoredItem CloneMonitoredItem( protected override void Dispose(bool disposing) { // Cleanup - var sampler = CloneSampler(); + var sampler = _sampler; + lock (_lock) + { + _disposed = true; + _sampler = null; + } sampler?.DisposeAsync().AsTask().GetAwaiter().GetResult(); base.Dispose(disposing); } @@ -138,30 +146,63 @@ public override bool MergeWith(OpcUaMonitoredItem item, IOpcUaSession session, if (!AttachedToSubscription) { // Disabling sampling - if (_sampler != null) - { - await _sampler.DisposeAsync().ConfigureAwait(false); - _sampler = null; - - _logger.LogDebug("Item {Item} unregistered from sampler.", this); - } + await StopSamplerAsync().ConfigureAwait(false); } - else if (_sampler == null) + else { Debug.Assert(MonitoringMode == MonitoringMode.Disabled); - _sampler = _client.Sample(TimeSpan.FromMilliseconds(SamplingInterval), - new ReadValueId - { - AttributeId = AttributeId, - IndexRange = IndexRange, - NodeId = ResolvedNodeId - }, - Subscription.DisplayName, ClientHandle); - _logger.LogDebug("Item {Item} successfully registered with sampler.", - this); + EnsureSamplerRunning(); } }; + /// + /// Ensure sampler is started + /// + private void EnsureSamplerRunning() + { + Debug.Assert(AttachedToSubscription); + lock (_lock) + { + if (_disposed) + { + return; + } + if (_sampler == null) + { + _sampling = true; + _sampler = _client.Sample(TimeSpan.FromMilliseconds(SamplingInterval), + new ReadValueId + { + AttributeId = AttributeId, + IndexRange = IndexRange, + NodeId = ResolvedNodeId + }, + Subscription.DisplayName, ClientHandle); + _logger.LogDebug("Item {Item} successfully registered with sampler.", + this); + } + } + } + + /// + /// Stop sampling + /// + /// + private async Task StopSamplerAsync() + { + var sampler = _sampler; + lock (_lock) + { + _sampler = null; + _sampling = false; + } + if (sampler != null) + { + await sampler.DisposeAsync().ConfigureAwait(false); + _logger.LogDebug("Item {Item} unregistered from sampler.", this); + } + } + /// public override bool TryGetMonitoredItemNotifications(uint sequenceNumber, DateTimeOffset timestamp, IEncodeable encodeablePayload, IList notifications) @@ -178,19 +219,11 @@ public override bool TryGetMonitoredItemNotifications(uint sequenceNumber, DateT return true; } - /// - /// Clone the sampler - /// - /// - private IAsyncDisposable? CloneSampler() - { - var sampler = _sampler; - _sampler = null; - return sampler; - } - private readonly IOpcUaClient _client; private IAsyncDisposable? _sampler; + private bool _sampling; + private readonly object _lock = new object(); + private bool _disposed; } } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.DataChange.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.DataChange.cs index a51f98e34a..e00f1e44e9 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.DataChange.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.DataChange.cs @@ -191,8 +191,12 @@ public override int GetHashCode() /// public override string ToString() { - return $"Data Item '{Template.StartNodeId}' with server id {RemoteId} - " + - $"{(Status?.Created == true ? "" : "not ")}created"; + var str = $"Data Item '{Template.StartNodeId}'"; + if (RemoteId.HasValue) + { + str += $" with server id {RemoteId} ({(Status?.Created == true ? "" : "not ")}created)"; + } + return str; } /// @@ -241,9 +245,9 @@ public override bool AddTo(Subscription subscription, IOpcUaSession session, StartNodeId = nodeId; MonitoringMode = Template.MonitoringMode.ToStackType() ?? Opc.Ua.MonitoringMode.Reporting; - QueueSize = Template.QueueSize; SamplingInterval = (int)Template.SamplingInterval. GetValueOrDefault(TimeSpan.FromSeconds(1)).TotalMilliseconds; + UpdateQueueSize(subscription, Template); Filter = Template.DataChangeFilter.ToStackModel() ?? (MonitoringFilter?)Template.AggregateFilter.ToStackModel( session.MessageContext); @@ -351,6 +355,20 @@ public override bool MergeWith(OpcUaMonitoredItem item, IOpcUaSession session, return itemChange; } + /// + protected override bool OnSamplingIntervalOrQueueSizeRevised( + bool samplingIntervalChanged, bool queueSizeChanged) + { + Debug.Assert(Subscription != null); + var applyChanges = base.OnSamplingIntervalOrQueueSizeRevised( + samplingIntervalChanged, queueSizeChanged); + if (samplingIntervalChanged) + { + applyChanges |= UpdateQueueSize(Subscription, Template); + } + return applyChanges; + } + /// public override bool TryGetLastMonitoredItemNotifications(uint sequenceNumber, IList notifications) diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Event.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Event.cs index d7f90dc33c..fb6e923223 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Event.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Event.cs @@ -173,8 +173,12 @@ public override int GetHashCode() /// public override string ToString() { - return $"Event Item '{Template.StartNodeId}' with server id {RemoteId} - " + - $"{(Status?.Created == true ? "" : "not ")}created"; + var str = $"Event Item '{Template.StartNodeId}'"; + if (RemoteId.HasValue) + { + str += $" with server id {RemoteId} ({(Status?.Created == true ? "" : "not ")}created)"; + } + return str; } /// @@ -246,8 +250,8 @@ public override bool AddTo(Subscription subscription, MonitoringMode = Template.MonitoringMode.ToStackType() ?? Opc.Ua.MonitoringMode.Reporting; StartNodeId = nodeId; - QueueSize = Template.QueueSize; SamplingInterval = 0; + UpdateQueueSize(subscription, Template); DiscardOldest = !(Template.DiscardNew ?? false); Valid = true; @@ -285,6 +289,21 @@ public override bool MergeWith(OpcUaMonitoredItem item, IOpcUaSession session, return itemChange; } + /// + protected override bool OnSamplingIntervalOrQueueSizeRevised( + bool samplingIntervalChanged, bool queueSizeChanged) + { + Debug.Assert(Subscription != null); + var applyChanges = base.OnSamplingIntervalOrQueueSizeRevised( + samplingIntervalChanged, queueSizeChanged); + if (samplingIntervalChanged && Status.SamplingInterval != 0) + { + // Not necessary as sampling interval will likely always stay 0 + applyChanges |= UpdateQueueSize(Subscription, Template); + } + return applyChanges; + } + public override Func? FinalizeMergeWith => async (session, ct) => Filter = await GetEventFilterAsync(session, ct).ConfigureAwait(false); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs index 38b752f584..9b2f4a41a7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs @@ -16,7 +16,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack.Services using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; - using System.Threading; internal abstract partial class OpcUaMonitoredItem { @@ -52,13 +51,8 @@ public Heartbeat(DataMonitoredItemModel dataTemplate, { _heartbeatInterval = dataTemplate.HeartbeatInterval ?? dataTemplate.SamplingInterval ?? TimeSpan.FromSeconds(1); - _timerInterval = Timeout.InfiniteTimeSpan; _heartbeatBehavior = dataTemplate.HeartbeatBehavior ?? HeartbeatBehavior.WatchdogLKV; - _heartbeatTimer = new TimerEx(timeProvider); - _heartbeatTimer.Elapsed += SendHeartbeatNotifications; - _heartbeatTimer.AutoReset = true; - _heartbeatTimer.Enabled = true; } /// @@ -72,13 +66,11 @@ private Heartbeat(Heartbeat item, bool copyEventHandlers, : base(item, copyEventHandlers, copyClientHandle) { _heartbeatInterval = item._heartbeatInterval; - _timerInterval = item._timerInterval; _heartbeatBehavior = item._heartbeatBehavior; _callback = item._callback; - _heartbeatTimer = item.CloneTimer(); - if (_heartbeatTimer != null) + if (item._timerEnabled) { - _heartbeatTimer.Elapsed += SendHeartbeatNotifications; + EnableHeartbeatTimer(); } } @@ -108,10 +100,13 @@ public override int GetHashCode() /// public override string ToString() { - return $"Data Item '{Template.StartNodeId}' " + - $"(with {Template.HeartbeatBehavior ?? HeartbeatBehavior.WatchdogLKV} Heartbeat) " + - $"with server id {RemoteId} - {(Status?.Created == true ? "" : - "not ")}created"; + var str = $"Data Item '{Template.StartNodeId}' " + + $"(with {Template.HeartbeatBehavior ?? HeartbeatBehavior.WatchdogLKV} Heartbeat) "; + if (RemoteId.HasValue) + { + str += $" with server id {RemoteId} ({(Status?.Created == true ? "" : "not ")}created)"; + } + return str; } /// @@ -119,8 +114,16 @@ protected override void Dispose(bool disposing) { if (disposing) { - var timer = CloneTimer(); - timer?.Dispose(); + lock (_timerLock) + { + _disposed = true; + if (_heartbeatTimer != null) + { + _heartbeatTimer.Elapsed -= SendHeartbeatNotifications; + _heartbeatTimer.Dispose(); + _heartbeatTimer = null; + } + } } base.Dispose(disposing); } @@ -134,10 +137,9 @@ protected override bool ProcessMonitoredItemNotification(uint sequenceNumber, var result = base.ProcessMonitoredItemNotification(sequenceNumber, publishTime, monitoredItemNotification, notifications); - if (_heartbeatTimer != null && (_heartbeatBehavior & HeartbeatBehavior.PeriodicLKV) == 0) + if (!_disposed && (_heartbeatBehavior & HeartbeatBehavior.PeriodicLKV) == 0) { - _heartbeatTimer.Interval = _timerInterval; - _heartbeatTimer.Enabled = true; + EnableHeartbeatTimer(); } return result; } @@ -180,7 +182,7 @@ public override bool MergeWith(OpcUaMonitoredItem item, IOpcUaSession session, public override bool TryCompleteChanges(Subscription subscription, ref bool applyChanges, Callback cb) { - if (_heartbeatTimer == null) + if (_disposed) { _logger.LogError("{Item}: Item was moved to another subscription " + "and the timer is handled by the new subscription now.", this); @@ -194,20 +196,13 @@ public override bool TryCompleteChanges(Subscription subscription, { _callback = null; // Stop heartbeat - _heartbeatTimer.Enabled = false; - _timerInterval = Timeout.InfiniteTimeSpan; + DisableHeartbeatTimer(); } else { Debug.Assert(AttachedToSubscription); _callback = cb; - if (_timerInterval != _heartbeatInterval) - { - // Start heartbeat after completion - _heartbeatTimer.Interval = _heartbeatInterval; - _timerInterval = _heartbeatInterval; - } - _heartbeatTimer.Enabled = true; + EnableHeartbeatTimer(); } } return result; @@ -217,9 +212,9 @@ public override bool TryCompleteChanges(Subscription subscription, public override bool TryGetMonitoredItemNotifications(uint sequenceNumber, DateTimeOffset publishTime, IEncodeable evt, IList notifications) { - if (_heartbeatTimer != null && (_heartbeatBehavior & HeartbeatBehavior.PeriodicLKV) == 0) + if (!_disposed && (_heartbeatBehavior & HeartbeatBehavior.PeriodicLKV) == 0) { - _heartbeatTimer.Enabled = false; + EnableHeartbeatTimer(); } return base.TryGetMonitoredItemNotifications(sequenceNumber, publishTime, evt, notifications); } @@ -344,26 +339,61 @@ private void SendHeartbeatNotifications(object? sender, ElapsedEventArgs e) } /// - /// Clone the timer + /// Enable timer /// - /// - private TimerEx? CloneTimer() + private void EnableHeartbeatTimer() + { + lock (_timerLock) + { + if (_disposed) + { + return; + } + if (_heartbeatTimer == null) + { + _heartbeatTimer = new TimerEx(TimeProvider); + _heartbeatTimer.AutoReset = true; + _heartbeatTimer.Elapsed += SendHeartbeatNotifications; + _heartbeatTimer.Enabled = true; + _heartbeatTimer.Interval = _heartbeatInterval; + _logger.LogDebug("Re-enable heartbeat timer"); + } + else if (_heartbeatInterval != _heartbeatTimer.Interval) + { + Debug.Assert(_heartbeatTimer.Enabled); + _heartbeatTimer.Interval = _heartbeatInterval; + _logger.LogDebug("Re-configured heartbeat timer"); + } + _timerEnabled = true; + } + } + + /// + /// Disable timer + /// + private void DisableHeartbeatTimer() { - var timer = _heartbeatTimer; - _heartbeatTimer = null; - if (timer != null) + lock (_timerLock) { - timer.Elapsed -= SendHeartbeatNotifications; + if (_heartbeatTimer != null) + { + _heartbeatTimer.Elapsed -= SendHeartbeatNotifications; + _heartbeatTimer.Dispose(); + _heartbeatTimer = null; + _logger.LogDebug("Disabled heartbeat timer"); + } + _timerEnabled = false; } - return timer; } private TimerEx? _heartbeatTimer; - private TimeSpan _timerInterval; private HeartbeatBehavior _heartbeatBehavior; + private bool _timerEnabled; private TimeSpan _heartbeatInterval; private Callback? _callback; private StatusCode? _lastStatusCode; + private object _timerLock = new(); + private bool _disposed; } } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.ModelChange.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.ModelChange.cs index 6db0f5e392..e4c741d3e6 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.ModelChange.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.ModelChange.cs @@ -64,13 +64,6 @@ private ModelChangeEventItem(ModelChangeEventItem item, bool copyEventHandlers, _client = item._client; _callback = item._callback; _fields = item._fields; - - _browser = item.CloneBrowser(); - if (_browser != null) - { - _browser.OnReferenceChange += OnReferenceChange; - _browser.OnNodeChange += OnNodeChange; - } } /// @@ -84,8 +77,18 @@ public override MonitoredItem CloneMonitoredItem( protected override void Dispose(bool disposing) { // Cleanup - var browser = CloneBrowser(); - browser?.CloseAsync().AsTask().GetAwaiter().GetResult(); + var browser = _browser; + lock (_lock) + { + _disposed = true; + _browser = null; + if (browser != null) + { + browser.OnReferenceChange -= OnReferenceChange; + browser.OnNodeChange -= OnNodeChange; + browser.CloseAsync().AsTask().GetAwaiter().GetResult(); + } + } base.Dispose(disposing); } @@ -131,9 +134,12 @@ public override int GetHashCode() /// public override string ToString() { - return - $"Model Change Item with server id {RemoteId}" + - $" - {(Status?.Created == true ? "" : "not ")}created"; + var str = "Model Change Item"; + if (RemoteId.HasValue) + { + str += $" with server id {RemoteId} ({(Status?.Created == true ? "" : "not ")}created)"; + } + return str; } /// @@ -178,29 +184,11 @@ public override bool TryCompleteChanges(Subscription subscription, { if (!AttachedToSubscription) { - // Stop the browser - if (_browser != null) - { - _browser.OnReferenceChange -= OnReferenceChange; - _browser.OnNodeChange -= OnNodeChange; - - await _browser.CloseAsync().ConfigureAwait(false); - _logger.LogInformation("Item {Item} unregistered from browser.", this); - _browser = null; - } + await StopBrowserAsync().ConfigureAwait(false); } else { - // Start the browser - if (_browser == null) - { - _browser = _client.Browse(Template.RebrowsePeriod ?? - TimeSpan.FromHours(12), Subscription.DisplayName); - - _browser.OnReferenceChange += OnReferenceChange; - _browser.OnNodeChange += OnNodeChange; - _logger.LogInformation("Item {Item} registered with browser.", this); - } + EnsureBrowserStarted(); } }; @@ -219,8 +207,8 @@ public override bool AddTo(Subscription subscription, AttributeId = Attributes.EventNotifier; MonitoringMode = Opc.Ua.MonitoringMode.Reporting; StartNodeId = nodeId; - QueueSize = Template.QueueSize; SamplingInterval = 0; + UpdateQueueSize(subscription, Template); Filter = GetEventFilter(); DiscardOldest = !(Template.DiscardNew ?? false); Valid = true; @@ -285,6 +273,7 @@ public override bool TryGetMonitoredItemNotifications(uint sequenceNumber, } // The model changed, trigger Rebrowse + EnsureBrowserStarted(); _browser?.Rebrowse(); return true; } @@ -308,6 +297,21 @@ protected override IEnumerable CreateTriggeredItems( return Enumerable.Empty(); } + /// + protected override bool OnSamplingIntervalOrQueueSizeRevised( + bool samplingIntervalChanged, bool queueSizeChanged) + { + Debug.Assert(Subscription != null); + var applyChanges = base.OnSamplingIntervalOrQueueSizeRevised( + samplingIntervalChanged, queueSizeChanged); + if (samplingIntervalChanged && Status.SamplingInterval != 0) + { + // Not necessary as sampling interval will likely always stay 0 + applyChanges |= UpdateQueueSize(Subscription, Template); + } + return applyChanges; + } + /// /// Called when node changed /// @@ -330,22 +334,6 @@ private void OnReferenceChange(object? sender, Change e) sender as ISession, DataSetName); } - /// - /// Clone the browser - /// - /// - private IOpcUaBrowser? CloneBrowser() - { - var browser = _browser; - _browser = null; - if (browser != null) - { - browser.OnReferenceChange -= OnReferenceChange; - browser.OnNodeChange -= OnNodeChange; - } - return browser; - } - /// /// Create the event /// @@ -421,14 +409,65 @@ static PublishedFieldMetaDataModel Create(string fieldName, NodeId? dataType = n } } + /// + /// Start browser + /// + private void EnsureBrowserStarted() + { + lock (_lock) + { + if (_disposed) + { + return; + } + // Start the browser + if (_browser == null) + { + _browser = _client.Browse(Template.RebrowsePeriod ?? + TimeSpan.FromHours(12), Subscription.DisplayName); + + _browser.OnReferenceChange += OnReferenceChange; + _browser.OnNodeChange += OnNodeChange; + _logger.LogInformation("Item {Item} registered with browser.", this); + } + } + } + + /// + /// Stop browser + /// + /// + private async Task StopBrowserAsync() + { + // Stop the browser + IOpcUaBrowser? browser; + lock (_lock) + { + browser = _browser; + if (browser != null) + { + browser.OnReferenceChange -= OnReferenceChange; + browser.OnNodeChange -= OnNodeChange; + } + _browser = null; + } + if (browser != null) + { + await browser.CloseAsync().ConfigureAwait(false); + _logger.LogInformation("Item {Item} unregistered from browser.", this); + } + } + private static readonly ExpandedNodeId _refChangeType = new("ReferenceChange", "http://www.microsoft.com/opc-publisher"); private static readonly ExpandedNodeId _nodeChangeType = new("NodeChange", "http://www.microsoft.com/opc-publisher"); private readonly PublishedFieldMetaDataModel[] _fields; private readonly IOpcUaClient _client; + private readonly object _lock = new(); private IOpcUaBrowser? _browser; private Callback? _callback; + private bool _disposed; } } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs index 867fb30ee6..eeccb03e26 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs @@ -391,6 +391,8 @@ public virtual bool TryCompleteChanges(Subscription subscription, return true; } + Debug.Assert(subscription == Subscription); + if (Status.MonitoringMode == Opc.Ua.MonitoringMode.Disabled) { _logger.LogDebug("{Item}: Item is disabled while trying to complete.", this); @@ -408,36 +410,58 @@ public virtual bool TryCompleteChanges(Subscription subscription, return false; } + if (OnSamplingIntervalOrQueueSizeRevised( + SamplingInterval != Status.SamplingInterval, QueueSize != Status.QueueSize)) + { + applyChanges = true; + } + return true; + } + + /// + /// Log revised sampling rate and queue size + /// + public void LogRevisedSamplingRateAndQueueSize() + { + if (!AttachedToSubscription || SamplingInterval < 0) + { + return; + } + Debug.Assert(Subscription != null); if (SamplingInterval != Status.SamplingInterval && QueueSize != Status.QueueSize) { - _logger.LogInformation("#{SubscriptionId}|{Item}('{Name}') - Server revised " + - "SamplingInterval from {SamplingInterval} to {CurrentSamplingInterval} and " + - "QueueSize from {QueueSize} to {CurrentQueueSize}.", - subscription.Id, StartNodeId, DisplayName, - SamplingInterval, Status.SamplingInterval, QueueSize, Status.QueueSize); + _logger.LogInformation("Server revised SamplingInterval from {SamplingInterval} " + + "to {CurrentSamplingInterval} and QueueSize from {QueueSize} " + + "to {CurrentQueueSize} for #{SubscriptionId}|{Item}('{Name}').", + SamplingInterval, Status.SamplingInterval, QueueSize, Status.QueueSize, + Subscription.Id, StartNodeId, DisplayName); } else if (SamplingInterval != Status.SamplingInterval) { - _logger.LogInformation("#{SubscriptionId}|{Item}('{Name}') - Server revised " + - "SamplingInterval from {SamplingInterval} to {CurrentSamplingInterval}.", - subscription.Id, StartNodeId, DisplayName, - SamplingInterval, Status.SamplingInterval); + _logger.LogInformation("Server revised SamplingInterval from {SamplingInterval} " + + "to {CurrentSamplingInterval} for #{SubscriptionId}|{Item}('{Name}').", + SamplingInterval, Status.SamplingInterval, + Subscription.Id, StartNodeId, DisplayName); } else if (QueueSize != Status.QueueSize) { - _logger.LogInformation("#{SubscriptionId}|{Item}('{Name}') - Server revised " + - "QueueSize from {QueueSize} to {CurrentQueueSize}.", - subscription.Id, StartNodeId, DisplayName, - QueueSize, Status.QueueSize); + _logger.LogInformation("Server revised QueueSize from {QueueSize} " + + "to {CurrentQueueSize} for #{SubscriptionId}|{Item}('{Name}').", + QueueSize, Status.QueueSize, + Subscription.Id, StartNodeId, DisplayName); } else { - _logger.LogDebug("#{SubscriptionId}|{Item}('{Name}') - Server accepted " + - "configuration unchanged.", - subscription.Id, StartNodeId, DisplayName); + _logger.LogDebug("Server accepted configuration " + + "unchanged for #{SubscriptionId}|{Item}('{Name}').", + Subscription.Id, StartNodeId, DisplayName); } - return true; + + _logger.LogDebug("SamplingInterval set to {SamplingInterval} and QueueSize " + + "to {QueueSize} for #{SubscriptionId}|{Item}('{Name}').", + Status.SamplingInterval, Status.QueueSize, + Subscription.Id, StartNodeId, DisplayName); } /// @@ -540,6 +564,18 @@ protected abstract bool TryGetErrorMonitoredItemNotifications( uint sequenceNumber, StatusCode statusCode, IList notifications); + /// + /// Notify queue size or sampling interval changed + /// + /// + /// + /// + protected virtual bool OnSamplingIntervalOrQueueSizeRevised( + bool samplingIntervalChanged, bool queueSizeChanged) + { + return false; + } + /// /// Merge item /// @@ -561,8 +597,7 @@ protected bool MergeWith(T template, T desired, out T updated, } var itemChange = false; - if ((updated.DiscardNew ?? false) != - desired.DiscardNew.GetValueOrDefault()) + if ((updated.DiscardNew ?? false) != (desired.DiscardNew ?? false)) { _logger.LogDebug("{Item}: Changing discard new mode from {Old} to {New}", this, updated.DiscardNew ?? false, @@ -571,14 +606,22 @@ protected bool MergeWith(T template, T desired, out T updated, DiscardOldest = !(updated.DiscardNew ?? false); itemChange = true; } - if (updated.QueueSize != desired.QueueSize) + if (updated.QueueSize != desired.QueueSize || + updated.AutoSetQueueSize != desired.AutoSetQueueSize) { - _logger.LogDebug("{Item}: Changing queue size from {Old} to {New}", - this, updated.QueueSize, - desired.QueueSize); - updated = updated with { QueueSize = desired.QueueSize }; - QueueSize = updated.QueueSize; - itemChange = true; + _logger.LogDebug( + "{Item}: Changing queue size from {Old} ({OldAuto}) to {New} ({NewAuto})", + this, updated.QueueSize, updated.AutoSetQueueSize, + desired.QueueSize, desired.AutoSetQueueSize); + updated = updated with + { + QueueSize = desired.QueueSize, + AutoSetQueueSize = desired.AutoSetQueueSize + }; + if (Subscription != null) + { + itemChange = UpdateQueueSize(Subscription, updated); + } } if ((updated.MonitoringMode ?? Publisher.Models.MonitoringMode.Reporting) != (desired.MonitoringMode ?? Publisher.Models.MonitoringMode.Reporting)) @@ -880,6 +923,48 @@ static bool IsBuiltInType(NodeId dataTypeId) } } + /// + /// Update queue size using sampling rate and publishing interval + /// + /// + /// + protected bool UpdateQueueSize(Subscription subscription, BaseMonitoredItemModel item) + { + var queueSize = item.QueueSize; + if (item.AutoSetQueueSize) + { + var publishingInterval = subscription.CurrentPublishingInterval; + if (publishingInterval == 0) + { + publishingInterval = subscription.PublishingInterval; + } + var samplingInterval = Status.SamplingInterval; + if (samplingInterval == 0) + { + samplingInterval = SamplingInterval; + } + if (samplingInterval > 0) + { + queueSize = Math.Max(queueSize, (uint)Math.Ceiling( + (double)publishingInterval / SamplingInterval)); + if (queueSize != QueueSize && item.QueueSize != queueSize) + { + _logger.LogDebug("Auto-set queue size for {Item} to '{QueueSize}'.", + this, queueSize); + } + } + else + { + _logger.LogDebug( + "No sampling interval set - cannot calculate queue size for {Item}.", + this); + } + } + var itemChanged = QueueSize != queueSize; + QueueSize = queueSize; + return itemChanged; + } + /// /// Logger /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs index 8632ef8782..398b1fbeb4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs @@ -64,9 +64,11 @@ internal List SubscriptionHandles } } - /// - /// Helper to set max publish requests - /// + // Remove when fixed in stack +#if !NO_UAFIX +#if !NO_DUMMY + internal int MaxPublishRequestCount { get; set; } +#else internal int MaxPublishRequestCount { get @@ -81,6 +83,19 @@ internal int MaxPublishRequestCount _maxPublishRequest?.SetValue(this, value); } } + private readonly FieldInfo? _maxPublishRequest = typeof(Session).GetField( + "m_tooManyPublishRequests", BindingFlags.NonPublic | BindingFlags.Instance); +#endif +#endif + + /// + /// Enable or disable ChannelDiagnostics + /// + public bool DiagnosticsEnabled + { + get => _diagnosticsEnabled != false; + set => _diagnosticsEnabled = value ? null : false; + } /// /// Create session @@ -95,7 +110,6 @@ internal int MaxPublishRequestCount /// /// /// - /// public OpcUaSession(OpcUaClient client, IJsonSerializer serializer, ILogger logger, TimeProvider timeProvider, ITransportChannel channel, ApplicationConfiguration configuration, @@ -113,12 +127,6 @@ public OpcUaSession(OpcUaClient client, IJsonSerializer serializer, Initialize(); Codec = new JsonVariantEncoder(MessageContext, serializer); - - // TODO: Make accessible in base class - _maxPublishRequest = typeof(Session).GetField("m_tooManyPublishRequests", - BindingFlags.NonPublic | BindingFlags.Instance); - MaxPublishRequestCount = client.MaxPublishRequests ?? 0; - MinPublishRequestCount = Math.Max(1, client.MinPublishRequests ?? 1); } /// @@ -198,6 +206,22 @@ public override Session CloneSession(ITransportChannel channel, return SessionName; } + /// + public async ValueTask GetServerDiagnosticAsync( + CancellationToken ct = default) + { + try + { + _lastDiagnostics = await FetchServerDiagnosticAsync(new RequestHeader(), + ct).ConfigureAwait(false); + } + catch (ServiceResultException sre) + { + _logger.LogDebug(sre, "Failed to fetch server diagnostics."); + } + return _lastDiagnostics ?? new SessionDiagnosticsModel(); + } + /// public async ValueTask GetOperationLimitsAsync( CancellationToken ct = default) @@ -701,6 +725,227 @@ private void Initialize() OperationTimeout = operationTimeout; } + /// + /// Fetch server diagnostics + /// + /// + /// + /// + private async Task FetchServerDiagnosticAsync(RequestHeader header, + CancellationToken ct) + { + if (_diagnosticsEnabled == false) + { + return null; + } + if (!_diagnosticsEnabled.HasValue) + { + // Check whether enabled and if not enabled enable it + var diagnosticsEnabled = await ReadValueAsync( + VariableIds.Server_ServerDiagnostics_EnabledFlag, ct).ConfigureAwait(false); + _diagnosticsEnabled = diagnosticsEnabled.Value as bool?; + if (_diagnosticsEnabled == false) + { + var enableResponse = await WriteAsync(header, new[] + { + new WriteValue + { + AttributeId = Attributes.Value, + NodeId = VariableIds.Server_ServerDiagnostics_EnabledFlag, + Value = new DataValue(true) + } + }, ct).ConfigureAwait(false); + if (ServiceResult.IsBad(enableResponse.Results[0])) + { + _logger.LogError("Session diagnostics disabled and failed to enable ({Error}).", + enableResponse.Results[0]); + return null; + } + _diagnosticsEnabled = true; + } + } + var response = await ReadAsync(header, 0.0, Opc.Ua.TimestampsToReturn.Neither, new[] + { + new ReadValueId + { + AttributeId = Attributes.Value, + NodeId = + VariableIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionDiagnosticsArray + }, + new ReadValueId + { + AttributeId = Attributes.Value, + NodeId = VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray + } + }, ct).ConfigureAwait(false); + if (ServiceResult.IsBad(response.Results[0].StatusCode)) + { + _logger.LogInformation("Session diagnostics not retrievable ({Error1}/{Error2}).", + response.Results[0].StatusCode, response.Results[1].StatusCode); + return null; + } + var sessionDiagnosticsArray = response.Results[0].Value as ExtensionObject[]; + var sessionDiagnostics = sessionDiagnosticsArray? + .Select(o => o.Body) + .OfType() + .FirstOrDefault(d => d.SessionId == SessionId); + if (sessionDiagnostics == null) + { + _logger.LogError("Failed to find diagnostics for this session ({Error}).", + response.Results[0].StatusCode); + return null; + } + + List? subscriptions = null; + var subscriptionDiagnosticsArray = response.Results[1].Value as ExtensionObject[]; + if (!ServiceResult.IsBad(response.Results[1].StatusCode) && + subscriptionDiagnosticsArray != null) + { + subscriptions = subscriptionDiagnosticsArray + .Select(o => o.Body) + .OfType() + .Where(d => d.SessionId == SessionId) + .Select(diag => new SubscriptionDiagnosticsModel + { + SubscriptionId = diag.SubscriptionId, + Priority = diag.Priority, + PublishingInterval = diag.PublishingInterval, + MaxKeepAliveCount = diag.MaxKeepAliveCount, + MaxLifetimeCount = diag.MaxLifetimeCount, + MaxNotificationsPerPublish = diag.MaxNotificationsPerPublish, + PublishingEnabled = diag.PublishingEnabled, + ModifyCount = diag.ModifyCount, + EnableCount = diag.EnableCount, + DisableCount = diag.DisableCount, + RepublishRequestCount = diag.RepublishRequestCount, + RepublishMessageRequestCount = diag.RepublishMessageRequestCount, + RepublishMessageCount = diag.RepublishMessageCount, + TransferRequestCount = diag.TransferRequestCount, + TransferredToAltClientCount = diag.TransferredToAltClientCount, + TransferredToSameClientCount = diag.TransferredToSameClientCount, + PublishRequestCount = diag.PublishRequestCount, + DataChangeNotificationsCount = diag.DataChangeNotificationsCount, + EventNotificationsCount = diag.EventNotificationsCount, + NotificationsCount = diag.NotificationsCount, + LatePublishRequestCount = diag.LatePublishRequestCount, + CurrentKeepAliveCount = diag.CurrentKeepAliveCount, + CurrentLifetimeCount = diag.CurrentLifetimeCount, + UnacknowledgedMessageCount = diag.UnacknowledgedMessageCount, + DiscardedMessageCount = diag.DiscardedMessageCount, + MonitoredItemCount = diag.MonitoredItemCount, + DisabledMonitoredItemCount = diag.DisabledMonitoredItemCount, + MonitoringQueueOverflowCount = diag.MonitoringQueueOverflowCount, + NextSequenceNumber = diag.NextSequenceNumber, + EventQueueOverFlowCount = diag.EventQueueOverFlowCount + }) + .ToList(); + } + else + { + _logger.LogInformation("Subscription diagnostics not retrievable ({Error}).", + response.Results[1].StatusCode); + } + + return new SessionDiagnosticsModel + { + SessionId = + sessionDiagnostics.SessionId.AsString(MessageContext, NamespaceFormat.Expanded), + TranslateBrowsePathsToNodeIdsCount = + ToCounter(sessionDiagnostics.TranslateBrowsePathsToNodeIdsCount), + AddNodesCount = + ToCounter(sessionDiagnostics.AddNodesCount), + AddReferencesCount = + ToCounter(sessionDiagnostics.AddReferencesCount), + BrowseCount = + ToCounter(sessionDiagnostics.BrowseCount), + BrowseNextCount = + ToCounter(sessionDiagnostics.BrowseNextCount), + CreateMonitoredItemsCount = + ToCounter(sessionDiagnostics.CreateMonitoredItemsCount), + CreateSubscriptionCount = + ToCounter(sessionDiagnostics.CreateSubscriptionCount), + DeleteMonitoredItemsCount = + ToCounter(sessionDiagnostics.DeleteMonitoredItemsCount), + DeleteNodesCount = + ToCounter(sessionDiagnostics.DeleteNodesCount), + DeleteReferencesCount = + ToCounter(sessionDiagnostics.DeleteReferencesCount), + DeleteSubscriptionsCount = + ToCounter(sessionDiagnostics.DeleteSubscriptionsCount), + CallCount = + ToCounter(sessionDiagnostics.CallCount), + HistoryReadCount = + ToCounter(sessionDiagnostics.HistoryReadCount), + HistoryUpdateCount = + ToCounter(sessionDiagnostics.HistoryUpdateCount), + ModifyMonitoredItemsCount = + ToCounter(sessionDiagnostics.ModifyMonitoredItemsCount), + ModifySubscriptionCount = + ToCounter(sessionDiagnostics.ModifySubscriptionCount), + PublishCount = + ToCounter(sessionDiagnostics.PublishCount), + RegisterNodesCount = + ToCounter(sessionDiagnostics.RegisterNodesCount), + RepublishCount = + ToCounter(sessionDiagnostics.RepublishCount), + SetMonitoringModeCount = + ToCounter(sessionDiagnostics.SetMonitoringModeCount), + SetPublishingModeCount = + ToCounter(sessionDiagnostics.SetPublishingModeCount), + UnregisterNodesCount = + ToCounter(sessionDiagnostics.UnregisterNodesCount), + QueryFirstCount = + ToCounter(sessionDiagnostics.QueryFirstCount), + QueryNextCount = + ToCounter(sessionDiagnostics.QueryNextCount), + ReadCount = + ToCounter(sessionDiagnostics.ReadCount), + WriteCount = + ToCounter(sessionDiagnostics.WriteCount), + SetTriggeringCount = + ToCounter(sessionDiagnostics.SetTriggeringCount), + TotalRequestCount = + ToCounter(sessionDiagnostics.TotalRequestCount), + TransferSubscriptionsCount = + ToCounter(sessionDiagnostics.TransferSubscriptionsCount), + ServerUri = + sessionDiagnostics.ServerUri, + SessionName = + sessionDiagnostics.SessionName, + ActualSessionTimeout = + sessionDiagnostics.ActualSessionTimeout, + MaxResponseMessageSize = + sessionDiagnostics.MaxResponseMessageSize, + UnauthorizedRequestCount = + sessionDiagnostics.UnauthorizedRequestCount, + ConnectTime = + sessionDiagnostics.ClientConnectionTime, + LastContactTime = + sessionDiagnostics.ClientLastContactTime, + CurrentSubscriptionsCount = + sessionDiagnostics.CurrentSubscriptionsCount, + CurrentMonitoredItemsCount = + sessionDiagnostics.CurrentMonitoredItemsCount, + CurrentPublishRequestsInQueue = + sessionDiagnostics.CurrentPublishRequestsInQueue, + Subscriptions = + subscriptions + }; + + static ServiceCounterModel? ToCounter(ServiceCounterDataType counter) + { + if (counter.TotalCount == 0 && counter.ErrorCount == 0) + { + return null; + } + return new ServiceCounterModel + { + TotalCount = counter.TotalCount, + ErrorCount = counter.ErrorCount + }; + } + } + /// /// Read operation limits /// @@ -1143,15 +1388,16 @@ private sealed record class LogScope(string name, Stopwatch sw, ILogger logger); private ServerCapabilitiesModel? _server; private OperationLimitsModel? _limits; + private SessionDiagnosticsModel? _lastDiagnostics; private HistoryServerCapabilitiesModel? _history; private Task? _complexTypeSystem; private bool _disposed; + private bool? _diagnosticsEnabled; private readonly CancellationTokenSource _cts = new(); private readonly ILogger _logger; private readonly OpcUaClient _client; private readonly IJsonSerializer _serializer; private readonly TimeProvider _timeProvider; - private readonly FieldInfo? _maxPublishRequest; private readonly ActivitySource _activitySource = Diagnostics.NewActivitySource(); private static readonly TimeSpan kDefaultOperationTimeout = TimeSpan.FromMinutes(1); private static readonly TimeSpan kDefaultKeepAliveInterval = TimeSpan.FromSeconds(30); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaStackKeySetLogger.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaStackKeySetLogger.cs index 41a7bd2100..778356131c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaStackKeySetLogger.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaStackKeySetLogger.cs @@ -9,7 +9,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack.Services using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; - using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -84,7 +83,7 @@ public async Task WriteDebugFileAsync(string folderName, CancellationToken ct) { Directory.Delete(rootFolder, true); } - await foreach (var change in _diagnostics.MonitorAsync( + await foreach (var change in _diagnostics.WatchChannelDiagnosticsAsync( ct).ConfigureAwait(false)) { try @@ -106,13 +105,12 @@ public async Task WriteDebugFileAsync(string folderName, CancellationToken ct) /// /// private static async Task WriteDebugLogFileAsync(string rootFolder, - ConnectionDiagnosticModel change, CancellationToken ct) + ChannelDiagnosticModel change, CancellationToken ct) { - var entry = change.ChannelDiagnostics; - if (entry?.Client == null || entry?.Server == null || + if (change?.Client == null || change?.Server == null || change.SessionCreated == null || change.RemotePort == null) { - // Not a valid entry, channel without keys + // Not a valid change, channel without keys return; } @@ -141,9 +139,9 @@ await log.WriteLineAsync($"RemoteEP: {change.RemoteIpAddress}:{change.RemotePort .ConfigureAwait(false); await log.WriteLineAsync($"LocalEP: {change.LocalIpAddress}:{change.LocalPort}") .ConfigureAwait(false); - await log.WriteLineAsync($"ChannelId: {entry.ChannelId}") + await log.WriteLineAsync($"ChannelId: {change.ChannelId}") .ConfigureAwait(false); - await log.WriteLineAsync($"TokenId: {entry.TokenId}") + await log.WriteLineAsync($"TokenId: {change.TokenId}") .ConfigureAwait(false); await log.WriteLineAsync($"Session: {change.SessionId}") .ConfigureAwait(false); @@ -160,30 +158,30 @@ await log.WriteLineAsync($"SecurityProfile: {change.Connection.Endpoint?.Securit var keysets = File.AppendText(keysetsFileName); await using (var _ = keysets.ConfigureAwait(false)) { - await keysets.WriteAsync($"client_iv_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"client_iv_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(Convert.ToHexString(entry.Client.Iv.ToArray())) + await keysets.WriteLineAsync(Convert.ToHexString(change.Client.Iv.ToArray())) .ConfigureAwait(false); - await keysets.WriteAsync($"client_key_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"client_key_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(Convert.ToHexString(entry.Client.Key.ToArray())) + await keysets.WriteLineAsync(Convert.ToHexString(change.Client.Key.ToArray())) .ConfigureAwait(false); - await keysets.WriteAsync($"client_siglen_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"client_siglen_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(entry.Client.SigLen.ToString(CultureInfo.InvariantCulture)) + await keysets.WriteLineAsync(change.Client.SigLen.ToString(CultureInfo.InvariantCulture)) .ConfigureAwait(false); - await keysets.WriteAsync($"server_iv_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"server_iv_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(Convert.ToHexString(entry.Server.Iv.ToArray())) + await keysets.WriteLineAsync(Convert.ToHexString(change.Server.Iv.ToArray())) .ConfigureAwait(false); - await keysets.WriteAsync($"server_key_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"server_key_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(Convert.ToHexString(entry.Server.Key.ToArray())) + await keysets.WriteLineAsync(Convert.ToHexString(change.Server.Key.ToArray())) .ConfigureAwait(false); - await keysets.WriteAsync($"server_siglen_{entry.ChannelId}_{entry.TokenId}: ") + await keysets.WriteAsync($"server_siglen_{change.ChannelId}_{change.TokenId}: ") .ConfigureAwait(false); - await keysets.WriteLineAsync(entry.Server.SigLen.ToString(CultureInfo.InvariantCulture)) + await keysets.WriteLineAsync(change.Server.SigLen.ToString(CultureInfo.InvariantCulture)) .ConfigureAwait(false); await keysets.FlushAsync(ct).ConfigureAwait(false); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs index 06c6023309..931463c999 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs @@ -740,6 +740,7 @@ private async Task SynchronizeMonitoredItemsAsync( } desired.Remove(theDesiredUpdate); Debug.Assert(toUpdate.GetType() == theDesiredUpdate.GetType()); + Debug.Assert(toUpdate.Subscription == this); try { if (toUpdate.MergeWith(theDesiredUpdate, session, out var metadata)) @@ -775,6 +776,7 @@ private async Task SynchronizeMonitoredItemsAsync( var removed = 0; foreach (var toRemove in remove) { + Debug.Assert(toRemove.Subscription == this); try { if (toRemove.RemoveFrom(this, out var metadata)) @@ -803,6 +805,7 @@ private async Task SynchronizeMonitoredItemsAsync( foreach (var toAdd in add) { desired.Remove(toAdd); + Debug.Assert(toAdd.Subscription == null); try { if (toAdd.AddTo(this, session, out var metadata)) @@ -1060,6 +1063,8 @@ private async Task SynchronizeMonitoredItemsAsync( .Where(m => !m.AttachedToSubscription) .ToFrozenDictionary(m => m.ClientHandle, m => m); + set.ForEach(item => item.LogRevisedSamplingRateAndQueueSize()); + _badMonitoredItems = invalidItems; _goodMonitoredItems = Math.Max(set.Count - invalidItems, 0); _reportingItems = set @@ -1104,11 +1109,6 @@ private async Task SynchronizeMonitoredItemsAsync( this, e.Message); noErrorFound = false; } - if (noErrorFound) - { - _logger.LogInformation("ConditionRefresh on subscription " + - "{Subscription} has completed.", this); - } } // Set up subscription management trigger diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs index 1ffbbe407a..5f488960b8 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs @@ -48,6 +48,8 @@ public PublishedNodesConverter(ILogger logger, Math.Max(1, options.Value.ScaleTestCount ?? 0); _maxNodesPerDataSet = options.Value.MaxNodesPerDataSet <= 0 ? int.MaxValue : options.Value.MaxNodesPerDataSet; + _noPublishingIntervalGrouping = + options.Value.IgnoreConfiguredPublishingIntervals ?? false; } /// @@ -227,7 +229,7 @@ public IEnumerable ToPublishedNodes(uint version, Date QueueSize = variable.ServerQueueSize, DataChangeTrigger = variable.DataChangeTrigger, HeartbeatBehavior = variable.HeartbeatBehavior, - HeartbeatInterval = preferTimeSpan ? null : (int?)variable.HeartbeatInterval?.TotalMilliseconds, + HeartbeatInterval = preferTimeSpan ? null : (int?)variable.HeartbeatInterval?.TotalSeconds, HeartbeatIntervalTimespan = !preferTimeSpan ? null : variable.HeartbeatInterval, OpcSamplingInterval = preferTimeSpan ? null : (int?)variable.SamplingIntervalHint?.TotalMilliseconds, OpcSamplingIntervalTimespan = !preferTimeSpan ? null : variable.SamplingIntervalHint, @@ -313,31 +315,35 @@ public IEnumerable ToWriterGroups(IEnumerable GetNodeModels(entry, _scaleTestCount) - .DefaultIfEmpty(kDummyEntry) - .GroupBy(n => n.GetNormalizedPublishingInterval( - entry.GetNormalizedDataSetPublishingInterval())) - .Select(g => entry with - { - // Set the publishing interval for this entry at the top - DataSetPublishingIntervalTimespan = g.Key, - DataSetPublishingInterval = null, - OpcNodes = g - .Where(n => n != kDummyEntry) - .Select(n => n with - { - // Unset all node specific settings. - OpcPublishingIntervalTimespan = null, - OpcPublishingInterval = null - }) - .ToList() - })) + entries = entries + .SelectMany(entry => GetNodeModels(entry, _scaleTestCount) + .DefaultIfEmpty(kDummyEntry) + .GroupBy(n => n.GetNormalizedPublishingInterval( + entry.GetNormalizedDataSetPublishingInterval())) + .Select(g => entry with + { + // Set the publishing interval for this entry at the top + DataSetPublishingIntervalTimespan = g.Key, + DataSetPublishingInterval = null, + OpcNodes = g + .Where(n => n != kDummyEntry) + .Select(n => n with + { + // Unset all node specific settings. + OpcPublishingIntervalTimespan = null, + OpcPublishingInterval = null + }) + .ToList() + })); + } + return entries // // Now we have entries with nodes that have no publishing interval, group all entries // by group identifier @@ -500,17 +506,22 @@ public IEnumerable ToWriterGroups(IEnumerable GetNodeModels(PublishedNodesEntryModel item, int scaleTestCount) + IEnumerable GetNodeModels(PublishedNodesEntryModel item, int scaleTestCount) { if (item.OpcNodes != null) { foreach (var node in item.OpcNodes) { + if (!node.TryGetId(out var id)) + { + _logger.LogError("No node id was configured in the opc node entry - skipping..."); + continue; + } if (scaleTestCount <= 1) { yield return new OpcNodeModel { - Id = !string.IsNullOrEmpty(node.Id) ? node.Id : node.ExpandedNodeId, + Id = id, DisplayName = node.DisplayName, DataSetClassFieldId = node.DataSetClassFieldId, DataSetFieldId = node.DataSetFieldId, @@ -549,7 +560,7 @@ static IEnumerable GetNodeModels(PublishedNodesEntryModel item, in { yield return new OpcNodeModel { - Id = !string.IsNullOrEmpty(node.Id) ? node.Id : node.ExpandedNodeId, + Id = id, DisplayName = !string.IsNullOrEmpty(node.DisplayName) ? $"{node.DisplayName}_{i}" : null, DataSetFieldId = node.DataSetFieldId, @@ -587,7 +598,7 @@ static IEnumerable GetNodeModels(PublishedNodesEntryModel item, in } } - if (item.NodeId?.Identifier != null) + if (!string.IsNullOrWhiteSpace(item.NodeId?.Identifier)) { yield return new OpcNodeModel { @@ -881,6 +892,7 @@ private async Task ToCredentialAsync(PublishedNodesEntryModel e private readonly bool _forceCredentialEncryption; private readonly int _scaleTestCount; private readonly int _maxNodesPerDataSet; + private readonly bool _noPublishingIntervalGrouping; private readonly IIoTEdgeWorkloadApi? _cryptoProvider; private readonly IJsonSerializer _serializer; private readonly ILogger _logger; diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs index 9259d8b8e5..9f98df6ace 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs @@ -29,7 +29,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services using System.Threading.Tasks; using Xunit; using Xunit.Abstractions; - using Avro.Generic; /// /// Tests the PublisherConfigService class @@ -842,6 +841,163 @@ await FluentActions .WithMessage("Field ids must be unique."); } + [Fact] + public async Task StartStopTest1() + { + await using var configService = InitPublisherConfigService(); + + await configService.PublishStartAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = "opc.tcp://testendpoint1" + } + }, new PublishStartRequestModel + { + Item = new PublishedItemModel + { + HeartbeatInterval = TimeSpan.FromMinutes(1), + PublishingInterval = TimeSpan.FromSeconds(2), + NodeId = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt0", + DisplayName = "test", + SamplingInterval = TimeSpan.FromSeconds(3), + } + }); + + var entries = await configService.GetConfiguredEndpointsAsync(true); + entries.Count.Should().Be(1); + entries[0].EndpointUrl.Should().Be("opc.tcp://testendpoint1"); + entries[0].UseSecurity.Should().BeFalse(); + entries[0].OpcNodes.Count.Should().Be(1); + entries[0].MessageEncoding.Should().Be(MessageEncoding.Json); + entries[0].MessagingMode.Should().Be(MessagingMode.FullSamples); + var node = entries[0].OpcNodes[0]; + node.Id.Should().Be("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt0"); + node.HeartbeatInterval.Should().BeNull(); + node.OpcPublishingInterval.Should().BeNull(); + node.OpcSamplingInterval.Should().BeNull(); + node.HeartbeatIntervalTimespan.Should().Be(TimeSpan.FromMinutes(1)); + node.OpcPublishingIntervalTimespan.Should().Be(TimeSpan.FromSeconds(2)); + node.OpcSamplingIntervalTimespan.Should().Be(TimeSpan.FromSeconds(3)); + + var list = await configService.PublishListAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = "opc.tcp://testendpoint1" + } + }, new PublishedItemListRequestModel()); + + list.Items.Count.Should().Be(1); + list.Items[0].HeartbeatInterval.Should().Be(node.HeartbeatIntervalTimespan); + list.Items[0].PublishingInterval.Should().Be(node.OpcPublishingIntervalTimespan); + list.Items[0].SamplingInterval.Should().Be(node.OpcSamplingIntervalTimespan); + + await configService.PublishStopAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = "opc.tcp://testendpoint1" + } + }, new PublishStopRequestModel + { + NodeId = "nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt0" + }); + + entries = await configService.GetConfiguredEndpointsAsync(true); + entries.Count.Should().Be(1); + entries[0].OpcNodes.Should().BeEmpty(); + + list = await configService.PublishListAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = "opc.tcp://testendpoint1" + } + }, new PublishedItemListRequestModel()); + list.Items.Should().BeEmpty(); + } + + [Fact] + public async Task StartStopTest2() + { + await using var configService = InitPublisherConfigService(); + var opcNodes = Enumerable.Range(0, 101) + .Select(i => new OpcNodeModel + { + Id = $"nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt{i}", + DataSetFieldId = "alwaysthesameid" + }) + .ToList(); + var items = Enumerable.Range(1, 100).Select(i => GenerateEndpoint(i, opcNodes, true) with + { + DataSetWriterGroup = null, + MessageEncoding = MessageEncoding.Json, + MessagingMode = MessagingMode.FullSamples, + DataSetWriterId = null, + DataSetPublishingInterval = null + }).ToList(); + await configService.SetConfiguredEndpointsAsync(items); + + var results = await configService.GetConfiguredEndpointsAsync(false); + results.Count.Should().Be(100); + + var list = await configService.PublishListAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = results[0].EndpointUrl + } + }, new PublishedItemListRequestModel()); + list.Items.Count.Should().Be(2); + + var updated = list.Items[0] with + { + HeartbeatInterval = TimeSpan.FromMinutes(1), + }; + // Update + await configService.PublishStartAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = results[0].EndpointUrl + } + }, new PublishStartRequestModel + { + Item = updated + }); + + var entries = await configService.GetConfiguredEndpointsAsync(true); + entries.Count.Should().Be(100); + var entry = entries.FirstOrDefault(e => e.EndpointUrl == results[0].EndpointUrl); + entry.Should().NotBeNull(); + entry.MessageEncoding.Should().Be(MessageEncoding.Json); + entry.MessagingMode.Should().Be(MessagingMode.FullSamples); + entry.OpcNodes.Count.Should().Be(2); + var node = entry.OpcNodes[0]; + node.Id.Should().Be("nsu=http://microsoft.com/Opc/OpcPlc/;s=FastUInt0"); + node.HeartbeatInterval.Should().BeNull(); + node.HeartbeatIntervalTimespan.Should().Be(TimeSpan.FromMinutes(1)); + + await configService.PublishStopAsync(new ConnectionModel + { + Endpoint = new EndpointModel + { + Url = results[0].EndpointUrl + } + }, new PublishStopRequestModel + { + NodeId = updated.NodeId + }); + + entries = await configService.GetConfiguredEndpointsAsync(true); + entries.Count.Should().Be(100); + entry = entries.FirstOrDefault(e => e.EndpointUrl == results[0].EndpointUrl); + entry.MessageEncoding.Should().Be(MessageEncoding.Json); + entry.MessagingMode.Should().Be(MessagingMode.FullSamples); + entry.OpcNodes.Count.Should().Be(1); + } + [Fact] public async Task Legacy25PublishedNodesFile() { @@ -1401,11 +1557,10 @@ public async Task TestInitStandaloneJobOrchestratorFromEmptyOpcNodes2() }); } - [Theory] - [InlineData("Publisher/pn_assets_with_optional_fields.json")] - public async Task OptionalFieldsPublishedNodesFile(string publishedNodesFile) + [Fact] + public async Task OptionalFieldsPublishedNodesFile() { - Utils.CopyContent(publishedNodesFile, _tempFile); + Utils.CopyContent("Publisher/pn_assets_with_optional_fields.json", _tempFile); await using (var configService = InitPublisherConfigService()) { var endpoints = await configService.GetConfiguredEndpointsAsync(); diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/OpcNodeModelEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/OpcNodeModelEx.cs index 2ac90820aa..a4d427127c 100644 --- a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/OpcNodeModelEx.cs +++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/OpcNodeModelEx.cs @@ -9,6 +9,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Config.Models using Furly.Extensions.Messaging; using System; using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; /// /// Dataset source extensions @@ -21,6 +22,22 @@ public static class OpcNodeModelEx public static EqualityComparer Comparer { get; } = new OpcNodeModelComparer(); + /// + /// Try get the id element of the node + /// + /// + /// + /// + public static bool TryGetId(this OpcNodeModel node, [NotNullWhen(true)] out string? id) + { + id = !string.IsNullOrWhiteSpace(node.Id) ? + node.Id : !string.IsNullOrWhiteSpace(node.ExpandedNodeId) ? + node.ExpandedNodeId : node.BrowsePath?.Count > 0 ? + Opc.Ua.ObjectIds.RootFolder.ToString() : node.ModelChangeHandling != null ? + Opc.Ua.ObjectIds.Server.ToString() : null; + return id != null; + } + /// /// Check if nodes are equal ///