release: prepare server 0.9.0
This commit is contained in:
@@ -26,6 +26,10 @@ RMM_PUBLIC_URL=https://rmm.example.com
|
||||
RMM_METRIC_RETENTION_DAYS=30
|
||||
RMM_NOTIFICATION_RETENTION_DAYS=90
|
||||
RMM_NOTIFICATION_MAX_ATTEMPTS=5
|
||||
RMM_STABLE_AGENT_VERSION=0.6.8
|
||||
RMM_UPDATE_MANIFEST_URL=https://benya9669.github.io/openwrt-rmm/update-manifest.json
|
||||
RMM_UPDATE_MANIFEST_SIGNATURE_URL=https://benya9669.github.io/openwrt-rmm/update-manifest.sig
|
||||
RMM_UPDATE_MANIFEST_PUBLIC_KEY=/app/keys/rmm-openwrt.pem
|
||||
|
||||
# Optional password recovery and alert notifications. Leave RMM_SMTP_HOST empty to disable e-mail.
|
||||
# Use starttls for port 587 or tls for implicit TLS on port 465.
|
||||
|
||||
@@ -230,6 +230,49 @@ jobs:
|
||||
package-repository \
|
||||
"${AGENT_TAG#agent-v}"
|
||||
|
||||
- name: Sign stable update manifest with the package key
|
||||
env:
|
||||
OPENWRT_APK_SECRET_B64: ${{ secrets.OPENWRT_APK_SECRET_B64 }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$OPENWRT_APK_SECRET_B64"
|
||||
umask 077
|
||||
trap 'rm -f manifest-signing.pem' EXIT
|
||||
printf '%s' "$OPENWRT_APK_SECRET_B64" | base64 --decode > manifest-signing.pem
|
||||
openssl dgst -sha256 \
|
||||
-sign manifest-signing.pem \
|
||||
-out package-repository/update-manifest.sig \
|
||||
package-repository/update-manifest.json
|
||||
openssl dgst -sha256 \
|
||||
-verify keys/openwrt/apk/rmm-openwrt.pem \
|
||||
-signature package-repository/update-manifest.sig \
|
||||
package-repository/update-manifest.json
|
||||
rm -f manifest-signing.pem
|
||||
trap - EXIT
|
||||
|
||||
- uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign and verify stable update manifest
|
||||
env:
|
||||
AGENT_TAG: ${{ inputs.agent_tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign-blob --yes \
|
||||
--bundle package-repository/update-manifest.sigstore.json \
|
||||
package-repository/update-manifest.json
|
||||
cosign verify-blob \
|
||||
--bundle package-repository/update-manifest.sigstore.json \
|
||||
--certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/\\.github/workflows/build-legacy\\.yml@refs/heads/" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
package-repository/update-manifest.json
|
||||
|
||||
- name: Attest stable update manifest
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: package-repository/update-manifest.json
|
||||
|
||||
- name: Upload legacy packages
|
||||
env:
|
||||
AGENT_TAG: ${{ inputs.agent_tag }}
|
||||
|
||||
+71
-11
@@ -20,6 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -27,6 +29,16 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Validate immutable signed agent release
|
||||
if: startsWith(github.ref, 'refs/tags/agent-v')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git cat-file -p "$GITHUB_REF_NAME" | grep -q -- '-----BEGIN PGP SIGNATURE-----'
|
||||
bash scripts/extract-release-notes.sh \
|
||||
"$GITHUB_REF_NAME" \
|
||||
CHANGELOG.md \
|
||||
release-notes.md
|
||||
- run: go test ./...
|
||||
- run: go vet ./...
|
||||
- run: npm run check:web
|
||||
@@ -88,6 +100,18 @@ jobs:
|
||||
test -f "$fixture/output/openwrt-24.10.7-ramips-mt7621/Packages"
|
||||
test -f "$fixture/output/openwrt-21.02.7-x86-64/rmm-agent.ipk"
|
||||
|
||||
mkdir -p "$fixture/repository-input/openwrt-24.10.7-ramips-mt7621"
|
||||
touch "$fixture/repository-input/openwrt-24.10.7-ramips-mt7621/rmm-agent.ipk"
|
||||
bash scripts/prepare-package-repository.sh \
|
||||
"$fixture/repository-input" \
|
||||
"$fixture/repository" \
|
||||
"0.6.8"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
if (manifest.agent.version !== "0.6.8" || manifest.packages.length !== 1) process.exit(1);
|
||||
' "$fixture/repository/update-manifest.json"
|
||||
|
||||
openwrt-packages:
|
||||
name: OpenWrt ${{ matrix.release }} · ${{ matrix.label }}
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/agent-v')
|
||||
@@ -254,6 +278,42 @@ jobs:
|
||||
package-repository \
|
||||
"${GITHUB_REF_NAME#agent-v}"
|
||||
|
||||
- name: Sign stable update manifest with the package key
|
||||
env:
|
||||
OPENWRT_APK_SECRET_B64: ${{ secrets.OPENWRT_APK_SECRET_B64 }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$OPENWRT_APK_SECRET_B64"
|
||||
umask 077
|
||||
trap 'rm -f manifest-signing.pem' EXIT
|
||||
printf '%s' "$OPENWRT_APK_SECRET_B64" | base64 --decode > manifest-signing.pem
|
||||
openssl dgst -sha256 \
|
||||
-sign manifest-signing.pem \
|
||||
-out package-repository/update-manifest.sig \
|
||||
package-repository/update-manifest.json
|
||||
openssl dgst -sha256 \
|
||||
-verify keys/openwrt/apk/rmm-openwrt.pem \
|
||||
-signature package-repository/update-manifest.sig \
|
||||
package-repository/update-manifest.json
|
||||
rm -f manifest-signing.pem
|
||||
trap - EXIT
|
||||
|
||||
- uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign and verify stable update manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign-blob --yes \
|
||||
--bundle package-repository/update-manifest.sigstore.json \
|
||||
package-repository/update-manifest.json
|
||||
cosign verify-blob \
|
||||
--bundle package-repository/update-manifest.sigstore.json \
|
||||
--certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/\\.github/workflows/build\\.yml@refs/tags/agent-v" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
package-repository/update-manifest.json
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: package-repository-site
|
||||
@@ -266,6 +326,11 @@ jobs:
|
||||
with:
|
||||
subject-path: publish/*
|
||||
|
||||
- name: Attest stable update manifest
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: package-repository/update-manifest.json
|
||||
|
||||
- name: Create release and upload packages
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -274,18 +339,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
mapfile -t files < <(find publish -type f -print | sort)
|
||||
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${GITHUB_REF_NAME}" "${files[@]}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--clobber
|
||||
gh release edit "${GITHUB_REF_NAME}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--notes-file release-notes.md
|
||||
else
|
||||
gh release create "${GITHUB_REF_NAME}" "${files[@]}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--notes-file release-notes.md \
|
||||
--title "OpenWrt RMM Agent ${GITHUB_REF_NAME#agent-v}"
|
||||
echo "release already exists; published agent tags are immutable" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "${GITHUB_REF_NAME}" "${files[@]}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--notes-file release-notes.md \
|
||||
--title "OpenWrt RMM Agent ${GITHUB_REF_NAME#agent-v}"
|
||||
|
||||
package-repository:
|
||||
name: Publish signed package repository
|
||||
|
||||
@@ -21,16 +21,34 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test server
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Validate immutable signed server release
|
||||
if: startsWith(github.ref, 'refs/tags/server-v')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git cat-file -p "$GITHUB_REF_NAME" | grep -q -- '-----BEGIN PGP SIGNATURE-----'
|
||||
bash scripts/extract-release-notes.sh \
|
||||
"$GITHUB_REF_NAME" \
|
||||
CHANGELOG.md \
|
||||
release-notes.md
|
||||
|
||||
- name: Test server and web application
|
||||
run: |
|
||||
go test ./server/...
|
||||
go vet ./server/...
|
||||
npm run check:web
|
||||
|
||||
- name: Resolve image version
|
||||
id: version
|
||||
@@ -70,6 +88,10 @@ jobs:
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.value }}
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
build-args: |
|
||||
RMM_SERVER_VERSION=${{ steps.version.outputs.value }}
|
||||
RMM_SOURCE_REVISION=${{ github.sha }}
|
||||
RMM_STABLE_AGENT_VERSION=0.6.8
|
||||
cache-from: type=gha,scope=rmm-server
|
||||
cache-to: type=gha,mode=max,scope=rmm-server
|
||||
provenance: mode=max
|
||||
@@ -132,17 +154,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash scripts/extract-release-notes.sh \
|
||||
"$GITHUB_REF_NAME" \
|
||||
CHANGELOG.md \
|
||||
release-notes.md
|
||||
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release edit "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file release-notes.md
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file release-notes.md \
|
||||
--title "OpenWrt RMM Server ${GITHUB_REF_NAME#server-v}"
|
||||
echo "release already exists; published server tags are immutable" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file release-notes.md \
|
||||
--title "OpenWrt RMM Server ${GITHUB_REF_NAME#server-v}"
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
This file contains user-facing release notes. Every section must match its Git tag;
|
||||
the release workflow fails when notes for a new tag have not been prepared.
|
||||
|
||||
## server-v0.9.0
|
||||
|
||||
Feature release for reliable notifications, LAN client presence, and release metadata.
|
||||
|
||||
### Added
|
||||
|
||||
- A built-in notification center with unread state, incident grouping, router links,
|
||||
and live updates through the existing authenticated event stream.
|
||||
- E-mail and Telegram ownership verification before a destination can receive alerts.
|
||||
- Signed webhook delivery, quiet hours with a user timezone, maintenance pauses, and
|
||||
per-router notification overrides.
|
||||
- Persistent LAN client history with Online, Recently online, and Unconfirmed states.
|
||||
- Authenticated `/api/meta` release metadata for the server version, source revision,
|
||||
stable agent version, and signed update-manifest location.
|
||||
- A stable agent update manifest containing package-feed compatibility entries. Agent
|
||||
release workflows sign it with the package repository ECDSA key and Sigstore, then
|
||||
publish it with the package repository.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The dashboard now compares agent versions using Semantic Versioning instead of treating
|
||||
every unequal version as outdated.
|
||||
- Agents newer than the configured stable version are shown as newer, not offered a
|
||||
downgrade.
|
||||
- Missing release metadata no longer produces a false update warning.
|
||||
- The server periodically refreshes the stable manifest and accepts a new version only
|
||||
after verifying its detached signature; network or signature failures retain the last
|
||||
trusted fallback.
|
||||
- HTML is served with `no-store`, scripts and styles are revalidated, and asset revisions
|
||||
were advanced so an old cached dashboard cannot keep reporting obsolete versions.
|
||||
- Static DHCP reservations and stale neighbor entries no longer prove that a LAN client
|
||||
is currently online.
|
||||
|
||||
### Security
|
||||
|
||||
- Webhook destinations require public HTTPS endpoints, reject local/private targets, and
|
||||
receive an HMAC-SHA256 signature over the timestamp and raw body.
|
||||
- Notification destinations stay masked in browser responses.
|
||||
- Contact verification, per-user ownership checks, and per-device notification access
|
||||
remain enforced server-side.
|
||||
|
||||
### Compatibility and upgrade
|
||||
|
||||
- The agent protocol remains `v1`; existing `0.6.x` agents continue to work.
|
||||
- SQLite startup migration adds the notification-center, verification, per-device
|
||||
notification, and LAN-client persistence structures without deleting existing data.
|
||||
- Back up the production SQLite database and test the migration on a copy before
|
||||
deploying this release.
|
||||
- Set `RMM_RELEASE_VERSION=0.9.0`. The default stable agent is `0.6.8` and can be
|
||||
overridden with `RMM_STABLE_AGENT_VERSION`.
|
||||
|
||||
## agent-v0.6.8
|
||||
|
||||
Stable agent release with more reliable LAN client presence detection.
|
||||
|
||||
+73
-67
@@ -1,82 +1,88 @@
|
||||
# Актуальный инженерный checklist
|
||||
# Инженерный и release checklist
|
||||
|
||||
Синхронизировано с кодом 2026-07-30. Источник продуктового порядка — `ROADMAP.md`.
|
||||
Актуализировано: 2026-07-31. Порядок продуктовой разработки задаёт `ROADMAP.md`.
|
||||
Этот файл содержит только критерии готовности к merge/release/deploy.
|
||||
|
||||
## Работает сейчас
|
||||
## Состояние ветки `main`
|
||||
|
||||
- [x] Go server, SQLite/WAL, Docker/Compose и `/healthz`.
|
||||
- [x] Go agent 0.6.7, heartbeat, команды, backoff и OpenWrt init integration.
|
||||
- [x] APK/IPK и LuCI-пакет: OpenWrt 24.10/25.12 в основном релизе,
|
||||
OpenWrt 21.02/22.03/23.05 в ручном legacy workflow.
|
||||
- [x] Multi-user, роли admin/user, владение и передача роутеров.
|
||||
- [x] Профиль, e-mail, смена/сброс пароля и управление сессиями.
|
||||
- [x] Одноразовый secure enrollment; reusable token secrets хранятся как hash.
|
||||
- [x] Alerts: offline, WAN, memory, disk, packet loss, latency, WAN IP, commands.
|
||||
- [x] Acknowledge/resolved lifecycle и audit events.
|
||||
- [x] E-mail/Telegram notifications, пользовательские пороги, repeat и delivery history.
|
||||
- [x] Durable notification queue: leased claims, restart recovery, exponential retry, dead-letter и retention.
|
||||
- [x] Reverse tunnel, cloud LuCI и wildcard router domain.
|
||||
- [x] DirectDNS удалён из продукта; адресация роутеров работает только через cloud/wildcard-домен и исходящий туннель.
|
||||
- [x] UCI preview/apply/commit/revert/backup/restore operations.
|
||||
- [x] Fleet filters, bulk commands, metrics history и SSE auto-refresh.
|
||||
- [x] Адаптивный UI и согласованные error/loading/empty состояния.
|
||||
- [x] Go agent source и production package имеют версию `0.6.8`.
|
||||
- [x] LuCI package `0.2.2` и отдельный `luci-i18n-rmm-agent-ru`.
|
||||
- [x] Notification center, verification, quiet hours, webhook, per-device overrides и incidents.
|
||||
- [x] LAN client persistence с online/recent/unconfirmed.
|
||||
- [x] Current OpenWrt 24.10/25.12 matrix и отдельная manual legacy matrix.
|
||||
- [x] Подписанные IPK/APK repositories, SBOM/provenance и Cosign для server images.
|
||||
- [x] Stable update manifest с package compatibility, ECDSA signature и Sigstore bundle.
|
||||
- [x] DirectDNS удалён; cloud access использует wildcard domain и исходящий tunnel.
|
||||
|
||||
## Обязательное перед следующим production-релизом
|
||||
## Текущее состояние релизов
|
||||
|
||||
- [x] Сгенерировать ключи `usign`/APK и зафиксировать проверяемые публичные ключи.
|
||||
- [ ] Сохранить приватные ключи в зашифрованной офлайн-копии и добавить два base64-значения в GitHub Actions Secrets.
|
||||
- [ ] Включить GitHub Pages из Actions и проверить первый подписанный package feed на реальном роутере.
|
||||
- [ ] Выпустить первый `server-v*`, проверить Cosign/provenance и закрепить `RMM_RELEASE_VERSION` в production `.env`.
|
||||
- [ ] Настроить SMTP и/или `RMM_TELEGRAM_BOT_TOKEN` в production environment.
|
||||
- [ ] Выполнить тестовую отправку из реального пользовательского профиля.
|
||||
- [ ] Проверить active → repeat → resolved на тестовом роутере.
|
||||
- [ ] Проверить миграцию существующей production SQLite базы на копии.
|
||||
- [ ] Сделать backup базы перед обновлением контейнера.
|
||||
- [ ] Проверить `docker compose config --quiet` и healthcheck после deploy.
|
||||
- [x] `server-v0.8.1` опубликован, но не содержит последних notification/client изменений.
|
||||
- [x] `agent-v0.6.8` создан как подписанный tag.
|
||||
- [x] Все jobs `agent-v0.6.8` завершены успешно и package feed опубликован.
|
||||
- [ ] Следующий server tag создан с GPG-подписью и содержит текущий `main`.
|
||||
- [ ] Production закреплён на точной `RMM_RELEASE_VERSION`, а не `latest`.
|
||||
|
||||
## Следующая разработка
|
||||
## Перед `agent-v*`
|
||||
|
||||
- [x] Добавить подтверждение e-mail и безопасную привязку Telegram-чата перед включением канала.
|
||||
- [x] Сделать встроенный центр уведомлений: unread/read, счётчик, переход к роутеру и обновление через SSE.
|
||||
- [x] Настройки по типам событий, maintenance/snooze и временное подавление алертов на период работ.
|
||||
- [ ] Добавить операционные метрики уведомлений: queued/sent/failed, возраст очереди и последняя ошибка канала.
|
||||
- [x] Активная проверка проводных клиентов и `last_seen`, чтобы flow offload не оставлял реально работающий ПК в состоянии `STALE`.
|
||||
- [x] Подписанный webhook channel.
|
||||
- [x] Quiet hours/timezone и per-device notification overrides.
|
||||
- [ ] Конфигурационные backup artifacts и retention.
|
||||
- [x] CI для тестов, Docker, текущей multi-architecture APK/IPK-матрицы и отдельной
|
||||
legacy-сборки без блокировки основного релиза.
|
||||
- [x] Keyless Cosign, provenance/SBOM контейнеров и подписанные checksum релизов.
|
||||
- [x] Нативные подписанные `Packages.sig`/`packages.adb` и публикация package feed.
|
||||
- [ ] Signed update manifest и безопасное обновление агента из кабинета.
|
||||
- [ ] Per-device tunnel credentials.
|
||||
- [ ] Organizations и расширенный RBAC.
|
||||
- [ ] MFA, command signatures и replay protection.
|
||||
- [ ] Graceful shutdown, readiness, server metrics и load tests.
|
||||
- [ ] Версия совпадает в Go source и production package.
|
||||
- [ ] В `CHANGELOG.md` есть английский раздел для точного tag.
|
||||
- [ ] Tag подписан GPG и ранее не публиковался.
|
||||
- [ ] Основная OpenWrt matrix собрана без ошибок.
|
||||
- [ ] Release содержит только устанавливаемые `.ipk`/`.apk`.
|
||||
- [ ] Feed содержит подписанные indexes и публичные ключи.
|
||||
- [ ] Update manifest и Sigstore bundle опубликованы и независимо проверены.
|
||||
- [ ] Установлены agent, LuCI и optional Russian i18n из опубликованного feed.
|
||||
- [ ] Проверены install, upgrade, restart, stop и удаление runtime lock/state.
|
||||
- [ ] При необходимости manual legacy workflow добавляет пакеты без перемещения tag.
|
||||
|
||||
## Постоянная проверка изменений
|
||||
## Перед `server-v*`
|
||||
|
||||
Это повторяемый шаблон для каждого следующего изменения. Результат последнего проверенного коммита фиксируется отдельно ниже.
|
||||
- [ ] В `CHANGELOG.md` есть английский раздел для точного tag.
|
||||
- [ ] Tag подписан GPG и ранее не публиковался.
|
||||
- [ ] `go test ./...`, `go vet ./...`, web checks и Docker build прошли.
|
||||
- [ ] Compose base/release/NPMplus configurations валидны.
|
||||
- [ ] Server и tunnel images имеют один version tag, digest, SBOM, provenance и Cosign signature.
|
||||
- [ ] Миграция проверена на копии актуальной production SQLite.
|
||||
- [ ] Подготовлен rollback на предыдущий image tag.
|
||||
|
||||
## Перед production deploy
|
||||
|
||||
- [ ] Приватные package/tunnel keys имеют зашифрованную офлайн-копию.
|
||||
- [ ] GitHub Actions secrets настроены; секреты отсутствуют в repository и build artifacts.
|
||||
- [ ] Сделан согласованный backup SQLite и проверено его чтение в отдельном окружении.
|
||||
- [ ] `.env` использует точный release, production URL/domain и secure cookie.
|
||||
- [ ] Legacy enrollment/LuCI proxy и insecure dev mode выключены.
|
||||
- [ ] `docker compose config --quiet`, pull/up и service healthcheck прошли.
|
||||
- [ ] Проверены login, profile, password reset, enrollment и user isolation.
|
||||
- [ ] Проверены свежие SSH/LuCI sessions, timeout, close и error states.
|
||||
- [ ] Проверены SMTP/Telegram/webhook test sends без раскрытия секретов.
|
||||
- [ ] Проверены active → repeat → resolved и retry → dead-letter.
|
||||
- [ ] Проверены notification center/SSE и quiet hours/maintenance.
|
||||
- [ ] Проверены LAN client online → recent → unconfirmed.
|
||||
- [ ] Проверены desktop, 4:3, tablet и mobile.
|
||||
|
||||
## Для каждого изменения
|
||||
|
||||
- [ ] Diff ограничен задачей и не содержит generated/production/secret файлов.
|
||||
- [ ] `gofmt` и `go test ./...`.
|
||||
- [ ] `go vet ./...`.
|
||||
- [ ] `node --check web/app.js` и `web/landing-motion.js`.
|
||||
- [ ] `docker compose config --quiet`.
|
||||
- [ ] Docker build.
|
||||
- [ ] Desktop 1366/1920, 4:3 1024, tablet 768 и mobile 390/360.
|
||||
- [ ] Отсутствие секретов и случайных production-файлов в diff.
|
||||
- [ ] `npm run check:web`.
|
||||
- [ ] `docker compose config --quiet`, если затронут deployment.
|
||||
- [ ] Docker build, если затронут server image или runtime.
|
||||
- [ ] OpenWrt package smoke, если затронут agent/LuCI/builder/workflow.
|
||||
- [ ] Добавлены или обновлены тесты для изменённой логики.
|
||||
- [ ] Обновлены API/config/deployment docs, если изменился контракт.
|
||||
- [ ] `ROADMAP.md` меняется только при изменении фактического статуса или порядка работ.
|
||||
|
||||
## Последняя верификация — `6a03061`
|
||||
## Последняя подтверждённая проверка
|
||||
|
||||
- [x] `gofmt`, `go test ./...` и `go vet ./...`.
|
||||
- [x] `node --check web/app.js` и `web/landing-motion.js`.
|
||||
- [x] `docker compose config --quiet` и Docker build.
|
||||
- [x] Notification profile: 768×1024, 390×844 и 360×800 без горизонтального overflow.
|
||||
- [x] Недоступные SMTP/Telegram-каналы блокируются с понятной подсказкой.
|
||||
- [x] Ошибки тестовой отправки локализованы и сохраняют request ID.
|
||||
- [x] Проверены diff и отсутствие реальных секретов; присутствуют только безопасные example-placeholder значения.
|
||||
Commit: `a343963`.
|
||||
|
||||
## Документация при изменении API и конфигурации
|
||||
|
||||
- [ ] Синхронизировать `server/README.md`, `docs/api.md` и `.env.example` с endpoint и переменными окружения.
|
||||
- [ ] Обновить `docs/recent-progress.md`, checklist и roadmap без противоречащих статусов.
|
||||
- [x] `go test ./...`.
|
||||
- [x] `go vet ./...`.
|
||||
- [x] `npm run check:web`.
|
||||
- [x] `docker compose config --quiet`.
|
||||
- [x] Main CI завершён успешно.
|
||||
- [x] Локальная OpenWrt 24.10.7 ramips/mt7621 сборка создала agent, LuCI и Russian i18n.
|
||||
- [x] Полная tagged matrix `agent-v0.6.8` и GitHub Pages deployment завершены.
|
||||
- [ ] Production smoke выполнен на release, содержащем текущий `main`.
|
||||
|
||||
+8
-1
@@ -1,12 +1,18 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
FROM golang:1.26.5-alpine AS build
|
||||
|
||||
ARG RMM_SERVER_VERSION=dev
|
||||
ARG RMM_SOURCE_REVISION=unknown
|
||||
ARG RMM_STABLE_AGENT_VERSION=0.6.8
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY server ./server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/rmm-server ./server/cmd/rmm-server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
|
||||
-ldflags="-s -w -X main.serverVersion=${RMM_SERVER_VERSION} -X main.serverRevision=${RMM_SOURCE_REVISION} -X main.stableAgentVersion=${RMM_STABLE_AGENT_VERSION}" \
|
||||
-o /out/rmm-server ./server/cmd/rmm-server
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -15,6 +21,7 @@ RUN apk add --no-cache ca-certificates su-exec tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/rmm-server /usr/local/bin/rmm-server
|
||||
COPY web ./web
|
||||
COPY keys/openwrt/apk/rmm-openwrt.pem ./keys/rmm-openwrt.pem
|
||||
COPY deploy/server/entrypoint.sh /usr/local/bin/rmm-entrypoint
|
||||
|
||||
RUN addgroup -S rmm && adduser -S -G rmm rmm \
|
||||
|
||||
@@ -86,3 +86,9 @@ Server and tunnel images are published with SBOM/provenance attestations bound t
|
||||
release workflow identity. Agent packages receive GitHub build-provenance attestations.
|
||||
IPK and APK feeds additionally use their native OpenWrt repository signatures so `opkg`
|
||||
and `apk` can enforce trust on the router.
|
||||
|
||||
The package repository also contains `update-manifest.json`, a detached ECDSA signature,
|
||||
and its Sigstore bundle. The server verifies the detached signature before adopting a
|
||||
version from the manifest, then exposes the trusted stable version through authenticated
|
||||
release metadata. The dashboard compares versions with Semantic Versioning and never
|
||||
recommends downgrading an agent newer than the stable channel.
|
||||
|
||||
+118
-88
@@ -1,119 +1,149 @@
|
||||
# OpenWrt RMM — актуальный roadmap
|
||||
# OpenWrt RMM — roadmap
|
||||
|
||||
Актуализировано: 2026-07-30. Текущая стабильная линия агента: `0.6.7`.
|
||||
Актуализировано: 2026-07-31.
|
||||
|
||||
## Цель продукта
|
||||
Этот файл — единственный источник порядка продуктовой разработки. Инженерные и
|
||||
production-проверки находятся в `CHECKLIST.md`, UI-критерии — в `UI_CHECKLIST.md`.
|
||||
|
||||
OpenWrt RMM — self-hosted платформа для мониторинга и безопасного управления роутерами
|
||||
OpenWrt через исходящее соединение агента. Пользователь видит только свои устройства,
|
||||
получает предупреждения и открывает LuCI через облако без публичного порта на роутере.
|
||||
Статусы в этом документе означают:
|
||||
|
||||
## Реализовано
|
||||
- `[x]` — функция реализована в ветке `main` и покрыта базовыми проверками;
|
||||
- `[ ]` — функция ещё не реализована полностью;
|
||||
- наличие функции в `main` не означает, что она уже вошла в опубликованный server/agent
|
||||
release или развёрнута на production.
|
||||
|
||||
### Агент и пакеты
|
||||
## Текущее состояние
|
||||
|
||||
- [x] Go-агент с enrollment, heartbeat, inventory, метриками и очередью команд.
|
||||
- [x] Allowlist операций, backoff, восстановление связи и очистка lock-файла при остановке.
|
||||
- [x] Сбор WAN, интерфейсов, DHCP/Wi-Fi-клиентов, памяти, диска и connectivity checks.
|
||||
### Агент и OpenWrt-пакеты
|
||||
|
||||
- [x] Go-агент `0.6.8`: enrollment, heartbeat, inventory, метрики и очередь команд.
|
||||
- [x] Allowlist операций, backoff, восстановление связи и очистка lock-файла.
|
||||
- [x] WAN, интерфейсы, DHCP/Wi-Fi-клиенты, память, диск и connectivity checks.
|
||||
- [x] Безопасная активная проверка до 32 IPv4 DHCP-клиентов и передача результатов серверу.
|
||||
- [x] Reverse SSH/LuCI tunnel через облачный сервер.
|
||||
- [x] APK и IPK для OpenWrt, включая `mipsel`/`ramips/mt7621`.
|
||||
- [x] LuCI-приложение настройки агента.
|
||||
- [x] IPK/APK для основной OpenWrt-матрицы и ручной legacy workflow.
|
||||
- [x] LuCI-приложение с английским интерфейсом по умолчанию.
|
||||
- [x] Отдельный пакет `luci-i18n-rmm-agent-ru`.
|
||||
|
||||
### Server и безопасность
|
||||
### Сервер и аккаунты
|
||||
|
||||
- [x] Go API, SQLite/WAL, автоматические совместимые миграции и retention метрик.
|
||||
- [x] Multi-user аккаунты, профиль, e-mail, смена/сброс пароля и отзыв сессий.
|
||||
- [x] Владение устройствами, передача другому пользователю и изоляция API.
|
||||
- [x] Go API, SQLite/WAL, совместимые startup-migrations и retention метрик.
|
||||
- [x] Multi-user аккаунты, admin/user роли, профиль, e-mail, пароль и отзыв сессий.
|
||||
- [x] Владение роутерами, передача устройства и изоляция пользовательского API.
|
||||
- [x] Одноразовый enrollment grant; legacy shared token выключен по умолчанию.
|
||||
- [x] Хеширование паролей, browser sessions, enrollment/device/access token.
|
||||
- [x] Rate limit входа и восстановления пароля, CSRF/origin checks, audit log.
|
||||
- [x] Облачные поддомены роутеров и одноразовый LuCI access grant.
|
||||
- [x] Persistent alerts: active, acknowledged, resolved.
|
||||
- [x] Пользовательские пороги и уведомления e-mail/Telegram с дедупликацией, повторами и историей.
|
||||
- [x] Хеширование паролей и reusable access tokens.
|
||||
- [x] Rate limiting авторизации, CSRF/origin checks и audit log.
|
||||
- [x] Wildcard device domains и одноразовый LuCI access grant.
|
||||
- [x] Alerts с состояниями active, acknowledged и resolved.
|
||||
|
||||
### Web UI
|
||||
### Уведомления и LAN-клиенты
|
||||
|
||||
- [x] Лендинг, favicon/бренд, партнёры и адаптивная motion-система.
|
||||
- [x] Полноширинный список объектов, поиск, фильтры, сортировка и автообновление.
|
||||
- [x] Карточка роутера: обзор, клиенты, сеть, операции, сведения, эксперт.
|
||||
- [x] Графики метрик, диагностика, команды, UCI, пакеты и аудит.
|
||||
- [x] Облачный LuCI с состояниями starting/offline/timeout/error и единым дизайном ошибок.
|
||||
- [x] Mobile/tablet/4:3 layout без случайного горизонтального overflow.
|
||||
- [x] Личный кабинет и настройки уведомлений.
|
||||
- [x] E-mail, Telegram и подписанный webhook.
|
||||
- [x] Подтверждение e-mail и безопасная привязка Telegram.
|
||||
- [x] Пользовательские пороги, repeat, quiet hours/timezone и maintenance pause.
|
||||
- [x] Per-device notification overrides.
|
||||
- [x] Durable queue с lease, retry, dead-letter и retention истории.
|
||||
- [x] Центр уведомлений: unread/read, счётчик, переход к роутеру и SSE-обновление.
|
||||
- [x] Группировка связанных событий по incident.
|
||||
- [x] Хранение `last_seen` LAN-клиентов и статусы online/recent/unconfirmed.
|
||||
|
||||
### Deployment
|
||||
### Web UI и deployment
|
||||
|
||||
- [x] Hardened Docker image и Compose.
|
||||
- [x] NPMplus overlay и wildcard device domain.
|
||||
- [x] Healthcheck, ограничение capabilities/resources и persistent volumes.
|
||||
- [x] SMTP STARTTLS/TLS и Telegram bot configuration через environment.
|
||||
- [x] Версионированные server/tunnel images в GHCR и production Compose overlay.
|
||||
- [x] SBOM/provenance, keyless Cosign и подписанный APK/IPK package repository.
|
||||
- [x] Быстрая матрица текущих OpenWrt 24.10/25.12 и отдельная ручная legacy-сборка
|
||||
OpenWrt 21.02/22.03/23.05 без блокировки основного релиза.
|
||||
- [x] Лендинг, favicon, партнёры и motion/reduced-motion.
|
||||
- [x] Fleet, поиск, фильтры, сортировка и автообновление.
|
||||
- [x] Карточка роутера, графики, alerts, диагностика, UCI, пакеты и аудит.
|
||||
- [x] Cloud LuCI со сценариями starting/offline/timeout/error.
|
||||
- [x] Адаптивный desktop/tablet/4:3/mobile layout.
|
||||
- [x] Hardened Docker image, Compose, NPMplus и wildcard overlay.
|
||||
- [x] Версионированные server/tunnel images, SBOM/provenance и Cosign.
|
||||
- [x] Подписанный IPK/APK feed через GitHub Pages.
|
||||
|
||||
## Следующие этапы
|
||||
## Порядок дальнейшей разработки
|
||||
|
||||
### Этап 1 — завершение notification platform
|
||||
### 0. Стабилизация и выпуск текущего `main`
|
||||
|
||||
- [x] E-mail и Telegram для warning/critical/resolved.
|
||||
- [x] Пользовательские пороги памяти, диска, потерь и latency.
|
||||
- [x] Lifecycle deduplication, повтор открытой проблемы и журнал доставки.
|
||||
- [x] Тестовая отправка из профиля.
|
||||
- [x] Перезапускаемая очередь с lease, exponential retry, dead-letter и retention терминальной истории.
|
||||
- [x] Webhook-канал с подписанным payload и ротацией секрета.
|
||||
- [x] Per-device override и расписание тишины.
|
||||
- [x] Группировка нескольких событий в incident.
|
||||
- [x] Дождаться полного успешного release workflow `agent-v0.6.8`, включая все текущие
|
||||
OpenWrt jobs и публикацию GitHub Pages.
|
||||
- [ ] Проверить установку и обновление `rmm-agent-go-production`,
|
||||
`luci-app-rmm-agent` и `luci-i18n-rmm-agent-ru` из подписанного feed на реальном
|
||||
OpenWrt 24.10/25.12.
|
||||
- [ ] Подготовить и выпустить подписанный `server-v0.9.0` с notification center,
|
||||
verification, per-device settings и LAN client presence.
|
||||
- [ ] Проверить миграцию копии production SQLite, сделать backup и только затем обновить
|
||||
production.
|
||||
- [ ] Выполнить production-проверки SMTP, Telegram, webhook, active → repeat → resolved,
|
||||
retry → dead-letter, SSE и online → recent → unconfirmed.
|
||||
- [ ] Добавить операционные метрики уведомлений: queued/sent/failed, queue age,
|
||||
последняя ошибка и последняя успешная доставка по каналу.
|
||||
|
||||
### Уточнение присутствия проводных клиентов
|
||||
### 1. Изоляция туннелей и защита управляющих команд
|
||||
|
||||
- [x] Дополнить пассивный `ip neigh` безопасной активной проверкой LAN-клиентов.
|
||||
- [x] Хранить время последнего подтверждения и показывать `STALE` как «Недавно был в сети».
|
||||
- [ ] Персональные SSH credentials или короткоживущие сертификаты для каждого устройства.
|
||||
- [ ] Ротация/отзыв tunnel credentials при transfer, delete и компрометации.
|
||||
- [ ] Авторизованное выделение портов без возможности pre-bind/hijack другой сессии.
|
||||
- [ ] Проверка полной цепочки agent → SSH → LuCI HTTP → wildcard TLS.
|
||||
- [ ] Лимиты одновременных сессий, rate limit и административная политика remote access.
|
||||
- [ ] Device token rotation и явный отзыв device credentials.
|
||||
- [ ] Подписанные команды, срок действия, nonce и replay protection.
|
||||
- [ ] Шифрование webhook secrets и чувствительных полей notification queue в SQLite.
|
||||
|
||||
### Этап 2 — backup и безопасное восстановление
|
||||
### 2. Автоматические проверки и наблюдаемость
|
||||
|
||||
- [ ] Интеграционные тесты notification center, contact verification, per-device
|
||||
overrides, incident grouping и quiet hours/timezone.
|
||||
- [ ] Тесты активной проверки LAN-клиентов, лимита адресов, ICMP-blocked и IPv6-сценариев.
|
||||
- [ ] E2E reverse tunnel и cloud LuCI с проверкой ошибок и истечения сессии.
|
||||
- [ ] Playwright smoke для login, fleet, router, profile, notifications и LuCI errors.
|
||||
- [ ] Автоматическая responsive/accessibility матрица 1920/1366/1024/768/390/360.
|
||||
- [ ] Package install/upgrade/remove smoke для IPK/APK и проверка LuCI i18n.
|
||||
- [ ] Prometheus metrics, readiness, queue/tunnel metrics и структурированные логи.
|
||||
- [ ] `govulncheck`, SAST, container/dependency/secret scanning.
|
||||
- [ ] Pin GitHub Actions и базовых container images на проверяемые immutable revisions.
|
||||
|
||||
### 3. Backup и безопасное восстановление
|
||||
|
||||
- [ ] Получение `sysupgrade -b` архива через агент.
|
||||
- [ ] Зашифрованное хранение и retention версий.
|
||||
- [ ] Скачивание, сравнение и проверка совместимости.
|
||||
- [ ] Restore с preview, подтверждением и планом отката.
|
||||
- [ ] Backup/restore самой SQLite базы RMM.
|
||||
- [ ] Шифрованное хранение, retention и контроль доступа к backup.
|
||||
- [ ] Скачивание, безопасный diff и проверка совместимости с target/device/version.
|
||||
- [ ] Restore preview, повторное подтверждение и план отката.
|
||||
- [ ] Backup/restore SQLite через согласованный snapshot, а не копирование активного файла.
|
||||
- [ ] Disaster recovery runbook и регулярный restore drill.
|
||||
|
||||
### Этап 3 — обновления и release pipeline
|
||||
### 4. Обновления агента и LuCI
|
||||
|
||||
- [x] Автосборка APK/IPK и multi-architecture artifacts в CI.
|
||||
- [x] Нативная подпись IPK/APK feed, Cosign checksums и публикация репозитория.
|
||||
- [ ] Подписанный update manifest агента и LuCI-приложения.
|
||||
- [ ] Обновление одного роутера из кабинета.
|
||||
- [ ] Canary/поэтапный rollout с остановкой при потере связи.
|
||||
- [ ] История и безопасный rollback версии агента.
|
||||
- [x] Подписанный stable update manifest с версиями, target/feed compatibility и Sigstore bundle.
|
||||
- [x] Проверка detached manifest signature сервером перед публикацией stable version в UI.
|
||||
- [ ] Проверка manifest signature агентом перед выполнением обновления.
|
||||
- [ ] Проверка свободного места, feed signature и package health до обновления.
|
||||
- [ ] Обновление одного роутера из кабинета с progress/reconnect/result.
|
||||
- [ ] Canary и поэтапный rollout с автоматической остановкой при ошибках.
|
||||
- [ ] История версий и rollback, если агент не вернулся после обновления.
|
||||
- [ ] Stable/candidate release channels без перемещения опубликованных тегов.
|
||||
|
||||
### Этап 4 — усиление cloud access
|
||||
|
||||
- [ ] Персональные ключи туннеля для каждого устройства и их ротация.
|
||||
- [ ] Browser terminal с ограниченной сессией и аудитом.
|
||||
- [ ] Детальная проверка цепочки agent → SSH → HTTP LuCI → TLS.
|
||||
- [ ] Лимиты одновременных сессий и административная политика доступа.
|
||||
|
||||
### Этап 5 — организации и расширенная безопасность
|
||||
### 5. Организации и расширенная безопасность
|
||||
|
||||
- [ ] Organizations/workspaces и приглашения.
|
||||
- [ ] Роли owner/admin/operator/viewer и права на отдельные группы устройств.
|
||||
- [ ] MFA/WebAuthn.
|
||||
- [ ] Device token rotation, signed commands и replay protection.
|
||||
- [ ] mTLS как дополнительный режим agent transport.
|
||||
- [ ] Роли owner/admin/operator/viewer.
|
||||
- [ ] Доступ к группам и отдельным устройствам без передачи владельца.
|
||||
- [ ] MFA/WebAuthn и recovery codes.
|
||||
- [ ] Audit retention/export и журнал административных изменений.
|
||||
- [ ] Удаление аккаунта, экспорт данных и отзыв связанных credentials.
|
||||
- [ ] Опциональный mTLS для agent transport.
|
||||
|
||||
### Этап 6 — production hardening
|
||||
### 6. Расширение cloud DNS и fleet management
|
||||
|
||||
- [ ] Версионированные migrations с отдельным migration log.
|
||||
- [ ] Graceful shutdown фоновых задач и HTTP server.
|
||||
- [ ] Server Prometheus metrics и расширенные health/readiness checks.
|
||||
- [ ] E2E agent-server, browser smoke и load tests в CI.
|
||||
- [ ] Disaster recovery runbook и регулярная проверка восстановления.
|
||||
- [ ] Безопасное переименование, резервирование и освобождение router DNS names.
|
||||
- [ ] Проверка wildcard DNS/TLS/tunnel health в интерфейсе.
|
||||
- [ ] Защита от повторного захвата недавно освобождённого имени и пользовательские квоты.
|
||||
- [ ] Опциональные пользовательские домены.
|
||||
- [ ] Конфигурационные шаблоны, группы, config drift и плановые команды.
|
||||
- [ ] Диагностический архив агента и безопасная выгрузка логов.
|
||||
- [ ] Интернационализация web UI и правила добавления языков LuCI.
|
||||
|
||||
## Ближайший приоритет
|
||||
### 7. Масштабирование и долговременный hardening
|
||||
|
||||
1. Завершить и проверить `agent-v0.6.7`, при необходимости добавить legacy-пакеты,
|
||||
развернуть server `0.8.1` и проверить свежие SSH/LuCI-сессии.
|
||||
2. Развернуть и проверить notification release на production.
|
||||
3. Добавить активное подтверждение проводных клиентов, webhook и quiet hours, затем
|
||||
начать конфигурационные backup/restore.
|
||||
- [ ] Версионированные migrations и отдельный migration log.
|
||||
- [ ] Graceful shutdown HTTP server, workers и активных lease.
|
||||
- [ ] Distributed rate limiting и worker coordination для нескольких server instances.
|
||||
- [ ] Load/soak tests, SLO и алерты состояния самой RMM-платформы.
|
||||
- [ ] Политика хранения audit, metrics, notifications и client history.
|
||||
|
||||
+29
-62
@@ -1,70 +1,37 @@
|
||||
# Полный UI/UX-аудит
|
||||
# UI/UX audit record
|
||||
|
||||
Дата основного аудита: 2026-07-21. Синхронизировано с notification UI: 2026-07-22.
|
||||
Production проверен на реальной учётной записи, изменения — на изолированной локальной
|
||||
Docker-сборке с фиктивным роутером и длинными тестовыми значениями.
|
||||
Основной аудит: 2026-07-21. Последняя синхронизация: 2026-07-31.
|
||||
|
||||
## Авторизация и общий каркас
|
||||
Это запись уже выполненной проверки, а не список будущей разработки. Актуальные
|
||||
критерии повторной приёмки находятся в `UI_CHECKLIST.md`.
|
||||
|
||||
- [x] Вход, выход и понятное состояние ошибки авторизации.
|
||||
- [x] Личный кабинет, профиль, e-mail, смена пароля и восстановление доступа представлены единообразно.
|
||||
- [x] Header, боковая и мобильная навигация, отступы, фокус и зоны нажатия.
|
||||
- [x] Loading, empty, offline, reconnecting, forbidden, timeout и server error используют общий визуальный язык.
|
||||
- [x] Нет горизонтального overflow или недоступных действий в основном каркасе.
|
||||
## Подтверждённая базовая проверка
|
||||
|
||||
## Список и карточка роутера
|
||||
- [x] Login, recovery, profile, password, users и sessions используют общий layout.
|
||||
- [x] Fleet и все вкладки роутера проверены с длинными hostname/ID/IPv6.
|
||||
- [x] Основной layout не имеет page-level overflow на 1920, 1366, 1024×768,
|
||||
768×1024, 390×844 и 360×800.
|
||||
- [x] Cloud LuCI starting/offline/expired/401/403/404/409/429/502/503/504 используют
|
||||
согласованный дизайн и request ID.
|
||||
- [x] Landing motion, scroll reveal и reduced-motion fallback проверены.
|
||||
- [x] Видимый focus, touch targets и внутренний scroll диалогов проверены.
|
||||
- [x] Notification profile и delivery history проверены локально на tablet/mobile.
|
||||
- [x] Недоступные SMTP/Telegram channels блокируются с объяснением.
|
||||
- [x] Provider error отображается безопасно и не раскрывает destination/secret.
|
||||
|
||||
- [x] Поиск, фильтры, обновление и длинные имена/ID/IPv6 не ломают сетку.
|
||||
- [x] Статусы online/offline/reconnecting понятны по цвету и тексту.
|
||||
- [x] Все шесть вкладок карточки проверены в 4:3 и на телефоне.
|
||||
- [x] Широкие таблицы превращаются в карточки либо остаются внутри локального scroll-контейнера.
|
||||
- [x] Мобильные действия и нижняя навигация остаются видимыми и доступными.
|
||||
## Добавлено после основного production-аудита
|
||||
|
||||
## Облачный LuCI
|
||||
- [x] Notification center, unread/read, incident grouping и SSE refresh реализованы в `main`.
|
||||
- [x] E-mail/Telegram verification, webhook, quiet hours и per-device overrides реализованы в `main`.
|
||||
- [x] LAN clients online/recent/unconfirmed и `last_seen` реализованы в `main`.
|
||||
- [ ] Эти изменения выпущены в новом server release после `server-v0.8.1`.
|
||||
- [ ] Полная browser matrix повторена на опубликованном server release.
|
||||
- [ ] Реальная SMTP/Telegram/webhook доставка проверена на production.
|
||||
- [ ] Retry/dead-letter и длинный provider error проверены в production history.
|
||||
- [ ] Online → recent → unconfirmed подтверждено на реальных wired/Wi-Fi клиентах.
|
||||
|
||||
- [x] Верхняя и карточная кнопки используют один сценарий и не зависят от состояния друг друга.
|
||||
- [x] Повторное нажатие блокируется на время запроса; открытая вкладка показывает состояние подключения.
|
||||
- [x] Проверены starting, offline и expired; backend-тесты покрывают grant, cookie и проксирование.
|
||||
- [x] Ошибки 401/403/404/409/429/502/504 имеют понятный текст, возврат в RMM и request ID.
|
||||
- [x] Отдельная страница ошибки приведена к стилю кабинета и проверена на телефоне.
|
||||
## Ограничения
|
||||
|
||||
## Лендинг и motion
|
||||
|
||||
- [x] Последовательное появление hero без блокировки управления.
|
||||
- [x] Scroll-reveal для возможностей, партнёров, безопасности и CTA.
|
||||
- [x] Спокойные hover/press-переходы для CTA, навигации и партнёров.
|
||||
- [x] Ненавязчивое движение фоновых градиентов без layout shift.
|
||||
- [x] `prefers-reduced-motion: reduce` отключает декоративное движение.
|
||||
- [x] Контент остаётся видимым при отключённом JavaScript.
|
||||
|
||||
## Матрица экранов
|
||||
|
||||
- [x] 1920×1080 — широкий desktop.
|
||||
- [x] 1366×768 — ноутбук.
|
||||
- [x] 1024×768 — формат 4:3.
|
||||
- [x] 768×1024 — tablet portrait.
|
||||
- [x] 390×844 — современный телефон.
|
||||
- [x] 360×800 — узкий телефон.
|
||||
|
||||
## Качество
|
||||
|
||||
- [x] Видимый keyboard focus, логичный tab order и зоны нажатия не меньше 42 px для ключевых действий.
|
||||
- [x] Диалоги ограничены viewport, прокручиваются внутри и предсказуемо закрываются.
|
||||
- [x] Нет синтаксических ошибок JavaScript и необработанного горизонтального overflow.
|
||||
- [x] Go tests/vet, Compose config, Docker build и проверка diff проходят.
|
||||
|
||||
## Уведомления — новый блок
|
||||
|
||||
- [x] Персональные настройки e-mail/Telegram и порогов алертов.
|
||||
- [x] Warning, critical, resolved, repeat и тестовая отправка.
|
||||
- [x] История queued/sending/retry/sent/dead-letter использует только masked destination.
|
||||
- [x] Повторить browser matrix нового profile-раздела перед production deploy.
|
||||
- [x] При отсутствии SMTP и Telegram token каналы недоступны и объясняют причину.
|
||||
- [x] Ошибка тестовой отправки показана по-русски и сохраняет request ID.
|
||||
- [ ] Реальная доставка через production SMTP и Telegram проверена из пользовательского профиля.
|
||||
- [ ] Retry/dead-letter доставка и длинный текст ошибки проверены в истории без раскрытия внутренних деталей.
|
||||
|
||||
## Ограничения production-проверки
|
||||
|
||||
- Смена пароля и e-mail на production намеренно не выполнялись, чтобы не изменять реальную учётную запись; формы и API покрыты локальной проверкой и автоматическими тестами.
|
||||
- Исправления становятся видны на `rmm.daemonlord.ru` после развёртывания нового Docker-образа.
|
||||
- Изменения пароля/e-mail на production ранее не выполнялись, чтобы не менять реальную
|
||||
учётную запись; формы и API проверялись локально.
|
||||
- Галочка «реализовано в `main`» не заменяет release и production smoke.
|
||||
|
||||
+44
-33
@@ -1,40 +1,51 @@
|
||||
# UI checklist
|
||||
# UI acceptance checklist
|
||||
|
||||
Синхронизировано: 2026-07-22. Детальный последний аудит находится в
|
||||
`UI_AUDIT_CHECKLIST.md`.
|
||||
Актуализировано: 2026-07-31. Реализованные возможности перечислены в `UI_ROADMAP.md`,
|
||||
результаты последнего ручного аудита — в `UI_AUDIT_CHECKLIST.md`.
|
||||
|
||||
## Проверено и реализовано
|
||||
Этот checklist применяется к каждому изменённому экрану и не дублирует продуктовый roadmap.
|
||||
|
||||
- [x] Вход, восстановление, профиль, пароль и управление пользователями.
|
||||
- [x] Fleet list, поиск, фильтры, сортировка, offline/online/problem states.
|
||||
- [x] Все вкладки роутера и длинные hostname/ID/IPv6.
|
||||
- [x] DHCP/Wi-Fi-клиенты не считаются online только из-за static lease.
|
||||
- [x] Metrics charts, alerts, acknowledge и operator diagnostics.
|
||||
- [x] Cloud LuCI button, waiting page и согласованные ошибки.
|
||||
- [x] Landing motion и reduced-motion fallback.
|
||||
- [x] Desktop, 4:3, tablet и mobile без page-level overflow.
|
||||
- [x] Notification settings, thresholds, test send и delivery history.
|
||||
- [x] История различает queued/sending/retry/sent/dead-letter и показывает номер попытки и время повтора.
|
||||
## Функциональные состояния
|
||||
|
||||
## Проверить перед выпуском notification UI
|
||||
- [ ] Основное действие доступно без перехода в «Эксперт».
|
||||
- [ ] Есть loading, empty, success, offline и server error.
|
||||
- [ ] Опасное действие объясняет последствия и требует подтверждения.
|
||||
- [ ] Повторное нажатие не создаёт дублирующую операцию.
|
||||
- [ ] Ошибка показывает безопасный текст и request ID.
|
||||
- [ ] Секреты, токены и полные notification destinations не отображаются.
|
||||
|
||||
- [ ] Профиль без e-mail: e-mail channel выключен с понятной подсказкой.
|
||||
- [x] SMTP отсутствует: e-mail channel недоступен, остальные настройки сохраняются.
|
||||
- [x] Telegram token отсутствует: Telegram channel недоступен.
|
||||
- [ ] Неверный Chat ID и пороги показывают ошибку формы.
|
||||
- [ ] Test send показывает sent/retry/dead-letter без раскрытия секретов.
|
||||
- [ ] История корректна при пустом списке и длинной ошибке доставки.
|
||||
- [ ] Profile dialog прокручивается внутри 390×844 и 360×800.
|
||||
- [ ] Keyboard navigation и zoom 200%.
|
||||
## Responsive layout
|
||||
|
||||
## Следующие элементы
|
||||
- [ ] 1920×1080 и 1366×768.
|
||||
- [ ] 1024×768 без page-level overflow.
|
||||
- [ ] 768×1024.
|
||||
- [ ] 390×844 и 360×800.
|
||||
- [ ] Широкий контент прокручивается только внутри локального контейнера.
|
||||
- [ ] Диалоги помещаются во viewport и прокручиваются внутри.
|
||||
- [ ] Ключевые touch targets не меньше 42 px.
|
||||
|
||||
- [ ] Встроенный notification center: колокольчик, unread/read, счётчик и переход к источнику события.
|
||||
- [ ] Фильтры уведомлений по роутеру, типу, severity, каналу и статусу доставки.
|
||||
- [ ] Настройки отдельных типов событий и maintenance/snooze для роутера.
|
||||
- [ ] Экран состояния каналов: последняя успешная отправка, последняя ошибка и тест соединения.
|
||||
- [ ] Quiet hours/timezone.
|
||||
- [ ] Webhook editor с ротацией секрета.
|
||||
- [ ] Per-device notification override.
|
||||
- [ ] Backup/restore wizard.
|
||||
- [ ] Agent update/rollout wizard.
|
||||
## Доступность и motion
|
||||
|
||||
- [ ] Видимый keyboard focus и логичный tab order.
|
||||
- [ ] Управление доступно без мыши.
|
||||
- [ ] Zoom 200% не скрывает действия и сообщения.
|
||||
- [ ] Текст не передаёт смысл только цветом.
|
||||
- [ ] `prefers-reduced-motion` отключает декоративную анимацию.
|
||||
- [ ] Контент остаётся доступным при ошибке или отключении animation script.
|
||||
|
||||
## Данные и обновление
|
||||
|
||||
- [ ] SSE обновляет экран без потери выбранного объекта/фильтра/вкладки.
|
||||
- [ ] Polling fallback не создаёт скачков и повторных toast.
|
||||
- [ ] Длинные hostname, ID, IPv6 и provider errors не ломают сетку.
|
||||
- [ ] Даты используют выбранную timezone и однозначный формат.
|
||||
- [ ] Stale/recent/offline статусы имеют понятный текст.
|
||||
|
||||
## Неподтверждённые regression-сценарии
|
||||
|
||||
- [ ] Профиль без e-mail корректно блокирует e-mail notifications.
|
||||
- [ ] Неверный Telegram Chat ID и пороги показывают ошибку поля.
|
||||
- [ ] Test send проверен для sent/retry/dead-letter.
|
||||
- [ ] Пустая история и длинная ошибка доставки проверены на mobile.
|
||||
- [ ] Production SMTP, Telegram и webhook проверены из профиля.
|
||||
- [ ] Notification/client/cloud-access flow покрыты browser E2E.
|
||||
|
||||
+44
-42
@@ -1,59 +1,61 @@
|
||||
# UI/UX roadmap — актуальный статус
|
||||
# UI/UX roadmap
|
||||
|
||||
Актуализировано: 2026-07-22.
|
||||
Актуализировано: 2026-07-31. Общий порядок разработки находится в `ROADMAP.md`.
|
||||
Здесь перечислены только UI-результаты, зависящие от соответствующих backend-этапов.
|
||||
|
||||
## Завершённая основа
|
||||
## Реализовано в `main`
|
||||
|
||||
- [x] Общие design tokens, типографика, focus и motion/reduced-motion.
|
||||
- [x] Лендинг с адаптивным hero, scroll reveal и партнёрами.
|
||||
- [x] Рабочий layout: sidebar, tablet header и mobile bottom navigation.
|
||||
- [x] Полноширинный список объектов, фильтры, сортировка и состояния.
|
||||
- [x] Карточка объекта с вкладками «Обзор», «Клиенты», «Сеть», «Операции»,
|
||||
«Сведения» и «Эксперт».
|
||||
- [x] Графики, alerts, диагностика, cloud LuCI и экспертные операции.
|
||||
- [x] Адаптивный landing, partners, app shell и mobile navigation.
|
||||
- [x] Fleet, фильтры, сортировка и состояния online/offline/problem.
|
||||
- [x] Карточка роутера, локально прокручиваемые таблицы и mobile layout.
|
||||
- [x] Метрики, alerts, диагностика, UCI, пакеты и audit.
|
||||
- [x] Cloud LuCI waiting/error flow.
|
||||
- [x] Профиль, e-mail, пароль, пользователи и сессии.
|
||||
- [x] Единые LuCI 401/403/404/409/429/502/503/504 состояния.
|
||||
- [x] Проверка 1920, 1366, 1024×768, 768×1024, 390 и 360 px.
|
||||
- [x] Notification settings, verification, delivery history и test send.
|
||||
- [x] Notification center, unread counter, incident grouping и переход к роутеру.
|
||||
- [x] Quiet hours/timezone, maintenance pause, webhook и per-device overrides.
|
||||
- [x] LAN clients со статусами «В сети», «Недавно был в сети» и «Не подтверждён».
|
||||
|
||||
## Текущий этап — уведомления
|
||||
## Следующие UI-этапы
|
||||
|
||||
- [x] Настройки каналов e-mail/Telegram в личном кабинете.
|
||||
- [x] Пороги memory/disk/packet loss/latency.
|
||||
- [x] Warning/critical/resolved и период повтора.
|
||||
- [x] Тестовая отправка и история queued/sending/retry/sent/dead-letter.
|
||||
- [x] Retry/dead-letter состояния, номер попытки и время следующей доставки.
|
||||
- [x] Недоступный серверный канал объясняется и блокируется.
|
||||
- [ ] Quiet hours и выбор timezone.
|
||||
- [ ] Per-device overrides в карточке объекта.
|
||||
- [ ] Webhook channel и masked secret editor.
|
||||
### 1. Стабилизация текущего интерфейса
|
||||
|
||||
## Следующие UI-блоки
|
||||
- [ ] Фильтры notification center/history по роутеру, severity, event, channel и delivery status.
|
||||
- [ ] Состояние каналов: последняя успешная доставка, последняя ошибка и queue age.
|
||||
- [ ] Production regression для notification, LAN clients и cloud access.
|
||||
- [ ] Автоматический browser smoke и responsive/accessibility matrix.
|
||||
|
||||
### Backup/restore
|
||||
### 2. Безопасный remote access
|
||||
|
||||
- [ ] Список конфигурационных архивов с датой, версией и размером.
|
||||
- [ ] Создание/скачивание backup и политика retention.
|
||||
- [ ] Diff конфигурации без отображения секретов по умолчанию.
|
||||
- [ ] Пошаговый restore с проверкой совместимости и rollback warning.
|
||||
- [ ] Показ tunnel identity/health без раскрытия credentials.
|
||||
- [ ] Понятные состояния port allocation, access denied, expired и revoked.
|
||||
- [ ] Browser terminal с ограниченной сессией, предупреждениями и audit trail.
|
||||
- [ ] Экран активных сессий и административных лимитов.
|
||||
|
||||
### Обновления
|
||||
### 3. Backup/restore
|
||||
|
||||
- [ ] Доступная/установленная версия агента и LuCI package.
|
||||
- [ ] Update preview, progress, reconnect state и результат.
|
||||
- [ ] Выбор группы/canary и остановка rollout при ошибке.
|
||||
- [ ] Список архивов с device/version/date/size/status.
|
||||
- [ ] Создание, скачивание и retention.
|
||||
- [ ] Безопасный diff с секретами, скрытыми по умолчанию.
|
||||
- [ ] Restore wizard с compatibility check, повторным подтверждением и rollback warning.
|
||||
|
||||
### Управление доступом
|
||||
### 4. Обновления
|
||||
|
||||
- [ ] Workspace/organization switcher.
|
||||
- [ ] Установленная и доступная версии agent/LuCI.
|
||||
- [ ] Update preview, progress, reconnect и итоговый health result.
|
||||
- [ ] Canary/group rollout и автоматическая остановка при ошибке.
|
||||
- [ ] История обновлений и rollback.
|
||||
|
||||
### 5. Organizations и безопасность
|
||||
|
||||
- [ ] Workspace switcher и приглашения.
|
||||
- [ ] Роли owner/admin/operator/viewer.
|
||||
- [ ] Матрица доступа к группам и отдельным устройствам.
|
||||
- [ ] Матрица прав групп и устройств.
|
||||
- [ ] MFA/WebAuthn, recovery codes и управление device credentials.
|
||||
|
||||
## Постоянные критерии приёмки
|
||||
### 6. Доступность и локализация
|
||||
|
||||
- Основное действие доступно без перехода в «Эксперт».
|
||||
- Опасное действие объясняет последствия и требует подтверждения.
|
||||
- Каждая операция имеет loading, success, empty, offline и error состояния.
|
||||
- Request ID доступен для серверной ошибки.
|
||||
- Нет горизонтального overflow страницы; широкий контент прокручивается локально.
|
||||
- Touch target ключевых действий не меньше 42 px.
|
||||
- Keyboard focus видим, `prefers-reduced-motion` соблюдается.
|
||||
- [ ] Web UI i18n без жёстко заданного русского текста.
|
||||
- [ ] Проверка keyboard-only, screen reader, contrast и zoom 200% в CI.
|
||||
- [ ] Полные loading/empty/offline/error состояния для каждого нового wizard.
|
||||
|
||||
@@ -24,6 +24,10 @@ services:
|
||||
RMM_METRIC_RETENTION_DAYS: "${RMM_METRIC_RETENTION_DAYS:-30}"
|
||||
RMM_NOTIFICATION_RETENTION_DAYS: "${RMM_NOTIFICATION_RETENTION_DAYS:-90}"
|
||||
RMM_NOTIFICATION_MAX_ATTEMPTS: "${RMM_NOTIFICATION_MAX_ATTEMPTS:-5}"
|
||||
RMM_STABLE_AGENT_VERSION: "${RMM_STABLE_AGENT_VERSION:-0.6.8}"
|
||||
RMM_UPDATE_MANIFEST_URL: "${RMM_UPDATE_MANIFEST_URL:-https://benya9669.github.io/openwrt-rmm/update-manifest.json}"
|
||||
RMM_UPDATE_MANIFEST_SIGNATURE_URL: "${RMM_UPDATE_MANIFEST_SIGNATURE_URL:-https://benya9669.github.io/openwrt-rmm/update-manifest.sig}"
|
||||
RMM_UPDATE_MANIFEST_PUBLIC_KEY: "${RMM_UPDATE_MANIFEST_PUBLIC_KEY:-/app/keys/rmm-openwrt.pem}"
|
||||
RMM_SMTP_HOST: "${RMM_SMTP_HOST:-}"
|
||||
RMM_SMTP_PORT: "${RMM_SMTP_PORT:-587}"
|
||||
RMM_SMTP_USERNAME: "${RMM_SMTP_USERNAME:-}"
|
||||
|
||||
+25
@@ -75,6 +75,31 @@ Response:
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
## Release metadata
|
||||
|
||||
Authenticated clients obtain the server build and stable agent channel from the server
|
||||
instead of embedding a version in JavaScript:
|
||||
|
||||
```http
|
||||
GET /api/meta
|
||||
Authorization: Bearer <operator-api-token>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"server_version": "0.9.0",
|
||||
"server_revision": "0123456789abcdef",
|
||||
"stable_agent_version": "0.6.8",
|
||||
"update_manifest_url": "https://benya9669.github.io/openwrt-rmm/update-manifest.json"
|
||||
}
|
||||
```
|
||||
|
||||
The dashboard applies Semantic Versioning: an older installed version receives an update
|
||||
notice, an equal version is current, and a newer version is never offered a downgrade.
|
||||
The referenced manifest, detached package-key signature, and Sigstore bundle are
|
||||
published by the agent release workflow. The server keeps its configured fallback
|
||||
version unless the detached signature verifies successfully.
|
||||
|
||||
## Agent Enrollment
|
||||
|
||||
```http
|
||||
|
||||
+12
-3
@@ -38,6 +38,10 @@ Core tables:
|
||||
- `remote_sessions`
|
||||
- `notification_settings`
|
||||
- `notification_deliveries`
|
||||
- `device_notification_settings`
|
||||
- `inbox_notifications`
|
||||
- `contact_verifications`
|
||||
- `lan_clients`
|
||||
|
||||
Notification delivery uses a persistent SQLite queue. Workers atomically claim ready rows
|
||||
with a lease, increment the attempt counter, and finish them as `sent`, `retry`, or
|
||||
@@ -46,14 +50,14 @@ an independent retention period; queued, retrying, and in-flight rows are preser
|
||||
|
||||
## Agent
|
||||
|
||||
The agent is a small Go binary and uses tools normally available on OpenWrt for platform
|
||||
operations:
|
||||
The production agent is a small Go binary with a native HTTPS client. It uses tools
|
||||
normally available on OpenWrt only for platform operations:
|
||||
|
||||
- `ubus`
|
||||
- `ip`
|
||||
- `opkg`
|
||||
- `/etc/init.d/*`
|
||||
- `curl` or `wget`
|
||||
- `ping`, `uci`, and the active package manager
|
||||
|
||||
The agent does not accept inbound connections. It polls the server and executes only allowlisted command types.
|
||||
|
||||
@@ -91,3 +95,8 @@ The agent uses outbound HTTPS polling:
|
||||
|
||||
Server-Sent Events update the browser dashboard; agent transport remains HTTPS polling.
|
||||
WebSocket or MQTT can be added later if faster agent command delivery is required.
|
||||
|
||||
The server exposes authenticated release metadata to the dashboard. A background
|
||||
resolver downloads the stable agent manifest, verifies its detached ECDSA signature with
|
||||
the package repository public key, and retains the last trusted fallback when refresh
|
||||
fails.
|
||||
|
||||
@@ -48,6 +48,10 @@ Telegram notifications use one server-side bot token and a per-user numeric Chat
|
||||
RMM_TELEGRAM_BOT_TOKEN=replace-with-the-token-from-botfather
|
||||
RMM_NOTIFICATION_MAX_ATTEMPTS=5
|
||||
RMM_NOTIFICATION_RETENTION_DAYS=90
|
||||
RMM_STABLE_AGENT_VERSION=0.6.8
|
||||
RMM_UPDATE_MANIFEST_URL=https://benya9669.github.io/openwrt-rmm/update-manifest.json
|
||||
RMM_UPDATE_MANIFEST_SIGNATURE_URL=https://benya9669.github.io/openwrt-rmm/update-manifest.sig
|
||||
RMM_UPDATE_MANIFEST_PUBLIC_KEY=/app/keys/rmm-openwrt.pem
|
||||
```
|
||||
|
||||
Do not commit this token. After deployment, each user enables Telegram and enters their
|
||||
@@ -62,7 +66,7 @@ For a production deployment, pin the server release in `.env` and use the releas
|
||||
overlay. This pulls the server and tunnel images published by the same `server-v*` tag:
|
||||
|
||||
```dotenv
|
||||
RMM_RELEASE_VERSION=0.8.1
|
||||
RMM_RELEASE_VERSION=0.9.0
|
||||
```
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -13,6 +13,38 @@ manual legacy workflow described below. The default public base URL is:
|
||||
https://benya9669.github.io/openwrt-rmm/feeds/stable/openwrt
|
||||
```
|
||||
|
||||
The same Pages deployment publishes the stable update metadata:
|
||||
|
||||
```text
|
||||
https://benya9669.github.io/openwrt-rmm/update-manifest.json
|
||||
https://benya9669.github.io/openwrt-rmm/update-manifest.sig
|
||||
https://benya9669.github.io/openwrt-rmm/update-manifest.sigstore.json
|
||||
```
|
||||
|
||||
The manifest declares the stable agent version and compatible OpenWrt feed directories.
|
||||
Both current and legacy workflows sign it with the existing APK package key for runtime
|
||||
server verification and keylessly with Sigstore for workflow identity/provenance. Both
|
||||
signatures are verified before deploying Pages.
|
||||
|
||||
Verify the package-key signature:
|
||||
|
||||
```sh
|
||||
openssl dgst -sha256 \
|
||||
-verify rmm-openwrt.pem \
|
||||
-signature update-manifest.sig \
|
||||
update-manifest.json
|
||||
```
|
||||
|
||||
Verify the Sigstore identity:
|
||||
|
||||
```sh
|
||||
cosign verify-blob \
|
||||
--bundle update-manifest.sigstore.json \
|
||||
--certificate-identity-regexp '^https://github.com/Benya9669/openwrt-rmm/\.github/workflows/build(-legacy)?\.yml@refs/(tags/agent-v|heads/)' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
update-manifest.json
|
||||
```
|
||||
|
||||
Before the first release, open **Repository settings → Pages** and select **GitHub
|
||||
Actions** as the deployment source. A custom domain such as `packages.daemonlord.ru`
|
||||
can be attached later in the same Pages settings; keep the GitHub Pages URL available
|
||||
|
||||
+38
-21
@@ -1,24 +1,41 @@
|
||||
# Recent Progress
|
||||
|
||||
- [x] Roadmap and engineering/UI checklists synchronized with the implemented product.
|
||||
- [x] Per-user notification settings and alert thresholds.
|
||||
- [x] E-mail and Telegram alert delivery with lifecycle deduplication and optional repeats.
|
||||
- [x] Persistent delivery history, masked destinations, test send and profile UI.
|
||||
- [x] Durable notification queue with restart recovery, leased claims, exponential retry,
|
||||
dead-letter state and terminal-history retention.
|
||||
Updated: 2026-07-31.
|
||||
|
||||
- [x] Persistent alert state with `active`, `acknowledged`, and `resolved`.
|
||||
- [x] Alert acknowledge API and UI action.
|
||||
- [x] WAN connectivity probes from agent heartbeat.
|
||||
- [x] Latency and packet loss alert rules.
|
||||
- [x] Device list alert counters and `Alerts` fleet filter.
|
||||
- [x] Fleet search/group/tag filters in UI.
|
||||
- [x] Bulk command API and filtered bulk command UI action.
|
||||
- [x] Remote SSH session API, SQLite state, audit events, and UI panel.
|
||||
- [x] Agent `remote_ssh_reverse` command using OpenSSH or Dropbear `dbclient`.
|
||||
- [x] Remote access docs updated for future Docker/Linux deployment.
|
||||
- [x] Docker Compose stack for RMM server and reverse SSH tunnel endpoint.
|
||||
- [x] Persistent Compose volumes for SQLite, SSH host keys, and router tunnel key.
|
||||
- [x] Operator username/password login with signed `HttpOnly` browser sessions.
|
||||
- [x] Caddy HTTPS reverse proxy Compose overlay.
|
||||
- [x] NPMplus shared-network Compose overlay and deployment guide.
|
||||
## Implemented in `main`
|
||||
|
||||
- Go agent `0.6.8` with stable lock cleanup, tunnel endpoint validation and active LAN
|
||||
client probes.
|
||||
- OpenWrt IPK/APK packaging for the current matrix plus a manual legacy tier.
|
||||
- LuCI application with English as the default language and optional
|
||||
`luci-i18n-rmm-agent-ru`.
|
||||
- Signed OpenWrt package feeds, GitHub Pages publishing, package provenance and
|
||||
server/tunnel image SBOM/Cosign support.
|
||||
- Signed stable update metadata with package compatibility, server-side signature
|
||||
verification and Semantic Version comparison in the dashboard.
|
||||
- Notification delivery through e-mail, Telegram and signed webhooks.
|
||||
- Contact verification, quiet hours/timezone, maintenance pause and per-device overrides.
|
||||
- Durable delivery queue with lease recovery, retries, dead-letter and retention.
|
||||
- Built-in notification center with unread state, incident grouping and SSE refresh.
|
||||
- Persistent LAN client presence with online, recent and unconfirmed states.
|
||||
- Cloud-only wildcard router addressing; legacy DirectDNS routes are removed.
|
||||
|
||||
## Release state
|
||||
|
||||
- `server-v0.8.1` is published but predates the latest notification center and LAN client
|
||||
changes.
|
||||
- `agent-v0.6.8` is a signed tag; its complete current OpenWrt matrix and Pages deployment
|
||||
finished successfully.
|
||||
- `server-v0.9.0` is being prepared to include the current `main`, use a signed immutable tag and
|
||||
be validated against a copy of the production SQLite database.
|
||||
|
||||
## Next
|
||||
|
||||
The authoritative development order is maintained in `ROADMAP.md`. Immediate work is:
|
||||
|
||||
1. finish and verify the `agent-v0.6.8` package release;
|
||||
2. publish and deploy a signed server release containing the current `main`;
|
||||
3. complete production notification, LAN-client and tunnel smoke tests;
|
||||
4. add notification operational metrics;
|
||||
5. move per-device tunnel credentials and signed/replay-protected commands ahead of
|
||||
backup/restore and remote update work.
|
||||
|
||||
@@ -44,6 +44,5 @@ Select HTTP or HTTPS when opening the session to match the router's LuCI listene
|
||||
## Next Hardening
|
||||
|
||||
- Per-session SSH keys.
|
||||
- Dedicated tunnel sidecar container.
|
||||
- Server-side active tunnel health checks.
|
||||
- Browser terminal proxy after the tunnel endpoint is reliable.
|
||||
|
||||
+6
-2
@@ -35,6 +35,9 @@ operators.
|
||||
- The pending queue stores the full destination because the worker needs it for delivery.
|
||||
Treat the SQLite volume and its backups as sensitive data; the browser receives only a
|
||||
masked destination. Storage encryption and access control remain deployment concerns.
|
||||
- The stable agent version is accepted from the remote update manifest only after an
|
||||
ECDSA signature verifies against the public package key bundled with the server image.
|
||||
The dashboard performs Semantic Version comparison and does not recommend downgrades.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
@@ -61,5 +64,6 @@ operators.
|
||||
- Add organization-level tenancy, recovery codes/MFA and external identity-provider
|
||||
integration before offering the service as public SaaS.
|
||||
- Add distributed rate limiting when running more than one server instance.
|
||||
- Add an automated SBOM/dependency scan and build signed, immutable container/package
|
||||
artifacts in CI.
|
||||
- Add automated dependency/container vulnerability scanning and policy checks for the
|
||||
SBOM already generated by CI.
|
||||
- Encrypt webhook secrets and pending notification destinations at the application layer.
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
"name": "rmm-openwrt",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:web": "node --check web/app.js && node --check web/landing-motion.js"
|
||||
"check:web": "node --check web/app.js && node --check web/landing-motion.js && node --check web/version.js && node web/version.test.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -p
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$output_dir/feeds/$agent_version/openwrt" "$output_dir/keys/usign" "$output_dir/keys/apk"
|
||||
manifest_entries="$output_dir/.update-manifest-entries"
|
||||
: > "$manifest_entries"
|
||||
|
||||
artifact_count=0
|
||||
for artifact_dir in "$source_dir"/openwrt-*; do
|
||||
@@ -40,10 +42,29 @@ for artifact_dir in "$source_dir"/openwrt-*; do
|
||||
echo "cannot parse artifact name: $artifact_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$openwrt_release:$target_label" in
|
||||
*[!0-9A-Za-z._:-]*)
|
||||
echo "artifact name contains unsupported manifest characters: $artifact_name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
destination="$output_dir/feeds/$agent_version/openwrt/$openwrt_release/$target_label"
|
||||
mkdir -p "$destination"
|
||||
cp "$artifact_dir"/* "$destination/"
|
||||
package_format="ipk"
|
||||
if find "$artifact_dir" -maxdepth 1 -type f -name '*.apk' -print -quit | grep -q .; then
|
||||
package_format="apk"
|
||||
fi
|
||||
if [ -s "$manifest_entries" ]; then
|
||||
printf ',\n' >> "$manifest_entries"
|
||||
fi
|
||||
printf ' {"openwrt_release":"%s","target":"%s","format":"%s","feed_url":"%s"}' \
|
||||
"$openwrt_release" \
|
||||
"$target_label" \
|
||||
"$package_format" \
|
||||
"https://benya9669.github.io/openwrt-rmm/feeds/stable/openwrt/$openwrt_release/$target_label" \
|
||||
>> "$manifest_entries"
|
||||
|
||||
while IFS= read -r public_key; do
|
||||
cp "$public_key" "$output_dir/keys/usign/$(basename "$public_key")"
|
||||
@@ -65,6 +86,25 @@ mkdir -p "$output_dir/feeds/stable"
|
||||
cp -a "$output_dir/feeds/$agent_version/." "$output_dir/feeds/stable/"
|
||||
touch "$output_dir/.nojekyll"
|
||||
|
||||
cat > "$output_dir/update-manifest.json" <<EOF
|
||||
{
|
||||
"schema": 1,
|
||||
"channel": "stable",
|
||||
"agent": {
|
||||
"version": "${agent_version}",
|
||||
"release_url": "https://github.com/Benya9669/openwrt-rmm/releases/tag/agent-v${agent_version}",
|
||||
"feed_base_url": "https://benya9669.github.io/openwrt-rmm/feeds/stable/openwrt"
|
||||
},
|
||||
"packages": [
|
||||
EOF
|
||||
cat "$manifest_entries" >> "$output_dir/update-manifest.json"
|
||||
printf '\n' >> "$output_dir/update-manifest.json"
|
||||
cat >> "$output_dir/update-manifest.json" <<EOF
|
||||
]
|
||||
}
|
||||
EOF
|
||||
rm -f "$manifest_entries"
|
||||
|
||||
cat > "$output_dir/index.html" <<EOF
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
|
||||
@@ -28,6 +28,11 @@ Environment variables:
|
||||
- `RMM_TELEGRAM_BOT_TOKEN` - optional Telegram bot used for per-user alert delivery
|
||||
- `RMM_NOTIFICATION_MAX_ATTEMPTS` - delivery attempt limit, default `5`
|
||||
- `RMM_NOTIFICATION_RETENTION_DAYS` - terminal delivery history retention, default `90`
|
||||
- `RMM_STABLE_AGENT_VERSION` - stable agent version reported to the dashboard
|
||||
- `RMM_UPDATE_MANIFEST_URL` - public URL of the signed stable agent update manifest
|
||||
- `RMM_UPDATE_MANIFEST_SIGNATURE_URL` - detached ECDSA signature URL
|
||||
- `RMM_UPDATE_MANIFEST_PUBLIC_KEY` - PEM public key used to verify the manifest before
|
||||
accepting a new stable version
|
||||
- `RMM_COOKIE_SECURE` - defaults to `true` outside explicit development mode
|
||||
- `RMM_DEVICE_DOMAIN` - wildcard device domain, for example `routers.example.com`
|
||||
- `RMM_ALLOW_LEGACY_ENROLLMENT` - opt-in shared enrollment compatibility mode
|
||||
@@ -63,6 +68,7 @@ Operator API:
|
||||
- `PATCH /api/auth/profile`
|
||||
- `POST /api/auth/change-password`
|
||||
- `POST /api/auth/logout-all`
|
||||
- `GET /api/meta`
|
||||
- `GET|PUT /api/notifications/settings`
|
||||
- `GET /api/notifications`
|
||||
- `POST /api/notifications/test`
|
||||
|
||||
@@ -12,6 +12,13 @@ import (
|
||||
|
||||
"rmm-openwrt/server/internal/httpapi"
|
||||
"rmm-openwrt/server/internal/store"
|
||||
"rmm-openwrt/server/internal/updateinfo"
|
||||
)
|
||||
|
||||
var (
|
||||
serverVersion = "dev"
|
||||
serverRevision = "unknown"
|
||||
stableAgentVersion = "0.6.8"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -81,6 +88,33 @@ func main() {
|
||||
}
|
||||
telegramSender = sender
|
||||
}
|
||||
manifestURL := env("RMM_UPDATE_MANIFEST_URL", "https://benya9669.github.io/openwrt-rmm/update-manifest.json")
|
||||
manifestSignatureURL := env("RMM_UPDATE_MANIFEST_SIGNATURE_URL", "https://benya9669.github.io/openwrt-rmm/update-manifest.sig")
|
||||
stableAgentFallback := env("RMM_STABLE_AGENT_VERSION", stableAgentVersion)
|
||||
var stableAgentVersionProvider func() string
|
||||
manifestPublicKey := strings.TrimSpace(os.Getenv("RMM_UPDATE_MANIFEST_PUBLIC_KEY"))
|
||||
if manifestPublicKey != "" {
|
||||
resolver, resolverErr := updateinfo.NewResolver(
|
||||
manifestURL,
|
||||
manifestSignatureURL,
|
||||
manifestPublicKey,
|
||||
stableAgentFallback,
|
||||
)
|
||||
if resolverErr != nil {
|
||||
log.Fatalf("invalid update manifest configuration: %v", resolverErr)
|
||||
}
|
||||
go func() {
|
||||
refreshContext, cancelRefresh := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelRefresh()
|
||||
if refreshErr := resolver.Refresh(refreshContext); refreshErr != nil {
|
||||
log.Printf("using stable agent fallback %s: %v", stableAgentFallback, refreshErr)
|
||||
}
|
||||
}()
|
||||
go resolver.Run(context.Background(), 15*time.Minute, func(refreshErr error) {
|
||||
log.Printf("update manifest refresh failed: %v", refreshErr)
|
||||
})
|
||||
stableAgentVersionProvider = resolver.Version
|
||||
}
|
||||
|
||||
st, err := store.OpenSQLite(context.Background(), dbPath)
|
||||
if err != nil {
|
||||
@@ -93,25 +127,30 @@ func main() {
|
||||
go maintenanceLoop(st)
|
||||
|
||||
handler := httpapi.NewHandler(st, httpapi.Config{
|
||||
EnrollmentToken: enrollmentToken,
|
||||
AllowLegacyEnrollment: allowLegacyEnrollment,
|
||||
AllowLegacyLuCIProxy: envBool("RMM_ALLOW_LEGACY_LUCI_PROXY", false),
|
||||
OperatorToken: operatorToken,
|
||||
OperatorUsername: env("RMM_OPERATOR_USERNAME", "admin"),
|
||||
OperatorPassword: operatorPassword,
|
||||
CookieSecure: envBool("RMM_COOKIE_SECURE", !insecureDevMode),
|
||||
TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"),
|
||||
TunnelPublicHost: strings.TrimSpace(os.Getenv("RMM_TUNNEL_PUBLIC_HOST")),
|
||||
TunnelPublicPort: envInt("RMM_TUNNEL_PUBLIC_PORT", 2222, 1, 65535),
|
||||
DeviceDomain: strings.TrimSpace(os.Getenv("RMM_DEVICE_DOMAIN")),
|
||||
PublicScheme: env("RMM_PUBLIC_SCHEME", "https"),
|
||||
PublicURL: publicURL,
|
||||
PasswordResetSender: passwordResetSender,
|
||||
AlertEmailSender: alertEmailSender,
|
||||
TelegramSender: telegramSender,
|
||||
BackgroundTasks: true,
|
||||
NotificationMaxAttempts: envInt("RMM_NOTIFICATION_MAX_ATTEMPTS", 5, 1, 20),
|
||||
StaticDir: env("RMM_WEB_DIR", "web"),
|
||||
EnrollmentToken: enrollmentToken,
|
||||
AllowLegacyEnrollment: allowLegacyEnrollment,
|
||||
AllowLegacyLuCIProxy: envBool("RMM_ALLOW_LEGACY_LUCI_PROXY", false),
|
||||
OperatorToken: operatorToken,
|
||||
OperatorUsername: env("RMM_OPERATOR_USERNAME", "admin"),
|
||||
OperatorPassword: operatorPassword,
|
||||
CookieSecure: envBool("RMM_COOKIE_SECURE", !insecureDevMode),
|
||||
TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"),
|
||||
TunnelPublicHost: strings.TrimSpace(os.Getenv("RMM_TUNNEL_PUBLIC_HOST")),
|
||||
TunnelPublicPort: envInt("RMM_TUNNEL_PUBLIC_PORT", 2222, 1, 65535),
|
||||
DeviceDomain: strings.TrimSpace(os.Getenv("RMM_DEVICE_DOMAIN")),
|
||||
PublicScheme: env("RMM_PUBLIC_SCHEME", "https"),
|
||||
PublicURL: publicURL,
|
||||
PasswordResetSender: passwordResetSender,
|
||||
AlertEmailSender: alertEmailSender,
|
||||
TelegramSender: telegramSender,
|
||||
BackgroundTasks: true,
|
||||
NotificationMaxAttempts: envInt("RMM_NOTIFICATION_MAX_ATTEMPTS", 5, 1, 20),
|
||||
StaticDir: env("RMM_WEB_DIR", "web"),
|
||||
ServerVersion: serverVersion,
|
||||
ServerRevision: serverRevision,
|
||||
StableAgentVersion: stableAgentFallback,
|
||||
StableAgentVersionProvider: stableAgentVersionProvider,
|
||||
UpdateManifestURL: manifestURL,
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -123,6 +123,11 @@ type Config struct {
|
||||
NotificationMaxAttempts int
|
||||
NotificationWorkerInterval time.Duration
|
||||
StaticDir string
|
||||
ServerVersion string
|
||||
ServerRevision string
|
||||
StableAgentVersion string
|
||||
StableAgentVersionProvider func() string
|
||||
UpdateManifestURL string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
@@ -144,6 +149,11 @@ type App struct {
|
||||
telegramSender NotificationSender
|
||||
notificationMaxAttempts int
|
||||
notificationWorkerInterval time.Duration
|
||||
serverVersion string
|
||||
serverRevision string
|
||||
stableAgentVersion string
|
||||
stableAgentVersionProvider func() string
|
||||
updateManifestURL string
|
||||
loginLimiter *loginRateLimiter
|
||||
passwordResetLimiter *loginRateLimiter
|
||||
loginSlots chan struct{}
|
||||
@@ -313,6 +323,11 @@ func NewHandler(s Store, cfg Config) http.Handler {
|
||||
telegramSender: cfg.TelegramSender,
|
||||
notificationMaxAttempts: cfg.NotificationMaxAttempts,
|
||||
notificationWorkerInterval: cfg.NotificationWorkerInterval,
|
||||
serverVersion: strings.TrimSpace(cfg.ServerVersion),
|
||||
serverRevision: strings.TrimSpace(cfg.ServerRevision),
|
||||
stableAgentVersion: strings.TrimSpace(cfg.StableAgentVersion),
|
||||
stableAgentVersionProvider: cfg.StableAgentVersionProvider,
|
||||
updateManifestURL: strings.TrimSpace(cfg.UpdateManifestURL),
|
||||
loginLimiter: newLoginRateLimiter(5, 5*time.Minute),
|
||||
passwordResetLimiter: newLoginRateLimiter(3, time.Hour),
|
||||
loginSlots: make(chan struct{}, 4),
|
||||
@@ -324,6 +339,12 @@ func NewHandler(s Store, cfg Config) http.Handler {
|
||||
if a.notificationWorkerInterval <= 0 {
|
||||
a.notificationWorkerInterval = 30 * time.Second
|
||||
}
|
||||
if a.serverVersion == "" {
|
||||
a.serverVersion = "dev"
|
||||
}
|
||||
if a.serverRevision == "" {
|
||||
a.serverRevision = "unknown"
|
||||
}
|
||||
if a.tunnelHTTPHost == "" {
|
||||
a.tunnelHTTPHost = "tunnel-ssh"
|
||||
}
|
||||
@@ -352,6 +373,7 @@ func NewHandler(s Store, cfg Config) http.Handler {
|
||||
mux.Handle("PATCH /api/auth/profile", a.operatorAuth(http.HandlerFunc(a.handleUpdateProfile)))
|
||||
mux.Handle("POST /api/auth/change-password", a.operatorAuth(http.HandlerFunc(a.handleChangePassword)))
|
||||
mux.Handle("POST /api/auth/logout-all", a.operatorAuth(http.HandlerFunc(a.handleLogoutAll)))
|
||||
mux.Handle("GET /api/meta", a.operatorAuth(http.HandlerFunc(a.handleReleaseMetadata)))
|
||||
mux.Handle("GET /api/notifications/settings", a.operatorAuth(http.HandlerFunc(a.handleGetNotificationSettings)))
|
||||
mux.Handle("PUT /api/notifications/settings", a.operatorAuth(http.HandlerFunc(a.handleUpdateNotificationSettings)))
|
||||
mux.Handle("GET /api/notifications", a.operatorAuth(http.HandlerFunc(a.handleListNotifications)))
|
||||
@@ -430,6 +452,22 @@ func (a *App) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, enrollResponse{DeviceID: enrolled.DeviceID, DeviceToken: enrolled.DeviceToken})
|
||||
}
|
||||
|
||||
func (a *App) handleReleaseMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
stableVersion := a.stableAgentVersion
|
||||
if a.stableAgentVersionProvider != nil {
|
||||
if resolved := strings.TrimSpace(a.stableAgentVersionProvider()); resolved != "" {
|
||||
stableVersion = resolved
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"server_version": a.serverVersion,
|
||||
"server_revision": a.serverRevision,
|
||||
"stable_agent_version": stableVersion,
|
||||
"update_manifest_url": a.updateManifestURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
var req heartbeatRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
@@ -2311,9 +2349,13 @@ func staticHandler(dir string) http.Handler {
|
||||
fullPath := filepath.Join(dir, filepath.FromSlash(assetPath))
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
if strings.HasSuffix(assetPath, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, fullPath)
|
||||
} else {
|
||||
switch strings.ToLower(filepath.Ext(assetPath)) {
|
||||
case ".js", ".css", ".json":
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
fs.ServeHTTP(w, r)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -648,6 +648,9 @@ func TestServesStaticWebUI(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "landing.html"), []byte("<!doctype html><title>RMM Landing</title>"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('rmm')"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
@@ -675,6 +678,9 @@ func TestServesStaticWebUI(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(data), "RMM Landing") {
|
||||
t.Fatalf("unexpected static response %d: %s", resp.StatusCode, data)
|
||||
}
|
||||
if cacheControl := resp.Header.Get("Cache-Control"); cacheControl != "no-store" {
|
||||
t.Fatalf("HTML must not be cached across releases, got %q", cacheControl)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/login", "/app"} {
|
||||
appResp, err := http.Get(srv.URL + path)
|
||||
@@ -691,6 +697,15 @@ func TestServesStaticWebUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
assetResp, err := http.Get(srv.URL + "/app.js?v=29")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = assetResp.Body.Close()
|
||||
if assetResp.StatusCode != http.StatusOK || assetResp.Header.Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("unexpected asset cache policy: status=%d cache=%q", assetResp.StatusCode, assetResp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
missingResp, err := http.Get(srv.URL + "/missing-page")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -701,6 +716,44 @@ func TestServesStaticWebUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMetadataRequiresAuthentication(t *testing.T) {
|
||||
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "metadata.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
|
||||
OperatorToken: "operator-test",
|
||||
ServerVersion: "0.9.0",
|
||||
ServerRevision: "abc123",
|
||||
StableAgentVersion: "0.6.8",
|
||||
StableAgentVersionProvider: func() string {
|
||||
return "0.6.9"
|
||||
},
|
||||
UpdateManifestURL: "https://packages.example.test/update-manifest.json",
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
unauthorized, err := http.Get(srv.URL + "/api/meta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = unauthorized.Body.Close()
|
||||
if unauthorized.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected unauthenticated metadata status: %d", unauthorized.StatusCode)
|
||||
}
|
||||
|
||||
var metadata map[string]string
|
||||
requestJSON(t, http.MethodGet, srv.URL+"/api/meta", "operator-test", nil, http.StatusOK, &metadata)
|
||||
if metadata["server_version"] != "0.9.0" ||
|
||||
metadata["server_revision"] != "abc123" ||
|
||||
metadata["stable_agent_version"] != "0.6.9" ||
|
||||
metadata["update_manifest_url"] != "https://packages.example.test/update-manifest.json" {
|
||||
t.Fatalf("unexpected release metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveEventsRequireAuthenticationAndStream(t *testing.T) {
|
||||
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "events.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package updateinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxManifestSize = 1 << 20
|
||||
|
||||
var stableVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
type Manifest struct {
|
||||
Schema int `json:"schema"`
|
||||
Channel string `json:"channel"`
|
||||
Agent struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"agent"`
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
manifestURL string
|
||||
signatureURL string
|
||||
publicKey *ecdsa.PublicKey
|
||||
client *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
version string
|
||||
}
|
||||
|
||||
func NewResolver(manifestURL, signatureURL, publicKeyPath, fallback string) (*Resolver, error) {
|
||||
publicKeyData, err := os.ReadFile(publicKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read update manifest public key: %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(publicKeyData)
|
||||
if block == nil {
|
||||
return nil, errors.New("decode update manifest public key: PEM block is missing")
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse update manifest public key: %w", err)
|
||||
}
|
||||
publicKey, ok := parsed.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("update manifest public key is not ECDSA")
|
||||
}
|
||||
manifestURL = strings.TrimSpace(manifestURL)
|
||||
signatureURL = strings.TrimSpace(signatureURL)
|
||||
if manifestURL == "" || signatureURL == "" {
|
||||
return nil, errors.New("update manifest and signature URLs are required")
|
||||
}
|
||||
return &Resolver{
|
||||
manifestURL: manifestURL, signatureURL: signatureURL, publicKey: publicKey,
|
||||
version: fallback,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) Version() string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.version
|
||||
}
|
||||
|
||||
func (r *Resolver) Refresh(ctx context.Context) error {
|
||||
manifestData, err := r.fetch(ctx, r.manifestURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download update manifest: %w", err)
|
||||
}
|
||||
signature, err := r.fetch(ctx, r.signatureURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download update manifest signature: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(manifestData)
|
||||
if !ecdsa.VerifyASN1(r.publicKey, digest[:], signature) {
|
||||
return errors.New("update manifest signature is invalid")
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||
return fmt.Errorf("decode update manifest: %w", err)
|
||||
}
|
||||
version := strings.TrimSpace(manifest.Agent.Version)
|
||||
if manifest.Schema != 1 || manifest.Channel != "stable" || !stableVersionPattern.MatchString(version) {
|
||||
return errors.New("update manifest schema, channel, or agent version is invalid")
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.version = version
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Resolver) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.Refresh(ctx); err != nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) fetch(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected HTTP status %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, maxManifestSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxManifestSize {
|
||||
return nil, errors.New("response is empty or exceeds the size limit")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package updateinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolverAcceptsOnlyVerifiedStableManifest(t *testing.T) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKeyData, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPath := filepath.Join(t.TempDir(), "manifest.pem")
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicKeyData}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manifest := []byte(`{"schema":1,"channel":"stable","agent":{"version":"0.6.9"}}`)
|
||||
digest := sha256.Sum256(manifest)
|
||||
signature, err := ecdsa.SignASN1(rand.Reader, privateKey, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(manifest) })
|
||||
mux.HandleFunc("/manifest.sig", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(signature) })
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
resolver, err := NewResolver(server.URL+"/manifest.json", server.URL+"/manifest.sig", keyPath, "0.6.8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := resolver.Refresh(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolver.Version() != "0.6.9" {
|
||||
t.Fatalf("unexpected verified version %q", resolver.Version())
|
||||
}
|
||||
|
||||
manifest = []byte(`{"schema":1,"channel":"stable","agent":{"version":"9.9.9"}}`)
|
||||
if err := resolver.Refresh(context.Background()); err == nil {
|
||||
t.Fatal("tampered manifest was accepted")
|
||||
}
|
||||
if resolver.Version() != "0.6.9" {
|
||||
t.Fatalf("last verified version was lost: %q", resolver.Version())
|
||||
}
|
||||
}
|
||||
+37
-8
@@ -36,13 +36,13 @@ const state = {
|
||||
previousMobileRoute: "fleet",
|
||||
liveConnected: false,
|
||||
lastUpdatedAt: null,
|
||||
releaseMetadata: null,
|
||||
releaseMetadataCheckedAt: 0,
|
||||
};
|
||||
|
||||
let eventSource = null;
|
||||
let liveRefreshTimer = null;
|
||||
|
||||
const EXPECTED_AGENT_VERSION = "0.6.8";
|
||||
|
||||
const els = {
|
||||
loginView: document.querySelector("#loginView"),
|
||||
loginForm: document.querySelector("#loginForm"),
|
||||
@@ -522,6 +522,7 @@ async function checkSession() {
|
||||
try {
|
||||
const me = await api("/api/auth/me");
|
||||
showApp(me.user || { username: me.username, role: "user" });
|
||||
await loadReleaseMetadata();
|
||||
await loadDevices();
|
||||
} catch {
|
||||
showLogin();
|
||||
@@ -538,6 +539,7 @@ async function login() {
|
||||
}),
|
||||
});
|
||||
showApp(response.user || { username: response.username, role: "user" });
|
||||
await loadReleaseMetadata();
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
@@ -701,10 +703,35 @@ function deviceAgentVersion(device) {
|
||||
return device && device.inventory && device.inventory.agent_version ? String(device.inventory.agent_version) : "";
|
||||
}
|
||||
|
||||
function stableAgentVersion() {
|
||||
return state.releaseMetadata && state.releaseMetadata.stable_agent_version
|
||||
? String(state.releaseMetadata.stable_agent_version)
|
||||
: "";
|
||||
}
|
||||
|
||||
function agentVersionState(version) {
|
||||
const stable = stableAgentVersion();
|
||||
if (!version || !stable || !globalThis.RMMVersions) return { comparison: null, stable };
|
||||
return { comparison: globalThis.RMMVersions.compareSemVer(version, stable), stable };
|
||||
}
|
||||
|
||||
function agentVersionLabel(version) {
|
||||
if (!version) return "Версия не определена";
|
||||
if (version === EXPECTED_AGENT_VERSION) return `${version} · актуальная`;
|
||||
return `${version} · доступна ${EXPECTED_AGENT_VERSION}`;
|
||||
const versionState = agentVersionState(version);
|
||||
if (versionState.comparison === -1) return `${version} · доступна ${versionState.stable}`;
|
||||
if (versionState.comparison === 0) return `${version} · актуальная`;
|
||||
if (versionState.comparison === 1) return `${version} · новее стабильной ${versionState.stable}`;
|
||||
return `${version} · стабильная версия не определена`;
|
||||
}
|
||||
|
||||
async function loadReleaseMetadata(force = false) {
|
||||
if (!force && Date.now() - state.releaseMetadataCheckedAt < 5 * 60 * 1000) return;
|
||||
state.releaseMetadataCheckedAt = Date.now();
|
||||
try {
|
||||
state.releaseMetadata = await api("/api/meta");
|
||||
} catch {
|
||||
state.releaseMetadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
function deviceClientCount(device) {
|
||||
@@ -1052,10 +1079,11 @@ function renderDeviceStatus(device) {
|
||||
title = "Роутер требует внимания";
|
||||
description = `Активных проблем: ${device.active_alerts}. Подробности находятся ниже в журнале проблем.`;
|
||||
icon = "!";
|
||||
} else if (version && version !== EXPECTED_AGENT_VERSION) {
|
||||
} else if (agentVersionState(version).comparison === -1) {
|
||||
const stable = stableAgentVersion();
|
||||
tone = "warn";
|
||||
title = "Работает, доступно обновление агента";
|
||||
description = `Установлена версия ${version}; актуальная стабильная версия — ${EXPECTED_AGENT_VERSION}.`;
|
||||
description = `Установлена версия ${version}; актуальная стабильная версия — ${stable}.`;
|
||||
icon = "↻";
|
||||
}
|
||||
|
||||
@@ -1071,7 +1099,7 @@ function renderDeviceStatus(device) {
|
||||
? (lastError
|
||||
? `${failures ? "Последняя ошибка" : "Последний восстановленный сбой"}: ${humanizeAgentError(lastError)} · ${formatRelativeTime(errorAt)}`
|
||||
: "Ошибок обмена не зафиксировано")
|
||||
: `Расширенная диагностика появится после обновления агента до ${EXPECTED_AGENT_VERSION}`;
|
||||
: "Расширенная диагностика недоступна в данных последнего heartbeat.";
|
||||
els.agentHealthSummary.innerHTML = `
|
||||
<div><span>Агент</span><strong>${escapeHtml(agentVersionLabel(version))}</strong></div>
|
||||
<div><span>Runtime</span><strong>${escapeHtml(device.inventory && device.inventory.agent_runtime || "неизвестно")}</strong></div>
|
||||
@@ -1381,6 +1409,7 @@ function renderAlerts(alerts) {
|
||||
|
||||
async function loadDevices() {
|
||||
setStatus("Обновление");
|
||||
await loadReleaseMetadata();
|
||||
const data = await api("/api/devices");
|
||||
state.devices = data.devices || [];
|
||||
if (state.selectedDeviceId && !currentDevice()) {
|
||||
@@ -2245,7 +2274,7 @@ function prepareCloudAccessPopup(popup) {
|
||||
if (!popup) return;
|
||||
try {
|
||||
popup.document.open();
|
||||
popup.document.write(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подключение к LuCI — OpenWrt RMM</title><link rel="stylesheet" href="/styles.css?v=24"></head><body class="cloud-wait-page"><main><span class="cloud-wait-mark">R</span><p class="eyebrow">Защищённый доступ</p><h1>Подключаемся к LuCI</h1><p>Создаём временный туннель и проверяем ответ роутера. Эта вкладка откроется автоматически.</p><div class="cloud-wait-progress" aria-label="Подключение выполняется"><i></i></div></main></body></html>`);
|
||||
popup.document.write(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подключение к LuCI — OpenWrt RMM</title><link rel="stylesheet" href="/styles.css?v=26"></head><body class="cloud-wait-page"><main><span class="cloud-wait-mark">R</span><p class="eyebrow">Защищённый доступ</p><h1>Подключаемся к LuCI</h1><p>Создаём временный туннель и проверяем ответ роутера. Эта вкладка откроется автоматически.</p><div class="cloud-wait-progress" aria-label="Подключение выполняется"><i></i></div></main></body></html>`);
|
||||
popup.document.close();
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
|
||||
+4
-3
@@ -11,7 +11,7 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=1">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=1">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="stylesheet" href="/styles.css?v=25">
|
||||
<link rel="stylesheet" href="/styles.css?v=26">
|
||||
</head>
|
||||
<body class="account-page">
|
||||
<main id="loginView" class="landing-view">
|
||||
@@ -992,7 +992,8 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/landing-motion.js?v=1" defer></script>
|
||||
<script src="/app.js?v=28" defer></script>
|
||||
<script src="/landing-motion.js?v=2" defer></script>
|
||||
<script src="/version.js?v=1" defer></script>
|
||||
<script src="/app.js?v=29" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=1">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=1">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="stylesheet" href="/styles.css?v=25">
|
||||
<link rel="stylesheet" href="/styles.css?v=26">
|
||||
</head>
|
||||
<body>
|
||||
<main class="landing-view">
|
||||
@@ -93,6 +93,6 @@
|
||||
<span class="landing-footer-links"><small>Безопасное управление вашей сетью</small><a href="/legal.html">Лицензия и исходный код</a></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="/landing-motion.js?v=1" defer></script>
|
||||
<script src="/landing-motion.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
<meta name="robots" content="noindex,follow">
|
||||
<title>Лицензия и исходный код — OpenWrt RMM</title>
|
||||
<link rel="icon" href="/favicon.svg?v=1" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/styles.css?v=25">
|
||||
<link rel="stylesheet" href="/styles.css?v=26">
|
||||
</head>
|
||||
<body class="legal-body">
|
||||
<header class="landing-header legal-header">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
(function exposeVersionHelpers(root, factory) {
|
||||
const helpers = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = helpers;
|
||||
if (root) root.RMMVersions = helpers;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function createVersionHelpers() {
|
||||
function parseSemVer(value) {
|
||||
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ? match[4].split(".") : [],
|
||||
};
|
||||
}
|
||||
|
||||
function comparePrerelease(left, right) {
|
||||
if (!left.length && !right.length) return 0;
|
||||
if (!left.length) return 1;
|
||||
if (!right.length) return -1;
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const a = left[index];
|
||||
const b = right[index];
|
||||
if (a === undefined) return -1;
|
||||
if (b === undefined) return 1;
|
||||
if (a === b) continue;
|
||||
const aNumber = /^\d+$/.test(a);
|
||||
const bNumber = /^\d+$/.test(b);
|
||||
if (aNumber && bNumber) return Number(a) < Number(b) ? -1 : 1;
|
||||
if (aNumber !== bNumber) return aNumber ? -1 : 1;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareSemVer(leftValue, rightValue) {
|
||||
const left = parseSemVer(leftValue);
|
||||
const right = parseSemVer(rightValue);
|
||||
if (!left || !right) return null;
|
||||
for (const key of ["major", "minor", "patch"]) {
|
||||
if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
|
||||
}
|
||||
return comparePrerelease(left.prerelease, right.prerelease);
|
||||
}
|
||||
|
||||
return { parseSemVer, compareSemVer };
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { compareSemVer, parseSemVer } = require("./version.js");
|
||||
|
||||
assert.deepEqual(parseSemVer("v0.6.8"), {
|
||||
major: 0,
|
||||
minor: 6,
|
||||
patch: 8,
|
||||
prerelease: [],
|
||||
});
|
||||
assert.equal(compareSemVer("0.6.7", "0.6.8"), -1);
|
||||
assert.equal(compareSemVer("0.6.8", "0.6.8"), 0);
|
||||
assert.equal(compareSemVer("0.6.9", "0.6.8"), 1);
|
||||
assert.equal(compareSemVer("0.7.0", "0.6.99"), 1);
|
||||
assert.equal(compareSemVer("0.6.8-rc.1", "0.6.8"), -1);
|
||||
assert.equal(compareSemVer("invalid", "0.6.8"), null);
|
||||
Reference in New Issue
Block a user