├── 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 |
--------------------------------------------------------------------------------