From 205dc214e3b1d64a8397c4a4a323483adb958acd Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Thu, 5 Nov 2026 20:57:51 +0700 Subject: [PATCH 1/7] feat: Add Modbus TCP TLS and server certificate management Refs #139 --- .../actions/build-openssl3-runtime/action.yml | 2 +- .github/actions/install-openssl/action.yml | 16 + .../workflows/build-appimage-qt6-x86_64.yml | 7 +- .github/workflows/build-deb-qt5-amd64.yml | 9 + .github/workflows/build-deb-qt6-amd64.yml | 7 +- .github/workflows/build-dmg-qt6-macos.yml | 2 + .../workflows/build-installer-qt5-win32.yml | 7 +- .../workflows/build-installer-qt5-win64.yml | 7 +- .../workflows/build-installer-qt6-win64.yml | 7 +- .github/workflows/build-rpm-qt6-x86_64.yml | 7 +- .github/workflows/test-ci.yml | 12 +- README.md | 4 + README.md.in | 4 + build-macos.sh | 5 + build.ps1 | 8 +- build.sh | 22 +- docs/modbus-tls.md | 52 +++ src/CMakeLists.txt | 1 + src/app/CMakeLists.txt | 1 + src/app/translations/omodsim_it.ts | 115 ++++++ src/app/translations/omodsim_ru.qm | Bin 79122 -> 83860 bytes src/app/translations/omodsim_ru.ts | 118 ++++++ src/app/translations/omodsim_zh_CN.ts | 115 ++++++ src/app/translations/omodsim_zh_TW.ts | 115 ++++++ src/cmake/cmake_install_macos.cmake | 4 +- src/cmake/cmake_install_windows.cmake | 6 +- src/cmake/openssl.cmake | 18 + src/cmake/tests.cmake | 8 + src/core/CMakeLists.txt | 8 + src/core/modbus/connectiondetails.h | 35 +- src/core/modbus/modbustcpserver.cpp | 25 +- src/core/modbus/modbustcpserver.h | 10 +- src/core/modbus/modbustlslistener.cpp | 99 +++++ src/core/modbus/modbustlslistener.h | 56 +++ src/core/pki/certificategenerator.cpp | 141 ++++++++ src/core/pki/certificategenerator.h | 34 ++ src/core/pki/tlscertificatestore.cpp | 146 ++++++++ src/core/pki/tlscertificatestore.h | 67 ++++ src/tests/test_connectiondetails.cpp | 39 ++ src/tests/test_modbustls.cpp | 317 ++++++++++++++++ src/tests/test_tlscertificatestore.cpp | 204 +++++++++++ src/tests/test_tlssettings.cpp | 342 ++++++++++++++++++ src/ui/CMakeLists.txt | 5 + src/ui/dialogs/dialogmodbusdefinitions.cpp | 11 + src/ui/dialogs/dialogmodbusdefinitions.h | 5 + src/ui/dialogs/dialogmodbusdefinitions.ui | 38 +- src/ui/dialogs/dialogselectserviceport.cpp | 26 +- src/ui/dialogs/dialogselectserviceport.h | 15 +- src/ui/dialogs/dialogselectserviceport.ui | 6 + src/ui/mainwindow.cpp | 2 +- src/ui/widgets/elidinglineedit.cpp | 59 +++ src/ui/widgets/elidinglineedit.h | 36 ++ src/ui/widgets/tlscertificateswidget.cpp | 114 ++++++ src/ui/widgets/tlscertificateswidget.h | 46 +++ src/ui/widgets/tlscertificateswidget.ui | 41 +++ third_party/licenses/OpenSSL.txt | 177 +++++++++ tools/build_openssl.sh | 23 ++ tools/install_openssl.ps1 | 40 ++ 58 files changed, 2799 insertions(+), 47 deletions(-) create mode 100644 .github/actions/install-openssl/action.yml create mode 100644 docs/modbus-tls.md create mode 100644 src/cmake/openssl.cmake create mode 100644 src/core/modbus/modbustlslistener.cpp create mode 100644 src/core/modbus/modbustlslistener.h create mode 100644 src/core/pki/certificategenerator.cpp create mode 100644 src/core/pki/certificategenerator.h create mode 100644 src/core/pki/tlscertificatestore.cpp create mode 100644 src/core/pki/tlscertificatestore.h create mode 100644 src/tests/test_modbustls.cpp create mode 100644 src/tests/test_tlscertificatestore.cpp create mode 100644 src/tests/test_tlssettings.cpp create mode 100644 src/ui/widgets/elidinglineedit.cpp create mode 100644 src/ui/widgets/elidinglineedit.h create mode 100644 src/ui/widgets/tlscertificateswidget.cpp create mode 100644 src/ui/widgets/tlscertificateswidget.h create mode 100644 src/ui/widgets/tlscertificateswidget.ui create mode 100644 third_party/licenses/OpenSSL.txt create mode 100644 tools/build_openssl.sh create mode 100644 tools/install_openssl.ps1 diff --git a/.github/actions/build-openssl3-runtime/action.yml b/.github/actions/build-openssl3-runtime/action.yml index bd0ba1bb..e49391af 100644 --- a/.github/actions/build-openssl3-runtime/action.yml +++ b/.github/actions/build-openssl3-runtime/action.yml @@ -50,7 +50,7 @@ runs: ./config \ shared zlib no-apps no-docs no-tests make -j$(nproc) build_libs - make install_runtime_libs + make install_dev install_runtime_libs - name: Verify OpenSSL runtime libraries shell: bash diff --git a/.github/actions/install-openssl/action.yml b/.github/actions/install-openssl/action.yml new file mode 100644 index 00000000..7cd7d355 --- /dev/null +++ b/.github/actions/install-openssl/action.yml @@ -0,0 +1,16 @@ +name: Install OpenSSL 3 +description: Install the Qt OpenSSL 3 development package and runtime for Windows. +inputs: + architecture: + description: Target architecture, x64 or x86. + default: x64 +runs: + using: composite + steps: + - name: Install OpenSSL + shell: pwsh + run: | + python -m pip install aqtinstall + $root = & ./tools/install_openssl.ps1 -Architecture '${{ inputs.architecture }}' + "OPENSSL_ROOT_DIR=$root" >> $env:GITHUB_ENV + "$root\bin" >> $env:GITHUB_PATH diff --git a/.github/workflows/build-appimage-qt6-x86_64.yml b/.github/workflows/build-appimage-qt6-x86_64.yml index a772c02d..49f84651 100644 --- a/.github/workflows/build-appimage-qt6-x86_64.yml +++ b/.github/workflows/build-appimage-qt6-x86_64.yml @@ -104,12 +104,16 @@ jobs: - name: Set BUILD_DIR run: echo "BUILD_DIR=build-omodsim-Qt_${{ env.QT_VERSION }}-Release" >> $GITHUB_ENV + - name: Resolve latest OpenSSL 3.5 LTS version + uses: ./.github/actions/build-openssl3-runtime + - name: Configure project run: | cmake src -B ${{ env.BUILD_DIR }} \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DOPENSSL_ROOT_DIR=/usr/local \ -DOMODSIM_BUILD_TESTS=OFF \ -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} @@ -127,9 +131,6 @@ jobs: with: qt-prefix-path: ${{ steps.qt.outputs.qt-root-dir }} - - name: Resolve latest OpenSSL 3.5 LTS version - uses: ./.github/actions/build-openssl3-runtime - - name: Bundle Qt libraries and plugins throught cqtdeployer run: | CQtDeployer -bin ${{ env.BUILD_DIR }}/omodsim \ diff --git a/.github/workflows/build-deb-qt5-amd64.yml b/.github/workflows/build-deb-qt5-amd64.yml index 378f4348..9c684e82 100644 --- a/.github/workflows/build-deb-qt5-amd64.yml +++ b/.github/workflows/build-deb-qt5-amd64.yml @@ -107,12 +107,16 @@ jobs: - name: Set BUILD_DIR run: echo "BUILD_DIR=build-omodsim-Qt_${{ env.QT_VERSION }}-Release" >> $GITHUB_ENV + - name: Resolve latest OpenSSL 3.5 LTS version + uses: ./.github/actions/build-openssl3-runtime + - name: Configure project run: | cmake src -B ${{ env.BUILD_DIR }} \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DOPENSSL_ROOT_DIR=/usr/local \ -DOMODSIM_BUILD_TESTS=OFF \ -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} @@ -155,6 +159,11 @@ jobs: /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 \ ${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/lib/ + - name: Copy OpenSSL 3 libraries + run: | + cp /usr/local/lib64/libssl.so.3 /usr/local/lib64/libcrypto.so.3 \ + ${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/lib/ + - name: Copy OpenModsim docs run: rsync -a "${{ env.BUILD_DIR }}/docs/" "${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/docs/" diff --git a/.github/workflows/build-deb-qt6-amd64.yml b/.github/workflows/build-deb-qt6-amd64.yml index 24a4ebcb..583e3030 100644 --- a/.github/workflows/build-deb-qt6-amd64.yml +++ b/.github/workflows/build-deb-qt6-amd64.yml @@ -108,12 +108,16 @@ jobs: - name: Set BUILD_DIR run: echo "BUILD_DIR=build-omodsim-Qt_${{ env.QT_VERSION }}-Release" >> $GITHUB_ENV + - name: Resolve latest OpenSSL 3.5 LTS version + uses: ./.github/actions/build-openssl3-runtime + - name: Configure project run: | cmake src -B ${{ env.BUILD_DIR }} \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DOPENSSL_ROOT_DIR=/usr/local \ -DOMODSIM_BUILD_TESTS=OFF \ -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} @@ -141,9 +145,6 @@ jobs: with: qt-prefix-path: ${{ steps.qt.outputs.qt-root-dir }} - - name: Resolve latest OpenSSL 3.5 LTS version - uses: ./.github/actions/build-openssl3-runtime - - name: Bundle Qt libraries and plugins throught cqtdeployer run: | CQtDeployer -bin ${{ env.BUILD_DIR }}/omodsim \ diff --git a/.github/workflows/build-dmg-qt6-macos.yml b/.github/workflows/build-dmg-qt6-macos.yml index e7a9e6af..fe99e276 100644 --- a/.github/workflows/build-dmg-qt6-macos.yml +++ b/.github/workflows/build-dmg-qt6-macos.yml @@ -50,6 +50,7 @@ jobs: run: | brew list --versions ninja >/dev/null || brew install ninja brew list --versions cmake >/dev/null || brew install cmake + brew list --versions openssl@3 >/dev/null || brew install openssl@3 - name: Install Qt uses: jurplel/install-qt-action@v4 @@ -70,6 +71,7 @@ jobs: -G "${{ env.CMAKE_GENERATOR }}" \ -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} \ -DCMAKE_PREFIX_PATH=${{ env.QT_ROOT_DIR }} \ + -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" \ -DCMAKE_INSTALL_PREFIX=/Applications \ -DUSE_QT6=ON \ -DOMODSIM_BUILD_TESTS=OFF \ diff --git a/.github/workflows/build-installer-qt5-win32.yml b/.github/workflows/build-installer-qt5-win32.yml index 10951183..ddd96f54 100644 --- a/.github/workflows/build-installer-qt5-win32.yml +++ b/.github/workflows/build-installer-qt5-win32.yml @@ -117,9 +117,14 @@ jobs: echo VS_PATH=%VS_PATH%>>%GITHUB_ENV% shell: cmd + - name: Install OpenSSL 3 + uses: ./.github/actions/install-openssl + with: + architecture: x86 + - name: Configure CMake run: | - cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A Win32 -T v142 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF + cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A Win32 -T v142 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DOPENSSL_ROOT_DIR="%OPENSSL_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF shell: cmd - name: Build diff --git a/.github/workflows/build-installer-qt5-win64.yml b/.github/workflows/build-installer-qt5-win64.yml index 1df5473d..49793e9a 100644 --- a/.github/workflows/build-installer-qt5-win64.yml +++ b/.github/workflows/build-installer-qt5-win64.yml @@ -117,9 +117,14 @@ jobs: echo VS_PATH=%VS_PATH%>>%GITHUB_ENV% shell: cmd + - name: Install OpenSSL 3 + uses: ./.github/actions/install-openssl + with: + architecture: x64 + - name: Configure CMake run: | - cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A x64 -T v142 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF + cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A x64 -T v142 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DOPENSSL_ROOT_DIR="%OPENSSL_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF shell: cmd - name: Build diff --git a/.github/workflows/build-installer-qt6-win64.yml b/.github/workflows/build-installer-qt6-win64.yml index 70279b32..0a12060a 100644 --- a/.github/workflows/build-installer-qt6-win64.yml +++ b/.github/workflows/build-installer-qt6-win64.yml @@ -113,9 +113,14 @@ jobs: echo VS_PATH=%VS_PATH%>>%GITHUB_ENV% shell: cmd + - name: Install OpenSSL 3 + uses: ./.github/actions/install-openssl + with: + architecture: x64 + - name: Configure CMake run: | - cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF + cmake src -B ${{ env.BUILD_DIR }} -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" -DOPENSSL_ROOT_DIR="%OPENSSL_ROOT_DIR%" -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} -DOMODSIM_BUILD_TESTS=OFF shell: cmd - name: Build diff --git a/.github/workflows/build-rpm-qt6-x86_64.yml b/.github/workflows/build-rpm-qt6-x86_64.yml index c72789cc..5cce0a49 100644 --- a/.github/workflows/build-rpm-qt6-x86_64.yml +++ b/.github/workflows/build-rpm-qt6-x86_64.yml @@ -108,12 +108,16 @@ jobs: - name: Set BUILD_DIR run: echo "BUILD_DIR=build-omodsim-Qt_${{ env.QT_VERSION }}-Release" >> $GITHUB_ENV + - name: Resolve latest OpenSSL 3.5 LTS version + uses: ./.github/actions/build-openssl3-runtime + - name: Configure project run: | cmake src -B ${{ env.BUILD_DIR }} \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DOPENSSL_ROOT_DIR=/usr/local \ -DOMODSIM_BUILD_TESTS=OFF \ -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} @@ -141,9 +145,6 @@ jobs: with: qt-prefix-path: ${{ steps.qt.outputs.qt-root-dir }} - - name: Resolve latest OpenSSL 3.5 LTS version - uses: ./.github/actions/build-openssl3-runtime - - name: Bundle Qt libraries and plugins throught cqtdeployer run: | CQtDeployer -bin ${{ env.BUILD_DIR }}/omodsim \ diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml index 32b6df4b..170281e3 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -63,12 +63,14 @@ jobs: sudo apt-get update sudo apt-get install -y \ ninja-build \ - libgl1-mesa-dev \ + libgl1-mesa-dev libssl-dev \ libxkbcommon-x11-0 - name: Install Ninja (macOS) if: runner.os == 'macOS' - run: brew install ninja + run: | + brew install ninja openssl@3 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV" - name: Set up MSVC environment (Windows) if: runner.os == 'Windows' @@ -81,6 +83,10 @@ jobs: modules: ${{ matrix.qt-modules }} cache: true + - name: Install OpenSSL 3 (Windows) + if: runner.os == 'Windows' + uses: ./.github/actions/install-openssl + - name: Configure run: > cmake -S src -B build -G Ninja @@ -118,7 +124,7 @@ jobs: sudo apt-get update sudo apt-get install -y \ ninja-build \ - libgl1-mesa-dev \ + libgl1-mesa-dev libssl-dev \ libxkbcommon-x11-0 python3 -m pip install --upgrade gcovr diff --git a/README.md b/README.md index c69d63c3..cb1543dd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Open ModSim is a free implimentation of modbus slave (server) utility for modbus # Features +Modbus TCP over TLS supports encrypted connections and built-in server certificate generation. See [TLS setup](docs/modbus-tls.md). + The following Modbus functions are available: - Discrete Coils/Flags @@ -180,6 +182,8 @@ src/ The core library does not depend on Qt Widgets. Each layer has its own `CMakeLists.txt`. # Building + +Certificate generation requires OpenSSL 3 development libraries (`libcrypto`). The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. Building is available via cmake (with installed Qt version 5.15 and above) or Qt Creator. Supports both OS Microsoft Windows and Linux. > [!NOTE] diff --git a/README.md.in b/README.md.in index 6b9d62a9..454a71f7 100644 --- a/README.md.in +++ b/README.md.in @@ -13,6 +13,8 @@ Open ModSim is a free implimentation of modbus slave (server) utility for modbus # Features +Modbus TCP over TLS supports encrypted connections and built-in server certificate generation. See [TLS setup](docs/modbus-tls.md). + The following Modbus functions are available: - Discrete Coils/Flags @@ -180,6 +182,8 @@ src/ The core library does not depend on Qt Widgets. Each layer has its own `CMakeLists.txt`. # Building + +Certificate generation requires OpenSSL 3 development libraries (`libcrypto`). The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. Building is available via cmake (with installed Qt version 5.15 and above) or Qt Creator. Supports both OS Microsoft Windows and Linux. > [!NOTE] diff --git a/build-macos.sh b/build-macos.sh index 62a29115..ddaeb325 100755 --- a/build-macos.sh +++ b/build-macos.sh @@ -85,6 +85,10 @@ echo " Found: ninja $(ninja --version)" # ========================== # Check Qt # ========================== +brew list --versions openssl@3 >/dev/null || brew install openssl@3 +OPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" +export OPENSSL_ROOT_DIR + echo "Checking for Qt..." QT_PREFIX="" @@ -194,6 +198,7 @@ cd "$BUILD_DIR" cmake ../src -GNinja \ -DCMAKE_PREFIX_PATH="${QT_PREFIX}" \ + -DOPENSSL_ROOT_DIR="${OPENSSL_ROOT_DIR}" \ -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \ ${CMAKE_QT_OPTION} diff --git a/build.ps1 b/build.ps1 index a9ab2bc2..b0e0d8d4 100644 --- a/build.ps1 +++ b/build.ps1 @@ -477,6 +477,11 @@ if (-not (Test-Path $QtDir)) { } Write-Host "Found Qt: $QtDir" +Write-Host "Checking for OpenSSL 3..." +$OpenSslArchitecture = if ($Arch -eq 'win32') { 'x86' } else { 'x64' } +$OpenSslRoot = & (Join-Path $PSScriptRoot 'tools\install_openssl.ps1') -Architecture $OpenSslArchitecture +$env:PATH = "$(Join-Path $OpenSslRoot 'bin');$env:PATH" + # Create Build dir $BuildDir = "build-omodsim-Qt_${QtVersion}_$($Compiler.ToUpper())bit-$BuildType" if (-not (Test-Path $BuildDir)) { @@ -497,6 +502,7 @@ $cmakeArgs = @( "-DCMAKE_PREFIX_PATH=`"$QtDir`"", "-DCMAKE_INSTALL_PREFIX=`"$InstallPrefix`"", "-DOMODSIM_BUILD_TESTS=OFF", + "-DOPENSSL_ROOT_DIR=$($OpenSslRoot -replace '\\', '/')", "-DUSE_QLEMENTINE_APP_STYLE=$(if ($qlementine) { 'ON' } else { 'OFF' })" ) @@ -565,7 +571,7 @@ if (Test-Path $exePath) { # Qt 6 installer workflow options $windeployArgs += @( "--no-system-dxc-compiler" - "--skip-plugin-types", "help,generic,networkinformation,qmltooling,tls" + "--skip-plugin-types", "help,generic,networkinformation,qmltooling" "--exclude-plugins", "qgif,qjpeg,qpdf,qsqlibase,qsqlmimer,qsqloci,qsqlodbc,qsqlpsql" ) } diff --git a/build.sh b/build.sh index 81f76caf..0bd546db 100755 --- a/build.sh +++ b/build.sh @@ -332,19 +332,19 @@ get_packages() { case "$DISTRO" in debian-based) - general_packages="build-essential cmake ninja-build curl tar gzip libxcb-cursor-dev libgl1-mesa-dev pkg-config libcups2-dev" + general_packages="build-essential cmake ninja-build curl tar gzip libxcb-cursor-dev libgl1-mesa-dev pkg-config libcups2-dev libssl-dev perl" ;; rhel-based) - general_packages="gcc gcc-c++ libstdc++-static cmake ninja-build tar gzip pkgconf-pkg-config xcb-util-cursor-devel" + general_packages="gcc gcc-c++ libstdc++-static cmake ninja-build tar gzip pkgconf-pkg-config xcb-util-cursor-devel openssl-devel perl" ;; altlinux) - general_packages="gcc gcc-c++ libstdc++-devel-static cmake ninja-build curl tar gzip pkg-config libxcbutil-cursor libcups-devel" + general_packages="gcc gcc-c++ libstdc++-devel-static cmake ninja-build curl tar gzip pkg-config libxcbutil-cursor libcups-devel libopenssl-devel perl" ;; suse-based) - general_packages="gcc gcc-c++ cmake ninja curl tar gzip pkg-config libxcb-cursor0" + general_packages="gcc gcc-c++ cmake ninja curl tar gzip pkg-config libxcb-cursor0 libopenssl-devel perl" ;; arch-based) - general_packages="base-devel cmake ninja curl tar gzip pkgconf libxcb libcups" + general_packages="base-devel cmake ninja curl tar gzip pkgconf libxcb libcups openssl perl" ;; esac @@ -582,6 +582,18 @@ else echo "Using Qt $QT_VERSION from: $CMAKE_PREFIX" fi +if [ -z "${OPENSSL_ROOT_DIR:-}" ] && ! pkg-config --atleast-version=3 openssl 2>/dev/null; then + OPENSSL_TOOLS_ROOT="$TOOLS_DIR/openssl-src" + OPENSSL_SOURCE_ROOT="$OPENSSL_TOOLS_ROOT/Tools/OpenSSLv3/src" + if [ ! -f "$OPENSSL_SOURCE_ROOT/Configure" ]; then + python3 -m venv "$TOOLS_DIR/openssl-aqt" + "$TOOLS_DIR/openssl-aqt/bin/python3" -m pip install aqtinstall + "$TOOLS_DIR/openssl-aqt/bin/python3" -m aqt install-tool linux desktop tools_opensslv3_src -O "$OPENSSL_TOOLS_ROOT" + fi + export OPENSSL_ROOT_DIR="$TOOLS_DIR/openssl" + bash "$PROJECT_DIR/tools/build_openssl.sh" "$OPENSSL_SOURCE_ROOT" "$OPENSSL_ROOT_DIR" +fi + # ========================== # Detect architecture # ========================== diff --git a/docs/modbus-tls.md b/docs/modbus-tls.md new file mode 100644 index 00000000..87158b9e --- /dev/null +++ b/docs/modbus-tls.md @@ -0,0 +1,52 @@ +# Modbus TCP over TLS + +Open **Modbus Definitions → Certificates** before starting a TLS server. +This page manages the application-wide certificate store, as in OpenUaSim. + +## Generate a server certificate + +Enter the server name, the DNS names and IP addresses clients will use, and the +validity period. Select **Generate…** to create a self-signed RSA-2048 certificate +with the `serverAuth` purpose and a matching private key. DNS names and IP addresses +are recorded in the certificate's Subject Alternative Name extension. + +Use **Export certificate…** to give clients a copy of the public PEM certificate. +Clients must explicitly trust this certificate. Export never includes the private key. +Replacing the server certificate requires clients to trust the new certificate. +No running CA service is needed for this workflow. + +## Connect + +Choose **Connect → Modbus/TCP Srv** and enable **Modbus TCP Security (TLS)**. +Enabling TLS changes the default port from 502 to 802; a custom port is preserved. +There are no certificate file selectors in the connection dialog. Every TLS endpoint +uses the shared server identity. + +TLS 1.2 or later is required. All TLS clients are accepted without requesting or +verifying client certificates. Clients can still verify the server certificate. +Plain TCP connections are rejected. Modbus registers, +scripts, and error simulations also work over the encrypted connection. +RTU over TCP does not offer this TLS option. + +Certificate operations are saved immediately, independently of the settings dialog's +Apply or Cancel buttons. Reconnect running TLS servers to apply certificate +changes. Existing connections retain the configuration they were started with. + +## Storage and build requirements + +The store is in `pki` under Qt's application data directory. The server certificate +and key are saved together in `own/identity.pem` using atomic replacement. On Unix, +the identity file is readable and writable only by its owner. Projects store the TLS +selection, while the server identity belongs to the local application installation. +Any existing files in the old `trusted` directory are left untouched and are not used. + +OpenSSL 3 `libcrypto` is a build and runtime dependency for certificate generation. +The build scripts install it through Homebrew on macOS, Qt's OpenSSL package on +Windows, or distribution packages/source builds on Linux. Runtime libraries and the +OpenSSL license are included in packaged distributions. The application does not +invoke the `openssl` command-line tool. Integration tests use that utility to create +client test fixtures and skip when it or a Qt TLS backend is unavailable. + +The simulator uses server-authenticated TLS. It does not require the mutual client +authentication described by [Modbus Security](https://www.modbus.org/modbus-specifications). +Certificate role extensions and role-based access rules are not interpreted. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 76d90a4f..1a1e4863 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,6 +19,7 @@ endif() set(PRODUCT_NAME "Open ModSim") include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/qt.cmake") +include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/openssl.cmake") include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/project_version.cmake") include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/ui_normalization.cmake") include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/help_generator.cmake") diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 6ae3eb2e..4efdbf68 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(${PROJECT_NAME} ) target_link_libraries(${PROJECT_NAME} PRIVATE omodsim_ui) +omodsim_deploy_openssl(${PROJECT_NAME}) omodsim_apply_coverage(${PROJECT_NAME}) set_target_properties(${PROJECT_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}" diff --git a/src/app/translations/omodsim_it.ts b/src/app/translations/omodsim_it.ts index 1dfb1459..5cd7e15b 100644 --- a/src/app/translations/omodsim_it.ts +++ b/src/app/translations/omodsim_it.ts @@ -182,6 +182,17 @@ Scambia byte all'interno di ciascun registro a 16 bit + + CertificateGenerator + + Enter a server name, valid DNS names or IP addresses, and a validity period from 1 to 3650 days. + + + + Cannot generate the server certificate or private key. + + + CmdLineParser @@ -891,6 +902,10 @@ Vuoi aprire la pagina di download? No response Nessuna risposta + + Certificates + + DialogMsgParser @@ -1233,6 +1248,14 @@ Vuoi aprire la pagina di download? 502 502 + + Modbus TCP Security (TLS) + + + + Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. + + DialogSetupSerialPort @@ -2978,6 +3001,13 @@ Verifica attentamente il risultato dell'unione. Per la comunicazione TCP sono state specificate impostazioni di connessione non valide. + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + + + OutputDataWidget @@ -3395,6 +3425,91 @@ Verifica attentamente il risultato dell'unione. Risposte: %1 + + TlsCertificateStore + + Generate a server certificate first. + + + + Export the certificate outside the certificate store. + + + + Generate a server certificate in Modbus Definitions → Certificates before enabling TLS. + + + + The stored TLS server identity is invalid. Generate a new server certificate. + + + + Cannot create the certificate store directory. + + + + Cannot restrict access to the server private key. + + + + + TlsCertificatesWidget + + Server name + + + + Host names / IP addresses + + + + DNS names and IP addresses clients use to reach this server, separated by commas. + + + + Valid for (days) + + + + Generate… + + + + Export certificate… + + + + No server certificate. Generate one to enable TLS. + + + + Subject: %1 +Host names / IP addresses: %2 +Valid until: %3 +SHA-256: %4 + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + TrafficLogWindow diff --git a/src/app/translations/omodsim_ru.qm b/src/app/translations/omodsim_ru.qm index 6dd67407390f88fa8c7df852ca8121e621928aee..799836c05e5f3cded0533d53dd0f68ba2c19dcc5 100644 GIT binary patch delta 10998 zcmbVy2UHa2_x7FLneD}f1nVk_SU^BPL`4LhKhc0=5Ws?9kmQ}+h+}BcAIT>P zxRvAu!1xGaXDo$ubGj3ov6UzykJ$CeM2cVutEwdY;UKZ!r4xm22Cjx+X%gP6D#@B0k2Zm#d$;x+EBOgZ4ig&sM~fgVw}>bNAsx& zFujDv%@QuTDk9^sOhV_2)WdrbH2zM)18u3th*m_^n@KortAsBLDQIZ~k*5nUUHExR z7!^dl7He>0Ck3bPBa-D(pUsHs@WIsY&7TOYlN1t*fHm)-P??V4b&Pm8Nz8Zy1KTqV_Qo_Jx6t+8u zC_0(K3Sf!AYZ4B=MuXx;6MY>-g9;B3U9LfctH1@r;%M+DU%28G4ef&zn-wbIiUJxs zZ5Ld(i$+8vbAGBp;Ul3~L@x5{Eas>#`G)E&`T6E4U*pIOYzY-pzT5l zJLOYCFa%0_NLCT2(&PjPcJVUJ^G1HlyeV@bV&wW$TD8|k^mrD1`w~G`*NMJs=}P3Z zg*Mi}e$7K^>x(r+f4I|e4J?_XqT_3qApf5g(#f^AiD_g{*XBZE=ROi{`Gf8_-vVKr zr+ZJ-L{a(l_+}bW<|BHt3@+){mi{VUN{qaaVeF7@e?Mlp+J`}0Eg9X;RAOp9XY7iL ziT<(|kuhXTX#9n#31yi251D#5Q<4APhlC5Fj^~-C<_w}w9y4wYzacufglRwF8a8~z zbjXF|M^-Q$J@8!KBqkuM5wfEn(|ykqqPORnAkLA<(Ots+zc9haA^80iCgc(Abp3?`{+6kPm}8K1$=CsLI%=?k|IqdCSbcY51WaGK-hzt*86CZdJ zWRmN#iGQFfv%@7_I>krpyuya?zZEzFxOJJuMcpJkBnZHDZe z_6E`CVgDij#;Q-jn;JO^4_tVago|XBA4A zt&n$`0TahAllPi=j%ZIGdH;QIQRXQ5pjC*G#*gL0Zl5LUvrax@OEaSBPV(qSPf;*i zN$9^sp77`zF^vP{V^3EldTEwVK8o0x*=CV+@wTcw^*{+x(+K&jZG(tzKatP3n*kyz zkT1LkQ`;|+XVxD;^vG5IH3{q87kr_t$5-nq-#NidU7NUecZDY>@m+B4V=69Qm1XZHVfPkYD(!2GQ}8@}FKOq7=EwAH1Cl z167xo_QP|v|CE=8;<>s%uuNtSkzWp5rT;oq?-Oj3_=m{eF|S##+)}u7Ju4ZogM_IK zB}|h`nB9);fvlru{n?(MM-sj0&-NOUP4rC=+u#0Mgh&rIYz%(iKh2sMcyw}8*4(Z( z(eM83$j#WlxRg!KTuQVcolRZ>K^D$nCuR!Od!dS*C@d_JvD4oagI@~RnNh&3)$AQs@tkpN=D{{7s{7fm#P<@}jRjYT zmc12`X|{vSn){OIhZ5jhbOBE6rWX7Gw)C~g;-wO{YQXL}umfo>V{=a225-$^ z_h}=D21c<5H3n=@m(5GY!72-T%!F)jVb2yFh35C3vPFL#B+~R|PmNoMHl#?xs+qMi)-4 zN+z29J;!T=h4g!zMqC)o*=0N?+8WF`rB6XBg>fx{K_`FAQF9bH~8x0?_BK8`b4G)5^h}0#h&g5 z7d_&v=ieadWSq4Gc(M^UK`6Ry7D*V$a+A+niB>M-X7wC~_1`263=xrW;0;_xH}of} zFjoiwgZxI!_ zh{!0XaI00&JbD|qG5rY9pBK0-M`nZ1oVjfq51{IQ%Wd0@efVpexb02h%jG8|-1Hl_ zb0!2RD&Tf4T}`yE1GnoMT1AJx+#Wdua^A}2^hFw;Zp0mOT!kih9(SlqfAE;Sh>ZG; zgf)k9hoT{X&$4UK$YU(%%>DgGN96lY zTxo+92$n|*x$7&Ut{oJL?^vYOa)qw@06h1Z!Y~9iY1C##y*mXcqKOi=Pf|3vb{Att zqN1UQ)fG-ZZbKIBS2(W^gzuj!T(06h*INpomuRvZomcqBcwyfGiq1ZKAxejnA}qTe zh9DQkpdp~%n_CrwtD$SpEl~_!`zH>%pcrxy_hqq)(T#^=7#gIA-~A(cT${pL49n~s zs+iOXZHHo}gjE9+(|hb7s?R9C?C462ZjNI9!6q207)7Q5>3bknk$Jc;(V0bx<^1H8 zL_t**%cmh2M#U>u)cOj;P!9>0EK;mE-wOHqjbdf9aCGm%ij_;VVVT>Ct)(Ue;Y$%2 zyVr``jn{C%UB!_xNaxx?iu^7HqAh-k{I5qK27DF8_DIL(D-`EHc|er#wc=7HT=9L8 zg8zB-K?tx_aceE|a?WN&$=emg=yxgEBXXiA*OZ*k8Z=HmO1tV0LBZcDYrpg*Y89ny z^~nsPst1+r1Kp7KT4fg(20XA@87%ZEzfV>6YX-)wc2oIj-6Lo(Mk_}+BDNe0l_Q2f zC0Y}vjNAi((%SM$OZ!qt-b2D_I;AB7L3O)cB7Tt5gVP93Jq%D9PxH%s`tQn~p>0Opn+63$R6x6g!yPTx@O z(p*Px%imY-`fVW&Tr1&St1@@UP@=W_l|>^rVBnpjJe8^=n)0{udH@7z$SH5+;y~*Q z%sRIR31wZ?K_fG9lQAvQ{{@P zbTO2Wn06S9LoA(;nKR3f@tIy1qgc6*wGyNQQ*| zk5uL=IIznT5t#;ds!?|i5>s=VD!Th3P`0Bgp;i)#)hgAvZV)glO7+=I^aI*ys%drI z(dQ^t^Kw=p4N0}(lo#?p@)OmDH@`rlCM2*y{U7zKkI`&n4-y#y7 zTRqib7Yk8`0@d**M`4j%RsMchqVIfFe(6Z0)jZXS=jULdQq`F;D_|kL>es)J9pjHk zIB~z~ZqF^KmV4FQxG8YKcr};RnujmjtD9Jc6H~9T+Vl7rqHo))+iaSKgLYP5X#I1bw-Wt5HP(yvFw;9!Hf%?QsR}>Q$_35a3 znCLF6ugVLEYK>K2PjJLR+tt@E=#jSz*anO121F1{KB6cG0YSe>a zsVT`4uJ5eTuUdzmwp3$3MNl+H)51Fye3PPS!3!70`WmkpUGaV5rp8+X4XW?d_|@oz zZu_ogmAG3{CiJPke{S(nPgG5LHjqn0wyBoO4rSUJt=S84(%E)x@Ns z^I74nu{whXn$_0u5946LE}HZsBftj}H5oB@-`l3)g$w4BW15V_TfixrO?`3DynM~J zB50KKsU~M9(j-Zt$5ZvL~hT|R*bw8r@ygC&@`h&KE9Ra| zyJ*h`Pe&9t)LyL76a>{qg~PI zr#40w?bjKjkwyBJx~ex|sbRNu_3J>v)+IXA7bV0rVRX)NSLA=|iNXa@K)SB^j2sk` zAvzbkU(wmz)_Fvt20YBt`7DFa@-#YMzB4rZUFUx{9F0b-F2LDFjL~1$b$&4>Cr99) zsPk(jbobNs3!RJT-=qs01k`WS4N37rb30Qvye6I>GyursVxGM&t{V=zG)$LdE5Xnk zuNz+zzHC_}q5BkF>d03Jo-Vp+dk{3qQ*_hiLOR;(rY}KMZ;H|_82*Cj^CaEE)Zs** zR?#gyjOUkC*DZN{8xxVM?(0kN{pusSH4k)1>ovMHC5XA{qjl@}QKcxiBX#Qvn_%|r zDdGCt5*{0+%R1N!U29$4uDmVybd;*w^&5Pdx?cDF0u50+Uc%1JC7cj1;iOp-&iB#% zFd`VOSg`K&-^ut0S4VfD=`*5jX}YVqa-zX~b-Z|SN~gQE^$UD|^wizcAiBqVtGoYr zEG)7|_iSwndcN(tKl|BXptz}**UCmW>!N4JV&ln6CCr_#ci@uX`x*K=6o(G!n!dUC zonnH%%}u!Ed8)ow!)rvZdA(&YELC_+L`K<&*T=p&0w#Md;q;vnzOvKX>{bx{=C8L+ zxPdP^A$r@Y1sJ`C>SuO;gtSW6r}eRi1#U^$d9FTf3wXl0tA5_tvqb7c`b=&bn9^Op z3DFTWi$4A2)g!9hb7>reT@ zcLxLY7sggc$8ueNbThZbTRv0EY=E5SLhM7N2L;YW=GR$3a0%;RySn~A_l+R#8PGk>!q46{9 zb#*~6ch*o;3j#H(Vz`$D0kriD&pdy`*SbK%-!tHX!UPc+#c{*i867dQ?KLVAm!gsR z%&45gqEYE?)LLNjM#07^!so~IDaPs|Rx{SQ#3BFB1RLvaTLj4#8XNU+Ao}tLqw5J& zuen7=p9cl_s6N~1+Z`L%vKYH~!e=47jNPw6kT;i&y&4V&_A~bV0l(`{GR919jPe_5 zOkay2oY_;t(qqPXyP(jxA;yKl*YFX3zj4ts3o&*c#x*zjt@uipgo~}jG#zVP{{>9g zbD8n`#vmNK{l@(pV3{u)8uvda0oR+1S2Jk5re9%kYbPO9;BTz@cL5KeXZQBEkFm|z6j_nQnP}UY$@nrp=Zy@dn zdbNY3c%C{@FonPp!ipE$o)z`tCB>*a^}#JSyy}me9i^M%=Kk2sSKQQ#Z6j{!fh~lC z{RC6tU%$iyan*{v@o!$jww~A?PggL?h|X~~=pSBYh`8`ra|_eB@Q4^ol&Mp2h)Gzm z3O4E9#}pnFm1wqF&DIvC@VF?vG%g&ES!~Is1aqP#KFTyQF+SGhZnDLjT6uf9nWDmz ztsggEwy)!IUzeI}1JRowc$)25?loNn%Uc&1BN}9%A6DNn)Ps6aJVj9iC6N_6_NPEt z$^=82DH3amWFZ?RW5pTwg0SASd`pL3@lg>;R#X4LKBf?JWKyD_fph<$kfsY>rPj-S zlH$Z5C8?PG1ZM33bGBuJ5MhCk^L-3{ATLb&p|^#|Y>ib9F%AXy`3iALS(R%``|vD0 zFS=21Ebk@w_5&SV@kphn!nzRbJ`k@x0?Uu}5H}M-F+hwOcZeR0>yC(y_vv6807rf- z2f`xb<3?IWB_*1pOqMv)JA-vHkF>;DY?k;qtLet)^Gy|9<7x_wv6$m*6;sJ-3Qshf zgq%w>k4>^*GuP}_sU>syp=>os*GrUENLDE&+#o-shc-f5^o9S#SccqzqVxf{SLOu4 zEuQ#KAPH~0f#`nc=l2>mhf7Tst0^fid|bFC1~F|Gl(9uO!D3$u?AVJHgnwWJ5`vM^_ZyltR(Uf{L~i5w}N<`al9b5$U|&*F+HV7_#i$+r@R-; z^&JyMCYsB5s3HMbZSgQ$6zmm=D~W)rL#RM=jdGR>5uOnVC=Sv&@U6-P=?Y;2*dPce z(2YPffouo-61buwP5Oau0)=%b1ELhKKMdmc0=vHx7};-2v_#rW;gOLb0wK{Q66Hk} zO-THZH6@`dk$EIr4dmx8dasN&1@aT5^@B{kAeEGQLJ1I<%L7IhBKji+=b`?;S=_7h zgoOA+n<#BXQ^zOStd=OL6jWRnT_DE9M|J3iY!p(qs}K(SJJ0*U#GY7a18D?tD~W~j z)v_w~-pq~Ol@{}RKCUR#NK2yCR=H4oSv2fq>x)+PVm(Exm3dd-27w{kmFI~d-2acb zB2ks^Em4~nk8S@~ZmWda%BgwKzgys=RK$P4MWzVzNFjyHaY7x78)X6!RBpF2v+aO0 z{mO%@FO1dxT^~`o2g~}7vMz%!b9I?h%h<>y77!1Frx$q2lf~8~G5J4jFL9sLyi|VZ z$Fj3m|7aeajKBy{f~sY;9DyBY6KWgi&JriqYFASQ1IL-impxh`%>La_A1T*-xd%U} z*Fx)LCo)$#30MbOqDGl*B6a1mwJH=YHz7DhbG>V{z>kSZR*826b62#psh>F^COlFiAM*rM93fQyZ$sMQKdd+p9c0T&T1cd1W!gf{j#Gq|MyMej*xqmgx{3<@MBn$_qR<>xY!^+D zVrzg(Y=yS*=mx?gqfItG8i6V4+X9#nE_CHlrif%yWPEIFxYRoSOZYgmE*0xUx#S-f z0ymRWd2rgjD+7`nc(LEVk+S|j4D9{Col}1PI}Rv6)#2T#|8y;HGyliQRYY2nN(zF$ zf+pIsJpOO-n^iVpP)jl?r1)6C7uoR+-Qp3ZQnTOk1LysRh*K(SO8;pJ{Kim%!1`w+ zS4M^Whh@`?Z6)>rVyxn2sf`x86seAZ5dKfM=MfxVN%B-6y!bdV_{5@ME-wjEeDRg? zQ;W)V!=TX_t+_B-Gy|0+VC0CcFkaaA>D;SgqMMwXxh9xnoBo?21^)14-&2y%@>D`U z75dA623f5#Hu-0i{b!KX#GJ!dGP0J(uQo&)l7;B_HzlA$1{%AzAPAu`Z;QV!B#beQ zF^p(r2Oo=WXDvNyBJzCGP%v57>11|rFoXs8WQQ*gbrgeM>Ybo9KKEB6kQE6-_kiccLE*Tuzkj4Tj-) zvxS`DBhhFG^Y?e6F@F;+DkK_<|M#&azF%7i9!~u4pr3*GKdb0Rd>%NnJ_+FoW}--e zgy>^LUVbF(4I&cVRT_V&JQqa5{wORm3QU4veN{fWr}Ecz5)N*MKpiaP40B017EEM5 zuJX$%63z+GP!b6@s(6!xo6vZAW6<1}XmJV#VA5?8?m)d`DpjV3f@_E}bR@ij#Fsu2 zvzlxg`6ReSQmfdxFljzHxXA&AQpuU-S|c<{xZ@09mEdzCfZy<>?k(K zuOXI<|KEL1%$y9HzPBQlF^$N&hsyWIh-G7fe-;tD42i905xW5u-%lh_SxeNd6^Wl$ z6LoE(vd;vSYnqXimP%CIg{oZ{4pR;$+rVu^546gq==NdFC?> z@@h|1y@5)zpUO`wX-L9k_;Vwb-CLN|f%gX*x>=7O_LFrEaj@qX%mODmH*5nb5=lDPiIqMRY zwP%xOzlDghhU6JmgorTPtApM#G~lMY&AzUG7ISj8@)XRjzn4^5uTqMY6FL~mD8 zaZDlzt;Z;$| zvAUO#sO&lAWC)y1;A{#DiOLHsR)pWN<)Y`oK)E>k83^+ImNb&Yuk7y;{WU@ zuE*kHO!S)Tl?h2Nj^%ng;SU*|xITw|LlhXefoI+meJkaLFngj#4k|~6bM841_-Qw8 zT%|XW{RD2-iKR$HF5JTEQ(?+jE--onk+zbHiakmkE94Hll@i6?vXHC!J9p-Y`2bYW zgUcn@sI7^+7JU>81ao)p?n|>|c$e%}t`&!!?dq z@&4uoM9L%0G6OWXDDC8X1}LMIEyt00rD@>9G|m}sI{FY^{hlxbV_rsz(gGN z)MWn|Lv*aQCi}$;l!t~YUD~UR?x*?NVJA^&p2}}qG`Ho)@cwz4vWKoj`@J>hrxja} z$46*h+{cFtmufy;!H(uu@S3`C$_}|Bv|SQL*W3qJV1nmNJU@*Rrg5;` z;xb`cca-+h@51yWSm0)w5EPd{w6?zx6b}K`xd=-v&so9}%ku>x>}R1u^gK+6m|94* zZ^Fi3T3hpji9$?cSloC7bRaU^6V|z5p2&7WTzXg3zIaje@2Gj=|;AL||n)!aD2dU*H} z6~wS!hwy&NE#{Jsn)mbxbL*0U?+onsFyzbIU71(+vqTGmnNL-13tgk~+aJug51!)> zsyr0Sd}m!JPVr;2P9yu|?P9Y}+7UIFrE>2CHrsr2BogUC=3j)ivdL%WUk>IzWQ(h6 zW9LYfE??NPB7dU4#;{d`|G@kEDtiyGkh8AGqTMdfcC^Ct`svJEHQmKR*LGi=9yWkfm8EaWuNEJ=%#&k`>_lk2ry$C%XXdn?&C6D^cGkqPSl`6nTisK;*&it3~5@RF-L@ z#0F3EPy(l`GSNPm6zr!uHc_#O-yq5{-DLGIqGQy{J9H^AB-H3vXypC+4;CZ-rRQSSOvG<(7csZL5qaRNn7hpjPWMPGtcmbzzCkRi^@?bg zr+6<8CO8ox{+E=Fv(p%{Y&XI%(nl=+wVgO^mLz2HM6VMh)@2to@lmp|dV|clPpbRH z8D~9DseP?*BGU<}hih9TtiPrHt<4N}1y)Aj|@#tJ4n%G#o2Yx;zI=w=BC}22Ja|3Nkasf>9Q=7Upjp)}T?WtB%FwZ~QORas0dR)-v zG`$QBEYs$m$Igd!(dK`?P1Nv%_SVeph^}1iqi=`;e{--pSQMyzKKLjS4C&aSV5r(j z#}0Ld%}RA0a%K|k{G#i6I2052)OmHrM6cWGyzae5tGP$#7ve+|Q>>e{5dX7F)dh4O zgjC!~x6~hbpk|CNGVTd9H&7Sz9{<~J)+HrkLqRdR15zn*W?_yl%^Evc-BOn}96Ns$ zsykct9C<^RF>D%+6YjeF9gb+U8tHCMZGeN+HQfU~kEqr(UCBIqEOb#<@(;c{vRGx& zT;1~}$QO<_x_3j05dWkrpAX;gUaQxQgJza=P`Q)zhQvLH|5s`HR-HnSNS3QKSm`^4 z55O%zq`s3Ll2_la?`b_0jYWn24=rpz(pf+0K2olSlivG#2b}V!>!)^yZ&aVIpEkG* zr;P3TX?r2isC_EG-_*~FMB|cBs`qb(@NQ<(o8QcV=A89WSzd_$84dN(voJvi9}79F zLVfgtGUBYF^@m4bq3BS3@>T3)ejR<Yx3JLlJ6jA*U(SzwECgO1Z3mIUnI1(_jBO#Vn!O7W8jjU|Y{L{ky!{ zL_bsXUuM8oBWz_|5O$*NB#xIE)27R&nlQ<+d$OrX7NTUTTzmH>qBD|Q zZy4rz(L}DdEC?-RkcC{0cXFd^qu{*t{mCn?rTw@{7?mi0ZeJUp@pccavYO2t@;PKz{$65q(HhG}jW4 zkTxlX6%L4+WX0rzsFCfJ>W`tR@llFhJ#4V!bEO#%6LvJuQ3oz&rDb>;dOusGwap_m zB#DaC6y)+3)0Hk;V5^Il6=!ol>};3f^4uFI#%4;NX6T&s?#h4-g}5Ck20x)p$E$4P zq>S{4fww0ro?}6Iw=#ZtM>M50M*l;h|*MG3Tp4O?fZ zY_mWKnf?`B@GB+s418r_S0#)`RE)WxgvG;~_uDBOCsq)xXsg7AOoTI^QerRR`%TxC z`0r29FFaSa-Gj}y1uMH=DG1}^%C2(w+HwbF&x}ekbJ_r9&y}Vq3h!0!OjMcKSUHs5 z9?fQTB{lmf;hXNOZWb z@*tDP4O4~6{2Zn1*jh7jTp#6y9^UM8M|t^fJ~ZL4eB8Ypz1l_P(?}caJkr3|Nx|*G zGlMW669$b|nLgA|n=OQ`T@3YT4mz9yL(8h$fk*8PT}xq#58Vwz8yDmL#M$6G4jRe{ zv5?a^8D{^?Lc)3hnlV^DSAFs6rXj#)JHqR`Az<-iT$~Ir1SD?6(W<5)V&GeZQIsKa zcunm5o=VphhIR7`h;$i-I2MZP_sS6ecQ$sq#gH%!f)sxz77F{uAbjqQUgk!Xe)oj=3Ij={zr<-Kt;bimm2%MzmD`Nj$ErARbx zMxV$GL`j9w*DoDC-e==HcZBD)UB-Z8Xvuy*Y+P)g32%REjQAIt*^*_9iO)xLbT-Cs zJArDbGp0=$gj!N&HlB5Cjn-?8@oF6%akV^*Kf0%4?>2_ZS4xnd&ERLP~cw{WhpJ(V8VD$9xo_ z)e)vHukujN`|lYdDb~Fh88gtd>7y^Mq+LwAN{=D@+FHofJZ;*$78)DqVmjr3 zJYtn>I)4C~h<;!?|Ee6f5wHI>J&41Cj~|*!e#XG2S4@usj^ms^-t=aqFH-6{)4O-r z!Q#m(lUADEzlp;w;99I!&w8VIXN#2L+RgPTR!ufLr&M^^*{7@tv*$KSs|+daBJyKS Q@3-aNH#qIxhilLN503D$wg3PC diff --git a/src/app/translations/omodsim_ru.ts b/src/app/translations/omodsim_ru.ts index 139cf600..ef7cb323 100644 --- a/src/app/translations/omodsim_ru.ts +++ b/src/app/translations/omodsim_ru.ts @@ -182,6 +182,17 @@ Поменять байты местами внутри каждого 16-битного регистра + + CertificateGenerator + + Enter a server name, valid DNS names or IP addresses, and a validity period from 1 to 3650 days. + Укажите имя сервера, допустимые DNS-имена или IP-адреса и срок действия от 1 до 3650 дней. + + + Cannot generate the server certificate or private key. + Не удалось создать сертификат сервера или закрытый ключ. + + CmdLineParser @@ -891,6 +902,10 @@ Open the download page? No response Не отвечать на запросы + + Certificates + Сертификаты + DialogMsgParser @@ -1233,6 +1248,14 @@ Open the download page? 502 + + Modbus TCP Security (TLS) + Modbus TCP Security (TLS) + + + Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. + Использует сертификат сервера из вкладки «Сертификаты» настроек Modbus. Сертификаты клиентов не требуются. + DialogSetupSerialPort @@ -2978,6 +3001,13 @@ Please verify the merge result carefully. Указаны некорректные настройки для TCP соединения. + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + В этой установке Qt отсутствует поддержка TLS. + + OutputDataWidget @@ -3395,6 +3425,94 @@ Please verify the merge result carefully. Ответов: %1 + + TlsCertificateStore + + Generate a server certificate first. + Сначала создайте сертификат сервера. + + + Export the certificate outside the certificate store. + Выберите место экспорта вне хранилища сертификатов. + + + Generate a server certificate in Modbus Definitions → Certificates before enabling TLS. + Перед включением TLS создайте сертификат сервера на вкладке «Сертификаты» настроек Modbus. + + + The stored TLS server identity is invalid. Generate a new server certificate. + Сохранённый сертификат или ключ сервера TLS повреждён. Создайте новый сертификат сервера. + + + Cannot create the certificate store directory. + Не удалось создать каталог хранилища сертификатов. + + + Cannot restrict access to the server private key. + Не удалось ограничить доступ к закрытому ключу сервера. + + + + TlsCertificatesWidget + + Server name + Имя сервера + + + Host names / IP addresses + DNS-имена / IP-адреса + + + DNS names and IP addresses clients use to reach this server, separated by commas. + DNS-имена и IP-адреса для подключения клиентов к серверу, через запятую. + + + Valid for (days) + Срок действия (дней) + + + Generate… + Создать… + + + Export certificate… + Экспорт сертификата… + + + No server certificate. Generate one to enable TLS. + Сертификат сервера отсутствует. Создайте его для включения TLS. + + + Subject: %1 +Host names / IP addresses: %2 +Valid until: %3 +SHA-256: %4 + Владелец: %1 +DNS-имена / IP-адреса: %2 +Действителен до: %3 +SHA-256: %4 + + + Replace server certificate + Замена сертификата сервера + + + Clients will need to trust the new certificate. Replace the existing server certificate? + Клиентам потребуется добавить новый сертификат в доверенные. Заменить текущий сертификат сервера? + + + Export server certificate + Экспорт сертификата сервера + + + PEM certificate (*.pem) + Сертификат PEM (*.pem) + + + Certificates + Сертификаты + + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_CN.ts b/src/app/translations/omodsim_zh_CN.ts index 5fed646c..07d2af2c 100644 --- a/src/app/translations/omodsim_zh_CN.ts +++ b/src/app/translations/omodsim_zh_CN.ts @@ -182,6 +182,17 @@ 交换每个 16 位寄存器内的字节 + + CertificateGenerator + + Enter a server name, valid DNS names or IP addresses, and a validity period from 1 to 3650 days. + + + + Cannot generate the server certificate or private key. + + + CmdLineParser @@ -891,6 +902,10 @@ Open the download page? No response 没有响应 + + Certificates + + DialogMsgParser @@ -1233,6 +1248,14 @@ Open the download page? 502 502 + + Modbus TCP Security (TLS) + + + + Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. + + DialogSetupSerialPort @@ -2979,6 +3002,13 @@ Please verify the merge result carefully. 指定的 TCP 通讯连接设定无效。 + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + + + OutputDataWidget @@ -3396,6 +3426,91 @@ Please verify the merge result carefully. 响应:%1 + + TlsCertificateStore + + Generate a server certificate first. + + + + Export the certificate outside the certificate store. + + + + Generate a server certificate in Modbus Definitions → Certificates before enabling TLS. + + + + The stored TLS server identity is invalid. Generate a new server certificate. + + + + Cannot create the certificate store directory. + + + + Cannot restrict access to the server private key. + + + + + TlsCertificatesWidget + + Server name + + + + Host names / IP addresses + + + + DNS names and IP addresses clients use to reach this server, separated by commas. + + + + Valid for (days) + + + + Generate… + + + + Export certificate… + + + + No server certificate. Generate one to enable TLS. + + + + Subject: %1 +Host names / IP addresses: %2 +Valid until: %3 +SHA-256: %4 + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_TW.ts b/src/app/translations/omodsim_zh_TW.ts index 0a108a3a..cf8ab97f 100644 --- a/src/app/translations/omodsim_zh_TW.ts +++ b/src/app/translations/omodsim_zh_TW.ts @@ -182,6 +182,17 @@ 交換每個 16 位元暫存器內的位元組 + + CertificateGenerator + + Enter a server name, valid DNS names or IP addresses, and a validity period from 1 to 3650 days. + + + + Cannot generate the server certificate or private key. + + + CmdLineParser @@ -891,6 +902,10 @@ Open the download page? No response 沒有響應 + + Certificates + + DialogMsgParser @@ -1233,6 +1248,14 @@ Open the download page? 502 502 + + Modbus TCP Security (TLS) + + + + Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. + + DialogSetupSerialPort @@ -2979,6 +3002,13 @@ Please verify the merge result carefully. 指定的 TCP 通訊連線設定無效。 + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + + + OutputDataWidget @@ -3396,6 +3426,91 @@ Please verify the merge result carefully. 響應:%1 + + TlsCertificateStore + + Generate a server certificate first. + + + + Export the certificate outside the certificate store. + + + + Generate a server certificate in Modbus Definitions → Certificates before enabling TLS. + + + + The stored TLS server identity is invalid. Generate a new server certificate. + + + + Cannot create the certificate store directory. + + + + Cannot restrict access to the server private key. + + + + + TlsCertificatesWidget + + Server name + + + + Host names / IP addresses + + + + DNS names and IP addresses clients use to reach this server, separated by commas. + + + + Valid for (days) + + + + Generate… + + + + Export certificate… + + + + No server certificate. Generate one to enable TLS. + + + + Subject: %1 +Host names / IP addresses: %2 +Valid until: %3 +SHA-256: %4 + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + TrafficLogWindow diff --git a/src/cmake/cmake_install_macos.cmake b/src/cmake/cmake_install_macos.cmake index 6bc2c895..fb6b9407 100644 --- a/src/cmake/cmake_install_macos.cmake +++ b/src/cmake/cmake_install_macos.cmake @@ -1,3 +1,5 @@ +get_filename_component(omodsim_openssl_lib "${OPENSSL_CRYPTO_LIBRARY}" DIRECTORY) + # macOS installation rules if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "/Applications" CACHE PATH "Install path prefix" FORCE) @@ -74,9 +76,9 @@ install(CODE " execute_process( COMMAND \"\${_macdeployqt_executable}\" \"\${_installed_app}\" + \"-libpath=${omodsim_openssl_lib}\" -always-overwrite -verbose=2 - \"-libpath=\${_qt_lib_dir}\" \"-codesign=-\" RESULT_VARIABLE _macdeployqt_result ) diff --git a/src/cmake/cmake_install_windows.cmake b/src/cmake/cmake_install_windows.cmake index 13e2e124..fdf1d4a1 100644 --- a/src/cmake/cmake_install_windows.cmake +++ b/src/cmake/cmake_install_windows.cmake @@ -1,3 +1,7 @@ +get_filename_component(omodsim_openssl_bin "${OPENSSL_INCLUDE_DIR}/../bin" ABSOLUTE) +file(GLOB omodsim_openssl_runtime "${omodsim_openssl_bin}/libcrypto-*.dll" "${omodsim_openssl_bin}/libssl-*.dll") +install(FILES ${omodsim_openssl_runtime} DESTINATION .) + # Windows installation rules if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "C:/Program Files/Open ModSim ${VERSION_MAJOR}" CACHE PATH "Install path prefix" FORCE) @@ -42,7 +46,7 @@ install(CODE " if(\"${Qt6_FOUND}\") list(APPEND _windeploy_args --no-system-dxc-compiler - --skip-plugin-types help,generic,networkinformation,qmltooling,tls + --skip-plugin-types help,generic,networkinformation,qmltooling --exclude-plugins qgif,qjpeg,qpdf,qsqlibase,qsqlmimer,qsqloci,qsqlodbc,qsqlpsql) elseif(\"${Qt5_FOUND}\") list(APPEND _windeploy_args diff --git a/src/cmake/openssl.cmake b/src/cmake/openssl.cmake new file mode 100644 index 00000000..680511c0 --- /dev/null +++ b/src/cmake/openssl.cmake @@ -0,0 +1,18 @@ +find_package(OpenSSL 3 REQUIRED COMPONENTS Crypto) + +function(omodsim_deploy_openssl target_name) + if(WIN32) + get_filename_component(openssl_bin "${OPENSSL_INCLUDE_DIR}/../bin" ABSOLUTE) + file(GLOB openssl_runtime "${openssl_bin}/libcrypto-*.dll" "${openssl_bin}/libssl-*.dll") + if(NOT openssl_runtime) + message(FATAL_ERROR "OpenSSL runtime DLLs not found in ${openssl_bin}") + endif() + add_custom_command(TARGET ${target_name} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${openssl_runtime} "$" + VERBATIM + ) + endif() +endfunction() + +configure_file("${PROJECT_SOURCE_DIR}/../third_party/licenses/OpenSSL.txt" + "${PROJECT_BINARY_DIR}/docs/licenses/OpenSSL.txt" COPYONLY) diff --git a/src/cmake/tests.cmake b/src/cmake/tests.cmake index ee879aff..30466546 100644 --- a/src/cmake/tests.cmake +++ b/src/cmake/tests.cmake @@ -21,6 +21,7 @@ function(omodsim_configure_tests) Qt::Widgets Qt::Test ) + omodsim_deploy_openssl(${test_name}) omodsim_apply_coverage(${test_name}) set_target_properties(${test_name} PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests ${test_name}) @@ -58,6 +59,11 @@ function(omodsim_configure_tests) omodsim_add_test(omodsim_tests_connectiondetails test_connectiondetails.cpp) omodsim_add_test(omodsim_tests_recentprojectsprompt test_recentprojectsprompt.cpp omodsim_ui) omodsim_add_test(omodsim_tests_modbusserver test_modbusserver.cpp) + omodsim_add_test(omodsim_tests_tlscertificatestore test_tlscertificatestore.cpp) + target_link_libraries(omodsim_tests_tlscertificatestore PRIVATE OpenSSL::Crypto) + omodsim_add_test(omodsim_tests_tlssettings test_tlssettings.cpp omodsim_ui) + omodsim_add_test(omodsim_tests_modbustls test_modbustls.cpp) + target_link_libraries(omodsim_tests_modbustls PRIVATE OpenSSL::Crypto) omodsim_add_test(omodsim_tests_modbustransports test_modbustransports.cpp) omodsim_add_test(omodsim_tests_rtusnifferparser test_rtusnifferparser.cpp) @@ -66,6 +72,7 @@ function(omodsim_configure_tests) "${PROJECT_SOURCE_DIR}/app/resources.qrc" ) target_link_libraries(omodsim_tests_projecttreewidget PRIVATE omodsim_ui Qt::Test) + omodsim_deploy_openssl(omodsim_tests_projecttreewidget) omodsim_apply_coverage(omodsim_tests_projecttreewidget) set_target_properties(omodsim_tests_projecttreewidget PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests omodsim_tests_projecttreewidget) @@ -77,6 +84,7 @@ function(omodsim_configure_tests) "${PROJECT_SOURCE_DIR}/app/resources.qrc" ) target_link_libraries(omodsim_tests_appproject PRIVATE omodsim_ui Qt::Test) + omodsim_deploy_openssl(omodsim_tests_appproject) omodsim_apply_coverage(omodsim_tests_appproject) target_compile_definitions(omodsim_tests_appproject PRIVATE OMODSIM_DEMOS_DIR="${PROJECT_SOURCE_DIR}/../demos/projects" diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 300f9414..cd8063b1 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,4 +1,8 @@ add_library(omodsim_core STATIC + pki/certificategenerator.cpp + pki/certificategenerator.h + pki/tlscertificatestore.cpp + pki/tlscertificatestore.h modbus/datasimulator.cpp modbus/datasimulator.h models/datamapdatamodel.cpp @@ -13,6 +17,8 @@ add_library(omodsim_core STATIC modbus/modbusrtuserialserver.cpp modbus/modbusrtutcpserver.cpp modbus/modbusserver.cpp + modbus/modbustlslistener.cpp + modbus/modbustlslistener.h modbus/modbustcpserver.cpp project/projectaddressspacefilter.cpp project/projectaddressspacexml.cpp @@ -123,3 +129,5 @@ omodsim_apply_coverage(omodsim_core) if(MSVC) target_compile_options(omodsim_core PUBLIC /utf-8) endif() + +target_link_libraries(omodsim_core PRIVATE OpenSSL::Crypto) diff --git a/src/core/modbus/connectiondetails.h b/src/core/modbus/connectiondetails.h index d103bff9..45ef1570 100644 --- a/src/core/modbus/connectiondetails.h +++ b/src/core/modbus/connectiondetails.h @@ -24,6 +24,7 @@ struct TcpConnectionParams { quint16 ServicePort = 502; QString IPAddress = "0.0.0.0"; + bool TlsEnabled = false; void normalize() { @@ -36,12 +37,14 @@ struct TcpConnectionParams { IPAddress = params.IPAddress; ServicePort = params.ServicePort; + TlsEnabled = params.TlsEnabled; return *this; } friend bool operator==(const TcpConnectionParams& params1, const TcpConnectionParams& params2) noexcept { - return params1.ServicePort == params2.ServicePort && params1.IPAddress == params2.IPAddress; + return params1.ServicePort == params2.ServicePort && params1.IPAddress == params2.IPAddress + && params1.TlsEnabled == params2.TlsEnabled; } }; Q_DECLARE_METATYPE(TcpConnectionParams) @@ -56,6 +59,9 @@ inline QDataStream& operator <<(QDataStream& out, const TcpConnectionParams& par { out << params.ServicePort; out << params.IPAddress; + // The optional marker preserves legacy streams followed by serial parameters. + if (params.TlsEnabled) + out << quint32(0x544c5302); return out; } @@ -69,6 +75,22 @@ inline QDataStream& operator >>(QDataStream& in, TcpConnectionParams& params) { in >> params.ServicePort; in >> params.IPAddress; + params.TlsEnabled = false; + if (in.device() && in.device()->bytesAvailable() >= 4) { + in.startTransaction(); + quint32 marker; + in >> marker; + if (marker == 0x544c5301 || marker == 0x544c5302) { + if (marker == 0x544c5301) { + QString certificate, key, ca; + in >> certificate >> key >> ca; + } + params.TlsEnabled = in.commitTransaction(); + } else { + in.rollbackTransaction(); + in.resetStatus(); + } + } params.normalize(); return in; } @@ -83,17 +105,16 @@ inline QSettings& operator <<(QSettings& out, const TcpConnectionParams& params) { out.setValue("TcpParams/IPAddress", params.IPAddress); out.setValue("TcpParams/ServicePort", params.ServicePort); + out.setValue("TcpParams/TlsEnabled", params.TlsEnabled); return out; } /// -/// \brief operator >> -/// \param in -/// \param params -/// \return +/// \brief Reads TCP settings including the optional TLS selection. /// inline QSettings& operator >>(QSettings& in, TcpConnectionParams& params) { + params.TlsEnabled = in.value("TcpParams/TlsEnabled", false).toBool(); params.IPAddress = in.value("TcpParams/IPAddress", "0.0.0.0").toString(); params.ServicePort = in.value("TcpParams/ServicePort", 502).toUInt(); params.normalize(); @@ -111,6 +132,9 @@ inline QXmlStreamWriter& operator <<(QXmlStreamWriter& xml, const TcpConnectionP xml.writeStartElement("TcpConnectionParams"); xml.writeAttribute("IPAddress", params.IPAddress); xml.writeAttribute("ServicePort", QString::number(params.ServicePort)); + if (params.TlsEnabled) { + xml.writeAttribute("TlsEnabled", "true"); + } xml.writeEndElement(); return xml; @@ -126,6 +150,7 @@ inline QXmlStreamReader& operator >>(QXmlStreamReader& xml, TcpConnectionParams& { if (xml.isStartElement() && xml.name() == QLatin1String("TcpConnectionParams")) { const QXmlStreamAttributes attributes = xml.attributes(); + params.TlsEnabled = attributes.value("TlsEnabled") == QLatin1String("true"); if (attributes.hasAttribute("IPAddress")) { params.IPAddress = attributes.value("IPAddress").toString(); diff --git a/src/core/modbus/modbustcpserver.cpp b/src/core/modbus/modbustcpserver.cpp index a3910a61..399461d3 100644 --- a/src/core/modbus/modbustcpserver.cpp +++ b/src/core/modbus/modbustcpserver.cpp @@ -13,15 +13,20 @@ #include #include #include "modbustcpserver.h" +#include "connectiondetails.h" +#include "modbustlslistener.h" /// -/// \brief ModbusTcpServer::ModbusTcpServer -/// \param parent +/// \brief Creates a TCP transport using the shared TLS certificate store. +/// \param parent Object owning the transport. +/// \param certificateStoreRoot PKI directory; empty uses the application store. /// -ModbusTcpServer::ModbusTcpServer(QObject *parent) +ModbusTcpServer::ModbusTcpServer(QObject *parent, const QString& certificateStoreRoot) : ModbusServer(parent) { - _server = new QTcpServer(this); + _server = new ModbusTlsListener(this, certificateStoreRoot); + connect(_server, &ModbusTlsListener::handshakeError, this, + [this](const QString& error) { setError(error, QModbusDevice::UnknownError); }); _delayedResponseTimer = new QTimer(this); _delayedResponseTimer->setSingleShot(true); @@ -359,6 +364,13 @@ bool ModbusTcpServer::open() if (_server->isListening()) return false; + const auto params = property("ConnectionDetails").value().TcpParams; + const QString tlsError = _server->configure(params.TlsEnabled); + if (!tlsError.isEmpty()) { + setError(tlsError, QModbusDevice::ConnectionError); + return false; + } + const QUrl url = QUrl::fromUserInput(_networkAddress + QStringLiteral(":") + QString::number(_networkPort)); if (!url.isValid()) { @@ -385,7 +397,7 @@ void ModbusTcpServer::close() return; if (_server->isListening()) - _server->close(); + _server->stop(); _delayedResponses.clear(); _delayedResponseCount = 0; @@ -393,7 +405,8 @@ void ModbusTcpServer::close() if (_delayedResponseTimer->isActive()) _delayedResponseTimer->stop(); - for (auto socket : std::as_const(_connections)) + const auto connections = _connections; + for (auto socket : connections) socket->disconnectFromHost(); setState(QModbusDevice::UnconnectedState); diff --git a/src/core/modbus/modbustcpserver.h b/src/core/modbus/modbustcpserver.h index 59fd110c..532521cc 100644 --- a/src/core/modbus/modbustcpserver.h +++ b/src/core/modbus/modbustcpserver.h @@ -17,6 +17,7 @@ #include "modbusserver.h" class QTimer; +class ModbusTlsListener; /// /// \brief The ModbusTcpServer class @@ -26,7 +27,12 @@ class ModbusTcpServer : public ModbusServer Q_OBJECT // GCOVR_EXCL_LINE public: - explicit ModbusTcpServer(QObject *parent = nullptr); + /// + /// \brief Creates a TCP transport using the shared TLS certificate store. + /// \param parent Object owning the transport. + /// \param certificateStoreRoot PKI directory; empty uses the application store. + /// + explicit ModbusTcpServer(QObject *parent = nullptr, const QString& certificateStoreRoot = {}); ~ModbusTcpServer(); QVariant connectionParameter(QModbusDevice::ConnectionParameter parameter) const override; @@ -68,7 +74,7 @@ private slots: int _networkPort = 502; QString _networkAddress = QStringLiteral("127.0.0.1"); - QTcpServer* _server = nullptr; + ModbusTlsListener* _server = nullptr; QVector _connections; QModbusTcpConnectionObserver* _observer = nullptr; diff --git a/src/core/modbus/modbustlslistener.cpp b/src/core/modbus/modbustlslistener.cpp new file mode 100644 index 00000000..0459d562 --- /dev/null +++ b/src/core/modbus/modbustlslistener.cpp @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file modbustlslistener.cpp +/// \brief Accepts encrypted Modbus TCP connections. +/// + +#include "modbustlslistener.h" +#include +#include + +/// +/// \brief Creates a listener owned by the TCP transport. +/// \param parent Transport owning this listener. +/// \param certificateStoreRoot Shared PKI directory; empty uses the application store. +/// +ModbusTlsListener::ModbusTlsListener(QObject* parent, const QString& certificateStoreRoot) + : QTcpServer(parent), _store(certificateStoreRoot) +{ +} + +/// +/// \brief Loads the server identity for encrypted client connections. +/// \param enabled Whether to use TLS for incoming connections. +/// \return An error description, or an empty string when configuration succeeds. +/// +QString ModbusTlsListener::configure(bool enabled) +{ + _tlsEnabled = enabled; + if (!_tlsEnabled) + return {}; + if (!QSslSocket::supportsSsl()) + return tr("TLS is unavailable in this Qt installation."); + + return _store.configuration(_configuration); +} + +/// +/// \brief Starts TLS before exposing an accepted socket to Modbus processing. +/// +void ModbusTlsListener::incomingConnection(qintptr descriptor) +{ + if (!_tlsEnabled) { + QTcpServer::incomingConnection(descriptor); + return; + } + auto* socket = new QSslSocket(this); + if (!socket->setSocketDescriptor(descriptor)) { + socket->deleteLater(); + return; + } + socket->setSslConfiguration(_configuration); + _handshakes.append(socket); + connect(socket, &QSslSocket::encrypted, this, &ModbusTlsListener::finishHandshake); + connect(socket, &QSslSocket::errorOccurred, this, &ModbusTlsListener::reportSocketError); + connect(socket, &QSslSocket::disconnected, socket, &QObject::deleteLater); + connect(socket, &QObject::destroyed, this, [this, socket] { _handshakes.removeAll(socket); }); + QTimer::singleShot(10000, socket, [socket] { if (!socket->isEncrypted()) socket->abort(); }); + socket->startServerEncryption(); +} + +/// +/// \brief Makes completed TLS connections available to the transport. +/// +void ModbusTlsListener::finishHandshake() +{ + auto* socket = qobject_cast(sender()); + _handshakes.removeAll(socket); + addPendingConnection(socket); + emit newConnection(); +} + +/// +/// \brief Reports failed TLS connections without accepting their application data. +/// +void ModbusTlsListener::reportSocketError() +{ + auto* socket = qobject_cast(sender()); + if (_handshakes.removeAll(socket)) { + emit handshakeError(socket->errorString()); + socket->abort(); + socket->deleteLater(); + } +} + +/// +/// \brief Closes the listener and aborts incomplete handshakes. +/// +void ModbusTlsListener::stop() +{ + close(); + const auto sockets = _handshakes; + for (auto* socket : sockets) { + socket->abort(); + socket->deleteLater(); + } + _handshakes.clear(); +} diff --git a/src/core/modbus/modbustlslistener.h b/src/core/modbus/modbustlslistener.h new file mode 100644 index 00000000..756c165d --- /dev/null +++ b/src/core/modbus/modbustlslistener.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file modbustlslistener.h +/// \brief Accepts encrypted TLS sockets for the Modbus TCP transport. +/// + +#ifndef MODBUSTLSLISTENER_H +#define MODBUSTLSLISTENER_H + +#include +#include +#include "pki/tlscertificatestore.h" + +class QSslSocket; + +class ModbusTlsListener : public QTcpServer +{ + Q_OBJECT +public: + /// + /// \brief Creates a listener owned by the TCP transport. + /// \param parent Transport owning this listener. + /// \param certificateStoreRoot Shared PKI directory; empty uses the application store. + /// + explicit ModbusTlsListener(QObject* parent, const QString& certificateStoreRoot); + + /// + /// \brief Loads the server identity for encrypted client connections. + /// \param enabled Whether to use TLS for incoming connections. + /// \return An error description, or an empty string when configuration succeeds. + /// + QString configure(bool enabled); + + /// + /// \brief Closes the listener and aborts incomplete handshakes. + /// + void stop(); + +signals: + void handshakeError(const QString& description); + +protected: + void incomingConnection(qintptr descriptor) override; + +private: + void finishHandshake(); + void reportSocketError(); + TlsCertificateStore _store; + QSslConfiguration _configuration; + bool _tlsEnabled = false; + QList _handshakes; +}; + +#endif diff --git a/src/core/pki/certificategenerator.cpp b/src/core/pki/certificategenerator.cpp new file mode 100644 index 00000000..305b91bd --- /dev/null +++ b/src/core/pki/certificategenerator.cpp @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file certificategenerator.cpp +/// \brief Generates X.509 server identities through libcrypto. +/// + +#include "certificategenerator.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +/// +/// \brief Encodes validated DNS names and IP addresses for the SAN extension. +/// +QByteArray alternativeNames(const QStringList& hosts) +{ + if (hosts.isEmpty() || hosts.size() > 128) + return {}; + const QRegularExpression label("^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"); + QByteArray names; + for (const QString& host : hosts) { + const QString value = host.trimmed(); + QHostAddress address; + QByteArray name; + if (address.setAddress(value) && address.scopeId().isEmpty()) { + name = "IP:" + address.toString().toLatin1(); + } else { + const QByteArray dns = QUrl::toAce(value); + if (dns.isEmpty() || dns.size() > 253) + return {}; + for (const QByteArray& part : dns.split('.')) { + if (!label.match(QString::fromLatin1(part)).hasMatch()) + return {}; + } + name = "DNS:" + dns; + } + if (!names.isEmpty()) + names += ','; + names += name; + } + return names; +} + +/// +/// \brief Adds an X.509 extension using the self-signed certificate as issuer. +/// +bool addExtension(X509* certificate, int nid, const QByteArray& value) +{ + X509V3_CTX context; + X509V3_set_ctx(&context, certificate, certificate, nullptr, nullptr, 0); + const std::unique_ptr extension( + X509V3_EXT_conf_nid(nullptr, &context, nid, value.constData()), X509_EXTENSION_free); + return extension && X509_add_ext(certificate, extension.get(), -1) == 1; +} + +/// +/// \brief Copies a memory BIO without exposing its backing allocation. +/// +QByteArray bioBytes(BIO* bio) +{ + char* data = nullptr; + const long size = BIO_get_mem_data(bio, &data); + return size > 0 ? QByteArray(data, int(size)) : QByteArray(); +} + +} + +/// +/// \brief Creates an RSA server identity with DNS and IP subject alternative names. +/// \param commonName Server name shown in certificate viewers. +/// \param hosts DNS names and IP addresses clients use to reach the server. +/// \param validDays Validity period from 1 to 3650 days. +/// \param error Receives a failure description. +/// \return PEM certificate and key, or empty data on failure. +/// +GeneratedCertificate CertificateGenerator::generate(const QString& commonName, const QStringList& hosts, + int validDays, QString* error) +{ + if (error) + error->clear(); + const QByteArray names = alternativeNames(hosts); + if (commonName.trimmed().isEmpty() || commonName.toUtf8().size() > 64 + || commonName.contains(QChar::Null) || names.isEmpty() || validDays < 1 || validDays > 3650) { + if (error) + *error = QCoreApplication::translate("CertificateGenerator", "Enter a server name, valid DNS names or IP addresses, and a validity period from 1 to 3650 days."); + return {}; + } + if (error) + *error = QCoreApplication::translate("CertificateGenerator", "Cannot generate the server certificate or private key."); + + const std::unique_ptr context( + EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr), EVP_PKEY_CTX_free); + EVP_PKEY* generatedKey = nullptr; + if (!context || EVP_PKEY_keygen_init(context.get()) <= 0 + || EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), 2048) <= 0 + || EVP_PKEY_keygen(context.get(), &generatedKey) <= 0) + return {}; + const std::unique_ptr key(generatedKey, EVP_PKEY_free); + const std::unique_ptr certificate(X509_new(), X509_free); + unsigned char serialBytes[20]; + if (!certificate || RAND_bytes(serialBytes, sizeof(serialBytes)) != 1) + return {}; + serialBytes[0] = (serialBytes[0] & 0x7f) | 1; + const std::unique_ptr serial( + BN_bin2bn(serialBytes, sizeof(serialBytes), nullptr), BN_free); + if (!serial || !BN_to_ASN1_INTEGER(serial.get(), X509_get_serialNumber(certificate.get())) + || X509_set_version(certificate.get(), 2) != 1 + || !X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -300) + || !X509_gmtime_adj(X509_getm_notAfter(certificate.get()), long(validDays) * 86400) + || X509_set_pubkey(certificate.get(), key.get()) != 1) + return {}; + const QByteArray name = commonName.toUtf8(); + X509_NAME* subject = X509_get_subject_name(certificate.get()); + if (X509_NAME_add_entry_by_NID(subject, NID_commonName, MBSTRING_UTF8, + reinterpret_cast(name.constData()), name.size(), -1, 0) != 1 + || X509_set_issuer_name(certificate.get(), subject) != 1 + || !addExtension(certificate.get(), NID_basic_constraints, "critical,CA:FALSE") + || !addExtension(certificate.get(), NID_key_usage, "critical,digitalSignature,keyEncipherment") + || !addExtension(certificate.get(), NID_ext_key_usage, "serverAuth") + || !addExtension(certificate.get(), NID_subject_alt_name, names) + || !addExtension(certificate.get(), NID_subject_key_identifier, "hash") + || X509_sign(certificate.get(), key.get(), EVP_sha256()) <= 0) + return {}; + const std::unique_ptr certBio(BIO_new(BIO_s_mem()), BIO_free); + const std::unique_ptr keyBio(BIO_new(BIO_s_mem()), BIO_free); + if (!certBio || !keyBio || PEM_write_bio_X509(certBio.get(), certificate.get()) != 1 + || PEM_write_bio_PrivateKey(keyBio.get(), key.get(), nullptr, nullptr, 0, nullptr, nullptr) != 1) + return {}; + if (error) + error->clear(); + return {bioBytes(certBio.get()), bioBytes(keyBio.get())}; +} diff --git a/src/core/pki/certificategenerator.h b/src/core/pki/certificategenerator.h new file mode 100644 index 00000000..9031261d --- /dev/null +++ b/src/core/pki/certificategenerator.h @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file certificategenerator.h +/// \brief Creates self-signed identities for the Modbus TLS server. +/// + +#ifndef CERTIFICATEGENERATOR_H +#define CERTIFICATEGENERATOR_H + +#include +#include + +struct GeneratedCertificate +{ + QByteArray Certificate; + QByteArray PrivateKey; +}; + +namespace CertificateGenerator { +/// +/// \brief Creates an RSA server identity with DNS and IP subject alternative names. +/// \param commonName Server name shown in certificate viewers. +/// \param hosts DNS names and IP addresses clients use to reach the server. +/// \param validDays Validity period from 1 to 3650 days. +/// \param error Receives a failure description. +/// \return PEM certificate and key, or empty data on failure. +/// +GeneratedCertificate generate(const QString& commonName, const QStringList& hosts, + int validDays, QString* error); +} + +#endif diff --git a/src/core/pki/tlscertificatestore.cpp b/src/core/pki/tlscertificatestore.cpp new file mode 100644 index 00000000..08a17257 --- /dev/null +++ b/src/core/pki/tlscertificatestore.cpp @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file tlscertificatestore.cpp +/// \brief Persists the shared TLS server identity. +/// + +#include "tlscertificatestore.h" +#include "certificategenerator.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +/// +/// \brief Translates store failures without a QObject dependency. +/// +QString tr(const char* message) +{ + return QCoreApplication::translate("TlsCertificateStore", message); +} + +/// +/// \brief Writes a complete file while preserving the previous version on failure. +/// +QString saveFile(const QString& path, const QByteArray& bytes, bool privateFile = false) +{ + if (!QDir().mkpath(QFileInfo(path).absolutePath())) + return QCoreApplication::translate("TlsCertificateStore", "Cannot create the certificate store directory."); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return file.errorString(); + if (privateFile && !file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)) + return QCoreApplication::translate("TlsCertificateStore", "Cannot restrict access to the server private key."); + if (file.write(bytes) != bytes.size() || !file.commit()) + return file.errorString(); + return {}; +} + +} + +/// +/// \brief Opens the application store or an explicitly selected test store. +/// \param root Store directory; empty uses the application data directory. +/// +TlsCertificateStore::TlsCertificateStore(const QString& root) + : _root(root.isEmpty() ? defaultRoot() : root) +{ +} + +/// +/// \brief Returns the application PKI directory. +/// +QString TlsCertificateStore::defaultRoot() +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/pki"; +} + +/// +/// \brief Returns the directory containing this store. +/// +QString TlsCertificateStore::root() const +{ + return _root; +} + +/// +/// \brief Keeps the certificate and key in one file so identity replacement is atomic. +/// +QString TlsCertificateStore::identityPath() const +{ + return _root + "/own/identity.pem"; +} + +/// +/// \brief Reads the public certificate of the installed server identity. +/// +QSslCertificate TlsCertificateStore::serverCertificate() const +{ + const auto certificates = QSslCertificate::fromPath(identityPath()); + return certificates.isEmpty() ? QSslCertificate() : certificates.first(); +} + +/// +/// \brief Atomically replaces the server identity with a self-signed certificate. +/// \param commonName Server display name. +/// \param hosts DNS names and IP addresses placed in the SAN extension. +/// \param validDays Validity period from 1 to 3650 days. +/// \return A failure description, or an empty string on success. +/// +QString TlsCertificateStore::generateServerCertificate(const QString& commonName, const QStringList& hosts, int validDays) +{ + QString error; + const auto generated = CertificateGenerator::generate(commonName, hosts, validDays, &error); + if (generated.Certificate.isEmpty() || generated.PrivateKey.isEmpty()) + return error; + return saveFile(identityPath(), generated.Certificate + generated.PrivateKey, true); +} + +/// +/// \brief Exports only the public server certificate for clients to trust. +/// \param fileName Destination PEM file. +/// \return A failure description, or an empty string on success. +/// +QString TlsCertificateStore::exportServerCertificate(const QString& fileName) const +{ + const auto certificate = serverCertificate(); + if (certificate.isNull()) + return tr("Generate a server certificate first."); + const QString target = QFileInfo(fileName).absoluteFilePath(); + const QString storeRoot = QFileInfo(_root).absoluteFilePath(); + if (target == storeRoot || target.startsWith(storeRoot + QDir::separator())) + return tr("Export the certificate outside the certificate store."); + return saveFile(fileName, certificate.toPem()); +} + +/// +/// \brief Loads the server identity without requiring client certificates. +/// \param configuration Receives TLS 1.2 or later with client verification disabled. +/// \return A failure description, or an empty string on success. +/// +QString TlsCertificateStore::configuration(QSslConfiguration& configuration) const +{ + QFile identity(identityPath()); + if (!identity.open(QIODevice::ReadOnly)) + return tr("Generate a server certificate in Modbus Definitions → Certificates before enabling TLS."); + const auto bytes = identity.readAll(); + const auto certificates = QSslCertificate::fromData(bytes); + const QSslKey key(bytes, QSsl::Rsa); + if (certificates.isEmpty() || key.isNull()) + return tr("The stored TLS server identity is invalid. Generate a new server certificate."); + configuration = QSslConfiguration::defaultConfiguration(); + configuration.setProtocol(QSsl::TlsV1_2OrLater); + configuration.setLocalCertificateChain(certificates); + configuration.setPrivateKey(key); + configuration.setCaCertificates({}); + configuration.setPeerVerifyMode(QSslSocket::VerifyNone); + return {}; +} diff --git a/src/core/pki/tlscertificatestore.h b/src/core/pki/tlscertificatestore.h new file mode 100644 index 00000000..07fd12f9 --- /dev/null +++ b/src/core/pki/tlscertificatestore.h @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file tlscertificatestore.h +/// \brief Manages the application-wide TLS server identity. +/// + +#ifndef TLSCERTIFICATESTORE_H +#define TLSCERTIFICATESTORE_H + +#include +#include + +class TlsCertificateStore +{ +public: + /// + /// \brief Opens the application store or an explicitly selected test store. + /// \param root Store directory; empty uses the application data directory. + /// + explicit TlsCertificateStore(const QString& root = {}); + + /// + /// \brief Returns the application PKI directory. + /// + static QString defaultRoot(); + + /// + /// \brief Returns the directory containing this store. + /// + QString root() const; + + /// + /// \brief Reads the public certificate of the installed server identity. + /// + QSslCertificate serverCertificate() const; + + /// + /// \brief Atomically replaces the server identity with a self-signed certificate. + /// \param commonName Server display name. + /// \param hosts DNS names and IP addresses placed in the SAN extension. + /// \param validDays Validity period from 1 to 3650 days. + /// \return A failure description, or an empty string on success. + /// + QString generateServerCertificate(const QString& commonName, const QStringList& hosts, int validDays); + + /// + /// \brief Exports only the public server certificate for clients to trust. + /// \param fileName Destination PEM file. + /// \return A failure description, or an empty string on success. + /// + QString exportServerCertificate(const QString& fileName) const; + + /// + /// \brief Loads the server identity without requiring client certificates. + /// \param configuration Receives TLS 1.2 or later with client verification disabled. + /// \return A failure description, or an empty string on success. + /// + QString configuration(QSslConfiguration& configuration) const; + +private: + QString identityPath() const; + QString _root; +}; + +#endif diff --git a/src/tests/test_connectiondetails.cpp b/src/tests/test_connectiondetails.cpp index 2d11409c..829fda60 100644 --- a/src/tests/test_connectiondetails.cpp +++ b/src/tests/test_connectiondetails.cpp @@ -99,6 +99,8 @@ class TestConnectionDetails : public QObject Q_OBJECT private slots: + void persistsTlsParameters(); + void readsLegacyTcpStream(); void normalizesTcpParameters(); void normalizesSerialParameters(); void streamsTcpParameters(); @@ -116,6 +118,43 @@ private slots: void validatesClientInfo(); }; +/// +/// \brief Preserves TLS credentials across project and preferences serialization. +/// +void TestConnectionDetails::persistsTlsParameters() +{ + ConnectionDetails expected; + expected.TcpParams.TlsEnabled = true; + expected.SerialParams = serialParams(); + const auto streamed = fromDataStream(toDataStream(expected)); + QCOMPARE(streamed, expected); + QCOMPARE(streamed.SerialParams, expected.SerialParams); + QCOMPARE(fromXml(toXml(expected)), expected); + QTemporaryDir directory; + QSettings settings(directory.filePath("tls.ini"), QSettings::IniFormat); + settings << expected; + ConnectionDetails actual; + settings >> actual; + QCOMPARE(actual, expected); + actual.TcpParams.TlsEnabled = false; + QVERIFY(!(actual == expected)); +} + +/// +/// \brief Leaves subsequent serial parameters intact when reading older streams. +/// +void TestConnectionDetails::readsLegacyTcpStream() +{ + QByteArray bytes; + QDataStream output(&bytes, QIODevice::WriteOnly); + output << ConnectionType::Tcp << quint16(1502) << QString("127.0.0.1") << serialParams(); + const auto actual = fromDataStream(bytes); + QCOMPARE(actual.TcpParams.ServicePort, quint16(1502)); + QVERIFY(!actual.TcpParams.TlsEnabled); + QCOMPARE(actual.SerialParams, serialParams()); + QCOMPARE(fromXml(" ").TlsEnabled, false); +} + void TestConnectionDetails::normalizesTcpParameters() { TcpConnectionParams params; diff --git a/src/tests/test_modbustls.cpp b/src/tests/test_modbustls.cpp new file mode 100644 index 00000000..6856736b --- /dev/null +++ b/src/tests/test_modbustls.cpp @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file test_modbustls.cpp +/// \brief Integration tests for Modbus TCP with server-authenticated TLS. +/// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "modbustcpserver.h" +#include "connectiondetails.h" +#include "pki/tlscertificatestore.h" + +class TestModbusTls : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void exchangesEncryptedRequests(); + void acceptsAnyTlsClient_data(); + void acceptsAnyTlsClient(); + void rejectsPlaintextClient(); + void rejectsUntrustedServer(); + void rejectsMissingCredentials(); + void closesIncompleteHandshakes(); + +private: + bool openssl(const QStringList& arguments); + bool createIdentity(const QString& name, const QString& usage); + bool expireCertificate(const QString& name); + quint16 startServer(ModbusTcpServer& server); + void configureClient(QSslSocket& socket, const QString& identity); + QTemporaryDir _directory; + QString _openssl; + ConnectionDetails _connection; +}; + +/// +/// \brief Runs certificate provisioning without requiring manual test setup. +/// +bool TestModbusTls::openssl(const QStringList& arguments) +{ + QProcess process; + process.setWorkingDirectory(_directory.path()); + process.start(_openssl, arguments); + return process.waitForFinished(10000) && process.exitCode() == 0; +} + +/// +/// \brief Creates a CA signed identity with the requested extended key usage. +/// +bool TestModbusTls::createIdentity(const QString& name, const QString& usage) +{ + QFile extensions(_directory.filePath(name + ".ext")); + if (!extensions.open(QIODevice::WriteOnly)) + return false; + extensions.write(("basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=" + + usage + "\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n").toUtf8()); + extensions.close(); + return openssl({"req", "-new", "-newkey", "rsa:2048", "-nodes", "-keyout", name + ".key", + "-out", name + ".csr", "-subj", "/CN=" + name}) + && openssl({"x509", "-req", "-in", name + ".csr", "-CA", "ca.pem", "-CAkey", "ca.key", + "-CAcreateserial", "-out", name + ".pem", "-days", "2", "-extfile", name + ".ext"}); +} + +/// +/// \brief Creates an expired fixture without depending on CLI support for negative validity periods. +/// +bool TestModbusTls::expireCertificate(const QString& name) +{ + QFile file(_directory.filePath(name + ".pem")); + QFile ca(_directory.filePath("ca.key")); + if (!file.open(QIODevice::ReadOnly) || !ca.open(QIODevice::ReadOnly)) + return false; + const QByteArray bytes = file.readAll(); + const QByteArray keyBytes = ca.readAll(); + file.close(); + const std::unique_ptr input(BIO_new_mem_buf(bytes.constData(), bytes.size()), BIO_free); + const std::unique_ptr keyInput(BIO_new_mem_buf(keyBytes.constData(), keyBytes.size()), BIO_free); + const std::unique_ptr certificate(PEM_read_bio_X509(input.get(), nullptr, nullptr, nullptr), X509_free); + const std::unique_ptr key(PEM_read_bio_PrivateKey(keyInput.get(), nullptr, nullptr, nullptr), EVP_PKEY_free); + const std::unique_ptr output(BIO_new(BIO_s_mem()), BIO_free); + if (!certificate || !key || !output + || !X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -2 * 86400) + || !X509_gmtime_adj(X509_getm_notAfter(certificate.get()), -86400) + || X509_sign(certificate.get(), key.get(), EVP_sha256()) <= 0 + || PEM_write_bio_X509(output.get(), certificate.get()) != 1 + || !file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + char* data = nullptr; + const long size = BIO_get_mem_data(output.get(), &data); + return file.write(data, size) == size; +} + +/// +/// \brief Provisions temporary certificates, skipping unavailable TLS dependencies. +/// +void TestModbusTls::initTestCase() +{ + if (!QSslSocket::supportsSsl()) + QSKIP("Qt TLS backend is unavailable"); + _openssl = QStandardPaths::findExecutable("openssl"); + if (_openssl.isEmpty()) + QSKIP("openssl is unavailable for test certificate provisioning"); + QVERIFY(_directory.isValid()); + QVERIFY(openssl({"req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", + "-out", "ca.pem", "-days", "2", "-subj", "/CN=Modbus Test CA", + "-addext", "basicConstraints=critical,CA:TRUE"})); + QVERIFY(createIdentity("server", "serverAuth")); + QVERIFY(createIdentity("client", "clientAuth")); + QVERIFY(createIdentity("expired", "clientAuth")); + QVERIFY(expireCertificate("expired")); + QVERIFY(openssl({"req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "rogue.key", + "-out", "rogue.pem", "-days", "2", "-subj", "/CN=Untrusted Client"})); + _connection.TcpParams.TlsEnabled = true; + TlsCertificateStore store(_directory.filePath("pki")); + QVERIFY(store.generateServerCertificate("Modbus test server", {"localhost", "127.0.0.1"}, 2).isEmpty()); +} + +/// +/// \brief Starts a TLS transport with a holding register map on a free local port. +/// +quint16 TestModbusTls::startServer(ModbusTcpServer& server) +{ + QTcpServer probe; + if (!probe.listen(QHostAddress::LocalHost, 0)) + return 0; + const quint16 port = probe.serverPort(); + probe.close(); + server.setProperty("ConnectionDetails", QVariant::fromValue(_connection)); + server.setConnectionParameter(QModbusDevice::NetworkAddressParameter, "127.0.0.1"); + server.setConnectionParameter(QModbusDevice::NetworkPortParameter, port); + ModbusDataUnitMap map; + map.addUnitMap(QUuid::createUuid(), QModbusDataUnit::HoldingRegisters, 0, 8); + server.addServerAddress(1); + server.setMap(map, 1); + return server.connectDevice() ? port : 0; +} + +/// +/// \brief Configures server verification and an optional client identity. +/// +void TestModbusTls::configureClient(QSslSocket& socket, const QString& identity) +{ + auto configuration = QSslConfiguration::defaultConfiguration(); + configuration.setProtocol(QSsl::TlsV1_2OrLater); + configuration.setCaCertificates({TlsCertificateStore(_directory.filePath("pki")).serverCertificate()}); + configuration.setPeerVerifyMode(QSslSocket::VerifyPeer); + if (!identity.isEmpty()) { + configuration.setLocalCertificateChain(QSslCertificate::fromPath(_directory.filePath(identity + ".pem"))); + QFile key(_directory.filePath(identity + ".key")); + QVERIFY(key.open(QIODevice::ReadOnly)); + configuration.setPrivateKey(QSslKey(key.readAll(), QSsl::Rsa)); + } + socket.setSslConfiguration(configuration); + socket.setPeerVerifyName("localhost"); +} + +/// +/// \brief Checks encrypted Modbus requests without a client certificate. +/// +void TestModbusTls::exchangesEncryptedRequests() +{ + ModbusTcpServer server(nullptr, _directory.filePath("pki")); + const auto port = startServer(server); + QVERIFY2(port, qPrintable(server.errorString(0))); + QSslSocket socket; + configureClient(socket, {}); + socket.connectToHostEncrypted("127.0.0.1", port); + QTRY_VERIFY(socket.isEncrypted()); + QTRY_COMPARE(server.connectedClientCount(), 1); + QSignalSpy requests(&server, &ModbusServer::modbusRequest); + const QByteArray request = QByteArray::fromHex("123400000006010300000002"); + QCOMPARE(socket.write(request.left(3)), qint64(3)); + QTest::qWait(20); + QCOMPARE(requests.count(), 0); + socket.write(request.mid(3) + request); + QTRY_COMPARE(requests.count(), 2); + QTRY_VERIFY(socket.bytesAvailable() >= 26); + const QByteArray response = QByteArray::fromHex("12340000000701030400000000"); + QCOMPARE(socket.readAll(), response + response); + socket.disconnectFromHost(); + QTRY_COMPARE(server.connectedClientCount(), 0); + server.disconnectDevice(); + QVERIFY(startServer(server)); + server.disconnectDevice(); +} + +/// +/// \brief Covers clients without an identity and clients with arbitrary certificate configurations. +/// +void TestModbusTls::acceptsAnyTlsClient_data() +{ + QTest::addColumn("identity"); + QTest::newRow("no-certificate") << QString(); + QTest::newRow("ca-signed") << QString("client"); + QTest::newRow("self-signed") << QString("rogue"); + QTest::newRow("expired") << QString("expired"); + QTest::newRow("server-purpose") << QString("server"); +} + +/// +/// \brief Exchanges Modbus data with every TLS client without importing any trust anchors. +/// +void TestModbusTls::acceptsAnyTlsClient() +{ + QFETCH(QString, identity); + ModbusTcpServer server(nullptr, _directory.filePath("pki")); + const auto port = startServer(server); + QVERIFY(port); + QSignalSpy errors(&server, &ModbusServer::errorOccurred); + QSslSocket socket; + configureClient(socket, identity); + socket.connectToHostEncrypted("127.0.0.1", port); + QTRY_VERIFY(socket.isEncrypted()); + QTRY_COMPARE(server.connectedClientCount(), 1); + socket.write(QByteArray::fromHex("123400000006010300000002")); + QTRY_VERIFY(socket.bytesAvailable() >= 13); + QCOMPARE(socket.readAll(), QByteArray::fromHex("12340000000701030400000000")); + QVERIFY(errors.isEmpty()); +} + +/// +/// \brief Rejects plaintext connections before any Modbus data is processed. +/// +void TestModbusTls::rejectsPlaintextClient() +{ + ModbusTcpServer server(nullptr, _directory.filePath("pki")); + const auto port = startServer(server); + QVERIFY(port); + QSignalSpy requests(&server, &ModbusServer::modbusRequest); + QSignalSpy connected(&server, &ModbusTcpServer::modbusClientConnected); + QSignalSpy errors(&server, &ModbusServer::errorOccurred); + QTcpSocket socket; + socket.connectToHost("127.0.0.1", port); + socket.write(QByteArray::fromHex("123400000006010300000002")); + QTRY_VERIFY(!errors.isEmpty()); + QTRY_COMPARE(socket.state(), QAbstractSocket::UnconnectedState); + QCOMPARE(requests.count(), 0); + QCOMPARE(connected.count(), 0); + QCOMPARE(server.state(), QModbusDevice::ConnectedState); +} + +/// +/// \brief Verifies that clients reject a server signed by an untrusted CA. +/// +void TestModbusTls::rejectsUntrustedServer() +{ + ModbusTcpServer server(nullptr, _directory.filePath("pki")); + const auto port = startServer(server); + QVERIFY(port); + QSignalSpy requests(&server, &ModbusServer::modbusRequest); + QSslSocket socket; + configureClient(socket, "client"); + auto configuration = socket.sslConfiguration(); + configuration.setCaCertificates(QSslCertificate::fromPath(_directory.filePath("rogue.pem"))); + socket.setSslConfiguration(configuration); + QSignalSpy errors(&socket, &QSslSocket::errorOccurred); + socket.connectToHostEncrypted("127.0.0.1", port); + QTRY_VERIFY(!errors.isEmpty()); + QTRY_COMPARE(socket.state(), QAbstractSocket::UnconnectedState); + QVERIFY(!socket.isEncrypted()); + QCOMPARE(requests.count(), 0); + QTRY_COMPARE(server.connectedClientCount(), 0); +} + +/// +/// \brief Fails closed for unreadable or invalid credential files. +/// +void TestModbusTls::rejectsMissingCredentials() +{ + const QString root = _directory.filePath("invalid-pki"); + ModbusTcpServer missing(nullptr, root); + missing.setProperty("ConnectionDetails", QVariant::fromValue(_connection)); + QVERIFY(!missing.connectDevice()); + QVERIFY(!missing.errorString(0).isEmpty()); + QVERIFY(QDir().mkpath(root + "/own")); + QFile malformed(root + "/own/identity.pem"); + QVERIFY(malformed.open(QIODevice::WriteOnly)); + malformed.write("not a PEM credential"); + malformed.close(); + ModbusTcpServer invalid(nullptr, root); + invalid.setProperty("ConnectionDetails", QVariant::fromValue(_connection)); + QVERIFY(!invalid.connectDevice()); + QVERIFY(!invalid.errorString(0).isEmpty()); + QCOMPARE(invalid.state(), QModbusDevice::UnconnectedState); +} + +/// +/// \brief Aborts clients that have connected without completing a handshake. +/// +void TestModbusTls::closesIncompleteHandshakes() +{ + ModbusTcpServer server(nullptr, _directory.filePath("pki")); + const auto port = startServer(server); + QVERIFY(port); + QTcpSocket socket; + socket.connectToHost(QHostAddress::LocalHost, port); + QTRY_COMPARE(socket.state(), QAbstractSocket::ConnectedState); + QTest::qWait(20); + QCOMPARE(server.connectedClientCount(), 0); + server.disconnectDevice(); + QTRY_COMPARE(socket.state(), QAbstractSocket::UnconnectedState); +} + +QTEST_GUILESS_MAIN(TestModbusTls) +#include "test_modbustls.moc" diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp new file mode 100644 index 00000000..9cafac71 --- /dev/null +++ b/src/tests/test_tlscertificatestore.cpp @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file test_tlscertificatestore.cpp +/// \brief Tests generated TLS server identities and atomic persistence. +/// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "pki/certificategenerator.h" +#include "pki/tlscertificatestore.h" + +class TestTlsCertificateStore : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void generatesServerIdentity(); + void rejectsInvalidGeneration_data(); + void rejectsInvalidGeneration(); + void persistsAndExportsIdentity(); + void ignoresLegacyTrustFiles(); + void preservesIdentityOnFailure(); + void rejectsMissingAndCorruptIdentity(); +}; + +/// +/// \brief Skips store integration when Qt cannot load a TLS backend. +/// +void TestTlsCertificateStore::initTestCase() +{ + if (!QSslSocket::supportsSsl()) + QSKIP("Qt TLS backend is unavailable"); +} + +/// +/// \brief Checks signatures, key matching, certificate purpose and host identities. +/// +void TestTlsCertificateStore::generatesServerIdentity() +{ + QString error; + const auto generated = CertificateGenerator::generate("Modbus simulator", {"localhost", "127.0.0.1", "::1"}, 2, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QSslCertificate certificate(generated.Certificate); + QVERIFY(!certificate.isNull()); + QVERIFY(certificate.isSelfSigned()); + QCOMPARE(certificate.subjectInfo(QSslCertificate::CommonName), QStringList{"Modbus simulator"}); + QCOMPARE(certificate.subjectAlternativeNames().values(QSsl::DnsEntry), QStringList{"localhost"}); + QVERIFY(certificate.subjectAlternativeNames().values(QSsl::IpAddressEntry).contains("127.0.0.1")); + QVERIFY(certificate.subjectAlternativeNames().values(QSsl::IpAddressEntry).contains("0:0:0:0:0:0:0:1") + || certificate.subjectAlternativeNames().values(QSsl::IpAddressEntry).contains("::1")); + QVERIFY(certificate.expiryDate() > QDateTime::currentDateTimeUtc().addDays(1)); + QVERIFY(certificate.expiryDate() <= QDateTime::currentDateTimeUtc().addDays(2)); + const std::unique_ptr certBio( + BIO_new_mem_buf(generated.Certificate.constData(), generated.Certificate.size()), BIO_free); + const std::unique_ptr keyBio( + BIO_new_mem_buf(generated.PrivateKey.constData(), generated.PrivateKey.size()), BIO_free); + const std::unique_ptr parsed(PEM_read_bio_X509(certBio.get(), nullptr, nullptr, nullptr), X509_free); + const std::unique_ptr key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr), EVP_PKEY_free); + QVERIFY(parsed && key); + QCOMPARE(EVP_PKEY_bits(key.get()), 2048); + QCOMPARE(X509_check_private_key(parsed.get(), key.get()), 1); + QCOMPARE(X509_verify(parsed.get(), key.get()), 1); + QCOMPARE(X509_check_ca(parsed.get()), 0); + QCOMPARE(X509_check_purpose(parsed.get(), X509_PURPOSE_SSL_SERVER, 0), 1); + QCOMPARE(X509_check_purpose(parsed.get(), X509_PURPOSE_SSL_CLIENT, 0), 0); + const auto second = CertificateGenerator::generate("Modbus simulator", {"localhost"}, 2, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QVERIFY(QSslCertificate(second.Certificate).serialNumber() != certificate.serialNumber()); +} + +/// +/// \brief Covers invalid subjects, host lists, SAN injection and validity periods. +/// +void TestTlsCertificateStore::rejectsInvalidGeneration_data() +{ + QTest::addColumn("name"); + QTest::addColumn("hosts"); + QTest::addColumn("days"); + QTest::newRow("empty-name") << QString() << QStringList{"localhost"} << 2; + QTest::newRow("long-name") << QString(65, 'x') << QStringList{"localhost"} << 2; + QTest::newRow("empty-hosts") << QString("server") << QStringList() << 2; + QTest::newRow("empty-host") << QString("server") << QStringList{QString()} << 2; + QTest::newRow("invalid-host") << QString("server") << QStringList{"invalid host"} << 2; + QTest::newRow("san-injection") << QString("server") << QStringList{"localhost,URI:injected"} << 2; + QTest::newRow("zero-days") << QString("server") << QStringList{"localhost"} << 0; + QTest::newRow("too-many-days") << QString("server") << QStringList{"localhost"} << 3651; +} + +/// +/// \brief Leaves no key material when certificate inputs are rejected. +/// +void TestTlsCertificateStore::rejectsInvalidGeneration() +{ + QFETCH(QString, name); + QFETCH(QStringList, hosts); + QFETCH(int, days); + QString error; + const auto generated = CertificateGenerator::generate(name, hosts, days, &error); + QVERIFY(!error.isEmpty()); + QVERIFY(generated.Certificate.isEmpty()); + QVERIFY(generated.PrivateKey.isEmpty()); +} + +/// +/// \brief Persists the identity with private permissions and exports no key material. +/// +void TestTlsCertificateStore::persistsAndExportsIdentity() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.filePath("pki")); + QVERIFY(store.generateServerCertificate("server", {"localhost"}, 2).isEmpty()); + TlsCertificateStore reopened(store.root()); + QCOMPARE(reopened.serverCertificate(), store.serverCertificate()); + QSslConfiguration configuration; + QVERIFY(reopened.configuration(configuration).isEmpty()); + QVERIFY(!configuration.privateKey().isNull()); + QCOMPARE(configuration.localCertificate(), store.serverCertificate()); + QCOMPARE(configuration.protocol(), QSsl::TlsV1_2OrLater); + QCOMPARE(configuration.peerVerifyMode(), QSslSocket::VerifyNone); + QVERIFY(configuration.caCertificates().isEmpty()); + const QString exported = directory.filePath("public.pem"); + QVERIFY(store.exportServerCertificate(exported).isEmpty()); + QFile file(exported); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QByteArray bytes = file.readAll(); + QCOMPARE(bytes, store.serverCertificate().toPem()); + QVERIFY(!bytes.contains("PRIVATE KEY")); + QVERIFY(!store.exportServerCertificate(store.root() + "/own/identity.pem").isEmpty()); +#ifndef Q_OS_WIN + const auto permissions = QFile::permissions(store.root() + "/own/identity.pem"); + QVERIFY(!(permissions & (QFileDevice::ReadGroup | QFileDevice::WriteGroup | QFileDevice::ReadOther | QFileDevice::WriteOther))); +#endif +} + +/// +/// \brief Ignores legacy trust files when configuring server-only TLS authentication. +/// +void TestTlsCertificateStore::ignoresLegacyTrustFiles() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.path()); + QVERIFY(store.generateServerCertificate("server", {"localhost"}, 2).isEmpty()); + QVERIFY(QDir().mkpath(directory.filePath("trusted"))); + QFile file(directory.filePath("trusted/legacy.pem")); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("invalid legacy certificate"); + file.close(); + QSslConfiguration configuration; + QVERIFY(store.configuration(configuration).isEmpty()); + QCOMPARE(configuration.peerVerifyMode(), QSslSocket::VerifyNone); + QVERIFY(configuration.caCertificates().isEmpty()); + QVERIFY(file.exists()); +} + +/// +/// \brief Keeps the installed identity when validation or persistence fails. +/// +void TestTlsCertificateStore::preservesIdentityOnFailure() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.filePath("pki")); + QVERIFY(store.generateServerCertificate("original", {"localhost"}, 2).isEmpty()); + const auto certificate = store.serverCertificate(); + QVERIFY(!store.generateServerCertificate("", {"localhost"}, 2).isEmpty()); + QCOMPARE(store.serverCertificate(), certificate); + const QString blockedPath = directory.filePath("file"); + QFile file(blockedPath); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + TlsCertificateStore blocked(blockedPath); + QVERIFY(!blocked.generateServerCertificate("server", {"localhost"}, 2).isEmpty()); + QVERIFY(blocked.serverCertificate().isNull()); +} + +/// +/// \brief Reports missing and corrupted identities before starting a TLS listener. +/// +void TestTlsCertificateStore::rejectsMissingAndCorruptIdentity() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.path()); + QSslConfiguration configuration; + QVERIFY(!store.configuration(configuration).isEmpty()); + QVERIFY(!store.exportServerCertificate(directory.filePath("export.pem")).isEmpty()); + QVERIFY(QDir().mkpath(directory.filePath("own"))); + QFile file(directory.filePath("own/identity.pem")); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("invalid identity"); + file.close(); + QVERIFY(!store.configuration(configuration).isEmpty()); +} + +QTEST_GUILESS_MAIN(TestTlsCertificateStore) +#include "test_tlscertificatestore.moc" diff --git a/src/tests/test_tlssettings.cpp b/src/tests/test_tlssettings.cpp new file mode 100644 index 00000000..4d3077c5 --- /dev/null +++ b/src/tests/test_tlssettings.cpp @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file test_tlssettings.cpp +/// \brief Tests the TCP security settings dialog. +/// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "elidinglineedit.h" +#include "tlscertificateswidget.h" +#include "dialogmodbusdefinitions.h" +#include +#include +#include +#include +#include +#include "application.h" +#include "macappstyle.h" +#include "qlementineappstyle.h" +#include +#include "dialogselectserviceport.h" +#include "numericlineedit.h" + +class TestTlsSettings : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void savesTlsSettings(); + void preservesCustomPorts(); + void excludesTlsFromRtuTcp(); + void cancelPreservesSettings(); + void generatesServerCertificateInSettings(); + void offersCertificatesTab(); + void keepsCertificateControlsAccessible_data(); + void keepsCertificateControlsAccessible(); + void keepsSettingsSpacingCompact_data(); + void keepsSettingsSpacingCompact(); + void usesCompactDefaultHeight(); + void elidesHostNamesWithoutChangingValue(); +private: + QTranslator _translator; +}; + +/// +/// \brief Exercises the translated interface with the production style when available. +/// +void TestTlsSettings::initTestCase() +{ + QVERIFY(_translator.load(QFINDTESTDATA("../app/translations/omodsim_ru.qm"))); + qApp->installTranslator(&_translator); +#if defined(HAVE_QLEMENTINE_APP_STYLE) +#ifdef Q_OS_MACOS + qApp->setStyle(new MacAppStyle()); +#else + qApp->setStyle(new QlementineAppStyle()); +#endif +#endif +} + +/// +/// \brief Saves TLS paths and selects the standard secure port. +/// +void TestTlsSettings::savesTlsSettings() +{ + TcpConnectionParams params; + DialogSelectServicePort dialog(params); + dialog.findChild("tlsGroup")->setChecked(true); + QCOMPARE(dialog.findChild("lineEditPort")->value(), 802); + dialog.accept(); + QVERIFY(params.TlsEnabled); + QCOMPARE(params.ServicePort, quint16(802)); + + DialogSelectServicePort reopened(params); + QVERIFY(reopened.findChild("tlsGroup")->isChecked()); + QVERIFY(!dialog.findChild("certificatePath")); + reopened.findChild("tlsGroup")->setChecked(false); + QCOMPARE(reopened.findChild("lineEditPort")->value(), 502); +} + +/// +/// \brief Keeps a user supplied port when TLS is toggled. +/// +void TestTlsSettings::preservesCustomPorts() +{ + TcpConnectionParams params; + params.ServicePort = 1502; + DialogSelectServicePort dialog(params); + dialog.findChild("tlsGroup")->setChecked(true); + QCOMPARE(dialog.findChild("lineEditPort")->value(), 1502); + dialog.findChild("tlsGroup")->setChecked(false); + QCOMPARE(dialog.findChild("lineEditPort")->value(), 1502); +} + +/// +/// \brief Hides TLS options for the RTU over TCP transport. +/// +void TestTlsSettings::excludesTlsFromRtuTcp() +{ + TcpConnectionParams params; + params.TlsEnabled = true; + DialogSelectServicePort dialog(params, nullptr, false); + QVERIFY(dialog.findChild("tlsGroup")->isHidden()); + dialog.accept(); + QVERIFY(!params.TlsEnabled); +} + +/// +/// \brief Leaves connection settings unchanged when editing is cancelled. +/// +void TestTlsSettings::cancelPreservesSettings() +{ + TcpConnectionParams params; + const auto original = params; + DialogSelectServicePort dialog(params); + dialog.findChild("tlsGroup")->setChecked(true); + dialog.reject(); + QCOMPARE(params, original); +} + +/// +/// \brief Generates the shared identity from the certificate settings page. +/// +void TestTlsSettings::generatesServerCertificateInSettings() +{ + if (!QSslSocket::supportsSsl()) + QSKIP("Qt TLS backend is unavailable"); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + TlsCertificatesWidget widget(nullptr, directory.path()); + auto* exportButton = widget.findChild("exportButton"); + QVERIFY(!exportButton->isEnabled()); + widget.findChild("commonName")->setText("Modbus test server"); + widget.findChild("hostNames")->setText("localhost, 127.0.0.1, ::1, server-with-a-long-name.example.test"); + widget.findChild("generateButton")->click(); + const auto certificate = TlsCertificateStore(directory.path()).serverCertificate(); + QVERIFY(!certificate.isNull()); + QCOMPARE(certificate.subjectInfo(QSslCertificate::CommonName), QStringList{"Modbus test server"}); + QVERIFY(certificate.subjectAlternativeNames().values(QSsl::DnsEntry).contains("server-with-a-long-name.example.test")); + QVERIFY(exportButton->isEnabled()); + QVERIFY(widget.findChild("serverDetails")->toPlainText().contains("Modbus test server")); + QVERIFY(!widget.findChild("serverDetails")->toPlainText().contains("PRIVATE KEY")); +} + +/// +/// \brief Exposes the shared certificate page through Modbus Definitions. +/// +void TestTlsSettings::offersCertificatesTab() +{ + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + auto* tabs = dialog.findChild("tabWidget"); + auto* page = dialog.findChild(); + QVERIFY(page); + auto* tab = dialog.findChild("tabCertificates"); + QVERIFY(tabs->indexOf(tab) >= 0); + QVERIFY(tab->isAncestorOf(page)); + QVERIFY(page->findChild("serverGroup")->title().isEmpty()); + QVERIFY(!page->findChild("changesHint")); + QVERIFY(!page->findChild("trustedCertificates")); + QVERIFY(!page->findChild("importButton")); + QVERIFY(!page->findChild("removeButton")); +} + +/// +/// \brief Covers normal and enlarged text on a height constrained display. +/// +void TestTlsSettings::keepsCertificateControlsAccessible_data() +{ + QTest::addColumn("fontSize"); + QTest::newRow("normal-text") << 12; + QTest::newRow("large-text") << 18; +} + +/// +/// \brief Keeps dialog actions visible while every certificate control can be scrolled into view. +/// +void TestTlsSettings::keepsCertificateControlsAccessible() +{ + QFETCH(int, fontSize); + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + QFont font = dialog.font(); + font.setPointSize(fontSize); + dialog.setFont(font); + dialog.setMaximumHeight(420); + auto* tabs = dialog.findChild("tabWidget"); + tabs->setCurrentWidget(dialog.findChild("tabCertificates")); + dialog.show(); + QCoreApplication::processEvents(); + QVERIFY(dialog.height() <= 420); + QVERIFY(dialog.height() <= dialog.screen()->availableGeometry().height() - 64); + auto* buttons = dialog.findChild("buttonBox"); + for (auto standard : {QDialogButtonBox::Ok, QDialogButtonBox::Cancel, QDialogButtonBox::Apply}) { + auto* button = buttons->button(standard); + QVERIFY(dialog.rect().contains(QRect(button->mapTo(&dialog, QPoint()), button->size()))); + } + auto* scroll = dialog.findChild("certificateScrollArea"); + QVERIFY(scroll); + for (auto name : {"generateButton", "exportButton"}) { + auto* button = dialog.findChild(name); + scroll->ensureWidgetVisible(button); + QCoreApplication::processEvents(); + const QRect visible(button->mapTo(scroll->viewport(), QPoint()), button->size()); + QVERIFY2(scroll->viewport()->rect().contains(visible), name); + } +} + +/// +/// \brief Checks spacing with normal and enlarged translated text. +/// +void TestTlsSettings::keepsSettingsSpacingCompact_data() +{ + QTest::addColumn("fontSize"); + QTest::newRow("normal-text") << 12; + QTest::newRow("large-text") << 18; +} + +/// +/// \brief Prevents nested page margins and spare dialog height from separating related controls. +/// +void TestTlsSettings::keepsSettingsSpacingCompact() +{ + QFETCH(int, fontSize); + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + QFont font = dialog.font(); + font.setPointSize(fontSize); + dialog.setFont(font); + auto* tabs = dialog.findChild("tabWidget"); + auto* certificateTab = dialog.findChild("tabCertificates"); + tabs->setCurrentWidget(certificateTab); + dialog.show(); + QCoreApplication::processEvents(); + auto* group = dialog.findChild("serverGroup"); + const QRect groupRect(group->mapTo(certificateTab, QPoint()), group->size()); + QVERIFY(groupRect.left() <= 12); + QVERIFY(certificateTab->width() - groupRect.right() - 1 <= 12); + auto* details = dialog.findChild("serverDetails"); + const QRect detailsRect(details->mapTo(group, QPoint()), details->size()); + QVERIFY(detailsRect.left() <= 20); + QVERIFY(group->width() - detailsRect.right() - 1 <= 20); + auto* validity = dialog.findChild("validDays"); + auto* generate = dialog.findChild("generateButton"); + const int actionGap = generate->mapTo(group, QPoint()).y() + - validity->mapTo(group, QPoint(0, validity->height())).y(); + QVERIFY(actionGap <= 24); + + tabs->setCurrentWidget(dialog.findChild("tabErrSimulations")); + QCoreApplication::processEvents(); + QCheckBox* previous = nullptr; + for (auto name : {"checkBoxErrIncorrentId", "checkBoxIllegalFunc", "checkBoxBusy", "checkBoxCrcErr", + "checkBoxDelay", "checkBoxRandomDelay", "checkBoxNoResponse"}) { + auto* checkbox = dialog.findChild(name); + if (previous) { + const int gap = checkbox->y() - previous->y() - previous->height(); + QVERIFY2(gap >= 0 && gap <= 16, name); + } + previous = checkbox; + } +} + +/// +/// \brief Avoids reserved blank space while keeping the tab controls and dialog actions visible. +/// +void TestTlsSettings::usesCompactDefaultHeight() +{ + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + dialog.show(); + QCoreApplication::processEvents(); + QVERIFY(dialog.height() < 480); + auto* certificateTab = dialog.findChild("tabCertificates"); + dialog.findChild("tabWidget")->setCurrentWidget(certificateTab); + QCoreApplication::processEvents(); + auto* scroll = dialog.findChild("certificateScrollArea"); + QCOMPARE(scroll->verticalScrollBar()->maximum(), 0); + auto* tabs = dialog.findChild("tabWidget"); + auto* errorTab = dialog.findChild("tabErrSimulations"); + tabs->setCurrentWidget(errorTab); + QCoreApplication::processEvents(); + auto* lastOption = dialog.findChild("checkBoxNoResponse"); + QVERIFY(errorTab->rect().contains(lastOption->geometry())); + auto* buttons = dialog.findChild("buttonBox"); + QVERIFY(dialog.rect().contains(QRect(buttons->mapTo(&dialog, QPoint()), buttons->size()))); +} + +/// +/// \brief Abbreviates long host lists while preserving their full value and normal editing history. +/// +void TestTlsSettings::elidesHostNamesWithoutChangingValue() +{ + QTemporaryDir directory; + TlsCertificatesWidget widget(nullptr, directory.path()); + widget.resize(460, 340); + auto* hosts = widget.findChild("hostNames"); + QVERIFY(hosts); + const QString full = "controller-with-a-long-hostname.example.test, localhost, 127.0.0.1, ::1"; + hosts->setText(full); + widget.show(); + auto* name = widget.findChild("commonName"); + name->setFocus(); + QCoreApplication::processEvents(); + QVERIFY(!hosts->hasFocus()); + QVERIFY(hosts->visibleText().endsWith(QChar(0x2026))); + QVERIFY(hosts->visibleText().startsWith("controller")); + QCOMPARE(hosts->text(), full); + hosts->setFocus(); + QTRY_VERIFY(hosts->hasFocus()); + QCOMPARE(hosts->visibleText(), full); + hosts->setCursorPosition(full.size()); + QTest::keyClick(hosts, Qt::Key_X); + QCOMPARE(hosts->text(), full + "x"); + name->setFocus(); + QCoreApplication::processEvents(); + hosts->undo(); + QCOMPARE(hosts->text(), full); + hosts->setText("localhost"); + QCOMPARE(hosts->visibleText(), QString("localhost")); +} + +/// +/// \brief Runs the layout tests with the application theme service. +/// +int main(int argc, char** argv) +{ + Application app(argc, argv); + TestTlsSettings test; + return QTest::qExec(&test, argc, argv); +} +#include "test_tlssettings.moc" diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 70010e8a..d24cb393 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -1,4 +1,5 @@ set(UI_FILES + widgets/tlscertificateswidget.ui widgets/applogoutput.ui widgets/bitpatterncontrol.ui widgets/consoleoutput.ui @@ -32,6 +33,10 @@ set(UI_FILES ) add_library(omodsim_ui STATIC + widgets/elidinglineedit.cpp + widgets/elidinglineedit.h + widgets/tlscertificateswidget.cpp + widgets/tlscertificateswidget.h projectformmetadata.cpp projectformmetadata.h ansimenu.cpp diff --git a/src/ui/dialogs/dialogmodbusdefinitions.cpp b/src/ui/dialogs/dialogmodbusdefinitions.cpp index b66b432e..57d696e5 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.cpp +++ b/src/ui/dialogs/dialogmodbusdefinitions.cpp @@ -7,6 +7,7 @@ /// #include +#include #include "dialogmodbusdefinitions.h" #include "ui_dialogmodbusdefinitions.h" @@ -95,6 +96,16 @@ DialogModbusDefinitions::~DialogModbusDefinitions() delete ui; } +/// +/// \brief Keeps the settings dialog inside the available screen area. +/// +QSize DialogModbusDefinitions::sizeHint() const +{ + const QSize natural = QFixedSizeDialog::sizeHint(); + const QScreen* target = parentWidget() ? parentWidget()->screen() : screen(); + return target ? natural.boundedTo(target->availableGeometry().size() - QSize(32, 64)) : natural; +} + /// /// \brief DialogModbusDefinitions::changeEvent /// diff --git a/src/ui/dialogs/dialogmodbusdefinitions.h b/src/ui/dialogs/dialogmodbusdefinitions.h index 41dd115f..ae95ba22 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.h +++ b/src/ui/dialogs/dialogmodbusdefinitions.h @@ -28,6 +28,11 @@ class DialogModbusDefinitions : public QFixedSizeDialog explicit DialogModbusDefinitions(ModbusMultiServer& srv, QWidget *parent = nullptr); ~DialogModbusDefinitions(); + /// + /// \brief Keeps the settings dialog inside the available screen area. + /// + QSize sizeHint() const override; + void accept() override; protected: diff --git a/src/ui/dialogs/dialogmodbusdefinitions.ui b/src/ui/dialogs/dialogmodbusdefinitions.ui index 88e45730..3d5484b6 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.ui +++ b/src/ui/dialogs/dialogmodbusdefinitions.ui @@ -6,8 +6,8 @@ 0 0 - 380 - 313 + 650 + 600 @@ -70,11 +70,38 @@ + + Certificates + + 0 + 0 + 0 + 0 + + + + true + QFrame::NoFrame + Qt::ScrollBarAlwaysOff + + + 0 + 0 + 0 + 0 + + + + + + + Error Simulations + 8 @@ -269,6 +296,12 @@ + + + Qt::Vertical + 00 + + @@ -286,6 +319,7 @@ + TlsCertificatesWidgetQWidget
tlscertificateswidget.h
1
AddressSpaceComboBox QComboBox diff --git a/src/ui/dialogs/dialogselectserviceport.cpp b/src/ui/dialogs/dialogselectserviceport.cpp index 7a1d8c96..9d5ff19f 100644 --- a/src/ui/dialogs/dialogselectserviceport.cpp +++ b/src/ui/dialogs/dialogselectserviceport.cpp @@ -11,17 +11,21 @@ #include "ui_dialogselectserviceport.h" /// -/// \brief DialogSelectServicePort::DialogSelectServicePort -/// \param port -/// \param parent +/// \brief Edits TCP endpoint settings and the TLS selection. +/// \param params Connection parameters updated on acceptance. +/// \param parent Parent widget. +/// \param allowTls Whether the transport supports TLS. /// -DialogSelectServicePort::DialogSelectServicePort(TcpConnectionParams& params, QWidget *parent) +DialogSelectServicePort::DialogSelectServicePort(TcpConnectionParams& params, QWidget *parent, bool allowTls) : QFixedSizeDialog(parent) , ui(new Ui::DialogSelectServicePort) ,_params(params) { ui->setupUi(this); - ui->lineEditPort->setInputRange(0, 65535); + ui->tlsGroup->setVisible(allowTls); + ui->tlsGroup->setChecked(allowTls && params.TlsEnabled); + connect(ui->tlsGroup, &QCheckBox::toggled, this, &DialogSelectServicePort::updateTlsPort); + ui->lineEditPort->setInputRange(1, 65535); ui->lineEditPort->setValue(params.ServicePort); ui->comboBoxIp->addItem("0.0.0.0"); @@ -68,12 +72,22 @@ void DialogSelectServicePort::changeEvent(QEvent* event) } /// -/// \brief DialogSelectServicePort::accept +/// \brief Applies the endpoint settings and the TLS selection. /// void DialogSelectServicePort::accept() { + _params.TlsEnabled = ui->tlsGroup->isChecked(); _params.ServicePort = ui->lineEditPort->value(); _params.IPAddress = ui->comboBoxIp->currentText(); QFixedSizeDialog::accept(); } +/// +/// \brief Uses the standard port when switching between plain TCP and TLS. +/// +void DialogSelectServicePort::updateTlsPort(bool enabled) +{ + if (ui->lineEditPort->value() == (enabled ? 502 : 802)) + ui->lineEditPort->setValue(enabled ? 802 : 502); +} + diff --git a/src/ui/dialogs/dialogselectserviceport.h b/src/ui/dialogs/dialogselectserviceport.h index f20e6f31..d2e5cea2 100644 --- a/src/ui/dialogs/dialogselectserviceport.h +++ b/src/ui/dialogs/dialogselectserviceport.h @@ -21,15 +21,28 @@ class DialogSelectServicePort : public QFixedSizeDialog Q_OBJECT public: - explicit DialogSelectServicePort(TcpConnectionParams& params, QWidget *parent = nullptr); + /// + /// \brief Edits TCP endpoint settings and the TLS selection. + /// \param params Connection parameters updated on acceptance. + /// \param parent Parent widget. + /// \param allowTls Whether the transport supports TLS. + /// + explicit DialogSelectServicePort(TcpConnectionParams& params, QWidget *parent = nullptr, bool allowTls = true); + /// + /// \brief DialogSelectServicePort::~DialogSelectServicePort + /// ~DialogSelectServicePort(); + /// + /// \brief Applies the endpoint settings and the TLS selection. + /// void accept() override; protected: void changeEvent(QEvent* event) override; private: + void updateTlsPort(bool enabled); Ui::DialogSelectServicePort *ui; TcpConnectionParams& _params; }; diff --git a/src/ui/dialogs/dialogselectserviceport.ui b/src/ui/dialogs/dialogselectserviceport.ui index e78884c8..9430fe47 100644 --- a/src/ui/dialogs/dialogselectserviceport.ui +++ b/src/ui/dialogs/dialogselectserviceport.ui @@ -58,6 +58,12 @@
+ + + Modbus TCP Security (TLS) + Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. + + diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index c40cbe33..4f826972 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -1179,7 +1179,7 @@ void MainWindow::on_connectAction(ConnectionDetails& cd) case ConnectionType::Tcp: case ConnectionType::RtuTcp: { - DialogSelectServicePort dlg(cd.TcpParams, this); + DialogSelectServicePort dlg(cd.TcpParams, this, cd.Type == ConnectionType::Tcp); if(dlg.exec() == QDialog::Accepted) { _mbMultiServer.connectDevice(cd); diff --git a/src/ui/widgets/elidinglineedit.cpp b/src/ui/widgets/elidinglineedit.cpp new file mode 100644 index 00000000..b558360d --- /dev/null +++ b/src/ui/widgets/elidinglineedit.cpp @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file elidinglineedit.cpp +/// \brief Draws abbreviated previews while preserving normal line editing. +/// + +#include "elidinglineedit.h" +#include +#include + +/// +/// \brief Creates an editor with an abbreviated preview when unfocused. +/// \param parent Parent widget. +/// +ElidingLineEdit::ElidingLineEdit(QWidget* parent) : QLineEdit(parent) +{ +} + +/// +/// \brief Returns the preview without changing the editable value or undo history. +/// +QString ElidingLineEdit::visibleText() const +{ + return hasFocus() ? displayText() + : fontMetrics().elidedText(displayText(), Qt::ElideRight, textContentsRect().width()); +} + +/// +/// \brief Respects the active style's text padding and explicit line edit margins. +/// +QRect ElidingLineEdit::textContentsRect() const +{ + QStyleOptionFrame option; + initStyleOption(&option); + return style()->subElementRect(QStyle::SE_LineEditContents, &option, this) + .marginsRemoved(textMargins()).adjusted(2, 0, -2, 0); +} + +/// +/// \brief Uses native editing while focused and paints an abbreviated preview otherwise. +/// +void ElidingLineEdit::paintEvent(QPaintEvent* event) +{ + if (hasFocus() || echoMode() != QLineEdit::Normal || visibleText() == displayText()) { + QLineEdit::paintEvent(event); + return; + } + QStyleOptionFrame option; + initStyleOption(&option); + QStylePainter painter(this); + painter.drawPrimitive(QStyle::PE_PanelLineEdit, option); + const QRect content = textContentsRect(); + painter.setClipRect(content); + style()->drawItemText(&painter, content, + QStyle::visualAlignment(layoutDirection(), alignment()) | Qt::AlignVCenter, + palette(), isEnabled(), visibleText(), QPalette::Text); +} diff --git a/src/ui/widgets/elidinglineedit.h b/src/ui/widgets/elidinglineedit.h new file mode 100644 index 00000000..8b816ad9 --- /dev/null +++ b/src/ui/widgets/elidinglineedit.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file elidinglineedit.h +/// \brief Declares a line edit that abbreviates long values outside editing. +/// + +#ifndef ELIDINGLINEEDIT_H +#define ELIDINGLINEEDIT_H + +#include + +class ElidingLineEdit : public QLineEdit +{ + Q_OBJECT +public: + /// + /// \brief Creates an editor with an abbreviated preview when unfocused. + /// \param parent Parent widget. + /// + explicit ElidingLineEdit(QWidget* parent = nullptr); + + /// + /// \brief Returns the preview without changing the editable value or undo history. + /// + QString visibleText() const; + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + QRect textContentsRect() const; +}; + +#endif diff --git a/src/ui/widgets/tlscertificateswidget.cpp b/src/ui/widgets/tlscertificateswidget.cpp new file mode 100644 index 00000000..421df2ef --- /dev/null +++ b/src/ui/widgets/tlscertificateswidget.cpp @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file tlscertificateswidget.cpp +/// \brief Implements TLS server identity generation and export. +/// + +#include "tlscertificateswidget.h" +#include "ui_tlscertificateswidget.h" +#include +#include +#include +#include +#include +#include + +/// +/// \brief Opens the shared certificate store in the Modbus settings page. +/// \param parent Parent widget. +/// \param certificateStoreRoot PKI directory; empty uses the application store. +/// +TlsCertificatesWidget::TlsCertificatesWidget(QWidget* parent, const QString& certificateStoreRoot) + : QWidget(parent), ui(new Ui::TlsCertificatesWidget), _store(certificateStoreRoot) +{ + ui->setupUi(this); + ui->commonName->setText("OpenModSim@" + QHostInfo::localHostName()); + QStringList hosts = {QHostInfo::localHostName(), "localhost", "127.0.0.1", "::1"}; + for (const auto& address : QNetworkInterface::allAddresses()) { + if (!address.isNull() && !address.isMulticast() && address.scopeId().isEmpty()) + hosts.append(address.toString()); + } + hosts.removeDuplicates(); + ui->hostNames->setText(hosts.join(", ")); + connect(ui->generateButton, &QPushButton::clicked, this, &TlsCertificatesWidget::generateCertificate); + connect(ui->exportButton, &QPushButton::clicked, this, &TlsCertificatesWidget::exportCertificate); + refresh(); +} + +/// +/// \brief Releases the certificate settings controls. +/// +TlsCertificatesWidget::~TlsCertificatesWidget() +{ + delete ui; +} + +/// +/// \brief Retranslates certificate details when the application language changes. +/// +void TlsCertificatesWidget::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) { + ui->retranslateUi(this); + refresh(); + } + QWidget::changeEvent(event); +} + +/// +/// \brief Reloads certificate metadata without displaying the private key. +/// +void TlsCertificatesWidget::refresh() +{ + const auto certificate = _store.serverCertificate(); + if (certificate.isNull()) { + ui->serverDetails->setPlainText(tr("No server certificate. Generate one to enable TLS.")); + } else { + const auto alternatives = certificate.subjectAlternativeNames(); + QStringList hosts = alternatives.values(QSsl::DnsEntry); + hosts.append(alternatives.values(QSsl::IpAddressEntry)); + ui->serverDetails->setPlainText(tr("Subject: %1\nHost names / IP addresses: %2\nValid until: %3\nSHA-256: %4") + .arg(certificate.subjectInfo(QSslCertificate::CommonName).join(", "), hosts.join(", "), + certificate.expiryDate().toLocalTime().toString(Qt::ISODate), + QString::fromLatin1(certificate.digest(QCryptographicHash::Sha256).toHex(':')))); + } + ui->exportButton->setEnabled(!certificate.isNull()); + +} + +/// +/// \brief Generates an identity after confirming replacement of an existing certificate. +/// +void TlsCertificatesWidget::generateCertificate() +{ + if (!_store.serverCertificate().isNull() + && QMessageBox::question(this, tr("Replace server certificate"), + tr("Clients will need to trust the new certificate. Replace the existing server certificate?")) != QMessageBox::Yes) + return; + const auto hosts = ui->hostNames->text().split(QRegularExpression("[,;\\s]+"), Qt::SkipEmptyParts); + const QString error = _store.generateServerCertificate(ui->commonName->text().trimmed(), hosts, ui->validDays->value()); + reportError(error); + if (error.isEmpty()) + refresh(); +} + +/// +/// \brief Exports the public certificate for installing trust in Modbus clients. +/// +void TlsCertificatesWidget::exportCertificate() +{ + const QString path = QFileDialog::getSaveFileName(this, tr("Export server certificate"), "server.pem", tr("PEM certificate (*.pem)")); + if (!path.isEmpty()) + reportError(_store.exportServerCertificate(path)); +} + +/// +/// \brief Shows failures without treating successful operations as notifications. +/// +void TlsCertificatesWidget::reportError(const QString& error) +{ + if (!error.isEmpty()) + QMessageBox::warning(this, tr("Certificates"), error); +} diff --git a/src/ui/widgets/tlscertificateswidget.h b/src/ui/widgets/tlscertificateswidget.h new file mode 100644 index 00000000..eeeaeaee --- /dev/null +++ b/src/ui/widgets/tlscertificateswidget.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file tlscertificateswidget.h +/// \brief Declares the Modbus certificate settings page. +/// + +#ifndef TLSCERTIFICATESWIDGET_H +#define TLSCERTIFICATESWIDGET_H + +#include +#include "pki/tlscertificatestore.h" + +namespace Ui { class TlsCertificatesWidget; } + +class TlsCertificatesWidget : public QWidget +{ + Q_OBJECT +public: + /// + /// \brief Opens the shared certificate store in the Modbus settings page. + /// \param parent Parent widget. + /// \param certificateStoreRoot PKI directory; empty uses the application store. + /// + explicit TlsCertificatesWidget(QWidget* parent = nullptr, const QString& certificateStoreRoot = {}); + + /// + /// \brief Releases the certificate settings controls. + /// + ~TlsCertificatesWidget() override; + +protected: + void changeEvent(QEvent* event) override; + +private: + void refresh(); + void generateCertificate(); + void exportCertificate(); + void reportError(const QString& error); + + Ui::TlsCertificatesWidget* ui; + TlsCertificateStore _store; +}; + +#endif diff --git a/src/ui/widgets/tlscertificateswidget.ui b/src/ui/widgets/tlscertificateswidget.ui new file mode 100644 index 00000000..a76cead3 --- /dev/null +++ b/src/ui/widgets/tlscertificateswidget.ui @@ -0,0 +1,41 @@ + + + TlsCertificatesWidget + + + 0 + 0 + 0 + 0 + + + + 8 + 8 + 8 + 8 + true72 + + Server name + 64 + Host names / IP addresses + DNS names and IP addresses clients use to reach this server, separated by commas. + Valid for (days) + 136503650 + + + Qt::Horizontal200 + Generate…false + Export certificate…false + + + + Qt::Vertical00 + + + + ElidingLineEditQLineEdit
elidinglineedit.h
+
+ + +
diff --git a/third_party/licenses/OpenSSL.txt b/third_party/licenses/OpenSSL.txt new file mode 100644 index 00000000..49cc83d2 --- /dev/null +++ b/third_party/licenses/OpenSSL.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/tools/build_openssl.sh b/tools/build_openssl.sh new file mode 100644 index 00000000..058580d8 --- /dev/null +++ b/tools/build_openssl.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +source_root="$1" +install_prefix="$2" +if [ -e "$install_prefix/lib/libssl.so.3" ] && [ -e "$install_prefix/lib/libcrypto.so.3" ]; then + exit 0 +fi +if [ ! -f "$source_root/Configure" ]; then + echo "No OpenSSL Configure script in $source_root" >&2 + exit 1 +fi +( + cd "$source_root" + ./Configure --prefix="$install_prefix" --libdir=lib --openssldir=/etc/ssl shared no-tests + make -j"$(nproc)" + make install_sw +) diff --git a/tools/install_openssl.ps1 b/tools/install_openssl.ps1 new file mode 100644 index 00000000..c8ef0229 --- /dev/null +++ b/tools/install_openssl.ps1 @@ -0,0 +1,40 @@ +param( + [ValidateSet('x64', 'x86')][string]$Architecture = 'x64', + [string]$QtToolsRoot = 'C:\Qt' +) + +$ErrorActionPreference = 'Stop' +$opensslRoot = Join-Path $QtToolsRoot "Tools\OpenSSLv3\Win_$Architecture" +if (-not (Test-Path (Join-Path $opensslRoot 'include\openssl\opensslv.h'))) { + if ($Architecture -eq 'x64') { + python -m aqt install-tool windows desktop tools_opensslv3_x64 --outputdir $QtToolsRoot | Out-Host + if ($LASTEXITCODE -ne 0) { throw 'Cannot install the Qt OpenSSL 3 package.' } + } else { + $sourceRoot = Join-Path $QtToolsRoot 'Tools\OpenSSLv3\src' + if (-not (Test-Path (Join-Path $sourceRoot 'Configure'))) { + python -m aqt install-tool windows desktop tools_opensslv3_src --outputdir $QtToolsRoot | Out-Host + if ($LASTEXITCODE -ne 0) { throw 'Cannot install the OpenSSL 3 sources.' } + } + if (-not (Get-Command perl -ErrorAction SilentlyContinue)) { + throw 'Building OpenSSL 3 for x86 requires Perl in PATH (for example Strawberry Perl).' + } + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { throw 'Cannot find the Visual Studio C++ build tools.' } + $vcvars = Join-Path $vsPath 'VC\Auxiliary\Build\vcvars32.bat' + $buildRoot = Join-Path $sourceRoot 'build-modsim-x86' + New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null + Push-Location $buildRoot + try { + cmd /c "call `"$vcvars`" && perl ..\Configure VC-WIN32 no-asm shared no-tests --prefix=`"$opensslRoot`" && nmake && nmake install_sw" | Out-Host + if ($LASTEXITCODE -ne 0) { throw 'Cannot build OpenSSL 3 for x86.' } + } finally { + Pop-Location + } + } +} +if (-not (Test-Path (Join-Path $opensslRoot 'include\openssl\opensslv.h')) -or + -not (Get-ChildItem (Join-Path $opensslRoot 'bin') -Filter 'libcrypto-*.dll' -ErrorAction SilentlyContinue)) { + throw "OpenSSL 3 installation is incomplete: $opensslRoot" +} +Write-Output $opensslRoot From 910b05757cf382e1027b9e412ec9a6caf0d70290 Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 07:08:07 +0700 Subject: [PATCH 2/7] feat: Generate TLS certificates on startup and refine settings UI Refs #139 --- docs/modbus-tls.md | 11 +- src/app/main.cpp | 5 + src/app/res/icons/fallback/calendar.svg | 1 + src/app/res/icons/fallback/certificate.svg | 1 + src/app/res/icons/fallback/hosts.svg | 1 + src/app/res/icons/fallback/person.svg | 1 + src/app/resources.qrc | 4 + src/app/translations/omodsim_it.ts | 19 +--- src/app/translations/omodsim_ru.qm | Bin 83860 -> 83109 bytes src/app/translations/omodsim_ru.ts | 22 +--- src/app/translations/omodsim_zh_CN.ts | 19 +--- src/app/translations/omodsim_zh_TW.ts | 19 +--- src/cmake/tests.cmake | 1 + src/core/pki/tlscertificatestore.cpp | 61 +++++++++- src/core/pki/tlscertificatestore.h | 21 ++++ src/tests/test_modbustls.cpp | 2 +- src/tests/test_tlscertificatestore.cpp | 100 ++++++++++++++++- src/tests/test_tlssettings.cpp | 123 ++++++++++++++++++--- src/ui/CMakeLists.txt | 2 + src/ui/dialogs/dialogmodbusdefinitions.cpp | 19 +++- src/ui/dialogs/dialogmodbusdefinitions.ui | 3 +- src/ui/dialogs/dialogselectserviceport.ui | 1 - src/ui/style/themedicons.cpp | 5 + src/ui/widgets/settingstabwidget.cpp | 95 ++++++++++++++++ src/ui/widgets/settingstabwidget.h | 30 +++++ src/ui/widgets/tlscertificateswidget.cpp | 62 ++++++----- src/ui/widgets/tlscertificateswidget.ui | 20 +--- 27 files changed, 517 insertions(+), 131 deletions(-) create mode 100644 src/app/res/icons/fallback/calendar.svg create mode 100644 src/app/res/icons/fallback/certificate.svg create mode 100644 src/app/res/icons/fallback/hosts.svg create mode 100644 src/app/res/icons/fallback/person.svg create mode 100644 src/ui/widgets/settingstabwidget.cpp create mode 100644 src/ui/widgets/settingstabwidget.h diff --git a/docs/modbus-tls.md b/docs/modbus-tls.md index 87158b9e..8ee88ed9 100644 --- a/docs/modbus-tls.md +++ b/docs/modbus-tls.md @@ -1,7 +1,8 @@ # Modbus TCP over TLS -Open **Modbus Definitions → Certificates** before starting a TLS server. -This page manages the application-wide certificate store, as in OpenUaSim. +A self-signed server certificate is created automatically on first startup. +Open **Modbus Definitions → Certificates** to review its parameters, regenerate it, +or export the public certificate for clients. ## Generate a server certificate @@ -35,10 +36,14 @@ changes. Existing connections retain the configuration they were started with. ## Storage and build requirements The store is in `pki` under Qt's application data directory. The server certificate -and key are saved together in `own/identity.pem` using atomic replacement. On Unix, +and key are saved together in `own/OpenModSim@.pem` using atomic replacement. On Unix, the identity file is readable and writable only by its owner. Projects store the TLS selection, while the server identity belongs to the local application installation. Any existing files in the old `trusted` directory are left untouched and are not used. +Only `own/OpenModSim@*.pem` identities are loaded, including identities copied from another host. +At application startup, a missing identity is generated with the local hostname, +local DNS/IP addresses, and a validity period of 3650 days. Existing identities are +preserved; invalid files require explicit replacement in the certificate settings. OpenSSL 3 `libcrypto` is a build and runtime dependency for certificate generation. The build scripts install it through Homebrew on macOS, Qt's OpenSSL package on diff --git a/src/app/main.cpp b/src/app/main.cpp index 87969668..7bdaa41b 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -7,6 +7,7 @@ /// #include +#include "pki/tlscertificatestore.h" #include #include #include "application.h" @@ -139,8 +140,12 @@ int main(int argc, char *argv[]) const QString cfg = parser.projectFile(); const bool noSession = parser.isSet(CmdLineParser::_no_session); + const QString certificateError = TlsCertificateStore().ensureServerCertificate(); + MainWindow w(profile, !noSession, cfg); w.show(); + if (!certificateError.isEmpty()) + QMessageBox::warning(&w, APP_PRODUCT_NAME, certificateError); return a.exec(); } diff --git a/src/app/res/icons/fallback/calendar.svg b/src/app/res/icons/fallback/calendar.svg new file mode 100644 index 00000000..d9983f96 --- /dev/null +++ b/src/app/res/icons/fallback/calendar.svg @@ -0,0 +1 @@ + diff --git a/src/app/res/icons/fallback/certificate.svg b/src/app/res/icons/fallback/certificate.svg new file mode 100644 index 00000000..63a07e85 --- /dev/null +++ b/src/app/res/icons/fallback/certificate.svg @@ -0,0 +1 @@ + diff --git a/src/app/res/icons/fallback/hosts.svg b/src/app/res/icons/fallback/hosts.svg new file mode 100644 index 00000000..5893f9fe --- /dev/null +++ b/src/app/res/icons/fallback/hosts.svg @@ -0,0 +1 @@ + diff --git a/src/app/res/icons/fallback/person.svg b/src/app/res/icons/fallback/person.svg new file mode 100644 index 00000000..34399315 --- /dev/null +++ b/src/app/res/icons/fallback/person.svg @@ -0,0 +1 @@ + diff --git a/src/app/resources.qrc b/src/app/resources.qrc index f8cc7b8b..9e7e7492 100644 --- a/src/app/resources.qrc +++ b/src/app/resources.qrc @@ -1,5 +1,9 @@ + res/icons/fallback/certificate.svg + res/icons/fallback/person.svg + res/icons/fallback/hosts.svg + res/icons/fallback/calendar.svg res/license.txt res/about.json res/omodsim-mac.png diff --git a/src/app/translations/omodsim_it.ts b/src/app/translations/omodsim_it.ts index 5cd7e15b..9bdcf341 100644 --- a/src/app/translations/omodsim_it.ts +++ b/src/app/translations/omodsim_it.ts @@ -1252,10 +1252,6 @@ Vuoi aprire la pagina di download? Modbus TCP Security (TLS)
- - Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. - -
DialogSetupSerialPort @@ -3478,17 +3474,6 @@ Verifica attentamente il risultato dell'unione. Export certificate… - - No server certificate. Generate one to enable TLS. - - - - Subject: %1 -Host names / IP addresses: %2 -Valid until: %3 -SHA-256: %4 - - Replace server certificate @@ -3509,6 +3494,10 @@ SHA-256: %4 Certificates + + Certificate parameters + + TrafficLogWindow diff --git a/src/app/translations/omodsim_ru.qm b/src/app/translations/omodsim_ru.qm index 799836c05e5f3cded0533d53dd0f68ba2c19dcc5..d53122a279db2eb3c2f33986a9b4959671c8aa0f 100644 GIT binary patch delta 5396 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 diff --git a/src/app/translations/omodsim_ru.ts b/src/app/translations/omodsim_ru.ts index ef7cb323..c4fd2801 100644 --- a/src/app/translations/omodsim_ru.ts +++ b/src/app/translations/omodsim_ru.ts @@ -1252,10 +1252,6 @@ Open the download page? Modbus TCP Security (TLS) Modbus TCP Security (TLS) - - Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. - Использует сертификат сервера из вкладки «Сертификаты» настроек Modbus. Сертификаты клиентов не требуются. - DialogSetupSerialPort @@ -3478,20 +3474,6 @@ Please verify the merge result carefully. Export certificate… Экспорт сертификата… - - No server certificate. Generate one to enable TLS. - Сертификат сервера отсутствует. Создайте его для включения TLS. - - - Subject: %1 -Host names / IP addresses: %2 -Valid until: %3 -SHA-256: %4 - Владелец: %1 -DNS-имена / IP-адреса: %2 -Действителен до: %3 -SHA-256: %4 - Replace server certificate Замена сертификата сервера @@ -3512,6 +3494,10 @@ SHA-256: %4 Certificates Сертификаты + + Certificate parameters + Параметры сертификата + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_CN.ts b/src/app/translations/omodsim_zh_CN.ts index 07d2af2c..68f704f8 100644 --- a/src/app/translations/omodsim_zh_CN.ts +++ b/src/app/translations/omodsim_zh_CN.ts @@ -1252,10 +1252,6 @@ Open the download page? Modbus TCP Security (TLS) - - Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. - - DialogSetupSerialPort @@ -3479,17 +3475,6 @@ Please verify the merge result carefully. Export certificate… - - No server certificate. Generate one to enable TLS. - - - - Subject: %1 -Host names / IP addresses: %2 -Valid until: %3 -SHA-256: %4 - - Replace server certificate @@ -3510,6 +3495,10 @@ SHA-256: %4 Certificates + + Certificate parameters + + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_TW.ts b/src/app/translations/omodsim_zh_TW.ts index cf8ab97f..11a8cde1 100644 --- a/src/app/translations/omodsim_zh_TW.ts +++ b/src/app/translations/omodsim_zh_TW.ts @@ -1252,10 +1252,6 @@ Open the download page? Modbus TCP Security (TLS) - - Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. - - DialogSetupSerialPort @@ -3479,17 +3475,6 @@ Please verify the merge result carefully. Export certificate… - - No server certificate. Generate one to enable TLS. - - - - Subject: %1 -Host names / IP addresses: %2 -Valid until: %3 -SHA-256: %4 - - Replace server certificate @@ -3510,6 +3495,10 @@ SHA-256: %4 Certificates + + Certificate parameters + + TrafficLogWindow diff --git a/src/cmake/tests.cmake b/src/cmake/tests.cmake index 30466546..fd638152 100644 --- a/src/cmake/tests.cmake +++ b/src/cmake/tests.cmake @@ -62,6 +62,7 @@ function(omodsim_configure_tests) omodsim_add_test(omodsim_tests_tlscertificatestore test_tlscertificatestore.cpp) target_link_libraries(omodsim_tests_tlscertificatestore PRIVATE OpenSSL::Crypto) omodsim_add_test(omodsim_tests_tlssettings test_tlssettings.cpp omodsim_ui) + target_sources(omodsim_tests_tlssettings PRIVATE "${PROJECT_SOURCE_DIR}/app/resources.qrc") omodsim_add_test(omodsim_tests_modbustls test_modbustls.cpp) target_link_libraries(omodsim_tests_modbustls PRIVATE OpenSSL::Crypto) omodsim_add_test(omodsim_tests_modbustransports test_modbustransports.cpp) diff --git a/src/core/pki/tlscertificatestore.cpp b/src/core/pki/tlscertificatestore.cpp index 08a17257..41415be3 100644 --- a/src/core/pki/tlscertificatestore.cpp +++ b/src/core/pki/tlscertificatestore.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -72,11 +74,64 @@ QString TlsCertificateStore::root() const } /// -/// \brief Keeps the certificate and key in one file so identity replacement is atomic. +/// \brief Returns the generated identity filename using the local hostname. +/// +QString TlsCertificateStore::generatedFileName() +{ + return QStringLiteral("OpenModSim@%1.pem").arg(QHostInfo::localHostName()); +} + +/// +/// \brief Returns the default certificate subject for this computer. +/// +QString TlsCertificateStore::defaultServerName() +{ + return (QStringLiteral("OpenModSim@") + QHostInfo::localHostName()).left(64); +} + +/// +/// \brief Returns local DNS names and IP addresses for the default certificate. +/// +QStringList TlsCertificateStore::defaultHostNames() +{ + QStringList hosts = {QHostInfo::localHostName(), "localhost", "127.0.0.1", "::1"}; + for (const auto& address : QNetworkInterface::allAddresses()) { + if (!address.isNull() && !address.isMulticast() && address.scopeId().isEmpty()) + hosts.append(address.toString()); + } + hosts.removeDuplicates(); + return hosts; +} + +/// +/// \brief Generates the default identity only when no named identity file exists. +/// \return A generation or validation error, or an empty string on success. +/// +QString TlsCertificateStore::ensureServerCertificate() +{ + const auto files = QDir(_root + "/own").entryList({QStringLiteral("OpenModSim@*.pem")}, QDir::Files, QDir::Name); + if (!files.isEmpty()) { + QSslConfiguration configuration; + return this->configuration(configuration); + } + return generateServerCertificate(defaultServerName(), defaultHostNames(), 3650); +} + +/// +/// \brief Locates the identity by application name, including identities from another host. /// QString TlsCertificateStore::identityPath() const { - return _root + "/own/identity.pem"; + const QDir own(_root + "/own"); + const QString target = own.filePath(generatedFileName()); + if (QFileInfo::exists(target)) + return target; + const auto files = own.entryInfoList({QStringLiteral("OpenModSim@*.pem")}, QDir::Files, QDir::Name); + for (const auto& file : files) { + if (!QSslCertificate::fromPath(file.absoluteFilePath()).isEmpty()) + return file.absoluteFilePath(); + } + return target; } /// @@ -101,7 +156,7 @@ QString TlsCertificateStore::generateServerCertificate(const QString& commonName const auto generated = CertificateGenerator::generate(commonName, hosts, validDays, &error); if (generated.Certificate.isEmpty() || generated.PrivateKey.isEmpty()) return error; - return saveFile(identityPath(), generated.Certificate + generated.PrivateKey, true); + return saveFile(_root + "/own/" + generatedFileName(), generated.Certificate + generated.PrivateKey, true); } /// diff --git a/src/core/pki/tlscertificatestore.h b/src/core/pki/tlscertificatestore.h index 07fd12f9..fbb50495 100644 --- a/src/core/pki/tlscertificatestore.h +++ b/src/core/pki/tlscertificatestore.h @@ -31,6 +31,27 @@ class TlsCertificateStore /// QString root() const; + /// + /// \brief Returns the generated identity filename using the local hostname. + /// + static QString generatedFileName(); + + /// + /// \brief Returns the default certificate subject for this computer. + /// + static QString defaultServerName(); + + /// + /// \brief Returns local DNS names and IP addresses for the default certificate. + /// + static QStringList defaultHostNames(); + + /// + /// \brief Generates the default identity only when no named identity file exists. + /// \return A generation or validation error, or an empty string on success. + /// + QString ensureServerCertificate(); + /// /// \brief Reads the public certificate of the installed server identity. /// diff --git a/src/tests/test_modbustls.cpp b/src/tests/test_modbustls.cpp index 6856736b..b41e54e4 100644 --- a/src/tests/test_modbustls.cpp +++ b/src/tests/test_modbustls.cpp @@ -285,7 +285,7 @@ void TestModbusTls::rejectsMissingCredentials() QVERIFY(!missing.connectDevice()); QVERIFY(!missing.errorString(0).isEmpty()); QVERIFY(QDir().mkpath(root + "/own")); - QFile malformed(root + "/own/identity.pem"); + QFile malformed(root + "/own/" + TlsCertificateStore::generatedFileName()); QVERIFY(malformed.open(QIODevice::WriteOnly)); malformed.write("not a PEM credential"); malformed.close(); diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp index 9cafac71..10ee10da 100644 --- a/src/tests/test_tlscertificatestore.cpp +++ b/src/tests/test_tlscertificatestore.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -25,10 +26,13 @@ class TestTlsCertificateStore : public QObject private slots: void initTestCase(); void generatesServerIdentity(); + void createsDefaultIdentityOnce(); + void reportsStartupGenerationFailures(); void rejectsInvalidGeneration_data(); void rejectsInvalidGeneration(); void persistsAndExportsIdentity(); void ignoresLegacyTrustFiles(); + void loadsNamedIdentitiesOnly(); void preservesIdentityOnFailure(); void rejectsMissingAndCorruptIdentity(); }; @@ -42,6 +46,58 @@ void TestTlsCertificateStore::initTestCase() QSKIP("Qt TLS backend is unavailable"); } +/// +/// \brief Creates the first-run identity and preserves its bytes on later launches. +/// +void TestTlsCertificateStore::createsDefaultIdentityOnce() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.filePath("pki")); + QVERIFY2(store.ensureServerCertificate().isEmpty(), "First-run generation failed"); + const auto certificate = store.serverCertificate(); + QVERIFY(!certificate.isNull()); + QCOMPARE(certificate.subjectInfo(QSslCertificate::CommonName), QStringList{TlsCertificateStore::defaultServerName()}); + QVERIFY(certificate.subjectAlternativeNames().values(QSsl::DnsEntry).contains("localhost")); + QVERIFY(certificate.expiryDate() > QDateTime::currentDateTimeUtc().addDays(3649)); + const QString path = store.root() + "/own/" + TlsCertificateStore::generatedFileName(); + QFile identity(path); + QVERIFY(identity.open(QIODevice::ReadOnly)); + const QByteArray original = identity.readAll(); + identity.close(); + QVERIFY(TlsCertificateStore(store.root()).ensureServerCertificate().isEmpty()); + QVERIFY(identity.open(QIODevice::ReadOnly)); + QCOMPARE(identity.readAll(), original); + identity.close(); + QVERIFY(QFile::rename(path, store.root() + "/own/OpenModSim@previous-host.pem")); + QVERIFY(store.ensureServerCertificate().isEmpty()); + QCOMPARE(store.serverCertificate(), certificate); + QVERIFY(!QFileInfo::exists(path)); +} + +/// +/// \brief Reports unwritable stores and preserves corrupt named files for explicit replacement. +/// +void TestTlsCertificateStore::reportsStartupGenerationFailures() +{ + QTemporaryDir directory; + const QString root = directory.filePath("pki"); + TlsCertificateStore store(root); + QVERIFY(QDir().mkpath(root + "/own")); + const QString path = root + "/own/" + TlsCertificateStore::generatedFileName(); + QFile identity(path); + QVERIFY(identity.open(QIODevice::WriteOnly)); + identity.write("corrupt identity"); + identity.close(); + QVERIFY(!store.ensureServerCertificate().isEmpty()); + QVERIFY(identity.open(QIODevice::ReadOnly)); + QCOMPARE(identity.readAll(), QByteArray("corrupt identity")); + identity.close(); + QFile blocked(directory.filePath("blocked")); + QVERIFY(blocked.open(QIODevice::WriteOnly)); + blocked.close(); + QVERIFY(!TlsCertificateStore(blocked.fileName()).ensureServerCertificate().isEmpty()); +} + /// /// \brief Checks signatures, key matching, certificate purpose and host identities. /// @@ -119,6 +175,10 @@ void TestTlsCertificateStore::persistsAndExportsIdentity() QTemporaryDir directory; TlsCertificateStore store(directory.filePath("pki")); QVERIFY(store.generateServerCertificate("server", {"localhost"}, 2).isEmpty()); + const QString identity = store.root() + "/own/" + TlsCertificateStore::generatedFileName(); + QCOMPARE(QFileInfo(identity).fileName(), QString("OpenModSim@%1.pem").arg(QHostInfo::localHostName())); + QVERIFY(QFileInfo::exists(identity)); + QVERIFY(!QFileInfo::exists(store.root() + "/own/identity.pem")); TlsCertificateStore reopened(store.root()); QCOMPARE(reopened.serverCertificate(), store.serverCertificate()); QSslConfiguration configuration; @@ -135,13 +195,47 @@ void TestTlsCertificateStore::persistsAndExportsIdentity() const QByteArray bytes = file.readAll(); QCOMPARE(bytes, store.serverCertificate().toPem()); QVERIFY(!bytes.contains("PRIVATE KEY")); - QVERIFY(!store.exportServerCertificate(store.root() + "/own/identity.pem").isEmpty()); + QVERIFY(!store.exportServerCertificate(identity).isEmpty()); #ifndef Q_OS_WIN - const auto permissions = QFile::permissions(store.root() + "/own/identity.pem"); + const auto permissions = QFile::permissions(identity); QVERIFY(!(permissions & (QFileDevice::ReadGroup | QFileDevice::WriteGroup | QFileDevice::ReadOther | QFileDevice::WriteOther))); #endif } +/// +/// \brief Ignores the old generic filename and loads application identities after host renaming. +/// +void TestTlsCertificateStore::loadsNamedIdentitiesOnly() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.path()); + QVERIFY(store.generateServerCertificate("original", {"localhost"}, 2).isEmpty()); + const auto original = store.serverCertificate(); + const QString current = directory.filePath("own/" + TlsCertificateStore::generatedFileName()); + const QString legacy = directory.filePath("own/identity.pem"); + QVERIFY(QFile::rename(current, legacy)); + QSslConfiguration configuration; + QVERIFY(!store.configuration(configuration).isEmpty()); + QVERIFY(store.serverCertificate().isNull()); + const QString renamed = directory.filePath("own/OpenModSim@previous-host.pem"); + QVERIFY(QFile::rename(legacy, renamed)); + QVERIFY(store.configuration(configuration).isEmpty()); + QCOMPARE(configuration.localCertificate(), original); + QFile invalid(directory.filePath("own/OpenModSim@000-invalid.pem")); + QVERIFY(invalid.open(QIODevice::WriteOnly)); + invalid.write("invalid identity"); + invalid.close(); + QCOMPARE(store.serverCertificate(), original); + QVERIFY(store.generateServerCertificate("replacement", {"localhost"}, 2).isEmpty()); + QCOMPARE(store.serverCertificate().subjectInfo(QSslCertificate::CommonName), QStringList{"replacement"}); + QVERIFY(QFileInfo::exists(renamed)); + QFile corrupt(current); + QVERIFY(corrupt.open(QIODevice::WriteOnly | QIODevice::Truncate)); + corrupt.write("invalid current identity"); + corrupt.close(); + QVERIFY(!store.configuration(configuration).isEmpty()); +} + /// /// \brief Ignores legacy trust files when configuring server-only TLS authentication. /// @@ -193,7 +287,7 @@ void TestTlsCertificateStore::rejectsMissingAndCorruptIdentity() QVERIFY(!store.configuration(configuration).isEmpty()); QVERIFY(!store.exportServerCertificate(directory.filePath("export.pem")).isEmpty()); QVERIFY(QDir().mkpath(directory.filePath("own"))); - QFile file(directory.filePath("own/identity.pem")); + QFile file(directory.filePath("own/" + TlsCertificateStore::generatedFileName())); QVERIFY(file.open(QIODevice::WriteOnly)); file.write("invalid identity"); file.close(); diff --git a/src/tests/test_tlssettings.cpp b/src/tests/test_tlssettings.cpp index 4d3077c5..c599c9e5 100644 --- a/src/tests/test_tlssettings.cpp +++ b/src/tests/test_tlssettings.cpp @@ -8,19 +8,20 @@ #include #include -#include #include #include +#include #include #include #include #include #include -#include #include "elidinglineedit.h" #include "tlscertificateswidget.h" #include "dialogmodbusdefinitions.h" #include +#include +#include #include #include #include @@ -43,12 +44,16 @@ private slots: void cancelPreservesSettings(); void generatesServerCertificateInSettings(); void offersCertificatesTab(); + void loadsCurrentCertificateParameters(); void keepsCertificateControlsAccessible_data(); void keepsCertificateControlsAccessible(); void keepsSettingsSpacingCompact_data(); void keepsSettingsSpacingCompact(); void usesCompactDefaultHeight(); void elidesHostNamesWithoutChangingValue(); + void highlightsSelectedSettingsTab(); + void paintsMacSettingsPane_data(); + void paintsMacSettingsPane(); private: QTranslator _translator; }; @@ -76,6 +81,7 @@ void TestTlsSettings::savesTlsSettings() { TcpConnectionParams params; DialogSelectServicePort dialog(params); + QVERIFY(dialog.findChild("tlsGroup")->toolTip().isEmpty()); dialog.findChild("tlsGroup")->setChecked(true); QCOMPARE(dialog.findChild("lineEditPort")->value(), 802); dialog.accept(); @@ -141,6 +147,8 @@ void TestTlsSettings::generatesServerCertificateInSettings() TlsCertificatesWidget widget(nullptr, directory.path()); auto* exportButton = widget.findChild("exportButton"); QVERIFY(!exportButton->isEnabled()); + QVERIFY(!widget.findChild("serverGroup")); + QVERIFY(!widget.findChild("emptyMessage")); widget.findChild("commonName")->setText("Modbus test server"); widget.findChild("hostNames")->setText("localhost, 127.0.0.1, ::1, server-with-a-long-name.example.test"); widget.findChild("generateButton")->click(); @@ -149,8 +157,24 @@ void TestTlsSettings::generatesServerCertificateInSettings() QCOMPARE(certificate.subjectInfo(QSslCertificate::CommonName), QStringList{"Modbus test server"}); QVERIFY(certificate.subjectAlternativeNames().values(QSsl::DnsEntry).contains("server-with-a-long-name.example.test")); QVERIFY(exportButton->isEnabled()); - QVERIFY(widget.findChild("serverDetails")->toPlainText().contains("Modbus test server")); - QVERIFY(!widget.findChild("serverDetails")->toPlainText().contains("PRIVATE KEY")); +} + +/// +/// \brief Shows the installed certificate parameters without a separate summary header. +/// +void TestTlsSettings::loadsCurrentCertificateParameters() +{ + if (!QSslSocket::supportsSsl()) + QSKIP("Qt TLS backend is unavailable"); + QTemporaryDir directory; + TlsCertificateStore store(directory.path()); + QVERIFY(store.generateServerCertificate("Installed server", {"installed.example.test", "127.0.0.1"}, 2).isEmpty()); + TlsCertificatesWidget widget(nullptr, directory.path()); + QCOMPARE(widget.findChild("commonName")->text(), QString("Installed server")); + QVERIFY(widget.findChild("hostNames")->text().contains("installed.example.test")); + QCOMPARE(widget.findChild("validDays")->value(), 2); + QVERIFY(widget.findChild("exportButton")->isEnabled()); + QVERIFY(!widget.findChild("serverGroup")); } /// @@ -166,7 +190,11 @@ void TestTlsSettings::offersCertificatesTab() auto* tab = dialog.findChild("tabCertificates"); QVERIFY(tabs->indexOf(tab) >= 0); QVERIFY(tab->isAncestorOf(page)); - QVERIFY(page->findChild("serverGroup")->title().isEmpty()); + QVERIFY(!page->findChild("serverGroup")); + QVERIFY(!page->findChild("certificateIcon")); + QVERIFY(page->findChild("parametersTitle")); + for (int index = 0; index < tabs->count(); ++index) + QVERIFY(!tabs->tabIcon(index).isNull()); QVERIFY(!page->findChild("changesHint")); QVERIFY(!page->findChild("trustedCertificates")); QVERIFY(!page->findChild("importButton")); @@ -243,18 +271,14 @@ void TestTlsSettings::keepsSettingsSpacingCompact() tabs->setCurrentWidget(certificateTab); dialog.show(); QCoreApplication::processEvents(); - auto* group = dialog.findChild("serverGroup"); - const QRect groupRect(group->mapTo(certificateTab, QPoint()), group->size()); - QVERIFY(groupRect.left() <= 12); - QVERIFY(certificateTab->width() - groupRect.right() - 1 <= 12); - auto* details = dialog.findChild("serverDetails"); - const QRect detailsRect(details->mapTo(group, QPoint()), details->size()); - QVERIFY(detailsRect.left() <= 20); - QVERIFY(group->width() - detailsRect.right() - 1 <= 20); + auto* page = dialog.findChild(); + const QRect pageRect(page->mapTo(certificateTab, QPoint()), page->size()); + QVERIFY(pageRect.left() <= 12); + QVERIFY(certificateTab->width() - pageRect.right() - 1 <= 12); auto* validity = dialog.findChild("validDays"); auto* generate = dialog.findChild("generateButton"); - const int actionGap = generate->mapTo(group, QPoint()).y() - - validity->mapTo(group, QPoint(0, validity->height())).y(); + const int actionGap = generate->mapTo(certificateTab, QPoint()).y() + - validity->mapTo(certificateTab, QPoint(0, validity->height())).y(); QVERIFY(actionGap <= 24); tabs->setCurrentWidget(dialog.findChild("tabErrSimulations")); @@ -280,7 +304,7 @@ void TestTlsSettings::usesCompactDefaultHeight() DialogModbusDefinitions dialog(server); dialog.show(); QCoreApplication::processEvents(); - QVERIFY(dialog.height() < 480); + QVERIFY2(dialog.height() < 480, qPrintable(QString::number(dialog.height()))); auto* certificateTab = dialog.findChild("tabCertificates"); dialog.findChild("tabWidget")->setCurrentWidget(certificateTab); QCoreApplication::processEvents(); @@ -330,6 +354,73 @@ void TestTlsSettings::elidesHostNamesWithoutChangingValue() QCOMPARE(hosts->visibleText(), QString("localhost")); } +/// +/// \brief Keeps accent navigation selectable by keyboard and honors palette changes. +/// +void TestTlsSettings::highlightsSelectedSettingsTab() +{ + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + auto* tabs = dialog.findChild("tabWidget"); + auto* bar = tabs->tabBar(); + QPalette colors = bar->palette(); + colors.setColor(QPalette::Highlight, QColor("#2859a0")); + colors.setColor(QPalette::HighlightedText, Qt::white); + bar->setPalette(colors); + dialog.show(); + bar->setFocus(); + tabs->setCurrentIndex(0); + QTest::keyClick(bar, Qt::Key_Right); + QCOMPARE(tabs->currentIndex(), 1); + const QRect selected = bar->tabRect(1); + const QPoint background(selected.left() + 5, selected.center().y()); + const QImage image = bar->grab().toImage(); + const qreal scale = image.devicePixelRatio(); + QCOMPARE(image.pixelColor(qRound(background.x() * scale), qRound(background.y() * scale)), + colors.color(QPalette::Highlight)); +} + +/// +/// \brief Covers distinct macOS content surfaces in light and dark palettes. +/// +void TestTlsSettings::paintsMacSettingsPane_data() +{ + QTest::addColumn("windowColor"); + QTest::addColumn("paneColor"); + QTest::newRow("light") << QColor("#efefef") << QColor("#ffffff"); + QTest::newRow("dark") << QColor("#2c2c2e") << QColor("#1c1c1e"); +} + +/// +/// \brief Keeps the content background visible through each macOS settings page. +/// +void TestTlsSettings::paintsMacSettingsPane() +{ +#ifndef Q_OS_MAC + QSKIP("The settings pane background is specific to macOS"); +#else + QFETCH(QColor, windowColor); + QFETCH(QColor, paneColor); + ModbusMultiServer server; + DialogModbusDefinitions dialog(server); + auto* tabs = dialog.findChild("tabWidget"); + QPalette colors = dialog.palette(); + colors.setColor(QPalette::Window, windowColor); + colors.setColor(QPalette::Base, paneColor); + dialog.setPalette(colors); + dialog.show(); + for (int index = 0; index < tabs->count(); ++index) { + tabs->setCurrentIndex(index); + QCoreApplication::processEvents(); + auto* page = tabs->currentWidget(); + const QPoint sample = page->mapTo(tabs, QPoint(page->width() - 6, page->height() - 6)); + const QImage image = tabs->grab().toImage(); + const qreal scale = image.devicePixelRatio(); + QCOMPARE(image.pixelColor(qRound(sample.x() * scale), qRound(sample.y() * scale)), paneColor); + } +#endif +} + /// /// \brief Runs the layout tests with the application theme service. /// diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index d24cb393..20e80634 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -33,6 +33,8 @@ set(UI_FILES ) add_library(omodsim_ui STATIC + widgets/settingstabwidget.cpp + widgets/settingstabwidget.h widgets/elidinglineedit.cpp widgets/elidinglineedit.h widgets/tlscertificateswidget.cpp diff --git a/src/ui/dialogs/dialogmodbusdefinitions.cpp b/src/ui/dialogs/dialogmodbusdefinitions.cpp index 57d696e5..c76a1ef8 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.cpp +++ b/src/ui/dialogs/dialogmodbusdefinitions.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include "themedicons.h" #include "dialogmodbusdefinitions.h" #include "ui_dialogmodbusdefinitions.h" @@ -22,6 +24,14 @@ DialogModbusDefinitions::DialogModbusDefinitions(ModbusMultiServer& srv, QWidget ,_mbMultiServer(srv) { ui->setupUi(this); +#ifdef Q_OS_MAC + ui->certificateLayout->setContentsMargins(12, 0, 12, 12); + ui->certificateScrollArea->viewport()->setAutoFillBackground(false); + ui->certificateScrollContents->setAutoFillBackground(false); +#endif + ui->tabWidget->setTabIcon(0, themedIcon("omodsim/modbus-definitions")); + ui->tabWidget->setTabIcon(1, themedIcon("omodsim/certificate")); + ui->tabWidget->setTabIcon(2, themedIcon("omodsim/warning")); const QList allCheckBoxes = findChildren(); for (QCheckBox* checkBox : allCheckBoxes) { @@ -101,7 +111,14 @@ DialogModbusDefinitions::~DialogModbusDefinitions() /// QSize DialogModbusDefinitions::sizeHint() const { - const QSize natural = QFixedSizeDialog::sizeHint(); + QSize natural = QFixedSizeDialog::sizeHint(); + const QMargins margins = ui->verticalLayout->contentsMargins(); + const int chromeHeight = ui->tabWidget->tabBar()->sizeHint().height() + + ui->buttonBox->sizeHint().height() + margins.top() + margins.bottom() + + ui->verticalLayout->spacing() + 12 + + ui->certificateLayout->contentsMargins().top() + ui->certificateLayout->contentsMargins().bottom(); + natural.setHeight(qMax(natural.height(), ui->certificatesWidget->sizeHint().height() + chromeHeight)); + natural = natural.boundedTo(maximumSize()); const QScreen* target = parentWidget() ? parentWidget()->screen() : screen(); return target ? natural.boundedTo(target->availableGeometry().size() - QSize(32, 64)) : natural; } diff --git a/src/ui/dialogs/dialogmodbusdefinitions.ui b/src/ui/dialogs/dialogmodbusdefinitions.ui index 3d5484b6..bd7b2158 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.ui +++ b/src/ui/dialogs/dialogmodbusdefinitions.ui @@ -15,7 +15,7 @@ - + 0 @@ -319,6 +319,7 @@ + SettingsTabWidgetQTabWidget
settingstabwidget.h
1
TlsCertificatesWidgetQWidget
tlscertificateswidget.h
1
AddressSpaceComboBox diff --git a/src/ui/dialogs/dialogselectserviceport.ui b/src/ui/dialogs/dialogselectserviceport.ui index 9430fe47..df44d1d3 100644 --- a/src/ui/dialogs/dialogselectserviceport.ui +++ b/src/ui/dialogs/dialogselectserviceport.ui @@ -61,7 +61,6 @@ Modbus TCP Security (TLS) - Uses the server certificate configured in Modbus Definitions → Certificates. Client certificates are not required. diff --git a/src/ui/style/themedicons.cpp b/src/ui/style/themedicons.cpp index ab12f998..ff3ba8df 100644 --- a/src/ui/style/themedicons.cpp +++ b/src/ui/style/themedicons.cpp @@ -30,6 +30,11 @@ struct IconDescriptor { const QHash& iconRegistry() { static const QHash registry { + { QStringLiteral("omodsim/certificate"), { QStringLiteral(""), QStringLiteral("certificate") } }, + { QStringLiteral("omodsim/person"), { QStringLiteral(""), QStringLiteral("person") } }, + { QStringLiteral("omodsim/hosts"), { QStringLiteral(""), QStringLiteral("hosts") } }, + { QStringLiteral("omodsim/calendar"), { QStringLiteral(""), QStringLiteral("calendar") } }, + { QStringLiteral("omodsim/about"), { QStringLiteral("misc/question"), QStringLiteral("about") } }, { QStringLiteral("omodsim/add"), { QStringLiteral("action/plus-circle"), QStringLiteral("add") } }, { QStringLiteral("omodsim/bell"), { QStringLiteral("misc/bell"), QStringLiteral("bell") } }, diff --git a/src/ui/widgets/settingstabwidget.cpp b/src/ui/widgets/settingstabwidget.cpp new file mode 100644 index 00000000..d24f13bb --- /dev/null +++ b/src/ui/widgets/settingstabwidget.cpp @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file settingstabwidget.cpp +/// \brief Paints the selected settings tab with the application accent. +/// + +#include "settingstabwidget.h" +#include +#include +#include + +namespace { +class SettingsTabBar : public QTabBar +{ +protected: + /// + /// \brief Preserves native inactive tabs while highlighting the current settings page. + /// + void paintEvent(QPaintEvent*) override + { + QStylePainter painter(this); + for (int index = 0; index < count(); ++index) { + QStyleOptionTab option; + initStyleOption(&option, index); + if (!(option.state & QStyle::State_Selected)) { + painter.save(); + painter.drawControl(QStyle::CE_TabBarTab, option); + painter.restore(); + continue; + } + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(palette().color(QPalette::Highlight).lighter(125)); + painter.setBrush(palette().highlight()); + painter.drawRoundedRect(QRectF(option.rect).adjusted(1, 1, -1, -1), 7, 7); + const QColor textColor = palette().color(QPalette::HighlightedText); + QRect textRect = option.rect; + if (!option.icon.isNull()) { + const int gap = 8; + const int width = option.iconSize.width() + gap + fontMetrics().horizontalAdvance(option.text); + QRect iconRect(option.rect.center().x() - width / 2, + option.rect.center().y() - option.iconSize.height() / 2, + option.iconSize.width(), option.iconSize.height()); + QPixmap icon = option.icon.pixmap(option.iconSize); + QPainter iconPainter(&icon); + iconPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); + iconPainter.fillRect(icon.rect(), textColor); + iconPainter.end(); + painter.drawPixmap(QStyle::visualRect(layoutDirection(), option.rect, iconRect), icon); + textRect.setLeft(iconRect.right() + gap); + textRect.setRight(textRect.left() + fontMetrics().horizontalAdvance(option.text)); + textRect = QStyle::visualRect(layoutDirection(), option.rect, textRect); + } + painter.setPen(textColor); + painter.drawText(textRect, Qt::AlignCenter | Qt::TextShowMnemonic, option.text); + if (option.state & QStyle::State_HasFocus) { + QStyleOptionFocusRect focus; + focus.QStyleOption::operator=(option); + focus.rect = option.rect.adjusted(4, 4, -4, -4); + painter.drawPrimitive(QStyle::PE_FrameFocusRect, focus); + } + } + } +}; +} + +/// +/// \brief Creates settings navigation with a palette-aware selected tab. +/// \param parent Parent widget. +/// +SettingsTabWidget::SettingsTabWidget(QWidget* parent) : QTabWidget(parent) +{ + setTabBar(new SettingsTabBar()); +} + +#ifdef Q_OS_MAC +/// +/// \brief Separates the macOS settings pane from the surrounding dialog using the active palette. +/// +void SettingsTabWidget::paintEvent(QPaintEvent* event) +{ + QTabWidget::paintEvent(event); + QStyleOptionTabWidgetFrame option; + initStyleOption(&option); + const QRect pane = style()->subElementRect(QStyle::SE_TabWidgetTabPane, &option, this); + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + painter.setBrush(palette().base()); + QColor border = palette().color(QPalette::Mid); + border.setAlpha(64); + painter.setPen(border); + painter.drawRoundedRect(QRectF(pane).adjusted(0.5, 0.5, -0.5, -0.5), 8, 8); +} +#endif diff --git a/src/ui/widgets/settingstabwidget.h b/src/ui/widgets/settingstabwidget.h new file mode 100644 index 00000000..5f3ca950 --- /dev/null +++ b/src/ui/widgets/settingstabwidget.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 OpenModSim contributors +// SPDX-License-Identifier: MIT + +/// +/// \file settingstabwidget.h +/// \brief Declares settings tabs with an accent-colored selection. +/// + +#ifndef SETTINGSTABWIDGET_H +#define SETTINGSTABWIDGET_H + +#include + +class SettingsTabWidget : public QTabWidget +{ + Q_OBJECT +public: + /// + /// \brief Creates settings navigation with a palette-aware selected tab. + /// \param parent Parent widget. + /// + explicit SettingsTabWidget(QWidget* parent = nullptr); + +#ifdef Q_OS_MAC +protected: + void paintEvent(QPaintEvent* event) override; +#endif +}; + +#endif diff --git a/src/ui/widgets/tlscertificateswidget.cpp b/src/ui/widgets/tlscertificateswidget.cpp index 421df2ef..0d788632 100644 --- a/src/ui/widgets/tlscertificateswidget.cpp +++ b/src/ui/widgets/tlscertificateswidget.cpp @@ -8,12 +8,26 @@ #include "tlscertificateswidget.h" #include "ui_tlscertificateswidget.h" -#include +#include "themedicons.h" #include -#include #include -#include #include +#include + +namespace { +/// +/// \brief Tints metadata glyphs with the current text or accent color. +/// +void setLabelIcon(QLabel* label, const QString& name, int size, const QColor& color) +{ + QPixmap pixmap = themedIcon(name).pixmap(size, size); + QPainter painter(&pixmap); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(pixmap.rect(), color); + painter.end(); + label->setPixmap(pixmap); +} +} /// /// \brief Opens the shared certificate store in the Modbus settings page. @@ -24,13 +38,17 @@ TlsCertificatesWidget::TlsCertificatesWidget(QWidget* parent, const QString& cer : QWidget(parent), ui(new Ui::TlsCertificatesWidget), _store(certificateStoreRoot) { ui->setupUi(this); - ui->commonName->setText("OpenModSim@" + QHostInfo::localHostName()); - QStringList hosts = {QHostInfo::localHostName(), "localhost", "127.0.0.1", "::1"}; - for (const auto& address : QNetworkInterface::allAddresses()) { - if (!address.isNull() && !address.isMulticast() && address.scopeId().isEmpty()) - hosts.append(address.toString()); + const auto certificate = _store.serverCertificate(); + QStringList hosts = TlsCertificateStore::defaultHostNames(); + QString name = TlsCertificateStore::defaultServerName(); + if (!certificate.isNull()) { + name = certificate.subjectInfo(QSslCertificate::CommonName).join(", "); + const auto alternatives = certificate.subjectAlternativeNames(); + hosts = alternatives.values(QSsl::DnsEntry); + hosts.append(alternatives.values(QSsl::IpAddressEntry)); + ui->validDays->setValue(qBound(1, int(certificate.effectiveDate().secsTo(certificate.expiryDate()) / (24 * 60 * 60)), 3650)); } - hosts.removeDuplicates(); + ui->commonName->setText(name); ui->hostNames->setText(hosts.join(", ")); connect(ui->generateButton, &QPushButton::clicked, this, &TlsCertificatesWidget::generateCertificate); connect(ui->exportButton, &QPushButton::clicked, this, &TlsCertificatesWidget::exportCertificate); @@ -53,29 +71,23 @@ void TlsCertificatesWidget::changeEvent(QEvent* event) if (event->type() == QEvent::LanguageChange) { ui->retranslateUi(this); refresh(); + } else if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) { + refresh(); } QWidget::changeEvent(event); } /// -/// \brief Reloads certificate metadata without displaying the private key. +/// \brief Updates certificate actions and icons for the current palette. /// void TlsCertificatesWidget::refresh() { - const auto certificate = _store.serverCertificate(); - if (certificate.isNull()) { - ui->serverDetails->setPlainText(tr("No server certificate. Generate one to enable TLS.")); - } else { - const auto alternatives = certificate.subjectAlternativeNames(); - QStringList hosts = alternatives.values(QSsl::DnsEntry); - hosts.append(alternatives.values(QSsl::IpAddressEntry)); - ui->serverDetails->setPlainText(tr("Subject: %1\nHost names / IP addresses: %2\nValid until: %3\nSHA-256: %4") - .arg(certificate.subjectInfo(QSslCertificate::CommonName).join(", "), hosts.join(", "), - certificate.expiryDate().toLocalTime().toString(Qt::ISODate), - QString::fromLatin1(certificate.digest(QCryptographicHash::Sha256).toHex(':')))); - } - ui->exportButton->setEnabled(!certificate.isNull()); - + setLabelIcon(ui->nameIcon, "omodsim/person", 18, palette().color(QPalette::WindowText)); + setLabelIcon(ui->hostsIcon, "omodsim/hosts", 18, palette().color(QPalette::WindowText)); + setLabelIcon(ui->validIcon, "omodsim/calendar", 18, palette().color(QPalette::WindowText)); + ui->generateButton->setIcon(themedIcon("omodsim/add")); + ui->exportButton->setIcon(themedIcon("omodsim/export")); + ui->exportButton->setEnabled(!_store.serverCertificate().isNull()); } /// @@ -99,7 +111,7 @@ void TlsCertificatesWidget::generateCertificate() /// void TlsCertificatesWidget::exportCertificate() { - const QString path = QFileDialog::getSaveFileName(this, tr("Export server certificate"), "server.pem", tr("PEM certificate (*.pem)")); + const QString path = QFileDialog::getSaveFileName(this, tr("Export server certificate"), TlsCertificateStore::generatedFileName(), tr("PEM certificate (*.pem)")); if (!path.isEmpty()) reportError(_store.exportServerCertificate(path)); } diff --git a/src/ui/widgets/tlscertificateswidget.ui b/src/ui/widgets/tlscertificateswidget.ui index a76cead3..185eb866 100644 --- a/src/ui/widgets/tlscertificateswidget.ui +++ b/src/ui/widgets/tlscertificateswidget.ui @@ -3,24 +3,18 @@ TlsCertificatesWidget + 10 0 - 0 + 12 0 0 - - - - 8 - 8 - 8 - 8 - true72 + trueCertificate parameters - Server name + 18181818Server name 64 - Host names / IP addresses + 18181818Host names / IP addresses DNS names and IP addresses clients use to reach this server, separated by commas. - Valid for (days) + 18181818Valid for (days) 136503650 @@ -28,8 +22,6 @@ Generate…false Export certificate…false - - Qt::Vertical00 From c408e9815ef6996ad41d8821c919ac311d997099 Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 07:20:45 +0700 Subject: [PATCH 3/7] fix: Use compatible TLS runtimes and portable certificate assertions in CI --- .../install-qt5-tls-runtime/action.yml | 57 +++++++++++++++++++ .github/workflows/test-ci.yml | 13 +++++ src/cmake/tests.cmake | 6 +- src/tests/test_modbustls.cpp | 3 + src/tests/test_tlscertificatestore.cpp | 4 +- 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .github/actions/install-qt5-tls-runtime/action.yml diff --git a/.github/actions/install-qt5-tls-runtime/action.yml b/.github/actions/install-qt5-tls-runtime/action.yml new file mode 100644 index 00000000..8e024ea0 --- /dev/null +++ b/.github/actions/install-qt5-tls-runtime/action.yml @@ -0,0 +1,57 @@ +name: Install Qt 5 TLS runtime +description: Build the OpenSSL 1.1 runtime required by the Qt 5.15.2 binary packages. +runs: + using: composite + steps: + - name: Cache TLS runtime + id: cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/qt5-openssl + key: qt5-openssl-1.1.1w-${{ runner.os }}-${{ runner.arch }}-v1 + + - name: Download OpenSSL 1.1.1w + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + working-directory: ${{ runner.temp }} + run: | + curl -fL --retry 3 https://github.com/openssl/openssl/releases/download/OpenSSL_1_1_1w/openssl-1.1.1w.tar.gz -o openssl-1.1.1w.tar.gz + echo 'cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8 openssl-1.1.1w.tar.gz' | sha256sum --check + tar -xzf openssl-1.1.1w.tar.gz + + - name: Build Linux runtime + if: runner.os == 'Linux' && steps.cache.outputs.cache-hit != 'true' + shell: bash + working-directory: ${{ runner.temp }}/openssl-1.1.1w + run: | + ./config shared no-tests --prefix="$RUNNER_TEMP/qt5-openssl" --libdir=lib + make -j"$(nproc)" + make install_sw + + - name: Build Windows runtime + if: runner.os == 'Windows' && steps.cache.outputs.cache-hit != 'true' + shell: pwsh + working-directory: ${{ runner.temp }}/openssl-1.1.1w + run: | + perl Configure VC-WIN64A shared no-asm no-tests --prefix="$env:RUNNER_TEMP/qt5-openssl" + if ($LASTEXITCODE -ne 0) { throw 'OpenSSL configuration failed' } + nmake /NOLOGO + if ($LASTEXITCODE -ne 0) { throw 'OpenSSL build failed' } + nmake /NOLOGO install_sw + if ($LASTEXITCODE -ne 0) { throw 'OpenSSL installation failed' } + + - name: Configure Linux loader + if: runner.os == 'Linux' + shell: bash + run: | + test -f "$RUNNER_TEMP/qt5-openssl/lib/libssl.so.1.1" + echo "LD_LIBRARY_PATH=$RUNNER_TEMP/qt5-openssl/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" + + - name: Configure Windows loader + if: runner.os == 'Windows' + shell: pwsh + run: | + if (!(Test-Path "$env:RUNNER_TEMP/qt5-openssl/bin/libssl-1_1-x64.dll")) { + throw 'Qt 5 TLS runtime is missing' + } + "$env:RUNNER_TEMP/qt5-openssl/bin" >> $env:GITHUB_PATH diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml index 170281e3..ded6ee49 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -52,6 +52,7 @@ jobs: env: # Run Qt GUI/Test binaries without a real display. QT_QPA_PLATFORM: offscreen + OMODSIM_REQUIRE_TLS: '1' steps: - name: Checkout @@ -87,6 +88,10 @@ jobs: if: runner.os == 'Windows' uses: ./.github/actions/install-openssl + - name: Install Qt 5 TLS runtime + if: matrix.qt-version == '5.15.2' + uses: ./.github/actions/install-qt5-tls-runtime + - name: Configure run: > cmake -S src -B build -G Ninja @@ -100,6 +105,13 @@ jobs: - name: Run tests run: ctest --test-dir build --output-on-failure -R "^omodsim_tests" + - name: Upload test diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-logs-${{ matrix.os }}-qt${{ matrix.qt-version }} + path: build/Testing/Temporary/ + coverage: name: Coverage (${{ matrix.os }}, Qt ${{ matrix.qt-version }}) runs-on: ${{ matrix.os }} @@ -114,6 +126,7 @@ jobs: env: QT_QPA_PLATFORM: offscreen + OMODSIM_REQUIRE_TLS: '1' steps: - name: Checkout diff --git a/src/cmake/tests.cmake b/src/cmake/tests.cmake index fd638152..66eb0b62 100644 --- a/src/cmake/tests.cmake +++ b/src/cmake/tests.cmake @@ -25,7 +25,7 @@ function(omodsim_configure_tests) omodsim_apply_coverage(${test_name}) set_target_properties(${test_name} PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests ${test_name}) - add_test(NAME ${test_name} COMMAND ${test_name}) + add_test(NAME ${test_name} COMMAND ${test_name} -o -,txt) set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endfunction() @@ -77,7 +77,7 @@ function(omodsim_configure_tests) omodsim_apply_coverage(omodsim_tests_projecttreewidget) set_target_properties(omodsim_tests_projecttreewidget PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests omodsim_tests_projecttreewidget) - add_test(NAME omodsim_tests_projecttreewidget COMMAND omodsim_tests_projecttreewidget) + add_test(NAME omodsim_tests_projecttreewidget COMMAND omodsim_tests_projecttreewidget -o -,txt) set_tests_properties(omodsim_tests_projecttreewidget PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(omodsim_tests_appproject @@ -95,6 +95,6 @@ function(omodsim_configure_tests) endif() set_target_properties(omodsim_tests_appproject PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests omodsim_tests_appproject) - add_test(NAME omodsim_tests_appproject COMMAND omodsim_tests_appproject) + add_test(NAME omodsim_tests_appproject COMMAND omodsim_tests_appproject -o -,txt) set_tests_properties(omodsim_tests_appproject PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endfunction() diff --git a/src/tests/test_modbustls.cpp b/src/tests/test_modbustls.cpp index b41e54e4..8e3bf66a 100644 --- a/src/tests/test_modbustls.cpp +++ b/src/tests/test_modbustls.cpp @@ -107,6 +107,9 @@ bool TestModbusTls::expireCertificate(const QString& name) /// void TestModbusTls::initTestCase() { + qRegisterMetaType("QModbusDevice::Error"); + if (qEnvironmentVariableIsSet("OMODSIM_REQUIRE_TLS")) + QVERIFY2(QSslSocket::supportsSsl(), "The configured TLS runtime could not be loaded"); if (!QSslSocket::supportsSsl()) QSKIP("Qt TLS backend is unavailable"); _openssl = QStandardPaths::findExecutable("openssl"); diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp index 10ee10da..87ea1a4e 100644 --- a/src/tests/test_tlscertificatestore.cpp +++ b/src/tests/test_tlscertificatestore.cpp @@ -42,6 +42,8 @@ private slots: /// void TestTlsCertificateStore::initTestCase() { + if (qEnvironmentVariableIsSet("OMODSIM_REQUIRE_TLS")) + QVERIFY2(QSslSocket::supportsSsl(), "The configured TLS runtime could not be loaded"); if (!QSslSocket::supportsSsl()) QSKIP("Qt TLS backend is unavailable"); } @@ -108,7 +110,6 @@ void TestTlsCertificateStore::generatesServerIdentity() QVERIFY2(error.isEmpty(), qPrintable(error)); const QSslCertificate certificate(generated.Certificate); QVERIFY(!certificate.isNull()); - QVERIFY(certificate.isSelfSigned()); QCOMPARE(certificate.subjectInfo(QSslCertificate::CommonName), QStringList{"Modbus simulator"}); QCOMPARE(certificate.subjectAlternativeNames().values(QSsl::DnsEntry), QStringList{"localhost"}); QVERIFY(certificate.subjectAlternativeNames().values(QSsl::IpAddressEntry).contains("127.0.0.1")); @@ -125,6 +126,7 @@ void TestTlsCertificateStore::generatesServerIdentity() QVERIFY(parsed && key); QCOMPARE(EVP_PKEY_bits(key.get()), 2048); QCOMPARE(X509_check_private_key(parsed.get(), key.get()), 1); + QCOMPARE(X509_NAME_cmp(X509_get_subject_name(parsed.get()), X509_get_issuer_name(parsed.get())), 0); QCOMPARE(X509_verify(parsed.get(), key.get()), 1); QCOMPARE(X509_check_ca(parsed.get()), 0); QCOMPARE(X509_check_purpose(parsed.get(), X509_PURPOSE_SSL_SERVER, 0), 1); From 6f6c166b2d9f6b0a8425e8d7d6afb2e0b022bddb Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 07:32:41 +0700 Subject: [PATCH 4/7] build: Use one OpenSSL version per Qt major version --- .github/actions/install-openssl/action.yml | 9 +- .github/actions/install-openssl11/action.yml | 45 +++++++ .../install-qt5-tls-runtime/action.yml | 57 -------- .github/workflows/build-deb-qt5-amd64.yml | 13 +- .../workflows/build-installer-qt5-win32.yml | 4 +- .../workflows/build-installer-qt5-win64.yml | 4 +- .github/workflows/test-ci.yml | 27 +++- README.md | 2 +- README.md.in | 2 +- build.ps1 | 5 +- build.sh | 8 +- docs/modbus-tls.md | 7 +- src/cmake/openssl.cmake | 10 +- src/cmake/tests.cmake | 19 ++- src/tests/test_tlscertificatestore.cpp | 26 ++++ third_party/licenses/OpenSSL-1.1.txt | 125 ++++++++++++++++++ tools/build_openssl.sh | 7 +- tools/fetch_openssl11.py | 36 +++++ tools/install_openssl.ps1 | 56 +++++--- 19 files changed, 344 insertions(+), 118 deletions(-) create mode 100644 .github/actions/install-openssl11/action.yml delete mode 100644 .github/actions/install-qt5-tls-runtime/action.yml create mode 100644 third_party/licenses/OpenSSL-1.1.txt create mode 100644 tools/fetch_openssl11.py diff --git a/.github/actions/install-openssl/action.yml b/.github/actions/install-openssl/action.yml index 7cd7d355..f0dbadea 100644 --- a/.github/actions/install-openssl/action.yml +++ b/.github/actions/install-openssl/action.yml @@ -1,6 +1,9 @@ -name: Install OpenSSL 3 -description: Install the Qt OpenSSL 3 development package and runtime for Windows. +name: Install OpenSSL +description: Install the selected OpenSSL development package and runtime for Windows. inputs: + version: + description: OpenSSL version family, 1.1 or 3. + default: '3' architecture: description: Target architecture, x64 or x86. default: x64 @@ -11,6 +14,6 @@ runs: shell: pwsh run: | python -m pip install aqtinstall - $root = & ./tools/install_openssl.ps1 -Architecture '${{ inputs.architecture }}' + $root = & ./tools/install_openssl.ps1 -Architecture '${{ inputs.architecture }}' -Version '${{ inputs.version }}' "OPENSSL_ROOT_DIR=$root" >> $env:GITHUB_ENV "$root\bin" >> $env:GITHUB_PATH diff --git a/.github/actions/install-openssl11/action.yml b/.github/actions/install-openssl11/action.yml new file mode 100644 index 00000000..806e6d9a --- /dev/null +++ b/.github/actions/install-openssl11/action.yml @@ -0,0 +1,45 @@ +name: Install OpenSSL 1.1 +description: Install the shared development and TLS libraries used by Qt 5 builds. +inputs: + architecture: + description: Windows target architecture, x64 or x86. + default: x64 +runs: + using: composite + steps: + - name: Cache Linux OpenSSL + if: runner.os == 'Linux' + id: linux-cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/qt5-openssl + key: qt5-openssl-1.1.1w-${{ runner.os }}-${{ runner.arch }}-v1 + + - name: Build Linux OpenSSL + if: runner.os == 'Linux' && steps.linux-cache.outputs.cache-hit != 'true' + shell: bash + run: | + source_root=$(python3 tools/fetch_openssl11.py "$RUNNER_TEMP/openssl11-src") + bash tools/build_openssl.sh "$source_root" "$RUNNER_TEMP/qt5-openssl" 1.1 + + - name: Configure Linux loader and build + if: runner.os == 'Linux' + shell: bash + run: | + test -f "$RUNNER_TEMP/qt5-openssl/lib/libssl.so.1.1" + echo "OPENSSL_ROOT_DIR=$RUNNER_TEMP/qt5-openssl" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$RUNNER_TEMP/qt5-openssl/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" + + - name: Cache Windows OpenSSL + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: C:/Qt/Tools/OpenSSL11/Win_${{ inputs.architecture }} + key: openssl-1.1.1w-Windows-${{ inputs.architecture }}-v1 + + - name: Install Windows OpenSSL + if: runner.os == 'Windows' + uses: ./.github/actions/install-openssl + with: + version: '1.1' + architecture: ${{ inputs.architecture }} diff --git a/.github/actions/install-qt5-tls-runtime/action.yml b/.github/actions/install-qt5-tls-runtime/action.yml deleted file mode 100644 index 8e024ea0..00000000 --- a/.github/actions/install-qt5-tls-runtime/action.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Install Qt 5 TLS runtime -description: Build the OpenSSL 1.1 runtime required by the Qt 5.15.2 binary packages. -runs: - using: composite - steps: - - name: Cache TLS runtime - id: cache - uses: actions/cache@v4 - with: - path: ${{ runner.temp }}/qt5-openssl - key: qt5-openssl-1.1.1w-${{ runner.os }}-${{ runner.arch }}-v1 - - - name: Download OpenSSL 1.1.1w - if: steps.cache.outputs.cache-hit != 'true' - shell: bash - working-directory: ${{ runner.temp }} - run: | - curl -fL --retry 3 https://github.com/openssl/openssl/releases/download/OpenSSL_1_1_1w/openssl-1.1.1w.tar.gz -o openssl-1.1.1w.tar.gz - echo 'cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8 openssl-1.1.1w.tar.gz' | sha256sum --check - tar -xzf openssl-1.1.1w.tar.gz - - - name: Build Linux runtime - if: runner.os == 'Linux' && steps.cache.outputs.cache-hit != 'true' - shell: bash - working-directory: ${{ runner.temp }}/openssl-1.1.1w - run: | - ./config shared no-tests --prefix="$RUNNER_TEMP/qt5-openssl" --libdir=lib - make -j"$(nproc)" - make install_sw - - - name: Build Windows runtime - if: runner.os == 'Windows' && steps.cache.outputs.cache-hit != 'true' - shell: pwsh - working-directory: ${{ runner.temp }}/openssl-1.1.1w - run: | - perl Configure VC-WIN64A shared no-asm no-tests --prefix="$env:RUNNER_TEMP/qt5-openssl" - if ($LASTEXITCODE -ne 0) { throw 'OpenSSL configuration failed' } - nmake /NOLOGO - if ($LASTEXITCODE -ne 0) { throw 'OpenSSL build failed' } - nmake /NOLOGO install_sw - if ($LASTEXITCODE -ne 0) { throw 'OpenSSL installation failed' } - - - name: Configure Linux loader - if: runner.os == 'Linux' - shell: bash - run: | - test -f "$RUNNER_TEMP/qt5-openssl/lib/libssl.so.1.1" - echo "LD_LIBRARY_PATH=$RUNNER_TEMP/qt5-openssl/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" - - - name: Configure Windows loader - if: runner.os == 'Windows' - shell: pwsh - run: | - if (!(Test-Path "$env:RUNNER_TEMP/qt5-openssl/bin/libssl-1_1-x64.dll")) { - throw 'Qt 5 TLS runtime is missing' - } - "$env:RUNNER_TEMP/qt5-openssl/bin" >> $env:GITHUB_PATH diff --git a/.github/workflows/build-deb-qt5-amd64.yml b/.github/workflows/build-deb-qt5-amd64.yml index 9c684e82..6b527b85 100644 --- a/.github/workflows/build-deb-qt5-amd64.yml +++ b/.github/workflows/build-deb-qt5-amd64.yml @@ -75,7 +75,7 @@ jobs: libxcb-shape0 libxcb-render-util0 libxcb-icccm4 libxcb-image0 \ libfontconfig1 libfreetype6 libx11-6 libxext6 libxrender1 \ zlib1g-dev libpng-dev libjpeg-dev libdbus-1-3 \ - libssl1.1 \ + libssl1.1 libssl-dev \ libwayland-cursor0 libwayland-egl1-mesa \ libgtk-3-0 libgdk-pixbuf2.0-0 libpangocairo-1.0-0 libpango-1.0-0 libatk1.0-0 libcairo2 libcairo-gobject2 \ libcups2 \ @@ -107,16 +107,12 @@ jobs: - name: Set BUILD_DIR run: echo "BUILD_DIR=build-omodsim-Qt_${{ env.QT_VERSION }}-Release" >> $GITHUB_ENV - - name: Resolve latest OpenSSL 3.5 LTS version - uses: ./.github/actions/build-openssl3-runtime - - name: Configure project run: | cmake src -B ${{ env.BUILD_DIR }} \ -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ - -DOPENSSL_ROOT_DIR=/usr/local \ -DOMODSIM_BUILD_TESTS=OFF \ -DPRERELEASE_SUFFIX=${{ env.PRERELEASE_SUFFIX }} @@ -150,7 +146,7 @@ jobs: CQtDeployer -bin ${{ env.BUILD_DIR }}/omodsim \ -targetDir ${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }} \ -libDir "$QT_ROOT_DIR/lib" \ - -ignore libcrypto.so.3,libmysqlclient.so.21,libpq.so.5,libssl.so.3,libqsqlmimer.so,libqsqlmysql.so,libqsqlodbc.so,libqsqlpsql.so \ + -ignore libmysqlclient.so.21,libpq.so.5,libqsqlmimer.so,libqsqlmysql.so,libqsqlodbc.so,libqsqlpsql.so \ -extraLibs icudata,icui18n,icuuc,libxcb-util,libxcb-cursor - name: Copy OpenSSL 1 libraries @@ -159,11 +155,6 @@ jobs: /usr/lib/x86_64-linux-gnu/libcrypto.so.1.1 \ ${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/lib/ - - name: Copy OpenSSL 3 libraries - run: | - cp /usr/local/lib64/libssl.so.3 /usr/local/lib64/libcrypto.so.3 \ - ${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/lib/ - - name: Copy OpenModsim docs run: rsync -a "${{ env.BUILD_DIR }}/docs/" "${{ env.DEB_SRC_DIR }}${{ env.APP_INSTALL_PREFIX }}${{ env.VERSION_MAJOR }}/docs/" diff --git a/.github/workflows/build-installer-qt5-win32.yml b/.github/workflows/build-installer-qt5-win32.yml index ddd96f54..6fdf606b 100644 --- a/.github/workflows/build-installer-qt5-win32.yml +++ b/.github/workflows/build-installer-qt5-win32.yml @@ -117,8 +117,8 @@ jobs: echo VS_PATH=%VS_PATH%>>%GITHUB_ENV% shell: cmd - - name: Install OpenSSL 3 - uses: ./.github/actions/install-openssl + - name: Install OpenSSL 1.1 + uses: ./.github/actions/install-openssl11 with: architecture: x86 diff --git a/.github/workflows/build-installer-qt5-win64.yml b/.github/workflows/build-installer-qt5-win64.yml index 49793e9a..61439400 100644 --- a/.github/workflows/build-installer-qt5-win64.yml +++ b/.github/workflows/build-installer-qt5-win64.yml @@ -117,8 +117,8 @@ jobs: echo VS_PATH=%VS_PATH%>>%GITHUB_ENV% shell: cmd - - name: Install OpenSSL 3 - uses: ./.github/actions/install-openssl + - name: Install OpenSSL 1.1 + uses: ./.github/actions/install-openssl11 with: architecture: x64 diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml index ded6ee49..10736fdc 100644 --- a/.github/workflows/test-ci.yml +++ b/.github/workflows/test-ci.yml @@ -5,12 +5,16 @@ on: branches: [dev] paths: - '.github/workflows/test-ci.yml' + - '.github/actions/*openssl*/**' + - 'tools/*openssl*' - 'src/**' - 'tools/coverage.py' pull_request: branches: [main, dev] paths: - '.github/workflows/test-ci.yml' + - '.github/actions/*openssl*/**' + - 'tools/*openssl*' - 'src/**' - 'tools/coverage.py' workflow_dispatch: @@ -85,12 +89,12 @@ jobs: cache: true - name: Install OpenSSL 3 (Windows) - if: runner.os == 'Windows' + if: runner.os == 'Windows' && matrix.qt-version != '5.15.2' uses: ./.github/actions/install-openssl - - name: Install Qt 5 TLS runtime + - name: Install OpenSSL 1.1 (Qt 5) if: matrix.qt-version == '5.15.2' - uses: ./.github/actions/install-qt5-tls-runtime + uses: ./.github/actions/install-openssl11 - name: Configure run: > @@ -105,12 +109,27 @@ jobs: - name: Run tests run: ctest --test-dir build --output-on-failure -R "^omodsim_tests" + - name: Print Windows test failures + if: failure() && runner.os == 'Windows' + shell: pwsh + run: | + $failedTests = 'build/Testing/Temporary/LastTestsFailed.log' + if (Test-Path $failedTests) { + foreach ($entry in Get-Content $failedTests) { + $testName = ($entry -split ':', 2)[1] + $testLog = "build/tests/$testName.testlog.txt" + if (Test-Path $testLog) { Get-Content $testLog } + } + } + - name: Upload test diagnostics if: failure() uses: actions/upload-artifact@v7 with: name: test-logs-${{ matrix.os }}-qt${{ matrix.qt-version }} - path: build/Testing/Temporary/ + path: | + build/Testing/Temporary/ + build/tests/*.testlog.txt coverage: name: Coverage (${{ matrix.os }}, Qt ${{ matrix.qt-version }}) diff --git a/README.md b/README.md index cb1543dd..262ecb42 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ The core library does not depend on Qt Widgets. Each layer has its own `CMakeLis # Building -Certificate generation requires OpenSSL 3 development libraries (`libcrypto`). The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. +Certificate generation requires OpenSSL development libraries (`libcrypto`): version 1.1.1 with Qt 5, or version 3 with Qt 6. The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. Building is available via cmake (with installed Qt version 5.15 and above) or Qt Creator. Supports both OS Microsoft Windows and Linux. > [!NOTE] diff --git a/README.md.in b/README.md.in index 454a71f7..e7e24ea2 100644 --- a/README.md.in +++ b/README.md.in @@ -183,7 +183,7 @@ The core library does not depend on Qt Widgets. Each layer has its own `CMakeLis # Building -Certificate generation requires OpenSSL 3 development libraries (`libcrypto`). The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. +Certificate generation requires OpenSSL development libraries (`libcrypto`): version 1.1.1 with Qt 5, or version 3 with Qt 6. The build scripts install this dependency; for manual builds, set `OPENSSL_ROOT_DIR` when CMake cannot locate it. Building is available via cmake (with installed Qt version 5.15 and above) or Qt Creator. Supports both OS Microsoft Windows and Linux. > [!NOTE] diff --git a/build.ps1 b/build.ps1 index b0e0d8d4..a2972ef1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -477,9 +477,10 @@ if (-not (Test-Path $QtDir)) { } Write-Host "Found Qt: $QtDir" -Write-Host "Checking for OpenSSL 3..." +$OpenSslVersion = if ($QtMajorVersion -eq '5') { '1.1' } else { '3' } +Write-Host "Checking for OpenSSL $OpenSslVersion..." $OpenSslArchitecture = if ($Arch -eq 'win32') { 'x86' } else { 'x64' } -$OpenSslRoot = & (Join-Path $PSScriptRoot 'tools\install_openssl.ps1') -Architecture $OpenSslArchitecture +$OpenSslRoot = & (Join-Path $PSScriptRoot 'tools\install_openssl.ps1') -Architecture $OpenSslArchitecture -Version $OpenSslVersion $env:PATH = "$(Join-Path $OpenSslRoot 'bin');$env:PATH" # Create Build dir diff --git a/build.sh b/build.sh index 0bd546db..09f79ea9 100755 --- a/build.sh +++ b/build.sh @@ -582,7 +582,13 @@ else echo "Using Qt $QT_VERSION from: $CMAKE_PREFIX" fi -if [ -z "${OPENSSL_ROOT_DIR:-}" ] && ! pkg-config --atleast-version=3 openssl 2>/dev/null; then +if [ "${QT_VERSION%%.*}" = "5" ]; then + if [ -z "${OPENSSL_ROOT_DIR:-}" ] && ! pkg-config --atleast-version=1.1.1 --max-version=1.1.999 openssl 2>/dev/null; then + OPENSSL_SOURCE_ROOT=$(python3 "$PROJECT_DIR/tools/fetch_openssl11.py" "$TOOLS_DIR/openssl11-src") + export OPENSSL_ROOT_DIR="$TOOLS_DIR/openssl11" + bash "$PROJECT_DIR/tools/build_openssl.sh" "$OPENSSL_SOURCE_ROOT" "$OPENSSL_ROOT_DIR" 1.1 + fi +elif [ -z "${OPENSSL_ROOT_DIR:-}" ] && ! pkg-config --atleast-version=3 openssl 2>/dev/null; then OPENSSL_TOOLS_ROOT="$TOOLS_DIR/openssl-src" OPENSSL_SOURCE_ROOT="$OPENSSL_TOOLS_ROOT/Tools/OpenSSLv3/src" if [ ! -f "$OPENSSL_SOURCE_ROOT/Configure" ]; then diff --git a/docs/modbus-tls.md b/docs/modbus-tls.md index 8ee88ed9..9bf4c41c 100644 --- a/docs/modbus-tls.md +++ b/docs/modbus-tls.md @@ -45,9 +45,10 @@ At application startup, a missing identity is generated with the local hostname, local DNS/IP addresses, and a validity period of 3650 days. Existing identities are preserved; invalid files require explicit replacement in the certificate settings. -OpenSSL 3 `libcrypto` is a build and runtime dependency for certificate generation. -The build scripts install it through Homebrew on macOS, Qt's OpenSSL package on -Windows, or distribution packages/source builds on Linux. Runtime libraries and the +Certificate generation and Qt TLS use the same OpenSSL version: 1.1.1 with Qt 5, +and 3 with Qt 6. CMake rejects a mismatched OpenSSL major version. The build scripts +install development and runtime libraries through system packages, Qt's OpenSSL 3 +package, or a checksum-verified OpenSSL 1.1.1w source build. Runtime libraries and the OpenSSL license are included in packaged distributions. The application does not invoke the `openssl` command-line tool. Integration tests use that utility to create client test fixtures and skip when it or a Qt TLS backend is unavailable. diff --git a/src/cmake/openssl.cmake b/src/cmake/openssl.cmake index 680511c0..7ae81b02 100644 --- a/src/cmake/openssl.cmake +++ b/src/cmake/openssl.cmake @@ -1,4 +1,10 @@ -find_package(OpenSSL 3 REQUIRED COMPONENTS Crypto) +if(Qt6_FOUND) + find_package(OpenSSL 3...<4 REQUIRED COMPONENTS Crypto) + set(omodsim_openssl_license "OpenSSL.txt") +else() + find_package(OpenSSL 1.1.1...<3 REQUIRED COMPONENTS Crypto) + set(omodsim_openssl_license "OpenSSL-1.1.txt") +endif() function(omodsim_deploy_openssl target_name) if(WIN32) @@ -14,5 +20,5 @@ function(omodsim_deploy_openssl target_name) endif() endfunction() -configure_file("${PROJECT_SOURCE_DIR}/../third_party/licenses/OpenSSL.txt" +configure_file("${PROJECT_SOURCE_DIR}/../third_party/licenses/${omodsim_openssl_license}" "${PROJECT_BINARY_DIR}/docs/licenses/OpenSSL.txt" COPYONLY) diff --git a/src/cmake/tests.cmake b/src/cmake/tests.cmake index 66eb0b62..51e8579d 100644 --- a/src/cmake/tests.cmake +++ b/src/cmake/tests.cmake @@ -10,6 +10,16 @@ function(omodsim_configure_tests) add_custom_target(omodsim_tests) set_target_properties(omodsim_tests PROPERTIES FOLDER "Tests") + function(omodsim_register_test test_name) + if(WIN32) + set(test_log "${CMAKE_CURRENT_BINARY_DIR}/${test_name}.testlog.txt,txt") + else() + set(test_log "-,txt") + endif() + add_test(NAME ${test_name} COMMAND ${test_name} -o "${test_log}") + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + endfunction() + function(omodsim_add_test test_name source_file) set(test_library omodsim_core) if(ARGN) @@ -25,8 +35,7 @@ function(omodsim_configure_tests) omodsim_apply_coverage(${test_name}) set_target_properties(${test_name} PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests ${test_name}) - add_test(NAME ${test_name} COMMAND ${test_name} -o -,txt) - set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + omodsim_register_test(${test_name}) endfunction() omodsim_add_test(omodsim_tests_numericutils test_numericutils.cpp) @@ -77,8 +86,7 @@ function(omodsim_configure_tests) omodsim_apply_coverage(omodsim_tests_projecttreewidget) set_target_properties(omodsim_tests_projecttreewidget PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests omodsim_tests_projecttreewidget) - add_test(NAME omodsim_tests_projecttreewidget COMMAND omodsim_tests_projecttreewidget -o -,txt) - set_tests_properties(omodsim_tests_projecttreewidget PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + omodsim_register_test(omodsim_tests_projecttreewidget) add_executable(omodsim_tests_appproject test_appproject.cpp @@ -95,6 +103,5 @@ function(omodsim_configure_tests) endif() set_target_properties(omodsim_tests_appproject PROPERTIES FOLDER "Tests") add_dependencies(omodsim_tests omodsim_tests_appproject) - add_test(NAME omodsim_tests_appproject COMMAND omodsim_tests_appproject -o -,txt) - set_tests_properties(omodsim_tests_appproject PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + omodsim_register_test(omodsim_tests_appproject) endfunction() diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp index 87ea1a4e..fd3538e3 100644 --- a/src/tests/test_tlscertificatestore.cpp +++ b/src/tests/test_tlscertificatestore.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include "pki/certificategenerator.h" #include "pki/tlscertificatestore.h" @@ -26,6 +27,7 @@ class TestTlsCertificateStore : public QObject private slots: void initTestCase(); void generatesServerIdentity(); + void usesSingleOpenSslVersion(); void createsDefaultIdentityOnce(); void reportsStartupGenerationFailures(); void rejectsInvalidGeneration_data(); @@ -48,6 +50,30 @@ void TestTlsCertificateStore::initTestCase() QSKIP("Qt TLS backend is unavailable"); } +/// +/// \brief Keeps certificate generation and the OpenSSL Qt backend on the same ABI family. +/// +void TestTlsCertificateStore::usesSingleOpenSslVersion() +{ +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + constexpr unsigned long minimum = 0x10101000UL; + constexpr unsigned long maximum = 0x30000000UL; +#else + constexpr unsigned long minimum = 0x30000000UL; + constexpr unsigned long maximum = 0x40000000UL; +#endif + QVERIFY(OpenSSL_version_num() >= minimum && OpenSSL_version_num() < maximum); +#ifndef Q_OS_MAC +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + if (QSslSocket::activeBackend() != "openssl") + return; +#endif + const auto tlsVersion = QSslSocket::sslLibraryVersionNumber(); + QVERIFY2(tlsVersion >= minimum && tlsVersion < maximum, + qPrintable(QSslSocket::sslLibraryVersionString())); +#endif +} + /// /// \brief Creates the first-run identity and preserves its bytes on later launches. /// diff --git a/third_party/licenses/OpenSSL-1.1.txt b/third_party/licenses/OpenSSL-1.1.txt new file mode 100644 index 00000000..9601ab43 --- /dev/null +++ b/third_party/licenses/OpenSSL-1.1.txt @@ -0,0 +1,125 @@ + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a double license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + diff --git a/tools/build_openssl.sh b/tools/build_openssl.sh index 058580d8..49d30231 100644 --- a/tools/build_openssl.sh +++ b/tools/build_openssl.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -if [ "$#" -ne 2 ]; then - echo "Usage: $0 " >&2 +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [3|1.1]" >&2 exit 1 fi source_root="$1" install_prefix="$2" -if [ -e "$install_prefix/lib/libssl.so.3" ] && [ -e "$install_prefix/lib/libcrypto.so.3" ]; then +openssl_abi="${3:-3}" +if [ -e "$install_prefix/lib/libssl.so.$openssl_abi" ] && [ -e "$install_prefix/lib/libcrypto.so.$openssl_abi" ]; then exit 0 fi if [ ! -f "$source_root/Configure" ]; then diff --git a/tools/fetch_openssl11.py b/tools/fetch_openssl11.py new file mode 100644 index 00000000..fe458bf2 --- /dev/null +++ b/tools/fetch_openssl11.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 OpenModSim contributors +# SPDX-License-Identifier: MIT +"""Fetch the pinned OpenSSL 1.1 source used by Qt 5 builds.""" + +import hashlib +from pathlib import Path +import sys +import tarfile +import tempfile +import urllib.request + +VERSION = "1.1.1w" +SHA256 = "cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8" +URL = f"https://github.com/openssl/openssl/releases/download/OpenSSL_1_1_1w/openssl-{VERSION}.tar.gz" + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("Usage: fetch_openssl11.py ") + destination = Path(sys.argv[1]).resolve() + source = destination / f"openssl-{VERSION}" + if not (source / "Configure").is_file(): + destination.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temporary: + archive = Path(temporary) / "openssl.tar.gz" + urllib.request.urlretrieve(URL, archive) + if hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: + raise SystemExit("OpenSSL source checksum mismatch") + with tarfile.open(archive) as package: + package.extractall(destination) + print(source) + + +if __name__ == "__main__": + main() diff --git a/tools/install_openssl.ps1 b/tools/install_openssl.ps1 index c8ef0229..33dd7974 100644 --- a/tools/install_openssl.ps1 +++ b/tools/install_openssl.ps1 @@ -1,12 +1,42 @@ param( [ValidateSet('x64', 'x86')][string]$Architecture = 'x64', + [ValidateSet('1.1', '3')][string]$Version = '3', [string]$QtToolsRoot = 'C:\Qt' ) $ErrorActionPreference = 'Stop' -$opensslRoot = Join-Path $QtToolsRoot "Tools\OpenSSLv3\Win_$Architecture" + +function Build-OpenSsl { + param([string]$SourceRoot, [string]$InstallRoot, [string]$TargetArchitecture) + + if (-not (Get-Command perl -ErrorAction SilentlyContinue)) { + throw 'Building OpenSSL requires Perl in PATH (for example Strawberry Perl).' + } + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { throw 'Cannot find the Visual Studio C++ build tools.' } + $vcvarsName = if ($TargetArchitecture -eq 'x86') { 'vcvars32.bat' } else { 'vcvars64.bat' } + $configureTarget = if ($TargetArchitecture -eq 'x86') { 'VC-WIN32' } else { 'VC-WIN64A' } + $vcvars = Join-Path $vsPath "VC\Auxiliary\Build\$vcvarsName" + $buildRoot = Join-Path $SourceRoot "build-modsim-$TargetArchitecture" + New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null + Push-Location $buildRoot + try { + cmd /c "call `"$vcvars`" && perl ..\Configure $configureTarget no-asm shared no-tests --prefix=`"$InstallRoot`" && nmake && nmake install_sw" | Out-Host + if ($LASTEXITCODE -ne 0) { throw "Cannot build OpenSSL $Version for $TargetArchitecture." } + } finally { + Pop-Location + } +} + +$packageName = if ($Version -eq '1.1') { 'OpenSSL11' } else { 'OpenSSLv3' } +$opensslRoot = Join-Path $QtToolsRoot "Tools\$packageName\Win_$Architecture" if (-not (Test-Path (Join-Path $opensslRoot 'include\openssl\opensslv.h'))) { - if ($Architecture -eq 'x64') { + if ($Version -eq '1.1') { + $sourceRoot = & python (Join-Path $PSScriptRoot 'fetch_openssl11.py') (Join-Path $QtToolsRoot 'Tools\OpenSSL11\src') + if ($LASTEXITCODE -ne 0) { throw 'Cannot download the OpenSSL 1.1 sources.' } + Build-OpenSsl -SourceRoot $sourceRoot -InstallRoot $opensslRoot -TargetArchitecture $Architecture + } elseif ($Architecture -eq 'x64') { python -m aqt install-tool windows desktop tools_opensslv3_x64 --outputdir $QtToolsRoot | Out-Host if ($LASTEXITCODE -ne 0) { throw 'Cannot install the Qt OpenSSL 3 package.' } } else { @@ -15,26 +45,12 @@ if (-not (Test-Path (Join-Path $opensslRoot 'include\openssl\opensslv.h'))) { python -m aqt install-tool windows desktop tools_opensslv3_src --outputdir $QtToolsRoot | Out-Host if ($LASTEXITCODE -ne 0) { throw 'Cannot install the OpenSSL 3 sources.' } } - if (-not (Get-Command perl -ErrorAction SilentlyContinue)) { - throw 'Building OpenSSL 3 for x86 requires Perl in PATH (for example Strawberry Perl).' - } - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if (-not $vsPath) { throw 'Cannot find the Visual Studio C++ build tools.' } - $vcvars = Join-Path $vsPath 'VC\Auxiliary\Build\vcvars32.bat' - $buildRoot = Join-Path $sourceRoot 'build-modsim-x86' - New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null - Push-Location $buildRoot - try { - cmd /c "call `"$vcvars`" && perl ..\Configure VC-WIN32 no-asm shared no-tests --prefix=`"$opensslRoot`" && nmake && nmake install_sw" | Out-Host - if ($LASTEXITCODE -ne 0) { throw 'Cannot build OpenSSL 3 for x86.' } - } finally { - Pop-Location - } + Build-OpenSsl -SourceRoot $sourceRoot -InstallRoot $opensslRoot -TargetArchitecture $Architecture } } if (-not (Test-Path (Join-Path $opensslRoot 'include\openssl\opensslv.h')) -or - -not (Get-ChildItem (Join-Path $opensslRoot 'bin') -Filter 'libcrypto-*.dll' -ErrorAction SilentlyContinue)) { - throw "OpenSSL 3 installation is incomplete: $opensslRoot" + -not (Get-ChildItem (Join-Path $opensslRoot 'bin') -Filter 'libcrypto-*.dll' -ErrorAction SilentlyContinue) -or + -not (Get-ChildItem (Join-Path $opensslRoot 'bin') -Filter 'libssl-*.dll' -ErrorAction SilentlyContinue)) { + throw "OpenSSL $Version installation is incomplete: $opensslRoot" } Write-Output $opensslRoot From aa155ea54a1823be170c004460db36d4717d5650 Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 08:02:28 +0700 Subject: [PATCH 5/7] fix: Normalize certificate export paths and portable UI assertions --- .github/actions/install-openssl11/action.yml | 10 +++++++++- src/core/pki/tlscertificatestore.cpp | 6 +++--- src/tests/test_tlscertificatestore.cpp | 3 +++ src/tests/test_tlssettings.cpp | 8 +++++--- third_party/licenses/OpenSSL-1.1.txt | 1 - 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/actions/install-openssl11/action.yml b/.github/actions/install-openssl11/action.yml index 806e6d9a..8bc9493e 100644 --- a/.github/actions/install-openssl11/action.yml +++ b/.github/actions/install-openssl11/action.yml @@ -32,7 +32,8 @@ runs: - name: Cache Windows OpenSSL if: runner.os == 'Windows' - uses: actions/cache@v4 + id: windows-cache + uses: actions/cache/restore@v4 with: path: C:/Qt/Tools/OpenSSL11/Win_${{ inputs.architecture }} key: openssl-1.1.1w-Windows-${{ inputs.architecture }}-v1 @@ -43,3 +44,10 @@ runs: with: version: '1.1' architecture: ${{ inputs.architecture }} + + - name: Save Windows OpenSSL + if: runner.os == 'Windows' && steps.windows-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: C:/Qt/Tools/OpenSSL11/Win_${{ inputs.architecture }} + key: openssl-1.1.1w-Windows-${{ inputs.architecture }}-v1 diff --git a/src/core/pki/tlscertificatestore.cpp b/src/core/pki/tlscertificatestore.cpp index 41415be3..9640cacd 100644 --- a/src/core/pki/tlscertificatestore.cpp +++ b/src/core/pki/tlscertificatestore.cpp @@ -169,9 +169,9 @@ QString TlsCertificateStore::exportServerCertificate(const QString& fileName) co const auto certificate = serverCertificate(); if (certificate.isNull()) return tr("Generate a server certificate first."); - const QString target = QFileInfo(fileName).absoluteFilePath(); - const QString storeRoot = QFileInfo(_root).absoluteFilePath(); - if (target == storeRoot || target.startsWith(storeRoot + QDir::separator())) + const QString target = QDir::cleanPath(QFileInfo(fileName).absoluteFilePath()); + const QString storeRoot = QDir::cleanPath(QFileInfo(_root).absoluteFilePath()); + if (target == storeRoot || target.startsWith(storeRoot + QLatin1Char('/'))) return tr("Export the certificate outside the certificate store."); return saveFile(fileName, certificate.toPem()); } diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp index fd3538e3..01115075 100644 --- a/src/tests/test_tlscertificatestore.cpp +++ b/src/tests/test_tlscertificatestore.cpp @@ -224,6 +224,9 @@ void TestTlsCertificateStore::persistsAndExportsIdentity() QCOMPARE(bytes, store.serverCertificate().toPem()); QVERIFY(!bytes.contains("PRIVATE KEY")); QVERIFY(!store.exportServerCertificate(identity).isEmpty()); + QVERIFY(!store.exportServerCertificate(store.root() + "/own/export.pem").isEmpty()); + QVERIFY(!store.exportServerCertificate(store.root() + "/own/../own/export.pem").isEmpty()); + QCOMPARE(store.serverCertificate(), configuration.localCertificate()); #ifndef Q_OS_WIN const auto permissions = QFile::permissions(identity); QVERIFY(!(permissions & (QFileDevice::ReadGroup | QFileDevice::WriteGroup | QFileDevice::ReadOther | QFileDevice::WriteOther))); diff --git a/src/tests/test_tlssettings.cpp b/src/tests/test_tlssettings.cpp index c599c9e5..07b1ec91 100644 --- a/src/tests/test_tlssettings.cpp +++ b/src/tests/test_tlssettings.cpp @@ -63,7 +63,7 @@ private slots: /// void TestTlsSettings::initTestCase() { - QVERIFY(_translator.load(QFINDTESTDATA("../app/translations/omodsim_ru.qm"))); + QVERIFY(_translator.load(QStringLiteral(":/res/translations/omodsim_ru.qm"))); qApp->installTranslator(&_translator); #if defined(HAVE_QLEMENTINE_APP_STYLE) #ifdef Q_OS_MACOS @@ -337,8 +337,10 @@ void TestTlsSettings::elidesHostNamesWithoutChangingValue() name->setFocus(); QCoreApplication::processEvents(); QVERIFY(!hosts->hasFocus()); - QVERIFY(hosts->visibleText().endsWith(QChar(0x2026))); - QVERIFY(hosts->visibleText().startsWith("controller")); + const QString preview = hosts->visibleText(); + QVERIFY(preview.endsWith(QChar(0x2026))); + QVERIFY(preview.size() > 1); + QVERIFY(full.startsWith(preview.chopped(1))); QCOMPARE(hosts->text(), full); hosts->setFocus(); QTRY_VERIFY(hosts->hasFocus()); diff --git a/third_party/licenses/OpenSSL-1.1.txt b/third_party/licenses/OpenSSL-1.1.txt index 9601ab43..5b5ccdc9 100644 --- a/third_party/licenses/OpenSSL-1.1.txt +++ b/third_party/licenses/OpenSSL-1.1.txt @@ -122,4 +122,3 @@ * copied and put under another distribution licence * [including the GNU Public Licence.] */ - From 18cc77e1d27cdfaa1703348218c4f0969e227e6f Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 08:18:35 +0700 Subject: [PATCH 6/7] test: Size TLS host field using native font metrics --- src/tests/test_tlssettings.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tests/test_tlssettings.cpp b/src/tests/test_tlssettings.cpp index 07b1ec91..2c672488 100644 --- a/src/tests/test_tlssettings.cpp +++ b/src/tests/test_tlssettings.cpp @@ -330,6 +330,7 @@ void TestTlsSettings::elidesHostNamesWithoutChangingValue() widget.resize(460, 340); auto* hosts = widget.findChild("hostNames"); QVERIFY(hosts); + hosts->setMinimumWidth(hosts->sizeHint().width()); const QString full = "controller-with-a-long-hostname.example.test, localhost, 127.0.0.1, ::1"; hosts->setText(full); widget.show(); From a77db8dc9f3ad372cd71acab76ddaa6e8abfa1f8 Mon Sep 17 00:00:00 2001 From: Alexandr Ananev Date: Fri, 6 Nov 2026 08:52:21 +0700 Subject: [PATCH 7/7] feat: Add private key matching check for TLS certificates and corresponding test --- src/core/pki/tlscertificatestore.cpp | 23 ++++++++++++++++++++++- src/tests/test_tlscertificatestore.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/core/pki/tlscertificatestore.cpp b/src/core/pki/tlscertificatestore.cpp index 9640cacd..2e9ba424 100644 --- a/src/core/pki/tlscertificatestore.cpp +++ b/src/core/pki/tlscertificatestore.cpp @@ -18,9 +18,30 @@ #include #include #include +#include +#include +#include namespace { +/// +/// \brief Checks that the first PEM certificate matches the contained private key. +/// +bool hasMatchingPrivateKey(const QByteArray& bytes) +{ + const std::unique_ptr certificateBio( + BIO_new_mem_buf(bytes.constData(), bytes.size()), BIO_free); + const std::unique_ptr keyBio( + BIO_new_mem_buf(bytes.constData(), bytes.size()), BIO_free); + if (!certificateBio || !keyBio) + return false; + const std::unique_ptr certificate( + PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr), X509_free); + const std::unique_ptr key( + PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr), EVP_PKEY_free); + return certificate && key && X509_check_private_key(certificate.get(), key.get()) == 1; +} + /// /// \brief Translates store failures without a QObject dependency. /// @@ -189,7 +210,7 @@ QString TlsCertificateStore::configuration(QSslConfiguration& configuration) con const auto bytes = identity.readAll(); const auto certificates = QSslCertificate::fromData(bytes); const QSslKey key(bytes, QSsl::Rsa); - if (certificates.isEmpty() || key.isNull()) + if (certificates.isEmpty() || key.isNull() || !hasMatchingPrivateKey(bytes)) return tr("The stored TLS server identity is invalid. Generate a new server certificate."); configuration = QSslConfiguration::defaultConfiguration(); configuration.setProtocol(QSsl::TlsV1_2OrLater); diff --git a/src/tests/test_tlscertificatestore.cpp b/src/tests/test_tlscertificatestore.cpp index 01115075..739fa5b2 100644 --- a/src/tests/test_tlscertificatestore.cpp +++ b/src/tests/test_tlscertificatestore.cpp @@ -37,6 +37,7 @@ private slots: void loadsNamedIdentitiesOnly(); void preservesIdentityOnFailure(); void rejectsMissingAndCorruptIdentity(); + void rejectsMismatchedIdentity(); }; /// @@ -325,5 +326,29 @@ void TestTlsCertificateStore::rejectsMissingAndCorruptIdentity() QVERIFY(!store.configuration(configuration).isEmpty()); } +/// +/// \brief Rejects a readable certificate paired with a different private key. +/// +void TestTlsCertificateStore::rejectsMismatchedIdentity() +{ + QTemporaryDir directory; + TlsCertificateStore store(directory.path()); + QString error; + const auto first = CertificateGenerator::generate("first", {"localhost"}, 2, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const auto second = CertificateGenerator::generate("second", {"localhost"}, 2, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QByteArray bytes = first.Certificate + second.PrivateKey; + QVERIFY(!QSslCertificate::fromData(bytes).isEmpty()); + QVERIFY(!QSslKey(bytes, QSsl::Rsa).isNull()); + QVERIFY(QDir().mkpath(directory.filePath("own"))); + QFile file(directory.filePath("own/" + TlsCertificateStore::generatedFileName())); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(bytes), qint64(bytes.size())); + file.close(); + QSslConfiguration configuration; + QVERIFY(!store.configuration(configuration).isEmpty()); +} + QTEST_GUILESS_MAIN(TestTlsCertificateStore) #include "test_tlscertificatestore.moc"