6d1686b2be
Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.
Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.
Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.
Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.
Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.
Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.
Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
321 lines
11 KiB
Go
321 lines
11 KiB
Go
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, err := service.PagePeer(peerPageDto)
|
||
if err != nil {
|
||
vo.Fail(err.Error(), c)
|
||
return
|
||
}
|
||
vo.Success(vo.PeerPageVo{Records: records, Total: total}, 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)
|
||
}
|