Files
HY2XS_flamy/apps/controller/peer.go
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

321 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
// resolveID читает идентификатор пира ИЗ ПУТИ и только оттуда.
//
// Запасной ветки «если в пути нет — разобрать тело» здесь больше нет. Все
// маршруты, ведущие сюда, объявлены с `:id` (см. router/peer.go), то есть
// ветка была недостижима. Хуже недостижимости было бы её срабатывание: она
// вызывала validateField, который читает тело запроса, а обработчик следом
// читает то же тело второй раз — gin его не буферизует, и второй разбор
// получил бы пустой поток. То есть запасной путь не работал бы ровно тогда,
// когда понадобился бы.
func resolveID(c *gin.Context) (int64, error) {
raw := strings.TrimSpace(c.Param("id"))
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
vo.FailValidation(
"идентификатор пира в адресе некорректен",
[]vo.FieldError{{
Code: constant.ErrCodeBodyInvalid,
Field: "id",
Message: fmt.Sprintf("ожидался положительный числовой идентификатор, получено %q", raw),
}},
c,
)
return 0, errors.New(constant.ErrCodeBodyInvalid)
}
return parsed, nil
}
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
// Неверные учётные данные получают код, чтобы панель показала
// оператору внятную фразу на его языке. Отказ базы остаётся системной
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
// значит отправить оператора искать несуществующую опечатку.
if errors.Is(err, service.ErrInvalidCredentials) {
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
return
}
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
}
func PagePeer(c *gin.Context) {
peerPageDto, err := validateField(c, dto.PeerPageDto{})
if err != nil {
return
}
records, total, onlineState, err := service.PagePeer(peerPageDto)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(vo.PeerPageVo{Records: records, Total: total, OnlineState: onlineState}, c)
}
func SavePeer(c *gin.Context) {
peerSaveDto, err := validateField(c, dto.PeerSaveDto{})
if err != nil {
return
}
peerVo, err := service.CreatePeer(peerSaveDto)
if err != nil {
failService(err, c)
return
}
vo.Success(peerVo, c)
}
func DeletePeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
// failService, а не vo.Fail: удаление умеет завершиться ЧАСТИЧНО — пир
// отключён в базе, но завершить его активную сессию не удалось, поэтому
// строка намеренно оставлена на месте. Через vo.Fail этот результат уехал
// бы панели неотличимо от полного отказа, и оператор сделал бы неверный
// вывод: «удаление не сработало, пир как был», — тогда как доступ уже
// закрыт, а строка ждёт повторной попытки.
if err = service.DeletePeer(id); err != nil {
failService(err, c)
return
}
vo.Success(nil, c)
}
func UpdatePeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
peerUpdateDto, err := validateField(c, dto.PeerUpdateDto{})
if err != nil {
return
}
peerUpdateDto.Id = &id
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" {
taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id)
if nameErr != nil {
vo.Fail(nameErr.Error(), c)
return
}
if taken {
failService(service.PeerNameTakenError(*peerUpdateDto.Name), c)
return
}
}
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
failService(err, c)
return
}
vo.Success(nil, c)
}
func ResetTraffic(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
if err = service.ResetPeerTraffic(id); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func GetPeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
peer, err := service.GetPeerVo(id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(peer, c)
}
// peerImportMaxBytes — предел размера загружаемого файла.
const peerImportMaxBytes = 2 * 1024 * 1024
// ImportPeer принимает выгрузку, сделанную ExportPeer.
//
// Импорт — полноценная дверь в таблицу пиров, поэтому его вход проверяется
// так же строго, как обычное создание пира: разбор JSON ограничен по размеру,
// неизвестные поля отклоняются, а содержимое записей валидируется в
// service.ValidatePeerImportBatch до первой записи в базу.
func ImportPeer(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
defer func() { _ = file.Close() }()
if header.Size > peerImportMaxBytes {
vo.Fail("the file is too big", c)
return
}
if !strings.HasSuffix(strings.ToLower(header.Filename), ".json") {
vo.FailValidation(
"импорт принимает только файлы .json",
[]vo.FieldError{{
Code: constant.ErrCodeImportFileExtension,
Field: "file",
Message: "импорт принимает только файлы .json",
}},
c,
)
return
}
// Заявленный Size — это то, что сказал клиент; читаем с собственным
// пределом, чтобы расхождение не превращалось в чтение произвольного
// объёма в память.
content, err := io.ReadAll(io.LimitReader(file, peerImportMaxBytes+1))
if err != nil {
vo.Fail("json file read err", c)
return
}
if len(content) > peerImportMaxBytes {
vo.Fail("the file is too big", c)
return
}
var peerExports []bo.PeerExport
decoder := json.NewDecoder(bytes.NewReader(content))
// Опечатка в имени поля должна быть видимой ошибкой, а не молча
// импортированным значением по умолчанию.
decoder.DisallowUnknownFields()
if err = decoder.Decode(&peerExports); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
// json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл вида
//
// [{"name":"peer"}]
// {"что-то":"ещё"}
//
// принимался целиком, а хвост просто не читался: оператор видел «импорт
// выполнен» и не узнавал, что применилась половина файла. Файл обязан
// содержать ровно один JSON-документ.
if err = decoder.Decode(&struct{}{}); err != io.EOF {
vo.Fail("json file must contain exactly one JSON document", c)
return
}
// failService, а не vo.Fail: импорт умеет завершиться ЧАСТИЧНО — партия
// зафиксирована в базе целиком, но завершить старые сессии обновлённых
// пиров не удалось. Полный отказ здесь означал бы для оператора «файл не
// применился», хотя он применился весь.
if err = service.UpsertPeerExport(peerExports); err != nil {
failService(err, c)
return
}
vo.Success(nil, c)
}
// ExportPeer отдаёт выгрузку пиров прямо в ответ, не создавая файл на сервере.
//
// Раньше выгрузка шла через os.Create в /var/lib/hy2xs-admin/export и файл
// оставался там навсегда. При includeSecrets=true это означало расшифрованные
// секреты пиров — фактические учётные данные доступа — в открытом виде на
// диске, накапливающиеся с каждым нажатием кнопки. Артефакт, который покидает
// сервер, не должен существовать на сервере дольше самого запроса.
func ExportPeer(c *gin.Context) {
includeSecrets := strings.EqualFold(strings.TrimSpace(c.Query("includeSecrets")), "true")
peerExports, err := service.ListExportPeer(includeSecrets)
if err != nil {
vo.Fail(err.Error(), c)
return
}
payload, err := json.MarshalIndent(peerExports, "", " ")
if err != nil {
logrus.Errorf("peer export marshal err: %v", err)
vo.Fail(constant.SysError, c)
return
}
fileName := fmt.Sprintf("PeerExport-%s.json", time.Now().Format("20060102150405"))
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Data(200, "application/octet-stream", payload)
}
func ReleaseKickPeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
if err = service.ReleaseKickPeer(id); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func KickPeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
kickDto, err := validateField(c, dto.PeerKickDto{})
if err != nil {
return
}
// failService, а не vo.Fail: отзыв доступа умеет завершиться ЧАСТИЧНО —
// блокировка записана, активную сессию завершить не удалось. Через
// vo.Fail этот результат уезжал бы панели неотличимо от полного отказа, и
// оператор сделал бы неверный вывод о состоянии пира.
if err = service.KickPeer(id, *kickDto.BannedUntil); err != nil {
failService(err, c)
return
}
vo.Success(nil, c)
}
func PeerClientConfig(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
data, err := service.BuildPeerClientConfig(id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(data, c)
}