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:
+144
-18
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/entity"
|
||||
)
|
||||
|
||||
// Резервная копия с учётными данными обязана быть полной или отсутствовать.
|
||||
//
|
||||
// Раньше ListExportPeer(true) молча пропускала пиров, чей секрет не удалось
|
||||
// получить, и отдавала успешный ответ с пустым полем secret. Оператор,
|
||||
// специально выбравший режим «копия с действующими credentials», узнавал о
|
||||
// потере только после импорта на новом сервере — по отвалившимся клиентам.
|
||||
|
||||
func seedPeerWithCipher(t *testing.T, name string, authID string, cipher string) int64 {
|
||||
t.Helper()
|
||||
|
||||
digest, err := PeerSecretDigest(name + "-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("digest: %v", err)
|
||||
}
|
||||
|
||||
remark := ""
|
||||
quota := int64(-1)
|
||||
zero := int64(0)
|
||||
maxDevices := int64(3)
|
||||
|
||||
id, err := dao.SavePeer(entity.Peer{
|
||||
Name: &name,
|
||||
Remark: &remark,
|
||||
AuthId: &authID,
|
||||
SecretDigest: &digest,
|
||||
SecretEncrypted: &cipher,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &zero,
|
||||
UploadBytes: &zero,
|
||||
ExpiresAt: &zero,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &zero,
|
||||
BannedUntil: &zero,
|
||||
LastConnectionAt: &zero,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось создать пира %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestBackupExportReturnsEverySecret(t *testing.T) {
|
||||
newTestDB(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth")
|
||||
seedPeer(t, "bravo2", "bravo-auth")
|
||||
|
||||
items, err := ListExportPeer(true)
|
||||
if err != nil {
|
||||
t.Fatalf("резервная копия отклонена на исправных данных: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("ожидалось 2 записи, получено %d", len(items))
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Secret == "" {
|
||||
t.Errorf("пир %s уехал в копию без секрета", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Повреждённый шифртекст: расшифровка падает, и весь бэкап обязан упасть
|
||||
// вместе с ней, назвав пира по имени.
|
||||
func TestBackupExportFailsWhenAnySecretCannotBeDecrypted(t *testing.T) {
|
||||
newTestDB(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth")
|
||||
seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext")
|
||||
seedPeer(t, "charl3", "charlie-auth")
|
||||
|
||||
items, err := ListExportPeer(true)
|
||||
if err == nil {
|
||||
t.Fatal("резервная копия с нерасшифровываемым секретом не отклонена")
|
||||
}
|
||||
if items != nil {
|
||||
t.Error("при отказе не должно возвращаться частичное содержимое файла")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bravo2") {
|
||||
t.Errorf("отказ не называет проблемного пира: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пир вовсе без сохранённого шифртекста — второй молчаливый канал потери.
|
||||
func TestBackupExportFailsWhenSecretIsMissing(t *testing.T) {
|
||||
newTestDB(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth")
|
||||
seedPeerWithCipher(t, "bravo2", "bravo-auth", "")
|
||||
|
||||
_, err := ListExportPeer(true)
|
||||
if err == nil {
|
||||
t.Fatal("резервная копия для пира без секрета не отклонена")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bravo2") {
|
||||
t.Errorf("отказ не называет проблемного пира: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Безопасная выгрузка шифртекст не трогает вовсе: пустой secret там — не
|
||||
// потеря, а весь смысл режима. Повреждённые данные не должны ей мешать.
|
||||
func TestSafeExportIgnoresBrokenSecrets(t *testing.T) {
|
||||
newTestDB(t)
|
||||
seedPeer(t, "alpha1", "alpha-auth")
|
||||
seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext")
|
||||
|
||||
items, err := ListExportPeer(false)
|
||||
if err != nil {
|
||||
t.Fatalf("безопасная выгрузка отклонена: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("ожидалось 2 записи, получено %d", len(items))
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Secret != "" {
|
||||
t.Errorf("безопасная выгрузка содержит секрет пира %s", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,11 @@ const MaxPeerImportItems = 5000
|
||||
// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в
|
||||
// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча
|
||||
// рассинхронизировала бы файл на диске с базой.
|
||||
const ReservedBootstrapPeerName = "bootstrap-admin-peer"
|
||||
//
|
||||
// Значение берётся из dao, а не пишется здесь второй раз: guard обязан
|
||||
// защищать ровно тот пир, который создаёт seeder, и две независимые строковые
|
||||
// константы рано или поздно разошлись бы.
|
||||
const ReservedBootstrapPeerName = dao.BootstrapPeerName
|
||||
|
||||
// Тот же набор символов, что и у validateStr в слое контроллеров.
|
||||
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hy2xs-admin/dao"
|
||||
@@ -42,9 +43,37 @@ func EncryptPeerSecret(rawSecret string) (string, error) {
|
||||
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, "v1:") {
|
||||
return stored, nil
|
||||
if !strings.HasPrefix(stored, peerSecretCipherPrefix) {
|
||||
return "", fmt.Errorf(
|
||||
"секрет пира хранится в неизвестном формате: ожидался префикс %q. "+
|
||||
"HY2XS хранит секреты пиров только зашифрованными",
|
||||
peerSecretCipherPrefix,
|
||||
)
|
||||
}
|
||||
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
||||
if err != nil {
|
||||
@@ -52,3 +81,8 @@ func DecryptPeerSecret(stored string) (string, error) {
|
||||
}
|
||||
return util.DecryptAESGCM(stored, key)
|
||||
}
|
||||
|
||||
// peerSecretCipherPrefix — единственный поддерживаемый формат хранения.
|
||||
// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и
|
||||
// сообщение об ошибке не расходились с ним по разным файлам молча.
|
||||
const peerSecretCipherPrefix = "v1:"
|
||||
|
||||
Reference in New Issue
Block a user