fix(admin): достроить вторые половины отзыва доступа, лимита и журнала

Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
This commit is contained in:
2026-09-01 17:17:17 +05:00
parent c0a43ae915
commit 162759c599
37 changed files with 3072 additions and 343 deletions
+43
View File
@@ -2,9 +2,11 @@ package controller
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
@@ -37,6 +39,46 @@ func newPeerControllerDB(t *testing.T) {
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
// answerTrafficStats поднимает минимальный Traffic Stats API Hysteria.
//
// Нужен там, где тест доходит до service.Hysteria2Auth: проверка лимита
// устройств теперь fail-closed, и подключение без ответа Traffic Stats API
// отклоняется (см. AUTH-01/AUTH-02). Наблюдений этот ответчик не ведёт —
// контракт `/kick` и `/online` доказывается в пакете service; здесь он лишь
// делает вопрос «сколько устройств онлайн» отвечаемым.
func answerTrafficStats(t *testing.T) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/online" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{}"))
}))
t.Cleanup(server.Close)
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", server.URL, err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
content := "listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:" + port + "\"\n secret: \"test-secret\"\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
}
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = path
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, "test-secret"); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
}
// peerPayload — тело создания пира со всеми обязательными полями.
// Тесты меняют в нём ровно то, что проверяют.
func peerPayload(name string) map[string]any {
@@ -92,6 +134,7 @@ func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
for label, mutate := range cases {
t.Run(label, func(t *testing.T) {
newPeerControllerDB(t)
answerTrafficStats(t)
body := peerPayload("client-01")
mutate(body)