cf094f6e6f
Проход по операциям, которые делают не то, что обещает их имя. 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.
89 lines
4.5 KiB
Go
89 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"hy2xs-admin/dao"
|
|
"hy2xs-admin/util"
|
|
)
|
|
|
|
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
|
|
//
|
|
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
|
|
// getPeerSecretEncryptionKey — построчные копии одноимённых функций из
|
|
// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
|
|
// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
|
|
// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
|
|
// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
|
|
// расшифровываются другим, и проявилось бы это на живых пирах.
|
|
//
|
|
// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
|
|
// из-за которого каждая первая загрузка печатала в журнал
|
|
// `duplicated key not allowed` уровня error на здоровом старте.
|
|
|
|
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
|
|
func GetPeerSecretKey() (string, error) {
|
|
return dao.GetOrCreatePeerSecretDigestKey()
|
|
}
|
|
|
|
func PeerSecretDigest(rawSecret string) (string, error) {
|
|
secretKey, err := GetPeerSecretKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return util.HmacSHA256Hex(rawSecret, secretKey), nil
|
|
}
|
|
|
|
func EncryptPeerSecret(rawSecret string) (string, error) {
|
|
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return util.EncryptAESGCM(rawSecret, key)
|
|
}
|
|
|
|
// DecryptPeerSecret расшифровывает сохранённый секрет пира.
|
|
//
|
|
// Формат хранения ровно один: `v1:` + AES-GCM. Значение без этого префикса —
|
|
// не «секрет в старом формате», а повреждённые данные, и ответом на них
|
|
// является ошибка.
|
|
//
|
|
// Что было:
|
|
//
|
|
// if !strings.HasPrefix(stored, "v1:") {
|
|
// return stored, nil
|
|
// }
|
|
//
|
|
// то есть содержимое колонки возвращалось как якобы успешно расшифрованный
|
|
// секрет. Ветка досталась от поколения, в котором секреты пиров лежали в базе
|
|
// открытым текстом; при clean-install-only политике такой строки не может
|
|
// существовать — EncryptPeerSecret всегда пишет префикс, — а вред остаётся:
|
|
//
|
|
// повреждённая колонка → мусор уходит в клиентскую ссылку как секрет;
|
|
// резервная копия с секретами → мусор попадает в файл вместо credentials;
|
|
// значение, записанное в обход → принимается без единой проверки.
|
|
//
|
|
// Это тот же класс, что и удалённый SHA-224 fallback при входе: молчаливое
|
|
// «понимаем формат предыдущего поколения» превращается в молчаливое «понимаем
|
|
// что угодно».
|
|
func DecryptPeerSecret(stored string) (string, error) {
|
|
if !strings.HasPrefix(stored, peerSecretCipherPrefix) {
|
|
return "", fmt.Errorf(
|
|
"секрет пира хранится в неизвестном формате: ожидался префикс %q. "+
|
|
"HY2XS хранит секреты пиров только зашифрованными",
|
|
peerSecretCipherPrefix,
|
|
)
|
|
}
|
|
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return util.DecryptAESGCM(stored, key)
|
|
}
|
|
|
|
// peerSecretCipherPrefix — единственный поддерживаемый формат хранения.
|
|
// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и
|
|
// сообщение об ошибке не расходились с ним по разным файлам молча.
|
|
const peerSecretCipherPrefix = "v1:"
|