├── CHANGELOG.md ├── GitVersion.yml ├── opcclient ├── AssemblyInfo.cs ├── opcclient.csproj ├── Diagnostics.cs ├── OpcConfigurationModel.cs ├── OpcAction.cs ├── OpcApplicationConfiguration.cs ├── OpcConfiguration.cs ├── OpcSession.cs ├── Program.cs └── OpcApplicationConfigurationSecurity.cs ├── Dockerfile ├── docker ├── linux │ ├── amd64 │ │ ├── Dockerfile │ │ ├── Dockerfile.ssh │ │ └── Dockerfile.debug │ └── arm32v7 │ │ └── Dockerfile └── windows │ └── amd64 │ └── Dockerfile ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── LICENSE.md ├── README.md ├── opcclient.sln ├── CONTRIBUTING.md └── .gitignore /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | assembly-versioning-scheme: MajorMinorPatch 2 | assembly-file-versioning-format: "{FullSemVer}" 3 | increment: none 4 | branches: { 5 | master: { 6 | increment: none 7 | } 8 | } 9 | ignore: 10 | sha: [] 11 | -------------------------------------------------------------------------------- /opcclient/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | 2 | [assembly: System.Reflection.AssemblyCompany("Microsoft")] 3 | [assembly: System.Reflection.AssemblyConfiguration("Release")] 4 | [assembly: System.Reflection.AssemblyDescription("OPC UA client able to run OPC operations on an OPC UA server.")] 5 | [assembly: System.Reflection.AssemblyFileVersion("1.0.0")] 6 | [assembly: System.Reflection.AssemblyInformationalVersion("1.0.0")] 7 | [assembly: System.Reflection.AssemblyProduct("opcclient")] 8 | [assembly: System.Reflection.AssemblyTitle("opcclient")] 9 | [assembly: System.Reflection.AssemblyVersion("1.0.0.0")] 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG runtime_base_tag=2.1-runtime-alpine 2 | ARG build_base_tag=2.1-sdk-alpine 3 | 4 | FROM microsoft/dotnet:${build_base_tag} AS build 5 | WORKDIR /app 6 | 7 | # copy csproj and restore as distinct layers 8 | COPY opcclient/*.csproj ./opcclient/ 9 | WORKDIR /app/opcclient 10 | RUN dotnet restore 11 | 12 | # copy and publish app 13 | WORKDIR /app 14 | COPY opcclient/. ./opcclient/ 15 | WORKDIR /app/opcclient 16 | RUN dotnet publish -c Release -o out 17 | 18 | # start it up 19 | FROM microsoft/dotnet:${runtime_base_tag} AS runtime 20 | WORKDIR /app 21 | COPY --from=build /app/opcclient/out ./ 22 | WORKDIR /appdata 23 | ENTRYPOINT ["dotnet", "/app/opcclient.dll"] 24 | -------------------------------------------------------------------------------- /docker/linux/amd64/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG runtime_base_tag=2.1-runtime-alpine 2 | ARG build_base_tag=2.1-sdk-alpine 3 | 4 | FROM microsoft/dotnet:${build_base_tag} AS build 5 | WORKDIR /app 6 | 7 | # copy csproj and restore as distinct layers 8 | COPY opcclient/*.csproj ./opcclient/ 9 | WORKDIR /app/opcclient 10 | RUN dotnet restore 11 | 12 | # copy and publish app 13 | WORKDIR /app 14 | COPY opcclient/. ./opcclient/ 15 | WORKDIR /app/opcclient 16 | RUN dotnet publish -c Release -o out 17 | 18 | # start it up 19 | FROM microsoft/dotnet:${runtime_base_tag} AS runtime 20 | WORKDIR /app 21 | COPY --from=build /app/opcclient/out ./ 22 | WORKDIR /appdata 23 | ENTRYPOINT ["dotnet", "/app/opcclient.dll"] 24 | -------------------------------------------------------------------------------- /docker/windows/amd64/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG runtime_base_tag=2.1-runtime-nanoserver-1809 2 | ARG build_base_tag=2.1-sdk 3 | 4 | FROM microsoft/dotnet:${build_base_tag} AS build 5 | WORKDIR /app 6 | 7 | # copy csproj and restore as distinct layers 8 | COPY opcclient/*.csproj ./opcclient/ 9 | WORKDIR /app/opcclient 10 | RUN dotnet restore 11 | 12 | # copy and publish app 13 | WORKDIR /app 14 | COPY opcclient/. ./opcclient/ 15 | WORKDIR /app/opcclient 16 | RUN dotnet publish -c Release -o out 17 | 18 | # start it up 19 | FROM microsoft/dotnet:${runtime_base_tag} AS runtime 20 | WORKDIR /app 21 | COPY --from=build /app/opcclient/out ./ 22 | WORKDIR /appdata 23 | ENTRYPOINT ["dotnet", "/app/opcclient.dll"] 24 | -------------------------------------------------------------------------------- /docker/linux/arm32v7/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG runtime_base_tag=2.1-runtime-bionic-arm32v7 2 | ARG build_base_tag=2.1-sdk-bionic-arm32v7 3 | 4 | FROM microsoft/dotnet:${build_base_tag} AS build 5 | WORKDIR /app 6 | 7 | # copy csproj and restore as distinct layers 8 | COPY opcclient/*.csproj ./opcclient/ 9 | WORKDIR /app/opcclient 10 | RUN dotnet restore 11 | 12 | # copy and publish app 13 | WORKDIR /app 14 | COPY opcclient/. ./opcclient/ 15 | WORKDIR /app/opcclient 16 | RUN dotnet publish -c Release -o out 17 | 18 | # start it up 19 | FROM microsoft/dotnet:${runtime_base_tag} AS runtime 20 | WORKDIR /app 21 | COPY --from=build /app/opcclient/out ./ 22 | WORKDIR /appdata 23 | ENTRYPOINT ["dotnet", "/app/opcclient.dll"] 24 | -------------------------------------------------------------------------------- /docker/linux/amd64/Dockerfile.ssh: -------------------------------------------------------------------------------- 1 | FROM microsoft/dotnet:2.1-sdk-stretch 2 | 3 | RUN apt-get update && apt-get install -y unzip \ 4 | && curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l ~/vsdbg 5 | ENV PATH="${PATH}:/root/vsdbg/vsdbg" 6 | 7 | RUN apt-get update && apt-get install -y openssh-server \ 8 | && mkdir /var/run/sshd \ 9 | && echo 'root:Passw0rd' | chpasswd \ 10 | && sed -i 's/PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config \ 11 | && sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd \ 12 | && echo "export VISIBLE=now" >> /etc/profile 13 | ENV NOTVISIBLE "in users profile" 14 | 15 | WORKDIR /app 16 | ENTRYPOINT ["bash"] 17 | -------------------------------------------------------------------------------- /docker/linux/amd64/Dockerfile.debug: -------------------------------------------------------------------------------- 1 | ARG runtime_base_tag=2.1-runtime-stretch-slim 2 | ARG build_base_tag=2.1-sdk-stretch 3 | 4 | FROM microsoft/dotnet:${build_base_tag} AS build 5 | WORKDIR /app 6 | 7 | # copy csproj and restore as distinct layers 8 | COPY opcclient/*.csproj ./opcclient/ 9 | WORKDIR /app/opcclient 10 | RUN dotnet restore 11 | 12 | # copy and publish app 13 | WORKDIR /app 14 | COPY opcclient/. ./opcclient/ 15 | WORKDIR /app/opcclient 16 | RUN dotnet publish -c Release -o out 17 | 18 | # start it up 19 | FROM microsoft/dotnet:${runtime_base_tag} AS runtime 20 | 21 | RUN apt-get update && \ 22 | apt-get install -y --no-install-recommends unzip procps && \ 23 | rm -rf /var/lib/apt/lists/* 24 | 25 | RUN curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l ~/vsdbg 26 | 27 | WORKDIR /app 28 | COPY --from=build /app/opcclient/out ./ 29 | WORKDIR /appdata 30 | ENTRYPOINT ["dotnet", "/app/opcclient.dll"] 31 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default behavior: if Git thinks a file is text (as opposed to binary), it 2 | # will normalize line endings to LF in the repository, but convert to your 3 | # platform's native line endings on checkout (e.g., CRLF for Windows). 4 | * text=auto 5 | 6 | # Explicitly declare text files you want to always be normalized and converted 7 | # to native line endings on checkout. E.g., 8 | #*.c text 9 | 10 | # Declare files that will always have CRLF line endings on checkout. E.g., 11 | #*.sln text eol=crlf 12 | 13 | # Declare files that will always have LF line endings on checkout. E.g., 14 | *.sh text eol=lf 15 | *.json text eol=lf 16 | 17 | # Denote all files that should not have line endings normalized, should not be 18 | # merged, and should not show in a textual diff. 19 | *.docm binary 20 | *.docx binary 21 | *.ico binary 22 | *.lib binary 23 | *.png binary 24 | *.pptx binary 25 | *.snk binary 26 | *.vsdx binary 27 | *.xps binary 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /opcclient/opcclient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.1 5 | opcclient 6 | Exe 7 | OpcClient 8 | 2.1 9 | portable 10 | false 11 | Microsoft 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | description: "Industrial IoT - OPC UA client able to run OPC operations on an OPC UA server" 4 | languages: 5 | - csharp 6 | products: 7 | - azure 8 | - azure-iot-hub 9 | urlFragment: azure-iot-opc-client 10 | --- 11 | 12 | # OPC UA client 13 | OPC UA client able to run OPC operations on an OPC UA server. 14 | 15 | 16 | ## Features 17 | The client allows run single or recurring operations targeting an OPC UA server to: 18 | - test connectivity by reading the current time node 19 | - read node values 20 | 21 | By default opc-client is testing the connectivity to `opc.tcp://opcplc:50000`. This can be disabled or changed to a different endpoint via command line. 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | The implementation is based on .NET Core so it is cross-platform and recommended hosting environment is docker. 28 | 29 | ### Installation 30 | 31 | There is no installation required. 32 | 33 | ### Quickstart 34 | 35 | A docker container of the component is hosted in the Microsoft Container Registry and can be pulled by: 36 | 37 | docker pull mcr.microsoft.com/iotedge/opc-client 38 | 39 | The tags of the container match the tags of this repository and the containers are available for Windows amd64, Linux amd64 and Linux ARM32. 40 | 41 | 42 | ## Demo 43 | 44 | The [OpcPlc](https://github.com/Azure-Samples/iot-edge-opc-plc) is an OPC UA server, which is the default target OPC UA server. 45 | 46 | Please check out the github repository https://github.com/Azure-Samples/iot-edge-industrial-configs for sample configurations showing usage of this OPC UA client implementation. 47 | 48 | 49 | ## Notes 50 | 51 | X.509 certificates releated: 52 | 53 | * Running on Windows natively, you can not use an application certificate store of type `Directory`, since the access to the private key fails. Please use the option `--at X509Store` in this case. 54 | * Running as Linux docker container, you can map the certificate stores to the host file system by using the docker run option `-v :/appdata`. This will make the certificate persistent over starts. 55 | * Running as Linux docker container and want to use an X509Store for the application certificate, you need to use the docker run option `-v x509certstores:/root/.dotnet/corefx/cryptography/x509stores` and the application option `--at X509Store` 56 | 57 | 58 | ## Resources 59 | 60 | - [The OPC Foundation OPC UA .NET reference stack](https://github.com/OPCFoundation/UA-.NETStandard) 61 | -------------------------------------------------------------------------------- /opcclient/Diagnostics.cs: -------------------------------------------------------------------------------- 1 | 2 | using System.Diagnostics; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace OpcClient 7 | { 8 | using static OpcConfiguration; 9 | using static Program; 10 | 11 | /// 12 | /// Class to enable output to the console. 13 | /// 14 | public static class Diagnostics 15 | { 16 | /// 17 | /// Interval in seconds to show diagnostic info. 18 | /// 19 | public static uint DiagnosticsInterval { get; set; } = 0; 20 | 21 | public static void Init() 22 | { 23 | // init data 24 | _showDiagnosticsInfoTask = null; 25 | _shutdownTokenSource = new CancellationTokenSource(); 26 | 27 | // kick off the task to show diagnostic info 28 | if (DiagnosticsInterval > 0) 29 | { 30 | _showDiagnosticsInfoTask = Task.Run(async () => await ShowDiagnosticsInfoAsync(_shutdownTokenSource.Token)); 31 | } 32 | 33 | 34 | } 35 | 36 | /// 37 | /// Shutdown diagnostic task. 38 | /// 39 | /// 40 | public async static Task ShutdownAsync() 41 | { 42 | // wait for diagnostic task completion if it is enabled 43 | if (_showDiagnosticsInfoTask != null) 44 | { 45 | _shutdownTokenSource.Cancel(); 46 | await _showDiagnosticsInfoTask; 47 | } 48 | 49 | _shutdownTokenSource = null; 50 | _showDiagnosticsInfoTask = null; 51 | } 52 | 53 | /// 54 | /// Kicks of the task to show diagnostic information each 30 seconds. 55 | /// 56 | public static async Task ShowDiagnosticsInfoAsync(CancellationToken ct) 57 | { 58 | while (true) 59 | { 60 | if (ct.IsCancellationRequested) 61 | { 62 | return; 63 | } 64 | 65 | try 66 | { 67 | await Task.Delay((int)DiagnosticsInterval * 1000, ct); 68 | 69 | Logger.Information("=========================================================================="); 70 | Logger.Information($"{ProgramName} status @ {System.DateTime.UtcNow} (started @ {ProgramStartTime})"); 71 | Logger.Information("---------------------------------"); 72 | Logger.Information($"OPC sessions: {NumberOfOpcSessions}"); 73 | Logger.Information($"connected OPC sessions: {NumberOfConnectedOpcSessions}"); 74 | Logger.Information($"# of actions: {NumberOfActions}"); 75 | Logger.Information($"# of recurring actions: {NumberOfRecurringActions}"); 76 | Logger.Information("---------------------------------"); 77 | Logger.Information($"current working set in MB: {Process.GetCurrentProcess().WorkingSet64 / (1024 * 1024)}"); 78 | Logger.Information("=========================================================================="); 79 | } 80 | catch 81 | { 82 | } 83 | } 84 | } 85 | 86 | private static CancellationTokenSource _shutdownTokenSource; 87 | private static Task _showDiagnosticsInfoTask; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /opcclient.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27130.2036 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{560B34E9-68E3-4033-94D4-CE24073F5904}" 7 | ProjectSection(SolutionItems) = preProject 8 | .gitattributes = .gitattributes 9 | .gitignore = .gitignore 10 | Dockerfile = Dockerfile 11 | GitVersion.yml = GitVersion.yml 12 | LICENSE = LICENSE 13 | README.md = README.md 14 | EndProjectSection 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "opcclient", "opcclient\opcclient.csproj", "{215DC07F-AFA5-445C-BE70-09BF009C9A66}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{8B205340-83A1-4748-9417-0300C1AA5CA3}" 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "linux", "linux", "{E702EA85-1054-4252-B029-78B694D08C08}" 21 | EndProject 22 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "windows", "windows", "{BC12870A-38D7-4A8F-9C6B-925101D402FB}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "amd64", "amd64", "{7AE30F71-DFA6-4E09-8CAD-334D009515C1}" 25 | ProjectSection(SolutionItems) = preProject 26 | docker\linux\amd64\Dockerfile = docker\linux\amd64\Dockerfile 27 | docker\linux\amd64\Dockerfile.debug = docker\linux\amd64\Dockerfile.debug 28 | docker\linux\amd64\Dockerfile.ssh = docker\linux\amd64\Dockerfile.ssh 29 | EndProjectSection 30 | EndProject 31 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "arm32v7", "arm32v7", "{94E5355D-AA16-4E28-8B34-FA1CDBC77312}" 32 | ProjectSection(SolutionItems) = preProject 33 | docker\linux\arm32v7\Dockerfile = docker\linux\arm32v7\Dockerfile 34 | EndProjectSection 35 | EndProject 36 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "amd64", "amd64", "{8F91D4C5-8CD0-4195-9B3E-1072F0B87E92}" 37 | ProjectSection(SolutionItems) = preProject 38 | docker\windows\amd64\Dockerfile = docker\windows\amd64\Dockerfile 39 | EndProjectSection 40 | EndProject 41 | Global 42 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 43 | Debug|Any CPU = Debug|Any CPU 44 | Release|Any CPU = Release|Any CPU 45 | EndGlobalSection 46 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 47 | {215DC07F-AFA5-445C-BE70-09BF009C9A66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {215DC07F-AFA5-445C-BE70-09BF009C9A66}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {215DC07F-AFA5-445C-BE70-09BF009C9A66}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {215DC07F-AFA5-445C-BE70-09BF009C9A66}.Release|Any CPU.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(SolutionProperties) = preSolution 53 | HideSolutionNode = FALSE 54 | EndGlobalSection 55 | GlobalSection(NestedProjects) = preSolution 56 | {8B205340-83A1-4748-9417-0300C1AA5CA3} = {560B34E9-68E3-4033-94D4-CE24073F5904} 57 | {E702EA85-1054-4252-B029-78B694D08C08} = {8B205340-83A1-4748-9417-0300C1AA5CA3} 58 | {BC12870A-38D7-4A8F-9C6B-925101D402FB} = {8B205340-83A1-4748-9417-0300C1AA5CA3} 59 | {7AE30F71-DFA6-4E09-8CAD-334D009515C1} = {E702EA85-1054-4252-B029-78B694D08C08} 60 | {94E5355D-AA16-4E28-8B34-FA1CDBC77312} = {E702EA85-1054-4252-B029-78B694D08C08} 61 | {8F91D4C5-8CD0-4195-9B3E-1072F0B87E92} = {BC12870A-38D7-4A8F-9C6B-925101D402FB} 62 | EndGlobalSection 63 | GlobalSection(ExtensibilityGlobals) = postSolution 64 | SolutionGuid = {C1B8DD9F-B491-4E8A-8DF9-3077636E9A84} 65 | EndGlobalSection 66 | EndGlobal 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 6 | 7 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /opcclient/OpcConfigurationModel.cs: -------------------------------------------------------------------------------- 1 | 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | namespace OpcClient 7 | { 8 | using System.ComponentModel; 9 | using static Program; 10 | 11 | /// 12 | /// Class describing an action to execute 13 | /// 14 | public class ActionModel 15 | { 16 | /// 17 | /// Ctor for the action. 18 | /// 19 | public ActionModel() 20 | { 21 | Id = null; 22 | Interval = 0; 23 | } 24 | 25 | /// 26 | /// Ctor for the action 27 | /// 28 | public ActionModel(string id, int interval = 0) 29 | { 30 | Id = id; 31 | Interval = interval; 32 | } 33 | 34 | // Id of the target node. Can be: 35 | // a NodeId ("ns=") 36 | // an ExpandedNodeId ("nsu=") 37 | public string Id; 38 | 39 | // if set action will recur with a period of Interval seconds, if set to 0 it will done only once 40 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling =DefaultValueHandling.IgnoreAndPopulate)] 41 | public int Interval; 42 | } 43 | 44 | /// 45 | /// Class describing a read action on an OPC UA server. 46 | /// 47 | public class ReadActionModel : ActionModel 48 | { 49 | /// 50 | /// Ctor of a read action model. 51 | /// 52 | public ReadActionModel() 53 | { 54 | Id = null; 55 | Interval = 0; 56 | } 57 | 58 | /// 59 | /// Ctor of a read action model. 60 | /// 61 | public ReadActionModel(string id, int interval = 0) 62 | { 63 | Id = id; 64 | Interval = interval; 65 | } 66 | 67 | /// 68 | /// Ctor of a read action model. 69 | /// 70 | public ReadActionModel(ReadActionModel action) 71 | { 72 | Id = action.Id; 73 | Interval = action.Interval; 74 | } 75 | } 76 | 77 | /// 78 | /// Class describing a test action on an OPC UA server. 79 | /// 80 | public class TestActionModel : ActionModel 81 | { 82 | /// 83 | /// Ctor of a test action model. 84 | /// Default test works on the current time node with an interval of 30 sec. 85 | /// 86 | public TestActionModel() 87 | { 88 | Id = "i=2258"; 89 | Interval = 30; 90 | } 91 | 92 | /// 93 | /// Ctor of a test action model. 94 | /// 95 | public TestActionModel(string id, int interval = 0) 96 | { 97 | Id = id; 98 | Interval = interval; 99 | } 100 | 101 | /// 102 | /// Ctor of a test action model. 103 | /// 104 | public TestActionModel(TestActionModel action) 105 | { 106 | Id = action.Id; 107 | Interval = action.Interval; 108 | } 109 | } 110 | 111 | /// 112 | /// Class describing a model for an OPC action. 113 | /// 114 | public partial class OpcActionConfigurationModel 115 | { 116 | /// 117 | /// Ctor of the action configuration model. 118 | /// 119 | public OpcActionConfigurationModel() 120 | { 121 | Init(); 122 | } 123 | 124 | /// 125 | /// Ctor of the action configuration model. 126 | /// 127 | public OpcActionConfigurationModel(ReadActionModel action, string endpointUrl = null, bool useSecurity = true) 128 | { 129 | Init(); 130 | EndpointUrl = new Uri(endpointUrl ?? DefaultEndpointUrl); 131 | UseSecurity = useSecurity; 132 | Read.Add(new ReadActionModel(action)); 133 | } 134 | 135 | /// 136 | /// Ctor of the action configuration model. 137 | /// 138 | public OpcActionConfigurationModel(TestActionModel action, string endpointUrl = null, bool useSecurity = true) 139 | { 140 | Init(); 141 | EndpointUrl = new Uri(endpointUrl ?? DefaultEndpointUrl); 142 | UseSecurity = useSecurity; 143 | Test.Add(new TestActionModel(action)); 144 | } 145 | 146 | /// 147 | /// Init the action configuration model. 148 | /// 149 | private void Init() 150 | { 151 | EndpointUrl = new Uri(DefaultEndpointUrl); 152 | UseSecurity = true; 153 | Read = new List(); 154 | Test = new List(); 155 | } 156 | 157 | /// 158 | /// Endpoint URL of the server the action should target. 159 | /// 160 | public Uri EndpointUrl { get; set; } 161 | 162 | /// 163 | /// Controls if the OPC UA session should use a secure endpoint. 164 | /// 165 | [DefaultValue(true)] 166 | [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore)] 167 | public bool UseSecurity { get; set; } 168 | 169 | /// 170 | /// The read actions on the endpoint. 171 | /// 172 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 173 | public List Read { get; set; } 174 | 175 | /// 176 | /// The test actions on the endpoint. 177 | /// 178 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] 179 | public List Test { get; set; } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /opcclient/OpcAction.cs: -------------------------------------------------------------------------------- 1 | 2 | using System; 3 | 4 | namespace OpcClient 5 | { 6 | using Opc.Ua; 7 | using static Program; 8 | 9 | /// 10 | /// Class to manage OPC sessions. 11 | /// 12 | public class OpcAction 13 | { 14 | /// 15 | /// Next action id. 16 | /// 17 | private static uint IdCount = 0; 18 | 19 | /// 20 | /// Instance action id. 21 | /// 22 | public uint Id; 23 | 24 | /// 25 | /// Endpoint URL of the target server. 26 | /// 27 | public string EndpointUrl; 28 | 29 | /// 30 | /// Configured id of action target node. 31 | /// 32 | public string OpcNodeId; 33 | 34 | /// 35 | /// Recurring interval of action in sec. 36 | /// 37 | public int Interval; 38 | 39 | /// 40 | /// Next execution of action in utc ticks. 41 | /// 42 | public long NextExecution; 43 | 44 | /// 45 | /// OPC UA node id of action target node. 46 | /// 47 | public NodeId OpcUaNodeId; 48 | 49 | /// 50 | /// Description of action. 51 | /// 52 | public string Description => $"ActionId: {Id:D3} ActionType: '{GetType().Name}', Endpoint: '{EndpointUrl}' Node '{OpcNodeId}'"; 53 | 54 | /// 55 | /// Ctor for the action. 56 | /// 57 | public OpcAction(Uri endpointUrl, string opcNodeId, int interval) 58 | { 59 | Id = IdCount++; ; 60 | EndpointUrl = endpointUrl.AbsoluteUri; 61 | Interval = interval; 62 | OpcNodeId = opcNodeId; 63 | NextExecution = DateTime.UtcNow.Ticks; 64 | OpcUaNodeId = null; 65 | } 66 | 67 | /// 68 | /// Execute function needs to be overloaded. 69 | /// 70 | public virtual void Execute(OpcSession session) 71 | { 72 | Logger.Error($"No Execute method for action ({Description}) defined."); 73 | throw new Exception($"No Execute method for action ({ Description}) defined."); 74 | } 75 | 76 | /// 77 | /// Report result of action. 78 | /// 79 | public virtual void ReportResult(ServiceResultException sre) 80 | { 81 | if (sre == null) 82 | { 83 | ReportSuccess(); 84 | } 85 | else 86 | { 87 | ReportFailure(sre); 88 | } 89 | } 90 | 91 | /// 92 | /// Report successful action execution. 93 | /// 94 | public virtual void ReportSuccess() 95 | { 96 | Logger.Information($"Action ({Description}) completed successfully"); 97 | } 98 | 99 | /// 100 | /// Report failed action execution. 101 | /// 102 | public virtual void ReportFailure(ServiceResultException sre) 103 | { 104 | Logger.Information($"Action ({Description}) execution with error"); 105 | Logger.Information($"Result ({Description}): {sre.Result.ToString()}"); 106 | if (sre.InnerException != null) 107 | { 108 | Logger.Information($"Details ({Description}): {sre.InnerException.Message}"); 109 | } 110 | } 111 | } 112 | 113 | /// 114 | /// Class to describe a read action. 115 | /// 116 | public class OpcReadAction : OpcAction 117 | { 118 | /// 119 | /// Value read by the action. 120 | /// 121 | public dynamic Value; 122 | 123 | /// 124 | /// Ctor for the read action. 125 | /// 126 | public OpcReadAction(Uri endpointUrl, ReadActionModel action) : base(endpointUrl, action.Id, action.Interval) 127 | { 128 | } 129 | 130 | /// 131 | public override void Execute(OpcSession session) 132 | { 133 | Logger.Information($"Start action {Description} on '{session.EndpointUrl}'"); 134 | 135 | // read the node info 136 | Node node = session.OpcUaClientSession.ReadNode(OpcUaNodeId); 137 | 138 | // report the node info 139 | Logger.Information($"Node Displayname is '{node.DisplayName}'"); 140 | Logger.Information($"Node Description is '{node.Description}'"); 141 | 142 | // read the value 143 | DataValue dataValue = session.OpcUaClientSession.ReadValue(OpcUaNodeId); 144 | 145 | // report the node value 146 | Logger.Information($"Node Value is '{dataValue.Value}'"); 147 | Logger.Information($"Node Value is '{dataValue.ToString()}'"); 148 | } 149 | 150 | /// 151 | public override void ReportSuccess() 152 | { 153 | Logger.Information($"Action ({Description}) completed successfully"); 154 | Logger.Information($"Value ({Description}): {Value}"); 155 | } 156 | } 157 | 158 | /// 159 | /// Class to describe a test action. 160 | /// 161 | public class OpcTestAction : OpcAction 162 | { 163 | /// 164 | /// Value read by the action. 165 | /// 166 | public dynamic Value; 167 | 168 | /// 169 | /// Ctor for the test action. 170 | /// 171 | public OpcTestAction(Uri endpointUrl, TestActionModel action) : base(endpointUrl, action.Id, action.Interval) 172 | { 173 | } 174 | 175 | /// 176 | public override void Execute(OpcSession session) 177 | { 178 | Logger.Debug($"Start action {Description} on '{session.EndpointUrl}'"); 179 | 180 | if (OpcUaNodeId == null) 181 | { 182 | // get the NodeId 183 | OpcUaNodeId = session.GetNodeIdFromId(OpcNodeId); 184 | } 185 | 186 | // read the node info 187 | Node node = session.OpcUaClientSession.ReadNode(OpcUaNodeId); 188 | 189 | // report the node info 190 | Logger.Debug($"Action ({Description}) Node DisplayName is '{node.DisplayName}'"); 191 | Logger.Debug($"Action ({Description}) Node Description is '{node.Description}'"); 192 | 193 | // read the value 194 | DataValue dataValue = session.OpcUaClientSession.ReadValue(OpcUaNodeId); 195 | try 196 | { 197 | Value = dataValue.Value; 198 | } 199 | catch (Exception e) 200 | { 201 | Logger.Warning(e, $"Cannot convert type of read value."); 202 | Value = "Cannot convert type of read value."; 203 | } 204 | 205 | // report the node value 206 | Logger.Debug($"Action ({Description}) Node data value is '{dataValue.Value}'"); 207 | } 208 | 209 | /// 210 | public override void ReportSuccess() 211 | { 212 | Logger.Information($"Action ({Description}) completed successfully"); 213 | Logger.Information($"Value ({Description}): {Value}"); 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /opcclient/OpcApplicationConfiguration.cs: -------------------------------------------------------------------------------- 1 | 2 | using Opc.Ua; 3 | using System; 4 | 5 | namespace OpcClient 6 | { 7 | using System.Threading.Tasks; 8 | using static Program; 9 | 10 | /// 11 | /// Class for OPC Application configuration. 12 | /// 13 | public partial class OpcApplicationConfiguration 14 | { 15 | /// 16 | /// Configuration info for the OPC application. 17 | /// 18 | public static ApplicationConfiguration ApplicationConfiguration { get; private set; } 19 | public static string Hostname 20 | { 21 | get => _hostname; 22 | set => _hostname = value.ToLowerInvariant(); 23 | } 24 | 25 | public static string HostnameLabel => (_hostname.Contains(".") ? _hostname.Substring(0, _hostname.IndexOf('.')) : _hostname); 26 | public static string ApplicationName => ProgramName; 27 | public static string ApplicationUri => $"urn:{ProgramName}:{HostnameLabel}"; 28 | public static string ProductUri => $"https://github.com/azure-samples/iot-edge-opc-client"; 29 | 30 | /// 31 | /// Default endpoint security of the application. 32 | /// 33 | public static string ServerSecurityPolicy { get; set; } = SecurityPolicies.Basic128Rsa15; 34 | 35 | /// 36 | /// Enables unsecure endpoint access to the application. 37 | /// 38 | public static bool EnableUnsecureTransport { get; set; } = false; 39 | 40 | /// 41 | /// Max timeout when creating a new session to a server. 42 | /// 43 | public static uint OpcSessionCreationTimeout { get; set; } = 10; 44 | 45 | /// 46 | /// Keep alive interval. 47 | /// 48 | public static int OpcKeepAliveInterval { get; set; } = 2; 49 | 50 | /// 51 | /// Backoff for session creation. 52 | /// 53 | public static uint OpcSessionCreationBackoffMax { get; set; } = 5; 54 | 55 | /// 56 | /// Number of missed keep alives allowed, before disconnecting the session. 57 | /// 58 | public static uint OpcKeepAliveDisconnectThreshold { get; set; } = 5; 59 | 60 | /// 61 | /// Set the max string length the OPC stack supports. 62 | /// 63 | public static int OpcMaxStringLength { get; set; } = 4 * 1024 * 1024; 64 | 65 | /// 66 | /// Operation timeout for OPC UA communication. 67 | /// 68 | public static int OpcOperationTimeout { get; set; } = 120000; 69 | 70 | /// 71 | /// Mapping of the application logging levels to OPC stack logging levels. 72 | /// 73 | public static int OpcTraceToLoggerVerbose = 0; 74 | public static int OpcTraceToLoggerDebug = 0; 75 | public static int OpcTraceToLoggerInformation = 0; 76 | public static int OpcTraceToLoggerWarning = 0; 77 | public static int OpcTraceToLoggerError = 0; 78 | public static int OpcTraceToLoggerFatal = 0; 79 | 80 | /// 81 | /// Set the OPC stack log level. 82 | /// 83 | public static int OpcStackTraceMask { get; set; } = 0; 84 | 85 | /// 86 | /// Ctor of the OPC application configuration. 87 | /// 88 | public OpcApplicationConfiguration() 89 | { 90 | } 91 | 92 | /// 93 | /// Configures all OPC stack settings. 94 | /// 95 | public async Task ConfigureAsync() 96 | { 97 | // instead of using a configuration XML file, we configure everything programmatically 98 | 99 | // passed in as command line argument 100 | ApplicationConfiguration = new ApplicationConfiguration(); 101 | ApplicationConfiguration.ApplicationName = ApplicationName; 102 | ApplicationConfiguration.ApplicationUri = ApplicationUri; 103 | ApplicationConfiguration.ProductUri = ProductUri; 104 | ApplicationConfiguration.ApplicationType = ApplicationType.Client; 105 | 106 | // configure OPC stack tracing 107 | ApplicationConfiguration.TraceConfiguration = new TraceConfiguration(); 108 | ApplicationConfiguration.TraceConfiguration.TraceMasks = OpcStackTraceMask; 109 | ApplicationConfiguration.TraceConfiguration.ApplySettings(); 110 | Utils.Tracing.TraceEventHandler += new EventHandler(LoggerOpcUaTraceHandler); 111 | Logger.Information($"opcstacktracemask set to: 0x{OpcStackTraceMask:X}"); 112 | 113 | // add default client configuration 114 | ApplicationConfiguration.ClientConfiguration = new ClientConfiguration(); 115 | 116 | // configure transport settings 117 | ApplicationConfiguration.TransportQuotas = new TransportQuotas(); 118 | ApplicationConfiguration.TransportQuotas.MaxStringLength = OpcMaxStringLength; 119 | ApplicationConfiguration.TransportQuotas.MaxMessageSize = 4 * 1024 * 1024; 120 | 121 | // security configuration 122 | await InitApplicationSecurityAsync(); 123 | 124 | // show certificate store information 125 | await ShowCertificateStoreInformationAsync(); 126 | 127 | return ApplicationConfiguration; 128 | } 129 | 130 | /// 131 | /// Event handler to log OPC UA stack trace messages into own logger. 132 | /// 133 | private static void LoggerOpcUaTraceHandler(object sender, TraceEventArgs e) 134 | { 135 | // return fast if no trace needed 136 | if ((e.TraceMask & OpcStackTraceMask) == 0) 137 | { 138 | return; 139 | } 140 | 141 | // e.Exception and e.Message are always null 142 | 143 | // format the trace message 144 | string message = string.Empty; 145 | message = string.Format(e.Format, e.Arguments).Trim(); 146 | message = "OPC: " + message; 147 | 148 | // map logging level 149 | if ((e.TraceMask & OpcTraceToLoggerVerbose) != 0) 150 | { 151 | Logger.Verbose(message); 152 | return; 153 | } 154 | if ((e.TraceMask & OpcTraceToLoggerDebug) != 0) 155 | { 156 | Logger.Debug(message); 157 | return; 158 | } 159 | if ((e.TraceMask & OpcTraceToLoggerInformation) != 0) 160 | { 161 | Logger.Information(message); 162 | return; 163 | } 164 | if ((e.TraceMask & OpcTraceToLoggerWarning) != 0) 165 | { 166 | Logger.Warning(message); 167 | return; 168 | } 169 | if ((e.TraceMask & OpcTraceToLoggerError) != 0) 170 | { 171 | Logger.Error(message); 172 | return; 173 | } 174 | if ((e.TraceMask & OpcTraceToLoggerFatal) != 0) 175 | { 176 | Logger.Fatal(message); 177 | return; 178 | } 179 | return; 180 | } 181 | 182 | private static string _hostname = $"{Utils.GetHostName().ToLowerInvariant()}"; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /opcclient/OpcConfiguration.cs: -------------------------------------------------------------------------------- 1 | 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | namespace OpcClient 8 | { 9 | using System.IO; 10 | using System.Linq; 11 | using System.Threading; 12 | using static OpcApplicationConfiguration; 13 | using static Program; 14 | 15 | /// 16 | /// Class for the applications internal OPC relevant configuration. 17 | /// 18 | public static class OpcConfiguration 19 | { 20 | /// 21 | /// list of all OPC sessions to manage 22 | /// 23 | public static List OpcSessions { get; set; } 24 | 25 | /// 26 | /// Semaphore to protect access to the OPC sessions list. 27 | /// 28 | public static SemaphoreSlim OpcSessionsListSemaphore { get; set; } 29 | 30 | /// 31 | /// Semaphore to protect the access to the action list. 32 | /// 33 | public static SemaphoreSlim OpcActionListSemaphore { get; set; } 34 | 35 | /// 36 | /// Filename of the configuration file. 37 | /// 38 | public static string OpcActionConfigurationFilename { get; set; } = $"{System.IO.Directory.GetCurrentDirectory()}{Path.DirectorySeparatorChar}actionconfig.json"; 39 | 40 | /// 41 | /// Reports number of OPC sessions. 42 | /// 43 | public static int NumberOfOpcSessions 44 | { 45 | get 46 | { 47 | int result = 0; 48 | try 49 | { 50 | OpcSessionsListSemaphore.Wait(); 51 | result = OpcSessions.Count(); 52 | } 53 | finally 54 | { 55 | OpcSessionsListSemaphore.Release(); 56 | } 57 | return result; 58 | } 59 | } 60 | 61 | /// 62 | /// Reports number of connected OPC sessions. 63 | /// 64 | public static int NumberOfConnectedOpcSessions 65 | { 66 | get 67 | { 68 | int result = 0; 69 | try 70 | { 71 | OpcSessionsListSemaphore.Wait(); 72 | result = OpcSessions.Count(s => s.State == OpcSession.SessionState.Connected); 73 | } 74 | finally 75 | { 76 | OpcSessionsListSemaphore.Release(); 77 | } 78 | return result; 79 | } 80 | } 81 | 82 | /// 83 | /// Reports number of configured recurring actions. 84 | /// 85 | public static int NumberOfRecurringActions 86 | { 87 | get 88 | { 89 | int result = 0; 90 | try 91 | { 92 | OpcSessionsListSemaphore.Wait(); 93 | result = OpcSessions.Where(s => s.OpcActions.Count > 0).Select(s => s.OpcActions).Sum(a => a.Count(i => i.Interval > 0)); 94 | } 95 | finally 96 | { 97 | OpcSessionsListSemaphore.Release(); 98 | } 99 | return result; 100 | } 101 | } 102 | 103 | /// 104 | /// Reports number of all actions. 105 | /// 106 | public static int NumberOfActions 107 | { 108 | get 109 | { 110 | int result = 0; 111 | try 112 | { 113 | OpcSessionsListSemaphore.Wait(); 114 | result = OpcSessions.Select(s => s.OpcActions).Sum(a => a.Count()); 115 | } 116 | finally 117 | { 118 | OpcSessionsListSemaphore.Release(); 119 | } 120 | return result; 121 | } 122 | } 123 | 124 | /// 125 | /// Initialize resources for the configuration management. 126 | /// 127 | public static void Init() 128 | { 129 | OpcSessionsListSemaphore = new SemaphoreSlim(1); 130 | OpcSessions = new List(); 131 | OpcActionListSemaphore = new SemaphoreSlim(1); 132 | OpcSessions = new List(); 133 | _actionConfiguration = new List(); 134 | } 135 | 136 | /// 137 | /// Frees resources for configuration management. 138 | /// 139 | public static void Deinit() 140 | { 141 | OpcSessions = null; 142 | OpcSessionsListSemaphore.Dispose(); 143 | OpcSessionsListSemaphore = null; 144 | OpcActionListSemaphore.Dispose(); 145 | OpcActionListSemaphore = null; 146 | } 147 | 148 | /// 149 | /// Read and parse the startup configuration file. 150 | /// 151 | public static async Task ReadOpcConfigurationAsync() 152 | { 153 | try 154 | { 155 | await OpcActionListSemaphore.WaitAsync(); 156 | 157 | // if the file exists, read it, if not just continue 158 | if (File.Exists(OpcActionConfigurationFilename)) 159 | { 160 | Logger.Information($"Attemtping to load action configuration from: {OpcActionConfigurationFilename}"); 161 | _actionConfiguration = JsonConvert.DeserializeObject>(File.ReadAllText(OpcActionConfigurationFilename)); 162 | } 163 | else 164 | { 165 | Logger.Information($"The action configuration file '{OpcActionConfigurationFilename}' does not exist. Continue..."); 166 | } 167 | 168 | // add connectivity test action if requested 169 | if (TestConnectivity) 170 | { 171 | Logger.Information($"Creating test action to test connectivity"); 172 | _actionConfiguration.Add(new OpcActionConfigurationModel(new TestActionModel())); 173 | } 174 | 175 | // add unsecure connectivity test action if requested 176 | if (TestUnsecureConnectivity) 177 | { 178 | Logger.Information($"Creating test action to test unsecured connectivity"); 179 | _actionConfiguration.Add(new OpcActionConfigurationModel(new TestActionModel(), DefaultEndpointUrl, false)); 180 | } 181 | } 182 | catch (Exception e) 183 | { 184 | Logger.Fatal(e, "Loading of the action configuration file failed. Does the file exist and has correct syntax? Exiting..."); 185 | return false; 186 | } 187 | finally 188 | { 189 | OpcActionListSemaphore.Release(); 190 | } 191 | Logger.Information($"There is(are) {_actionConfiguration.Sum(c => c.Read.Count + c.Test.Count)} action(s) configured."); 192 | return true; 193 | } 194 | 195 | /// 196 | /// Create the data structures to manage actions. 197 | /// 198 | public static async Task CreateOpcActionDataAsync() 199 | { 200 | try 201 | { 202 | await OpcActionListSemaphore.WaitAsync(); 203 | await OpcSessionsListSemaphore.WaitAsync(); 204 | 205 | // create actions out of the configuration 206 | var uniqueSessionInfo = _actionConfiguration.Select(n => new Tuple(n.EndpointUrl, n.UseSecurity)).Distinct(); 207 | foreach (var sessionInfo in uniqueSessionInfo) 208 | { 209 | // create new session info. 210 | OpcSession opcSession = new OpcSession(sessionInfo.Item1, sessionInfo.Item2, OpcSessionCreationTimeout); 211 | 212 | // add all actions to the session 213 | List actionsOnEndpoint = new List(); 214 | var endpointConfigs = _actionConfiguration.Where(c => c.EndpointUrl == sessionInfo.Item1 && c.UseSecurity == sessionInfo.Item2); 215 | foreach (var config in endpointConfigs) 216 | { 217 | config?.Read.ForEach(a => opcSession.OpcActions.Add(new OpcReadAction(config.EndpointUrl, a))); 218 | config?.Test.ForEach(a => opcSession.OpcActions.Add(new OpcTestAction(config.EndpointUrl, a))); 219 | } 220 | 221 | // report actions 222 | Logger.Information($"Actions on '{opcSession.EndpointUrl.AbsoluteUri}' {(opcSession.UseSecurity ? "with" : "without")} security."); 223 | foreach (var action in opcSession.OpcActions) 224 | { 225 | Logger.Information($"{action.Description}, recurring each: {action.Interval} sec"); 226 | } 227 | 228 | // add session 229 | OpcSessions.Add(opcSession); 230 | } 231 | } 232 | catch (Exception e) 233 | { 234 | Logger.Fatal(e, "Creation of the internal OPC management structures failed. Exiting..."); 235 | return false; 236 | } 237 | finally 238 | { 239 | OpcSessionsListSemaphore.Release(); 240 | OpcActionListSemaphore.Release(); 241 | } 242 | return true; 243 | } 244 | 245 | /// 246 | /// Create an OPC session management data structures. 247 | /// 248 | private static void CreateOpcSession(Uri endpointUrl, bool useSecurity) 249 | { 250 | try 251 | { 252 | // create new session info 253 | OpcSession opcSession = new OpcSession(endpointUrl, useSecurity, OpcSessionCreationTimeout); 254 | 255 | // add all actions to the session 256 | List actionsOnEndpoint = new List(); 257 | var endpointConfigs = _actionConfiguration.Where(c => c.EndpointUrl == endpointUrl); 258 | foreach (var config in endpointConfigs) 259 | { 260 | config?.Read.ForEach(a => opcSession.OpcActions.Add(new OpcReadAction(config.EndpointUrl, a))); 261 | config?.Test.ForEach(a => opcSession.OpcActions.Add(new OpcTestAction(config.EndpointUrl, a))); 262 | } 263 | } 264 | catch (Exception e) 265 | { 266 | Logger.Fatal(e, "Creation of the OPC session failed."); 267 | throw e; 268 | } 269 | } 270 | private static List _actionConfiguration; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /opcclient/OpcSession.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | using Opc.Ua.Client; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | 8 | namespace OpcClient 9 | { 10 | using Opc.Ua; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using static OpcApplicationConfiguration; 14 | using static OpcConfiguration; 15 | using static Program; 16 | 17 | /// 18 | /// Class to manage OPC sessions. 19 | /// 20 | public class OpcSession 21 | { 22 | /// 23 | /// State of a session. 24 | /// 25 | public enum SessionState 26 | { 27 | Disconnected = 0, 28 | Connecting, 29 | Connected, 30 | } 31 | 32 | /// 33 | /// Wait time in seconds until we try to connect disconnected sessions again. 34 | /// 35 | public static int SessionConnectWait { get; set; } = 10; 36 | 37 | /// 38 | /// Endpoint URL of the session. 39 | /// 40 | public Uri EndpointUrl; 41 | 42 | /// 43 | /// The OPC UA stack object of the session. 44 | /// 45 | public Session OpcUaClientSession; 46 | 47 | /// 48 | /// The state of the OPC session. 49 | /// 50 | public SessionState State; 51 | 52 | /// 53 | /// The last exception occured on the OPC session. 54 | /// 55 | public ServiceResultException ServiceResultException; 56 | 57 | /// 58 | /// Number of unsuccessful connection attempts. 59 | /// 60 | public uint UnsuccessfulConnectionCount; 61 | 62 | /// 63 | /// Number of missed keep alives. 64 | /// 65 | public uint MissedKeepAlives; 66 | 67 | /// 68 | /// Timeout for creating an OPC session. 69 | /// 70 | public uint SessionTimeout { get; } 71 | 72 | /// 73 | /// Specify if session should use a secure endpoint. 74 | /// 75 | public bool UseSecurity { get; set; } = true; 76 | 77 | /// 78 | /// Event to trigger the connection task 79 | /// 80 | public AutoResetEvent ConnectAndMonitorSession; 81 | 82 | /// 83 | /// List of actions to execute on the endpoint. 84 | /// 85 | public List OpcActions; 86 | 87 | /// 88 | /// Ctor for the session. 89 | /// 90 | public OpcSession(Uri endpointUrl, bool useSecurity, uint sessionTimeout) 91 | { 92 | State = SessionState.Disconnected; 93 | EndpointUrl = endpointUrl; 94 | SessionTimeout = sessionTimeout * 1000; 95 | UnsuccessfulConnectionCount = 0; 96 | MissedKeepAlives = 0; 97 | UseSecurity = useSecurity; 98 | ConnectAndMonitorSession = new AutoResetEvent(false); 99 | _sessionCancelationTokenSource = new CancellationTokenSource(); 100 | _sessionCancelationToken = _sessionCancelationTokenSource.Token; 101 | _opcSessionSemaphore = new SemaphoreSlim(1); 102 | _namespaceTable = new NamespaceTable(); 103 | OpcActions = new List(); 104 | Task.Run(ConnectAndMonitorAsync); 105 | } 106 | 107 | /// 108 | /// This task is started when a session is configured and is running till session shutdown and ensures: 109 | /// - disconnected sessions are reconnected. 110 | /// - unused sessions are removed. 111 | /// 112 | public async Task ConnectAndMonitorAsync() 113 | { 114 | WaitHandle[] connectAndMonitorEvents = new WaitHandle[] 115 | { 116 | _sessionCancelationToken.WaitHandle, 117 | ConnectAndMonitorSession 118 | }; 119 | 120 | // run till session is closed 121 | while (!_sessionCancelationToken.IsCancellationRequested) 122 | { 123 | try 124 | { 125 | // wait till: 126 | // - cancelation is requested 127 | // - got signaled because we need to check for pending session activity 128 | // - timeout to try to reestablish any disconnected sessions or a action needs to be executed 129 | int timeout = CalculateSessionTimeout(); 130 | WaitHandle.WaitAny(connectAndMonitorEvents, timeout); 131 | 132 | // step out on cancel 133 | if (_sessionCancelationToken.IsCancellationRequested) 134 | { 135 | break; 136 | } 137 | 138 | await ConnectSessionAsync(_sessionCancelationToken); 139 | 140 | await ExecuteActionsAsync(_sessionCancelationToken); 141 | 142 | await RemoveUnusedSessionsAsync(_sessionCancelationToken); 143 | } 144 | catch (Exception e) 145 | { 146 | Logger.Error(e, "Error in ConnectAndMonitorAsync."); 147 | } 148 | } 149 | } 150 | 151 | /// 152 | /// Connects the session if it is disconnected. 153 | /// 154 | public async Task ConnectSessionAsync(CancellationToken ct) 155 | { 156 | bool sessionLocked = false; 157 | try 158 | { 159 | EndpointDescription selectedEndpoint = null; 160 | ConfiguredEndpoint configuredEndpoint = null; 161 | sessionLocked = await LockSessionAsync(); 162 | 163 | // if the session is already connected or connecting or shutdown in progress, return 164 | if (!sessionLocked || ct.IsCancellationRequested || State == SessionState.Connected || State == SessionState.Connecting) 165 | { 166 | return; 167 | } 168 | 169 | Logger.Information($"Connect and monitor session and nodes on endpoint '{EndpointUrl.AbsoluteUri}' {(UseSecurity ? "with" : "without")} security."); 170 | State = SessionState.Connecting; 171 | try 172 | { 173 | // release the session to not block for high network timeouts 174 | ReleaseSession(); 175 | sessionLocked = false; 176 | 177 | // start connecting 178 | selectedEndpoint = CoreClientUtils.SelectEndpoint(EndpointUrl.AbsoluteUri, UseSecurity); 179 | configuredEndpoint = new ConfiguredEndpoint(null, selectedEndpoint, EndpointConfiguration.Create(OpcApplicationConfiguration.ApplicationConfiguration)); 180 | uint timeout = SessionTimeout * ((UnsuccessfulConnectionCount >= OpcSessionCreationBackoffMax) ? OpcSessionCreationBackoffMax : UnsuccessfulConnectionCount + 1); 181 | Logger.Information($"Create {(UseSecurity ? "secured" : "unsecured")} session for endpoint URI '{EndpointUrl.AbsoluteUri}' with timeout of {timeout} ms."); 182 | OpcUaClientSession = await Session.Create( 183 | OpcApplicationConfiguration.ApplicationConfiguration, 184 | configuredEndpoint, 185 | true, 186 | false, 187 | ApplicationName, 188 | timeout, 189 | new UserIdentity(new AnonymousIdentityToken()), 190 | null); 191 | } 192 | catch (Exception e) 193 | { 194 | // save error info 195 | if (e is ServiceResultException sre) 196 | { 197 | ServiceResultException = sre; 198 | } 199 | Logger.Error(e, $"Session creation to endpoint '{EndpointUrl.AbsoluteUri}' failed {++UnsuccessfulConnectionCount} time(s). Please verify if server is up and {ProgramName} configuration is correct."); 200 | State = SessionState.Disconnected; 201 | OpcUaClientSession = null; 202 | return; 203 | } 204 | finally 205 | { 206 | if (OpcUaClientSession != null) 207 | { 208 | sessionLocked = await LockSessionAsync(); 209 | if (sessionLocked) 210 | { 211 | Logger.Information($"Session successfully created with Id {OpcUaClientSession.SessionId}."); 212 | if (!selectedEndpoint.EndpointUrl.Equals(configuredEndpoint.EndpointUrl.AbsoluteUri, StringComparison.OrdinalIgnoreCase)) 213 | { 214 | Logger.Information($"the Server has updated the EndpointUrl to '{selectedEndpoint.EndpointUrl}'"); 215 | } 216 | 217 | // init object state and install keep alive 218 | UnsuccessfulConnectionCount = 0; 219 | OpcUaClientSession.KeepAliveInterval = OpcKeepAliveInterval * 1000; 220 | OpcUaClientSession.KeepAlive += StandardClient_KeepAlive; 221 | 222 | // fetch the namespace array and cache it. it will not change as long the session exists. 223 | DataValue namespaceArrayNodeValue = OpcUaClientSession.ReadValue(VariableIds.Server_NamespaceArray); 224 | _namespaceTable.Update(namespaceArrayNodeValue.GetValue(null)); 225 | 226 | // show the available namespaces 227 | Logger.Information($"The session to endpoint '{selectedEndpoint.EndpointUrl}' has {_namespaceTable.Count} entries in its namespace array:"); 228 | int i = 0; 229 | foreach (var ns in _namespaceTable.ToArray()) 230 | { 231 | Logger.Information($"Namespace index {i++}: {ns}"); 232 | } 233 | 234 | // fetch the minimum supported item sampling interval from the server. 235 | DataValue minSupportedSamplingInterval = OpcUaClientSession.ReadValue(VariableIds.Server_ServerCapabilities_MinSupportedSampleRate); 236 | _minSupportedSamplingInterval = minSupportedSamplingInterval.GetValue(0); 237 | Logger.Information($"The server on endpoint '{selectedEndpoint.EndpointUrl}' supports a minimal sampling interval of {_minSupportedSamplingInterval} ms."); 238 | State = SessionState.Connected; 239 | } 240 | else 241 | { 242 | State = SessionState.Disconnected; 243 | } 244 | } 245 | } 246 | } 247 | catch (Exception e) 248 | { 249 | Logger.Error(e, "Error in ConnectSessions."); 250 | } 251 | finally 252 | { 253 | if (sessionLocked) 254 | { 255 | ReleaseSession(); 256 | } 257 | } 258 | } 259 | 260 | /// 261 | /// Executes OPC actions. 262 | /// 263 | public async Task ExecuteActionsAsync(CancellationToken ct) 264 | { 265 | bool sessionLocked = false; 266 | try 267 | { 268 | sessionLocked = await LockSessionAsync(); 269 | 270 | // if the session is not connected or shutdown in progress, return 271 | if (!sessionLocked || ct.IsCancellationRequested) 272 | { 273 | return; 274 | } 275 | 276 | // check if there are any commands ready to execute 277 | var currentTicks = DateTime.UtcNow.Ticks; 278 | foreach (var action in OpcActions) 279 | { 280 | Logger.Information($"Execute '{action.GetType().ToString()}' action on node '{action.OpcNodeId}' on endpoint '{EndpointUrl.AbsoluteUri}' {(UseSecurity ? "with" : "without")} security."); 281 | // execute only when connected 282 | try 283 | { 284 | if (State == SessionState.Connected) 285 | { 286 | action.Execute(this); 287 | 288 | ServiceResultException = null; 289 | } 290 | } 291 | catch (Exception e) 292 | { 293 | // save exception 294 | if (e is ServiceResultException sre) 295 | { 296 | ServiceResultException = sre; 297 | } 298 | Logger.Error(e, $"Error while executing action on endpoint '{EndpointUrl}'"); 299 | } 300 | action.ReportResult(ServiceResultException); 301 | 302 | // calculate next execution or remove 303 | if (action.Interval > 0) 304 | { 305 | action.NextExecution += TimeSpan.FromSeconds(action.Interval).Ticks; 306 | } 307 | else 308 | { 309 | OpcActions.Remove(action); 310 | } 311 | } 312 | } 313 | catch (Exception e) 314 | { 315 | Logger.Error(e, "Error in executing actions."); 316 | } 317 | finally 318 | { 319 | if (sessionLocked) 320 | { 321 | ReleaseSession(); 322 | } 323 | } 324 | } 325 | 326 | /// 327 | /// Converts the configured target node id into a OPC UA NodeId. 328 | /// 329 | public NodeId GetNodeIdFromId(string id) 330 | { 331 | NodeId nodeId = null; 332 | ExpandedNodeId expandedNodeId = null; 333 | try 334 | { 335 | if (id.Contains("nsu=")) 336 | { 337 | expandedNodeId = ExpandedNodeId.Parse(id); 338 | nodeId = new NodeId(expandedNodeId.Identifier, (ushort)_namespaceTable.GetIndex(expandedNodeId.NamespaceUri)); 339 | 340 | } 341 | else 342 | { 343 | nodeId = NodeId.Parse(id); 344 | } 345 | } 346 | catch (Exception e) 347 | { 348 | Logger.Error(e, $"The NodeId has an invalid format '{id}'!"); 349 | } 350 | return nodeId; 351 | } 352 | 353 | /// 354 | /// Checks if there are session without any subscriptions and remove them. 355 | /// 356 | public async Task RemoveUnusedSessionsAsync(CancellationToken ct) 357 | { 358 | try 359 | { 360 | await OpcSessionsListSemaphore.WaitAsync(); 361 | 362 | // if session is not connected or shutdown is in progress, return 363 | if (ct.IsCancellationRequested || State != SessionState.Connected) 364 | { 365 | return; 366 | } 367 | 368 | // remove sessions in the stack 369 | var sessionsToRemove = new List(); 370 | foreach (var sessionToRemove in sessionsToRemove) 371 | { 372 | Logger.Information($"Remove unused session on endpoint '{EndpointUrl}'."); 373 | await sessionToRemove.ShutdownAsync(); 374 | } 375 | } 376 | finally 377 | { 378 | OpcSessionsListSemaphore.Release(); 379 | } 380 | } 381 | 382 | /// 383 | /// Disconnects a session and removes all subscriptions on it and marks all nodes on those subscriptions 384 | /// as unmonitored. 385 | /// 386 | public async Task DisconnectAsync() 387 | { 388 | bool sessionLocked = await LockSessionAsync(); 389 | if (sessionLocked) 390 | { 391 | try 392 | { 393 | InternalDisconnect(); 394 | } 395 | catch (Exception e) 396 | { 397 | Logger.Error(e, $"Error while disconnecting '{EndpointUrl}'."); 398 | } 399 | ReleaseSession(); 400 | } 401 | } 402 | 403 | /// 404 | /// Get the namespace index for a namespace URI. 405 | /// 406 | public int GetNamespaceIndexUnlocked(string namespaceUri) 407 | { 408 | return _namespaceTable.GetIndex(namespaceUri); 409 | } 410 | 411 | /// 412 | /// Internal disconnect method. Caller must have taken the _opcSessionSemaphore. 413 | /// 414 | private void InternalDisconnect() 415 | { 416 | try 417 | { 418 | try 419 | { 420 | OpcUaClientSession.Close(); 421 | } 422 | catch 423 | { 424 | // the session might be already invalidated. ignore 425 | } 426 | OpcUaClientSession = null; 427 | } 428 | catch (Exception e) 429 | { 430 | Logger.Error(e, "Error in InternalDisconnect."); 431 | } 432 | State = SessionState.Disconnected; 433 | MissedKeepAlives = 0; 434 | } 435 | 436 | /// 437 | /// Shutdown the current session if it is connected. 438 | /// 439 | public async Task ShutdownAsync() 440 | { 441 | bool sessionLocked = false; 442 | try 443 | { 444 | sessionLocked = await LockSessionAsync(); 445 | 446 | // if the session is connected, close it 447 | if (sessionLocked && (State == SessionState.Connecting || State == SessionState.Connected)) 448 | { 449 | try 450 | { 451 | Logger.Information($"Closing session to endpoint URI '{EndpointUrl.AbsoluteUri}' closed successfully."); 452 | OpcUaClientSession.Close(); 453 | State = SessionState.Disconnected; 454 | Logger.Information($"Session to endpoint URI '{EndpointUrl.AbsoluteUri}' closed successfully."); 455 | } 456 | catch (Exception e) 457 | { 458 | Logger.Error(e, $"Error while closing session to endpoint '{EndpointUrl.AbsoluteUri}'."); 459 | State = SessionState.Disconnected; 460 | return; 461 | } 462 | } 463 | } 464 | finally 465 | { 466 | if (sessionLocked) 467 | { 468 | // cancel all threads waiting on the session semaphore 469 | _sessionCancelationTokenSource.Cancel(); 470 | _opcSessionSemaphore.Dispose(); 471 | _opcSessionSemaphore = null; 472 | } 473 | } 474 | } 475 | 476 | /// 477 | /// Calculate the minimal timeout when the next activity on a session should happen. 478 | /// 479 | private int CalculateSessionTimeout() 480 | { 481 | int defaultTimeout = SessionConnectWait * 1000; 482 | int? actionTimeout = CalculateActionTimeout() * 1000; 483 | return Math.Min(defaultTimeout, actionTimeout ?? int.MaxValue); 484 | } 485 | 486 | 487 | /// 488 | /// Calculate the minimal timeout when the next action on a session should be done 489 | /// 490 | private int? CalculateActionTimeout() 491 | { 492 | return OpcActions.Count > 0 ? OpcActions.Where(a => a.Interval > 0).Min(a => a.Interval) : (int?)null; 493 | } 494 | 495 | /// 496 | /// Handler for the standard "keep alive" event sent by all OPC UA servers. 497 | /// 498 | private void StandardClient_KeepAlive(Session session, KeepAliveEventArgs e) 499 | { 500 | // ignore if we are shutting down 501 | if (ShutdownTokenSource.IsCancellationRequested == true) 502 | { 503 | return; 504 | } 505 | 506 | if (e != null && session != null && session.ConfiguredEndpoint != null && OpcUaClientSession != null) 507 | { 508 | try 509 | { 510 | if (!ServiceResult.IsGood(e.Status)) 511 | { 512 | Logger.Warning($"Session endpoint: {session.ConfiguredEndpoint.EndpointUrl} has Status: {e.Status}"); 513 | Logger.Information($"Outstanding requests: {session.OutstandingRequestCount}, Defunct requests: {session.DefunctRequestCount}"); 514 | Logger.Information($"Good publish requests: {session.GoodPublishRequestCount}, KeepAlive interval: {session.KeepAliveInterval}"); 515 | Logger.Information($"SessionId: {session.SessionId}"); 516 | 517 | if (State == SessionState.Connected) 518 | { 519 | MissedKeepAlives++; 520 | Logger.Information($"Missed KeepAlives: {MissedKeepAlives}"); 521 | if (MissedKeepAlives >= OpcKeepAliveDisconnectThreshold) 522 | { 523 | Logger.Warning($"Hit configured missed keep alive threshold of {OpcKeepAliveDisconnectThreshold}. Disconnecting the session to endpoint {session.ConfiguredEndpoint.EndpointUrl}."); 524 | session.KeepAlive -= StandardClient_KeepAlive; 525 | Task t = Task.Run(async () => await DisconnectAsync()); 526 | } 527 | } 528 | } 529 | else 530 | { 531 | if (MissedKeepAlives != 0) 532 | { 533 | // reset missed keep alive count 534 | Logger.Information($"Session endpoint: {session.ConfiguredEndpoint.EndpointUrl} got a keep alive after {MissedKeepAlives} {(MissedKeepAlives == 1 ? "was" : "were")} missed."); 535 | MissedKeepAlives = 0; 536 | } 537 | } 538 | } 539 | catch (Exception ex) 540 | { 541 | Logger.Error(ex, $"Error in keep alive handling for endpoint '{session.ConfiguredEndpoint.EndpointUrl}'. (message: '{ex.Message}'"); 542 | } 543 | } 544 | else 545 | { 546 | Logger.Warning("Keep alive arguments seems to be wrong."); 547 | } 548 | } 549 | 550 | /// 551 | /// Take the session semaphore. 552 | /// 553 | public async Task LockSessionAsync() 554 | { 555 | await _opcSessionSemaphore.WaitAsync(_sessionCancelationToken); 556 | if (_sessionCancelationToken.IsCancellationRequested) 557 | { 558 | return false; 559 | } 560 | return true; 561 | } 562 | 563 | /// 564 | /// Release the session semaphore. 565 | /// 566 | public void ReleaseSession() 567 | { 568 | _opcSessionSemaphore.Release(); 569 | } 570 | 571 | private SemaphoreSlim _opcSessionSemaphore; 572 | private CancellationTokenSource _sessionCancelationTokenSource; 573 | private CancellationToken _sessionCancelationToken; 574 | private NamespaceTable _namespaceTable; 575 | private double _minSupportedSamplingInterval; 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /opcclient/Program.cs: -------------------------------------------------------------------------------- 1 | 2 | using Mono.Options; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace OpcClient 11 | { 12 | using Opc.Ua; 13 | using Opc.Ua.Server; 14 | using Serilog; 15 | using System.Diagnostics; 16 | using System.IO; 17 | using System.Text; 18 | using static Diagnostics; 19 | using static OpcApplicationConfiguration; 20 | using static OpcConfiguration; 21 | using static OpcSession; 22 | 23 | public class Program 24 | { 25 | /// 26 | /// name of the application 27 | /// 28 | public const string ProgramName = "OpcClient"; 29 | 30 | /// 31 | /// shutdown token 32 | /// 33 | public static CancellationTokenSource ShutdownTokenSource; 34 | 35 | /// 36 | /// retry count when trying to shutdown sessions 37 | /// 38 | public static uint ShutdownRetryCount { get; } = 10; 39 | 40 | /// 41 | /// logging object 42 | /// 43 | public static Serilog.Core.Logger Logger { get; set; } = null; 44 | 45 | /// 46 | /// signals to do a connectivity test to the default server on a secure endpoint 47 | /// 48 | public static bool TestConnectivity { get; set; } = true; 49 | 50 | /// 51 | /// signals to do a connectivity test to the default server on n unsecure endpoint 52 | /// 53 | public static bool TestUnsecureConnectivity { get; set; } = false; 54 | 55 | /// 56 | /// default target server 57 | /// 58 | public static string DefaultEndpointUrl { get; set; } = "opc.tcp://opcplc:50000"; 59 | 60 | /// 61 | /// time of application start 62 | /// 63 | public static DateTime ProgramStartTime { get; set; } = DateTime.UtcNow; 64 | 65 | /// 66 | /// Synchronous main method of the app. 67 | /// 68 | public static void Main(string[] args) 69 | { 70 | MainAsync(args).Wait(); 71 | } 72 | 73 | /// 74 | /// Asynchronous part of the main method of the app. 75 | /// 76 | public async static Task MainAsync(string[] args) 77 | { 78 | try 79 | { 80 | var shouldShowHelp = false; 81 | 82 | // shutdown token sources 83 | ShutdownTokenSource = new CancellationTokenSource(); 84 | 85 | // command line options 86 | Mono.Options.OptionSet options = new Mono.Options.OptionSet { 87 | { "cf|configfile=", $"the filename containing action configuration.\nDefault: '{OpcActionConfigurationFilename}'", (string p) => OpcActionConfigurationFilename = p }, 88 | 89 | { "tc|testconnectivity", $"tests connectivity with the default server '{TestConnectivity}'.\nDefault: {TestConnectivity}", b => TestConnectivity = b != null }, 90 | { "tu|testunsecureconnectivity", $"tests connectivity with the default server '{TestUnsecureConnectivity}' using an unsecured endpoint.\nDefault: {TestUnsecureConnectivity}", b => TestUnsecureConnectivity = b != null }, 91 | 92 | { "de|defaultendpointurl=", $"endpoint OPC UA server used as default.\nDefault: {DefaultEndpointUrl}", (string s) => DefaultEndpointUrl = s }, 93 | 94 | { "sw|sessionconnectwait=", $"specify the wait time in seconds we try to connect to disconnected endpoints and starts monitoring unmonitored items\nMin: 10\nDefault: {SessionConnectWait}", (int i) => { 95 | if (i > 10) 96 | { 97 | SessionConnectWait = i; 98 | } 99 | else 100 | { 101 | throw new OptionException("The sessionconnectwait must be greater than 10 sec", "sessionconnectwait"); 102 | } 103 | } 104 | }, 105 | { "di|diagnosticsinterval=", $"shows diagnostic info at the specified interval in seconds (need log level info). 0 disables diagnostic output.\nDefault: {DiagnosticsInterval}", (uint u) => DiagnosticsInterval = u }, 106 | 107 | { "lf|logfile=", $"the filename of the logfile to use.\nDefault: './{_logFileName}'", (string l) => _logFileName = l }, 108 | { "lt|logflushtimespan=", $"the timespan in seconds when the logfile should be flushed.\nDefault: {_logFileFlushTimeSpanSec} sec", (int s) => { 109 | if (s > 0) 110 | { 111 | _logFileFlushTimeSpanSec = TimeSpan.FromSeconds(s); 112 | } 113 | else 114 | { 115 | throw new Mono.Options.OptionException("The logflushtimespan must be a positive number.", "logflushtimespan"); 116 | } 117 | } 118 | }, 119 | { "ll|loglevel=", $"the loglevel to use (allowed: fatal, error, warn, info, debug, verbose).\nDefault: info", (string l) => { 120 | List logLevels = new List {"fatal", "error", "warn", "info", "debug", "verbose"}; 121 | if (logLevels.Contains(l.ToLowerInvariant())) 122 | { 123 | _logLevel = l.ToLowerInvariant(); 124 | } 125 | else 126 | { 127 | throw new Mono.Options.OptionException("The loglevel must be one of: fatal, error, warn, info, debug, verbose", "loglevel"); 128 | } 129 | } 130 | }, 131 | 132 | // opc configuration options 133 | { "ol|opcmaxstringlen=", $"the max length of a string opc can transmit/receive.\nDefault: {OpcMaxStringLength}", (int i) => { 134 | if (i > 0) 135 | { 136 | OpcMaxStringLength = i; 137 | } 138 | else 139 | { 140 | throw new OptionException("The max opc string length must be larger than 0.", "opcmaxstringlen"); 141 | } 142 | } 143 | }, 144 | { "ot|operationtimeout=", $"the operation timeout of the OPC UA client in ms.\nDefault: {OpcOperationTimeout}", (int i) => { 145 | if (i >= 0) 146 | { 147 | OpcOperationTimeout = i; 148 | } 149 | else 150 | { 151 | throw new OptionException("The operation timeout must be larger or equal 0.", "operationtimeout"); 152 | } 153 | } 154 | }, 155 | { "ct|createsessiontimeout=", $"specify the timeout in seconds used when creating a session to an endpoint. On unsuccessful connection attemps a backoff up to {OpcSessionCreationBackoffMax} times the specified timeout value is used.\nMin: 1\nDefault: {OpcSessionCreationTimeout}", (uint u) => { 156 | if (u > 1) 157 | { 158 | OpcSessionCreationTimeout = u; 159 | } 160 | else 161 | { 162 | throw new OptionException("The createsessiontimeout must be greater than 1 sec", "createsessiontimeout"); 163 | } 164 | } 165 | }, 166 | { "ki|keepaliveinterval=", $"specify the interval in seconds se send keep alive messages to the OPC servers on the endpoints it is connected to.\nMin: 2\nDefault: {OpcKeepAliveInterval}", (int i) => { 167 | if (i >= 2) 168 | { 169 | OpcKeepAliveInterval = i; 170 | } 171 | else 172 | { 173 | throw new OptionException("The keepaliveinterval must be greater or equal 2", "keepalivethreshold"); 174 | } 175 | } 176 | }, 177 | { "kt|keepalivethreshold=", $"specify the number of keep alive packets a server can miss, before the session is disconneced\nMin: 1\nDefault: {OpcKeepAliveDisconnectThreshold}", (uint u) => { 178 | if (u > 1) 179 | { 180 | OpcKeepAliveDisconnectThreshold = u; 181 | } 182 | else 183 | { 184 | throw new OptionException("The keepalivethreshold must be greater than 1", "keepalivethreshold"); 185 | } 186 | } 187 | }, 188 | 189 | { "aa|autoaccept", $"trusts all servers we establish a connection to.\nDefault: {AutoAcceptCerts}", b => AutoAcceptCerts = b != null }, 190 | 191 | { "to|trustowncert", $"our own certificate is put into the trusted certificate store automatically.\nDefault: {TrustMyself}", t => TrustMyself = t != null }, 192 | 193 | // cert store options 194 | { "at|appcertstoretype=", $"the own application cert store type. \n(allowed values: Directory, X509Store)\nDefault: '{OpcOwnCertStoreType}'", (string s) => { 195 | if (s.Equals(CertificateStoreType.X509Store, StringComparison.OrdinalIgnoreCase) || s.Equals(CertificateStoreType.Directory, StringComparison.OrdinalIgnoreCase)) 196 | { 197 | OpcOwnCertStoreType = s.Equals(CertificateStoreType.X509Store, StringComparison.OrdinalIgnoreCase) ? CertificateStoreType.X509Store : CertificateStoreType.Directory; 198 | OpcOwnCertStorePath = s.Equals(CertificateStoreType.X509Store, StringComparison.OrdinalIgnoreCase) ? OpcOwnCertX509StorePathDefault : OpcOwnCertDirectoryStorePathDefault; 199 | } 200 | else 201 | { 202 | throw new OptionException(); 203 | } 204 | } 205 | }, 206 | { "ap|appcertstorepath=", $"the path where the own application cert should be stored\nDefault (depends on store type):\n" + 207 | $"X509Store: '{OpcOwnCertX509StorePathDefault}'\n" + 208 | $"Directory: '{OpcOwnCertDirectoryStorePathDefault}'", (string s) => OpcOwnCertStorePath = s 209 | }, 210 | 211 | { "tp|trustedcertstorepath=", $"the path of the trusted cert store\nDefault '{OpcTrustedCertDirectoryStorePathDefault}'", (string s) => OpcTrustedCertStorePath = s 212 | }, 213 | 214 | { "rp|rejectedcertstorepath=", $"the path of the rejected cert store\nDefault '{OpcRejectedCertDirectoryStorePathDefault}'", (string s) => OpcRejectedCertStorePath = s 215 | }, 216 | 217 | { "ip|issuercertstorepath=", $"the path of the trusted issuer cert store\nDefault '{OpcIssuerCertDirectoryStorePathDefault}'", (string s) => OpcIssuerCertStorePath = s 218 | }, 219 | 220 | { "csr", $"show data to create a certificate signing request\nDefault '{ShowCreateSigningRequestInfo}'", c => ShowCreateSigningRequestInfo = c != null 221 | }, 222 | 223 | { "ab|applicationcertbase64=", $"update/set this applications certificate with the certificate passed in as bas64 string", (string s) => 224 | { 225 | NewCertificateBase64String = s; 226 | } 227 | }, 228 | { "af|applicationcertfile=", $"update/set this applications certificate with the certificate file specified", (string s) => 229 | { 230 | if (File.Exists(s)) 231 | { 232 | NewCertificateFileName = s; 233 | } 234 | else 235 | { 236 | throw new OptionException($"The file '{s}' does not exist.", "applicationcertfile"); 237 | } 238 | } 239 | }, 240 | 241 | { "pb|privatekeybase64=", $"initial provisioning of the application certificate (with a PEM or PFX fomat) requires a private key passed in as base64 string", (string s) => 242 | { 243 | PrivateKeyBase64String = s; 244 | } 245 | }, 246 | { "pk|privatekeyfile=", $"initial provisioning of the application certificate (with a PEM or PFX fomat) requires a private key passed in as file", (string s) => 247 | { 248 | if (File.Exists(s)) 249 | { 250 | PrivateKeyFileName = s; 251 | } 252 | else 253 | { 254 | throw new OptionException($"The file '{s}' does not exist.", "privatekeyfile"); 255 | } 256 | } 257 | }, 258 | 259 | { "cp|certpassword=", $"the optional password for the PEM or PFX or the installed application certificate", (string s) => 260 | { 261 | CertificatePassword = s; 262 | } 263 | }, 264 | 265 | { "tb|addtrustedcertbase64=", $"adds the certificate to the applications trusted cert store passed in as base64 string (multiple strings supported)", (string s) => 266 | { 267 | TrustedCertificateBase64Strings = ParseListOfStrings(s); 268 | } 269 | }, 270 | { "tf|addtrustedcertfile=", $"adds the certificate file(s) to the applications trusted cert store passed in as base64 string (multiple filenames supported)", (string s) => 271 | { 272 | TrustedCertificateFileNames = ParseListOfFileNames(s, "addtrustedcertfile"); 273 | } 274 | }, 275 | 276 | { "ib|addissuercertbase64=", $"adds the specified issuer certificate to the applications trusted issuer cert store passed in as base64 string (multiple strings supported)", (string s) => 277 | { 278 | IssuerCertificateBase64Strings = ParseListOfStrings(s); 279 | } 280 | }, 281 | { "if|addissuercertfile=", $"adds the specified issuer certificate file(s) to the applications trusted issuer cert store (multiple filenames supported)", (string s) => 282 | { 283 | IssuerCertificateFileNames = ParseListOfFileNames(s, "addissuercertfile"); 284 | } 285 | }, 286 | 287 | { "rb|updatecrlbase64=", $"update the CRL passed in as base64 string to the corresponding cert store (trusted or trusted issuer)", (string s) => 288 | { 289 | CrlBase64String = s; 290 | } 291 | }, 292 | { "uc|updatecrlfile=", $"update the CRL passed in as file to the corresponding cert store (trusted or trusted issuer)", (string s) => 293 | { 294 | if (File.Exists(s)) 295 | { 296 | CrlFileName = s; 297 | } 298 | else 299 | { 300 | throw new OptionException($"The file '{s}' does not exist.", "updatecrlfile"); 301 | } 302 | } 303 | }, 304 | 305 | { "rc|removecert=", $"remove cert(s) with the given thumbprint(s) (multiple thumbprints supported)", (string s) => 306 | { 307 | ThumbprintsToRemove = ParseListOfStrings(s); 308 | } 309 | }, 310 | 311 | // misc 312 | { "h|help", "show this message and exit", h => shouldShowHelp = h != null }, 313 | }; 314 | 315 | List extraArgs = new List(); 316 | try 317 | { 318 | // parse the command line 319 | extraArgs = options.Parse(args); 320 | } 321 | catch (OptionException e) 322 | { 323 | // initialize logging 324 | InitLogging(); 325 | 326 | // show message 327 | Logger.Fatal(e, "Error in command line options"); 328 | Logger.Error($"Command line arguments: {String.Join(" ", args)}"); 329 | 330 | // show usage 331 | Usage(options); 332 | return; 333 | } 334 | 335 | // initialize logging 336 | InitLogging(); 337 | 338 | // show usage if requested 339 | if (shouldShowHelp) 340 | { 341 | Usage(options); 342 | return; 343 | } 344 | 345 | // validate and parse extra arguments 346 | if (extraArgs.Count > 0) 347 | { 348 | Logger.Error("Error in command line options"); 349 | Logger.Error($"Command line arguments: {String.Join(" ", args)}"); 350 | Usage(options); 351 | return; 352 | } 353 | 354 | //show version 355 | Logger.Information($"{ProgramName} V{FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion} starting up..."); 356 | Logger.Debug($"Informational version: V{(Attribute.GetCustomAttribute(Assembly.GetEntryAssembly(), typeof(AssemblyInformationalVersionAttribute)) as AssemblyInformationalVersionAttribute).InformationalVersion}"); 357 | 358 | // allow canceling the application 359 | var quitEvent = new ManualResetEvent(false); 360 | try 361 | { 362 | Console.CancelKeyPress += (sender, eArgs) => 363 | { 364 | quitEvent.Set(); 365 | eArgs.Cancel = true; 366 | ShutdownTokenSource.Cancel(); 367 | }; 368 | } 369 | catch 370 | { 371 | } 372 | 373 | // init OPC configuration and tracing 374 | OpcApplicationConfiguration applicationStackConfiguration = new OpcApplicationConfiguration(); 375 | await applicationStackConfiguration.ConfigureAsync(); 376 | 377 | // read OPC action configuration 378 | OpcConfiguration.Init(); 379 | if (!await ReadOpcConfigurationAsync()) 380 | { 381 | return; 382 | } 383 | 384 | // add well known actions 385 | if (!await CreateOpcActionDataAsync()) 386 | { 387 | return; 388 | } 389 | 390 | // kick off OPC client activities 391 | await SessionStartAsync(); 392 | 393 | // initialize diagnostics 394 | Diagnostics.Init(); 395 | 396 | // stop on user request 397 | Logger.Information(""); 398 | Logger.Information(""); 399 | Logger.Information($"{ProgramName} is running. Press CTRL-C to quit."); 400 | 401 | // wait for Ctrl-C 402 | quitEvent.WaitOne(Timeout.Infinite); 403 | 404 | Logger.Information(""); 405 | Logger.Information(""); 406 | ShutdownTokenSource.Cancel(); 407 | Logger.Information($"{ProgramName} is shutting down..."); 408 | 409 | // shutdown all OPC sessions 410 | await SessionShutdownAsync(); 411 | 412 | // shutdown diagnostics 413 | await ShutdownAsync(); 414 | 415 | ShutdownTokenSource = null; 416 | } 417 | catch (Exception e) 418 | { 419 | Logger.Fatal(e, e.StackTrace); 420 | e = e.InnerException ?? null; 421 | while (e != null) 422 | { 423 | Logger.Fatal(e, e.StackTrace); 424 | e = e.InnerException ?? null; 425 | } 426 | Logger.Fatal($"{ProgramName} exiting... "); 427 | } 428 | } 429 | 430 | /// 431 | /// Start all sessions. 432 | /// 433 | public async static Task SessionStartAsync() 434 | { 435 | try 436 | { 437 | await OpcSessionsListSemaphore.WaitAsync(); 438 | OpcSessions.ForEach(s => s.ConnectAndMonitorSession.Set()); 439 | } 440 | catch (Exception e) 441 | { 442 | Logger.Fatal(e, "Failed to start all sessions."); 443 | } 444 | finally 445 | { 446 | OpcSessionsListSemaphore.Release(); 447 | } 448 | } 449 | 450 | /// 451 | /// Shutdown all sessions. 452 | /// 453 | public async static Task SessionShutdownAsync() 454 | { 455 | try 456 | { 457 | while (OpcSessions.Count > 0) 458 | { 459 | OpcSession opcSession = null; 460 | try 461 | { 462 | await OpcSessionsListSemaphore.WaitAsync(); 463 | opcSession = OpcSessions.ElementAt(0); 464 | OpcSessions.RemoveAt(0); 465 | } 466 | finally 467 | { 468 | OpcSessionsListSemaphore.Release(); 469 | } 470 | await opcSession?.ShutdownAsync(); 471 | } 472 | } 473 | catch (Exception e) 474 | { 475 | Logger.Fatal(e, "Failed to shutdown all sessions."); 476 | } 477 | 478 | // wait and continue after a while 479 | uint maxTries = ShutdownRetryCount; 480 | while (true) 481 | { 482 | int sessionCount = OpcSessions.Count; 483 | if (sessionCount == 0) 484 | { 485 | return; 486 | } 487 | if (maxTries-- == 0) 488 | { 489 | Logger.Information($"There are still {sessionCount} sessions alive. Ignore and continue shutdown."); 490 | return; 491 | } 492 | Logger.Information($"{ProgramName} is shutting down. Wait {SessionConnectWait} seconds, since there are stil {sessionCount} sessions alive..."); 493 | await Task.Delay(SessionConnectWait * 1000); 494 | } 495 | } 496 | 497 | /// 498 | /// Usage message. 499 | /// 500 | private static void Usage(Mono.Options.OptionSet options) 501 | { 502 | // show usage 503 | Logger.Information(""); 504 | Logger.Information($"{ProgramName} V{FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion}"); 505 | Logger.Information($"Informational version: V{(Attribute.GetCustomAttribute(Assembly.GetEntryAssembly(), typeof(AssemblyInformationalVersionAttribute)) as AssemblyInformationalVersionAttribute).InformationalVersion}"); 506 | Logger.Information(""); 507 | Logger.Information("Usage: {0}.exe []", Assembly.GetEntryAssembly().GetName().Name); 508 | Logger.Information(""); 509 | Logger.Information($"{ProgramName} to run actions against OPC UA servers."); 510 | Logger.Information("To exit the application, just press CTRL-C while it is running."); 511 | Logger.Information(""); 512 | Logger.Information("To specify a list of strings, please use the following format:"); 513 | Logger.Information("\",,...,\""); 514 | Logger.Information("or if one string contains commas:"); 515 | Logger.Information("\"\"\",\"\",...,\"\"\""); 516 | Logger.Information(""); 517 | 518 | // output the options 519 | Logger.Information("Options:"); 520 | StringBuilder stringBuilder = new StringBuilder(); 521 | StringWriter stringWriter = new StringWriter(stringBuilder); 522 | options.WriteOptionDescriptions(stringWriter); 523 | string[] helpLines = stringBuilder.ToString().Split("\n"); 524 | foreach (var line in helpLines) 525 | { 526 | Logger.Information(line); 527 | } 528 | } 529 | 530 | /// 531 | /// Handler for server status changes. 532 | /// 533 | private static void ServerEventStatus(Session session, SessionEventReason reason) 534 | { 535 | PrintSessionStatus(session, reason.ToString()); 536 | } 537 | 538 | /// 539 | /// Shows the session status. 540 | /// 541 | private static void PrintSessionStatus(Session session, string reason) 542 | { 543 | lock (session.DiagnosticsLock) 544 | { 545 | string item = String.Format("{0,9}:{1,20}:", reason, session.SessionDiagnostics.SessionName); 546 | if (session.Identity != null) 547 | { 548 | item += String.Format(":{0,20}", session.Identity.DisplayName); 549 | } 550 | item += String.Format(":{0}", session.Id); 551 | Logger.Information(item); 552 | } 553 | } 554 | 555 | /// 556 | /// Initialize logging. 557 | /// 558 | private static void InitLogging() 559 | { 560 | LoggerConfiguration loggerConfiguration = new LoggerConfiguration(); 561 | 562 | // set the log level 563 | switch (_logLevel) 564 | { 565 | case "fatal": 566 | loggerConfiguration.MinimumLevel.Fatal(); 567 | OpcTraceToLoggerFatal = 0; 568 | break; 569 | case "error": 570 | loggerConfiguration.MinimumLevel.Error(); 571 | OpcStackTraceMask = OpcTraceToLoggerError = Utils.TraceMasks.Error; 572 | break; 573 | case "warn": 574 | loggerConfiguration.MinimumLevel.Warning(); 575 | OpcTraceToLoggerWarning = 0; 576 | break; 577 | case "info": 578 | loggerConfiguration.MinimumLevel.Information(); 579 | OpcStackTraceMask = OpcTraceToLoggerInformation = 0; 580 | break; 581 | case "debug": 582 | loggerConfiguration.MinimumLevel.Debug(); 583 | OpcStackTraceMask = OpcTraceToLoggerDebug = Utils.TraceMasks.StackTrace | Utils.TraceMasks.Operation | 584 | Utils.TraceMasks.StartStop | Utils.TraceMasks.ExternalSystem | Utils.TraceMasks.Security; 585 | break; 586 | case "verbose": 587 | loggerConfiguration.MinimumLevel.Verbose(); 588 | OpcStackTraceMask = OpcTraceToLoggerVerbose = Utils.TraceMasks.All; 589 | break; 590 | } 591 | 592 | // set logging sinks 593 | loggerConfiguration.WriteTo.Console(); 594 | 595 | if (!string.IsNullOrEmpty(_logFileName)) 596 | { 597 | // configure rolling file sink 598 | const int MAX_LOGFILE_SIZE = 1024 * 1024; 599 | const int MAX_RETAINED_LOGFILES = 2; 600 | loggerConfiguration.WriteTo.File(_logFileName, fileSizeLimitBytes: MAX_LOGFILE_SIZE, flushToDiskInterval: _logFileFlushTimeSpanSec, rollOnFileSizeLimit: true, retainedFileCountLimit: MAX_RETAINED_LOGFILES); 601 | } 602 | 603 | Logger = loggerConfiguration.CreateLogger(); 604 | Logger.Information($"Current directory is: {System.IO.Directory.GetCurrentDirectory()}"); 605 | Logger.Information($"Log file is: {_logFileName}"); 606 | Logger.Information($"Log level is: {_logLevel}"); 607 | return; 608 | } 609 | 610 | /// 611 | /// Helper to build a list of byte arrays out of a comma separated list of base64 strings (optional in double quotes). 612 | /// 613 | private static List ParseListOfStrings(string s) 614 | { 615 | List strings = new List(); 616 | if (s[0] == '"' && (s.Count(c => c.Equals('"')) % 2 == 0)) 617 | { 618 | while (s.Contains('"')) 619 | { 620 | int first = 0; 621 | int next = 0; 622 | first = s.IndexOf('"', next); 623 | next = s.IndexOf('"', ++first); 624 | strings.Add(s.Substring(first, next - first)); 625 | s = s.Substring(++next); 626 | } 627 | } 628 | else if (s.Contains(',')) 629 | { 630 | strings = s.Split(',').ToList(); 631 | strings.ForEach(st => st.Trim()); 632 | strings = strings.Select(st => st.Trim()).ToList(); 633 | } 634 | else 635 | { 636 | strings.Add(s); 637 | } 638 | return strings; 639 | } 640 | 641 | /// 642 | /// Helper to build a list of filenames out of a comma separated list of filenames (optional in double quotes). 643 | /// 644 | private static List ParseListOfFileNames(string s, string option) 645 | { 646 | List fileNames = new List(); 647 | if (s[0] == '"' && (s.Count(c => c.Equals('"')) % 2 == 0)) 648 | { 649 | while (s.Contains('"')) 650 | { 651 | int first = 0; 652 | int next = 0; 653 | first = s.IndexOf('"', next); 654 | next = s.IndexOf('"', ++first); 655 | var fileName = s.Substring(first, next - first); 656 | if (File.Exists(fileName)) 657 | { 658 | fileNames.Add(fileName); 659 | } 660 | else 661 | { 662 | throw new OptionException($"The file '{fileName}' does not exist.", option); 663 | } 664 | s = s.Substring(++next); 665 | } 666 | } 667 | else if (s.Contains(',')) 668 | { 669 | List parsedFileNames = s.Split(',').ToList(); 670 | parsedFileNames = parsedFileNames.Select(st => st.Trim()).ToList(); 671 | foreach (var fileName in parsedFileNames) 672 | { 673 | if (File.Exists(fileName)) 674 | { 675 | fileNames.Add(fileName); 676 | } 677 | else 678 | { 679 | throw new OptionException($"The file '{fileName}' does not exist.", option); 680 | } 681 | 682 | } 683 | } 684 | else 685 | { 686 | if (File.Exists(s)) 687 | { 688 | fileNames.Add(s); 689 | } 690 | else 691 | { 692 | throw new OptionException($"The file '{s}' does not exist.", option); 693 | } 694 | } 695 | return fileNames; 696 | } 697 | 698 | private static string _logFileName = $"{Utils.GetHostName()}-{ProgramName.ToLower()}.log"; 699 | private static string _logLevel = "info"; 700 | private static TimeSpan _logFileFlushTimeSpanSec = TimeSpan.FromSeconds(30); 701 | } 702 | } 703 | -------------------------------------------------------------------------------- /opcclient/OpcApplicationConfigurationSecurity.cs: -------------------------------------------------------------------------------- 1 | 2 | using Opc.Ua; 3 | using System; 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace OpcClient 7 | { 8 | using System.Collections.Generic; 9 | using System.IO; 10 | using System.Threading.Tasks; 11 | using static Program; 12 | 13 | /// 14 | /// Class for OPC Application configuration. Here the security relevant configuration. 15 | /// 16 | public partial class OpcApplicationConfiguration 17 | { 18 | /// 19 | /// Add own certificate to trusted peer store. 20 | /// 21 | public static bool TrustMyself { get; set; } = false; 22 | 23 | /// 24 | /// Certficate store configuration for own, trusted peer, issuer and rejected stores. 25 | /// 26 | public static string OpcOwnCertStoreType { get; set; } = CertificateStoreType.Directory; 27 | public static string OpcOwnCertDirectoryStorePathDefault => "pki/own"; 28 | public static string OpcOwnCertX509StorePathDefault => "CurrentUser\\UA_MachineDefault"; 29 | public static string OpcOwnCertStorePath { get; set; } = OpcOwnCertDirectoryStorePathDefault; 30 | 31 | public static string OpcTrustedCertDirectoryStorePathDefault => "pki/trusted"; 32 | public static string OpcTrustedCertStorePath { get; set; } = OpcTrustedCertDirectoryStorePathDefault; 33 | 34 | public static string OpcRejectedCertDirectoryStorePathDefault => "pki/rejected"; 35 | public static string OpcRejectedCertStorePath { get; set; } = OpcRejectedCertDirectoryStorePathDefault; 36 | 37 | public static string OpcIssuerCertDirectoryStorePathDefault => "pki/issuer"; 38 | public static string OpcIssuerCertStorePath { get; set; } = OpcIssuerCertDirectoryStorePathDefault; 39 | 40 | /// 41 | /// Accept certs of the clients automatically. 42 | /// 43 | public static bool AutoAcceptCerts { get; set; } = false; 44 | 45 | /// 46 | /// Show CSR information during startup. 47 | /// 48 | public static bool ShowCreateSigningRequestInfo { get; set; } = false; 49 | 50 | /// 51 | /// Update application certificate. 52 | /// 53 | public static string NewCertificateBase64String { get; set; } = null; 54 | public static string NewCertificateFileName { get; set; } = null; 55 | public static string CertificatePassword { get; set; } = string.Empty; 56 | 57 | /// 58 | /// If there is no application cert installed we need to install the private key as well. 59 | /// 60 | public static string PrivateKeyBase64String { get; set; } = null; 61 | public static string PrivateKeyFileName { get; set; } = null; 62 | 63 | /// 64 | /// Issuer certificates to add. 65 | /// 66 | public static List IssuerCertificateBase64Strings = null; 67 | public static List IssuerCertificateFileNames = null; 68 | 69 | /// 70 | /// Trusted certificates to add. 71 | /// 72 | public static List TrustedCertificateBase64Strings = null; 73 | public static List TrustedCertificateFileNames = null; 74 | 75 | /// 76 | /// CRL to update/install. 77 | /// 78 | public static string CrlFileName { get; set; } = null; 79 | public static string CrlBase64String { get; set; } = null; 80 | 81 | /// 82 | /// Thumbprint of certificates to delete. 83 | /// 84 | public static List ThumbprintsToRemove = null; 85 | 86 | /// 87 | /// Configures OPC stack certificates. 88 | /// 89 | public async Task InitApplicationSecurityAsync() 90 | { 91 | // security configuration 92 | ApplicationConfiguration.SecurityConfiguration = new SecurityConfiguration(); 93 | 94 | // configure trusted issuer certificates store 95 | ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates = new CertificateTrustList(); 96 | ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StoreType = CertificateStoreType.Directory; 97 | ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath = OpcIssuerCertStorePath; 98 | Logger.Information($"Trusted Issuer store type is: {ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StoreType}"); 99 | Logger.Information($"Trusted Issuer Certificate store path is: {ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath}"); 100 | 101 | // configure trusted peer certificates store 102 | ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates = new CertificateTrustList(); 103 | ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StoreType = CertificateStoreType.Directory; 104 | ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath = OpcTrustedCertStorePath; 105 | Logger.Information($"Trusted Peer Certificate store type is: {ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StoreType}"); 106 | Logger.Information($"Trusted Peer Certificate store path is: {ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath}"); 107 | 108 | // configure rejected certificates store 109 | ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore = new CertificateTrustList(); 110 | ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.StoreType = CertificateStoreType.Directory; 111 | ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.StorePath = OpcRejectedCertStorePath; 112 | 113 | Logger.Information($"Rejected certificate store type is: {ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.StoreType}"); 114 | Logger.Information($"Rejected Certificate store path is: {ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.StorePath}"); 115 | 116 | // this is a security risk and should be set to true only for debugging purposes 117 | ApplicationConfiguration.SecurityConfiguration.AutoAcceptUntrustedCertificates = false; 118 | 119 | // we allow SHA1 certificates for now as many OPC Servers still use them 120 | ApplicationConfiguration.SecurityConfiguration.RejectSHA1SignedCertificates = false; 121 | Logger.Information($"Rejection of SHA1 signed certificates is {(ApplicationConfiguration.SecurityConfiguration.RejectSHA1SignedCertificates ? "enabled" : "disabled")}"); 122 | 123 | // we allow a minimum key size of 1024 bit, as many OPC UA servers still use them 124 | ApplicationConfiguration.SecurityConfiguration.MinimumCertificateKeySize = 1024; 125 | Logger.Information($"Minimum certificate key size set to {ApplicationConfiguration.SecurityConfiguration.MinimumCertificateKeySize}"); 126 | 127 | // configure application certificate store 128 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate = new CertificateIdentifier(); 129 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StoreType = OpcOwnCertStoreType; 130 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StorePath = OpcOwnCertStorePath; 131 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.SubjectName = ApplicationConfiguration.ApplicationName; 132 | Logger.Information($"Application Certificate store type is: {ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StoreType}"); 133 | Logger.Information($"Application Certificate store path is: {ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StorePath}"); 134 | Logger.Information($"Application Certificate subject name is: {ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.SubjectName}"); 135 | 136 | // handle cert validation 137 | if (AutoAcceptCerts) 138 | { 139 | Logger.Warning("WARNING: Automatically accepting certificates. This is a security risk."); 140 | ApplicationConfiguration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true; 141 | } 142 | ApplicationConfiguration.CertificateValidator = new Opc.Ua.CertificateValidator(); 143 | ApplicationConfiguration.CertificateValidator.CertificateValidation += new Opc.Ua.CertificateValidationEventHandler(CertificateValidator_CertificateValidation); 144 | 145 | // update security information 146 | await ApplicationConfiguration.CertificateValidator.Update(ApplicationConfiguration.SecurityConfiguration); 147 | 148 | // remove issuer and trusted certificates with the given thumbprints 149 | if (ThumbprintsToRemove?.Count > 0) 150 | { 151 | if (!await RemoveCertificatesAsync(ThumbprintsToRemove)) 152 | { 153 | throw new Exception("Removing certificates failed."); 154 | } 155 | } 156 | 157 | // add trusted issuer certificates 158 | if (IssuerCertificateBase64Strings?.Count > 0 || IssuerCertificateFileNames?.Count > 0) 159 | { 160 | if (!await AddCertificatesAsync(IssuerCertificateBase64Strings, IssuerCertificateFileNames, true)) 161 | { 162 | throw new Exception("Adding trusted issuer certificate(s) failed."); 163 | } 164 | } 165 | 166 | // add trusted peer certificates 167 | if (TrustedCertificateBase64Strings?.Count > 0 || TrustedCertificateFileNames?.Count > 0) 168 | { 169 | if (!await AddCertificatesAsync(TrustedCertificateBase64Strings, TrustedCertificateFileNames, false)) 170 | { 171 | throw new Exception("Adding trusted peer certificate(s) failed."); 172 | } 173 | } 174 | 175 | // update CRL if requested 176 | if (!string.IsNullOrEmpty(CrlBase64String) || !string.IsNullOrEmpty(CrlFileName)) 177 | { 178 | if (!await UpdateCrlAsync(CrlBase64String, CrlFileName)) 179 | { 180 | throw new Exception("CRL update failed."); 181 | } 182 | } 183 | 184 | // update application certificate if requested or use the existing certificate 185 | X509Certificate2 certificate = null; 186 | if (!string.IsNullOrEmpty(NewCertificateBase64String) || !string.IsNullOrEmpty(NewCertificateFileName)) 187 | { 188 | if (!await UpdateApplicationCertificateAsync(NewCertificateBase64String, NewCertificateFileName, CertificatePassword, PrivateKeyBase64String, PrivateKeyFileName)) 189 | { 190 | throw new Exception("Update/Setting of the application certificate failed."); 191 | } 192 | } 193 | 194 | // use existing certificate, if it is there 195 | certificate = await ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.Find(true); 196 | 197 | // create a self signed certificate if there is none 198 | if (certificate == null) 199 | { 200 | Logger.Information($"No existing Application certificate found. Create a self-signed Application certificate valid from yesterday for {CertificateFactory.defaultLifeTime} months,"); 201 | Logger.Information($"with a {CertificateFactory.defaultKeySize} bit key and {CertificateFactory.defaultHashSize} bit hash."); 202 | certificate = CertificateFactory.CreateCertificate( 203 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StoreType, 204 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StorePath, 205 | null, 206 | ApplicationConfiguration.ApplicationUri, 207 | ApplicationConfiguration.ApplicationName, 208 | ApplicationConfiguration.ApplicationName, 209 | null, 210 | CertificateFactory.defaultKeySize, 211 | DateTime.UtcNow - TimeSpan.FromDays(1), 212 | CertificateFactory.defaultLifeTime, 213 | CertificateFactory.defaultHashSize, 214 | false, 215 | null, 216 | null 217 | ); 218 | Logger.Information($"Application certificate with thumbprint '{certificate.Thumbprint}' created."); 219 | 220 | // update security information 221 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.Certificate = certificate ?? throw new Exception("OPC UA application certificate can not be created! Cannot continue without it!"); 222 | await ApplicationConfiguration.CertificateValidator.UpdateCertificate(ApplicationConfiguration.SecurityConfiguration); 223 | } 224 | else 225 | { 226 | Logger.Information($"Application certificate with thumbprint '{certificate.Thumbprint}' found in the application certificate store."); 227 | } 228 | ApplicationConfiguration.ApplicationUri = Utils.GetApplicationUriFromCertificate(certificate); 229 | Logger.Information($"Application certificate is for ApplicationUri '{ApplicationConfiguration.ApplicationUri}', ApplicationName '{ApplicationConfiguration.ApplicationName}' and Subject is '{ApplicationConfiguration.ApplicationName}'"); 230 | 231 | // we make the default reference stack behavior configurable to put our own certificate into the trusted peer store, but only for self-signed certs 232 | // note: SecurityConfiguration.AddAppCertToTrustedStore only works for Application instance objects, which we do not have 233 | if (TrustMyself) 234 | { 235 | // ensure it is trusted 236 | try 237 | { 238 | using (ICertificateStore trustedStore = ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.OpenStore()) 239 | { 240 | Logger.Information($"Adding server certificate to trusted peer store. StorePath={ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath}"); 241 | await trustedStore.Add(certificate); 242 | } 243 | } 244 | catch (Exception e) 245 | { 246 | Logger.Warning(e, $"Can not add server certificate to trusted peer store. Maybe it is already there."); 247 | } 248 | } 249 | 250 | // show CreateSigningRequest data 251 | if (ShowCreateSigningRequestInfo) 252 | { 253 | await ShowCreateSigningRequestInformationAsync(certificate); 254 | } 255 | } 256 | 257 | /// 258 | /// Show information needed for the Create Signing Request process. 259 | /// 260 | public static async Task ShowCreateSigningRequestInformationAsync(X509Certificate2 certificate) 261 | { 262 | try 263 | { 264 | // we need a certificate with a private key 265 | if (!certificate.HasPrivateKey) 266 | { 267 | // fetch the certificate with the private key 268 | try 269 | { 270 | certificate = await ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.LoadPrivateKey(null); 271 | } 272 | catch (Exception e) 273 | { 274 | Logger.Error(e, $"Error while loading private key."); 275 | return; 276 | } 277 | } 278 | byte[] certificateSigningRequest = null; 279 | try 280 | { 281 | certificateSigningRequest = CertificateFactory.CreateSigningRequest(certificate); 282 | } 283 | catch (Exception e) 284 | { 285 | Logger.Error(e, $"Error while creating signing request."); 286 | return; 287 | } 288 | Logger.Information($"----------------------- CreateSigningRequest information ------------------"); 289 | Logger.Information($"ApplicationUri: {ApplicationConfiguration.ApplicationUri}"); 290 | Logger.Information($"ApplicationName: {ApplicationConfiguration.ApplicationName}"); 291 | Logger.Information($"ApplicationType: {ApplicationConfiguration.ApplicationType}"); 292 | Logger.Information($"ProductUri: {ApplicationConfiguration.ProductUri}"); 293 | if (ApplicationConfiguration.ApplicationType != ApplicationType.Client) 294 | { 295 | int serverNum = 0; 296 | foreach (var endpoint in ApplicationConfiguration.ServerConfiguration.BaseAddresses) 297 | { 298 | Logger.Information($"DiscoveryUrl[{serverNum++}]: {endpoint}"); 299 | } 300 | foreach (var endpoint in ApplicationConfiguration.ServerConfiguration.AlternateBaseAddresses) 301 | { 302 | Logger.Information($"DiscoveryUrl[{serverNum++}]: {endpoint}"); 303 | } 304 | string[] serverCapabilities = ApplicationConfiguration.ServerConfiguration.ServerCapabilities.ToArray(); 305 | Logger.Information($"ServerCapabilities: {string.Join(", ", serverCapabilities)}"); 306 | } 307 | Logger.Information($"CSR (base64 encoded):"); 308 | Console.WriteLine($"{ Convert.ToBase64String(certificateSigningRequest)}"); 309 | Logger.Information($"---------------------------------------------------------------------------"); 310 | try 311 | { 312 | await File.WriteAllBytesAsync($"{ApplicationConfiguration.ApplicationName}.csr", certificateSigningRequest); 313 | Logger.Information($"Binary CSR written to '{ApplicationConfiguration.ApplicationName}.csr'"); 314 | } 315 | catch (Exception e) 316 | { 317 | Logger.Error(e, $"Error while writing .csr file."); 318 | } 319 | } 320 | catch (Exception e) 321 | { 322 | Logger.Error(e, "Error in CSR creation"); 323 | } 324 | } 325 | 326 | 327 | /// 328 | /// Show all certificates in the certificate stores. 329 | /// 330 | public static async Task ShowCertificateStoreInformationAsync() 331 | { 332 | // show trusted issuer certs 333 | try 334 | { 335 | using (ICertificateStore certStore = ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.OpenStore()) 336 | { 337 | var certs = await certStore.Enumerate(); 338 | int certNum = 1; 339 | Logger.Information($"Trusted issuer store contains {certs.Count} certs"); 340 | foreach (var cert in certs) 341 | { 342 | Logger.Information($"{certNum++:D2}: Subject '{cert.Subject}' (thumbprint: {cert.GetCertHashString()})"); 343 | } 344 | if (certStore.SupportsCRLs) 345 | { 346 | var crls = certStore.EnumerateCRLs(); 347 | int crlNum = 1; 348 | Logger.Information($"Trusted issuer store has {crls.Count} CRLs."); 349 | foreach (var crl in certStore.EnumerateCRLs()) 350 | { 351 | Logger.Information($"{crlNum++:D2}: Issuer '{crl.Issuer}', Next update time '{crl.NextUpdateTime}'"); 352 | } 353 | } 354 | } 355 | } 356 | catch (Exception e) 357 | { 358 | Logger.Error(e, "Error while trying to read information from trusted issuer store."); 359 | } 360 | 361 | // show trusted peer certs 362 | try 363 | { 364 | using (ICertificateStore certStore = ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.OpenStore()) 365 | { 366 | var certs = await certStore.Enumerate(); 367 | int certNum = 1; 368 | Logger.Information($"Trusted peer store contains {certs.Count} certs"); 369 | foreach (var cert in certs) 370 | { 371 | Logger.Information($"{certNum++:D2}: Subject '{cert.Subject}' (thumbprint: {cert.GetCertHashString()})"); 372 | } 373 | if (certStore.SupportsCRLs) 374 | { 375 | var crls = certStore.EnumerateCRLs(); 376 | int crlNum = 1; 377 | Logger.Information($"Trusted peer store has {crls.Count} CRLs."); 378 | foreach (var crl in certStore.EnumerateCRLs()) 379 | { 380 | Logger.Information($"{crlNum++:D2}: Issuer '{crl.Issuer}', Next update time '{crl.NextUpdateTime}'"); 381 | } 382 | } 383 | } 384 | } 385 | catch (Exception e) 386 | { 387 | Logger.Error(e, "Error while trying to read information from trusted peer store."); 388 | } 389 | 390 | // show rejected peer certs 391 | try 392 | { 393 | using (ICertificateStore certStore = ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.OpenStore()) 394 | { 395 | var certs = await certStore.Enumerate(); 396 | int certNum = 1; 397 | Logger.Information($"Rejected certificate store contains {certs.Count} certs"); 398 | foreach (var cert in certs) 399 | { 400 | Logger.Information($"{certNum++:D2}: Subject '{cert.Subject}' (thumbprint: {cert.GetCertHashString()})"); 401 | } 402 | } 403 | } 404 | catch (Exception e) 405 | { 406 | Logger.Error(e, "Error while trying to read information from rejected certificate store."); 407 | } 408 | } 409 | 410 | /// 411 | /// Event handler to validate certificates. 412 | /// 413 | private static void CertificateValidator_CertificateValidation(Opc.Ua.CertificateValidator validator, Opc.Ua.CertificateValidationEventArgs e) 414 | { 415 | if (e.Error.StatusCode == Opc.Ua.StatusCodes.BadCertificateUntrusted) 416 | { 417 | e.Accept = AutoAcceptCerts; 418 | if (AutoAcceptCerts) 419 | { 420 | Logger.Information($"Certificate '{e.Certificate.Subject}' will be trusted, because of corresponding command line option."); 421 | } 422 | else 423 | { 424 | Logger.Information($"Not trusting OPC application with the certificate subject '{e.Certificate.Subject}'."); 425 | Logger.Information("If you want to trust this certificate, please copy it from the directory:"); 426 | Logger.Information($"{OpcApplicationConfiguration.ApplicationConfiguration.SecurityConfiguration.RejectedCertificateStore.StorePath}/certs"); 427 | Logger.Information("to the directory:"); 428 | Logger.Information($"{OpcApplicationConfiguration.ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath}/certs"); 429 | Logger.Information($"Rejecting certificate for now."); 430 | } 431 | } 432 | } 433 | 434 | /// 435 | /// Delete certificates with the given thumbprints from the trusted peer and issuer certifiate store. 436 | /// 437 | private async Task RemoveCertificatesAsync(List thumbprintsToRemove) 438 | { 439 | bool result = true; 440 | 441 | if (thumbprintsToRemove.Count == 0) 442 | { 443 | Logger.Error($"There is no thumbprint specified for certificates to remove. Please check your command line options."); 444 | return false; 445 | } 446 | 447 | // search the trusted peer store and remove certificates with a specified thumbprint 448 | try 449 | { 450 | Logger.Information($"Starting to remove certificate(s) from trusted peer and trusted issuer store."); 451 | using (ICertificateStore trustedStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath)) 452 | { 453 | foreach (var thumbprint in thumbprintsToRemove) 454 | { 455 | var certToRemove = await trustedStore.FindByThumbprint(thumbprint); 456 | if (certToRemove != null && certToRemove.Count > 0) 457 | { 458 | if (await trustedStore.Delete(thumbprint) == false) 459 | { 460 | Logger.Warning($"Failed to remove certificate with thumbprint '{thumbprint}' from the trusted peer store."); 461 | } 462 | else 463 | { 464 | Logger.Information($"Removed certificate with thumbprint '{thumbprint}' from the trusted peer store."); 465 | } 466 | } 467 | } 468 | } 469 | } 470 | catch (Exception e) 471 | { 472 | Logger.Error(e, "Error while trying to remove certificate(s) from the trusted peer store."); 473 | result = false; 474 | } 475 | 476 | // search the trusted issuer store and remove certificates with a specified thumbprint 477 | try 478 | { 479 | using (ICertificateStore issuerStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath)) 480 | { 481 | foreach (var thumbprint in thumbprintsToRemove) 482 | { 483 | var certToRemove = await issuerStore.FindByThumbprint(thumbprint); 484 | if (certToRemove != null && certToRemove.Count > 0) 485 | { 486 | if (await issuerStore.Delete(thumbprint) == false) 487 | { 488 | Logger.Warning($"Failed to delete certificate with thumbprint '{thumbprint}' from the trusted issuer store."); 489 | } 490 | else 491 | { 492 | Logger.Information($"Removed certificate with thumbprint '{thumbprint}' from the trusted issuer store."); 493 | } 494 | } 495 | } 496 | } 497 | } 498 | catch (Exception e) 499 | { 500 | Logger.Error(e, "Error while trying to remove certificate(s) from the trusted issuer store."); 501 | result = false; 502 | } 503 | return result; 504 | } 505 | 506 | /// 507 | /// Validate and add certificates to the trusted issuer or trusted peer store. 508 | /// 509 | private async Task AddCertificatesAsync( 510 | List certificateBase64Strings, 511 | List certificateFileNames, 512 | bool issuerCertificate = true) 513 | { 514 | bool result = true; 515 | 516 | if (certificateBase64Strings?.Count == 0 && certificateFileNames?.Count == 0) 517 | { 518 | Logger.Error($"There is no certificate provided. Please check your command line options."); 519 | return false; 520 | } 521 | 522 | Logger.Information($"Starting to add certificate(s) to the {(issuerCertificate ? "trusted issuer" : "trusted peer")} store."); 523 | X509Certificate2Collection certificatesToAdd = new X509Certificate2Collection(); 524 | try 525 | { 526 | // validate the input and build issuer cert collection 527 | if (certificateFileNames?.Count > 0) 528 | { 529 | foreach (var certificateFileName in certificateFileNames) 530 | { 531 | var certificate = new X509Certificate2(certificateFileName); 532 | certificatesToAdd.Add(certificate); 533 | } 534 | } 535 | if (certificateBase64Strings?.Count > 0) 536 | { 537 | foreach (var certificateBase64String in certificateBase64Strings) 538 | { 539 | byte[] buffer = new byte[certificateBase64String.Length * 3 / 4]; 540 | if (Convert.TryFromBase64String(certificateBase64String, buffer, out int written)) 541 | { 542 | var certificate = new X509Certificate2(buffer); 543 | certificatesToAdd.Add(certificate); 544 | } 545 | else 546 | { 547 | Logger.Error($"The provided string '{certificateBase64String.Substring(0, 10)}...' is not a valid base64 string."); 548 | return false; 549 | } 550 | } 551 | } 552 | } 553 | catch (Exception e) 554 | { 555 | Logger.Error(e, $"The issuer certificate data is invalid. Please check your command line options."); 556 | return false; 557 | } 558 | 559 | // add the certificate to the right store 560 | if (issuerCertificate) 561 | { 562 | try 563 | { 564 | using (ICertificateStore issuerStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath)) 565 | { 566 | foreach (var certificateToAdd in certificatesToAdd) 567 | { 568 | try 569 | { 570 | await issuerStore.Add(certificateToAdd); 571 | Logger.Information($"Certificate '{certificateToAdd.SubjectName.Name}' and thumbprint '{certificateToAdd.Thumbprint}' was added to the trusted issuer store."); 572 | } 573 | catch (ArgumentException) 574 | { 575 | // ignore error if cert already exists in store 576 | Logger.Information($"Certificate '{certificateToAdd.SubjectName.Name}' already exists in trusted issuer store."); 577 | } 578 | } 579 | } 580 | } 581 | catch (Exception e) 582 | { 583 | Logger.Error(e, "Error while adding a certificate to the trusted issuer store."); 584 | result = false; 585 | } 586 | } 587 | else 588 | { 589 | try 590 | { 591 | using (ICertificateStore trustedStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath)) 592 | { 593 | foreach (var certificateToAdd in certificatesToAdd) 594 | { 595 | try 596 | { 597 | await trustedStore.Add(certificateToAdd); 598 | Logger.Information($"Certificate '{certificateToAdd.SubjectName.Name}' and thumbprint '{certificateToAdd.Thumbprint}' was added to the trusted peer store."); 599 | } 600 | catch (ArgumentException) 601 | { 602 | // ignore error if cert already exists in store 603 | Logger.Information($"Certificate '{certificateToAdd.SubjectName.Name}' already exists in trusted peer store."); 604 | } 605 | } 606 | } 607 | } 608 | catch (Exception e) 609 | { 610 | Logger.Error(e, "Error while adding a certificate to the trusted peer store."); 611 | result = false; 612 | } 613 | } 614 | return result; 615 | } 616 | 617 | /// 618 | /// Update the CRL in the corresponding store. 619 | /// 620 | private async Task UpdateCrlAsync(string newCrlBase64String, string newCrlFileName) 621 | { 622 | bool result = true; 623 | 624 | if (string.IsNullOrEmpty(newCrlBase64String) && string.IsNullOrEmpty(newCrlFileName)) 625 | { 626 | Logger.Error($"There is no CRL specified. Please check your command line options."); 627 | return false; 628 | } 629 | 630 | // validate input and create the new CRL 631 | Logger.Information($"Starting to update the current CRL."); 632 | X509CRL newCrl; 633 | try 634 | { 635 | if (string.IsNullOrEmpty(newCrlFileName)) 636 | { 637 | byte[] buffer = new byte[newCrlBase64String.Length * 3 / 4]; 638 | if (Convert.TryFromBase64String(newCrlBase64String, buffer, out int written)) 639 | { 640 | newCrl = new X509CRL(buffer); 641 | } 642 | else 643 | { 644 | Logger.Error($"The provided string '{newCrlBase64String.Substring(0, 10)}...' is not a valid base64 string."); 645 | return false; 646 | } 647 | } 648 | else 649 | { 650 | newCrl = new X509CRL(newCrlFileName); 651 | } 652 | } 653 | catch (Exception e) 654 | { 655 | Logger.Error(e, $"The new CRL data is invalid."); 656 | return false; 657 | } 658 | 659 | // check if CRL was signed by a trusted peer cert 660 | using (ICertificateStore trustedStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath)) 661 | { 662 | bool trustedCrlIssuer = false; 663 | var trustedCertificates = await trustedStore.Enumerate(); 664 | foreach (var trustedCertificate in trustedCertificates) 665 | { 666 | try 667 | { 668 | if (Utils.CompareDistinguishedName(newCrl.Issuer, trustedCertificate.Subject) && newCrl.VerifySignature(trustedCertificate, false)) 669 | { 670 | // the issuer of the new CRL is trusted. delete the crls of the issuer in the trusted store 671 | Logger.Information($"Remove the current CRL from the trusted peer store."); 672 | trustedCrlIssuer = true; 673 | var crlsToRemove = trustedStore.EnumerateCRLs(trustedCertificate); 674 | foreach (var crlToRemove in crlsToRemove) 675 | { 676 | try 677 | { 678 | if (trustedStore.DeleteCRL(crlToRemove) == false) 679 | { 680 | Logger.Warning($"Failed to remove CRL issued by '{crlToRemove.Issuer}' from the trusted peer store."); 681 | } 682 | } 683 | catch (Exception e) 684 | { 685 | Logger.Error(e, $"Error while removing the current CRL issued by '{crlToRemove.Issuer}' from the trusted peer store."); 686 | result = false; 687 | } 688 | } 689 | } 690 | } 691 | catch (Exception e) 692 | { 693 | Logger.Error(e, $"Error while removing the cureent CRL from the trusted peer store."); 694 | result = false; 695 | } 696 | } 697 | // add the CRL if we trust the issuer 698 | if (trustedCrlIssuer) 699 | { 700 | try 701 | { 702 | trustedStore.AddCRL(newCrl); 703 | Logger.Information($"The new CRL issued by '{newCrl.Issuer}' was added to the trusted peer store."); 704 | } 705 | catch (Exception e) 706 | { 707 | Logger.Error(e, $"Error while adding the new CRL to the trusted peer store."); 708 | result = false; 709 | } 710 | } 711 | } 712 | 713 | // check if CRL was signed by a trusted issuer cert 714 | using (ICertificateStore issuerStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath)) 715 | { 716 | bool trustedCrlIssuer = false; 717 | var issuerCertificates = await issuerStore.Enumerate(); 718 | foreach (var issuerCertificate in issuerCertificates) 719 | { 720 | try 721 | { 722 | if (Utils.CompareDistinguishedName(newCrl.Issuer, issuerCertificate.Subject) && newCrl.VerifySignature(issuerCertificate, false)) 723 | { 724 | // the issuer of the new CRL is trusted. delete the crls of the issuer in the trusted store 725 | Logger.Information($"Remove the current CRL from the trusted issuer store."); 726 | trustedCrlIssuer = true; 727 | var crlsToRemove = issuerStore.EnumerateCRLs(issuerCertificate); 728 | foreach (var crlToRemove in crlsToRemove) 729 | { 730 | try 731 | { 732 | if (issuerStore.DeleteCRL(crlToRemove) == false) 733 | { 734 | Logger.Warning($"Failed to remove the current CRL issued by '{crlToRemove.Issuer}' from the trusted issuer store."); 735 | } 736 | } 737 | catch (Exception e) 738 | { 739 | Logger.Error(e, $"Error while removing the current CRL issued by '{crlToRemove.Issuer}' from the trusted issuer store."); 740 | result = false; 741 | } 742 | } 743 | } 744 | } 745 | catch (Exception e) 746 | { 747 | Logger.Error(e, $"Error while removing the current CRL from the trusted issuer store."); 748 | result = false; 749 | } 750 | } 751 | 752 | // add the CRL if we trust the issuer 753 | if (trustedCrlIssuer) 754 | { 755 | try 756 | { 757 | issuerStore.AddCRL(newCrl); 758 | Logger.Information($"The new CRL issued by '{newCrl.Issuer}' was added to the trusted issuer store."); 759 | } 760 | catch (Exception e) 761 | { 762 | Logger.Error(e, $"Error while adding the new CRL issued by '{newCrl.Issuer}' to the trusted issuer store."); 763 | result = false; 764 | } 765 | } 766 | } 767 | return result; 768 | } 769 | 770 | /// 771 | /// Validate and update the application. 772 | /// 773 | private async Task UpdateApplicationCertificateAsync( 774 | string newCertificateBase64String, 775 | string newCertificateFileName, 776 | string certificatePassword, 777 | string privateKeyBase64String, 778 | string privateKeyFileName) 779 | { 780 | if (string.IsNullOrEmpty(newCertificateFileName) && string.IsNullOrEmpty(newCertificateBase64String)) 781 | { 782 | Logger.Error($"There is no new application certificate data provided. Please check your command line options."); 783 | return false; 784 | } 785 | 786 | // validate input and create the new application cert 787 | X509Certificate2 newCertificate; 788 | try 789 | { 790 | if (string.IsNullOrEmpty(newCertificateFileName)) 791 | { 792 | byte[] buffer = new byte[newCertificateBase64String.Length * 3 / 4]; 793 | if (Convert.TryFromBase64String(newCertificateBase64String, buffer, out int written)) 794 | { 795 | newCertificate = new X509Certificate2(buffer); 796 | } 797 | else 798 | { 799 | Logger.Error($"The provided string '{newCertificateBase64String.Substring(0, 10)}...' is not a valid base64 string."); 800 | return false; 801 | } 802 | } 803 | else 804 | { 805 | newCertificate = new X509Certificate2(newCertificateFileName); 806 | } 807 | } 808 | catch (Exception e) 809 | { 810 | Logger.Error(e, $"The new application certificate data is invalid."); 811 | return false; 812 | } 813 | 814 | // validate input and create the private key 815 | Logger.Information($"Start updating the current application certificate."); 816 | byte[] privateKey = null; 817 | try 818 | { 819 | if (!string.IsNullOrEmpty(privateKeyBase64String)) 820 | { 821 | privateKey = new byte[privateKeyBase64String.Length * 3 / 4]; 822 | if (!Convert.TryFromBase64String(privateKeyBase64String, privateKey, out int written)) 823 | { 824 | Logger.Error($"The provided string '{privateKeyBase64String.Substring(0, 10)}...' is not a valid base64 string."); 825 | return false; 826 | } 827 | } 828 | if (!string.IsNullOrEmpty(privateKeyFileName)) 829 | { 830 | privateKey = await File.ReadAllBytesAsync(privateKeyFileName); 831 | } 832 | } 833 | catch (Exception e) 834 | { 835 | Logger.Error(e, $"The private key data is invalid."); 836 | return false; 837 | } 838 | 839 | // check if there is an application certificate and fetch its data 840 | bool hasApplicationCertificate = false; 841 | X509Certificate2 currentApplicationCertificate = null; 842 | string currentSubjectName = null; 843 | if (ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate?.Certificate != null) 844 | { 845 | hasApplicationCertificate = true; 846 | currentApplicationCertificate = ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.Certificate; 847 | currentSubjectName = currentApplicationCertificate.SubjectName.Name; 848 | Logger.Information($"The current application certificate has SubjectName '{currentSubjectName}' and thumbprint '{currentApplicationCertificate.Thumbprint}'."); 849 | } 850 | else 851 | { 852 | Logger.Information($"There is no existing application certificate."); 853 | } 854 | 855 | // for a cert update subject names of current and new certificate must match 856 | if (hasApplicationCertificate && !Utils.CompareDistinguishedName(currentSubjectName, newCertificate.SubjectName.Name)) 857 | { 858 | Logger.Error($"The SubjectName '{newCertificate.SubjectName.Name}' of the new certificate doesn't match the current certificates SubjectName '{currentSubjectName}'."); 859 | return false; 860 | } 861 | 862 | // if the new cert is not selfsigned verify with the trusted peer and trusted issuer certificates 863 | try 864 | { 865 | if (!Utils.CompareDistinguishedName(newCertificate.Subject, newCertificate.Issuer)) 866 | { 867 | // verify the new certificate was signed by a trusted issuer or trusted peer 868 | CertificateValidator certValidator = new CertificateValidator(); 869 | CertificateTrustList verificationTrustList = new CertificateTrustList(); 870 | CertificateIdentifierCollection verificationCollection = new CertificateIdentifierCollection(); 871 | using (ICertificateStore issuerStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedIssuerCertificates.StorePath)) 872 | { 873 | var certs = await issuerStore.Enumerate(); 874 | foreach (var cert in certs) 875 | { 876 | verificationCollection.Add(new CertificateIdentifier(cert)); 877 | } 878 | } 879 | using (ICertificateStore trustedStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.TrustedPeerCertificates.StorePath)) 880 | { 881 | var certs = await trustedStore.Enumerate(); 882 | foreach (var cert in certs) 883 | { 884 | verificationCollection.Add(new CertificateIdentifier(cert)); 885 | } 886 | } 887 | verificationTrustList.TrustedCertificates = verificationCollection; 888 | certValidator.Update(verificationTrustList, verificationTrustList, null); 889 | certValidator.Validate(newCertificate); 890 | } 891 | } 892 | catch (Exception e) 893 | { 894 | Logger.Error(e, $"Failed to verify integrity of the new certificate and the trusted issuer list."); 895 | return false; 896 | } 897 | 898 | // detect format of new cert and create/update the application certificate 899 | X509Certificate2 newCertificateWithPrivateKey = null; 900 | string newCertFormat = null; 901 | // check if new cert is PFX 902 | if (string.IsNullOrEmpty(newCertFormat)) 903 | { 904 | try 905 | { 906 | X509Certificate2 certWithPrivateKey = CertificateFactory.CreateCertificateFromPKCS12(privateKey, certificatePassword); 907 | newCertificateWithPrivateKey = CertificateFactory.CreateCertificateWithPrivateKey(newCertificate, certWithPrivateKey); 908 | newCertFormat = "PFX"; 909 | Logger.Information($"The private key for the new certificate was passed in using PFX format."); 910 | } 911 | catch 912 | { 913 | Logger.Debug($"Certificate file is not PFX"); 914 | } 915 | } 916 | // check if new cert is PEM 917 | if (string.IsNullOrEmpty(newCertFormat)) 918 | { 919 | try 920 | { 921 | newCertificateWithPrivateKey = CertificateFactory.CreateCertificateWithPEMPrivateKey(newCertificate, privateKey, certificatePassword); 922 | newCertFormat = "PEM"; 923 | Logger.Information($"The private key for the new certificate was passed in using PEM format."); 924 | } 925 | catch 926 | { 927 | Logger.Debug($"Certificate file is not PEM"); 928 | } 929 | } 930 | if (string.IsNullOrEmpty(newCertFormat)) 931 | { 932 | // check if new cert is DER and there is an existing application certificate 933 | try 934 | { 935 | if (hasApplicationCertificate) 936 | { 937 | X509Certificate2 certWithPrivateKey = await ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.LoadPrivateKey(certificatePassword); 938 | newCertificateWithPrivateKey = CertificateFactory.CreateCertificateWithPrivateKey(newCertificate, certWithPrivateKey); 939 | newCertFormat = "DER"; 940 | } 941 | else 942 | { 943 | Logger.Error($"There is no existing application certificate we can use to extract the private key. You need to pass in a private key using PFX or PEM format."); 944 | } 945 | } 946 | catch 947 | { 948 | Logger.Debug($"Application certificate format is not DER"); 949 | } 950 | } 951 | 952 | // if there is no current application cert, we need a new cert with a private key 953 | if (hasApplicationCertificate) 954 | { 955 | if (string.IsNullOrEmpty(newCertFormat)) 956 | { 957 | Logger.Error($"The provided format of the private key is not supported (must be PEM or PFX) or the provided cert password is wrong."); 958 | return false; 959 | } 960 | } 961 | else 962 | { 963 | if (string.IsNullOrEmpty(newCertFormat)) 964 | { 965 | Logger.Error($"There is no application certificate we can update and for the new application certificate there was not usable private key (must be PEM or PFX format) provided or the provided cert password is wrong."); 966 | return false; 967 | } 968 | } 969 | 970 | // remove the existing and add the new application cert 971 | using (ICertificateStore appStore = CertificateStoreIdentifier.OpenStore(ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.StorePath)) 972 | { 973 | Logger.Information($"Remove the existing application certificate."); 974 | try 975 | { 976 | if (hasApplicationCertificate && !await appStore.Delete(currentApplicationCertificate.Thumbprint)) 977 | { 978 | Logger.Warning($"Removing the existing application certificate with thumbprint '{currentApplicationCertificate.Thumbprint}' failed."); 979 | } 980 | } 981 | catch 982 | { 983 | Logger.Warning($"Failed to remove the existing application certificate from the ApplicationCertificate store."); 984 | } 985 | try 986 | { 987 | await appStore.Add(newCertificateWithPrivateKey); 988 | Logger.Information($"The new application certificate '{newCertificateWithPrivateKey.SubjectName.Name}' and thumbprint '{newCertificateWithPrivateKey.Thumbprint}' was added to the application certificate store."); 989 | } 990 | catch (Exception e) 991 | { 992 | Logger.Error(e, $"Failed to add the new application certificate to the application certificate store."); 993 | return false; 994 | } 995 | } 996 | 997 | // update the application certificate 998 | try 999 | { 1000 | Logger.Information($"Activating the new application certificate with thumbprint '{newCertificateWithPrivateKey.Thumbprint}'."); 1001 | ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.Certificate = newCertificate; 1002 | await ApplicationConfiguration.CertificateValidator.UpdateCertificate(ApplicationConfiguration.SecurityConfiguration); 1003 | } 1004 | catch (Exception e) 1005 | { 1006 | Logger.Error(e, $"Failed to activate the new application certificate."); 1007 | return false; 1008 | } 1009 | 1010 | return true; 1011 | } 1012 | } 1013 | } 1014 | --------------------------------------------------------------------------------