fix(admin): убрать каналы утечки секретов и остатки H UI из runtime
Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export, и файл там оставался навсегда. При includeSecrets=true это означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки формируются в памяти, каталога export/ больше нет. Generic export/import таблицы config удалён целиком. Он исключал только сырой Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export выгружала их открытым текстом, импорт позволял подменить. Для PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже существующих пиров. Production-сценария у этой пары не было. Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира: в базу попадало имя любой длины и с любыми символами, disabled с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список наполовину изменённым, а импорт мог перезаписать bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret. Партия проверяется целиком до первой записи, неизвестные поля отклоняются. Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT, H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт аргументом, админка писала его в SQLite и тут же читала обратно, а UI показывал поля disabled — второй источник истины, из которого ничего нельзя было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR, база переименована в hy2xs-admin.db, reference-схема — в schema.sql. API namespace разделён по природе маршрутов: операторский API на /api, machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth — runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому объявлен одной константой на компонент. Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr: будущее upstream-поле с другим именем уносило учётные данные и access_token целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора. Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен: NProgress приходил со своим #29d и был единственным элементом вне палитры.
This commit is contained in:
@@ -27,10 +27,6 @@ func ListConfig(keys []string) ([]entity.Config, error) {
|
||||
return dao.ListConfig("key in ?", keys)
|
||||
}
|
||||
|
||||
func ListConfigNotIn(keys []string) ([]entity.Config, error) {
|
||||
return dao.ListConfig("key not in ?", keys)
|
||||
}
|
||||
|
||||
func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
|
||||
var serverConfig bo.Hysteria2ServerConfig
|
||||
config, err := dao.GetConfig("key = ?", constant.Hysteria2Config)
|
||||
@@ -53,10 +49,6 @@ func GetHysteria2Config() (bo.Hysteria2ServerConfig, error) {
|
||||
return serverConfig, nil
|
||||
}
|
||||
|
||||
func UpsertConfig(configs []entity.Config) error {
|
||||
return dao.UpsertConfig(configs)
|
||||
}
|
||||
|
||||
func GetHysteria2ApiPort() (int64, error) {
|
||||
hysteria2Config, err := GetHysteria2Config()
|
||||
if err != nil {
|
||||
@@ -100,31 +92,3 @@ func parseTrafficStatsPort(listen string) (int64, error) {
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func GetPortAndCert() (int64, string, string, error) {
|
||||
configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath})
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
port := ""
|
||||
crtPath := ""
|
||||
keyPath := ""
|
||||
for _, config := range configs {
|
||||
value := *config.Value
|
||||
if *config.Key == constant.HUIWebPort {
|
||||
port = value
|
||||
} else if *config.Key == constant.HUICrtPath {
|
||||
crtPath = value
|
||||
} else if *config.Key == constant.HUIKeyPath {
|
||||
keyPath = value
|
||||
}
|
||||
}
|
||||
|
||||
portInt, err := strconv.ParseInt(port, 10, 64)
|
||||
if err != nil {
|
||||
logrus.Errorf("port: %s is invalid", port)
|
||||
return 0, "", "", errors.New(fmt.Sprintf("port: %s is invalid", port))
|
||||
}
|
||||
|
||||
return portInt, crtPath, keyPath, nil
|
||||
}
|
||||
|
||||
@@ -154,20 +154,35 @@ func redactNode(node *yaml.Node, path []string) {
|
||||
redactSubtree(value)
|
||||
continue
|
||||
}
|
||||
if value.Kind == yaml.ScalarNode && looksLikeURLKey(key) {
|
||||
value.Value = sanitizeURLValue(value.Value)
|
||||
value.Tag = "!!str"
|
||||
value.Style = 0
|
||||
continue
|
||||
}
|
||||
redactNode(value, childPath)
|
||||
}
|
||||
|
||||
case yaml.ScalarNode:
|
||||
// Секрет может лежать в URL и без «говорящего» имени ключа.
|
||||
//
|
||||
// Раньше здесь стоял фильтр по имени ключа (url / addr / *url), и
|
||||
// upstream-поле вроде `endpoint: https://user:pass@host/?token=…`
|
||||
// уезжало в выгрузку целиком. Правильная граница — не имя ключа, а
|
||||
// сам факт, что значение разбирается как URL: sanitizeURLValue
|
||||
// возвращает вход без изменений, если это не URL. Ровно так же
|
||||
// устроен TS-санитайзер в orchestrator/src/lib/redaction.ts.
|
||||
sanitizeURLScalar(node)
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeURLKey(key string) bool {
|
||||
lowered := strings.ToLower(key)
|
||||
return lowered == "url" || lowered == "addr" || strings.HasSuffix(lowered, "url")
|
||||
// sanitizeURLScalar применяет URL-санитайзер к скаляру, не трогая ни его тип,
|
||||
// ни значения, которые URL не являются.
|
||||
func sanitizeURLScalar(node *yaml.Node) {
|
||||
if node.Tag != "" && node.Tag != "!!str" {
|
||||
return
|
||||
}
|
||||
sanitized := sanitizeURLValue(node.Value)
|
||||
if sanitized == node.Value {
|
||||
return
|
||||
}
|
||||
node.Value = sanitized
|
||||
node.Tag = "!!str"
|
||||
node.Style = 0
|
||||
}
|
||||
|
||||
func redactSubtree(node *yaml.Node) {
|
||||
|
||||
@@ -26,7 +26,7 @@ acme:
|
||||
auth:
|
||||
type: http
|
||||
http:
|
||||
url: http://127.0.0.1:8080/hui/hysteria2/auth?access_token=machine-secret
|
||||
url: http://127.0.0.1:8080/internal/hysteria/auth?access_token=machine-secret
|
||||
insecure: false
|
||||
userpass:
|
||||
alice: alice-password
|
||||
@@ -71,6 +71,11 @@ someFutureUpstreamFeature:
|
||||
list:
|
||||
- a
|
||||
- b
|
||||
# Ни одно из имён ниже не является ни secret-shaped, ни url/addr-подобным:
|
||||
# именно так выглядит поле, которое upstream добавит завтра.
|
||||
endpoint: https://svc-user:svc-p4ss@relay.example.com/?access_token=endpoint-token
|
||||
mirrors:
|
||||
- https://mirror-user:mirror-p4ss@mirror.example.com/pull
|
||||
`
|
||||
|
||||
func sanitizeForTest(t *testing.T, raw string) string {
|
||||
@@ -93,6 +98,9 @@ func TestSanitizeHysteria2ConfigYaml_RemovesSecrets(t *testing.T) {
|
||||
"bob-password",
|
||||
"proxy-password",
|
||||
"super-secret-token",
|
||||
"svc-p4ss",
|
||||
"endpoint-token",
|
||||
"mirror-p4ss",
|
||||
}
|
||||
for _, secret := range leaked {
|
||||
if strings.Contains(sanitized, secret) {
|
||||
@@ -152,7 +160,7 @@ func TestSanitizeHysteria2ConfigYaml_KeepsNonSecretOperationalFields(t *testing.
|
||||
func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/hui/hysteria2/auth") {
|
||||
if !strings.Contains(sanitized, "127.0.0.1:8080/internal/hysteria/auth") {
|
||||
t.Fatalf("auth url shape was lost:\n%s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "access_token="+RedactedPlaceholder) &&
|
||||
@@ -161,6 +169,69 @@ func TestSanitizeHysteria2ConfigYaml_StripsAccessTokenButKeepsUrlShape(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: санитайзер применял sanitizeURLValue только к ключам url/addr/*url.
|
||||
// Любое будущее upstream-поле с другим именем уносило встроенные учётные данные
|
||||
// и секретные query-параметры наружу целиком. TS-санитайзер оркестратора такой
|
||||
// границы никогда не имел, и расхождение между двумя реализациями одного
|
||||
// контракта — само по себе дефект.
|
||||
func TestSanitizeHysteria2ConfigYaml_RedactsURLsUnderArbitraryKeys(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal([]byte(sanitized), &parsed); err != nil {
|
||||
t.Fatalf("sanitized output is not valid yaml: %v", err)
|
||||
}
|
||||
future, ok := parsed["someFutureUpstreamFeature"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unknown upstream section was dropped:\n%s", sanitized)
|
||||
}
|
||||
|
||||
endpoint, _ := future["endpoint"].(string)
|
||||
if !strings.Contains(endpoint, "relay.example.com") {
|
||||
t.Fatalf("endpoint потерял адрес, диагностика станет бесполезной: %q", endpoint)
|
||||
}
|
||||
if strings.Contains(endpoint, "svc-p4ss") || strings.Contains(endpoint, "endpoint-token") {
|
||||
t.Fatalf("endpoint унёс секреты наружу: %q", endpoint)
|
||||
}
|
||||
|
||||
// Скаляры внутри последовательностей раньше вообще не доходили до
|
||||
// санитайзера: у redactNode не было ветки ScalarNode.
|
||||
mirrors, ok := future["mirrors"].([]any)
|
||||
if !ok || len(mirrors) != 1 {
|
||||
t.Fatalf("список mirrors потерян: %+v", future["mirrors"])
|
||||
}
|
||||
mirror, _ := mirrors[0].(string)
|
||||
if !strings.Contains(mirror, "mirror.example.com") {
|
||||
t.Fatalf("mirror потерял адрес: %q", mirror)
|
||||
}
|
||||
if strings.Contains(mirror, "mirror-p4ss") {
|
||||
t.Fatalf("mirror унёс встроенные учётные данные: %q", mirror)
|
||||
}
|
||||
}
|
||||
|
||||
// Санитайзер обязан оставаться безвредным для значений, которые URL не
|
||||
// являются: он проходит по каждому скаляру документа.
|
||||
func TestSanitizeHysteria2ConfigYaml_LeavesNonURLScalarsIntact(t *testing.T) {
|
||||
sanitized := sanitizeForTest(t, exportSampleConfig)
|
||||
|
||||
intact := []string{
|
||||
"up: 50 mbps",
|
||||
"down: 50 mbps",
|
||||
"listen: 0.0.0.0:443",
|
||||
"listen: 127.0.0.1:36712",
|
||||
"addr: 10.0.0.1:1080",
|
||||
"email: admin@example.com",
|
||||
"dir: /var/lib/hysteria/acme",
|
||||
"initStreamReceiveWindow: 8388608",
|
||||
"tuning: 42",
|
||||
}
|
||||
for _, fragment := range intact {
|
||||
if !strings.Contains(sanitized, fragment) {
|
||||
t.Fatalf("санитайзер изменил не-URL значение %q:\n%s", fragment, sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeURLValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -170,8 +241,8 @@ func TestSanitizeURLValue(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "strips access token",
|
||||
in: "http://127.0.0.1:8080/hui/hysteria2/auth?access_token=abc123",
|
||||
mustKeep: []string{"127.0.0.1:8080", "/hui/hysteria2/auth"},
|
||||
in: "http://127.0.0.1:8080/internal/hysteria/auth?access_token=abc123",
|
||||
mustKeep: []string{"127.0.0.1:8080", "/internal/hysteria/auth"},
|
||||
mustRemove: []string{"abc123"},
|
||||
},
|
||||
{
|
||||
|
||||
+14
-4
@@ -205,11 +205,15 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
||||
}
|
||||
|
||||
func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
// Первый проход — только проверка. Импорт либо применяется целиком, либо
|
||||
// не применяется вовсе: наполовину импортированный список пиров хуже
|
||||
// отклонённого файла.
|
||||
if err := ValidatePeerImportBatch(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
return errors.New(constant.InvalidError)
|
||||
}
|
||||
|
||||
var existing entity.Peer
|
||||
var err error
|
||||
@@ -217,10 +221,16 @@ func UpsertPeerExport(items []bo.PeerExport) error {
|
||||
if authID != "" {
|
||||
existing, err = dao.GetPeer("auth_id = ?", authID)
|
||||
}
|
||||
if (err != nil || existing.Id == nil) && name != "" {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"hy2xs-admin/model/bo"
|
||||
)
|
||||
|
||||
// Проверка импортируемой выгрузки пиров.
|
||||
//
|
||||
// Обычный путь создания пира проходит через dto.PeerSaveDto и его теги
|
||||
// валидатора: имя 6-32 символа из ограниченного набора, quota >= -1,
|
||||
// maxDevices >= 1, disabled строго 0/1 и так далее. Импорт JSON шёл мимо всего
|
||||
// этого и писал в базу что угодно — включая disabled=7 и имя с переводом
|
||||
// строки, которое потом попадало бы в клиентскую ссылку.
|
||||
//
|
||||
// Правила здесь намеренно повторяют PeerSaveDto: две двери в одну и ту же
|
||||
// таблицу не имеют права требовать разного.
|
||||
|
||||
// MaxPeerImportItems ограничивает размер одной операции импорта.
|
||||
// Верхняя граница нужна не для памяти (файл уже ограничен 2 МБ), а чтобы
|
||||
// одна ошибка в файле не превращалась в многоминутную операцию с AES на
|
||||
// каждой записи.
|
||||
const MaxPeerImportItems = 5000
|
||||
|
||||
// ReservedBootstrapPeerName — пир, который создаёт установка из
|
||||
// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в
|
||||
// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча
|
||||
// рассинхронизировала бы файл на диске с базой.
|
||||
const ReservedBootstrapPeerName = "bootstrap-admin-peer"
|
||||
|
||||
// Тот же набор символов, что и у validateStr в слое контроллеров.
|
||||
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
|
||||
|
||||
// authId генерируется через util.RandomString и участвует в HTTP-обмене с
|
||||
// Hysteria, поэтому здесь набор ещё уже.
|
||||
var peerAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._\-]{1,64}$`)
|
||||
|
||||
func peerImportError(index int, reason string) error {
|
||||
return fmt.Errorf("peer import: запись #%d: %s", index+1, reason)
|
||||
}
|
||||
|
||||
// ValidatePeerImportBatch проверяет всю партию целиком и не меняет состояние.
|
||||
//
|
||||
// Импорт применяется двумя проходами: сначала проверка всех записей, затем
|
||||
// запись. Иначе файл, у которого невалидна десятая запись, оставлял бы первые
|
||||
// девять уже применёнными — оператор получал бы ошибку и наполовину изменённый
|
||||
// список пиров.
|
||||
func ValidatePeerImportBatch(items []bo.PeerExport) error {
|
||||
if len(items) == 0 {
|
||||
return errors.New("peer import: файл не содержит ни одной записи")
|
||||
}
|
||||
if len(items) > MaxPeerImportItems {
|
||||
return fmt.Errorf("peer import: слишком много записей: %d (максимум %d)", len(items), MaxPeerImportItems)
|
||||
}
|
||||
|
||||
seenNames := make(map[string]int, len(items))
|
||||
seenAuthIDs := make(map[string]int, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
return peerImportError(i, "пустое имя")
|
||||
}
|
||||
if !peerNamePattern.MatchString(name) {
|
||||
return peerImportError(i, fmt.Sprintf("недопустимое имя %q: 6-32 символа из [a-zA-Z0-9!@#$%%^&*()_+-=]", name))
|
||||
}
|
||||
if name == ReservedBootstrapPeerName {
|
||||
return peerImportError(i, fmt.Sprintf("имя %q зарезервировано установщиком и не может быть импортировано", name))
|
||||
}
|
||||
if prev, ok := seenNames[name]; ok {
|
||||
return peerImportError(i, fmt.Sprintf("имя %q дублирует запись #%d", name, prev+1))
|
||||
}
|
||||
seenNames[name] = i
|
||||
|
||||
authID := strings.TrimSpace(item.AuthId)
|
||||
if authID != "" {
|
||||
if !peerAuthIDPattern.MatchString(authID) {
|
||||
return peerImportError(i, fmt.Sprintf("недопустимый authId %q", authID))
|
||||
}
|
||||
if authID == ReservedBootstrapPeerName {
|
||||
return peerImportError(i, fmt.Sprintf("authId %q зарезервирован установщиком", authID))
|
||||
}
|
||||
if prev, ok := seenAuthIDs[authID]; ok {
|
||||
return peerImportError(i, fmt.Sprintf("authId %q дублирует запись #%d", authID, prev+1))
|
||||
}
|
||||
seenAuthIDs[authID] = i
|
||||
}
|
||||
|
||||
if secret := strings.TrimSpace(item.Secret); secret != "" {
|
||||
if len(secret) < 6 || len(secret) > 128 {
|
||||
return peerImportError(i, "длина secret должна быть 6-128 символов")
|
||||
}
|
||||
}
|
||||
|
||||
if len([]rune(item.Remark)) > 64 {
|
||||
return peerImportError(i, "remark длиннее 64 символов")
|
||||
}
|
||||
|
||||
if item.QuotaBytes < -1 {
|
||||
return peerImportError(i, fmt.Sprintf("quotaBytes должен быть >= -1, получено %d", item.QuotaBytes))
|
||||
}
|
||||
if item.ExpiresAt < 0 {
|
||||
return peerImportError(i, fmt.Sprintf("expiresAt должен быть >= 0, получено %d", item.ExpiresAt))
|
||||
}
|
||||
// Ноль означает «не задано»: UpsertPeerExport подставит значение по
|
||||
// умолчанию. Отрицательное значение — это уже ошибка в файле.
|
||||
if item.MaxDevices < 0 {
|
||||
return peerImportError(i, fmt.Sprintf("maxDevices должен быть >= 0, получено %d", item.MaxDevices))
|
||||
}
|
||||
if item.Disabled != 0 && item.Disabled != 1 {
|
||||
return peerImportError(i, fmt.Sprintf("disabled должен быть 0 или 1, получено %d", item.Disabled))
|
||||
}
|
||||
if item.BannedUntil < 0 {
|
||||
return peerImportError(i, fmt.Sprintf("bannedUntil должен быть >= 0, получено %d", item.BannedUntil))
|
||||
}
|
||||
if item.DownloadBytes < 0 || item.UploadBytes < 0 {
|
||||
return peerImportError(i, "счётчики трафика не могут быть отрицательными")
|
||||
}
|
||||
if item.LastConnectionAt < 0 {
|
||||
return peerImportError(i, fmt.Sprintf("lastConnectionAt должен быть >= 0, получено %d", item.LastConnectionAt))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/model/bo"
|
||||
)
|
||||
|
||||
func validPeer(name string) bo.PeerExport {
|
||||
return bo.PeerExport{
|
||||
Name: name,
|
||||
Remark: "office laptop",
|
||||
QuotaBytes: -1,
|
||||
ExpiresAt: 0,
|
||||
MaxDevices: 3,
|
||||
Disabled: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePeerImportBatchAcceptsExportShape(t *testing.T) {
|
||||
items := []bo.PeerExport{
|
||||
validPeer("alpha1"),
|
||||
{
|
||||
Name: "bravo-2",
|
||||
AuthId: "Kf83jd0sLa93kd0sQx",
|
||||
Secret: "s3cret-value",
|
||||
QuotaBytes: 107374182400,
|
||||
DownloadBytes: 12,
|
||||
UploadBytes: 34,
|
||||
ExpiresAt: 1893456000000,
|
||||
MaxDevices: 5,
|
||||
Disabled: 1,
|
||||
BannedUntil: 0,
|
||||
LastConnectionAt: 1893456000000,
|
||||
},
|
||||
}
|
||||
if err := ValidatePeerImportBatch(items); err != nil {
|
||||
t.Fatalf("валидная выгрузка отклонена: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePeerImportBatchRejectsEmpty(t *testing.T) {
|
||||
if err := ValidatePeerImportBatch(nil); err == nil {
|
||||
t.Fatal("пустой импорт должен быть отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: импорт шёл мимо dto.PeerSaveDto, поэтому в базу попадало имя
|
||||
// любой длины и с любыми символами — включая перевод строки, который потом
|
||||
// оказался бы в клиентской ссылке.
|
||||
func TestValidatePeerImportBatchRejectsBadNames(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"пустое": "",
|
||||
"только пробелы": " ",
|
||||
"короткое": "abc",
|
||||
"слишком длинное": strings.Repeat("a", 33),
|
||||
"перевод строки": "peer\nname",
|
||||
"пробел внутри": "peer name",
|
||||
"недопустимый слэш": "peer/name",
|
||||
}
|
||||
for label, name := range cases {
|
||||
item := validPeer(name)
|
||||
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil {
|
||||
t.Errorf("%s: имя %q принято, ожидался отказ", label, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пир установщика продублирован в /etc/hy2xs/bootstrap-admin.secret:
|
||||
// подмена его секрета импортом рассинхронизировала бы файл с базой.
|
||||
func TestValidatePeerImportBatchRejectsReservedBootstrapPeer(t *testing.T) {
|
||||
item := validPeer(ReservedBootstrapPeerName)
|
||||
err := ValidatePeerImportBatch([]bo.PeerExport{item})
|
||||
if err == nil {
|
||||
t.Fatal("зарезервированное имя bootstrap-пира принято")
|
||||
}
|
||||
if !strings.Contains(err.Error(), ReservedBootstrapPeerName) {
|
||||
t.Fatalf("сообщение не называет зарезервированное имя: %v", err)
|
||||
}
|
||||
|
||||
byAuthID := validPeer("alpha1")
|
||||
byAuthID.AuthId = ReservedBootstrapPeerName
|
||||
if err := ValidatePeerImportBatch([]bo.PeerExport{byAuthID}); err == nil {
|
||||
t.Fatal("зарезервированный authId принят")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePeerImportBatchRejectsOutOfRangeNumbers(t *testing.T) {
|
||||
cases := map[string]func(*bo.PeerExport){
|
||||
"quota < -1": func(p *bo.PeerExport) { p.QuotaBytes = -2 },
|
||||
"expiresAt < 0": func(p *bo.PeerExport) { p.ExpiresAt = -1 },
|
||||
"maxDevices < 0": func(p *bo.PeerExport) { p.MaxDevices = -1 },
|
||||
"disabled = 7": func(p *bo.PeerExport) { p.Disabled = 7 },
|
||||
"bannedUntil < 0": func(p *bo.PeerExport) { p.BannedUntil = -1 },
|
||||
"download < 0": func(p *bo.PeerExport) { p.DownloadBytes = -1 },
|
||||
"upload < 0": func(p *bo.PeerExport) { p.UploadBytes = -1 },
|
||||
"lastConnection < 0": func(p *bo.PeerExport) { p.LastConnectionAt = -1 },
|
||||
"remark длиннее 64": func(p *bo.PeerExport) { p.Remark = strings.Repeat("я", 65) },
|
||||
"secret короче 6": func(p *bo.PeerExport) { p.Secret = "abc" },
|
||||
"secret длиннее 128": func(p *bo.PeerExport) { p.Secret = strings.Repeat("s", 129) },
|
||||
"authId с пробелом": func(p *bo.PeerExport) { p.AuthId = "bad id" },
|
||||
"authId длиннее 64": func(p *bo.PeerExport) { p.AuthId = strings.Repeat("a", 65) },
|
||||
}
|
||||
for label, mutate := range cases {
|
||||
item := validPeer("alpha1")
|
||||
mutate(&item)
|
||||
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err == nil {
|
||||
t.Errorf("%s: запись принята, ожидался отказ", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -1 (безлимит) и 0 (не задано) — легальные значения, а не пограничный мусор.
|
||||
func TestValidatePeerImportBatchAcceptsSentinelValues(t *testing.T) {
|
||||
item := validPeer("alpha1")
|
||||
item.QuotaBytes = -1
|
||||
item.MaxDevices = 0
|
||||
item.ExpiresAt = 0
|
||||
if err := ValidatePeerImportBatch([]bo.PeerExport{item}); err != nil {
|
||||
t.Fatalf("допустимые sentinel-значения отклонены: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePeerImportBatchRejectsDuplicates(t *testing.T) {
|
||||
dupName := []bo.PeerExport{validPeer("alpha1"), validPeer("alpha1")}
|
||||
if err := ValidatePeerImportBatch(dupName); err == nil {
|
||||
t.Error("дублирующееся имя принято")
|
||||
}
|
||||
|
||||
first := validPeer("alpha1")
|
||||
first.AuthId = "same-auth-id"
|
||||
second := validPeer("bravo1")
|
||||
second.AuthId = "same-auth-id"
|
||||
if err := ValidatePeerImportBatch([]bo.PeerExport{first, second}); err == nil {
|
||||
t.Error("дублирующийся authId принят")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePeerImportBatchRejectsOversizedBatch(t *testing.T) {
|
||||
items := make([]bo.PeerExport, MaxPeerImportItems+1)
|
||||
for i := range items {
|
||||
items[i] = validPeer("alpha1")
|
||||
}
|
||||
if err := ValidatePeerImportBatch(items); err == nil {
|
||||
t.Fatal("партия больше лимита принята")
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация обязана быть полной до первой записи в базу: файл, у которого
|
||||
// невалидна последняя запись, не должен применить первые.
|
||||
func TestValidatePeerImportBatchReportsLastInvalidRecord(t *testing.T) {
|
||||
items := []bo.PeerExport{validPeer("alpha1"), validPeer("bravo1"), validPeer("x")}
|
||||
err := ValidatePeerImportBatch(items)
|
||||
if err == nil {
|
||||
t.Fatal("невалидная последняя запись пропущена")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "#3") {
|
||||
t.Fatalf("сообщение не указывает номер записи: %v", err)
|
||||
}
|
||||
}
|
||||
+7
-33
@@ -3,9 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/util"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -19,10 +17,13 @@ func InitServer(addr string, handler http.Handler) {
|
||||
}
|
||||
}
|
||||
|
||||
func StartServer(crtPath string, keyPath string) error {
|
||||
if crtPath != "" && keyPath != "" {
|
||||
return server.ListenAndServeTLS(crtPath, keyPath)
|
||||
}
|
||||
// StartServer запускает панель по HTTP.
|
||||
//
|
||||
// Собственного TLS-слоя у админки нет: production-контракт HY2XS —
|
||||
// HY2XS_UI_BIND_HOST=127.0.0.1 и HY2XS_UI_PUBLIC_ACCESS=false, то есть
|
||||
// внутренний сервис. Ветка ListenAndServeTLS с путями к сертификатам из
|
||||
// SQLite была наследием H UI, где панель публиковалась наружу сама.
|
||||
func StartServer() error {
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -36,30 +37,3 @@ func StopServer() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetServerPortAndCert() (int64, string, string, error) {
|
||||
port, crtPath, keyPath, err := GetPortAndCert()
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
|
||||
if !util.IsPortAvailable(uint(port), "tcp") {
|
||||
errMsg := fmt.Sprintf("port %d is taken", port)
|
||||
logrus.Errorf(errMsg)
|
||||
return 0, "", "", errors.New(errMsg)
|
||||
}
|
||||
|
||||
if crtPath != "" && !util.Exists(crtPath) {
|
||||
errMsg := fmt.Sprintf("crt path: %s does not exist", crtPath)
|
||||
logrus.Errorf(errMsg)
|
||||
return 0, "", "", errors.New(errMsg)
|
||||
}
|
||||
|
||||
if keyPath != "" && !util.Exists(keyPath) {
|
||||
errMsg := fmt.Sprintf("key path: %s does not exist", keyPath)
|
||||
logrus.Errorf(errMsg)
|
||||
return 0, "", "", errors.New(errMsg)
|
||||
}
|
||||
|
||||
return port, crtPath, keyPath, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user