package service import ( "errors" "fmt" "strings" "github.com/sirupsen/logrus" "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" "hy2xs-admin/util" ) func PagePeer(peerPageDto dto.PeerPageDto) ([]vo.PeerVo, int64, error) { peers, total, err := dao.PagePeer(peerPageDto) if err != nil { return nil, 0, err } onlineUsers, _ := Hysteria2Online() result := make([]vo.PeerVo, 0, len(peers)) for _, p := range peers { item := vo.PeerVo{ BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime}, Name: strVal(p.Name), Remark: strVal(p.Remark), AuthId: strVal(p.AuthId), QuotaBytes: int64Val(p.QuotaBytes), DownloadBytes: int64Val(p.DownloadBytes), UploadBytes: int64Val(p.UploadBytes), ExpiresAt: int64Val(p.ExpiresAt), MaxDevices: int64Val(p.MaxDevices), Disabled: int64Val(p.Disabled), BannedUntil: int64Val(p.BannedUntil), LastConnectionAt: int64Val(p.LastConnectionAt), } authID := strVal(p.AuthId) if v, ok := onlineUsers[authID]; ok { item.Online = true item.OnlineDevices = v } result = append(result, item) } return result, total, nil } // Пир установщика защищён во ВСЕХ путях записи, а не только в импорте. // // Что было. ReservedBootstrapPeerName проверялся исключительно в // applyPeerImportEntry. Обычный CRUD панели такой проверки не имел, то есть // ровно то, ради чего guard существует, делалось через интерфейс: секрет // bootstrap-admin-peer продублирован в /etc/hy2xs/bootstrap-admin.secret, // откуда его читает проверка machine-auth оркестратора, и смена секрета через // форму молча рассинхронизировала бы файл с базой. Дверей в таблицу было две, // а замок висел на одной. // // Границы намеренно разные для разных операций: // // идентичность и секрет — запрещены: они и есть то, что дублируется в файле; // удаление и отключение — разрешены: это осознанные действия оператора, // результат которых виден в списке пиров. Bootstrap-пир после установки // остаётся действующим доступом, и запрет его убрать означал бы вечный // неотзываемый доступ. var errBootstrapPeerIdentity = &PeerError{ Code: constant.ErrCodePeerBootstrapLocked, Message: fmt.Sprintf( "пир %q принадлежит установщику: его имя и секрет продублированы в "+ "/etc/hy2xs/bootstrap-admin.secret и не могут быть изменены через панель. "+ "Ненужный bootstrap-пир следует удалить целиком, а не переподписывать", ReservedBootstrapPeerName, ), } func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) { if peerDto.Name == nil || *peerDto.Name == "" { return vo.PeerVo{}, ErrPeerNameRequired } // Имя зарезервировано за установщиком даже когда сам пир уже удалён: // иначе после удаления обычный пир мог бы занять это имя и оказаться под // защитой, предназначенной не ему. if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { return vo.PeerVo{}, ErrPeerNameReserved } taken, err := ExistPeerName(*peerDto.Name, 0) if err != nil { return vo.PeerVo{}, err } if taken { return vo.PeerVo{}, PeerNameTakenError(*peerDto.Name) } secret, err := resolvePeerSecret(*peerDto.Name, peerDto.Secret) if err != nil { return vo.PeerVo{}, err } authId, err := util.RandomString(18) if err != nil { return vo.PeerVo{}, err } secretDigest, err := PeerSecretDigest(secret) if err != nil { return vo.PeerVo{}, err } secretEncrypted, err := EncryptPeerSecret(secret) if err != nil { return vo.PeerVo{}, err } peer := entity.Peer{ Name: peerDto.Name, Remark: peerDto.Remark, AuthId: &authId, SecretDigest: &secretDigest, SecretEncrypted: &secretEncrypted, QuotaBytes: peerDto.QuotaBytes, ExpiresAt: peerDto.ExpiresAt, MaxDevices: peerDto.MaxDevices, Disabled: peerDto.Disabled, } id, saveErr := dao.SavePeer(peer) if saveErr != nil { return vo.PeerVo{}, saveErr } return GetPeerVo(id) } func UpdatePeer(id int64, peerDto dto.PeerUpdateDto) error { if err := assertBootstrapPeerIdentityUnchanged(id, peerDto); err != nil { return err } updates := map[string]interface{}{} if peerDto.Name != nil && *peerDto.Name != "" { updates["name"] = *peerDto.Name } if peerDto.Secret != nil && *peerDto.Secret != "" { digest, err := PeerSecretDigest(*peerDto.Secret) if err != nil { return err } enc, err := EncryptPeerSecret(*peerDto.Secret) if err != nil { return err } updates["secret_digest"] = digest updates["secret_ciphertext"] = enc } if peerDto.QuotaBytes != nil { updates["quota_bytes"] = *peerDto.QuotaBytes } if peerDto.ExpiresAt != nil { updates["expires_at"] = *peerDto.ExpiresAt } if peerDto.MaxDevices != nil { updates["max_devices"] = *peerDto.MaxDevices } if peerDto.Disabled != nil { updates["disabled"] = *peerDto.Disabled } if peerDto.Remark != nil { updates["remark"] = *peerDto.Remark } if err := dao.UpdatePeer([]int64{id}, updates); err != nil { return err } // Отключение пира — это ОБЕ половины официального контракта Hysteria. // // Запись `disabled=1` закрывает только будущие обращения к HTTP-auth: её // видит условие выборки в Hysteria2Auth. Уже установленная QUIC-сессия // живёт своей жизнью и сама по себе не разрывается — то есть после // «Отключить» пир продолжал пользоваться доступом сколько угодно долго, // пока не переподключался по своей воле. Панель при этом показывала его // отключённым. // // Вторую половину даёт Traffic Stats `/kick`. Официальная документация // описывает их именно как пару: `/kick` завершает сессию, но клиент // немедленно переподключается, поэтому одновременно требуется блокировка в // auth backend. По отдельности не работает ни одна. // // Порядок обязателен и обратному не подлежит: сначала долговременная // запись, потом разрыв. При обратном порядке клиент успевает // переподключиться в окне между `/kick` и записью — и остаётся на связи с // формально отключённым пиром. // // Условие проверяет ЗАПРОШЕННОЕ состояние, а не переход из включённого. // Так операция остаётся повторяемой: если разрыв не удался, оператор // повторяет «Отключить» и получает вторую попытку, вместо того чтобы // включать пира ради возможности отключить его снова. if peerDto.Disabled != nil && *peerDto.Disabled == 1 { return disconnectAfterRevoke(id) } return nil } // disconnectAfterRevoke рвёт сессии пира после уже применённой записи. // // Отказ НЕ откатывает состояние: безопасная его половина достигнута, и // возвращать пиру полный доступ из-за неудачи второго шага нельзя. Вызывающему // возвращается частичный результат отдельным кодом — см. PeerDisconnectError. func disconnectAfterRevoke(id int64) error { if err := DisconnectPeers([]int64{id}); err != nil { logrus.WithError(err). WithField("peerId", id). Error("peer access revoked in database, but hysteria2 session disconnect failed") return PeerDisconnectError() } return nil } // assertBootstrapPeerIdentityUnchanged запрещает менять то, что продублировано // в /etc/hy2xs/bootstrap-admin.secret, и занимать зарезервированное имя. func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) error { // Переименование ЛЮБОГО пира в зарезервированное имя запрещено отдельно от // проверки цели: UNIQUE(name) закрывает этот путь только пока bootstrap-пир // существует. if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName { existing, err := dao.GetPeer("id = ?", id) if err != nil { return err } if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName { return ErrPeerNameReserved } } existing, err := dao.GetPeer("id = ?", id) if err != nil { return err } if existing.Name == nil || *existing.Name != ReservedBootstrapPeerName { return nil } if peerDto.Name != nil && strings.TrimSpace(*peerDto.Name) != "" && strings.TrimSpace(*peerDto.Name) != ReservedBootstrapPeerName { return errBootstrapPeerIdentity } if peerDto.Secret != nil && strings.TrimSpace(*peerDto.Secret) != "" { return errBootstrapPeerIdentity } return nil } // DeletePeer удаляет пира, включая bootstrap-пира установщика. // // Удаление намеренно НЕ запрещено. Bootstrap-пир после установки — обычный // действующий доступ, секрет которого лежит ещё и в файле на диске; оператор // обязан иметь возможность его отозвать. В отличие от смены секрета, удаление // не создаёт расхождения между базой и файлом: пира просто нет, и это видно в // списке. // // Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а // отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока // признаком «создавать или нет» служило наличие строки в таблице, удаление // откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не // девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом // заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе // панель предлагает операции, которых не выполняет. // // Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом // владеет оркестратор, админка его не трогает; после отзыва он содержит уже // недействующее значение (см. docs/admin/04-admin-panel.md). func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) } func GetPeerVo(id int64) (vo.PeerVo, error) { p, err := dao.GetPeer("id = ?", id) if err != nil { return vo.PeerVo{}, err } return vo.PeerVo{ BaseVo: vo.BaseVo{Id: *p.Id, CreateTime: *p.CreateTime}, Name: strVal(p.Name), Remark: strVal(p.Remark), AuthId: strVal(p.AuthId), QuotaBytes: int64Val(p.QuotaBytes), DownloadBytes: int64Val(p.DownloadBytes), UploadBytes: int64Val(p.UploadBytes), ExpiresAt: int64Val(p.ExpiresAt), MaxDevices: int64Val(p.MaxDevices), Disabled: int64Val(p.Disabled), BannedUntil: int64Val(p.BannedUntil), LastConnectionAt: int64Val(p.LastConnectionAt), }, nil } func ResetPeerTraffic(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}) } func ReleaseKickPeer(id int64) error { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0}) } // KickPeer — временная блокировка: пир не пускается до bannedUntil, а его // текущая сессия завершается. // // Механизм независим от `disabled`. Это два разных решения оператора с разным // сроком жизни: `banned_until` истекает сам, `disabled` снимается только // руками. Поэтому ReleaseKickPeer не трогает `disabled`, а включение пира не // сбрасывает `banned_until`. // // Что было. `banned_until` записывался здесь, а затем ВТОРОЙ РАЗ внутри // Hysteria2Kick — то же значение двумя операторами подряд. Хуже дублирования // был порядок отказа: Hysteria2Kick начинался с проверки состояния службы и // возвращал ошибку, не сделав ничего, — но первая запись к этому моменту уже // применилась. Операция отвечала чистым отказом, находясь при этом в // применённом состоянии, и оператор видел «не сработало» у сработавшей // блокировки. func KickPeer(id int64, bannedUntil int64) error { if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": bannedUntil}); err != nil { return err } return disconnectAfterRevoke(id) } func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) { url, err := Hysteria2Url(id) if err != nil { return vo.PeerClientConfigVo{}, err } 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, err } out := make([]bo.PeerExport, 0, len(peers)) for _, item := range peers { ex := bo.PeerExport{ Id: int64Val(item.Id), AuthId: strVal(item.AuthId), Name: strVal(item.Name), Remark: strVal(item.Remark), QuotaBytes: int64Val(item.QuotaBytes), DownloadBytes: int64Val(item.DownloadBytes), UploadBytes: int64Val(item.UploadBytes), ExpiresAt: int64Val(item.ExpiresAt), MaxDevices: int64Val(item.MaxDevices), Disabled: int64Val(item.Disabled), BannedUntil: int64Val(item.BannedUntil), LastConnectionAt: int64Val(item.LastConnectionAt), } 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 — запись импорта со всем криптоматериалом, посчитанным // заранее. // // Крипто выносится ИЗ транзакции сознательно. 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) authID := strings.TrimSpace(item.AuthId) maxDevices := item.MaxDevices if maxDevices <= 0 { maxDevices = 3 } entry := preparedPeerImport{ source: item, name: name, authID: authID, remark: item.Remark, quota: item.QuotaBytes, expires: item.ExpiresAt, maxDevices: maxDevices, disabled: item.Disabled, } 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 { // Генерация одна на весь продукт: импорт без секрета обязан давать // пира, неотличимого от созданного через форму. createSecret, err := GeneratePeerSecret(name) if err != nil { return nil, err } 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 }) } func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error { existing, found, err := findPeerForImport(tx, entry) if err != nil { return err } // Пир установщика не переопределяется импортом ни при каком совпадении: // его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret. if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName { return fmt.Errorf( "peer import: пир %q принадлежит установщику и не может быть изменён импортом", ReservedBootstrapPeerName, ) } if found { 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 } // 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) } 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 { return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt}) } func strVal(v *string) string { if v == nil { return "" } return *v } func int64Val(v *int64) int64 { if v == nil { return 0 } return *v }