package service import ( "encoding/json" "errors" "fmt" "net" "net/http" "net/http/httptest" "os" "path/filepath" "sync" "testing" "hy2xs-admin/dao" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" ) // Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого // клиента. // // Контракт, который здесь доказывается, — официальный Traffic Stats API // Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов // в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана // нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy // ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому // настоящий запрос ставится без единой правки продуктового кода. const testTrafficStatsSecret = "test-traffic-stats-secret" // trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует // админка. type trafficStatsStub struct { mu sync.Mutex // online отдаётся на GET /online. online map[string]int64 // onlineStatus != 0 подменяет ответ /online кодом отказа. onlineStatus int // kickStatus != 0 подменяет ответ /kick кодом отказа. kickStatus int // Наблюдения. onlineCalls int kickCalls int kickedKeys [][]string kickAuth []string // disabledAtKick — состояние колонки disabled на момент прихода /kick. // Именно этим доказывается порядок «сначала запись, потом разрыв»: // проверить его после операции нельзя, там оба шага уже завершены. disabledAtKick []map[string]int64 bannedAtKick []map[string]int64 } func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) { t.Helper() disabled := map[string]int64{} banned := map[string]int64{} peers, err := dao.ListPeer("1=1") if err != nil { return disabled, banned } for _, peer := range peers { if peer.Name == nil { continue } if peer.Disabled != nil { disabled[*peer.Name] = *peer.Disabled } if peer.BannedUntil != nil { banned[*peer.Name] = *peer.BannedUntil } } return disabled, banned } // startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на // него конфиг Hysteria, который читает админка. func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub { t.Helper() if stub == nil { stub = &trafficStatsStub{} } if stub.online == nil { stub.online = map[string]int64{} } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stub.mu.Lock() defer stub.mu.Unlock() switch r.URL.Path { case "/online": stub.onlineCalls++ if stub.onlineStatus != 0 { w.WriteHeader(stub.onlineStatus) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(stub.online) case "/kick": stub.kickCalls++ stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization")) disabled, banned := stub.snapshot(t) stub.disabledAtKick = append(stub.disabledAtKick, disabled) stub.bannedAtKick = append(stub.bannedAtKick, banned) var keys []string if err := json.NewDecoder(r.Body).Decode(&keys); err != nil { w.WriteHeader(http.StatusBadRequest) return } stub.kickedKeys = append(stub.kickedKeys, keys) if stub.kickStatus != 0 { w.WriteHeader(stub.kickStatus) return } w.WriteHeader(http.StatusOK) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(server.Close) pointHysteriaConfigAt(t, server.URL) if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil { t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err) } return stub } // pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API // слушает порт переданного адреса. func pointHysteriaConfigAt(t *testing.T, serverURL string) { t.Helper() host := serverURL for _, prefix := range []string{"http://", "https://"} { if len(host) > len(prefix) && host[:len(prefix)] == prefix { host = host[len(prefix):] } } _, port, err := net.SplitHostPort(host) if err != nil { t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err) } path := filepath.Join(t.TempDir(), "config.yaml") content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n", port, testTrafficStatsSecret) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err) } previous := constant.Hysteria2ConfigPath constant.Hysteria2ConfigPath = path t.Cleanup(func() { constant.Hysteria2ConfigPath = previous }) } // pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не // слушает: так воспроизводится недоступность Traffic Stats API. func pointHysteriaConfigAtDeadPort(t *testing.T) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("не удалось занять порт: %v", err) } addr := listener.Addr().String() // Порт освобождается сразу: адрес известен, слушателя на нём нет. if err := listener.Close(); err != nil { t.Fatalf("не удалось освободить порт: %v", err) } pointHysteriaConfigAt(t, "http://"+addr) if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil { t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err) } } // withHysteriaRunning подменяет ответ systemd о состоянии службы. func withHysteriaRunning(t *testing.T, running bool) { t.Helper() previous := hysteria2IsRunning hysteria2IsRunning = func() bool { return running } t.Cleanup(func() { hysteria2IsRunning = previous }) } func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) { t.Helper() peer, ok := snapshotPeers(t)[name] if !ok { t.Fatalf("пир %q не найден", name) } if peer.Disabled != nil { disabled = *peer.Disabled } if peer.BannedUntil != nil { bannedUntil = *peer.BannedUntil } return disabled, bannedUntil } func requireDisconnectError(t *testing.T, err error) { t.Helper() if err == nil { t.Fatal("частичный результат не сообщён: ошибки нет вовсе") } var peerErr *PeerError if !errors.As(err, &peerErr) { t.Fatalf("отказ не несёт машиночитаемого кода: %v", err) } if peerErr.Code != constant.ErrCodePeerDisconnectFailed { t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code) } if peerErr.Field != "" { t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field) } } // --- DisconnectPeers --------------------------------------------------------- func TestDisconnectPeersSendsOfficialKickContract(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := DisconnectPeers([]int64{id}); err != nil { t.Fatalf("разрыв сессии отказал: %v", err) } if stub.kickCalls != 1 { t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls) } if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" { t.Fatalf("в /kick уехали не те идентификаторы: %v", got) } if stub.kickAuth[0] != testTrafficStatsSecret { t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0]) } } // Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно // проставлял banned_until, из-за чего им нельзя было воспользоваться для // операции «Отключить»: она записала бы временную блокировку — другой механизм // с другим сроком жизни. func TestDisconnectPeersDoesNotTouchPeerState(t *testing.T) { newTestDB(t) startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := DisconnectPeers([]int64{id}); err != nil { t.Fatalf("разрыв сессии отказал: %v", err) } disabled, bannedUntil := peerByName(t, "alpha1") if disabled != 0 { t.Errorf("разрыв сессии изменил disabled: %d", disabled) } if bannedUntil != 0 { t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil) } } func TestDisconnectPeersIsNoopWithoutTargets(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) if err := DisconnectPeers(nil); err != nil { t.Fatalf("пустой список признан отказом: %v", err) } // Пир без authId Hysteria не знает: рвать нечего. id := seedPeer(t, "alpha1", "") if err := DisconnectPeers([]int64{id}); err != nil { t.Fatalf("пир без authId признан отказом: %v", err) } if stub.kickCalls != 0 { t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls) } } func TestDisconnectPeersReportsApiFailure(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError}) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := DisconnectPeers([]int64{id}); err == nil { t.Fatal("отказ Traffic Stats API не сообщён") } } // Состояние службы по systemd на этом пути не спрашивается вовсе: ответ // systemctl не отличает «служба неактивна» от «спросить не удалось», и на // прежнем пути его сбой отказывал операции при живой Hysteria. func TestDisconnectPeersIgnoresSystemdOpinion(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) withHysteriaRunning(t, false) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := DisconnectPeers([]int64{id}); err != nil { t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err) } if stub.kickCalls != 1 { t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls) } } // --- Отключение пира --------------------------------------------------------- func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") one := int64(1) if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil { t.Fatalf("отключение пира отказало: %v", err) } disabled, bannedUntil := peerByName(t, "alpha1") if disabled != 1 { t.Fatalf("пир не отключён: disabled=%d", disabled) } // Временная блокировка — независимый механизм, отключение её не ставит. if bannedUntil != 0 { t.Errorf("отключение проставило banned_until=%d", bannedUntil) } if stub.kickCalls != 1 { t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls) } // Порядок: к моменту прихода /kick запрет новых подключений уже в базе. // При обратном порядке клиент успевает переподключиться в окне между // разрывом и записью и остаётся на связи с формально отключённым пиром. if got := stub.disabledAtKick[0]["alpha1"]; got != 1 { t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got) } } // Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина // операции достигнута, и возвращать пиру полный доступ из-за второго шага // нельзя. func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError}) id := seedPeer(t, "alpha1", "alpha-auth-id") one := int64(1) err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}) requireDisconnectError(t, err) if disabled, _ := peerByName(t, "alpha1"); disabled != 1 { t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled) } } // Включение пира не рвёт сессий и не трогает временную блокировку: это // независимые механизмы. func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := KickPeer(id, 1_700_000_000_000); err != nil { t.Fatalf("временная блокировка отказала: %v", err) } kicksAfterBan := stub.kickCalls zero := int64(0) if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil { t.Fatalf("включение пира отказало: %v", err) } if stub.kickCalls != kicksAfterBan { t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls) } disabled, bannedUntil := peerByName(t, "alpha1") if disabled != 0 { t.Fatalf("пир не включён: disabled=%d", disabled) } if bannedUntil != 1_700_000_000_000 { t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil) } } // Правка постороннего поля не является отзывом доступа и сессий не рвёт. func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil { t.Fatalf("правка комментария отказала: %v", err) } if stub.kickCalls != 0 { t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls) } } // Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на // переход из включённого. Иначе после неудачного разрыва оператору пришлось бы // включить пира, чтобы получить право отключить его снова. func TestUpdatePeerDisableIsRetryable(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError}) id := seedPeer(t, "alpha1", "alpha-auth-id") one := int64(1) requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})) stub.mu.Lock() stub.kickStatus = 0 stub.mu.Unlock() if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil { t.Fatalf("повторное отключение отказало: %v", err) } if stub.kickCalls != 2 { t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls) } } // --- Временная блокировка ---------------------------------------------------- func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) { newTestDB(t) stub := startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") const bannedUntil = int64(1_700_000_000_000) if err := KickPeer(id, bannedUntil); err != nil { t.Fatalf("временная блокировка отказала: %v", err) } disabled, banned := peerByName(t, "alpha1") if banned != bannedUntil { t.Fatalf("banned_until не записан: %d", banned) } // Временная блокировка и отключение независимы. if disabled != 0 { t.Errorf("временная блокировка изменила disabled: %d", disabled) } if stub.kickCalls != 1 { t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls) } if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil { t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got) } } // Регрессия: прежний путь начинался с проверки состояния службы и возвращал // чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому // моменту уже применилась. Оператор видел «не сработало» у сработавшей // блокировки. func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError}) id := seedPeer(t, "alpha1", "alpha-auth-id") const bannedUntil = int64(1_700_000_000_000) requireDisconnectError(t, KickPeer(id, bannedUntil)) if _, banned := peerByName(t, "alpha1"); banned != bannedUntil { t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned) } } func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) { newTestDB(t) startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") one := int64(1) if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil { t.Fatalf("отключение пира отказало: %v", err) } if err := KickPeer(id, 1_700_000_000_000); err != nil { t.Fatalf("временная блокировка отказала: %v", err) } if err := ReleaseKickPeer(id); err != nil { t.Fatalf("снятие блокировки отказало: %v", err) } disabled, banned := peerByName(t, "alpha1") if banned != 0 { t.Fatalf("banned_until не сброшен: %d", banned) } if disabled != 1 { t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled) } } // --- Ограничение устройств (fail-closed) ------------------------------------- func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError}) seedPeer(t, "alpha1", "alpha-auth-id") if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств") } } func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) { newTestDB(t) pointHysteriaConfigAtDeadPort(t) seedPeer(t, "alpha1", "alpha-auth-id") if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств") } } // Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online // отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», — // а этот ответ не отличается от «спросить systemctl не удалось». Лимит // устройств переставал проверяться, и в журнале не появлялось ни строки. func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}}) withHysteriaRunning(t, false) seedPeer(t, "alpha1", "alpha-auth-id") if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("мнение systemd отключило проверку лимита устройств") } } func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) { newTestDB(t) startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}}) id := seedPeer(t, "alpha1", "alpha-auth-id") gotID, gotAuth, err := Hysteria2Auth("alpha1-secret") if err != nil { t.Fatalf("подключение в пределах лимита отклонено: %v", err) } if gotID != id || gotAuth != "alpha-auth-id" { t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth) } } func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) { newTestDB(t) // seedPeer создаёт пира с maxDevices = 3. startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}}) seedPeer(t, "alpha1", "alpha-auth-id") if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("подключение сверх лимита устройств принято") } } // Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices // на пути принятия решения о доступе обязана вести к отказу. func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) { newTestDB(t) startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil { t.Fatalf("не удалось подготовить повреждённую строку: %v", err) } if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("пир без пригодного лимита устройств принят") } } // Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому // разыменование nil на нём означает панику в обработчике, а не отказ одному // клиенту. func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) { newTestDB(t) startTrafficStats(t, nil) id := seedPeer(t, "alpha1", "alpha-auth-id") if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil { t.Fatalf("не удалось подготовить повреждённую строку: %v", err) } defer func() { if recovered := recover(); recovered != nil { t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered) } }() if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil { t.Fatal("пир без идентичности принят") } } // Отображение остаётся терпимым: пустая картина подключений — честный ответ на // вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна // только там, где принимается решение о доступе. func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) { newTestDB(t) pointHysteriaConfigAtDeadPort(t) withHysteriaRunning(t, false) online, err := Hysteria2Online() if err != nil { t.Fatalf("отображение подключений стало отказывать: %v", err) } if len(online) != 0 { t.Fatalf("ожидалась пустая картина подключений, получено %v", online) } }