fix(install): no unbounded download, no leftovers, no needless re-download
Some checks are pending
Check / Test & lint (linux) (push) Waiting to run
Check / Artifact round-trip (push) Waiting to run
Check / Test & lint (windows) (push) Waiting to run
Check / Dependency policy (push) Waiting to run

An install could sit for minutes with the binary already on disk, could be left
with its own files in the install directory when it was interrupted, and could
fetch a release that was already there.

The manifest was swept across every source in turn, four attempts each, and the
last of those attempts — the optionless `wget` that every BusyBox accepts — has
no timeout at all: BusyBox sets none and GNU wget waits 900 s for a first byte.
A source that accepts the connection and then stalls therefore held the install
open, which is the one case the mirror race exists for. Measured against such a
source the installer had not finished after 90 s, and a release published before
the manifest existed (`alpha.19` publishes none) cost 28-32 s of silence after
the binary was already on disk. Every attempt is bounded now, the release URL is
asked first — a 404 there settles that no mirror can have the manifest, so the
sweep stops instead of walking the mirrors — and the source that served the
binary is asked only when GitHub itself cannot be reached. The same install,
measured: 29 s -> 2 s in WSL, 14 s -> under a second on the Keenetic.

Stopping the race left the losing downloads running: a worker is a subshell and
curl/wget is its child, so the signal reached the shell while the transfer kept
pulling to its own `--max-time`. A worker now stops its own download in a trap,
the signal traps stop the workers and this shell's own download, and an
interrupted install removes the race directory, the temp file and the manifest.
Measured with seven downloads in flight: after the interrupt the install
directory is empty and no downloader is left; before, the race directory and a
live curl stayed behind.

An install that already holds this release is not fetched again: the destination
is asked for its version (`--version`; `-v` is the detector's `--verbose`), and
when it matches the version this script carries, the download is skipped and that
binary is run with the arguments the caller passed — which is what a second
`curl … | sh -s -- -t 2 -d rutracker.org` is for. A file that cannot be run, or
has no execute bit, is not a version, so a foreign-architecture or truncated file
is replaced as before; `DPI_VERSION=latest` has nothing to compare against and
installs as before.

README: the installer bullets say what the script does now.
This commit is contained in:
Kirill Minovsky 2026-09-21 19:33:04 +03:00
parent 8a93576cad
commit 9fcda04f31
2 changed files with 199 additions and 62 deletions

View file

@ -72,8 +72,8 @@ irm https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install.ps1 |
При запуске на роутере скрипт `install.sh`:
1. Автоматически находит директорию `/opt/bin` (Entware) и устанавливает бинарник туда (сохраняется после перезагрузки).
2. **Контролирует свободное место**: если на накопителе осталось `< 200 МБ`, скрипт автоматически ставит компактную версию со сжатием UPX (~1.4 МБ вместо ~4.0 МБ), защищая роутер от переполнения Flash или `tmpfs`.
3. Заменяет файл атомарно (`.tmp.$$` -> `dpi-detector`) с проверкой архитектуры через `--version` (при сбое UPX автоматически переключается на стандартный бинарник) и печатает **фактически занятое место** — по `du` уже установленного файла, а не обещанный размер из релиза.
4. **Качает со всех зеркал одновременно**: побеждает то, что загрузилось первым, остальные отменяются, а скачанный файл всё равно проверяется запуском (не стартовал — зеркала перебираются по одному). Гонка включается только тогда, когда в целевом каталоге хватает места под копию от каждого зеркала — `число зеркал × 5 МБ`, но не меньше 30 МБ; иначе зеркала идут по очереди, и установка просто медленнее на медленном зеркале.
3. Заменяет файл атомарно (`.tmp.$$` -> `dpi-detector`) с проверкой архитектуры через `--version` (при сбое UPX автоматически переключается на стандартный бинарник) и печатает **фактически занятое место** — по `du` уже установленного файла, а не обещанный размер из релиза. Если релиз публикует `SHA256SUMS.txt`, скачанное сверяется с ним до запуска: манифест берётся с самого релиза, а если GitHub недоступен — с того зеркала, откуда пришёл файл; несовпадение — это отказ от кандидата и переход к следующему зеркалу, а отсутствие манифеста (старые релизы) — предупреждение, после которого установка идёт дальше.
4. **Качает со всех зеркал одновременно**: побеждает то, что загрузилось первым, остальные отменяются — вместе с их загрузками; скачанный файл всё равно проверяется запуском (не стартовал — зеркала перебираются по одному). Гонка включается только тогда, когда в целевом каталоге хватает места под копию от каждого зеркала — `число зеркал × 5 МБ`, но не меньше 30 МБ; иначе зеркала идут по очереди, и установка просто медленнее на медленном зеркале. Прерванный по `Ctrl-C` запуск не оставляет за собой ни временного файла в целевом каталоге, ни каталога гонки.
5. Поддерживает принудительный выбор версии: `DPI_UPX=1` (компактная) или `DPI_UPX=0` (стандартная).
6. Поддерживает запуск напрямую через SSH:
@ -84,6 +84,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
# Повторный запуск после установки
/opt/bin/dpi-detector
```
7. **Не скачивает то, что уже стоит**: если в целевом каталоге лежит запускаемый `dpi-detector` той же версии, что несёт сам установщик, скрипт пишет об этом и сразу запускает его с переданными аргументами — повторный `curl … | sh -s -- -t 2 -d rutracker.org` не тратит трафик и стартует тестер сразу. Файл, который не запускается (чужая архитектура, обрезанный) или не имеет бита выполнения, версией не считается, а версия, отличная от версии установщика, — обычная установка с явной строкой об этом. С `DPI_VERSION=latest` сравнение невозможно (тег движется), поэтому установка идёт как обычно.
---
@ -120,6 +121,8 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
Справка и все сообщения локализованы: `dpi-detector --lang en --help`, `--lang zh --help`, `--lang fa --help`. Язык интерфейса задаётся `--lang`, таблицы, легенда и предупреждения следуют за ним; Farsi выводится латиницей (Finglish). Статусные бейджи и имена протоколов (`OK`, `BLOCKED`, `TLS RST`, `SNI`, `ClientHello`) остаются латиницей в любом языке. Машинный вывод `--json` от языка не зависит.
В тесте 1 таблица и `--json` показывают одно и то же разными способами: строка эндпоинта несёт бейдж (`NO CA BUNDLE`, `TLS DROP`, `SYN DROP`), а `results.dns_availability.failures` — те же отказы как данные: `provider`, `protocol` (`udp`/`doh_wire`/`dot`), `endpoint`, счётчики `ok`/`total` и причина первого отказа (`status` — токен `DpiStatus` вроде `no_ca_bundle`, `detail` — код `Detail` вроде `no_root_certificates`; у отказа без классификации там исходное сообщение). Чистые эндпоинты в список не попадают, поэтому `[]` означает «ответили все». Рядом лежит `fakeip_sub` — доля подменённых UDP-ответов, пришедшая из диапазона fake-ip (198.18.0.0/15), то есть от прокси или VPN, а не от резолвера: в таблице это отдельная строка «FakeIP responses», и она же входит в `subst_sub`.
Проверки можно увести в выбранный интерфейс: строка **Интерфейс** в меню (по умолчанию «по умолчанию» — таблица маршрутов) и флаг `-i/--iface <имя|адрес>` для тихого запуска. Перед соединением сокет привязывается к адресу этого интерфейса — источник пакета и есть то, по чему ОС выбирает маршрут (таблица маршрутов на обычной машине, policy-правила на роутере, который делит трафик по источнику), — так прогон идёт через VPN-туннель (`--iface wg0`, `--iface opkgtun10`, `--iface 172.16.0.2`), а не через WAN провайдера; выбирать можно и по имени устройства, и по любому его адресу, TCP и UDP одинаково. Имя, которому ничего не соответствует, — это ошибка запуска, а не тихий прогон по маршруту по умолчанию: иначе «проверка через туннель» на деле проверяла бы провайдера. Без `--iface` и без строки в меню не меняется ничего — сокеты открываются ровно так же, как до появления выбора.
### Номера доступных тестов:
@ -136,6 +139,12 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
Статус `host_unreach` — это errno `EHOSTUNREACH` на connect: так ядро отдаёт **любое** ICMP-сообщение о недостижимости, и по одному errno не видно, был это отказ маршрутизации или фильтр провайдера. На Linux детектор читает из очереди ошибок сокета (`IP_RECVERR`) исходное ICMP-сообщение и, если это type 3 code 13 («administratively prohibited», именно так отвечает фильтр на пути), пишет в детали `icmp_admin_prohibited` — «ICMP: административно запрещено (фильтр провайдера)» вместо общего «нет маршрута до хоста». На Windows такой очереди нет, поэтому там деталь остаётся прежней.
Статус `NO CA BUNDLE` (`no_ca_bundle`, деталь `no_root_certificates`) означает ровно одно: цепочка сертификатов, которую увидел детектор, не сходится ни с одним корнем из вшитого в бинарь списка Mozilla (`webpki-roots`, только корни с правом серверной аутентификации; хранилище Windows не читается никогда — иначе ответ зависел бы от того, что установлено на конкретной машине). Проверяют сертификат только два места: DoH/DoT в тесте 1 и определение внешнего IP в тесте 0; остальные тесты сертификат не проверяют, потому что он там сам является сигналом DPI, — поэтому `NO CA BUNDLE` не может появиться в тесте 2.
Практически это почти всегда не цензура, а **перехват TLS на самой машине**: антивирусный web-shield или корпоративный прокси подставляет свой сертификат, подписанный его собственным корнем. Windows этот корень знает (браузеры открывают те же адреса), а детектор — нет, и каждая строка DoH/DoT читается как `NO CA BUNDLE`. Второй, более редкий случай — цепочка, ведущая к устаревшему корню, который Mozilla сняла с доверия для TLS (например `DigiCert Global Root CA`, `GlobalSign Root CA`, `AAA Certificate Services`): Windows такой корень всё ещё держит в своём хранилище.
Что с этим делать: посмотреть, какой издатель у листа и какой корень у цепочки. Готовый отчёт для Windows собирает `tools/diag/dns-ca-report.ps1` — он печатает цепочку каждого DoH/DoT хоста, вердикт Windows, корни из «снятого с доверия» набора, корни-перехватчики в хранилище, резолв, `hosts` и NRPT, а также `results.dns_availability.failures` из `--json`. Если в цепочке виден корень антивируса, лечение одно: выключить проверку защищённых соединений (SSL/HTTPS-инспекцию) в его настройках или добавить `dpi-detector` в исключения — до тех пор любые замеры DoH/DoT на этой машине будут показывать чужой сертификат вместо сертификата сервера.
---
### Блок о перехвате: печатается перед тестами
@ -196,7 +205,7 @@ NFQWS_EXTRA_ARGS="$MODE_ALL"
При выборе теста `6` открывается свой экран настроек: число соединений на домен (от `1` до `100`; переключатель заворачивается — `←` на единице даёт сотню, `→` на сотне — единицу), таймаут одного рукопожатия **в секундах**, **версия TLS** (`1.3+1.2` — предложение браузера, ответ должен быть `1.3`; `1.3` — только 1.3; `1.2` — только 1.2), **протокол HTTP в ALPN** (`h2` — как у браузеров, с откатом на `http/1.1`, или `http/1.1` — только он), домен (пустое поле — весь список теста `6`; вставленный URL приводится к имени хоста) и **Fingerprint** (переключатель идёт по всем двадцати формам, а первое его значение — «все», то есть все формы разом; набор из `--burst-profiles`, которому в переключателе нет места, экран заменяет этим первым значением, так что строка всегда называет то, что запустит Enter). Поле домена подсвечивается: серый фон — строка выбрана, синий — в поле идёт ввод. `Q`/`Esc` отменяет тест.
Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же `User-Agent`/заголовками) уходит `GET /` по тому протоколу, который выбрал ALPN. Попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, `HTTP BLOCK` или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: основной домен, его поддомены и два поддомена одного домена — одна и та же площадка, поэтому `holod.media` → `www.holod.media`, `m.holod.media` → `holod.media` и `m.youtube.com` → `www.youtube.com` читаются как `OK`, а на чужой домен (`www.instagram.com` → `www.facebook.com`) — как `REDIR` с именем хоста в детали.
Попытка не заканчивается рукопожатием: сразу после него тем же профилем (и тем же `User-Agent`/заголовками) уходит `GET /` по тому протоколу, который выбрал ALPN. Попытка считается удачной, только если вернулся ответ — обрыв на запросе, редирект на чужой хост, `BLOCKED` или таймаут чтения видны как своя строка, а не как «ответили». Редирект внутри своего сайта вердикта не меняет: основной домен, его поддомены и два поддомена одного домена — одна и та же площадка, поэтому `holod.media` → `www.holod.media`, `m.holod.media` → `holod.media` и `m.youtube.com` → `www.youtube.com` читаются как `OK`, а на чужой домен (`www.instagram.com` → `www.facebook.com`) — как `REDIR` с именем хоста в детали.
В таблице строки — отпечатки, столбцы — сайты: так на экран помещаются все двадцать форм сразу. В ячейке `M/N` по каждому отпечатку на каждом сайте: зелёный — все попытки прошли целиком, жёлтый — часть, красный — никто. В колонке «Детали» — **все** вердикты этой формы по всем сайтам, сгруппированные по статусу, самые частые первыми (`TLS ALERT ×8 · TLS RST ×4`), так что двенадцать потерянных рукопожатий не схлопываются в одну «победившую» ошибку; сайт, который не разрешился, помечен прямо в заголовке своего столбца как `DNS FAIL`, иначе столбец был бы просто прочерками. Номер попытки в трассе — это номер **старта**, а не завершения: попытки идут внахлёст, поэтому в отчёте видно, на какой именно из них включился блок (`2/5` — первые два соединения ответили, дальше перестало).

View file

@ -149,8 +149,18 @@ TMP_FILE="${OUT_DIR}/.dpi-detector.tmp.$$"
# before the manifest existed simply has none, and then the check is skipped with
# one warning instead of failing an install that would otherwise work.
MANIFEST_FILE="${OUT_DIR}/.dpi-detector.sums.$$"
# Seconds for one attempt at the manifest. It is a couple of hundred bytes, so the
# budget only ever decides how long a source that does not answer is waited for:
# the binary's 120 s would let one stall hold the install for minutes, and the
# manifest is fetched after the binary is already on disk, so that wait is dead
# time in front of the user.
MANIFEST_MAX_TIME=5
# "" until the manifest has been looked for, then "ok" or "missing".
MANIFEST_STATE=""
# The URL that produced the file being checked — the race's winner, or the mirror
# the sequential loop is on. The manifest is looked for beside it as well as at
# the release URL itself.
FETCHED_URL=""
# Set by the checksum check for the summary line: "verified" or "not checked".
CHECKSUM_RESULT="not checked"
@ -288,30 +298,63 @@ stop_downloader() {
return 0
}
# One attempt with the status kept, because "the server answered with an error"
# and "the server could not be reached" are different answers and the manifest
# needs the difference: `curl -f` reports the first as 22 and wget as 8, and a
# release that publishes no manifest says exactly that. `DOWNLOAD_STATUS` is 0
# when the attempt worked, 2 for an answer, 1 for anything else.
DOWNLOAD_STATUS=0
try_download() {
DOWNLOAD_STATUS=0
run_downloader "$@" || DOWNLOAD_STATUS=$?
if [ "$DOWNLOAD_STATUS" -eq 0 ]; then
return 0
fi
if [ "$DOWNLOAD_STATUS" -eq 22 ] || [ "$DOWNLOAD_STATUS" -eq 8 ]; then
DOWNLOAD_STATUS=2
else
DOWNLOAD_STATUS=1
fi
return 1
}
# Every downloader is tried by running it, never by asking `command -v`: BusyBox
# hush — the shell Padavan and several other stock firmwares give root — has no
# `command` builtin, so a PATH test reports a working wget as absent and the
# install stops before it tries a single mirror. Both are attempted because
# neither is guaranteed to work: a stock firmware may have only a wget, a box
# with Entware may have only a curl, and either may be built without TLS.
#
# 0 — the file is there, 2 — the source answered with an HTTP error, 1 — it did
# not answer. The binary's callers only ask whether it worked; the manifest's
# reads the difference (`fetch_manifest`).
download_file() {
_url="$1"
_dest="$2"
# Seconds for one attempt, the binary's unless the caller says otherwise.
_max="${3:-120}"
rm -f "$_dest" 2>/dev/null || true
# curl: --connect-timeout/--max-time are understood by every curl; the second
# attempt drops the certificate check for boxes with no CA bundle.
if run_downloader curl -fsSL --connect-timeout 4 --max-time 120 "$_url" -o "$_dest" 2>/dev/null ||
run_downloader curl -kfsSL --connect-timeout 4 --max-time 120 "$_url" -o "$_dest" 2>/dev/null; then
[ -s "$_dest" ] && return 0
fi
# attempt drops the certificate check for boxes with no CA bundle, and is
# skipped when the first one was answered — a server that answered does not
# need to be asked again without the certificate check.
try_download curl -fsSL --connect-timeout 4 --max-time "$_max" "$_url" -o "$_dest" 2>/dev/null && [ -s "$_dest" ] && return 0
[ "$DOWNLOAD_STATUS" -eq 2 ] && return 2
try_download curl -kfsSL --connect-timeout 4 --max-time "$_max" "$_url" -o "$_dest" 2>/dev/null && [ -s "$_dest" ] && return 0
[ "$DOWNLOAD_STATUS" -eq 2 ] && return 2
# wget: BusyBox knows neither --timeout nor --no-check-certificate, so the
# first form is for GNU wget and the second is what every wget accepts.
if run_downloader wget -q --timeout=4 -O "$_dest" "$_url" 2>/dev/null ||
run_downloader wget -q -O "$_dest" "$_url" 2>/dev/null; then
[ -s "$_dest" ] && return 0
fi
# wget: BusyBox knows neither `--timeout` nor `--no-check-certificate`, so the
# first form is for GNU wget and `-T`, which both understand, is the second.
# There is no third, optionless form on purpose: it would be the one attempt
# with no timeout at all — BusyBox has none and GNU wget waits 900 s for a
# first byte — so a source that accepts the connection and then trickles would
# hold the install past the budget every other attempt respects, and it would
# be reached exactly when the bounded attempts timed out, i.e. on that source.
try_download wget -q --timeout="$_max" -O "$_dest" "$_url" 2>/dev/null && [ -s "$_dest" ] && return 0
[ "$DOWNLOAD_STATUS" -eq 2 ] && return 2
try_download wget -q -T "$_max" -O "$_dest" "$_url" 2>/dev/null && [ -s "$_dest" ] && return 0
[ "$DOWNLOAD_STATUS" -eq 2 ] && return 2
# Say why when the reason is that neither tool is installed at all.
if ! curl --version >/dev/null 2>&1 && ! wget --help >/dev/null 2>&1; then
@ -320,6 +363,23 @@ download_file() {
return 1
}
# The asset's URL on GitHub itself: the release the manifest is checked against,
# and the head of the list below.
release_url() {
if [ "$VERSION" = "latest" ]; then
echo "https://github.com/${REPO}/releases/latest/download/$1"
else
echo "https://github.com/${REPO}/releases/download/${VERSION}/$1"
fi
}
# Where the manifest of a release sits, given the URL of one of its assets: the
# asset name is the last path segment in every source's URL — the release URL and
# the proxies that wrap it alike — so the directory is what stays.
manifest_url() {
echo "${1%/*}/SHA256SUMS.txt"
}
# Every release asset this script fetches — the binary and `SHA256SUMS.txt` —
# is looked for at the same set of sources, so the list is built from the asset
# name rather than written twice.
@ -332,11 +392,7 @@ build_url_list() {
done
fi
if [ "$VERSION" = "latest" ]; then
_gh="https://github.com/${REPO}/releases/latest/download/${_file}"
else
_gh="https://github.com/${REPO}/releases/download/${VERSION}/${_file}"
fi
_gh=$(release_url "$_file")
_list="${_list} ${_gh}"
_list="${_list} https://ghfast.top/${_gh}"
@ -348,18 +404,44 @@ build_url_list() {
echo "$_list"
}
# The manifest is fetched once, from the first source that has it, and every
# source is tried because a release from before the manifest existed answers 404
# on all of them and the install has to go on without it. `curl -f`/wget fail on
# a 404 immediately, so the sweep costs nothing on such a release.
# The manifest is fetched once: from the release itself, and then from the source
# the binary came from when the release cannot be reached.
#
# The release URL is what the check is against — it is the published build — and
# its answer settles whether a manifest exists at all: a mirror only has what the
# release has, so an HTTP error there ends the search instead of sending the
# install through the other sources. That is what the sweep of every source this
# used to do got wrong: a release from before the manifest existed answers 404 on
# all of them, and through the proxies that is seconds per source rather than
# nothing — measured 31 s of silence *after* the binary was already on disk — and
# a source that stalls instead of answering costs its whole budget, one at a time.
# The source the binary came from is asked only when GitHub itself did not answer,
# which is the case this script exists for on a filtered network.
fetch_manifest() {
[ -z "$MANIFEST_STATE" ] || return 0
_urls=$(build_url_list "SHA256SUMS.txt")
for _url in $_urls; do
if download_file "$_url" "$MANIFEST_FILE" && [ -s "$MANIFEST_FILE" ]; then
_canonical=$(release_url "SHA256SUMS.txt")
_near=""
if [ -n "$FETCHED_URL" ]; then
_near=$(manifest_url "$FETCHED_URL")
fi
_seen=""
for _url in "$_canonical" "$_near"; do
if [ -z "$_url" ]; then
continue
fi
if [ "$_url" = "$_seen" ]; then
continue
fi
_seen="$_url"
_rc=0
download_file "$_url" "$MANIFEST_FILE" "$MANIFEST_MAX_TIME" || _rc=$?
if [ "$_rc" -eq 0 ] && [ -s "$MANIFEST_FILE" ]; then
MANIFEST_STATE="ok"
return 0
fi
if [ "$_rc" -eq 2 ]; then
break
fi
done
rm -f "$MANIFEST_FILE" 2>/dev/null || true
MANIFEST_STATE="missing"
@ -518,7 +600,8 @@ race_download() {
return 1
fi
echo "Fetched from: $(cat "${RACE_DIR}/flag.${_winner}")"
FETCHED_URL=$(cat "${RACE_DIR}/flag.${_winner}")
echo "Fetched from: ${FETCHED_URL}"
if mv -f "${RACE_DIR}/part.${_winner}" "$TMP_FILE" 2>/dev/null; then
rm -rf "$RACE_DIR" 2>/dev/null
RACE_DIR=""
@ -556,6 +639,9 @@ try_download_and_verify() {
_urls=$(build_url_list "$_tgt")
for _url in $_urls; do
echo "Fetching from: ${_url} ..."
# What `fetch_manifest` looks beside when this candidate is checked: the
# manifest is looked for next to the file it describes.
FETCHED_URL="$_url"
rm -f "$TMP_FILE" 2>/dev/null || true
if download_file "$_url" "$TMP_FILE" && [ -s "$TMP_FILE" ]; then
if ! verify_checksum "$_tgt"; then
@ -577,35 +663,71 @@ try_download_and_verify() {
return 1
}
DOWNLOADED=0
CHOSEN_TARGET="$PRIMARY_TARGET"
# The version an installed binary reports, "" when it cannot be run or says
# nothing. `VERSION` is written with its `v` (`v5.0.0-alpha.19`) and the binary
# prints without it (`dpi-detector 5.0.0-alpha.19`), so the `v` is dropped on the
# way out and what is compared is plain text. The version flag is `--version`
# (`-V`); `-v` is the detector's `--verbose`.
binary_version() {
_out=$("$1" --version 2>/dev/null) || return 0
_last=""
for _word in $_out; do
_last="$_word"
done
printf '%s\n' "${_last#v}"
}
if try_download_and_verify "$PRIMARY_TARGET"; then
DOWNLOADED=1
CHOSEN_TARGET="$PRIMARY_TARGET"
elif [ -n "$FALLBACK_TARGET" ]; then
echo "Notice: could not download or verify ${PRIMARY_TARGET}."
echo " Attempting fallback to ${FALLBACK_TARGET}..."
if try_download_and_verify "$FALLBACK_TARGET"; then
DOWNLOADED=1
CHOSEN_TARGET="$FALLBACK_TARGET"
# A destination that already holds this release is not downloaded again: the
# reason to run the installer a second time is to run the tester, and the file
# that is there is the one it would have fetched. `-x` comes first — a file that
# cannot be run is not a version, which is also how a binary built for another
# architecture is passed over — and a moving `latest` tag is left out, because
# there is no fixed string to compare the file's own report against.
LOCAL_VERSION=""
LOCAL_CURRENT=0
if [ -x "$OUT_FILE" ]; then
LOCAL_VERSION=$(binary_version "$OUT_FILE")
fi
if [ -n "$LOCAL_VERSION" ]; then
if [ "$VERSION" != "latest" ] && [ "$LOCAL_VERSION" = "${VERSION#v}" ]; then
LOCAL_CURRENT=1
echo "Already installed: dpi-detector ${LOCAL_VERSION} at ${OUT_FILE} — nothing to download."
elif [ "$VERSION" != "latest" ]; then
echo "Installed: dpi-detector ${LOCAL_VERSION}, this installer carries ${VERSION#v} — installing that release."
fi
fi
if [ "$DOWNLOADED" -ne 1 ]; then
echo "Error: failed to download working binary (${PRIMARY_TARGET}${FALLBACK_TARGET:+ / $FALLBACK_TARGET}) from all mirrors." >&2
exit 1
if [ "$LOCAL_CURRENT" -eq 0 ]; then
DOWNLOADED=0
CHOSEN_TARGET="$PRIMARY_TARGET"
if try_download_and_verify "$PRIMARY_TARGET"; then
DOWNLOADED=1
CHOSEN_TARGET="$PRIMARY_TARGET"
elif [ -n "$FALLBACK_TARGET" ]; then
echo "Notice: could not download or verify ${PRIMARY_TARGET}."
echo " Attempting fallback to ${FALLBACK_TARGET}..."
if try_download_and_verify "$FALLBACK_TARGET"; then
DOWNLOADED=1
CHOSEN_TARGET="$FALLBACK_TARGET"
fi
fi
if [ "$DOWNLOADED" -ne 1 ]; then
echo "Error: failed to download working binary (${PRIMARY_TARGET}${FALLBACK_TARGET:+ / $FALLBACK_TARGET}) from all mirrors." >&2
exit 1
fi
# Atomic install: replace destination file with verified temp binary
mv -f "$TMP_FILE" "$OUT_FILE"
chmod +x "$OUT_FILE"
# What the file really occupies, measured after it is in place — not the size the
# release notes promise. `du` counts allocated blocks (what `df` will report as
# used); when it is unavailable, the apparent size rounded up to a block does.
SIZE_KB=$(du -k "$OUT_FILE" 2>/dev/null | awk 'NR == 1 { print $1 + 0 }')
[ -n "$SIZE_KB" ] && [ "$SIZE_KB" -gt 0 ] || SIZE_KB=$(( ($(wc -c < "$OUT_FILE") + 1023) / 1024 ))
SIZE_MB=$(awk -v kb="$SIZE_KB" 'BEGIN { printf "%.1f", kb / 1024 }' 2>/dev/null)
[ -n "$SIZE_MB" ] || SIZE_MB=$((SIZE_KB / 1024))
fi
# Atomic install: replace destination file with verified temp binary
mv -f "$TMP_FILE" "$OUT_FILE"
chmod +x "$OUT_FILE"
# What the file really occupies, measured after it is in place — not the size the
# release notes promise. `du` counts allocated blocks (what `df` will report as
# used); when it is unavailable, the apparent size rounded up to a block does.
SIZE_KB=$(du -k "$OUT_FILE" 2>/dev/null | awk 'NR == 1 { print $1 + 0 }')
[ -n "$SIZE_KB" ] && [ "$SIZE_KB" -gt 0 ] || SIZE_KB=$(( ($(wc -c < "$OUT_FILE") + 1023) / 1024 ))
SIZE_MB=$(awk -v kb="$SIZE_KB" 'BEGIN { printf "%.1f", kb / 1024 }' 2>/dev/null)
[ -n "$SIZE_MB" ] || SIZE_MB=$((SIZE_KB / 1024))
RUN_FILE="$OUT_FILE"
# The command to suggest: the bare name when the shell would reach this very file
@ -633,17 +755,23 @@ fi
echo ""
echo "=============================================="
echo " DPI Detector successfully installed!"
echo " Location: ${RUN_FILE}"
case "$CHOSEN_TARGET" in
*-upx)
echo " Variant: Compact UPX (${SIZE_MB} MB)"
;;
*)
echo " Variant: Standard (${SIZE_MB} MB)"
;;
esac
echo " Checksum: ${CHECKSUM_RESULT}"
if [ "$LOCAL_CURRENT" -eq 1 ]; then
echo " DPI Detector is already installed!"
echo " Location: ${RUN_FILE}"
echo " Version: ${LOCAL_VERSION} — the release this installer carries"
else
echo " DPI Detector successfully installed!"
echo " Location: ${RUN_FILE}"
case "$CHOSEN_TARGET" in
*-upx)
echo " Variant: Compact UPX (${SIZE_MB} MB)"
;;
*)
echo " Variant: Standard (${SIZE_MB} MB)"
;;
esac
echo " Checksum: ${CHECKSUM_RESULT}"
fi
echo "=============================================="
echo ""
echo "To start the interactive menu:"