release: prepare server 0.9.0

This commit is contained in:
2026-07-31 00:28:22 +03:00
parent a343963bf0
commit f492782aff
34 changed files with 1156 additions and 380 deletions
+4
View File
@@ -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.
+43
View File
@@ -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
View File
@@ -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
+29 -13
View File
@@ -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}"
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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 \
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5 -1
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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.
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+40
View File
@@ -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">
+6
View File
@@ -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`
+58 -19
View File
@@ -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{
+43 -1
View File
@@ -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
+53
View File
@@ -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 {
+144
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+48
View File
@@ -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 };
});
+15
View File
@@ -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);