HTTPS-сервис, через который десктопные программы проверяют наличие новых версий и скачивают
установщики (.exe), плюс клиентская библиотека Updater.dll, которая делает это внутри
приложения. Подлинность каждого релиза защищена цифровой подписью Ed25519: подписывает машина
сборки, проверяет клиент, и подменить установщик не может даже взломанный сервер обновлений.
| Проект | Что это |
|---|---|
Updater.Server |
Бэкенд (ASP.NET Core, Minimal APIs): клиентское API обновлений и админское API |
Updater |
Библиотека Updater.dll — встраивается в десктопное приложение: проверка версии, скачивание, проверка подписи |
Updater.Contract |
Общие типы сервера и клиента: DTO ответов API, SemVersion, вычисление и проверка подписи (ReleaseSigning) |
Updater.SigningTool |
Консольная утилита для машины сборки: генерация ключей Ed25519 и подпись релизов |
Инфраструктура (docker-compose.yml): MySQL (хранит метаданные, токены, аудит), Traefik
(HTTPS с автоматическими сертификатами Let's Encrypt), WireGuard (VPN-доступ к админ-зоне).
Файлы установщиков лежат в Docker volume installer-files, в БД — только метаданные.
Две зоны API:
-
Клиентская —
https://<домен>/api/updates/..., доступна из интернета, требует токен приложения в заголовкеAuthorization: Bearer <токен>. -
Админская —
/api/admin/...на внутреннем порту 8081, снаружи недоступна в принципе: только через WireGuard-туннель (http://172.30.0.3:8081) и с HTTP Basic Auth (логин/пароль администратора). Веб-панели нет — всё делается curl'ом или скриптами.
Понадобятся: сервер с Docker и docker compose, домен, указывающий на сервер (для Let's
Encrypt), открытые порты 80, 443 и 51820/udp, а на машине сборки — .NET 10 SDK.
На машине сборки (не на сервере!) сгенерировать пару ключей Ed25519:
dotnet run --project Updater.SigningTool -- keygen
# private_key=<base64>
# public_key=<base64>-
Приватный ключ сохранить в файл (например,
signing.key) и хранить только на машине сборки. На сервер обновлений он не попадает никогда — в этом вся защита: даже полный взлом сервера не позволит подписать вредоносный.exe. -
Публичный ключ пойдёт в два места: в
.envсервера (SIGNING_PUBLIC_KEY) и в код приложения (TrustedPublicKeys, см. раздел проUpdater.dll). Публичный ключ — не секрет.
cp .env.example .envЗаполнить (все переменные описаны в комментариях .env.example):
- пароли MySQL (
MYSQL_ROOT_PASSWORD,MYSQL_PASSWORD); -
ADMIN_LOGIN/ADMIN_PASSWORD— первый администратор (пароль минимум 12 символов). Создаётся автоматически при первом старте, пока таблица администраторов пуста; при последующих стартах эти переменные игнорируются; -
SIGNING_PUBLIC_KEY— публичный ключ из шага 1; -
DOMAINиACME_EMAIL— домен сервиса и почта для Let's Encrypt; -
WG_SERVER_URL(обычно тот же домен) иWG_PEERS— число VPN-клиентов для админов.
Файл .env в git не попадает — храните его копию вместе с бэкапами (docs/backup.md).
docker compose up -d --build
curl -fsS http://127.0.0.1:8080/health # → 200, сервис живСхема БД создаётся автоматически (EF-миграции применяются на старте).
Подключить WireGuard-туннель — пошагово в docs/wireguard.md. После подключения
админ-API доступен как http://172.30.0.3:8081/api/admin/... с Basic Auth. Проверка:
curl -u admin:пароль http://172.30.0.3:8081/api/admin/apps
# → []Дальше все админские команды в примерах используют -u admin:пароль и этот адрес.
Каждая обновляемая программа регистрируется один раз и получает app_code — короткий
идентификатор (строчные латинские буквы, цифры, дефисы):
curl -u admin:пароль -H "Content-Type: application/json" \
-d '{"app_code": "my-app", "name": "My Application"}' \
http://172.30.0.3:8081/api/admin/apps
# → {"id": 1, "app_code": "my-app", "name": "My Application"}Затем выпустить токен доступа — случайную строку, которую программа будет предъявлять клиентскому API. Токен один на приложение (не на пользователя) и зашивается в его конфиг:
curl -u admin:пароль -H "Content-Type: application/json" \
-d '{"app_code": "my-app", "expires_in_days": 730, "note": "первичный выпуск"}' \
http://172.30.0.3:8081/api/admin/issue-token
# → {"token_id": 1, "token": "Q4x...43-символьная-строка", "expires_at": "2028-07-06T12:00:00Z"}Строка token показывается только один раз — сервер хранит лишь её хэш и восстановить
токен не сможет. Сохраните её сразу: она пойдёт в конфиг дистрибутива приложения.
Посмотреть выпущенные токены (без самих строк): GET /api/admin/tokens?app_code=my-app;
отозвать: POST /api/admin/revoke-token с телом {"token_id": 1}.
Версии — строго SemVer 2.0: 1.0.0, 1.2.3, 2.0.0-rc1.
«Последней» считается максимальная по правилам SemVer активная версия (не последняя по дате),
pre-release-версии (-rc1 и т. п.) в выдачу по умолчанию не попадают.
На машине сборки: собрать .exe и подписать его приватным ключом:
dotnet run --project Updater.SigningTool -- \
sign --key-file signing.key --file MyApp-1.2.0.exe > MyApp-1.2.0.exe.sig(--key-file предпочтительнее --key <base64>: ключ не светится в истории шелла и в ps.
Проверить подпись локально можно командой verify --pub <публичный ключ> --file <exe> --signature <base64>.)
Затем один запрос — файл, подпись и метаданные вместе:
curl -u admin:пароль \
-F "app_code=my-app" \
-F "version=1.2.0" \
-F "changelog=Исправлен экспорт, ускорен запуск" \
-F "signature=<MyApp-1.2.0.exe.sig" \
-F "installer=@MyApp-1.2.0.exe" \
http://172.30.0.3:8081/api/admin/add-updateСервер сам считает SHA-256 файла, проверяет подпись публичным ключом из конфига, валидирует
версию и кладёт файл в хранилище. Типичные отказы: 422 — битая подпись или невалидная
версия; 409 — такая версия уже опубликована (перезаписать нельзя — выпустите следующую).
Если в свежем релизе нашёлся критичный баг — снимите его с раздачи (yank):
curl -u admin:пароль -H "Content-Type: application/json" \
-d '{"app_code": "my-app", "version": "1.2.0"}' \
http://172.30.0.3:8081/api/admin/yank-updateВерсия перестаёт выдаваться как последняя, но остаётся доступной для скачивания по прямому
запросу (для отката и диагностики). Обратно — POST /api/admin/unyank-update с тем же телом.
Приложение ссылается на проекты Updater и Updater.Contract. Минимальный сценарий
«проверить и обновиться»:
using System.Diagnostics;
using Updater;
var options = new UpdaterOptions
{
BaseUrl = "https://updates.example.com",
Token = "<токен из issue-token>",
AppCode = "my-app",
TrustedPublicKeys = ["<публичный ключ base64>"],
DownloadDirectory = Path.Combine(Path.GetTempPath(), "my-app-updates")
};
using var client = new UpdateClient(options);
var check = await client.CheckForUpdateAsync(currentVersion: "1.1.0");
if (check.IsUpdateAvailable)
{
var download = await client.DownloadAndVerifyAsync(check.Latest!.Version);
if (download.Success)
Process.Start(download.FilePath!); // запуск инсталлятора — на стороне приложения
else
Console.Error.WriteLine($"Обновление отклонено: {download.Failure} — {download.Message}");
}Что берёт на себя библиотека:
-
CheckForUpdateAsyncсравнивает версии по правилам SemVer (не строками:1.10.0больше1.9.0); канал предрелизов задаётся параметромchannel— идентификатор в нижнем регистре (beta,rc), совпадающий с первым pre-release-идентификатором версии (channel: "beta"— беты этой линии плюс стабильные, стабильная приоритетнее; без канала — только стабильные); -
DownloadAndVerifyAsyncскачивает во временный файл с докачкой после обрывов, сверяет SHA-256 с метаданными и проверяет Ed25519-подпись ключами изTrustedPublicKeys. Не сошлось — файл удаляется, возвращается причина (HashMismatch,SignatureInvalid, …). Путь к файлу вернётся только для проверенного установщика; - TLS строгий: отключить проверку сертификата библиотека не позволяет намеренно.
TrustedPublicKeys — список, чтобы при ротации ключа подписи выпустить сборку, доверяющую
и старому, и новому ключу (см. ниже). В UpdateClient можно передать ILogger вторым
аргументом — отказы проверок будут в логе приложения.
Утёкший токен нельзя отзывать мгновенно. Токен зашит в установленные у пользователей копии программы; отозвать его сразу — значит отрезать всех легитимных клиентов от обновлений (включая обновление, которое принесло бы им новый токен). Порядок такой:
- выпустить новый токен (
issue-token); - собрать и опубликовать версию приложения с новым токеном в конфиге;
- подождать, пока пользователи обновятся (grace-период — дни или недели, по вашей телеметрии), тем, кто не обновился, раздать сборку тем же каналом, что и первую установку;
- только затем отозвать старый токен (
revoke-token).
Утечка токена — неприятность, а не катастрофа: он даёт лишь скачивать релизы, а подсунуть клиентам вредоносный файл без приватного ключа подписи невозможно.
Ротация ключа подписи — та же идея в две фазы: выпустить сборку, где в
TrustedPublicKeys оба ключа (старый и новый), и добавить новый ключ в Signing:PublicKeys
на сервере (.env поддерживает список: Signing__PublicKeys__0, Signing__PublicKeys__1);
после того как парк обновился — подписывать релизы новым ключом и убрать старый из обеих точек.
-
Бэкапы — обязательны, скрипты и инструкция восстановления:
docs/backup.md. -
VPN для админов —
docs/wireguard.md. -
Логи — структурированный JSON в stdout:
docker compose logs -f api. Журнал действий (кто что опубликовал, кто скачал, попытки входа) — в таблице аудита, читается черезGET /api/admin/auditс фильтрами и пагинацией. -
Лимиты запросов — на токен: 60/мин для метаданных (
/latest,/changelog,/version), 10/мин для скачиваний; 5/мин на IP для админ-API. Превышение →429. Значения меняются в.envбез пересборки. -
Один экземпляр. Сервис рассчитан ровно на один запущенный контейнер
api: счётчики лимитов живут в памяти процесса. Не запускайте несколько реплик за балансировщиком — для этого потребовалось бы внешнее хранилище состояния (например, Redis).
Клиентская зона (Authorization: Bearer <токен>; токен должен принадлежать тому же
app_code, что и в URL):
| Метод и путь | Что делает |
|---|---|
GET /api/updates/{app_code}/latest[?channel=beta] |
Последняя активная версия канала: {version, file_hash, signature}
|
GET /api/updates/{app_code}/version/{version} |
Метаданные конкретной версии (включая снятые с раздачи) |
GET /api/updates/{app_code}/changelog[?from=1.5.0][&channel=beta] |
Список изменений (SemVer по убыванию); from — только версии новее, channel — канал |
GET /api/updates/{app_code}/download?version=1.5.1 |
Скачивание установщика (поддерживает докачку через Range) |
Админская зона (WireGuard + Basic Auth, JSON в snake_case):
| Метод и путь | Что делает |
|---|---|
POST /api/admin/apps, GET /api/admin/apps
|
Регистрация и список приложений |
POST /api/admin/issue-token |
Выпуск токена (строка токена — один раз в ответе) |
GET /api/admin/tokens?app_code=&include_revoked= |
Список токенов (без самих строк) |
POST /api/admin/revoke-token |
Отзыв токена по token_id
|
POST /api/admin/add-update |
Публикация релиза (multipart: файл + подпись + метаданные) |
POST /api/admin/yank-update, POST /api/admin/unyank-update
|
Снять релиз с раздачи / вернуть |
GET /api/admin/apps/{app_code}/updates |
Все релизы приложения, включая снятые |
POST /api/admin/admins, GET /api/admin/admins
|
Создание и список администраторов |
POST /api/admin/admins/deactivate, POST /api/admin/admins/change-password
|
Деактивация, смена пароля |
GET /api/admin/audit |
Чтение журнала аудита (фильтры + пагинация before_id) |
Ошибки всех эндпоинтов — в формате ProblemDetails с честными кодами: 401 (нет/плохой
токен или пароль), 403 (чужой app_code), 404, 409 (дубликат), 422 (невалидные
данные), 429 (превышен лимит).