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..f0dbadea --- /dev/null +++ b/.github/actions/install-openssl/action.yml @@ -0,0 +1,19 @@ +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 +runs: + using: composite + steps: + - name: Install OpenSSL + shell: pwsh + run: | + python -m pip install aqtinstall + $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..8bc9493e --- /dev/null +++ b/.github/actions/install-openssl11/action.yml @@ -0,0 +1,53 @@ +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' + 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 + + - name: Install Windows OpenSSL + if: runner.os == 'Windows' + uses: ./.github/actions/install-openssl + 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/.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..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 \ @@ -146,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 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..6fdf606b 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 1.1 + uses: ./.github/actions/install-openssl11 + 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..61439400 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 1.1 + uses: ./.github/actions/install-openssl11 + 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..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: @@ -52,6 +56,7 @@ jobs: env: # Run Qt GUI/Test binaries without a real display. QT_QPA_PLATFORM: offscreen + OMODSIM_REQUIRE_TLS: '1' steps: - name: Checkout @@ -63,12 +68,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 +88,14 @@ jobs: modules: ${{ matrix.qt-modules }} cache: true + - name: Install OpenSSL 3 (Windows) + if: runner.os == 'Windows' && matrix.qt-version != '5.15.2' + uses: ./.github/actions/install-openssl + + - name: Install OpenSSL 1.1 (Qt 5) + if: matrix.qt-version == '5.15.2' + uses: ./.github/actions/install-openssl11 + - name: Configure run: > cmake -S src -B build -G Ninja @@ -94,6 +109,28 @@ 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/ + build/tests/*.testlog.txt + coverage: name: Coverage (${{ matrix.os }}, Qt ${{ matrix.qt-version }}) runs-on: ${{ matrix.os }} @@ -108,6 +145,7 @@ jobs: env: QT_QPA_PLATFORM: offscreen + OMODSIM_REQUIRE_TLS: '1' steps: - name: Checkout @@ -118,7 +156,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..262ecb42 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 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 6b9d62a9..e7e24ea2 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 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-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..a2972ef1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -477,6 +477,12 @@ if (-not (Test-Path $QtDir)) { } Write-Host "Found Qt: $QtDir" +$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 -Version $OpenSslVersion +$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 +503,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 +572,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..09f79ea9 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,24 @@ else echo "Using Qt $QT_VERSION from: $CMAKE_PREFIX" fi +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 + 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..9bf4c41c --- /dev/null +++ b/docs/modbus-tls.md @@ -0,0 +1,58 @@ +# Modbus TCP over TLS + +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 + +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/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. + +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. + +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/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 1dfb1459..9bdcf341 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,10 @@ Vuoi aprire la pagina di download? 502 502 + + Modbus TCP Security (TLS) + + DialogSetupSerialPort @@ -2978,6 +2997,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 +3421,84 @@ 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… + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + + Certificate parameters + + + TrafficLogWindow diff --git a/src/app/translations/omodsim_ru.qm b/src/app/translations/omodsim_ru.qm index 6dd67407..d53122a2 100644 Binary files a/src/app/translations/omodsim_ru.qm and b/src/app/translations/omodsim_ru.qm differ diff --git a/src/app/translations/omodsim_ru.ts b/src/app/translations/omodsim_ru.ts index 139cf600..c4fd2801 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,10 @@ Open the download page? 502 + + Modbus TCP Security (TLS) + Modbus TCP Security (TLS) + DialogSetupSerialPort @@ -2978,6 +2997,13 @@ Please verify the merge result carefully. Указаны некорректные настройки для TCP соединения. + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + В этой установке Qt отсутствует поддержка TLS. + + OutputDataWidget @@ -3395,6 +3421,84 @@ 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… + Экспорт сертификата… + + + 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 + Сертификаты + + + Certificate parameters + Параметры сертификата + + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_CN.ts b/src/app/translations/omodsim_zh_CN.ts index 5fed646c..68f704f8 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,10 @@ Open the download page? 502 502 + + Modbus TCP Security (TLS) + + DialogSetupSerialPort @@ -2979,6 +2998,13 @@ Please verify the merge result carefully. 指定的 TCP 通讯连接设定无效。 + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + + + OutputDataWidget @@ -3396,6 +3422,84 @@ 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… + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + + Certificate parameters + + + TrafficLogWindow diff --git a/src/app/translations/omodsim_zh_TW.ts b/src/app/translations/omodsim_zh_TW.ts index 0a108a3a..11a8cde1 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,10 @@ Open the download page? 502 502 + + Modbus TCP Security (TLS) + + DialogSetupSerialPort @@ -2979,6 +2998,13 @@ Please verify the merge result carefully. 指定的 TCP 通訊連線設定無效。 + + ModbusTlsListener + + TLS is unavailable in this Qt installation. + + + OutputDataWidget @@ -3396,6 +3422,84 @@ 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… + + + + Replace server certificate + + + + Clients will need to trust the new certificate. Replace the existing server certificate? + + + + Export server certificate + + + + PEM certificate (*.pem) + + + + Certificates + + + + Certificate parameters + + + 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..7ae81b02 --- /dev/null +++ b/src/cmake/openssl.cmake @@ -0,0 +1,24 @@ +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) + 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/${omodsim_openssl_license}" + "${PROJECT_BINARY_DIR}/docs/licenses/OpenSSL.txt" COPYONLY) diff --git a/src/cmake/tests.cmake b/src/cmake/tests.cmake index ee879aff..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) @@ -21,11 +31,11 @@ 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}) - add_test(NAME ${test_name} COMMAND ${test_name}) - 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) @@ -58,6 +68,12 @@ 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) + 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) omodsim_add_test(omodsim_tests_rtusnifferparser test_rtusnifferparser.cpp) @@ -66,17 +82,18 @@ 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) - add_test(NAME omodsim_tests_projecttreewidget COMMAND omodsim_tests_projecttreewidget) - 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 "${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" @@ -86,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) - set_tests_properties(omodsim_tests_appproject PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + omodsim_register_test(omodsim_tests_appproject) endfunction() 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..2e9ba424 --- /dev/null +++ b/src/core/pki/tlscertificatestore.cpp @@ -0,0 +1,222 @@ +// 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 +#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. +/// +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 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 +{ + 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; +} + +/// +/// \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(_root + "/own/" + generatedFileName(), 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 = 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()); +} + +/// +/// \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() || !hasMatchingPrivateKey(bytes)) + 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..fbb50495 --- /dev/null +++ b/src/core/pki/tlscertificatestore.h @@ -0,0 +1,88 @@ +// 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 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. + /// + 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..8e3bf66a --- /dev/null +++ b/src/tests/test_modbustls.cpp @@ -0,0 +1,320 @@ +// 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() +{ + 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"); + 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/" + TlsCertificateStore::generatedFileName()); + 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..739fa5b2 --- /dev/null +++ b/src/tests/test_tlscertificatestore.cpp @@ -0,0 +1,354 @@ +// 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 +#include +#include "pki/certificategenerator.h" +#include "pki/tlscertificatestore.h" + +class TestTlsCertificateStore : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + void generatesServerIdentity(); + void usesSingleOpenSslVersion(); + void createsDefaultIdentityOnce(); + void reportsStartupGenerationFailures(); + void rejectsInvalidGeneration_data(); + void rejectsInvalidGeneration(); + void persistsAndExportsIdentity(); + void ignoresLegacyTrustFiles(); + void loadsNamedIdentitiesOnly(); + void preservesIdentityOnFailure(); + void rejectsMissingAndCorruptIdentity(); + void rejectsMismatchedIdentity(); +}; + +/// +/// \brief Skips store integration when Qt cannot load a TLS backend. +/// +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"); +} + +/// +/// \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. +/// +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. +/// +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()); + 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_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); + 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()); + 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; + 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(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))); +#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. +/// +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/" + TlsCertificateStore::generatedFileName())); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("invalid identity"); + file.close(); + 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" diff --git a/src/tests/test_tlssettings.cpp b/src/tests/test_tlssettings.cpp new file mode 100644 index 00000000..2c672488 --- /dev/null +++ b/src/tests/test_tlssettings.cpp @@ -0,0 +1,436 @@ +// 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 "elidinglineedit.h" +#include "tlscertificateswidget.h" +#include "dialogmodbusdefinitions.h" +#include +#include +#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 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; +}; + +/// +/// \brief Exercises the translated interface with the production style when available. +/// +void TestTlsSettings::initTestCase() +{ + QVERIFY(_translator.load(QStringLiteral(":/res/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); + QVERIFY(dialog.findChild("tlsGroup")->toolTip().isEmpty()); + 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()); + 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(); + 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()); +} + +/// +/// \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")); +} + +/// +/// \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")); + 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")); + 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* 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(certificateTab, QPoint()).y() + - validity->mapTo(certificateTab, 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(); + QVERIFY2(dialog.height() < 480, qPrintable(QString::number(dialog.height()))); + 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); + 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(); + auto* name = widget.findChild("commonName"); + name->setFocus(); + QCoreApplication::processEvents(); + QVERIFY(!hosts->hasFocus()); + 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()); + 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 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. +/// +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..20e80634 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,12 @@ set(UI_FILES ) add_library(omodsim_ui STATIC + widgets/settingstabwidget.cpp + widgets/settingstabwidget.h + 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..c76a1ef8 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.cpp +++ b/src/ui/dialogs/dialogmodbusdefinitions.cpp @@ -7,6 +7,9 @@ /// #include +#include +#include +#include "themedicons.h" #include "dialogmodbusdefinitions.h" #include "ui_dialogmodbusdefinitions.h" @@ -21,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) { @@ -95,6 +106,23 @@ DialogModbusDefinitions::~DialogModbusDefinitions() delete ui; } +/// +/// \brief Keeps the settings dialog inside the available screen area. +/// +QSize DialogModbusDefinitions::sizeHint() const +{ + 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; +} + /// /// \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..bd7b2158 100644 --- a/src/ui/dialogs/dialogmodbusdefinitions.ui +++ b/src/ui/dialogs/dialogmodbusdefinitions.ui @@ -6,8 +6,8 @@ 0 0 - 380 - 313 + 650 + 600 @@ -15,7 +15,7 @@ - + 0 @@ -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,8 @@ + SettingsTabWidgetQTabWidget
settingstabwidget.h
1
+ 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..df44d1d3 100644 --- a/src/ui/dialogs/dialogselectserviceport.ui +++ b/src/ui/dialogs/dialogselectserviceport.ui @@ -58,6 +58,11 @@ + + + Modbus TCP Security (TLS) + + 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/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/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/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 new file mode 100644 index 00000000..0d788632 --- /dev/null +++ b/src/ui/widgets/tlscertificateswidget.cpp @@ -0,0 +1,126 @@ +// 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 "themedicons.h" +#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. +/// \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); + 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)); + } + 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); + 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(); + } else if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) { + refresh(); + } + QWidget::changeEvent(event); +} + +/// +/// \brief Updates certificate actions and icons for the current palette. +/// +void TlsCertificatesWidget::refresh() +{ + 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()); +} + +/// +/// \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"), TlsCertificateStore::generatedFileName(), 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..185eb866 --- /dev/null +++ b/src/ui/widgets/tlscertificateswidget.ui @@ -0,0 +1,33 @@ + + + TlsCertificatesWidget + + + 10 + 0 + 12 + 0 + 0 + trueCertificate parameters + + 18181818Server name + 64 + 18181818Host names / IP addresses + DNS names and IP addresses clients use to reach this server, separated by commas. + 18181818Valid for (days) + 136503650 + + + Qt::Horizontal200 + Generate…false + Export certificate…false + + Qt::Vertical00 + + + + ElidingLineEditQLineEdit
elidinglineedit.h
+
+ + +
diff --git a/third_party/licenses/OpenSSL-1.1.txt b/third_party/licenses/OpenSSL-1.1.txt new file mode 100644 index 00000000..5b5ccdc9 --- /dev/null +++ b/third_party/licenses/OpenSSL-1.1.txt @@ -0,0 +1,124 @@ + + 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/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..49d30231 --- /dev/null +++ b/tools/build_openssl.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [3|1.1]" >&2 + exit 1 +fi + +source_root="$1" +install_prefix="$2" +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 + 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/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 new file mode 100644 index 00000000..33dd7974 --- /dev/null +++ b/tools/install_openssl.ps1 @@ -0,0 +1,56 @@ +param( + [ValidateSet('x64', 'x86')][string]$Architecture = 'x64', + [ValidateSet('1.1', '3')][string]$Version = '3', + [string]$QtToolsRoot = 'C:\Qt' +) + +$ErrorActionPreference = 'Stop' + +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 ($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 { + $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.' } + } + 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) -or + -not (Get-ChildItem (Join-Path $opensslRoot 'bin') -Filter 'libssl-*.dll' -ErrorAction SilentlyContinue)) { + throw "OpenSSL $Version installation is incomplete: $opensslRoot" +} +Write-Output $opensslRoot