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:
@@ -1663,7 +1663,7 @@ run_access_revocation_acceptance() {
|
||||
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
|
||||
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
|
||||
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
|
||||
! code_has apps/service/cron.go -F -- 'Hysteria2IsRunning' \
|
||||
! code_has apps/service/cron.go -E -- 'Hysteria2(IsRunning|ServiceState)\(' \
|
||||
|| fail "acceptance: the account cron job must not gate on the systemd state"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
@@ -1675,7 +1675,7 @@ run_access_revocation_acceptance() {
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (body.includes("Hysteria2IsRunning")) {
|
||||
if (/Hysteria2(IsRunning|ServiceState)\(|hysteriaServiceState\(/.test(body)) {
|
||||
throw new Error("auth gates on the systemd state");
|
||||
}
|
||||
' || fail "acceptance: auth must not gate on the systemd state"
|
||||
@@ -1721,7 +1721,21 @@ run_access_revocation_acceptance() {
|
||||
// Замок обязан быть взят ДО чтения статистики: взятый после него он
|
||||
// защищал бы только резервацию, а переупорядочиваются именно снимки.
|
||||
if (gate > online) throw new Error("the admission gate is taken after the /online read");
|
||||
if (!/defer\s+\w+\(\)/.test(body)) throw new Error("the admission gate is never released");
|
||||
// Освобождение проверяется ИМЕННО ЭТОГО замка.
|
||||
//
|
||||
// Здесь стояло `/defer\s+\w+\(\)/`, то есть «в функции есть какой-нибудь
|
||||
// отложенный вызов». Такой гейт пережил бы
|
||||
//
|
||||
// unlockAdmission := lockPeerAdmission(...)
|
||||
// defer someOtherCleanup()
|
||||
//
|
||||
// — то есть замок, который не отпускается никогда. Имя переменной берётся
|
||||
// из самого присваивания, поэтому её переименование гейт не ломает.
|
||||
const binding = body.match(/(\w+)\s*:=\s*lockPeerAdmission\(/);
|
||||
if (!binding) throw new Error("the admission gate result is not bound to a variable");
|
||||
if (!new RegExp("defer\\s+" + binding[1] + "\\(\\)").test(body)) {
|
||||
throw new Error("the admission gate is never released");
|
||||
}
|
||||
// Замок именно по authId. Литеральный ключ означал бы один замок на
|
||||
// процесс, то есть очередь из подключений ВСЕХ пиров за одним HTTP-обменом.
|
||||
if (!/lockPeerAdmission\(\*peer\.AuthId\)/.test(body)) {
|
||||
@@ -1947,7 +1961,7 @@ run_access_revocation_acceptance() {
|
||||
}
|
||||
' || fail "acceptance: the device limit must be fail-closed"
|
||||
|
||||
code_has apps/service/hysteria2.go -F -- 'var hysteria2IsRunning' \
|
||||
code_has apps/service/hysteria2.go -F -- 'var hysteriaServiceState' \
|
||||
|| fail "acceptance: the systemd state seam is missing, so fail-closed is untestable"
|
||||
|
||||
log_step "Acceptance: the public endpoint error names no transport"
|
||||
@@ -1955,6 +1969,222 @@ run_access_revocation_acceptance() {
|
||||
# сообщение заведомо ложную семантику.
|
||||
! code_has apps/service/hysteria2_api.go -F -- 'must be a valid TCP port' \
|
||||
|| fail "acceptance: the public port error must not claim a TCP transport"
|
||||
|
||||
log_step "Acceptance: rotating a credential rotates the session identity"
|
||||
# Отзыв секрета состоит из двух шагов — записать новый digest и завершить
|
||||
# старые сессии. Второй шаг может не удаться, и это нормально: сходимость
|
||||
# обязан обеспечить cron. Но сверять ему было нечем, пока `auth_id`
|
||||
# оставался прежним: сессия по отозванному секрету выглядела законной по
|
||||
# всем признакам.
|
||||
#
|
||||
# Хуже того, у этого состояния есть путь БЕЗ единой неудачи. Hysteria
|
||||
# регистрирует соединение в Traffic Stats API только ПОСЛЕ возврата
|
||||
# backend-auth (app/v2.12.2), поэтому `/kick`, прошедший в это окно, его не
|
||||
# видит. Атомарной пары «решение авторизации + регистрация онлайна» upstream
|
||||
# не даёт, и повторное чтение базы перед ответом окно не закрывает.
|
||||
code_has apps/service/peer_secret.go -F -- 'func newPeerAuthID' \
|
||||
|| fail "acceptance: the single auth_id generator is missing"
|
||||
code_has apps/service/peer_secret.go -F -- 'func credentialGenerationChanged' \
|
||||
|| fail "acceptance: the credential generation rule is missing"
|
||||
# Генератор ровно один: `util.RandomString(18)` россыпью по местам создания
|
||||
# означал бы, что ротация и создание умеют разойтись.
|
||||
# Область скана — только исходники Go: `apps` целиком втянул бы
|
||||
# node_modules и dist фронтенда.
|
||||
local auth_id_generators
|
||||
auth_id_generators="$(code_mentions_in 'RandomString(18)' \
|
||||
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
|
||||
[ -z "$auth_id_generators" ] \
|
||||
|| fail "acceptance: auth_id is generated outside newPeerAuthID: $auth_id_generators"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||
const bodyOf = (name) => {
|
||||
const start = source.indexOf(name);
|
||||
if (start < 0) throw new Error(name + " is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
return (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Форма записи: новая идентичность попадает в ТУ ЖЕ операцию, что и новый
|
||||
// digest, — иначе между ними появляется состояние «секрет уже сменён,
|
||||
// сессии ещё старые», которое ничем не отличить от законного.
|
||||
for (const fn of ["func UpdatePeer", "func applyPeerImportEntry"]) {
|
||||
const body = bodyOf(fn);
|
||||
if (!body.includes("credentialGenerationChanged(")) {
|
||||
throw new Error(fn + " does not check the credential generation");
|
||||
}
|
||||
if (!/updates\["auth_id"\]\s*=/.test(body)) {
|
||||
throw new Error(fn + " does not rotate the session identity with the secret");
|
||||
}
|
||||
}
|
||||
|
||||
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
|
||||
// сессию. Разрыв по новому не завершил бы ничего.
|
||||
const update = bodyOf("func UpdatePeer");
|
||||
if (!update.includes("reconcileLiveSessions([]string{authIDOf(before)})")) {
|
||||
throw new Error("UpdatePeer no longer disconnects the previous session identity");
|
||||
}
|
||||
' || fail "acceptance: a rotated secret must not keep the old session identity"
|
||||
|
||||
log_step "Acceptance: the Traffic Stats API address is one contract"
|
||||
# Оркестратор принимал любой IPv4, шаблон честно его подставлял, а проверка
|
||||
# профиля сверяла конфиг с тем же значением: все гейты проходили, а продукт
|
||||
# переставал работать — админка читает из `trafficStats.listen` только порт
|
||||
# и всегда идёт на loopback.
|
||||
code_has orchestrator/src/config/env.ts -F -- 'TRAFFIC_STATS_HOST is fixed' \
|
||||
|| fail "acceptance: the orchestrator no longer fixes the Traffic Stats host"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
|
||||
const start = source.indexOf("export function validateRuntimeConfig");
|
||||
if (start < 0) throw new Error("validateRuntimeConfig is missing");
|
||||
const body = source.slice(start);
|
||||
if (!/config\.hysteriaTrafficStatsHost\s*!==\s*"127\.0\.0\.1"/.test(body)) {
|
||||
throw new Error("the Traffic Stats host is not pinned to loopback");
|
||||
}
|
||||
' || fail "acceptance: HY2XS_HYSTERIA_TRAFFIC_STATS_HOST must be pinned to 127.0.0.1"
|
||||
grep -q '^HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127\.0\.0\.1$' package/config/hy2xs.env \
|
||||
|| fail "acceptance: the packaged env no longer pins the Traffic Stats host"
|
||||
# Вторая половина контракта: админка не подставляет loopback молча вместо
|
||||
# прочитанного адреса, а называет расхождение.
|
||||
code_has apps/service/config.go -F -- 'func assertTrafficStatsHostReachable' \
|
||||
|| fail "acceptance: the admin backend accepts any Traffic Stats host silently"
|
||||
|
||||
log_step "Acceptance: the systemd state has three values, not two"
|
||||
# util.Exec выбрасывает вывод при ненулевом коде, а `systemctl is-active`
|
||||
# отвечает словом состояния в stdout ВМЕСТЕ с кодом 3. Пока другого
|
||||
# примитива не было, «служба остановлена» и «спросить не удалось» приходили
|
||||
# в панель одним значением.
|
||||
code_has apps/util/linux.go -F -- 'func ExecProbe' \
|
||||
|| fail "acceptance: the exit-code aware probe is missing"
|
||||
code_has apps/service/hysteria2.go -F -- 'HysteriaServiceUnknown' \
|
||||
|| fail "acceptance: the unknown service state is missing"
|
||||
! code_has apps/service/hysteria2.go -F -- 'is-active --quiet' \
|
||||
|| fail "acceptance: --quiet suppresses the very output the probe needs"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func Hysteria2Online");
|
||||
if (start < 0) throw new Error("Hysteria2Online is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
// Ярлык «служба неактивна -> пусто, ошибки нет» делал недоступность
|
||||
// Traffic Stats API неотличимой от «никто не подключён»: сборщик метрик
|
||||
// выставлял apiReachable = true, ни разу не сходив в API.
|
||||
if (/hysteria2IsRunning\(\)|HysteriaServiceState\(\)/.test(body)) {
|
||||
throw new Error("the display path still derives the online picture from systemd");
|
||||
}
|
||||
if (/return\s+map\[string\]int64\{\}\s*,\s*nil/.test(body)) {
|
||||
throw new Error("the display path still invents an empty online picture");
|
||||
}
|
||||
' || fail "acceptance: the online picture must come from the Traffic Stats API"
|
||||
code_has apps/model/vo/dashboard.go -E -- 'ServiceState +string' \
|
||||
|| fail "acceptance: the dashboard no longer reports the service state separately"
|
||||
code_has apps/model/vo/peer.go -F -- 'PeerOnlineStateUnavailable' \
|
||||
|| fail "acceptance: the peer list cannot report an unknown online state"
|
||||
! code_has apps/service/peer.go -E -- 'onlineUsers,\s*_\s*:=' \
|
||||
|| fail "acceptance: the peer list swallows the Traffic Stats failure again"
|
||||
|
||||
log_step "Acceptance: the Hysteria journal is parsed in its actual format"
|
||||
# JSON-логгер Hysteria 2.12.2 пишет `time` числом (EpochMillisTimeEncoder,
|
||||
# причём дробным), поэтому разбор в структуру со строковым полем падал на
|
||||
# КАЖДОЙ строке и уходил в fallback: `HYSTERIA_LOG_FORMAT=json` был включён,
|
||||
# а структурой никто не пользовался.
|
||||
code_has apps/service/journal.go -F -- 'func hysteriaLogTime' \
|
||||
|| fail "acceptance: the journal no longer understands the upstream time format"
|
||||
code_has apps/service/journal.go -F -- 'func hysteriaLogContext' \
|
||||
|| fail "acceptance: the structured context of the journal is dropped"
|
||||
! code_has apps/service/journal.go -E -- 'json\.Unmarshal\(\[\]byte\(.*\), &parsed\)' \
|
||||
|| fail "acceptance: the journal record is parsed straight into the display type again"
|
||||
|
||||
log_step "Acceptance: production does not run the upstream update check"
|
||||
# Версией Hysteria владеет versions.env -> сборка -> пакет -> оркестратор.
|
||||
# Параллельная проверка обновлений ничего не обновляет, ходит наружу при
|
||||
# каждом старте и отличает production от тестового окружения, где она уже
|
||||
# выключена.
|
||||
grep -q '^Environment=HYSTERIA_DISABLE_UPDATE_CHECK=1$' package/systemd/hysteria-server.service \
|
||||
|| fail "acceptance: the hysteria unit does not disable the upstream update check"
|
||||
|
||||
log_step "Acceptance: the config screen shows the file, not a rendering of it"
|
||||
# Панель накладывала ответ сервера на полный объект дефолтов, поэтому
|
||||
# отсутствующая секция trafficStats показывалась как `:9999`. Экран,
|
||||
# существующий ради диагностики дрейфа, этот дрейф скрывал.
|
||||
code_has apps/service/hysteria2_profile.go -F -- 'func BuildHysteria2Profile' \
|
||||
|| fail "acceptance: the production-profile projection is missing"
|
||||
code_has apps/controller/config.go -F -- 'service.BuildHysteria2Profile()' \
|
||||
|| fail "acceptance: the config route no longer returns the profile projection"
|
||||
! code_has apps/controller/config.go -F -- 'service.GetHysteria2Config()' \
|
||||
|| fail "acceptance: the config route serializes the internal model to the browser again"
|
||||
# Списки секций профиля в Go и в оркестраторе обязаны совпадать: два
|
||||
# источника истины разъехались бы молча, и панель начала бы называть
|
||||
# расхождением то, что оркестратор считает нормой.
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const go = fs.readFileSync("apps/service/hysteria2_profile.go", "utf8");
|
||||
const ts = fs.readFileSync("orchestrator/src/steps/configAssertions.ts", "utf8");
|
||||
|
||||
const goBlock = go.match(/var hysteria2ProfileSections = \[\]string\{([\s\S]*?)\}/);
|
||||
if (!goBlock) throw new Error("the Go profile section list is missing");
|
||||
const goSections = [...goBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort();
|
||||
|
||||
const tsBlock = ts.match(/const allowed = new Set\(\[([\s\S]*?)\]\)/);
|
||||
if (!tsBlock) throw new Error("the orchestrator profile section list is missing");
|
||||
const tsSections = [...tsBlock[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
// В оркестраторе режимы TLS взаимоисключающие, панель показывает оба.
|
||||
const expected = [...new Set([...tsSections, "acme", "tls"])].sort();
|
||||
|
||||
if (goSections.join(",") !== expected.join(",")) {
|
||||
throw new Error(
|
||||
"profile sections diverged:\n go: " + goSections.join(",") + "\n ts: " + expected.join(",")
|
||||
);
|
||||
}
|
||||
' || fail "acceptance: the profile section lists must not diverge"
|
||||
|
||||
log_step "Acceptance: the read-only config view carries no secrets"
|
||||
# Скачиваемая выгрузка того же конфига секреты вырезает; читающий экран не
|
||||
# имеет права быть щедрее. Пароль обфускации, токены ACME DNS и учётные
|
||||
# данные outbound-прокси уезжали в браузер в открытом виде.
|
||||
# Выравнивание полей структуры делает gofmt, поэтому число пробелов между
|
||||
# именем и типом задавать нельзя.
|
||||
code_has apps/model/vo/hysteria2_profile.go -E -- 'PasswordSet +bool' \
|
||||
|| fail "acceptance: the profile view still carries the obfuscation password"
|
||||
code_has apps/model/vo/hysteria2_profile.go -E -- 'SecretSet +bool' \
|
||||
|| fail "acceptance: the profile view still carries the Traffic Stats secret"
|
||||
! code_has apps/model/vo/hysteria2_profile.go -E -- '(Password|Secret) +\*?string' \
|
||||
|| fail "acceptance: the profile view declares a secret-bearing field"
|
||||
code_has apps/service/hysteria2_profile.go -F -- 'sanitizeURLValue(' \
|
||||
|| fail "acceptance: the auth url reaches the browser with its machine token"
|
||||
|
||||
log_step "Acceptance: the config sanitizer follows YAML aliases"
|
||||
# redactNode и redactSubtree разбирали документ, последовательность,
|
||||
# отображение и скаляр, но не AliasNode: секрет за якорем покидал сервер
|
||||
# дважды — и по ссылке, и в самом объявлении якоря.
|
||||
code_has apps/service/hysteria2_export.go -F -- 'yaml.AliasNode' \
|
||||
|| fail "acceptance: the config sanitizer ignores YAML aliases"
|
||||
code_has apps/service/hysteria2_export.go -F -- 'func newAliasGuard' \
|
||||
|| fail "acceptance: the alias walk has no cycle guard"
|
||||
|
||||
log_step "Acceptance: the removed lifecycle leftovers stay removed"
|
||||
# Мёртвые остатки прежней архитектуры: лексикографическое сравнение версий
|
||||
# без потребителя, пустая заглушка освобождения Hysteria и второй канал
|
||||
# доставки QR-кода, который панель рисует сама.
|
||||
[ ! -f apps/util/string.go ] \
|
||||
|| fail "acceptance: the unused lexicographic CompareVersion is back"
|
||||
local dead
|
||||
for dead in CompareVersion ReleaseHysteria2; do
|
||||
local carriers
|
||||
carriers="$(code_mentions_in "${dead}(" \
|
||||
apps/model apps/router apps/controller apps/service apps/middleware apps/cmd apps/dao apps/util apps/proxy)"
|
||||
[ -z "$carriers" ] \
|
||||
|| fail "acceptance: the removed ${dead} is referenced again: $carriers"
|
||||
done
|
||||
! code_has apps/model/vo/peer.go -F -- 'QrCode' \
|
||||
|| fail "acceptance: the deprecated QR transport is back in the peer response"
|
||||
}
|
||||
|
||||
# Наблюдаемость: журнал админки и страницы, которые его показывают.
|
||||
|
||||
Reference in New Issue
Block a user