Updater.Contract

Abstractions for Updater


Keywords
License
MIT
Install
Install-Package Updater.Contract -Version 1.0.0

Documentation

Сервис обновлений десктопных приложений

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.

1. Ключ подписи релизов

На машине сборки (не на сервере!) сгенерировать пару ключей Ed25519:

dotnet run --project Updater.SigningTool -- keygen
# private_key=<base64>
# public_key=<base64>
  • Приватный ключ сохранить в файл (например, signing.key) и хранить только на машине сборки. На сервер обновлений он не попадает никогда — в этом вся защита: даже полный взлом сервера не позволит подписать вредоносный .exe.
  • Публичный ключ пойдёт в два места: в .env сервера (SIGNING_PUBLIC_KEY) и в код приложения (TrustedPublicKeys, см. раздел про Updater.dll). Публичный ключ — не секрет.

2. Конфигурация .env

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).

3. Запуск

docker compose up -d --build
curl -fsS http://127.0.0.1:8080/health   # → 200, сервис жив

Схема БД создаётся автоматически (EF-миграции применяются на старте).

4. Доступ в админ-зону

Подключить 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.dll в приложение

Приложение ссылается на проекты 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 вторым аргументом — отказы проверок будут в логе приложения.

Ротация секретов

Утёкший токен нельзя отзывать мгновенно. Токен зашит в установленные у пользователей копии программы; отозвать его сразу — значит отрезать всех легитимных клиентов от обновлений (включая обновление, которое принесло бы им новый токен). Порядок такой:

  1. выпустить новый токен (issue-token);
  2. собрать и опубликовать версию приложения с новым токеном в конфиге;
  3. подождать, пока пользователи обновятся (grace-период — дни или недели, по вашей телеметрии), тем, кто не обновился, раздать сборку тем же каналом, что и первую установку;
  4. только затем отозвать старый токен (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).

Справочник API

Клиентская зона (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 (превышен лимит).