mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-09-22 22:37:59 +03:00
feat: probe autoupdate (#88)
* feat: autoupdate * chore: bump version * feat: publish installer * fix: standalone builds * ci: release docs * fix: windows icmp socket * docs: windows notice * docs: remove line breaks
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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/*
|
||||
|
||||
Generated
+5
-59
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-9
@@ -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 <me@lowderplay.dev>"
|
||||
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"] }
|
||||
|
||||
+223
-109
@@ -1,140 +1,260 @@
|
||||
# Cheburcheck Probe
|
||||
|
||||
[](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 <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 <binary> <output-dir>`.
|
||||
Та же переменная поддерживается скриптом `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 <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 <binary> <output-dir>
|
||||
|
||||
OPENWRT_ARCH=aarch64_cortex-a53 \
|
||||
probe/openwrt/build-ipk.sh <binary> <output-dir>
|
||||
```
|
||||
|
||||
### Как работает проверка
|
||||
|
||||
После подключения сканер:
|
||||
|
||||
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-сертификата намеренно отключена: измеряется доступность соединения, а не доверие к сертификату.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/tty ) 2>/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 || fail "could not read the answer"
|
||||
[ -n "$answer" ] || answer=$default
|
||||
case "$answer" in
|
||||
y|Y|yes|YES|д|Д|да|ДА) return 0 ;;
|
||||
n|N|no|NO|н|Н|нет|НЕТ) return 1 ;;
|
||||
*) printf 'Введите y или n.\n' >/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/tty
|
||||
IFS= read -r PROBE_TOKEN </dev/tty
|
||||
read_status=$?
|
||||
stty echo </dev/tty
|
||||
else
|
||||
# BusyBox ash supports silent input as a shell built-in even when stty is absent.
|
||||
IFS= read -r -s PROBE_TOKEN </dev/tty 2>/dev/null
|
||||
read_status=$?
|
||||
if [ "$read_status" -ne 0 ]; then
|
||||
IFS= read -r PROBE_TOKEN </dev/tty
|
||||
read_status=$?
|
||||
fi
|
||||
fi
|
||||
set -e
|
||||
printf '\n' >/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/tty || fail "could not read PROBE_ID"
|
||||
read_probe_token
|
||||
[ -n "$PROBE_ID" ] && [ -n "$PROBE_TOKEN" ] || fail "ID and token must not be empty"
|
||||
case "$PROBE_ID$PROBE_TOKEN" in *'
|
||||
'*) fail "ID and token must not contain newlines" ;; esac
|
||||
CONFIGURE=1
|
||||
}
|
||||
|
||||
asset_url() {
|
||||
printf '%s/%s/releases/latest/download/%s\n' "$GITHUB_URL" "$REPOSITORY" "$1"
|
||||
}
|
||||
|
||||
download_asset() {
|
||||
asset=$1
|
||||
url=$(asset_url "$asset")
|
||||
log "downloading $asset"
|
||||
download "$url" "$WORK_DIR/$asset"
|
||||
case "$asset" in
|
||||
*.ipk)
|
||||
gzip -t "$WORK_DIR/$asset" 2>/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
|
||||
@@ -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" "" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,6 +77,14 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result<DpiHopPr
|
||||
));
|
||||
}
|
||||
|
||||
// Winsock requires a raw socket to be bound before `recvfrom`; otherwise
|
||||
// the first drain/read fails with WSAEINVAL (10022). Binding to the address
|
||||
// selected for the TCP connection also limits replies to the right local
|
||||
// interface. Raw sockets do not use a transport port, so bind with port 0.
|
||||
let mut icmp_addr = local_addr;
|
||||
icmp_addr.set_port(0);
|
||||
icmp.bind(&icmp_addr.into())?;
|
||||
|
||||
tcp.write_all(&client_hello)?;
|
||||
let mut hops = Vec::with_capacity(config.max_ttl as usize);
|
||||
let mut max_icmp_time_exceeded_ttl = None;
|
||||
|
||||
+154
-16
@@ -2,20 +2,24 @@ mod dns;
|
||||
mod dpi_hop;
|
||||
mod sni;
|
||||
mod traceroute;
|
||||
mod update;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use log::{debug, error, info, warn};
|
||||
use reports::probe::{DpiProbeConfig, ProbeConfig, ProbeResult, ProbeStatus, ProbeTask};
|
||||
use rumqttc::{
|
||||
AsyncClient, Event, Incoming, LastWill, MqttOptions, NetworkOptions, QoS, Transport,
|
||||
};
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const CONFIG_TOPIC: &str = "probe/config/v1";
|
||||
const UPDATE_TOPIC: &str = "probe/update/v1";
|
||||
const UPDATE_REQUEST_COMMAND: &str = "/usr/libexec/cheburprobe-request-update";
|
||||
const MQTT_MAX_PACKET_SIZE: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -32,8 +36,17 @@ struct DpiHops {
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(author, version, about = "Dynamic probing daemon")]
|
||||
struct Args {
|
||||
#[command(
|
||||
author,
|
||||
version,
|
||||
about = "Dynamic probing daemon",
|
||||
subcommand_negates_reqs = true,
|
||||
args_conflicts_with_subcommands = true
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[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<String>,
|
||||
|
||||
#[arg(long, env = "PROBE_TOKEN")]
|
||||
probe_token: String,
|
||||
probe_token: Option<String>,
|
||||
|
||||
#[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<Args> {
|
||||
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<RwLock<Option<LoadedProbeConfig>>>,
|
||||
payload: Vec<u8>,
|
||||
) {
|
||||
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<Transport> {
|
||||
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!({
|
||||
|
||||
@@ -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<Asset>,
|
||||
}
|
||||
|
||||
#[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<Vec<Asset>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Asset>,
|
||||
}
|
||||
|
||||
#[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<Option<Self>> {
|
||||
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<Option<Self>> {
|
||||
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<Release> {
|
||||
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::<Release>()
|
||||
.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<Output> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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<Version> {
|
||||
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<std::path::PathBuf> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
touch /run/cheburprobe/update-requested
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/bin/cheburprobe update >/dev/null 2>&1 &
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Periodically check for Cheburprobe updates
|
||||
|
||||
[Timer]
|
||||
OnBootSec=15min
|
||||
OnUnitActiveSec=6h
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "website"
|
||||
version = "1.2.4"
|
||||
version = "1.2.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user