fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному

Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
This commit is contained in:
2026-09-02 23:24:01 +05:00
parent 8dcb50a07c
commit cb20d8d28f
66 changed files with 4981 additions and 2684 deletions
+271
View File
@@ -0,0 +1,271 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
)
// Разбор журнала проверяется на ФАКТИЧЕСКОМ формате Hysteria 2.12.2.
//
// Записи в тестах не выдуманы: они собираются тем же способом, каким их пишет
// zap с EncoderConfig upstream — `time` числом epoch millis (дробным, потому
// что EpochMillisTimeEncoder делит наносекунды на миллисекунду), `level`
// строчными буквами, `msg` и произвольный структурный контекст рядом.
//
// Прежний разбор падал на КАЖДОЙ такой строке (`cannot unmarshal number into
// Go struct field ... of type string`) и уходил в fallback, показывая оператору
// сырой JSON. То есть `HYSTERIA_LOG_FORMAT=json` в юните был включён, а
// структурой никто не пользовался.
// hysteriaJSONLine собирает запись так же, как её пишет zap Hysteria.
func hysteriaJSONLine(t *testing.T, fields map[string]any) string {
t.Helper()
encoded, err := json.Marshal(fields)
if err != nil {
t.Fatalf("не удалось собрать запись журнала: %v", err)
}
return string(encoded)
}
func journalLine(message string) journalctlLogLine {
return journalctlLogLine{
Message: journalMessage(message),
Priority: "6",
RealtimeTimestamp: "1788321234567000",
}
}
// Главная регрессия: числовое `time` больше не роняет разбор.
func TestParseHysteriaJournalRecordReadsNumericEpochMillis(t *testing.T) {
// Значение дробное намеренно: EpochMillisTimeEncoder печатает float64.
moment := time.Date(2026, 9, 2, 12, 34, 56, 789_012_345, time.Local)
millis := float64(moment.UnixNano()) / float64(time.Millisecond)
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": millis,
"level": "info",
"msg": "server up and running",
})))
if record.Level != "info" {
t.Fatalf("уровень %q, ожидался info", record.Level)
}
if record.Msg != "server up and running" {
t.Fatalf("сообщение %q, ожидался разобранный msg", record.Msg)
}
want := moment.Format("2006-01-02 15:04:05")
if record.Time != want {
t.Fatalf("время %q, ожидалось %q", record.Time, want)
}
// Сырой JSON в сообщение не попадает: это и был прежний исход.
if strings.Contains(record.Msg, "{") {
t.Fatalf("в сообщение уехала сырая запись: %q", record.Msg)
}
}
// Структурный контекст upstream сохраняется: именно он отвечает на вопрос,
// ради которого журнал открывают.
func TestParseHysteriaJournalRecordKeepsStructuredContext(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.1235,
"level": "error",
"msg": "authentication failed",
"addr": "203.0.113.10:51820",
"id": "alpha-auth-id",
"error": "device limited",
"listen": ":443",
})))
for _, want := range []string{
"authentication failed",
"addr=203.0.113.10:51820",
"error=device limited",
"id=alpha-auth-id",
"listen=:443",
} {
if !strings.Contains(record.Msg, want) {
t.Fatalf("контекст %q потерян: %q", want, record.Msg)
}
}
}
// Порядок полей устойчив: карта в Go обходится случайно, и без сортировки одна
// и та же запись выглядела бы по-разному при каждом обновлении страницы.
func TestParseHysteriaJournalRecordContextOrderIsStable(t *testing.T) {
line := journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "info",
"msg": "client connected",
"zulu": "z",
"alpha": "a",
"mike": "m",
}))
first := parseHysteriaJournalRecord(line).Msg
for i := 0; i < 20; i++ {
if got := parseHysteriaJournalRecord(line).Msg; got != first {
t.Fatalf("порядок контекста не устойчив: %q против %q", got, first)
}
}
if !strings.Contains(first, "[alpha=a mike=m zulu=z]") {
t.Fatalf("контекст собран не по алфавиту: %q", first)
}
}
// Числа контекста печатаются человеку, а не в экспоненте.
func TestParseHysteriaJournalRecordFormatsNumbers(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "info",
"msg": "traffic",
"tx": float64(1_234_567_890),
"ratio": 0.5,
"ok": true,
})))
for _, want := range []string{"tx=1234567890", "ratio=0.5", "ok=true"} {
if !strings.Contains(record.Msg, want) {
t.Fatalf("значение %q отформатировано неверно: %q", want, record.Msg)
}
}
}
// Вложенный объект остаётся однострочным и читаемым.
func TestParseHysteriaJournalRecordFlattensNestedFields(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "warn",
"msg": "acl reloaded",
"stats": map[string]any{"rules": float64(12)},
})))
if !strings.Contains(record.Msg, `stats={"rules":12}`) {
t.Fatalf("вложенный объект потерян: %q", record.Msg)
}
if strings.Contains(record.Msg, "\n") {
t.Fatalf("запись журнала стала многострочной: %q", record.Msg)
}
}
// Секреты вырезаются и из сообщения, и из структурного контекста: HY2_AUTH_URL
// несёт access_token, и upstream волен упомянуть его в тексте ошибки.
func TestParseHysteriaJournalRecordSanitizesSecrets(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": 1788321234567.0,
"level": "error",
"msg": "auth backend request failed",
"url": "http://127.0.0.1:8080/internal/hysteria/auth?access_token=super-secret-value",
})))
if strings.Contains(record.Msg, "super-secret-value") {
t.Fatalf("секрет уехал в журнал панели: %q", record.Msg)
}
// Внутри query маркер приезжает percent-кодированным: URL обязан остаться
// валидным URL (см. encodedRedactedPlaceholder в log_sanitize.go).
if !strings.Contains(record.Msg, RedactedPlaceholder) &&
!strings.Contains(record.Msg, encodedRedactedPlaceholder) {
t.Fatalf("секрет вырезан без маркера: %q", record.Msg)
}
// Остальная часть адреса остаётся читаемой: без неё запись перестаёт быть
// диагностикой.
if !strings.Contains(record.Msg, "/internal/hysteria/auth") {
t.Fatalf("адрес вырезан целиком вместо секрета: %q", record.Msg)
}
}
// Не-JSON строка показывается как есть: console-формат и посторонний вывод
// рантайма терять нельзя.
func TestParseHysteriaJournalRecordFallsBackToRawMessage(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine("panic: runtime error: invalid memory address"))
if record.Msg != "panic: runtime error: invalid memory address" {
t.Fatalf("сырое сообщение потеряно: %q", record.Msg)
}
// Время и уровень берутся у journald: у него они есть всегда.
if record.Time == "" {
t.Fatal("время journald не подставлено")
}
if record.Level != "info" {
t.Fatalf("уровень journald не подставлен: %q", record.Level)
}
}
// JSON без `msg` — не запись Hysteria, и терять её тоже нельзя.
func TestParseHysteriaJournalRecordKeepsForeignJSON(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(`{"foo":"bar"}`))
if !strings.Contains(record.Msg, "foo") {
t.Fatalf("посторонний JSON потерян: %q", record.Msg)
}
}
// Запись без собственного времени и уровня добирает их у journald.
func TestParseHysteriaJournalRecordUsesJournaldFallbacks(t *testing.T) {
line := journalLine(hysteriaJSONLine(t, map[string]any{"msg": "no level, no time"}))
line.Priority = "3"
record := parseHysteriaJournalRecord(line)
if record.Level != "error" {
t.Fatalf("уровень не взят из PRIORITY: %q", record.Level)
}
if record.Time != convertJournalTimestamp(line.RealtimeTimestamp) {
t.Fatalf("время не взято из __REALTIME_TIMESTAMP: %q", record.Time)
}
}
// Строковое `time` (console-формат, будущая смена энкодера) принимается как
// есть: переписывать чужой формат продукт не обязан.
func TestParseHysteriaJournalRecordAcceptsStringTime(t *testing.T) {
record := parseHysteriaJournalRecord(journalLine(hysteriaJSONLine(t, map[string]any{
"time": "2026-09-02T12:34:56Z",
"level": "info",
"msg": "server up and running",
})))
if record.Time != "2026-09-02T12:34:56Z" {
t.Fatalf("строковое время подменено: %q", record.Time)
}
}
// MESSAGE, отданный journald массивом байт (сообщение не является корректным
// UTF-8), больше не выбрасывает запись целиком.
func TestJournalMessageAcceptsByteArray(t *testing.T) {
var line journalctlLogLine
// Так journald кодирует «hi\xff» в `-o json`.
raw := `{"MESSAGE":[104,105,255],"PRIORITY":"6","__REALTIME_TIMESTAMP":"1788321234567000"}`
if err := json.Unmarshal([]byte(raw), &line); err != nil {
t.Fatalf("запись с байтовым MESSAGE отвергнута целиком: %v", err)
}
if string(line.Message) != "hi\xff" {
t.Fatalf("байтовое сообщение разобрано неверно: %q", string(line.Message))
}
if line.Priority != "6" {
t.Fatalf("остальные поля записи потеряны: %+v", line)
}
}
func TestJournalMessageAcceptsString(t *testing.T) {
var line journalctlLogLine
if err := json.Unmarshal([]byte(`{"MESSAGE":"plain","PRIORITY":"4"}`), &line); err != nil {
t.Fatalf("обычная запись отвергнута: %v", err)
}
if string(line.Message) != "plain" {
t.Fatalf("сообщение разобрано неверно: %q", string(line.Message))
}
}
// Отсутствующее или неизвестной формы MESSAGE не роняет запись: у неё остаются
// время и приоритет.
func TestJournalMessageSurvivesUnknownShape(t *testing.T) {
var line journalctlLogLine
if err := json.Unmarshal([]byte(`{"MESSAGE":{"unexpected":true},"PRIORITY":"6"}`), &line); err != nil {
t.Fatalf("запись с неизвестной формой MESSAGE отвергнута: %v", err)
}
if string(line.Message) != "" {
t.Fatalf("неизвестная форма превратилась в сообщение: %q", string(line.Message))
}
}