diff --git a/.github/workflows/probe-build.yml b/.github/workflows/probe-build.yml index 6df3556..5a1d17a 100644 --- a/.github/workflows/probe-build.yml +++ b/.github/workflows/probe-build.yml @@ -34,6 +34,12 @@ jobs: - name: Build release binary run: cargo build --release --package probe --bin cheburprobe --target x86_64-unknown-linux-musl + - name: Name Linux amd64 release asset + run: | + version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "probe") | .version') + cp target/x86_64-unknown-linux-musl/release/cheburprobe \ + "target/x86_64-unknown-linux-musl/release/cheburprobe-$version-linux-amd64" + - name: Build Debian package run: cargo deb --package probe --target x86_64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe @@ -41,7 +47,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: cheburprobe-linux-amd64 - path: target/x86_64-unknown-linux-musl/release/cheburprobe + path: target/x86_64-unknown-linux-musl/release/cheburprobe-*-linux-amd64 compression-level: 0 - name: Upload Linux amd64 Debian package @@ -74,6 +80,12 @@ jobs: - name: Build Debian package run: cargo deb --package probe --target aarch64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe + - name: Name Linux arm64 release asset + run: | + version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "probe") | .version') + cp target/aarch64-unknown-linux-musl/release/cheburprobe \ + "target/aarch64-unknown-linux-musl/release/cheburprobe-$version-linux-arm64" + - name: Build legacy OpenWrt opkg packages run: | chmod +x probe/openwrt/build-ipk.sh @@ -102,7 +114,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: cheburprobe-linux-arm64 - path: target/aarch64-unknown-linux-musl/release/cheburprobe + path: target/aarch64-unknown-linux-musl/release/cheburprobe-*-linux-arm64 compression-level: 0 - name: Upload Linux arm64 Debian package @@ -133,11 +145,18 @@ jobs: - name: Build release binary run: cargo build --release --package probe --bin cheburprobe + - name: Name Windows release asset + shell: pwsh + run: | + $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $version = ($metadata.packages | Where-Object name -eq 'probe').version + Copy-Item target/release/cheburprobe.exe "target/release/cheburprobe-$version-windows-x86_64.exe" + - name: Upload Windows amd64 binary uses: actions/upload-artifact@v7 with: name: cheburprobe-windows-amd64 - path: target/release/cheburprobe.exe + path: target/release/cheburprobe-*-windows-x86_64.exe compression-level: 0 docker: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2754ea..a467243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,11 +149,98 @@ jobs: rm -rf "$extract_dir" done + - name: Generate release body + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ github.ref_name }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const tag = process.env.TAG_NAME; + const files = fs.readdirSync('release-artifacts') + .filter((name) => fs.statSync(path.join('release-artifacts', name)).isFile()) + .sort(); + + function describe(name) { + if (/^cheburchecker_.+_amd64\.deb$/.test(name)) { + return 'Пакет Cheburchecker для Debian Linux x86-64.'; + } + if (/^cheburchecker_.+_arm64\.deb$/.test(name)) { + return 'Пакет Cheburchecker для Debian Linux ARM64.'; + } + if (/^cheburprobe-.+-linux-amd64$/.test(name)) { + return 'Cheburprobe для Linux x86-64.'; + } + if (/^cheburprobe-.+-linux-arm64$/.test(name)) { + return 'Cheburprobe для Linux ARM64.'; + } + if (/^cheburprobe-.+-windows-x86_64\.exe$/.test(name)) { + return 'Cheburprobe для Windows x86-64.'; + } + if (/^cheburprobe_.+_amd64\.deb$/.test(name)) { + return 'Пакет Cheburprobe для Debian Linux x86-64 с сервисом и автообновлением.'; + } + if (/^cheburprobe_.+_arm64\.deb$/.test(name)) { + return 'Пакет Cheburprobe для Debian Linux ARM64 с сервисом и автообновлением.'; + } + if (/^cheburprobe-.+_.+\.apk$/.test(name)) { + return 'Пакет Cheburprobe для версий OpenWrt с пакетным менеджером APK (25.12+).'; + } + if (/^luci-app-cheburprobe-.+\.apk$/.test(name)) { + return 'Интерфейс LuCI для APK-пакета Cheburprobe.'; + } + if (/^cheburprobe_.+_.+\.ipk$/.test(name)) { + return 'Пакет Cheburprobe для версий OpenWrt с пакетным менеджером opkg.'; + } + if (/^luci-app-cheburprobe_.+_all\.ipk$/.test(name)) { + return 'Интерфейс LuCI для IPK-пакета Cheburprobe.'; + } + return 'Файл релиза.'; + } + + const baseUrl = [ + 'https://github.com', + encodeURIComponent(context.repo.owner), + encodeURIComponent(context.repo.repo), + 'releases', + 'download', + encodeURIComponent(tag), + ].join('/'); + const sourceBaseUrl = [ + 'https://github.com', + encodeURIComponent(context.repo.owner), + encodeURIComponent(context.repo.repo), + 'blob', + encodeURIComponent(tag), + ].join('/'); + const rows = files.map((name) => { + const url = baseUrl + '/' + encodeURIComponent(name); + return '| [`' + name + '`](' + url + ') | ' + describe(name) + ' |'; + }); + const body = [ + '## Документация', + '', + '- [Cheburprobe](' + sourceBaseUrl + '/probe/README.md)', + '- [Cheburchecker](' + sourceBaseUrl + '/reporter/README.md)', + '', + '## Файлы релиза', + '', + '| Файл | Описание |', + '| --- | --- |', + ...rows, + '', + ].join('\n'); + + fs.writeFileSync('release-body.md', body); + - name: Publish draft release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} draft: true + body_path: release-body.md fail_on_unmatched_files: true files: release-artifacts/* diff --git a/Cargo.lock b/Cargo.lock index fddc48b..c2f8e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,28 +204,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -309,8 +287,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -413,15 +389,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -732,12 +699,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.15.0" @@ -933,12 +894,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1884,16 +1839,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.82" @@ -2632,7 +2577,7 @@ dependencies = [ [[package]] name = "probe" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "clap", @@ -2644,12 +2589,15 @@ dependencies = [ "polling", "rand 0.8.5", "reports", + "reqwest", "rumqttc 0.25.1", "rustix", "rustls 0.23.35", + "semver", "serde", "serde_json", "socket2 0.6.3", + "tempfile", "tokio", "tokio-rustls 0.26.4", ] @@ -3273,7 +3221,6 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -3344,7 +3291,6 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4706,7 +4652,7 @@ dependencies = [ [[package]] name = "website" -version = "1.2.4" +version = "1.2.5" dependencies = [ "dotenvy", "env_logger", diff --git a/nginx.conf b/nginx.conf index 7b5b5b8..e9c61d8 100644 --- a/nginx.conf +++ b/nginx.conf @@ -44,6 +44,10 @@ server { proxy_pass http://website_backend; } + location = /install-probe.sh { + proxy_pass http://website_backend; + } + location /mqtt { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/probe/Cargo.toml b/probe/Cargo.toml index 69fe8ac..bebeaa6 100644 --- a/probe/Cargo.toml +++ b/probe/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "probe" -version = "0.4.0" +version = "0.5.0" edition = "2024" license-file = "../LICENSE" description = "Dynamic network probe daemon for Cheburcheck" @@ -9,16 +9,23 @@ description = "Dynamic network probe daemon for Cheburcheck" name = "cheburprobe" path = "src/main.rs" +[[bin]] +name = "mock-release-server" +path = "src/mock_release_server.rs" + [package.metadata.deb] name = "cheburprobe" maintainer = "Lowder " maintainer-scripts = "debian/" -systemd-units = [ - { unit-name = "cheburprobe", enable = false, start = false }, -] +depends = "ca-certificates" assets = [ ["target/release/cheburprobe", "usr/bin/", "755"], ["debian/cheburprobe.default", "etc/default/cheburprobe", "644"], + ["debian/cheburprobe.service", "usr/lib/systemd/system/cheburprobe.service", "644"], + ["update/cheburprobe-request-update.debian", "usr/libexec/cheburprobe-request-update", "755"], + ["update/cheburprobe-update.service", "usr/lib/systemd/system/cheburprobe-update.service", "644"], + ["update/cheburprobe-update.timer", "usr/lib/systemd/system/cheburprobe-update.timer", "644"], + ["update/cheburprobe-update.path", "usr/lib/systemd/system/cheburprobe-update.path", "644"], ] [dependencies] @@ -29,14 +36,17 @@ etherparse = "0.21" futures = "0.3" log = { workspace = true } polling = "3.11" -rustix = { version = "1.1", features = ["net"] } -rumqttc = { version = "0.25", features = ["use-rustls", "websocket"] } +rustix = { version = "1.1", features = ["fs", "net", "process"] } +reqwest = { workspace = true, features = ["json"] } +rumqttc = { version = "0.25", default-features = false, features = ["use-rustls-no-provider", "websocket"] } +semver = "1.0" serde = { workspace = true } serde_json = "1.0" +tempfile = "3.23" tokio = { workspace = true } reports = { path = "../reports" } -rustls = "0.23" -tokio-rustls = "0.26" +rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] } rand = "0.8" socket2 = { version = "0.6", features = ["all"] } -hickory-resolver = { version = "0.26.0-beta.3", features = ["tokio", "webpki-roots", "https-aws-lc-rs"] } +hickory-resolver = { version = "0.26.0-beta.3", features = ["tokio", "webpki-roots", "https-ring"] } diff --git a/probe/README.md b/probe/README.md index df4b224..6f29b7f 100644 --- a/probe/README.md +++ b/probe/README.md @@ -1,140 +1,260 @@ # Cheburcheck Probe + [![Build Probe](https://github.com/LowderPlay/cheburcheck/actions/workflows/probe-build.yml/badge.svg)](https://github.com/LowderPlay/cheburcheck/actions/workflows/probe-build.yml) -Динамический сканер для Cheburcheck. -Подключается к MQTT-брокеру Cheburcheck по WebSocket, получает задания на проверку доменов, выполняет сетевые пробы со своей точки подключения и отправляет результаты обратно на сайт. +Cheburcheck Probe (`cheburprobe`) — динамический сетевой сканер. Он подключается к Cheburcheck, получает задания на проверку доменов, выполняет их из вашей сети и отправляет технические результаты обратно. -Сканер нужен для проверки «изнутри» разных сетей: например, от разных операторов, регионов или хостингов. -Он не принимает итоговое решение сам, а передает технические признаки, по которым Cheburcheck показывает результат пользователю. +Сканер помогает проверять доступность из разных сетей, регионов и хостингов. Он не принимает итоговое решение сам: Cheburcheck использует собранные им признаки при формировании результата. -## Сборка +## Быстрый старт -Готовые бинарные файлы, Debian-пакеты и OpenWrt пакеты для arm64 можно скачать на [странице релизов](https://github.com/LowderPlay/cheburcheck/releases). +### 1. Получите ID и токен -На Debian-based дистрибутивах можно собрать пакет через `cargo-deb`: +Для запуска нужны `PROBE_ID` и `PROBE_TOKEN`. Запросите их по адресу [support@cheburcheck.ru](mailto:support@cheburcheck.ru) и укажите в письме: + +- регион; +- интернет-провайдера или хостинг; +- ASN, если он известен; +- устройство, на котором будет работать сканер: сервер, домашний роутер, микрокомпьютер и т. п. + +Не публикуйте полученный токен и не добавляйте его в систему контроля версий. + +### 2. Выберите способ установки + +Готовые пакеты и бинарные файлы находятся на [странице релизов](https://github.com/LowderPlay/cheburcheck/releases), Docker-образ — в GitHub Container Registry: `ghcr.io/lowderplay/cheburcheck-probe`. + +| Среда | Что использовать | +| --- | --- | +| Debian, Ubuntu и производные | `.deb` для amd64 или arm64 | +| Docker на Linux | образ для amd64 или arm64 | +| OpenWrt с `apk` (25.12+) | основной `.apk` и пакет LuCI `.apk` | +| OpenWrt с `opkg` | основной `.ipk` и пакет LuCI `.ipk` | +| Windows | экспериментальная standalone-сборка для x86-64 | + +> [!WARNING] +> Поддержка Windows нестабильна. DPI-трассировка использует raw ICMP-сокеты, доставка +> ответов в которые зависит от Windows Firewall и сетевого драйвера. В частности, +> IPv6-прыжки могут отображаться как таймауты, даже если пакеты ICMPv6 Time Exceeded +> видны в анализаторе трафика. Для постоянной работы рекомендуется Linux. + +### 3. Автоматическая установка Debian/OpenWrt + +Интерактивный мастер определяет ОС, архитектуру и пакетный менеджер, находит версию Probe среди пакетов последнего GitHub Release, показывает текущую и доступную версии, а затем запрашивает подтверждение установки или обновления. Данные авторизации можно указать сразу или настроить позднее. + +На Debian/Ubuntu выполните: ```shell -cargo deb --package probe -- --bin cheburprobe +curl -fsSL https://cheburcheck.ru/install-probe.sh | sudo sh ``` -На прочих дистрибутивах и ОС можно запустить напрямую: +На OpenWrt выполните от имени `root`: ```shell -cargo run --package probe --bin cheburprobe -- \ - --probe-id \ - --probe-token <ТОКЕН_СКАНЕРА> +wget -qO- https://cheburcheck.ru/install-probe.sh | sh ``` -Также доступен Docker-образ, который собирается из `probe/Dockerfile`. +Перед запуском можно скачать и просмотреть скрипт отдельно: -Для OpenWrt на arm64 установите основной `.apk` и пакет LuCI. Пакеты из GitHub -Actions не подписаны, поэтому для локальной установки нужен флаг `--allow-untrusted`: +```shell +curl -fSLO https://cheburcheck.ru/install-probe.sh +less install.sh +sudo sh install.sh +``` -Узнать архитектуру на OpenWrt с `apk`: +Если Cheburprobe уже установлен, мастер покажет установленную и последнюю версии и спросит разрешение на обновление. Существующие настройки авторизации можно сохранить. + +Для автоматизированной установки передайте `PROBE_ID`, `PROBE_TOKEN` и `CHEBURPROBE_ASSUME_YES=1`. + +По умолчанию в OpenWrt также устанавливается интерфейс LuCI. Чтобы установить только сервис, передайте `CHEBURPROBE_WITH_LUCI=0`. Для установки конкретного релиза можно передать `CHEBURPROBE_VERSION`, например `CHEBURPROBE_VERSION=0.5.0`. + +## Debian и Ubuntu + +1. На [странице релизов](https://github.com/LowderPlay/cheburcheck/releases) скачайте файл `cheburprobe_*.deb` для архитектуры вашей системы. Проверить архитектуру можно командой `dpkg --print-architecture`. + +2. Установите пакет: + + ```shell + sudo apt install ./cheburprobe_*.deb + ``` + +3. Откройте файл настроек: + + ```shell + sudo nano /etc/default/cheburprobe + ``` + + Укажите выданные ID и токен. Адрес брокера обычно менять не требуется: + + ```shell + PROBE_ID=1 + PROBE_TOKEN=ваш-токен + MQTT_HOST=wss://cheburcheck.ru/mqtt + MQTT_PORT=443 + ``` + +4. Запустите сервис и добавьте его в автозагрузку: + + ```shell + sudo systemctl enable --now cheburprobe.service + ``` + +5. Убедитесь, что сканер работает: + + ```shell + systemctl status cheburprobe.service + journalctl -u cheburprobe.service -f + ``` + +Пакет устанавливает systemd-сервис `cheburprobe.service`. Сервис работает без root-доступа и получает только capability `CAP_NET_RAW`, необходимую для traceroute. + +## Docker + +Образ `ghcr.io/lowderplay/cheburcheck-probe:latest` доступен для Linux amd64 и arm64. Скачайте его и запустите контейнер: + +```shell +docker pull ghcr.io/lowderplay/cheburcheck-probe:latest + +docker run -d \ + --name cheburprobe \ + --restart unless-stopped \ + --cap-add NET_RAW \ + -e PROBE_ID=1 \ + -e PROBE_TOKEN=ваш-токен \ + ghcr.io/lowderplay/cheburcheck-probe:latest +``` + +Проверьте состояние и логи: + +```shell +docker ps --filter name=cheburprobe +docker logs -f cheburprobe +``` + +Для постоянной установки удобнее хранить параметры в отдельном файле, например `cheburprobe.env`, ограничить доступ к нему и передать Docker через `--env-file cheburprobe.env`; либо использовать Docker Compose. + +## OpenWrt + +Готовые пакеты выпускаются для `aarch64_generic`, `aarch64_cortex-a53` и `aarch64_cortex-a72`. Со [страницы релиза](https://github.com/LowderPlay/cheburcheck/releases) нужно скачать два файла: основной пакет `cheburprobe` для архитектуры роутера и универсальный пакет интерфейса `luci-app-cheburprobe`. + +Сначала определите, какой пакетный менеджер используется: + +```shell +command -v apk || command -v opkg +``` + +### OpenWrt с `apk` + +Узнайте архитектуру: ```shell apk --print-arch ``` -Например, для результата `aarch64_cortex-a53` нужен файл с -`_aarch64_cortex-a53.apk` в имени: +Например, для `aarch64_cortex-a53` выберите основной файл с `_aarch64_cortex-a53.apk` в имени. Пакеты релиза не подписаны ключом вашего OpenWrt, поэтому при локальной установке требуется `--allow-untrusted`: ```shell apk --allow-untrusted add \ - ./cheburprobe-*_"$(apk --print-arch)".apk \ + ./cheburprobe-*_<АРХИТЕКТУРА>.apk \ ./luci-app-cheburprobe-*.apk ``` -После установки откройте **Службы → Cheburprobe** в LuCI, заполните ID и токен, -включите сервис и нажмите **Сохранить и применить**. Те же настройки доступны -через UCI в `/etc/config/cheburprobe`. Конфигурационный файл устанавливается с -правами `0600`, поскольку токен хранится в нем в открытом виде. +### OpenWrt с `opkg` -На OpenWrt с пакетным менеджером `opkg` установите совместимые `.ipk` пакеты: +Посмотрите список поддерживаемых архитектур: ```shell opkg print-architecture ``` -Команда может вывести несколько строк. Выберите специфичную для процессора -архитектуру, например `aarch64_cortex-a53`, а не универсальную `all`. +Выберите архитектуру процессора, например `aarch64_cortex-a53`, а не универсальную `all`, и установите оба пакета: ```shell opkg install \ - ./cheburprobe_*_<АРХИТЕКТУРА_УСТРОЙСТВА>.ipk \ + ./cheburprobe_*_<АРХИТЕКТУРА>.ipk \ ./luci-app-cheburprobe_*_all.ipk ``` -GitHub Actions выпускает пакеты для `aarch64_generic`, `aarch64_cortex-a53` и -`aarch64_cortex-a72`. Для другого имени архитектуры OpenWrt пакет можно собрать -с переменной `OPENWRT_ARCH`, например -`OPENWRT_ARCH=aarch64_cortex-a53 probe/openwrt/build-apk.sh `. -Та же переменная поддерживается скриптом `build-ipk.sh`. +### Настройка OpenWrt -## Получение доступа +После установки откройте **Службы → Cheburprobe** в LuCI: -Для подключения сканера нужен `PROBE_ID` и `PROBE_TOKEN`. -Они должны соответствовать записи в таблице `reporters` на стороне Cheburcheck. +1. укажите **Probe ID** и **Probe token**; +2. включите **Enable service**; +3. нажмите **Сохранить и применить**. -Чтобы получить доступ, напишите на [support@cheburcheck.ru](mailto:support@cheburcheck.ru). -В письме укажите: +Настройки также доступны через UCI в `/etc/config/cheburprobe`. Файл создаётся с правами `0600`, потому что токен хранится в открытом виде. Для проверки используйте: -- регион; -- интернет-провайдера или хостинг; -- ASN, если он известен; -- где будет запущен сканер: сервер, домашний роутер, микрокомпьютер и так далее. +```shell +/etc/init.d/cheburprobe status +logread -e cheburprobe +``` -## Установка как systemd-демона +## Общие настройки -Самый простой способ установки на Debian-based систему — скачать `.deb` пакет `cheburprobe` со [страницы релизов](https://github.com/LowderPlay/cheburcheck/releases). +Параметры можно передавать через переменные окружения или аргументы командной строки. В Debian переменные задаются в `/etc/default/cheburprobe`, в Docker — через `-e` или `--env-file`. OpenWrt настраивается через LuCI/UCI. -Debian-пакет устанавливает systemd unit `cheburprobe.service` и файл конфигурации `/etc/default/cheburprobe`. -Сервис не включается автоматически: сначала нужно указать данные сканера. +| Аргумент / переменная | Назначение | По умолчанию | +| --- | --- | --- | +| `--probe-id`, `PROBE_ID` | ID сканера | обязательный параметр | +| `--probe-token`, `PROBE_TOKEN` | Секретный токен | обязательный параметр | +| `--mqtt-host`, `MQTT_HOST` | URL MQTT-брокера; `ws://` или `wss://` | `wss://cheburcheck.ru/mqtt` | +| `--mqtt-port`, `MQTT_PORT` | Порт MQTT-брокера | `443` | +| `--mqtt-connection-timeout-secs`, `MQTT_CONNECTION_TIMEOUT_SECS` | Таймаут подключения, секунды | `30` | +| `--max-concurrent-tasks`, `MAX_CONCURRENT_TASKS` | Максимум одновременных заданий | `8` | +| `--traceroute-retries`, `TRACEROUTE_RETRIES` | Число одновременных TCP-попыток на каждом TTL | `3` | +| `RUST_LOG` | Уровень логирования | `info` | -1. Установите пакет: +`MAX_CONCURRENT_TASKS` и `TRACEROUTE_RETRIES` должны быть больше нуля. - ```shell - sudo apt install ./cheburprobe_*.deb - ``` +## Автоматические обновления -2. Настройте `/etc/default/cheburprobe`: +Пакеты Debian и OpenWrt каждые шесть часов проверяют последний опубликованный GitHub Release. При появлении новой версии пакет обновляется, а сервис перезапускается. В OpenWrt пакет LuCI обновляется вместе с основным. - ```shell - sudo nano /etc/default/cheburprobe - ``` +Автообновления включены по умолчанию. Отключить их в Debian можно командой: - Минимальная конфигурация: +```shell +sudo systemctl disable --now cheburprobe-update.timer +``` - ```shell - PROBE_ID=1 - PROBE_TOKEN=ваш-токен - MQTT_HOST=wss://cheburcheck.ru/mqtt - MQTT_PORT=443 - ``` +В OpenWrt используйте переключатель в LuCI или команды: -3. Запустите и включите сервис: +```shell +uci set cheburprobe.main.auto_update='0' +uci commit cheburprobe +/etc/init.d/cheburprobe-updater restart +``` - ```shell - sudo systemctl enable --now cheburprobe.service - ``` +Интервал проверки OpenWrt задаётся параметром `update_interval` в `/etc/config/cheburprobe`. Ручная проверка доступна независимо от настроек: -4. Проверьте статус: +```shell +/usr/bin/cheburprobe update +``` - ```shell - systemctl status cheburprobe.service - ``` +На Windows updater скачивает новую версию и заменяет текущий executable: -5. Посмотрите логи: +```powershell +cheburprobe.exe update +``` - ```shell - journalctl -u cheburprobe.service -f - ``` +Standalone-сборки Linux и Windows обновляются только ручной командой `cheburprobe update`. -Сервис запускается с `DynamicUser=yes`, поэтому сканеру не нужен root-доступ. +Docker-контейнер не обновляет образ автоматически. Для обновления скачайте новый образ и пересоздайте контейнер с прежними параметрами. -## Запуск без установки +## Диагностика -Пример запуска из исходников: +Если сканер не подключается: + +- проверьте `PROBE_ID` и `PROBE_TOKEN`; +- убедитесь, что `MQTT_HOST` начинается с `ws://` или `wss://`; +- проверьте доступность `MQTT_HOST:MQTT_PORT` из сети сканера; +- изучите логи (`journalctl`, `docker logs` или `logread` — в зависимости от установки); +- временно задайте `RUST_LOG=debug`. + +## Для разработчиков + +### Запуск из исходников + +Из корня репозитория: ```shell PROBE_ID=1 \ @@ -144,52 +264,46 @@ MQTT_PORT=443 \ cargo run --package probe --bin cheburprobe ``` -Пример запуска через Docker: +Те же обязательные параметры можно передать аргументами: ```shell -docker run --rm \ - --cap-add NET_RAW \ - -e PROBE_ID=1 \ - -e PROBE_TOKEN=ваш-токен \ - -e MQTT_HOST=wss://cheburcheck.ru/mqtt \ - -e MQTT_PORT=443 \ - ghcr.io/lowderplay/cheburcheck-probe:latest +cargo run --package probe --bin cheburprobe -- \ + --probe-id \ + --probe-token <ТОКЕН_СКАНЕРА> ``` -## Конфигурация +### Сборка пакетов -| Параметр | Описание | Значение по умолчанию | -| --- | --- | --- | -| `--mqtt-host`, `MQTT_HOST` | Адрес MQTT-брокера по WebSocket. Поддерживаются `ws://` и `wss://`. | `wss://cheburcheck.ru/mqtt` | -| `--mqtt-port`, `MQTT_PORT` | Порт MQTT-брокера. | `443` | -| `--mqtt-connection-timeout-secs`, `MQTT_CONNECTION_TIMEOUT_SECS` | Таймаут подключения к MQTT-брокеру. | `30` | -| `--probe-id`, `PROBE_ID` | ID сканера. | обязательно | -| `--probe-token`, `PROBE_TOKEN` | Секретный токен сканера. | обязательно | -| `--max-concurrent-tasks`, `MAX_CONCURRENT_TASKS` | Максимальное количество одновременных заданий. | `8` | -| `--traceroute-retries`, `TRACEROUTE_RETRIES` | Количество одновременных TCP-попыток на каждом TTL. | `3` | -| `RUST_LOG` | Уровень логирования. | `info` | +Debian-пакет собирается через `cargo-deb`: -`MAX_CONCURRENT_TASKS` и `TRACEROUTE_RETRIES` должны быть больше нуля. Для получения ICMP-ответов traceroute процессу требуется capability `CAP_NET_RAW`; systemd unit и Docker-образ настраивают её автоматически. +```shell +cargo deb --package probe -- --bin cheburprobe +``` -## Как работает проверка +Docker-образ собирается из корня репозитория: + +```shell +docker build -f probe/Dockerfile -t cheburprobe:local . +``` + +Для сборки OpenWrt-пакета другой архитектуры задайте `OPENWRT_ARCH`: + +```shell +OPENWRT_ARCH=aarch64_cortex-a53 \ + probe/openwrt/build-apk.sh + +OPENWRT_ARCH=aarch64_cortex-a53 \ + probe/openwrt/build-ipk.sh +``` + +### Как работает проверка После подключения сканер: 1. публикует retained-статус `online` в MQTT; 2. подписывается на конфигурацию динамического сканирования; 3. получает задания на проверку доменов и IP-адресов; -4. параллельно запускает SNI-проверки (для доменов) и TCP traceroute до цели, начиная со следующего после DPI узла; +4. параллельно запускает SNI-проверки для доменов и TCP traceroute до цели, начиная со следующего после DPI узла; 5. отправляет результат обратно в Cheburcheck. -Для каждого тестового хоста сканер открывает TCP-соединение, начинает TLS-handshake с проверяемым доменом в SNI, затем отправляет простой HTTP GET-запрос. -Проверка намеренно отключает валидацию TLS-сертификата, потому что измеряется доступность соединения, а не доверие к сертификату. - -## Диагностика - -Если сканер не подключается: - -- проверьте `PROBE_ID` и `PROBE_TOKEN`; -- убедитесь, что `MQTT_HOST` начинается с `ws://` или `wss://`; -- проверьте доступность `MQTT_HOST:MQTT_PORT` с сервера; -- посмотрите логи через `journalctl -u cheburprobe.service -f`; -- временно установите `RUST_LOG=debug`. +Для каждого тестового хоста сканер открывает TCP-соединение, начинает TLS-handshake с проверяемым доменом в SNI, затем отправляет простой HTTP GET-запрос. Валидация TLS-сертификата намеренно отключена: измеряется доступность соединения, а не доверие к сертификату. diff --git a/probe/debian/cheburprobe.service b/probe/debian/cheburprobe.service index c7efbc6..9b1df1b 100644 --- a/probe/debian/cheburprobe.service +++ b/probe/debian/cheburprobe.service @@ -10,6 +10,7 @@ ExecStart=/usr/bin/cheburprobe Restart=always RestartSec=5s DynamicUser=yes +RuntimeDirectory=cheburprobe AmbientCapabilities=CAP_NET_RAW CapabilityBoundingSet=CAP_NET_RAW LimitNOFILE=16384 diff --git a/probe/debian/postinst b/probe/debian/postinst new file mode 100644 index 0000000..ac9624c --- /dev/null +++ b/probe/debian/postinst @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +if [ "$1" = configure ]; then + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable --now cheburprobe-update.timer cheburprobe-update.path >/dev/null 2>&1 || true + if [ -n "${2:-}" ]; then + systemctl try-restart cheburprobe.service >/dev/null 2>&1 || true + fi +fi + +exit 0 diff --git a/probe/debian/prerm b/probe/debian/prerm new file mode 100644 index 0000000..ec9a893 --- /dev/null +++ b/probe/debian/prerm @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +if [ "$1" = remove ]; then + systemctl disable --now cheburprobe-update.timer cheburprobe-update.path cheburprobe.service >/dev/null 2>&1 || true +fi + +exit 0 diff --git a/probe/install.sh b/probe/install.sh new file mode 100644 index 0000000..ffbdcc0 --- /dev/null +++ b/probe/install.sh @@ -0,0 +1,335 @@ +#!/bin/sh +set -eu + +REPOSITORY=${CHEBURPROBE_REPOSITORY:-LowderPlay/cheburcheck} +GITHUB_URL=${CHEBURPROBE_GITHUB_URL:-https://github.com} +GITHUB_API_URL=${CHEBURPROBE_GITHUB_API_URL:-https://api.github.com} +WITH_LUCI=${CHEBURPROBE_WITH_LUCI:-1} +ASSUME_YES=${CHEBURPROBE_ASSUME_YES:-0} +PROBE_ID=${PROBE_ID:-} +PROBE_TOKEN=${PROBE_TOKEN:-} + +log() { printf '%s\n' "cheburprobe installer: $*"; } +fail() { printf '%s\n' "cheburprobe installer: error: $*" >&2; exit 1; } +command_exists() { command -v "$1" >/dev/null 2>&1; } +has_tty() { [ -r /dev/tty ] && ( : /dev/null; } + +prompt() { + message=$1 + default=$2 + if [ "$ASSUME_YES" = 1 ]; then + return 0 + fi + has_tty || fail "interactive terminal is required (or set CHEBURPROBE_ASSUME_YES=1)" + while :; do + printf '%s ' "$message" >/dev/tty + IFS= read -r answer /dev/tty ;; + esac + done +} + +download() { + url=$1 + destination=$2 + if command_exists curl; then + curl --fail --location --silent --show-error "$url" --output "$destination" + elif command_exists wget; then + wget -q -O "$destination" "$url" + else + fail "curl or wget is required" + fi +} + +probe_version() { + if [ -n "${CHEBURPROBE_VERSION:-}" ]; then + version=${CHEBURPROBE_VERSION#v} + else + case "$PLATFORM" in + debian) + version=$(sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"cheburprobe_\([^\"]*\)-1_${ARCH}\\.deb\".*/\1/p" "$RELEASE_JSON" | head -n 1) + ;; + openwrt-apk) + version=$(sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"cheburprobe-\([^\"]*\)-r1_${ARCH}\\.apk\".*/\1/p" "$RELEASE_JSON" | head -n 1) + ;; + openwrt-opkg) + version=$(sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"cheburprobe_\([^\"]*\)-1_${ARCH}\\.ipk\".*/\1/p" "$RELEASE_JSON" | head -n 1) + ;; + esac + fi + [ -n "$version" ] || fail "the latest GitHub release has no Cheburprobe package for $PLATFORM_NAME/$ARCH" + case "$version" in ''|*[!0-9A-Za-z.+~-]*) fail "invalid Probe package version: $version" ;; esac + printf '%s\n' "$version" +} + +validate_openwrt_arch() { + case "$1" in + aarch64_generic|aarch64_cortex-a53|aarch64_cortex-a72) ;; + *) fail "unsupported OpenWrt architecture: $1" ;; + esac +} + +detect_platform() { + if [ -f /etc/openwrt_release ]; then + if command_exists apk; then + PLATFORM=openwrt-apk + PLATFORM_NAME='OpenWrt (apk)' + ARCH=$(apk --print-arch) + validate_openwrt_arch "$ARCH" + elif command_exists opkg; then + PLATFORM=openwrt-opkg + PLATFORM_NAME='OpenWrt (opkg)' + ARCH=$(opkg print-architecture | awk '$2 != "all" { arch = $2 } END { print arch }') + [ -n "$ARCH" ] || fail "opkg did not report a package architecture" + validate_openwrt_arch "$ARCH" + else + fail "OpenWrt package manager apk or opkg was not found" + fi + elif [ -f /etc/debian_version ]; then + PLATFORM=debian + PLATFORM_NAME='Debian/Ubuntu' + ARCH=$(dpkg --print-architecture) + case "$ARCH" in amd64|arm64) ;; *) fail "unsupported Debian architecture: $ARCH" ;; esac + else + fail "unsupported operating system (expected Debian/Ubuntu or OpenWrt)" + fi +} + +select_packages() { + case "$PLATFORM" in + debian) + PACKAGE="cheburprobe_${VERSION}-1_${ARCH}.deb" + LUCI_PACKAGE= + ;; + openwrt-apk) + PACKAGE="cheburprobe-${VERSION}-r1_${ARCH}.apk" + LUCI_PACKAGE="luci-app-cheburprobe-${VERSION}-r1.apk" + ;; + openwrt-opkg) + PACKAGE="cheburprobe_${VERSION}-1_${ARCH}.ipk" + LUCI_PACKAGE="luci-app-cheburprobe_${VERSION}-1_all.ipk" + ;; + esac +} + +installed_version() { + if command_exists cheburprobe; then + cheburprobe --version 2>/dev/null | awk 'NR == 1 { print $2 }' + return + fi + case "$PLATFORM" in + debian) + dpkg-query -W -f='${Version}' cheburprobe 2>/dev/null | sed 's/-[^-]*$//' || true + ;; + openwrt-apk) + apk info --exists cheburprobe >/dev/null 2>&1 || return 0 + apk info cheburprobe 2>/dev/null | sed -n 's/^cheburprobe-\([0-9][^-]*\)-r[0-9][0-9]*$/\1/p' | head -n 1 + ;; + openwrt-opkg) + opkg status cheburprobe 2>/dev/null | sed -n 's/^Version: \(.*\)-[^-]*$/\1/p' | head -n 1 + ;; + esac +} + +read_existing_credentials() { + EXISTING_ID= + EXISTING_TOKEN= + case "$PLATFORM" in + debian) + if [ -f /etc/default/cheburprobe ]; then + EXISTING_ID=$(sed -n 's/^PROBE_ID=//p' /etc/default/cheburprobe | head -n 1) + EXISTING_TOKEN=$(sed -n 's/^PROBE_TOKEN=//p' /etc/default/cheburprobe | head -n 1) + fi + ;; + openwrt-*) + EXISTING_ID=$(uci -q get cheburprobe.main.probe_id 2>/dev/null || true) + EXISTING_TOKEN=$(uci -q get cheburprobe.main.probe_token 2>/dev/null || true) + ;; + esac +} + +read_probe_token() { + printf 'Probe token: ' >/dev/tty + set +e + if command_exists stty; then + stty -echo /dev/null + read_status=$? + if [ "$read_status" -ne 0 ]; then + IFS= read -r PROBE_TOKEN /dev/tty + [ "$read_status" -eq 0 ] || fail "could not read PROBE_TOKEN" +} + +read_credentials() { + CONFIGURE=0 + if { [ -n "$PROBE_ID" ] && [ -z "$PROBE_TOKEN" ]; } || { [ -z "$PROBE_ID" ] && [ -n "$PROBE_TOKEN" ]; }; then + fail "PROBE_ID and PROBE_TOKEN must be provided together" + fi + if [ -n "$PROBE_ID" ]; then + CONFIGURE=1 + return + fi + read_existing_credentials + if [ -n "$EXISTING_ID" ] && [ -n "$EXISTING_TOKEN" ]; then + printf '\nНайдены сохранённые данные авторизации для Probe ID %s.\n' "$EXISTING_ID" + if prompt 'Использовать их? [Y/n]' y; then + PROBE_ID=$EXISTING_ID + PROBE_TOKEN=$EXISTING_TOKEN + CONFIGURE=1 + return + fi + fi + printf '\nБез данных авторизации пакет будет установлен, но основной сервис не будет запущен.\n' + if [ "$ASSUME_YES" = 1 ] && ! has_tty; then + return + fi + if ! prompt 'Настроить авторизацию сейчас? [Y/n]' y; then + return + fi + printf 'Probe ID: ' >/dev/tty + IFS= read -r PROBE_ID /dev/null || + fail "published IPK has an incompatible container; rebuild and republish the OpenWrt packages" + ;; + esac +} +sed_replacement() { printf '%s' "$1" | sed 's/[\\&|]/\\&/g'; } + +install_package() { + download_asset "$PACKAGE" + case "$PLATFORM" in + debian) + log "installing $PACKAGE" + apt-get install -y "$WORK_DIR/$PACKAGE" + ;; + openwrt-apk) + set -- "$WORK_DIR/$PACKAGE" + if [ "$WITH_LUCI" = 1 ]; then download_asset "$LUCI_PACKAGE"; set -- "$@" "$WORK_DIR/$LUCI_PACKAGE"; fi + log "installing OpenWrt packages" + apk --allow-untrusted add "$@" + ;; + openwrt-opkg) + set -- "$WORK_DIR/$PACKAGE" + if [ "$WITH_LUCI" = 1 ]; then download_asset "$LUCI_PACKAGE"; set -- "$@" "$WORK_DIR/$LUCI_PACKAGE"; fi + log "installing OpenWrt packages" + opkg install "$@" + ;; + esac +} + +configure_and_start() { + case "$PLATFORM" in + debian) + config=/etc/default/cheburprobe + id=$(sed_replacement "$PROBE_ID") + token=$(sed_replacement "$PROBE_TOKEN") + sed -i "s|^PROBE_ID=.*|PROBE_ID=$id|; s|^PROBE_TOKEN=.*|PROBE_TOKEN=$token|" "$config" + chmod 600 "$config" + systemctl enable --now cheburprobe.service + ;; + openwrt-*) + uci set cheburprobe.main.probe_id="$PROBE_ID" + uci set cheburprobe.main.probe_token="$PROBE_TOKEN" + uci set cheburprobe.main.enabled='1' + uci commit cheburprobe + chmod 600 /etc/config/cheburprobe + /etc/init.d/cheburprobe enable + /etc/init.d/cheburprobe restart + ;; + esac +} + +disable_unconfigured_service() { + case "$PLATFORM" in + debian) systemctl disable --now cheburprobe.service >/dev/null 2>&1 || true ;; + openwrt-*) + uci set cheburprobe.main.enabled='0' + uci commit cheburprobe + /etc/init.d/cheburprobe stop >/dev/null 2>&1 || true + /etc/init.d/cheburprobe disable >/dev/null 2>&1 || true + ;; + esac +} + +print_configuration_help() { + printf '\nCheburprobe установлен, но не запущен.\n' + case "$PLATFORM" in + debian) + printf '%s\n' 'Укажите PROBE_ID и PROBE_TOKEN в /etc/default/cheburprobe, затем выполните:' + printf '%s\n' ' sudo systemctl enable --now cheburprobe.service' + ;; + openwrt-*) + if [ "$WITH_LUCI" = 1 ]; then printf '%s\n' 'Откройте Службы → Cheburprobe в LuCI, укажите ID и токен и включите сервис.'; fi + printf '%s\n' 'Или настройте /etc/config/cheburprobe через UCI, затем выполните:' + printf '%s\n' ' /etc/init.d/cheburprobe enable && /etc/init.d/cheburprobe start' + ;; + esac +} + +[ "$(id -u)" -eq 0 ] || fail "run this installer as root" +WORK_DIR=$(mktemp -d /tmp/cheburprobe-install.XXXXXX) +trap 'rm -rf "$WORK_DIR"' EXIT INT TERM +RELEASE_JSON=$WORK_DIR/latest.json +download "$GITHUB_API_URL/repos/$REPOSITORY/releases/latest" "$RELEASE_JSON" +detect_platform +VERSION=$(probe_version) +select_packages +INSTALLED_VERSION=$(installed_version) + +printf '\nCheburcheck Probe — мастер установки\n' +printf '%s\n' '------------------------------------' +printf 'Система: %s\n' "$PLATFORM_NAME" +printf 'Архитектура: %s\n' "$ARCH" +if [ -n "$INSTALLED_VERSION" ]; then printf 'Установлено: v%s\n' "$INSTALLED_VERSION"; else printf '%s\n' 'Установлено: нет'; fi +printf 'Будет установлен: v%s\n' "$VERSION" +printf 'Пакет: %s\n' "$PACKAGE" +if [ -n "$LUCI_PACKAGE" ] && [ "$WITH_LUCI" = 1 ]; then printf 'LuCI: %s\n' "$LUCI_PACKAGE"; fi + +if [ -n "$INSTALLED_VERSION" ]; then + if [ "$INSTALLED_VERSION" = "$VERSION" ]; then question='Последняя версия уже установлена. Переустановить её? [y/N]'; default=n; else question='Обновить Cheburprobe до указанной версии? [Y/n]'; default=y; fi + if ! prompt "$question" "$default"; then log "update cancelled"; exit 0; fi +else + if ! prompt 'Продолжить установку? [Y/n]' y; then log "installation cancelled"; exit 0; fi +fi + +read_credentials +install_package +if [ "$CONFIGURE" = 1 ]; then + configure_and_start + printf '\nCheburprobe v%s настроен, запущен и добавлен в автозагрузку.\n' "$VERSION" +else + disable_unconfigured_service + print_configuration_help +fi diff --git a/probe/openwrt/build-apk.sh b/probe/openwrt/build-apk.sh index cd83e2d..1fcd725 100644 --- a/probe/openwrt/build-apk.sh +++ b/probe/openwrt/build-apk.sh @@ -78,10 +78,12 @@ build_apk() { } PROBE_ROOT="$WORK_DIR/cheburprobe" -mkdir -p "$PROBE_ROOT/usr/bin" "$PROBE_ROOT/etc/init.d" "$PROBE_ROOT/etc/config" +mkdir -p "$PROBE_ROOT/usr/bin" "$PROBE_ROOT/usr/libexec" "$PROBE_ROOT/etc/init.d" "$PROBE_ROOT/etc/config" install -m 0755 "$BINARY" "$PROBE_ROOT/usr/bin/cheburprobe" install -m 0755 "$ROOT_DIR/probe/openwrt/cheburprobe.init" "$PROBE_ROOT/etc/init.d/cheburprobe" +install -m 0755 "$ROOT_DIR/probe/openwrt/cheburprobe-updater.init" "$PROBE_ROOT/etc/init.d/cheburprobe-updater" install -m 0600 "$ROOT_DIR/probe/openwrt/cheburprobe.config" "$PROBE_ROOT/etc/config/cheburprobe" +install -m 0755 "$ROOT_DIR/probe/update/cheburprobe-request-update.openwrt" "$PROBE_ROOT/usr/libexec/cheburprobe-request-update" add_openwrt_metadata cheburprobe "$PROBE_ROOT" /etc/config/cheburprobe build_apk cheburprobe "$ARCH" "$PROBE_ROOT" \ "Dynamic network probe daemon for Cheburcheck" "" \ diff --git a/probe/openwrt/build-ipk.sh b/probe/openwrt/build-ipk.sh index d9f16c5..31dba5c 100644 --- a/probe/openwrt/build-ipk.sh +++ b/probe/openwrt/build-ipk.sh @@ -42,16 +42,24 @@ EOF printf '2.0\n' > "$package_root/debian-binary" (cd "$package_root/control" && tar --sort=name --owner=0 --group=0 --numeric-owner -czf ../control.tar.gz .) (cd "$package_root/data" && tar --sort=name --owner=0 --group=0 --numeric-owner -czf ../data.tar.gz .) - (cd "$package_root" && ar r "$OUTPUT_DIR/$archive_name" debian-binary control.tar.gz data.tar.gz) + # OpenWrt's ipkg-build uses a gzip-compressed tar as the outer container. + # GNU ar appends '/' to member names; older opkg versions then fail with + # "pkg_init_from_file: Malformed package file". + (cd "$package_root" && \ + tar --sort=name --owner=0 --group=0 --numeric-owner \ + -cf - ./debian-binary ./data.tar.gz ./control.tar.gz | \ + gzip -n > "$OUTPUT_DIR/$archive_name") echo "$OUTPUT_DIR/$archive_name" } PROBE_ROOT="$WORK_DIR/cheburprobe" mkdir -p "$PROBE_ROOT/control" "$PROBE_ROOT/data/usr/bin" \ - "$PROBE_ROOT/data/etc/init.d" "$PROBE_ROOT/data/etc/config" + "$PROBE_ROOT/data/usr/libexec" "$PROBE_ROOT/data/etc/init.d" "$PROBE_ROOT/data/etc/config" install -m 0755 "$BINARY" "$PROBE_ROOT/data/usr/bin/cheburprobe" install -m 0755 "$ROOT_DIR/probe/openwrt/cheburprobe.init" "$PROBE_ROOT/data/etc/init.d/cheburprobe" +install -m 0755 "$ROOT_DIR/probe/openwrt/cheburprobe-updater.init" "$PROBE_ROOT/data/etc/init.d/cheburprobe-updater" install -m 0600 "$ROOT_DIR/probe/openwrt/cheburprobe.config" "$PROBE_ROOT/data/etc/config/cheburprobe" +install -m 0755 "$ROOT_DIR/probe/update/cheburprobe-request-update.openwrt" "$PROBE_ROOT/data/usr/libexec/cheburprobe-request-update" install -m 0755 "$ROOT_DIR/probe/openwrt/cheburprobe.postinst" "$PROBE_ROOT/control/postinst" printf '/etc/config/cheburprobe\n' > "$PROBE_ROOT/control/conffiles" build_ipk cheburprobe "$ARCH" "$PROBE_ROOT" "Description: Dynamic network probe daemon for Cheburcheck diff --git a/probe/openwrt/cheburprobe-updater.init b/probe/openwrt/cheburprobe-updater.init new file mode 100644 index 0000000..5f8f553 --- /dev/null +++ b/probe/openwrt/cheburprobe-updater.init @@ -0,0 +1,25 @@ +#!/bin/sh /etc/rc.common + +START=96 +STOP=9 +USE_PROCD=1 + +start_service() { + config_load cheburprobe + config_get_bool auto_update main auto_update 1 + [ "$auto_update" -eq 1 ] || return 0 + config_get update_interval main update_interval 21600 + initial_delay=300 + + procd_open_instance + procd_set_param command /bin/sh -c \ + "sleep $initial_delay; while :; do /usr/bin/cheburprobe update; sleep $update_interval; done" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger cheburprobe +} diff --git a/probe/openwrt/cheburprobe.config b/probe/openwrt/cheburprobe.config index 4cb784a..5824df1 100644 --- a/probe/openwrt/cheburprobe.config +++ b/probe/openwrt/cheburprobe.config @@ -8,3 +8,5 @@ config cheburprobe 'main' option max_concurrent_tasks '8' option traceroute_retries '3' option log_level 'info' + option auto_update '1' + option update_interval '21600' diff --git a/probe/openwrt/cheburprobe.postinst b/probe/openwrt/cheburprobe.postinst index 8989158..1df1201 100644 --- a/probe/openwrt/cheburprobe.postinst +++ b/probe/openwrt/cheburprobe.postinst @@ -1,5 +1,8 @@ #!/bin/sh -[ -n "${IPKG_INSTROOT:-}" ] || /etc/init.d/cheburprobe enable +[ -n "${IPKG_INSTROOT:-}" ] || { + /etc/init.d/cheburprobe enable + /etc/init.d/cheburprobe-updater enable + /etc/init.d/cheburprobe-updater running || /etc/init.d/cheburprobe-updater start +} exit 0 - diff --git a/probe/openwrt/luci/config.js b/probe/openwrt/luci/config.js index 3aca463..804e008 100644 --- a/probe/openwrt/luci/config.js +++ b/probe/openwrt/luci/config.js @@ -56,6 +56,14 @@ return view.extend({ o.value('trace', _('Trace')); o.default = 'info'; + o = s.option(form.Flag, 'auto_update', _('Automatic updates')); + o.default = '1'; + o.rmempty = false; + + o = s.option(form.Value, 'update_interval', _('Update check interval'), _('Seconds')); + o.datatype = 'and(uinteger,min(300))'; + o.default = '21600'; + return m.render(); } }); diff --git a/probe/src/dpi_hop.rs b/probe/src/dpi_hop.rs index 48e26c9..53a6313 100644 --- a/probe/src/dpi_hop.rs +++ b/probe/src/dpi_hop.rs @@ -77,6 +77,14 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result, + #[arg(long, env = "MQTT_HOST", default_value = "wss://cheburcheck.ru/mqtt")] mqtt_host: String, @@ -44,10 +57,10 @@ struct Args { mqtt_connection_timeout_secs: u64, #[arg(long, env = "PROBE_ID")] - probe_id: String, + probe_id: Option, #[arg(long, env = "PROBE_TOKEN")] - probe_token: String, + probe_token: Option, #[arg(long, env = "MAX_CONCURRENT_TASKS", default_value_t = 8)] max_concurrent_tasks: usize, @@ -56,10 +69,52 @@ struct Args { traceroute_retries: u8, } +#[derive(Debug, Clone)] +struct Args { + mqtt_host: String, + mqtt_port: u16, + mqtt_connection_timeout_secs: u64, + probe_id: String, + probe_token: String, + max_concurrent_tasks: usize, + traceroute_retries: u8, +} + +impl Cli { + fn into_daemon_args(self) -> Result { + Ok(Args { + mqtt_host: self.mqtt_host, + mqtt_port: self.mqtt_port, + mqtt_connection_timeout_secs: self.mqtt_connection_timeout_secs, + probe_id: self + .probe_id + .context("--probe-id or PROBE_ID is required when running the probe")?, + probe_token: self + .probe_token + .context("--probe-token or PROBE_TOKEN is required when running the probe")?, + max_concurrent_tasks: self.max_concurrent_tasks, + traceroute_retries: self.traceroute_retries, + }) + } +} + +#[derive(Subcommand, Debug, Clone)] +enum Command { + /// Update Cheburprobe from the latest GitHub release. + Update, +} + #[tokio::main] async fn main() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("failed to install the rustls ring crypto provider"))?; env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let args = Args::parse(); + let cli = Cli::parse(); + if matches!(cli.command, Some(Command::Update)) { + return update::run().await; + } + let args = cli.into_daemon_args()?; if args.max_concurrent_tasks == 0 { bail!("max_concurrent_tasks must be greater than zero"); } @@ -89,6 +144,7 @@ async fn main() -> Result<()> { )); let (client, mut eventloop) = AsyncClient::new(options, 100); + let mqtt_updates_enabled = Path::new(UPDATE_REQUEST_COMMAND).is_file(); let config = Arc::new(RwLock::new(None)); let task_semaphore = Arc::new(tokio::sync::Semaphore::new(args.max_concurrent_tasks)); let mut network_options = NetworkOptions::new(); @@ -98,6 +154,11 @@ async fn main() -> Result<()> { wait_for_connection(&mut eventloop).await; publish_status(&client, &status_topic, &args, true, DpiHops::default()).await?; client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?; + if mqtt_updates_enabled { + subscribe_to_update_requests(&client, &args.probe_id).await?; + } else { + debug!("MQTT-triggered updates are disabled for this standalone installation"); + } client .subscribe("probe/tasks/v1/+", QoS::AtLeastOnce) .await?; @@ -116,17 +177,20 @@ async fn main() -> Result<()> { loop { match eventloop.poll().await { Ok(Event::Incoming(Incoming::Publish(publish))) => { - if publish.topic == CONFIG_TOPIC { - match update_config(&config, &publish.payload).await { - Ok(dpi_hops) => { - if let Err(error) = - publish_status(&client, &status_topic, &args, true, dpi_hops).await - { - warn!("failed to publish probe status with DPI hop: {error}"); - } - } - Err(error) => warn!("failed to update probe config: {error}"), + if mqtt_updates_enabled && is_update_topic(&publish.topic, &args.probe_id) { + if publish.retain { + warn!("ignoring retained update request on {}", publish.topic); + } else { + request_update_check(); } + } else if publish.topic == CONFIG_TOPIC { + spawn_config_update( + client.clone(), + status_topic.clone(), + args.clone(), + config.clone(), + publish.payload.to_vec(), + ); } else { let client = client.clone(); let args = args.clone(); @@ -179,6 +243,9 @@ async fn main() -> Result<()> { }); publish_status(&client, &status_topic, &args, true, dpi_hops).await?; client.subscribe(CONFIG_TOPIC, QoS::AtLeastOnce).await?; + if mqtt_updates_enabled { + subscribe_to_update_requests(&client, &args.probe_id).await?; + } client .subscribe("probe/tasks/v1/+", QoS::AtLeastOnce) .await?; @@ -194,6 +261,52 @@ async fn main() -> Result<()> { } } +fn spawn_config_update( + client: AsyncClient, + status_topic: String, + args: Args, + config: Arc>>, + payload: Vec, +) { + tokio::spawn(async move { + match update_config(&config, &payload).await { + Ok(dpi_hops) => { + if let Err(error) = + publish_status(&client, &status_topic, &args, true, dpi_hops).await + { + warn!("failed to publish probe status with DPI hop: {error}"); + } + } + Err(error) => warn!("failed to update probe config: {error}"), + } + }); +} + +async fn subscribe_to_update_requests(client: &AsyncClient, probe_id: &str) -> Result<()> { + client.subscribe(UPDATE_TOPIC, QoS::AtLeastOnce).await?; + client + .subscribe(format!("{UPDATE_TOPIC}/{probe_id}"), QoS::AtLeastOnce) + .await?; + Ok(()) +} + +fn is_update_topic(topic: &str, probe_id: &str) -> bool { + topic == UPDATE_TOPIC || topic == format!("{UPDATE_TOPIC}/{probe_id}") +} + +fn request_update_check() { + tokio::spawn(async { + match tokio::process::Command::new(UPDATE_REQUEST_COMMAND) + .status() + .await + { + Ok(status) if status.success() => info!("requested an update check over MQTT"), + Ok(status) => warn!("update request command exited with {status}"), + Err(error) => warn!("failed to request an update check: {error}"), + } + }); +} + fn mqtt_transport(mqtt_host: &str) -> Result { if mqtt_host.starts_with("wss://") { Ok(Transport::wss_with_default_config()) @@ -449,6 +562,23 @@ fn probe_task_job_id(topic: &str) -> Option<&str> { mod tests { use super::*; + #[test] + fn parses_update_subcommand_without_daemon_arguments() { + let args = Cli::try_parse_from(["cheburprobe", "update"]).unwrap(); + assert!(matches!(args.command, Some(Command::Update))); + } + + #[test] + fn preserves_daemon_invocation_without_a_subcommand() { + let args = + Cli::try_parse_from(["cheburprobe", "--probe-id", "42", "--probe-token", "secret"]) + .unwrap() + .into_daemon_args() + .unwrap(); + assert_eq!(args.probe_id, "42"); + assert_eq!(args.probe_token, "secret"); + } + #[test] fn extracts_job_id_from_legacy_global_and_individual_topics() { assert_eq!(probe_task_job_id("probe/tasks/v1/job-1"), Some("job-1")); @@ -457,6 +587,14 @@ mod tests { assert_eq!(probe_task_job_id("probe/tasks/v1/42/job-2/extra"), None); } + #[test] + fn recognizes_global_and_individual_update_topics() { + assert!(is_update_topic("probe/update/v1", "42")); + assert!(is_update_topic("probe/update/v1/42", "42")); + assert!(!is_update_topic("probe/update/v1/7", "42")); + assert!(!is_update_topic("probe/update/v1/42/extra", "42")); + } + #[test] fn decodes_separate_dpi_targets() { let config: ProbeConfig = serde_json::from_value(serde_json::json!({ diff --git a/probe/src/mock_release_server.rs b/probe/src/mock_release_server.rs new file mode 100644 index 0000000..3c49c65 --- /dev/null +++ b/probe/src/mock_release_server.rs @@ -0,0 +1,190 @@ +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde::Serialize; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; + +#[derive(Parser, Debug)] +#[command(about = "Serve local packages through a mock GitHub release API")] +struct Args { + #[arg(long, default_value = "127.0.0.1:8080")] + bind: String, + + #[arg(long, default_value = "http://127.0.0.1:8080")] + public_url: String, + + #[arg(long, default_value = "LowderPlay/cheburcheck")] + repository: String, + + /// Directory containing .deb, .apk, and .ipk release assets. + #[arg(long)] + assets_dir: PathBuf, +} + +#[derive(Serialize)] +struct Release { + assets: Vec, +} + +#[derive(Serialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +fn main() -> Result<()> { + let args = Args::parse(); + validate_repository(&args.repository)?; + let assets_dir = args + .assets_dir + .canonicalize() + .with_context(|| format!("failed to open {}", args.assets_dir.display()))?; + let public_url = args.public_url.trim_end_matches('/').to_owned(); + let listener = TcpListener::bind(&args.bind) + .with_context(|| format!("failed to listen on {}", args.bind))?; + + println!( + "mock release API: {public_url}/repos/{}/releases/latest", + args.repository + ); + println!("serving assets from {}", assets_dir.display()); + + for connection in listener.incoming() { + match connection { + Ok(stream) => { + if let Err(error) = + handle_request(stream, &assets_dir, &args.repository, &public_url) + { + eprintln!("request failed: {error:#}"); + } + } + Err(error) => eprintln!("failed to accept connection: {error}"), + } + } + Ok(()) +} + +fn handle_request( + mut stream: TcpStream, + assets_dir: &Path, + repository: &str, + public_url: &str, +) -> Result<()> { + let mut reader = BufReader::new(stream.try_clone().context("failed to read request")?); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .context("failed to read request line")?; + let mut parts = request_line.split_whitespace(); + let (Some(method), Some(path), Some(_version), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return respond(&mut stream, 400, "text/plain", b"bad request\n"); + }; + if method != "GET" { + return respond(&mut stream, 405, "text/plain", b"method not allowed\n"); + } + println!("request: {method} {path}"); + + let release_path = format!("/repos/{repository}/releases/latest"); + if path == release_path { + let body = serde_json::to_vec(&Release { + assets: list_assets(assets_dir, public_url)?, + })?; + return respond(&mut stream, 200, "application/json", &body); + } + + if let Some(name) = path.strip_prefix("/assets/") { + if !valid_name(name) { + return respond(&mut stream, 400, "text/plain", b"invalid asset name\n"); + } + let asset_path = assets_dir.join(name); + return match fs::read(&asset_path) { + Ok(body) => respond(&mut stream, 200, "application/octet-stream", &body), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + respond(&mut stream, 404, "text/plain", b"not found\n") + } + Err(error) => { + Err(error).with_context(|| format!("failed to read {}", asset_path.display())) + } + }; + } + + respond(&mut stream, 404, "text/plain", b"not found\n") +} + +fn list_assets(directory: &Path, public_url: &str) -> Result> { + let mut assets = Vec::new(); + for entry in fs::read_dir(directory) + .with_context(|| format!("failed to list {}", directory.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow::anyhow!("asset filename is not valid UTF-8"))?; + let supported_extension = matches!( + entry.path().extension().and_then(|value| value.to_str()), + Some("deb" | "apk" | "ipk") + ); + if !valid_name(&name) || !supported_extension { + continue; + } + assets.push(Asset { + browser_download_url: format!("{public_url}/assets/{name}"), + name, + }); + } + assets.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(assets) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn validate_repository(repository: &str) -> Result<()> { + let mut parts = repository.split('/'); + match (parts.next(), parts.next(), parts.next()) { + (Some(owner), Some(repo), None) if valid_name(owner) && valid_name(repo) => Ok(()), + _ => bail!("invalid repository {repository:?}; expected owner/name"), + } +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) -> Result<()> { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + _ => "Error", + }; + write!( + stream, + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )?; + stream.write_all(body)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_package_names_and_rejects_paths() { + assert!(valid_name("cheburprobe-0.6.0-r1_x86_64.apk")); + assert!(valid_name("luci-app-cheburprobe_0.6.0-1_all.ipk")); + assert!(!valid_name("../cheburprobe.apk")); + assert!(!valid_name("directory/cheburprobe.apk")); + } +} diff --git a/probe/src/update.rs b/probe/src/update.rs new file mode 100644 index 0000000..26218dc --- /dev/null +++ b/probe/src/update.rs @@ -0,0 +1,555 @@ +use anyhow::{Context, Result, bail}; +use reqwest::{Client, Url}; +use semver::Version; +use serde::Deserialize; +use std::env; +#[cfg(unix)] +use std::fs::{File, OpenOptions}; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use std::time::Duration; +use tempfile::TempDir; + +const DEFAULT_REPOSITORY: &str = "LowderPlay/cheburcheck"; +const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; + +#[derive(Debug, Deserialize)] +struct Release { + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PackageKind { + Debian, + Apk, + Opkg, + Linux, + #[cfg_attr(not(windows), allow(dead_code))] + Windows, +} + +#[cfg(unix)] +struct UpdateLock { + _file: File, +} + +#[cfg(unix)] +impl UpdateLock { + fn acquire() -> Result> { + let lock_path = env::temp_dir().join(format!( + "cheburprobe-update-{}.lock", + rustix::process::getuid().as_raw() + )); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("failed to open update lock {}", lock_path.display()))?; + + match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) { + Ok(()) => Ok(Some(Self { _file: file })), + Err(rustix::io::Errno::WOULDBLOCK) => Ok(None), + Err(error) => Err(error).context("failed to lock updater"), + } + } +} + +#[cfg(windows)] +struct UpdateLock; + +#[cfg(windows)] +impl UpdateLock { + fn acquire() -> Result> { + Ok(Some(Self)) + } +} + +pub async fn run() -> Result<()> { + let Some(_lock) = UpdateLock::acquire()? else { + println!("another cheburprobe update check is already running"); + return Ok(()); + }; + + update().await +} + +async fn update() -> Result<()> { + let repository = + env::var("CHEBURPROBE_UPDATE_REPOSITORY").unwrap_or_else(|_| DEFAULT_REPOSITORY.to_owned()); + validate_repository(&repository)?; + + let current = Version::parse(env!("CARGO_PKG_VERSION")) + .context("the installed cheburprobe version is invalid")?; + let client = Client::builder() + .user_agent(concat!("cheburprobe-update/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(60)) + .build() + .context("failed to create HTTP client")?; + let api_base_url = env::var("CHEBURPROBE_UPDATE_API_BASE_URL") + .unwrap_or_else(|_| DEFAULT_API_BASE_URL.to_owned()); + let api_url = format!( + "{}/repos/{repository}/releases/latest", + api_base_url.trim_end_matches('/') + ); + let release = fetch_release(&client, &api_url).await?; + let (kind, architecture, luci_installed) = detect_platform()?; + let (asset, latest) = select_asset(&release.assets, kind, &architecture)?; + + if latest <= current { + if latest == current { + println!("cheburprobe is current ({current})"); + } else { + println!( + "installed cheburprobe {current} is newer than packaged version {latest}; not downgrading" + ); + } + return Ok(()); + } + + let luci_asset = luci_installed + .then(|| select_luci_asset(&release.assets, kind, &latest)) + .transpose()?; + + let temp_dir = TempDir::with_prefix("cheburprobe-update.") + .context("failed to create a temporary update directory")?; + let package = download_asset(&client, asset, temp_dir.path()).await?; + let luci_package = match luci_asset { + Some(asset) => Some(download_asset(&client, asset, temp_dir.path()).await?), + None => None, + }; + install(kind, &package, luci_package.as_deref())?; + + println!("updated cheburprobe from {current} to {latest}"); + Ok(()) +} + +async fn fetch_release(client: &Client, api_url: &str) -> Result { + client + .get(api_url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("failed to query the latest GitHub release")? + .error_for_status() + .context("GitHub rejected the latest-release request")? + .json::() + .await + .context("GitHub returned an invalid release document") +} + +fn validate_repository(repository: &str) -> Result<()> { + let mut parts = repository.split('/'); + let valid_part = |part: &str| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }; + match (parts.next(), parts.next(), parts.next()) { + (Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => Ok(()), + _ => bail!("invalid GitHub repository {repository:?}; expected owner/name"), + } +} + +fn command_exists(command: &str) -> bool { + env::var_os("PATH").is_some_and(|path| { + env::split_paths(&path).any(|directory| directory.join(command).is_file()) + }) +} + +fn command_output(command: &str, arguments: &[&str]) -> Result { + let output = Command::new(command) + .args(arguments) + .output() + .with_context(|| format!("failed to execute {command}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + bail!("{command} exited with {}: {stderr}", output.status); + } + Ok(output) +} + +fn output_text(command: &str, arguments: &[&str]) -> Result { + let output = command_output(command, arguments)?; + String::from_utf8(output.stdout).with_context(|| format!("{command} returned non-UTF-8 output")) +} + +fn command_succeeds(command: &str, arguments: &[&str]) -> Result { + let status = Command::new(command) + .args(arguments) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("failed to execute {command}"))?; + Ok(status.success()) +} + +fn detect_platform() -> Result<(PackageKind, String, bool)> { + #[cfg(windows)] + { + let architecture = match env::consts::ARCH { + "x86_64" => "x86_64", + architecture => bail!("unsupported Windows architecture: {architecture}"), + }; + return Ok((PackageKind::Windows, architecture.to_owned(), false)); + } + + #[cfg(target_os = "linux")] + if command_exists("dpkg") && command_succeeds("dpkg-query", &["-W", "cheburprobe"])? { + let architecture = output_text("dpkg", &["--print-architecture"])?; + let architecture = architecture.trim(); + if !matches!(architecture, "amd64" | "arm64") { + bail!("unsupported Debian architecture: {architecture}"); + } + Ok((PackageKind::Debian, architecture.to_owned(), false)) + } else if command_exists("apk") + && command_succeeds("apk", &["info", "--exists", "cheburprobe"])? + { + let architecture = output_text("apk", &["--print-arch"])?; + let luci_installed = + command_succeeds("apk", &["info", "--exists", "luci-app-cheburprobe"])?; + Ok(( + PackageKind::Apk, + architecture.trim().to_owned(), + luci_installed, + )) + } else if command_exists("opkg") + && output_text("opkg", &["list-installed", "cheburprobe"])? + .lines() + .any(|line| line.split_whitespace().next() == Some("cheburprobe")) + { + let architectures = output_text("opkg", &["print-architecture"])?; + let architecture = architectures + .lines() + .filter_map(|line| line.split_whitespace().nth(1)) + .rfind(|architecture| *architecture != "all") + .context("opkg did not report a package architecture")?; + let luci_installed = output_text("opkg", &["list-installed", "luci-app-cheburprobe"])? + .lines() + .any(|line| line.split_whitespace().next() == Some("luci-app-cheburprobe")); + Ok((PackageKind::Opkg, architecture.to_owned(), luci_installed)) + } else { + let architecture = match env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => bail!("unsupported standalone Linux architecture: {architecture}"), + }; + Ok((PackageKind::Linux, architecture.to_owned(), false)) + } + + #[cfg(not(any(target_os = "linux", windows)))] + bail!("updates are not supported on this operating system") +} + +fn select_luci_asset<'a>( + assets: &'a [Asset], + kind: PackageKind, + version: &Version, +) -> Result<&'a Asset> { + let (prefix, suffix) = match kind { + PackageKind::Apk => (format!("luci-app-cheburprobe-{version}-r"), ".apk"), + PackageKind::Opkg => (format!("luci-app-cheburprobe_{version}-"), "_all.ipk"), + PackageKind::Debian | PackageKind::Linux | PackageKind::Windows => { + bail!("LuCI packages are only supported on OpenWrt") + } + }; + let matches: Vec<_> = assets + .iter() + .filter(|asset| asset.name.starts_with(&prefix) && asset.name.ends_with(suffix)) + .collect(); + match matches.as_slice() { + [asset] => Ok(asset), + [] => bail!("LuCI package for v{version} not found"), + _ => bail!("multiple LuCI packages for v{version} found"), + } +} + +fn package_version(name: &str, kind: PackageKind, architecture: &str) -> Option { + let (prefix, suffix) = match kind { + PackageKind::Debian => ("cheburprobe_", format!("_{architecture}.deb")), + PackageKind::Apk => ("cheburprobe-", format!("_{architecture}.apk")), + PackageKind::Opkg => ("cheburprobe_", format!("_{architecture}.ipk")), + PackageKind::Linux => ("cheburprobe-", format!("-linux-{architecture}")), + PackageKind::Windows => ("cheburprobe-", format!("-windows-{architecture}.exe")), + }; + let version_with_revision = name.strip_prefix(prefix)?.strip_suffix(&suffix)?; + let version = match kind { + PackageKind::Apk => version_with_revision.rsplit_once("-r")?.0, + PackageKind::Debian | PackageKind::Opkg => version_with_revision.rsplit_once('-')?.0, + PackageKind::Linux | PackageKind::Windows => version_with_revision, + }; + Version::parse(version).ok() +} + +fn select_asset<'a>( + assets: &'a [Asset], + kind: PackageKind, + architecture: &str, +) -> Result<(&'a Asset, Version)> { + let mut matches: Vec<_> = assets + .iter() + .filter_map(|asset| { + package_version(&asset.name, kind, architecture).map(|version| (asset, version)) + }) + .collect(); + matches.sort_by(|(_, left), (_, right)| left.cmp(right)); + let Some((asset, version)) = matches.pop() else { + bail!("Cheburprobe package for architecture {architecture} not found"); + }; + if matches.last().is_some_and(|(_, other)| other == &version) { + bail!("multiple Cheburprobe {version} packages for architecture {architecture} found"); + } + Ok((asset, version)) +} + +async fn download(client: &Client, url: Url, destination: &Path) -> Result<()> { + let bytes = client + .get(url) + .send() + .await + .context("failed to download the update package")? + .error_for_status() + .context("GitHub rejected the package download")? + .bytes() + .await + .context("failed to read the update package")?; + std::fs::write(destination, bytes) + .with_context(|| format!("failed to write {}", destination.display())) +} + +async fn download_asset( + client: &Client, + asset: &Asset, + directory: &Path, +) -> Result { + if Path::new(&asset.name) + .file_name() + .and_then(|name| name.to_str()) + != Some(&asset.name) + { + bail!("invalid release asset name: {:?}", asset.name); + } + let url = Url::parse(&asset.browser_download_url) + .context("GitHub returned an invalid release asset URL")?; + let destination = directory.join(&asset.name); + download(client, url, &destination).await?; + Ok(destination) +} + +fn run_paths(command: &str, arguments: &[&Path]) -> Result<()> { + let status = Command::new(command) + .args(arguments) + .status() + .with_context(|| format!("failed to execute {command}"))?; + if !status.success() { + bail!("{command} exited with {status}"); + } + Ok(()) +} + +fn run_args(command: &str, arguments: &[&str]) -> Result<()> { + let status = Command::new(command) + .args(arguments) + .status() + .with_context(|| format!("failed to execute {command}"))?; + if !status.success() { + bail!("{command} exited with {status}"); + } + Ok(()) +} + +fn install(kind: PackageKind, package: &Path, luci_package: Option<&Path>) -> Result<()> { + match kind { + PackageKind::Debian => { + run_paths("dpkg-deb", &[Path::new("--info"), package])?; + run_paths("dpkg", &[Path::new("-i"), package])?; + run_args("systemctl", &["try-restart", "cheburprobe.service"]) + } + PackageKind::Apk => { + let mut arguments = vec![Path::new("add"), Path::new("--allow-untrusted"), package]; + arguments.extend(luci_package); + run_paths("apk", &arguments)?; + run_args("/etc/init.d/cheburprobe", &["restart"]) + } + PackageKind::Opkg => { + let mut arguments = vec![Path::new("install"), package]; + arguments.extend(luci_package); + run_paths("opkg", &arguments)?; + run_args("/etc/init.d/cheburprobe", &["restart"]) + } + PackageKind::Linux => replace_linux_executable(package), + PackageKind::Windows => replace_windows_executable(package), + } +} + +#[cfg(unix)] +fn replace_linux_executable(package: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let executable = env::current_exe().context("failed to locate the running executable")?; + let replacement = executable.with_extension("new"); + let mode = std::fs::metadata(&executable) + .context("failed to inspect the running executable")? + .permissions() + .mode(); + std::fs::copy(package, &replacement) + .context("failed to copy the new Linux executable beside the current one")?; + std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(mode)) + .context("failed to set permissions on the new Linux executable")?; + if let Err(error) = std::fs::rename(&replacement, &executable) { + let _ = std::fs::remove_file(&replacement); + return Err(error).context("failed to replace the Linux executable"); + } + Ok(()) +} + +#[cfg(not(unix))] +fn replace_linux_executable(_package: &Path) -> Result<()> { + bail!("Linux executable replacement is unavailable on this platform") +} + +#[cfg(windows)] +fn replace_windows_executable(package: &Path) -> Result<()> { + let executable = env::current_exe().context("failed to locate the running executable")?; + let backup = executable.with_extension("old.exe"); + match std::fs::remove_file(&backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("failed to remove the previous executable backup"), + } + + std::fs::rename(&executable, &backup) + .context("failed to move the running executable to its backup path")?; + if let Err(error) = std::fs::copy(package, &executable) { + let _ = std::fs::rename(&backup, &executable); + return Err(error).context("failed to install the new Windows executable"); + } + + // The renamed executable may stay locked until this process exits. A later + // update removes the backup if it cannot be deleted immediately. + let _ = std::fs::remove_file(backup); + Ok(()) +} + +#[cfg(not(windows))] +fn replace_windows_executable(_package: &Path) -> Result<()> { + bail!("Windows executable replacement is unavailable on this platform") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn asset(name: &str) -> Asset { + Asset { + name: name.to_owned(), + browser_download_url: format!( + "https://github.com/LowderPlay/cheburcheck/releases/download/v0.5.0/{name}" + ), + } + } + + #[test] + fn parses_versions_from_package_names() { + assert_eq!( + package_version( + "cheburprobe_1.2.3-1_arm64.deb", + PackageKind::Debian, + "arm64" + ), + Some(Version::new(1, 2, 3)) + ); + assert_eq!( + package_version( + "cheburprobe-1.2.3-r1_aarch64_generic.apk", + PackageKind::Apk, + "aarch64_generic" + ), + Some(Version::new(1, 2, 3)) + ); + assert_eq!( + package_version( + "cheburprobe_1.2.3-1_aarch64_generic.ipk", + PackageKind::Opkg, + "aarch64_generic" + ), + Some(Version::new(1, 2, 3)) + ); + } + + #[test] + fn validates_repository_names() { + assert!(validate_repository("LowderPlay/cheburcheck").is_ok()); + assert!(validate_repository("owner/repo/extra").is_err()); + assert!(validate_repository("owner?x/repo").is_err()); + } + + #[test] + fn selects_each_package_format() { + let assets = vec![ + asset("cheburprobe_0.5.0-1_arm64.deb"), + asset("cheburprobe-0.5.0-r1_aarch64_generic.apk"), + asset("cheburprobe_0.5.0-1_aarch64_generic.ipk"), + asset("luci-app-cheburprobe-0.5.0-r1.apk"), + asset("luci-app-cheburprobe_0.5.0-1_all.ipk"), + asset("cheburprobe-0.5.0-windows-x86_64.exe"), + asset("cheburprobe-0.5.0-linux-amd64"), + ]; + let version = Version::new(0, 5, 0); + assert_eq!( + select_asset(&assets, PackageKind::Debian, "arm64") + .unwrap() + .0 + .name, + "cheburprobe_0.5.0-1_arm64.deb" + ); + assert_eq!( + select_asset(&assets, PackageKind::Apk, "aarch64_generic") + .unwrap() + .0 + .name, + "cheburprobe-0.5.0-r1_aarch64_generic.apk" + ); + assert_eq!( + select_asset(&assets, PackageKind::Opkg, "aarch64_generic") + .unwrap() + .0 + .name, + "cheburprobe_0.5.0-1_aarch64_generic.ipk" + ); + assert_eq!( + select_luci_asset(&assets, PackageKind::Apk, &version) + .unwrap() + .name, + "luci-app-cheburprobe-0.5.0-r1.apk" + ); + assert_eq!( + select_luci_asset(&assets, PackageKind::Opkg, &version) + .unwrap() + .name, + "luci-app-cheburprobe_0.5.0-1_all.ipk" + ); + assert_eq!( + select_asset(&assets, PackageKind::Windows, "x86_64") + .unwrap() + .0 + .name, + "cheburprobe-0.5.0-windows-x86_64.exe" + ); + assert_eq!( + select_asset(&assets, PackageKind::Linux, "amd64") + .unwrap() + .0 + .name, + "cheburprobe-0.5.0-linux-amd64" + ); + } +} diff --git a/probe/update/cheburprobe-request-update.debian b/probe/update/cheburprobe-request-update.debian new file mode 100644 index 0000000..f2ddc87 --- /dev/null +++ b/probe/update/cheburprobe-request-update.debian @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +touch /run/cheburprobe/update-requested diff --git a/probe/update/cheburprobe-request-update.openwrt b/probe/update/cheburprobe-request-update.openwrt new file mode 100644 index 0000000..8ba4441 --- /dev/null +++ b/probe/update/cheburprobe-request-update.openwrt @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/bin/cheburprobe update >/dev/null 2>&1 & diff --git a/probe/update/cheburprobe-update.path b/probe/update/cheburprobe-update.path new file mode 100644 index 0000000..7680a05 --- /dev/null +++ b/probe/update/cheburprobe-update.path @@ -0,0 +1,9 @@ +[Unit] +Description=Check for Cheburprobe updates when requested over MQTT + +[Path] +PathExists=/run/cheburprobe/update-requested +Unit=cheburprobe-update.service + +[Install] +WantedBy=multi-user.target diff --git a/probe/update/cheburprobe-update.service b/probe/update/cheburprobe-update.service new file mode 100644 index 0000000..aa9865e --- /dev/null +++ b/probe/update/cheburprobe-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Update Cheburprobe from the latest GitHub release +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/cheburprobe update +ExecStartPost=-/usr/bin/rm -f /run/cheburprobe/update-requested diff --git a/probe/update/cheburprobe-update.timer b/probe/update/cheburprobe-update.timer new file mode 100644 index 0000000..80adf83 --- /dev/null +++ b/probe/update/cheburprobe-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodically check for Cheburprobe updates + +[Timer] +OnBootSec=15min +OnUnitActiveSec=6h +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/website/Cargo.toml b/website/Cargo.toml index 3ec27e7..84485f2 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "website" -version = "1.2.4" +version = "1.2.5" edition = "2024" [dependencies] diff --git a/website/src/main.rs b/website/src/main.rs index c56d593..5cfb402 100644 --- a/website/src/main.rs +++ b/website/src/main.rs @@ -6,6 +6,7 @@ mod database_refresh; mod db; mod mqtt; mod mqtt_auth; +mod probe_installer; mod whitelist; use env_logger::Env; @@ -106,6 +107,7 @@ async fn rocket() -> _ { ) .mount("/agency", routes![agency::upload_report]) .mount("/mqtt", routes![mqtt_auth::auth, mqtt_auth::acl]) + .mount("/", routes![probe_installer::download]) .mount("/whitelist", routes![whitelist::export_csv]) .register("/", catchers![api_error]) } diff --git a/website/src/mqtt_auth.rs b/website/src/mqtt_auth.rs index 263def8..1bb3806 100644 --- a/website/src/mqtt_auth.rs +++ b/website/src/mqtt_auth.rs @@ -120,7 +120,11 @@ pub async fn acl( } async fn can_probe_subscribe(client_id: &str, topic: &str, pool: &PgPool) -> bool { - if topic == "probe/config/v1" || is_own_task_subscription(client_id, topic) { + if topic == "probe/config/v1" + || topic == "probe/update/v1" + || topic == format!("probe/update/v1/{client_id}") + || is_own_task_subscription(client_id, topic) + { return true; } if !is_global_task_subscription(topic) { @@ -182,6 +186,14 @@ mod tests { assert!(!is_own_task_subscription("42", "probe/tasks/v1/#")); } + #[rocket::async_test] + async fn update_subscriptions_are_node_scoped() { + let pool = PgPool::connect_lazy("postgres://localhost/unused").unwrap(); + assert!(can_probe_subscribe("42", "probe/update/v1", &pool).await); + assert!(can_probe_subscribe("42", "probe/update/v1/42", &pool).await); + assert!(!can_probe_subscribe("42", "probe/update/v1/7", &pool).await); + } + #[test] fn results_can_only_be_published_as_the_authenticated_node() { assert!(can_probe_publish("42", "probe/results/v1/job/42")); diff --git a/website/src/probe_installer.rs b/website/src/probe_installer.rs new file mode 100644 index 0000000..b4e4e8d --- /dev/null +++ b/website/src/probe_installer.rs @@ -0,0 +1,8 @@ +use rocket::http::ContentType; + +const INSTALLER: &str = include_str!("../../probe/install.sh"); + +#[get("/install-probe.sh")] +pub fn download() -> (ContentType, &'static str) { + (ContentType::new("text", "x-shellscript"), INSTALLER) +}