diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ae30ec..e6d0cd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,12 @@ -# Linux and macOS builds. PyInstaller does not cross-compile, so each is -# made on its own runner by tools/build_unix.py, then STARTED: the build is -# kept only if the program is still running 20 seconds later, and a screen -# shot of it is kept with the build (artifacts). Windows builds are made by -# hand with tools/build_installer.py. +# The release builds. PyInstaller does not cross-compile, so each system is +# built on its own runner, then STARTED: the build is kept only if the +# program is still running 20 seconds later, and a screen shot of it is kept +# with the build (artifacts). +# +# windows tools/build_installer.py — the installer and the two portable +# exes, signed by SignPath Foundation (see the job) +# linux tools/build_unix.py — the AppImage +# macos tools/build_unix.py — ModbusReader.app in a zip # # Runs for a version tag and by hand (Actions → build → Run workflow). name: build @@ -16,6 +20,185 @@ permissions: contents: read jobs: + windows: + # Code signing (https://modbusreader.ru/code-signing/): SignPath signs + # only what this job built from the tagged source, and only after a + # person approved the request on app.signpath.io — twice per build: + # 1. the programs: the one-folder app, the launcher, the portable exes + # (the installer must carry them signed, so it is built after); + # 2. the installer. + # On while the repository has the variable SIGNPATH_ORGANIZATION_ID + # (and the secret SIGNPATH_API_TOKEN); without it the same files are + # built unsigned. installer/signpath/ holds the two artifact + # configurations and the set-up steps. + runs-on: windows-latest + timeout-minutes: 150 # mostly waiting for the two approvals + permissions: + contents: read + actions: read # SignPath fetches the unsigned artifacts + env: + PYTHONUTF8: "1" + SIGNPATH_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG || 'release-signing' }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + + - name: Dependencies (the pinned versions) + run: python -m pip install -r requirements-dev.txt + + # The style of installer/ModbusReader.iss needs a current Inno Setup, + # whatever the runner image carries. + - name: Inno Setup + run: choco upgrade innosetup --no-progress -y + + - name: Tests + run: python -m pytest -q -p no:cacheprovider + + - name: Build the programs + run: python tools/build_installer.py --portable --only programs + + # Four files, three of them named ModbusReader.exe: one folder each. + # installer/signpath/programs.xml lists the same paths. + - name: Collect the programs for signing + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + run: | + $s = "signing/programs" + New-Item -ItemType Directory -Force "$s/program", "$s/launcher", "$s/portable" | Out-Null + Copy-Item dist/ModbusReader/ModbusReader.exe "$s/program/" + Copy-Item build/launcher/ModbusReader.exe "$s/launcher/" + Copy-Item dist/ModbusReader.exe, dist/ModbusReader_debug.exe "$s/portable/" + + - id: unsigned-programs + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@v7 + with: + name: unsigned-programs + path: signing/programs + retention-days: 1 + overwrite: true + + - name: Sign the programs (approve on app.signpath.io) + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'ModbusReader' }} + signing-policy-slug: ${{ env.SIGNPATH_POLICY }} + artifact-configuration-slug: programs + github-artifact-id: ${{ steps.unsigned-programs.outputs.artifact-id }} + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: signing/programs-signed + + - name: Put the signed programs in place + if: vars.SIGNPATH_ORGANIZATION_ID != '' + shell: pwsh + run: | + $s = "signing/programs-signed" + Copy-Item -Force "$s/program/ModbusReader.exe" dist/ModbusReader/ + Copy-Item -Force "$s/launcher/ModbusReader.exe" build/launcher/ + Copy-Item -Force "$s/portable/ModbusReader.exe", "$s/portable/ModbusReader_debug.exe" dist/ + + - name: Build the installer + run: python tools/build_installer.py --only installer + + # The whole way a user goes: install for the current user (no rights + # needed), start the launcher, which starts the version folder. + - name: Install it and start it + shell: pwsh + run: | + $setup = Start-Process dist/ModbusReader-Setup.exe -PassThru -Wait -ArgumentList ` + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER', '/NOLAUNCH' + if ($setup.ExitCode) { Write-Output "::error::the installer exited with $($setup.ExitCode)"; exit 1 } + $root = "$env:LOCALAPPDATA\Programs\ModbusReader" + Start-Process "$root\ModbusReader.exe" + Start-Sleep 20 + try { + Add-Type -AssemblyName System.Windows.Forms, System.Drawing + $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height + [System.Drawing.Graphics]::FromImage($bmp).CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size) + $bmp.Save("$PWD\dist\screen-windows.png") + } catch { Write-Output "no screen shot: $_" } + $app = Get-Process ModbusReader -ErrorAction SilentlyContinue | + Where-Object { $_.Path -like "$root\app-*" } + if (-not $app) { Write-Output "::error::the program exited"; exit 1 } + $app | Stop-Process -Force + $log = "$env:APPDATA\ModbusReader\modbusreader_errors.log" + if ((Test-Path $log) -and (Get-Item $log).Length) { Get-Content $log; exit 1 } + + - id: unsigned-installer + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: actions/upload-artifact@v7 + with: + name: unsigned-installer + path: dist/ModbusReader-Setup.exe + retention-days: 1 + overwrite: true + + - name: Sign the installer (approve on app.signpath.io) + if: vars.SIGNPATH_ORGANIZATION_ID != '' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'ModbusReader' }} + signing-policy-slug: ${{ env.SIGNPATH_POLICY }} + artifact-configuration-slug: installer + github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: signing/installer-signed + + - name: Put the signed installer in place + if: vars.SIGNPATH_ORGANIZATION_ID != '' + run: Copy-Item -Force signing/installer-signed/ModbusReader-Setup.exe dist/ + + # Each file with its signature and SHA-256 (what GitHub will show for + # the release asset). When signing is on, a file without a valid + # signature fails the build; SignPath's test-signing policy signs with + # a certificate Windows does not trust, so then it only has to be there. + - name: Signatures + shell: pwsh + env: + SIGNING: ${{ vars.SIGNPATH_ORGANIZATION_ID != '' }} + run: | + $bad = 0 + foreach ($f in 'dist/ModbusReader-Setup.exe', 'dist/ModbusReader.exe', 'dist/ModbusReader_debug.exe', + 'dist/ModbusReader/ModbusReader.exe', 'build/launcher/ModbusReader.exe') { + $sig = Get-AuthenticodeSignature $f + $hash = (Get-FileHash $f -Algorithm SHA256).Hash.ToLower() + '{0,-36} {1,-13} {2} {3}' -f $f, $sig.Status, $hash, $sig.SignerCertificate.Subject + $ok = if ($env:SIGNPATH_POLICY -eq 'test-signing') { $sig.SignerCertificate -and $sig.Status -ne 'HashMismatch' } + else { $sig.Status -eq 'Valid' } + if ($env:SIGNING -eq 'true' -and -not $ok) { $bad++ } + } + if ($bad) { Write-Output "::error::$bad file(s) without a valid signature"; exit 1 } + + - uses: actions/upload-artifact@v7 + with: + name: windows + # All in dist/, so the artifact holds them without a folder. + path: | + dist/ModbusReader-Setup.exe + dist/ModbusReader.exe + dist/ModbusReader_debug.exe + dist/screen-windows.png + if-no-files-found: error + retention-days: 30 + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: windows-failed + path: dist/screen-windows.png + if-no-files-found: ignore + retention-days: 30 + linux: # The oldest supported Ubuntu: an AppImage runs on systems with the same # or a NEWER glibc than the one it was built on, never an older one. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a9c5c1..d9c7fd7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,5 @@ -# The test suite on Linux and macOS (Windows is where the releases are built -# and tested by hand). Each run also photographs every screen headless +# The test suite on Linux and macOS (on Windows it runs in the release build, +# .github/workflows/build.yml). Each run also photographs every screen headless # (tools/ui_snapshots.py) and keeps the pictures as an artifact, to see how # the window looks with that system's fonts. # diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 435a99f..4091deb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -429,9 +429,20 @@ pictures, from the demo project). | `installer/ModbusReader.iss` | the installer (Inno Setup 6) | | `ModbusReader_unix.spec` | Linux and macOS: the program as a folder / `ModbusReader.app` | -Windows builds are made locally by `tools/build_installer.py`. Linux and -macOS builds cannot be (PyInstaller does not cross-compile): -`.github/workflows/build.yml` runs `tools/build_unix.py` on an Ubuntu 22.04 +Release builds come from `.github/workflows/build.yml`, one runner per +system (PyInstaller does not cross-compile). **Windows**: the tests, then +`tools/build_installer.py` in two halves — `--portable --only programs` +(the one-folder program, the launcher, the portable exes) and +`--only installer` (ISCC) — with a SignPath signing request after each, so +the installer carries signed files and is signed itself; then a silent +per-user install, a start of the launcher and the same 20-second check as +below. SignPath Foundation signs only artifacts of this workflow, after a +person approves each request; the job signs while the repository has the +variable `SIGNPATH_ORGANIZATION_ID` and builds the same files unsigned +without it (`installer/signpath/` — the artifact configurations and the +set-up; the public policy is `docs/code-signing.md`). A local +`tools/build_installer.py` build is for testing: it is never signed. +**Linux and macOS**: `tools/build_unix.py` on an Ubuntu 22.04 runner — `ModbusReader-x86_64.AppImage`, built on the oldest supported glibc so it runs on newer ones — and a macOS arm64 runner — `ModbusReader-macOS-arm64.zip`, ad-hoc signed, not notarized. Each build is @@ -471,7 +482,9 @@ commit passes the test suite. ## 11. Known limitations - Polling reads one register per request; block reads are not implemented yet. -- Builds are not code-signed, so Windows SmartScreen warns on first - installation. +- Windows builds are signed only once SignPath Foundation has accepted the + project; until then, and for a new version with few downloads, Windows + SmartScreen warns on first installation. Linux and macOS builds are not + signed (macOS: ad-hoc only). - `ModernScadaApp` is still the largest module and the natural next candidate for splitting (project handling, overlay management). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbfc31..a47ee0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ неверной CRC, оборванный или искажённый кадр учитывается как ошибка CRC, а не как таймаут; эхо адаптера RS-485 и помехи перед ответом обрабатываются; ответ читается сразу, как только он полностью пришёл. +- Файлы для Windows собирает GitHub Actions из исходного кода выпуска, а не + компьютер разработчика: так их сможет подписывать SignPath Foundation + (бесплатная подпись для открытых проектов). Что подписывается и какие + данные программа передаёт по сети — в + [политике подписи кода](https://modbusreader.ru/code-signing/). ### Исправлено - Демо-проект в английском интерфейсе показывал русские единицы, подписи diff --git a/README.md b/README.md index 1c76023..177563c 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,11 @@ Linux / macOS: те же шаги (`source .venv/bin/activate`, `python main.py` `ModbusReader.exe` / `ModbusReader_debug.exe` (с консолью). Сведения о версии exe берутся из `app/version.py`; иконка и заставка — `tools/make_icon.py`, `tools/make_splash.py`. + +Локальная сборка — для проверки. Файлы выпуска собирает GitHub Actions +(`.github/workflows/build.yml`): тесты, сборка в два этапа (`--only programs`, +затем `--only installer`), подпись через SignPath после каждого этапа, пробная +установка и запуск. Настройка подписи — [installer/signpath/](installer/signpath/README.md).
@@ -310,7 +315,8 @@ CHANGELOG в раздел выпуска со ссылкой на него. В репозитории один выпуск — всегда последний: новая версия заменяет в нём файлы `ModbusReader-Setup.exe`, `ModbusReader.exe` и `ModbusReader_debug.exe` -и переносит его на свой тег `vX.Y.Z` (не pre-release). Теги всех версий +(подписанные, из сборки GitHub Actions для тега) и переносит его на свой тег +`vX.Y.Z` (не pre-release). Теги всех версий остаются в истории, а выпуск — один. Скачанное программа сверяет с SHA-256, который GitHub считает при загрузке файла. @@ -377,6 +383,14 @@ ModbusReader бесплатен и таким останется. Если пр вопросы по работе с программой — в [Discussions](https://github.com/Mercurrial/ModbusReader/discussions). О найденных уязвимостях сообщайте по правилам [SECURITY.md](SECURITY.md). +## Подпись кода + +Бесплатная подпись кода предоставлена [SignPath.io](https://about.signpath.io/), +сертификат — [SignPath Foundation](https://signpath.org/). Файлы для Windows +собирает GitHub Actions из исходного кода выпуска; что подписывается, кто +утверждает выпуски и какие данные программа передаёт по сети — в +[политике подписи кода](https://modbusreader.ru/code-signing/). + ## Лицензия ModbusReader — свободная программа: её можно использовать (в том числе в diff --git a/SECURITY.md b/SECURITY.md index f6a093c..3744d14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,10 @@ GitHub и устанавливает файл, лишь если его SHA-256 совпадает с контрольной суммой, которую GitHub публикует для файла выпуска. Любой обход этой проверки — уязвимость. +- **Подпись выпусков.** Файлы для Windows подписываются сертификатом + SignPath Foundation только после сборки в GitHub Actions и ручного + утверждения ([политика подписи кода](https://modbusreader.ru/code-signing/)). + Способ получить такую подпись в обход этой сборки — уязвимость. - **Открытие файлов.** Карты регистров (`.json`), проекты (`.mbproj`), дашборды (`.mbdash`), снимки (`.mbsnap`) и импортируемые таблицы могут прийти от третьих лиц. Выполнение кода или запись за пределами выбранного @@ -50,5 +54,6 @@ - Протокол Modbus RTU не предусматривает аутентификации и шифрования; любой, кто подключён к линии RS-485, может читать и записывать регистры. Это свойство протокола, а не программы. -- Предупреждение Windows SmartScreen при первом запуске: сборки пока не - подписаны сертификатом разработчика. +- Предупреждение Windows SmartScreen при первом запуске: неподписанные + выпуски (до подключения подписи) и только что вышедшая версия, у которой + ещё мало скачиваний. diff --git a/core/updater.py b/core/updater.py index 9e48a64..64564af 100644 --- a/core/updater.py +++ b/core/updater.py @@ -497,6 +497,9 @@ def start_detached(args: list[str]) -> subprocess.Popen: | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)) kw = dict(stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, env=child_env()) + if sys.platform != "win32": + # creationflags is Windows-only (Popen raises ValueError elsewhere). + return subprocess.Popen(args, start_new_session=True, **kw) try: return subprocess.Popen(args, creationflags=flags | 0x01000000, **kw) except OSError: diff --git a/docs/code-signing.en.md b/docs/code-signing.en.md new file mode 100644 index 0000000..4e0f18f --- /dev/null +++ b/docs/code-signing.en.md @@ -0,0 +1,95 @@ +--- +seo_title: ModbusReader code signing policy +description: How the Windows builds of ModbusReader are signed — free code signing by SignPath.io, certificate by SignPath Foundation. What is signed, who approves releases, how to check a signature and what the app sends over the network. +--- + +# Code signing policy + +Free code signing provided by [SignPath.io](https://about.signpath.io/), +certificate by [SignPath Foundation](https://signpath.org/). + +## What is signed + +The Windows files of a release on the +[Releases](https://github.com/Mercurrial/ModbusReader/releases/latest) page: + +- `ModbusReader-Setup.exe` — the installer; +- `ModbusReader.exe` and `ModbusReader_debug.exe` — the portable builds; +- inside the installer — the program itself and the `ModbusReader.exe` that + starts it. + +Only files built from the source code of the +[ModbusReader repository](https://github.com/Mercurrial/ModbusReader) are +signed. The libraries of other projects that the build contains (Python, Qt +and others — see +[THIRD_PARTY_LICENSES.md](https://github.com/Mercurrial/ModbusReader/blob/main/THIRD_PARTY_LICENSES.md)) +are not signed with this certificate. The Linux and macOS builds are not +signed. + +Releases built after signing was set up are signed; older ones have no +"Digital Signatures" tab in the file's properties. + +## How a signed file is made + +1. A release is a tag `vYY.MM.N` in the repository. +2. GitHub Actions builds the files from the source code of that tag + ([`.github/workflows/build.yml`](https://github.com/Mercurrial/ModbusReader/blob/main/.github/workflows/build.yml)), + not a developer's computer. +3. SignPath verifies that the files come from that very build of this + repository and signs them only after a release approver has approved the + request by hand. + +The certificate's private key is kept by SignPath in a hardware security +module (HSM): the developers of the app do not have it and cannot sign +anything with it outside the build. + +## Team roles + +| Role | Members | +|---|---| +| Committers and reviewers | [Mercurrial](https://github.com/Mercurrial) | +| Approvers | [Mercurrial](https://github.com/Mercurrial) | + +Changes from other contributors (pull requests) get into the code only after +a reviewer has checked them. Everyone listed above uses two-factor +authentication on GitHub and on SignPath. + +## How to check a signature + +Right-click the file → **Properties → Digital Signatures**. The list should +show a signature by **SignPath Foundation**, and **Details** should say "This +digital signature is OK". + +The same in PowerShell: + +```powershell +Get-AuthenticodeSignature .\ModbusReader-Setup.exe | Format-List Status, SignerCertificate +``` + +`Status` should be `Valid`. If there is no signature or Windows says it is +not valid, do not run the file and +[report it](https://github.com/Mercurrial/ModbusReader/security/advisories/new). + +For a brand-new version Windows SmartScreen may still show a warning for a +while, until the version has been downloaded enough; it names SignPath +Foundation as the publisher. What to do — see [Install](install.md#smartscreen). + +## Privacy policy + +This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or +operating it. The one thing it does by itself is check for updates: + +- at start-up and every few hours the program asks GitHub + (`api.github.com`) for the latest release and, if a new version is out, + downloads its file from GitHub. The request carries only the program's + version; GitHub, like any website, sees the computer's IP address. How + GitHub handles that data is described in its + [privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement); +- the check can be turned off: **Settings → General → Updates**, the + "Update automatically" switch. + +Everything else — connecting to a device over Modbus TCP, looking for +devices on the network with the scanner, opening pages in the browser — the +program does only when the user tells it to. It sends no telemetry, usage +statistics or error reports. diff --git a/docs/code-signing.md b/docs/code-signing.md new file mode 100644 index 0000000..858199c --- /dev/null +++ b/docs/code-signing.md @@ -0,0 +1,93 @@ +--- +seo_title: Политика подписи кода ModbusReader +description: Как подписываются сборки ModbusReader для Windows — бесплатная подпись SignPath.io, сертификат SignPath Foundation. Что подписывается, кто утверждает выпуски, как проверить подпись и какие данные программа передаёт по сети. +--- + +# Политика подписи кода + +Бесплатная подпись кода предоставлена [SignPath.io](https://about.signpath.io/), +сертификат — [SignPath Foundation](https://signpath.org/). + +## Что подписывается + +Файлы выпуска для Windows со страницы +[Releases](https://github.com/Mercurrial/ModbusReader/releases/latest): + +- `ModbusReader-Setup.exe` — установщик; +- `ModbusReader.exe` и `ModbusReader_debug.exe` — портативные версии; +- внутри установщика — сама программа и запускающий её `ModbusReader.exe`. + +Подписываются только файлы, собранные из исходного кода +[репозитория ModbusReader](https://github.com/Mercurrial/ModbusReader). +Библиотеки других проектов, которые входят в сборку (Python, Qt и другие — +см. [THIRD_PARTY_LICENSES.md](https://github.com/Mercurrial/ModbusReader/blob/main/THIRD_PARTY_LICENSES.md)), +этой подписью не подписываются. Сборки для Linux и macOS не подписываются. + +Подписаны выпуски, собранные после подключения подписи; у более ранних +вкладки «Цифровые подписи» в свойствах файла нет. + +## Как получается подписанный файл + +1. Выпуск — это тег `vГГ.ММ.N` в репозитории. +2. Файлы собирает GitHub Actions из исходного кода этого тега + ([`.github/workflows/build.yml`](https://github.com/Mercurrial/ModbusReader/blob/main/.github/workflows/build.yml)), + а не компьютер разработчика. +3. SignPath проверяет, что файлы получены именно этой сборкой из этого + репозитория, и подписывает их только после того, как запрос вручную + утвердил ответственный за выпуски. + +Закрытый ключ сертификата хранится у SignPath в аппаратном модуле (HSM): +у разработчиков программы его нет, и подписать им что-то в обход сборки +нельзя. + +## Кто отвечает + +| Роль | Участники | +|---|---| +| Авторы и рецензенты кода | [Mercurrial](https://github.com/Mercurrial) | +| Утверждают подпись выпусков | [Mercurrial](https://github.com/Mercurrial) | + +Изменения от других участников (pull request) попадают в код только после +проверки рецензентом. У всех, кто указан в таблице, включена двухфакторная +аутентификация на GitHub и в SignPath. + +## Как проверить подпись + +Щёлкните файл правой кнопкой мыши → **«Свойства» → «Цифровые подписи»**. +В списке должна быть подпись **SignPath Foundation**, а кнопка +**«Подробно»** покажет «Эта цифровая подпись действительна». + +То же в PowerShell: + +```powershell +Get-AuthenticodeSignature .\ModbusReader-Setup.exe | Format-List Status, SignerCertificate +``` + +`Status` должен быть `Valid`. Если подписи нет или Windows пишет, что она +недействительна, не запускайте файл и +[сообщите об этом](https://github.com/Mercurrial/ModbusReader/security/advisories/new). + +Для совсем новой версии Windows SmartScreen может ещё какое-то время +показывать предупреждение, пока у неё мало скачиваний; издателем в нём +будет указан SignPath Foundation. Что делать — в разделе +[Установка](install.md#smartscreen). + +## Конфиденциальность + +Программа не передаёт никаких сведений другим компьютерам в сети, если об +этом не попросил пользователь или тот, кто её устанавливает и настраивает. +Единственное, что она делает сама, — проверяет обновления: + +- при запуске и раз в несколько часов программа запрашивает у GitHub + (`api.github.com`) сведения о последнем выпуске, а если вышла новая + версия — скачивает её файл с GitHub. В запросе передаётся только номер + версии программы; GitHub, как любой сайт, видит IP-адрес компьютера. Как + GitHub обращается с этими данными, описано в его + [заявлении о конфиденциальности](https://docs.github.com/ru/site-policy/privacy-policies/github-general-privacy-statement); +- проверку можно выключить: **«Настройки» → «Общие» → «Обновления»**, + переключатель «Обновлять автоматически». + +Всё остальное — подключение к прибору по Modbus TCP, поиск приборов в сети +сканером, открытие страниц в браузере — программа делает только по команде +пользователя. Телеметрию, статистику использования и отчёты об ошибках она не +отправляет. diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 673381b..7c33901 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -1063,3 +1063,32 @@ page, menu, dialog and settings page headless for the next review. stale TCP answers, dead links) plus the core/statistics/scanner/raw-console paths; `test_library_contracts` guards that pymodbus never returns. Still to do: try it on real adapters (FTDI latency, cheap CH340 echo). +- **Code signing through SignPath Foundation** (2026-10-26, the user's + call: free signing for open source, the certificate is issued to SignPath + Foundation, which is then the publisher in the signature; the copyright + stays with the author). SignPath signs only an artifact uploaded by a + GitHub Actions run of this repository, so the Windows release files are + now built by `.github/workflows/build.yml` (job `windows`), not locally. + Two signing requests per build, each approved by hand on app.signpath.io: + the programs first (the one-folder `ModbusReader.exe`, the launcher, the + two portable exes — three files named ModbusReader.exe, hence one folder + each in the zip, `installer/signpath/programs.xml`), because Inno Setup + must pack them signed (Smart App Control and antivirus look at every + exe, not only the download); then the installer + (`installer/signpath/installer.xml`). `tools/build_installer.py --only + programs|installer` splits the build for that. The uninstaller + (`unins000.exe`, written by Setup at install time) stays unsigned: that + would take Inno's `SignTool` calling SignPath in the middle of ISCC, and + nothing downloads it. Third-party DLLs (Python, Qt) are not signed with + the certificate. Everything is gated on the repository variable + `SIGNPATH_ORGANIZATION_ID`: without it the job builds the same files + unsigned, so releases come from CI already before SignPath accepts the + project. A file without a valid signature fails the job (test-signing + policy: present is enough). The job also runs the test suite on Windows + (the launcher tests run only there) and installs the built Setup per + user (`/VERYSILENT /CURRENTUSER /NOLAUNCH`), starts the launcher and + checks after 20 s that the version folder's program runs and wrote no + error log. The policy SignPath requires on the home page is + `docs/code-signing.md`: roles, what is signed, how to verify, and the + privacy statement — the update check is the only traffic the app starts + by itself. diff --git a/installer/signpath/README.md b/installer/signpath/README.md new file mode 100644 index 0000000..dbceda3 --- /dev/null +++ b/installer/signpath/README.md @@ -0,0 +1,176 @@ +# Подпись кода через SignPath Foundation + +SignPath Foundation бесплатно подписывает сборки открытых проектов. Сертификат +выдан на SignPath Foundation: в свойствах файла издателем будет указан +SignPath Foundation. Права на программу остаются у автора. Публичная часть — +[политика подписи кода](https://modbusreader.ru/code-signing/) +(`docs/code-signing.md`), её SignPath требует на сайте проекта. + +Как устроена сборка: `.github/workflows/build.yml`, задание `windows`. Оно +отправляет в SignPath два запроса на подпись: + +| Запрос | Конфигурация | Что подписывается | +|---|---|---| +| 1 | [`programs.xml`](programs.xml), slug `programs` | программа (`dist/ModbusReader/ModbusReader.exe`), запускающий файл установленной копии, портативные `ModbusReader.exe` и `ModbusReader_debug.exe` | +| 2 | [`installer.xml`](installer.xml), slug `installer` | `ModbusReader-Setup.exe`, собранный уже из подписанных файлов | + +Пока в репозитории нет переменной `SIGNPATH_ORGANIZATION_ID`, задание собирает +те же файлы без подписи. + +## 1. До заявки + +1. Слейте в `main` изменения с этой папкой. Сайт опубликуется сам, и + страница https://modbusreader.ru/code-signing/ должна открываться. +2. Проверьте сборку на GitHub Actions: **Actions → build → Run workflow** + (ветка `main`). Через 15–20 минут в запуске появится артефакт `windows` с + `ModbusReader-Setup.exe`, портативными exe и снимком экрана. Установите + этот `ModbusReader-Setup.exe` у себя и убедитесь, что всё работает так же, + как с локальной сборкой. Отличие от локальной сборки одно: на GitHub нет + UPX, поэтому exe не сжаты им (файлы чуть больше, зато антивирусы меньше + придираются). +3. Выпускайте версии из этой сборки (см. раздел 5) — ещё до подписи. + SignPath подписывает только проекты, которые уже выпускаются в том виде, + в каком будут подписываться. +4. Включите двухфакторную аутентификацию на GitHub: **Settings → Password + and authentication → Two-factor authentication**. SignPath требует её у + всех, кто вносит код и утверждает подпись. + +## 2. Заявка + +На https://signpath.org откройте **Apply** (условия — https://signpath.org/terms) +и заполните форму. Данные для неё: + +| Поле | Значение | +|---|---| +| Project name | ModbusReader | +| Repository | https://github.com/Mercurrial/ModbusReader | +| Homepage | https://modbusreader.ru/ | +| Download page | https://github.com/Mercurrial/ModbusReader/releases/latest | +| Code signing policy | https://modbusreader.ru/code-signing/ | +| License | GPL-3.0-or-later | +| Build system | GitHub Actions (GitHub-hosted runners), `.github/workflows/build.yml` | +| What to sign | Windows PE files: an Inno Setup installer and PyInstaller executables, a small C# launcher | + +Описание проекта (на английском): + +> ModbusReader is a free, open-source desktop tool for commissioning and +> monitoring Modbus RTU and Modbus TCP devices: a register map with names +> and units, live trends, an HMI-style dashboard, a bus scanner, an RS-485 +> sniffer, Python macros and a Modbus slave simulator. Windows, Linux and +> macOS; Russian and English UI. All components are open source (see +> THIRD_PARTY_LICENSES.md). Windows releases are built by GitHub Actions +> from the tagged source; we would like to sign the installer and the +> executables so that users stop seeing SmartScreen's "unknown publisher" +> warning. + +Если спросят про состав сборки: все библиотеки открытые +(`THIRD_PARTY_LICENSES.md`); загрузчик PyInstaller — GPL-2.0 с исключением +для загрузчика; Inno Setup — открытый исходный код +(https://github.com/jrsoftware/issrc) под собственной разрешительной лицензией. + +Ответ придёт на почту — обычно через несколько дней, иногда через несколько недель. + +## 3. Настройка в SignPath + +После одобрения придёт приглашение на https://app.signpath.io. Войдите и +включите двухфакторную аутентификацию в своём профиле. + +Названия пунктов меню могут немного отличаться — ориентируйтесь на смысл. +Обычно SignPath Foundation сама создаёт проект, политики подписи +`test-signing` и `release-signing` и подключает GitHub как доверенную систему +сборки. Проверьте и дополните: + +1. **Projects → ModbusReader.** Запомните **slug** проекта. Если он не + `ModbusReader`, его нужно будет указать в GitHub (раздел 4). В настройках + проекта должен стоять репозиторий + `https://github.com/Mercurrial/ModbusReader`, в **Trusted Build Systems** + — GitHub.com. +2. **Artifact Configurations → Add**, две штуки: + - slug `programs`, содержимое — файл [`programs.xml`](programs.xml) + целиком; + - slug `installer`, содержимое — файл [`installer.xml`](installer.xml). +3. **Signing Policies.** В `release-signing` утверждающим (Approver) должны + быть вы. Тот, кто отправляет запросы (Submitter), — пользователь из + следующего шага. +4. **Users → Add CI user** (например, `github-actions`), затем **Generate API + token** — скопируйте токен сразу, второй раз его не покажут. Добавьте + этого пользователя в **Submitters** обеих политик (`test-signing` и + `release-signing`). +5. **ID организации** — в **Settings** организации (Organization ID, вида + `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) или в адресе страницы + `app.signpath.io/Web//…`. + +## 4. Настройка в GitHub + +Репозиторий → **Settings → Secrets and variables → Actions**: + +- вкладка **Secrets → New repository secret**: + `SIGNPATH_API_TOKEN` — токен из шага 3.4; +- вкладка **Variables → New repository variable**: + `SIGNPATH_ORGANIZATION_ID` — ID организации из шага 3.5. С этой переменной + подпись включается; +- только если slug проекта не `ModbusReader`: переменная + `SIGNPATH_PROJECT_SLUG`. + +### Пробный запуск + +1. Добавьте переменную `SIGNPATH_SIGNING_POLICY_SLUG` = `test-signing`. +2. **Actions → build → Run workflow** (ветка `main`). +3. Задание дойдёт до шага «Sign the programs» и будет ждать (до часа). На + app.signpath.io в **Signing Requests** появится запрос: откройте его и + нажмите **Approve**, если политика требует утверждения. Затем то же для + «Sign the installer». +4. Шаг «Signatures» покажет каждый файл с подписью. Тестовый сертификат + Windows не доверяет — для пробы этого достаточно. +5. Удалите переменную `SIGNPATH_SIGNING_POLICY_SLUG`: без неё используется + `release-signing`. + +## 5. Выпуск версии + +```bash +python tools/release.py # номер версии, CHANGELOG +python -m pytest +git commit -am "release: ГГ.ММ.N" +git tag -a vГГ.ММ.N -m "ModbusReader ГГ.ММ.N" +git push origin main vГГ.ММ.N +``` + +Тег запускает **Actions → build**. Задание `windows` дважды остановится и +будет ждать утверждения на app.signpath.io: сначала программы, потом +установщик. SignPath пришлёт письмо о каждом запросе. На каждое утверждение +есть час; если не успели, перезапустите задание (**Re-run failed jobs**). + +Когда все три задания (`windows`, `linux`, `macos`) зелёные, скачайте файлы и +загрузите их в выпуск. Команды печатает `tools/release.py`: + +```bash +gh run download <номер запуска> -D dist/ci -n windows -n linux -n macos +gh release upload <текущий тег> --clobber dist/ci/windows/ModbusReader-Setup.exe ... +gh release edit <текущий тег> --tag vГГ.ММ.N --title "ModbusReader ГГ.ММ.N" --notes-file <заметки> +``` + +Перед загрузкой проверьте подпись: **Свойства → Цифровые подписи → SignPath +Foundation** у `ModbusReader-Setup.exe`. + +## 6. После первого подписанного выпуска + +- Поправьте тексты про SmartScreen: теперь программа подписана, а + предупреждение может появиться только у совсем новой версии. Тексты лежат + в `README.md` («Windows может предупредить»), `docs/install.md` и + `docs/install.en.md` (раздел `#smartscreen`), `docs/troubleshooting.md` и + `docs/troubleshooting.en.md`, а также в строке «Known limitations» в + `ARCHITECTURE.md`. +- По желанию отправляйте `ModbusReader-Setup.exe` каждой новой версии в + Microsoft: https://www.microsoft.com/wdsi/filesubmission → Software + developer → Microsoft Defender SmartScreen. Так предупреждение для новой + версии пропадёт быстрее. + +## Чего не делать + +- Не подписывайте этим сертификатом чужие файлы и не собирайте файлы выпуска + на своём компьютере: SignPath подписывает только то, что собрал GitHub + Actions из этого репозитория. +- Не добавляйте в программу закрытые компоненты, рекламу или незаявленную + передачу данных. Это нарушит условия SignPath Foundation, и она может + отозвать подпись. Если программа начнёт передавать что-то по сети сама, + сначала допишите это в раздел «Конфиденциальность» политики. diff --git a/installer/signpath/installer.xml b/installer/signpath/installer.xml new file mode 100644 index 0000000..f97cee5 --- /dev/null +++ b/installer/signpath/installer.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/installer/signpath/programs.xml b/installer/signpath/programs.xml new file mode 100644 index 0000000..2f0876d --- /dev/null +++ b/installer/signpath/programs.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 490214e..c68d473 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -210,3 +210,4 @@ nav: - Помощь: - troubleshooting.md - contributing.md + - code-signing.md diff --git a/tests/test_updater.py b/tests/test_updater.py index d0852b4..5aacf92 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -743,7 +743,9 @@ def test_release_tool_moves_the_one_release_to_the_new_tag(): text = out.getvalue() assert applied and applied[0][0] == "99.12.0" assert 'git tag -a v99.12.0' in text and "git push origin main v99.12.0" in text - up = text.index("gh release upload --clobber dist/ModbusReader-Setup.exe") + # The Windows files are the ones CI built (and signed), not a local build. + assert "build_installer.py" not in text + up = text.index("gh release upload --clobber dist/ci/windows/ModbusReader-Setup.exe") ed = text.index("gh release edit --tag v99.12.0") assert up < ed assert "release create" not in text and "--delete" not in text and "tag -d" not in text diff --git a/tools/build_installer.py b/tools/build_installer.py index 868e7a7..c92301f 100644 --- a/tools/build_installer.py +++ b/tools/build_installer.py @@ -3,6 +3,8 @@ python tools/build_installer.py installer only python tools/build_installer.py --portable + the one-file exes + python tools/build_installer.py --portable --only programs + python tools/build_installer.py --only installer 1. PyInstaller, ModbusReader_setup.spec → dist/ModbusReader/ (one folder) 2. The launcher, installer/launcher/Launcher.cs → build/launcher/ModbusReader.exe @@ -13,6 +15,11 @@ 4. (--portable) ModbusReader.spec + ModbusReader_debug.spec → dist/*.exe 5. Prints each file with its size and SHA-256. +"--only programs" stops before 3, "--only installer" is 3 alone, packing +the program and launcher already in dist/ and build/. Between the two the +release build (.github/workflows/build.yml) has the exes signed, so the +installer carries signed files and is signed itself afterwards. + The release itself (tag vX.Y.Z, not a pre-release) must carry exactly these asset names — updater.py picks the build by name, and the installer's name never changes, so one link always downloads the latest: @@ -99,6 +106,9 @@ def environment_drift(wanted: dict) -> list[str]: def main(): portable = "--portable" in sys.argv + only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv[:-1] else "" + if only not in ("", "programs", "installer"): + sys.exit(f"--only {only}: expected programs or installer") # Only the tested versions may be shipped: a build from a drifted # environment would carry libraries no test has seen. drift = environment_drift(pins(os.path.join(ROOT, "requirements-dev.txt"))) @@ -106,24 +116,29 @@ def main(): sys.exit("The environment differs from the pinned versions:\n " + "\n ".join(drift) + "\nRun: python -m pip install -r requirements-dev.txt") - iscc = find_iscc() - if not iscc: + iscc = find_iscc() if only != "programs" else "" + if only != "programs" and not iscc: sys.exit("Inno Setup (ISCC.exe) not found — install it first: " "winget install JRSoftware.InnoSetup") py = sys.executable - run([py, "-m", "PyInstaller", "--noconfirm", "ModbusReader_setup.spec"]) - import build_launcher - build_launcher.build() - run([iscc, f"/DAppVersion={APP_VERSION}", os.path.join("installer", "ModbusReader.iss")]) - outputs = [os.path.join(ROOT, "dist", "ModbusReader-Setup.exe")] - if portable: + outputs = [] + if only != "installer": + run([py, "-m", "PyInstaller", "--noconfirm", "ModbusReader_setup.spec"]) + import build_launcher + outputs += [os.path.join(ROOT, "dist", "ModbusReader", "ModbusReader.exe"), + build_launcher.build()] + if only != "programs": + run([iscc, f"/DAppVersion={APP_VERSION}", os.path.join("installer", "ModbusReader.iss")]) + outputs += [os.path.join(ROOT, "dist", "ModbusReader-Setup.exe")] + if portable and only != "installer": for spec in ("ModbusReader.spec", "ModbusReader_debug.spec"): run([py, "-m", "PyInstaller", "--noconfirm", spec]) outputs += [os.path.join(ROOT, "dist", n) for n in ("ModbusReader.exe", "ModbusReader_debug.exe")] print() for p in outputs: - print(f"{os.path.basename(p):34} {os.path.getsize(p) / 1_048_576:7.1f} MB {sha256(p)}") + name = os.path.relpath(p, ROOT).replace(os.sep, "/") + print(f"{name:36} {os.path.getsize(p) / 1_048_576:7.1f} MB {sha256(p)}") if __name__ == "__main__": diff --git a/tools/release.py b/tools/release.py index 3fb592b..c576325 100644 --- a/tools/release.py +++ b/tools/release.py @@ -34,10 +34,12 @@ REPO_URL = "https://github.com/Mercurrial/ModbusReader" UNRELEASED = "Не выпущено" -ASSETS = ("dist/ModbusReader-Setup.exe", "dist/ModbusReader.exe", - "dist/ModbusReader_debug.exe", - # Built by CI for the tag (.github/workflows/build.yml), downloaded - # with "gh run download -D dist/ci": +# All built by CI for the tag (.github/workflows/build.yml) — the Windows +# files signed there, which a local build cannot be — and downloaded with +# "gh run download -D dist/ci -n windows -n linux -n macos": +ASSETS = ("dist/ci/windows/ModbusReader-Setup.exe", + "dist/ci/windows/ModbusReader.exe", + "dist/ci/windows/ModbusReader_debug.exe", "dist/ci/linux/dist/ModbusReader-x86_64.AppImage", "dist/ci/macos/dist/ModbusReader-macOS-arm64.zip") _VERSION_RE = re.compile(r"^\d{2}\.(?:[1-9]|1[0-2])\.\d+$") @@ -127,10 +129,10 @@ def main(): print(" python -m pytest") print(f' git commit -am "release: {version}"') print(f' git tag -a v{version} -m "ModbusReader {version}"') - print(" python tools/build_installer.py --portable") print(f" git push origin main v{version}") - print(" (the tag starts the Linux and macOS builds: Actions → build)") - print(" gh run download -D dist/ci") + print(" (the tag starts the builds: Actions → build; the Windows one waits") + print(" until both of its signing requests are approved on app.signpath.io)") + print(" gh run download -D dist/ci -n windows -n linux -n macos") # ONE release: replace its files, THEN move it to the new tag. In that # order a copy never sees the new number next to the old build (it would # install the old one and be offered the update again). The old tag stays.