fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам

Проход по операциям, которые делают не то, что обещает их имя.

P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать
пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS
продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор
удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же
секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config:
«создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной
транзакцией.

P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и
отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь
недоступный секрет любого пира отклоняет весь запрос с указанием имени.

P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет,
если оно не начиналось с v1: — остаток поколения с открытыми секретами.

P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на
проблему обрывала все живые соединения.

P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в
/etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine
auth переставал совпадать.

P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести
артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и
документации, отсутствие проверяется приёмкой.

P2. UPDATE по отсутствующей строке config считался успехом, и cron
перепланировался при несохранённом значении. Решение по RowsAffected.

P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения
ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage.

P2. Удалены алиасы /:id/client-url и /:id/qr.

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
This commit is contained in:
2026-08-30 06:48:50 +05:00
parent e30fdaa004
commit cf094f6e6f
33 changed files with 1976 additions and 154 deletions
+144 -18
View File
@@ -80,8 +80,12 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
}
if ExistPeerName(*peerDto.Name, 0) {
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
taken, err := ExistPeerName(*peerDto.Name, 0)
if err != nil {
return vo.PeerVo{}, err
}
if taken {
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
}
secret := ""
if peerDto.Secret != nil && *peerDto.Secret != "" {
@@ -203,6 +207,18 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
// списке.
//
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока
// признаком «создавать или нет» служило наличие строки в таблице, удаление
// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не
// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом
// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе
// панель предлагает операции, которых не выполняет.
//
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
// недействующее значение (см. docs/04-admin-panel.md).
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
func GetPeerVo(id int64) (vo.PeerVo, error) {
@@ -248,10 +264,42 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
return vo.PeerClientConfigVo{Url: url}, nil
}
// ListExportPeer собирает выгрузку пиров в одном из двух режимов.
//
// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе,
// поле secret остаётся пустым, и это ожидаемое содержимое файла.
//
// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными,
// которую оператор запрашивает отдельной кнопкой с подтверждением риска.
// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет.
//
// Что было:
//
// if includeSecrets && item.SecretEncrypted != nil {
// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
// ex.Secret = dec
// }
// }
//
// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир
// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом
// завершался успехом, и оператор получал файл, выглядящий полным:
//
// [{"name":"A","secret":"..."},
// {"name":"B","secret":""}, <- потерян молча
// {"name":"C","secret":"..."}]
//
// Обнаруживалось это уже после импорта на новом сервере: B получал новый
// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в
// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
// частичный результат под этим именем — худший из возможных ответов.
//
// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а
// без имени оператору некуда идти с этой ошибкой.
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
peers, err := dao.ListPeer("1=1")
if err != nil {
return nil, errors.New(constant.SysError)
return nil, err
}
out := make([]bo.PeerExport, 0, len(peers))
for _, item := range peers {
@@ -269,16 +317,47 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
BannedUntil: int64Val(item.BannedUntil),
LastConnectionAt: int64Val(item.LastConnectionAt),
}
if includeSecrets && item.SecretEncrypted != nil {
if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
ex.Secret = dec
if includeSecrets {
secret, secretErr := exportPeerSecret(item)
if secretErr != nil {
return nil, secretErr
}
ex.Secret = secret
}
out = append(out, ex)
}
return out, nil
}
// exportPeerSecret возвращает действующий секрет пира или объясняет, почему
// резервная копия невозможна.
func exportPeerSecret(peer entity.Peer) (string, error) {
name := strVal(peer.Name)
if name == "" {
name = fmt.Sprintf("id=%d", int64Val(peer.Id))
}
if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной. "+
"Пересоздайте секрет этого пира или выгрузите список без учётных данных",
name,
)
}
decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted)
if err != nil {
return "", fmt.Errorf(
"резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+
"Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+
"Файл не сформирован целиком, чтобы копия не выглядела полной",
name,
)
}
return decrypted, nil
}
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
// заранее.
//
@@ -416,26 +495,21 @@ func UpsertPeerExport(items []bo.PeerExport) error {
}
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
var existing entity.Peer
var err error
if entry.authID != "" {
existing, err = tx.GetPeer("auth_id = ?", entry.authID)
}
if err != nil || existing.Id == nil {
existing, err = tx.GetPeer("name = ?", entry.name)
existing, found, err := findPeerForImport(tx, entry)
if err != nil {
return err
}
// Пир установщика не переопределяется импортом ни при каком совпадении:
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
return fmt.Errorf(
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
ReservedBootstrapPeerName,
)
}
if err == nil && existing.Id != nil {
if found {
updates := map[string]interface{}{
"name": entry.name,
"remark": entry.remark,
@@ -491,14 +565,66 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
return saveErr
}
func ExistPeerName(name string, id int64) bool {
// findPeerForImport ищет запись, которую импорт должен обновить.
//
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
// последних двух не существовало — код выглядел так:
//
// existing, err = tx.GetPeer("auth_id = ?", entry.authID)
// if err != nil || existing.Id == nil {
// existing, err = tx.GetPeer("name = ?", entry.name)
// }
//
// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего
// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет
// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а
// пытался наплодить дубликаты; целостность спасала транзакция, но решение
// принималось на несуществующих данных.
//
// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной
// ключ для файлов, выгруженных без него.
func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) {
if entry.authID != "" {
peer, err := tx.GetPeer("auth_id = ?", entry.authID)
switch {
case err == nil:
return peer, true, nil
case !errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, err
}
}
peer, err := tx.GetPeer("name = ?", entry.name)
switch {
case err == nil:
return peer, true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return entity.Peer{}, false, nil
default:
return entity.Peer{}, false, err
}
}
// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может.
//
// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища
// означал «имя свободно». Проверка уникальности, которая при недоступной базе
// отвечает «можно», ничего не проверяет.
func ExistPeerName(name string, id int64) (bool, error) {
var err error
if id != 0 {
_, err = dao.GetPeer("name = ? and id != ?", name, id)
} else {
_, err = dao.GetPeer("name = ?", name)
}
return err == nil
switch {
case err == nil:
return true, nil
case errors.Is(err, dao.ErrPeerNotFound):
return false, nil
default:
return false, err
}
}
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {