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:
+31
-167
@@ -3,7 +3,6 @@ package dao
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,9 +13,6 @@ import (
|
||||
"hy2xs-admin/util"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -24,8 +20,17 @@ import (
|
||||
var sqliteDB *gorm.DB
|
||||
|
||||
func InitSqliteDB() error {
|
||||
return InitSqliteDBAt(constant.SqliteDBPath)
|
||||
}
|
||||
|
||||
// InitSqliteDBAt открывает базу по указанному пути.
|
||||
//
|
||||
// Путь параметризован, а не зашит: без этого единственным способом проверить
|
||||
// поведение слоя данных (транзакционность импорта пиров, в первую очередь)
|
||||
// было бы обращение к реальной /var/lib/hy2xs-admin/hy2xs-admin.db.
|
||||
func InitSqliteDBAt(path string) error {
|
||||
var err error
|
||||
sqliteDB, err = gorm.Open(sqlite.Open(constant.SqliteDBPath), &gorm.Config{
|
||||
sqliteDB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
@@ -76,15 +81,6 @@ func InitSql() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBoolAsInt(name string, fallback int) int {
|
||||
raw := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
|
||||
if raw == "" {
|
||||
@@ -264,6 +260,15 @@ func getOrCreateConfigKey(key string, size int) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// RunMigrations приводит схему базы к текущему поколению.
|
||||
//
|
||||
// Экспортирована, чтобы схему можно было поднять отдельно от InitSql: тестам
|
||||
// слоя данных нужна таблица, но не нужны bootstrap-администратор, bootstrap-пир
|
||||
// и генерация JWT-секрета.
|
||||
func RunMigrations() error {
|
||||
return runMigrations()
|
||||
}
|
||||
|
||||
func runMigrations() error {
|
||||
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
@@ -278,11 +283,21 @@ func runMigrations() error {
|
||||
apply func() error
|
||||
}
|
||||
|
||||
// Миграций 002_migrate_legacy_accounts и 003_archive_legacy_account здесь
|
||||
// больше нет.
|
||||
//
|
||||
// Они переносили таблицу `account` предыдущего поколения в admin_user/peer
|
||||
// и переименовывали её в бэкап. Это compatibility-слой продукта, который
|
||||
// HY2XS v1 принципиально не поддерживает: установка возможна только на
|
||||
// чистый хост, база 0.x не мигрируется ни при каком сценарии, а clean-host
|
||||
// контракт отказывает ещё до создания базы. Живого пути, по которому такая
|
||||
// таблица могла бы оказаться в hy2xs-admin.db, не существует.
|
||||
//
|
||||
// Номера оставшихся миграций сохранены: перенумерация заставила бы их
|
||||
// примениться повторно на уже установленных машинах.
|
||||
migrations := []migration{
|
||||
{version: "000_base_config", apply: migrateBaseConfig},
|
||||
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
|
||||
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
|
||||
{version: "003_archive_legacy_account", apply: archiveLegacyAccount},
|
||||
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
|
||||
{version: "005_metric_sample", apply: migrateMetricSampleTable},
|
||||
}
|
||||
@@ -403,125 +418,6 @@ func migrateAdminPeerSplit() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyAccounts() error {
|
||||
if !tableExists("account") {
|
||||
return nil
|
||||
}
|
||||
var accounts []entity.LegacyAccount
|
||||
if tx := sqliteDB.Model(&entity.LegacyAccount{}).Order("id asc").Find(&accounts); tx.Error != nil {
|
||||
logrus.Errorf("sqlite legacy account query err: %v", tx.Error)
|
||||
return errors.New("sqlite legacy account query err")
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
for _, acc := range accounts {
|
||||
if acc.Role != nil && *acc.Role == "admin" {
|
||||
if acc.Username == nil || acc.Pass == nil {
|
||||
continue
|
||||
}
|
||||
_, getErr := GetAdminUser("username = ?", *acc.Username)
|
||||
if getErr == nil {
|
||||
continue
|
||||
}
|
||||
status := int64(1)
|
||||
if acc.Deleted != nil && *acc.Deleted == 1 {
|
||||
status = 0
|
||||
}
|
||||
lastLogin := int64(0)
|
||||
if acc.LoginAt != nil {
|
||||
lastLogin = *acc.LoginAt
|
||||
}
|
||||
passwordChangedAt := nowMs
|
||||
admin := entity.AdminUser{
|
||||
Username: acc.Username,
|
||||
PasswordHash: acc.Pass,
|
||||
Status: &status,
|
||||
ForcePasswordChange: acc.ForcePasswordChange,
|
||||
LastLoginAt: &lastLogin,
|
||||
PasswordChangedAt: &passwordChangedAt,
|
||||
TokenVersion: func() *int64 { v := int64(1); return &v }(),
|
||||
}
|
||||
if _, saveErr := SaveAdminUser(admin); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if acc.Username == nil || acc.ConPass == nil {
|
||||
continue
|
||||
}
|
||||
_, getPeerErr := GetPeer("name = ?", *acc.Username)
|
||||
if getPeerErr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
authId, authErr := util.RandomString(18)
|
||||
if authErr != nil {
|
||||
return authErr
|
||||
}
|
||||
peerSecretKey, keyErr := getOrCreatePeerSecretDigestKey()
|
||||
if keyErr != nil {
|
||||
return keyErr
|
||||
}
|
||||
secretDigest := util.HmacSHA256Hex(*acc.ConPass, peerSecretKey)
|
||||
secretEncrypted := *acc.ConPass
|
||||
quota := int64(0)
|
||||
if acc.Quota != nil {
|
||||
quota = *acc.Quota
|
||||
}
|
||||
download := int64(0)
|
||||
if acc.Download != nil {
|
||||
download = *acc.Download
|
||||
}
|
||||
upload := int64(0)
|
||||
if acc.Upload != nil {
|
||||
upload = *acc.Upload
|
||||
}
|
||||
expires := int64(0)
|
||||
if acc.ExpireTime != nil {
|
||||
expires = *acc.ExpireTime
|
||||
}
|
||||
maxDevices := int64(3)
|
||||
if acc.DeviceNo != nil {
|
||||
maxDevices = *acc.DeviceNo
|
||||
}
|
||||
disabled := int64(0)
|
||||
if acc.Deleted != nil {
|
||||
disabled = *acc.Deleted
|
||||
}
|
||||
bannedUntil := int64(0)
|
||||
if acc.KickUtilTime != nil {
|
||||
bannedUntil = *acc.KickUtilTime
|
||||
}
|
||||
lastConnection := int64(0)
|
||||
if acc.ConAt != nil {
|
||||
lastConnection = *acc.ConAt
|
||||
}
|
||||
remark := ""
|
||||
if acc.Remark != nil {
|
||||
remark = *acc.Remark
|
||||
}
|
||||
peer := entity.Peer{
|
||||
Name: acc.Username,
|
||||
Remark: &remark,
|
||||
AuthId: &authId,
|
||||
SecretDigest: &secretDigest,
|
||||
SecretEncrypted: &secretEncrypted,
|
||||
QuotaBytes: "a,
|
||||
DownloadBytes: &download,
|
||||
UploadBytes: &upload,
|
||||
ExpiresAt: &expires,
|
||||
MaxDevices: &maxDevices,
|
||||
Disabled: &disabled,
|
||||
BannedUntil: &bannedUntil,
|
||||
LastConnectionAt: &lastConnection,
|
||||
}
|
||||
if _, saveErr := SavePeer(peer); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrCreateJwtSecret() (string, error) {
|
||||
if existing, err := GetConfig("key = ?", constant.JwtSecret); err == nil {
|
||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||
@@ -562,18 +458,6 @@ func getOrCreatePeerSecretDigestKey() (string, error) {
|
||||
return keyValue, nil
|
||||
}
|
||||
|
||||
func archiveLegacyAccount() error {
|
||||
if !tableExists("account") {
|
||||
return nil
|
||||
}
|
||||
backupName := fmt.Sprintf("legacy_account_backup_%d", time.Now().Unix())
|
||||
if tx := sqliteDB.Exec("ALTER TABLE account RENAME TO " + backupName); tx.Error != nil {
|
||||
logrus.Errorf("sqlite legacy archive err: %v", tx.Error)
|
||||
return errors.New("sqlite legacy archive err")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTrafficTables() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS traffic_sample (
|
||||
@@ -658,26 +542,6 @@ func tableExists(tableName string) bool {
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func listSQLMigrationFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func ensureTrafficStatsSecret() error {
|
||||
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
||||
if envSecret != "" {
|
||||
|
||||
Reference in New Issue
Block a user