fix: закрыть каналы утечки секретов и сделать PHASE 1 владением оркестратора
Hardening-проход перед первой сборкой на Debian. Три из найденного не воспроизводились ни на одном dry-run и проявились бы только на живом сервере. Установка * preflight внутри install вызывался дважды и оба раза проверял clean-host. Ко второму вызову на диске лежал собственный /var/lib/hy2xs/install-state.json, записанный после первого preflight, и опознавался как маркер посторонней установки: КАЖДАЯ чистая установка падала сразу после apt-get с fatal_post_apply и оставляла сервер наполовину настроенным. Чистота хоста — условие входа в операцию, возможности платформы проверяются уже внутри PHASE 1, поэтому checkCleanHost стал отдельным параметром без умолчания. * PHASE 1 начиналась в install.sh: shell сам создавал /usr/local/lib/hy2xs, ставил бинарник, вешал symlink и копировал runtime-пакет, и только потом запускал оркестратор с его собственным preflight. Отказ того preflight объявлялся fatal_pre_apply — «на сервере ничего не изменено» — при уже созданном каталоге оркестратора. Отследить владение мутацией невозможно, пока мутируют двое: install.sh больше не изменяет ничего, раскладку выполняет steps/bootstrap.ts под ownership.bootstrapTouched, пути попали в owned_paths. Как следствие удалено деление clean-host на фазы. * diagnosticsCollect стояла перед rollback обычным await в install и в reconfigure. На заполненном диске она падает сама и отменяла откат целиком. Диагностика — best effort, откат — обязателен. * reconfigure/repair выбирали записываемую фазу отказа регулярным выражением по тексту ошибки. Переведено на ownership-флаги. Секреты * Журнал админки писал RequestURI, то есть путь вместе с query. Hysteria обращается к /internal/hysteria/auth?access_token=<секрет> при каждом подключении пира, поэтому действующий machine token оседал открытым текстом в hy2xs-admin.log, который отдаётся через ExportLog и попадает в diagnostics-бандл. Логируется путь; значения query не пишутся, имена — пишутся. Канала было два: gin.Default() печатает path?query в stdout, оттуда в journald и в тот же бандл, — панель переведена на gin.New() + Recovery(). Журналы внутри бандла и журнал Hysteria из ExportLog теперь проходят санитайз. Сравнение токена — constant time. * Config API позволял прочитать и подменить ключи приложения: getConfig и listConfig принимали произвольный ключ, а проверка записи была denylist'ом из трёх ключей оркестратора. Запрос ?key=PEER_SECRET_ENCRYPTION_KEY отдавал master-key шифрования секретов пиров. Доступ переведён на allowlist, маршрут getConfig удалён целиком — потребителей у него не было ни одного. Пиры * Импорт применялся по одной записи вне транзакции, вопреки собственному контракту. Валидация не знает, что уже лежит в базе: cross-conflict по UNIQUE(name) оставлял часть файла применённой. Применение выполняется одной транзакцией, криптоматериал считается до её открытия. * Файл импорта мог содержать хвостовой JSON-документ, который молча не применялся. После разбора проверяется io.EOF. * Экспорт разделён на «Экспорт настроек» и «Резервная копия» с секретами и подтверждением: обычный экспорт выдаёт пирам новые секреты при импорте, и прежние клиентские ссылки после переноса переставали работать. Сборка * Два stale-грепа в приёмке роняли build.sh в самом конце, внутри verify_archive. Первый искал в smoke.ts исчезнувший литерал URL, второй совпадал с router_test.go, который перечисляет удалённые маршруты, потому что проверяет их отсутствие: добавление регрессионного теста ломало сборку. * verify_archive требовал наличия мутирующей строки в install.sh. Инвариант перевёрнут: их не должно быть ни одной. Очистка * Удалены entity.LegacyAccount, миграции 002/003 и мёртвые хелперы listSQLMigrationFiles и envInt: v1 не мигрирует базу 0.x ни при каком сценарии. Номера оставшихся миграций сохранены. H UI-словарь убран из обычных доков, в docs/14 он остаётся — там это имена объектов для удаления. * Список непубличных IPv4 приведён к IANA Special-Purpose Address Registry: 203.0.113.5 из RFC-примеров считался публичным адресом сервера. Отказ резолвера отделён от отсутствия A-записи. Проверено: bun test 233, go test 71, tsc/vue-tsc, bash -n 11 скриптов, приёмка прогнана против дерева.
This commit is contained in:
+192
-95
@@ -204,119 +204,216 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
// Первый проход — только проверка. Импорт либо применяется целиком, либо
|
||||
// не применяется вовсе: наполовину импортированный список пиров хуже
|
||||
// отклонённого файла.
|
||||
if err := ValidatePeerImportBatch(items); err != nil {
|
||||
return err
|
||||
}
|
||||
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
|
||||
// заранее.
|
||||
//
|
||||
// Крипто выносится ИЗ транзакции сознательно. PeerSecretDigest и
|
||||
// EncryptPeerSecret читают ключи из таблицы `config`, то есть ходят в ту же
|
||||
// базу; делать это, удерживая открытую запись, значит без нужды держать
|
||||
// блокировку на время AES по каждой из тысяч записей. Внутри транзакции должна
|
||||
// остаться только работа с таблицей пиров.
|
||||
type preparedPeerImport struct {
|
||||
source bo.PeerExport
|
||||
|
||||
name string
|
||||
authID string
|
||||
remark string
|
||||
quota int64
|
||||
expires int64
|
||||
maxDevices int64
|
||||
disabled int64
|
||||
|
||||
// Задан, только если секрет пришёл в файле: у существующего пира секрет
|
||||
// перезаписывается лишь в этом случае.
|
||||
hasExplicitSecret bool
|
||||
explicitDigest string
|
||||
explicitCipher string
|
||||
|
||||
// Готовятся всегда: понадобятся, если запись окажется новой.
|
||||
createDigest string
|
||||
createCipher string
|
||||
createAuthID string
|
||||
}
|
||||
|
||||
func preparePeerImport(items []bo.PeerExport) ([]preparedPeerImport, error) {
|
||||
prepared := make([]preparedPeerImport, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
|
||||
var existing entity.Peer
|
||||
var err error
|
||||
authID := strings.TrimSpace(item.AuthId)
|
||||
if authID != "" {
|
||||
existing, err = dao.GetPeer("auth_id = ?", authID)
|
||||
}
|
||||
if err != nil || existing.Id == nil {
|
||||
existing, err = dao.GetPeer("name = ?", name)
|
||||
}
|
||||
|
||||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||||
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||||
return fmt.Errorf("peer import: пир %q принадлежит установщику и не может быть изменён импортом", ReservedBootstrapPeerName)
|
||||
}
|
||||
|
||||
quota := item.QuotaBytes
|
||||
expires := item.ExpiresAt
|
||||
maxDevices := item.MaxDevices
|
||||
if maxDevices <= 0 {
|
||||
maxDevices = 3
|
||||
}
|
||||
disabled := item.Disabled
|
||||
remark := item.Remark
|
||||
|
||||
if err == nil && existing.Id != nil {
|
||||
updates := map[string]interface{}{
|
||||
"name": name,
|
||||
"remark": remark,
|
||||
"quota_bytes": quota,
|
||||
"download_bytes": item.DownloadBytes,
|
||||
"upload_bytes": item.UploadBytes,
|
||||
"expires_at": expires,
|
||||
"max_devices": maxDevices,
|
||||
"disabled": disabled,
|
||||
"banned_until": item.BannedUntil,
|
||||
"last_connection_at": item.LastConnectionAt,
|
||||
}
|
||||
if authID != "" {
|
||||
updates["auth_id"] = authID
|
||||
}
|
||||
if strings.TrimSpace(item.Secret) != "" {
|
||||
digest, derr := PeerSecretDigest(item.Secret)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
enc, eerr := EncryptPeerSecret(item.Secret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
updates["secret_digest"] = digest
|
||||
updates["secret_ciphertext"] = enc
|
||||
}
|
||||
if uerr := dao.UpdatePeer([]int64{*existing.Id}, updates); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
continue
|
||||
entry := preparedPeerImport{
|
||||
source: item,
|
||||
name: name,
|
||||
authID: authID,
|
||||
remark: item.Remark,
|
||||
quota: item.QuotaBytes,
|
||||
expires: item.ExpiresAt,
|
||||
maxDevices: maxDevices,
|
||||
disabled: item.Disabled,
|
||||
}
|
||||
|
||||
createSecret := strings.TrimSpace(item.Secret)
|
||||
if createSecret == "" {
|
||||
generated, gerr := util.RandomString(24)
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
}
|
||||
createSecret = fmt.Sprintf("%s.%s", name, generated)
|
||||
}
|
||||
secretDigest, derr := PeerSecretDigest(createSecret)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
secretEncrypted, eerr := EncryptPeerSecret(createSecret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
if authID == "" {
|
||||
authID, err = util.RandomString(18)
|
||||
explicitSecret := strings.TrimSpace(item.Secret)
|
||||
if explicitSecret != "" {
|
||||
digest, err := PeerSecretDigest(explicitSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cipher, err := EncryptPeerSecret(explicitSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.hasExplicitSecret = true
|
||||
entry.explicitDigest = digest
|
||||
entry.explicitCipher = cipher
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
} else {
|
||||
generated, err := util.RandomString(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createSecret := fmt.Sprintf("%s.%s", name, generated)
|
||||
digest, err := PeerSecretDigest(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cipher, err := EncryptPeerSecret(createSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.createDigest = digest
|
||||
entry.createCipher = cipher
|
||||
}
|
||||
|
||||
entry.createAuthID = authID
|
||||
if entry.createAuthID == "" {
|
||||
generated, err := util.RandomString(18)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.createAuthID = generated
|
||||
}
|
||||
|
||||
prepared = append(prepared, entry)
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
// UpsertPeerExport применяет выгрузку пиров целиком или не применяет вовсе.
|
||||
//
|
||||
// Три прохода, и каждый отвечает за своё:
|
||||
//
|
||||
// 1. ValidatePeerImportBatch — содержимое файла, без обращения к базе;
|
||||
// 2. preparePeerImport — весь криптоматериал, без обращения к таблице пиров;
|
||||
// 3. одна транзакция — только записи.
|
||||
//
|
||||
// Раньше третьего прохода не существовало: записи шли по одной, каждая своим
|
||||
// оператором. Комментарий обещал «либо целиком, либо никак», но UNIQUE-конфликт
|
||||
// на 37-й записи оставлял 36 применённых, и откатить это оператор уже не мог.
|
||||
// Конфликт не гипотетический: пусть в базе есть A(auth_id=a, name=alice) и
|
||||
// B(auth_id=b, name=bob), а файл несёт (auth_id=a, name=bob). Поиск найдёт A
|
||||
// по auth_id и переименует его в bob — прямо в UNIQUE(name).
|
||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
if err := ValidatePeerImportBatch(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prepared, err := preparePeerImport(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.WithPeerTx(func(tx dao.PeerTx) error {
|
||||
for _, entry := range prepared {
|
||||
if err := applyPeerImportEntry(tx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
peer := entity.Peer{
|
||||
Name: &name,
|
||||
Remark: &remark,
|
||||
AuthId: &authID,
|
||||
SecretDigest: &secretDigest,
|
||||
SecretEncrypted: &secretEncrypted,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &item.DownloadBytes,
|
||||
UploadBytes: &item.UploadBytes,
|
||||
ExpiresAt: &expires,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &disabled,
|
||||
BannedUntil: &item.BannedUntil,
|
||||
LastConnectionAt: &item.LastConnectionAt,
|
||||
}
|
||||
if _, serr := dao.SavePeer(peer); serr != nil {
|
||||
return serr
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
if err != nil || existing.Id == nil {
|
||||
existing, err = tx.GetPeer("name = ?", entry.name)
|
||||
}
|
||||
|
||||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||||
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||||
return fmt.Errorf(
|
||||
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
||||
ReservedBootstrapPeerName,
|
||||
)
|
||||
}
|
||||
|
||||
if err == nil && existing.Id != nil {
|
||||
updates := map[string]interface{}{
|
||||
"name": entry.name,
|
||||
"remark": entry.remark,
|
||||
"quota_bytes": entry.quota,
|
||||
"download_bytes": entry.source.DownloadBytes,
|
||||
"upload_bytes": entry.source.UploadBytes,
|
||||
"expires_at": entry.expires,
|
||||
"max_devices": entry.maxDevices,
|
||||
"disabled": entry.disabled,
|
||||
"banned_until": entry.source.BannedUntil,
|
||||
"last_connection_at": entry.source.LastConnectionAt,
|
||||
}
|
||||
if entry.authID != "" {
|
||||
updates["auth_id"] = entry.authID
|
||||
}
|
||||
if entry.hasExplicitSecret {
|
||||
updates["secret_digest"] = entry.explicitDigest
|
||||
updates["secret_ciphertext"] = entry.explicitCipher
|
||||
}
|
||||
return tx.UpdatePeer([]int64{*existing.Id}, updates)
|
||||
}
|
||||
|
||||
name := entry.name
|
||||
remark := entry.remark
|
||||
authID := entry.createAuthID
|
||||
digest := entry.createDigest
|
||||
cipher := entry.createCipher
|
||||
quota := entry.quota
|
||||
expires := entry.expires
|
||||
maxDevices := entry.maxDevices
|
||||
disabled := entry.disabled
|
||||
download := entry.source.DownloadBytes
|
||||
upload := entry.source.UploadBytes
|
||||
bannedUntil := entry.source.BannedUntil
|
||||
lastConnection := entry.source.LastConnectionAt
|
||||
|
||||
peer := entity.Peer{
|
||||
Name: &name,
|
||||
Remark: &remark,
|
||||
AuthId: &authID,
|
||||
SecretDigest: &digest,
|
||||
SecretEncrypted: &cipher,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &download,
|
||||
UploadBytes: &upload,
|
||||
ExpiresAt: &expires,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &disabled,
|
||||
BannedUntil: &bannedUntil,
|
||||
LastConnectionAt: &lastConnection,
|
||||
}
|
||||
_, saveErr := tx.SavePeer(peer)
|
||||
return saveErr
|
||||
}
|
||||
|
||||
func ExistPeerName(name string, id int64) bool {
|
||||
|
||||
Reference in New Issue
Block a user