diff --git a/.gitignore b/.gitignore index 667031c..9d9f9c5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ go.work.sum .idea/ .vscode/ +# Tooling scratch (Playwright MCP traces/console logs) +.playwright-mcp/ + # OS / editor noise .DS_Store ._* diff --git a/AGENTS.md b/AGENTS.md index 69ff7bd..4e7a387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,389 +1,399 @@ -# AGENTS.md — стиль и правила кода subgen - -Документ описывает структуру и стилистические правила сервиса `subgen`. Сервис -**приведён** к этой раскладке (entity / clients / repository / service / handlers, -`contract.go`+mockgen, table-driven тесты). Любой новый код пиши по этим правилам; -трогаешь старый — подтягивай его к ним в том же изменении. - -Композиционный корень — `cmd/service/main.go`: загружает config, открывает -репозитории, конструирует клиентов/сервисы и per-action хендлеры с их зависимостями, -собирает их в композит `internal/handlers/api` и поднимает ogen-сервер (он же — -единственный `http.Handler`; рядом на stdlib `http.ServeMux` только статика). **Оракула -`App` нет** — данные идут снизу вверх -(`repository`/`clients` → `service` → `handler`), кеш узкий (внутри конкретного -сервиса), без глобальных atomic-снапшотов. HTTP-хендлеры — по пакету на действие в -`internal/handlers/`, общий HTTP-инструментарий — `internal/handlers/web`. - -Дизайн и эксплуатация subgen — в [`docs/subgen.md`](docs/subgen.md) и -[`README.md`](README.md). Инфраструктурные факты, от которых зависит **код** (контракт -3x-ui API, секреты, dev-гочи) — в разделе «Инфраструктура, 3x-ui API и секреты» ниже. -Остальной документ — про **код** Go-сервиса. - -> subgen — самостоятельный продукт (подписочный сервер mihomo/Clash.Meta), выделенный -> из монорепо парка [`Postlog/vpn-toolchain`](https://github.com/Postlog/vpn-toolchain); -> топология парка, узлы и наблюдаемость живут там. - -Эталоны структуры (смотреть как образец): +# AGENTS.md — subgen code style and rules + +This document describes the structure and stylistic rules of the `subgen` service. The +service has been **brought** to this layout (entity / clients / repository / service / handlers, +`contract.go`+mockgen, table-driven tests). Write any new code by these rules; +if you touch old code — bring it up to them in the same change. + +The composition root is `cmd/service/main.go`: it loads config, opens +repositories, constructs clients/services and per-action handlers with their dependencies, +assembles them into the `internal/handlers/api` composite and brings up the ogen server (which is also +the only `http.Handler`; on the stdlib `http.ServeMux` alongside it there is only static content). **There is no +`App` oracle** — data flows bottom-up +(`repository`/`clients` → `service` → `handler`), the cache is narrow (inside a specific +service), without global atomic snapshots. HTTP handlers — one package per action in +`internal/handlers/`, shared HTTP tooling — `internal/handlers/web`. + +The design and operation of subgen are in [`docs/subgen.md`](docs/subgen.md) and +[`README.md`](README.md). Infrastructure facts that the **code** depends on (the +3x-ui API contract, secrets, dev gotchas) are in the «Infrastructure, 3x-ui API and secrets» section below. +The rest of the document is about the **code** of the Go service. + +> subgen is a standalone product (a mihomo/Clash.Meta subscription server), split off +> from the fleet monorepo [`Postlog/vpn-toolchain`](https://github.com/Postlog/vpn-toolchain); +> the fleet topology, nodes and observability live there. + +## Language — everything in English + +**All text in this repository is English, no exceptions.** This covers code identifiers and +comments, log/error messages, user-facing strings and the admin UI, the docs (`README.md`, +`AGENTS.md`, `docs/`, ADRs), the `CHANGELOG`, commit messages, and **pull-request titles and +descriptions**. Non-English text (e.g. Cyrillic) must not appear anywhere in the tree — the +only exception is operator-entered data that lives in the running store, not in the repo. +Quick guard: `grep -rI '[А-Яа-я]'` over the tree comes back empty. See +[ADR-0009](docs/decisions/0009-public-ready-and-english-docs.md). + +Structure references (look at as a model): `go.avito.ru/av/service-listing-admin`, `go.avito.ru/av/service-mnz-sf`. -## Инфраструктура, 3x-ui API и секреты - -Это не «про стиль», но код subgen завязан на эти факты — держи их в голове, правя -клиента/провижининг/деплой. Полный дизайн — в [`docs/subgen.md`](docs/subgen.md). - -### Секреты — никогда в git - -Пароли панелей, `SUBGEN_SECRET` (HMAC), админ-креды, client UUID/ключи и отрендеренные -per-client конфиги **не коммитятся**. Bootstrap-секреты — в `.env` (gitignored, рядом с -`.env.example`); `db/` (SQLite-стор) тоже gitignored. В git уходят только примеры. - -### 3x-ui API (обе панели парка — 3.2.6) — `internal/clients/xui` - -- **Auth = Bearer API-токен.** `Authorization: Bearer ` (выдать: `x-ui setting - -getApiToken` или Settings → API tokens в панели). Токен-вызовы обходят CSRF, логин/кука - не нужны — это машинный путь subgen. Браузерный логин на 3.x требует CSRF-токен - (`` → `X-CSRF-Token`) — для сервиса не используем. -- **Управление клиентами переехало** в `/panel/api/clients/*` (`add`, `update/:email`, - `del/:email`). Старый `/panel/api/inbounds/addClient` на 3.2.x отдаёт **404**. Тело add: - `{"client":{…,"tgId":0},"inboundIds":[…]}` — `tgId` это **int** (0), не строка, иначе 400. -- **Модель идентичности клиента (важно).** Клиент 3x-ui = один `uuid` (VLESS-credential); - `email` и `subId` — метки на этом uuid. **Один клиент может висеть на многих инбаундах** — - передай несколько id в `inboundIds`; uuid/email/subId остаются одни на все. Грабли, - набитые трудом: (a) `del/:email` резолвит email→один uuid и снимает его со всех инбаундов, - но если **один email на двух инбаундах с разными uuid** (провижинились отдельными `add`, - каждый минтил новый uuid) — падает `Client Not Found In Inbound For ID: ` и **не - удаляет ничего**; (b) `subId` привязан к одному email — переиспользование subId на двух - email → `subId already in use`. Поэтому пользователь провижинится как **один клиент на - панель** (один uuid, email = ник, общий subId), привязанный ко всем своим инбаундам - **одним** `add`; правка = ре-байнд того же клиента (сохраняем uuid). Per-inbound - delete-роута в этой сборке **нет** (`/panel/api/inbounds/:id/delClient/:id` → 404); только +## Infrastructure, 3x-ui API and secrets + +This is not «about style», but subgen's code is tied to these facts — keep them in mind when editing +the client/provisioning/deploy. The full design is in [`docs/subgen.md`](docs/subgen.md). + +### Secrets — never in git + +Panel passwords, `SUBGEN_SECRET` (HMAC), admin creds, client UUIDs/keys and the rendered +per-client configs are **not committed**. Bootstrap secrets — in `.env` (gitignored, next to +`.env.example`); `db/` (the SQLite store) is gitignored too. Only the examples go into git. + +### 3x-ui API (both fleet panels are 3.2.6) — `internal/clients/xui` + +- **Auth = Bearer API token.** `Authorization: Bearer ` (issue it: `x-ui setting + -getApiToken` or Settings → API tokens in the panel). Token calls bypass CSRF, login/cookie + are not needed — this is subgen's machine path. A browser login on 3.x requires a CSRF token + (`` → `X-CSRF-Token`) — we do not use it for the service. +- **Client management has moved** to `/panel/api/clients/*` (`add`, `update/:email`, + `del/:email`). The old `/panel/api/inbounds/addClient` returns **404** on 3.2.x. The add body: + `{"client":{…,"tgId":0},"inboundIds":[…]}` — `tgId` is an **int** (0), not a string, otherwise 400. +- **Client identity model (important).** A 3x-ui client = one `uuid` (VLESS credential); + `email` and `subId` are labels on that uuid. **One client can hang on many inbounds** — + pass several ids in `inboundIds`; the uuid/email/subId stay the same for all of them. Pitfalls + learned the hard way: (a) `del/:email` resolves email→one uuid and removes it from all inbounds, + but if **one email is on two inbounds with different uuids** (provisioned by separate `add` calls, + each minting a new uuid) — it fails with `Client Not Found In Inbound For ID: ` and **does + not delete anything**; (b) `subId` is bound to one email — reusing a subId across two + emails → `subId already in use`. So a user is provisioned as **one client per + panel** (one uuid, email = nickname, shared subId), bound to all of its inbounds + with a **single** `add`; an edit = a re-bind of the same client (we keep the uuid). There is **no** + per-inbound delete route in this build (`/panel/api/inbounds/:id/delClient/:id` → 404); only `del/:email`. -- `settings`/`streamSettings` приходят как **JSON-объекты** (на 3.x; до 3.x были - JSON-строкой внутри JSON) — клиент разбирает оба через `json.RawMessage`. -- Теги новых инбаундов — `in--` (напр. `in-8443-tcp`). -- DNS RU1 починен на уровне хоста — кастомный resolver-воркэраунд в xui-клиенте убран, - используется системный резолвер. - -### Dev-гочи (когда дёргаешь панели/узлы с Mac оператора) - -- **На узлах нет `sqlite3`.** Чтобы заглянуть в `/etc/x-ui/x-ui.db` — копируй локально или - ходи по HTTP API 3x-ui. -- **У Mac оператора есть HTTP(S)-прокси** (`HTTPS_PROXY`), рубящий нестандартные порты (напр. - 61001). Для curl/go против панелей префикси - `env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy`. (subgen на узле не задет — - его HTTP-транспорт прокси не ставит.) -- Прод-узел живой и общий с реальными юзерами: **сначала read-only разведка**, и - **подтверждай наружу-видимые/необратимые действия** (деплой, рестарт Xray, правки клиентов). - -### Деплой - -Docker (не systemd). Прод-деплой — ручной GitHub Actions workflow -(`.github/workflows/deploy.yml`, `workflow_dispatch`): образ собирается на runner'е (узел -RAM-голодный — Go/реестр на узле не нужны), шлётся по SSH (`docker save | ssh | docker load`), -`.env` рендерится из секретов Environment `production`, `docker compose up -d`. Подробности — -`README.md` / `docs/subgen.md`. Legacy systemd-деплой удалён (был `systemd/`). SIGHUP-релоада нет — -конфиг течёт снизу вверх из стора на каждый запрос. **Миграции БД — упорядоченный раннер -на старте** (`migrations.Apply`: `0001-init.sql`-базлайн + `NNNN-*.sql` по имени, учёт в -`schema_migrations`; см. [ADR-0002](docs/decisions/0002-ordered-migration-runner.md)). - -## Целевые правила (инверсия слоёв) - -Ниже — жёсткие правила второго прохода. Они уточняют разделы ниже; при конфликте -приоритет у этих формулировок. Любой новый код им следует; трогаешь старый — подтягивай. - -### Клиенты внешних API — тонкие адаптеры - -- Клиент = тонкая прослойка-адаптер к `entity`. **Ноль бизнес-логики.** Никакого - внутреннего состояния, кроме нужного для подключения к ресурсу (`http.Client`, - таймауты). Имя узла, публичный хост, конкретный токен — **не** состояние клиента. -- Никаких «толстых» методов: метод не дергает общий список (`ListInbounds`) и не - вычисляет из него что-то под конкретную задачу (поиск id по порту, uuid по email) — - это бизнес-логика, её место в сервисе. Клиент отдаёт сырые доменные данные. -- Если разные узлы задают разные креды подключения — **креды передаются параметром - метода**, не в конструктор. Один клиент на процесс, цель вызова — аргумент. -- **Один метод — один `.go`-файл** (`list_inbounds.go`, `add_client.go`, `del_client.go`). -- На каждый метод (= файл) — отдельный unit-тест; HTTP мокается (`httptest.Server` / - `http.RoundTripper`-мок), без живой сети. - -### Config — только статика, не течёт по слоям - -- `config` = пакет с `Load() (Config, error)`: читает окружение/`.env` (через теги, - библиотекой) и валидирует. Точка. -- У `Config` — 0 методов (максимум простые геттеры). Она «особая»: в `entity` её можно - не класть. -- `Config` **не прокидывается** по слоям. В конструкторы сервисов/хендлеров идут только - конкретные примитивные поля (никакого `New(cfg *config.Config)`). Взаимодействие со - структурой `Config` — максимум в `main`. -- **Никакого конфига из БД** (`FromStore`-подобного кода быть не должно — это нарушение - data-flow: операционные данные идут снизу вверх из репозиториев). -- **Никакого seed.** Нет данных в хранилище — значит нет данных; дефолтных - конфигов/правил/провайдеров в коде нет. - -### entity — самодокументируемые типы - -- Признаки — типами и константами, не «магическими» строками. - Эталон — `mihomo.PolicyRef`/`PolicyKind` и `RuleType`/ - `ProxyGroupType` (в пакете `internal/mihomo`): цель роутинга резолвится по - типизированному `Kind`, **никогда** по подстроке вроде +- `settings`/`streamSettings` arrive as **JSON objects** (on 3.x; before 3.x they were + a JSON string inside JSON) — the client parses both via `json.RawMessage`. +- Tags of new inbounds — `in--` (e.g. `in-8443-tcp`). +- DNS RU1 is fixed at the host level — the custom resolver workaround in the xui client has been removed, + the system resolver is used. + +### Dev gotchas (when poking panels/nodes from the operator's Mac) + +- **There is no `sqlite3` on the nodes.** To peek into `/etc/x-ui/x-ui.db` — copy it locally or + go via the 3x-ui HTTP API. +- **The operator's Mac has an HTTP(S) proxy** (`HTTPS_PROXY`) that kills non-standard ports (e.g. + 61001). For curl/go against the panels, prefix with + `env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy`. (subgen on the node is not affected — + its HTTP transport does not set a proxy.) +- The prod node is live and shared with real users: **read-only recon first**, and + **confirm externally-visible/irreversible actions** (deploy, Xray restart, client edits). + +### Deploy + +Docker (not systemd). Prod deploy — a manual GitHub Actions workflow +(`.github/workflows/deploy.yml`, `workflow_dispatch`): the image is built on the runner (the node is +RAM-starved — Go/registry are not needed on the node), shipped over SSH (`docker save | ssh | docker load`), +`.env` is rendered from the `production` Environment secrets, `docker compose up -d`. Details — +`README.md` / `docs/subgen.md`. The legacy systemd deploy has been removed (it was `systemd/`). There is no SIGHUP reload — +the config flows bottom-up from the store on every request. **DB migrations — an ordered runner +on start** (`migrations.Apply`: the `0001-init.sql` baseline + `NNNN-*.sql` by name, tracked in +`schema_migrations`; see [ADR-0002](docs/decisions/0002-ordered-migration-runner.md)). + +## Target rules (layer inversion) + +Below are the hard rules of the second pass. They refine the sections below; on conflict +these formulations take priority. Any new code follows them; if you touch old code — bring it up. + +### External-API clients — thin adapters + +- A client = a thin adapter layer to `entity`. **Zero business logic.** No + internal state except what is needed to connect to the resource (`http.Client`, + timeouts). The node name, public host, a specific token — are **not** client state. +- No «fat» methods: a method does not pull the general list (`ListInbounds`) and does not + compute something from it for a specific task (finding an id by port, a uuid by email) — + that is business logic, its place is in the service. The client returns raw domain data. +- If different nodes specify different connection creds — **the creds are passed as a method + parameter**, not into the constructor. One client per process, the call target is an argument. +- **One method — one `.go` file** (`list_inbounds.go`, `add_client.go`, `del_client.go`). +- Per method (= file) — a separate unit test; HTTP is mocked (`httptest.Server` / + `http.RoundTripper` mock), without live network. + +### Config — static only, does not flow through layers + +- `config` = a package with `Load() (Config, error)`: it reads the environment/`.env` (via tags, + with a library) and validates. Period. +- `Config` has 0 methods (at most simple getters). It is «special»: you may leave it out + of `entity`. +- `Config` is **not passed** through layers. Only the + concrete primitive fields go into the constructors of services/handlers (no `New(cfg *config.Config)`). Interacting with the + `Config` struct — at most in `main`. +- **No config from the DB** (`FromStore`-like code must not exist — it is a data-flow + violation: operational data flows bottom-up from the repositories). +- **No seed.** No data in the store means no data; there are no default + configs/rules/providers in the code. + +### entity — self-documenting types + +- Traits — via types and constants, not «magic» strings. + The reference is `mihomo.PolicyRef`/`PolicyKind` and `RuleType`/ + `ProxyGroupType` (in the `internal/mihomo` package): the routing target is resolved by a + typed `Kind`, **never** by a substring like `strings.HasPrefix(name, "<...>")`. -- Сильная типизация: client id → `github.com/google/uuid.UUID`. Где встроенный тип - неудобен — обосновать в комментарии (напр. `Node.PanelBaseURL` остаётся `string`: - гоняется через SQLite-текст/HTML-формы и только конкатенируется с path — `url.URL` - тут ничего не даёт). -- **Ссылки на сущности — по числовому id, не по имени/порту.** Имя (узла и т.п.) - мутабельно и годится только для отображения и label-имён (`-`). Всё, что - пересекает границу (API ↔ фронт) или используется как ключ поиска/диффа — это id - (`node_inbounds.id` для выбора подключения, `node.id` для группировки). Исключение — - сопоставление `inbound_port` с **внешним** 3x-ui инбаундом: порт — идентификатор на - стороне 3x-ui, не наш id. -- **Сравнение содержимого строк запрещено** (`strings.Contains(name, "...")` и т.п.). - Нужен признак — заводи булев флаг или типизированную константу. Исключения допустимы, - но только с обоснованием в комментарии. - -### Ошибки — sentinel, без интерполяции - -- Доменные ошибки — sentinel-константы в `entity`: - `var ErrNameTaken = errors.New("name already taken")`. Возвращай их - (`return entity.ErrNameTaken`), **не** подставляй имя/значение в текст ошибки — оно уже - есть в контексте вызывающего. -- Нижние слои — обёрнутые технические ошибки (`fmt.Errorf("dep.Method: %w", err)`), без - человеко-читаемого текста. -- **Ни одного `fmt.Errorf` с русским/человеко-текстом в `repository`/`service`/`clients`.** - Понятные сообщения (в т.ч. русские) — только константы на слое хендлера. -- **Классификация ошибки — в самом хендлере, по конкретным sentinel'ам.** Хендлер знает, - что он вызывает и какие типизированные ошибки оно возвращает: на них — явный - `errors.Is`/`switch`. Известный доменный sentinel → типизированный 4xx-ответ с - **локальной текст-константой этого хендлера** (`slog.Warn`); **всё остальное** - (БД/панель/marshal — инфраструктура) → `return nil, err` (станет 5xx) + `slog.Error` с - контекстом. **Ошибку БД нельзя отдавать как 400.** Никакого общего «маппера» - `UserMessage(err) → текст` на все хендлеры: тексты живут константами в каждом хендлере, - по месту проверки. -- **Уникальность — из ответа БД, без пред-чек SELECT-ов.** Дубль ловится по типизированному - коду констрейнта (`internal/repository/dberr.IsUniqueViolation`: `errors.As` → +- Strong typing: client id → `github.com/google/uuid.UUID`. Where a built-in type + is inconvenient — justify it in a comment (e.g. `Node.PanelBaseURL` stays a `string`: + it travels through SQLite text/HTML forms and is only concatenated with a path — `url.URL` + buys nothing here). +- **References to entities — by numeric id, not by name/port.** A name (of a node, etc.) is + mutable and is good only for display and label names (`-`). Everything that + crosses a boundary (API ↔ frontend) or is used as a search/diff key is an id + (`node_inbounds.id` for selecting a connection, `node.id` for grouping). The exception is + matching `inbound_port` to an **external** 3x-ui inbound: the port is an identifier on the + 3x-ui side, not our id. +- **Comparing string contents is forbidden** (`strings.Contains(name, "...")` and the like). + If you need a trait — introduce a boolean flag or a typed constant. Exceptions are allowed, + but only with a justification in a comment. + +### Errors — sentinel, without interpolation + +- Domain errors — sentinel constants in `entity`: + `var ErrNameTaken = errors.New("name already taken")`. Return them + (`return entity.ErrNameTaken`), **do not** substitute the name/value into the error text — it is already + in the caller's context. +- Lower layers — wrapped technical errors (`fmt.Errorf("dep.Method: %w", err)`), without + human-readable text. +- **Not a single `fmt.Errorf` with Russian/human text in `repository`/`service`/`clients`.** + Understandable messages (including Russian ones) — only constants on the handler layer. +- **Error classification — in the handler itself, by specific sentinels.** The handler knows + what it calls and which typed errors it returns: against them — an explicit + `errors.Is`/`switch`. A known domain sentinel → a typed 4xx response with a + **local text constant of this handler** (`slog.Warn`); **everything else** + (DB/panel/marshal — infrastructure) → `return nil, err` (becomes a 5xx) + `slog.Error` with + context. **A DB error must not be returned as 400.** No general «mapper» + `UserMessage(err) → text` across all handlers: the texts live as constants in each handler, + at the place of the check. +- **Uniqueness — from the DB response, without pre-check SELECTs.** A duplicate is caught by a typed + constraint code (`internal/repository/dberr.IsUniqueViolation`: `errors.As` → `*sqlite.Error.Code()` ∈ {`SQLITE_CONSTRAINT_UNIQUE` 2067, `SQLITE_CONSTRAINT_PRIMARYKEY` - 1555}; modernc включает extended-коды на каждом соединении — **никакого сравнения строк**), - и репозиторий переводит его в доменный sentinel (`entity.ErrNameTaken` / `ErrNodeNameTaken` / - `ErrInboundDuplicate` / `ErrRuleProviderNameTaken`). `users.NameTaken`-подобных пред-проверок - быть не должно. PK даёт 1555, обычный UNIQUE — 2067; детектор матчит оба. - -### Handlers — ogen из openapi, типизированные зависимости, структурный лог - -- **Контракт ручек описывается в OpenAPI-схеме (`openapi/`), код генерится ogen - (`internal/oas`).** Добавить/изменить ручку = править спеку (`openapi/.yaml` - + `$ref` в `openapi/openapi.yaml`), затем `go generate ./internal/oas/`, затем писать - хендлер под сгенерённый интерфейс. **Руками роуты не регистрируем** — маппинг - path+method→операция владеет сгенерённый роутер. Один пакет на операцию в - `internal/handlers/`, реализующий метод `oas.Handler`; тонкий композит - `internal/handlers/api` форвардит каждую операцию её хендлеру (без - `UnimplementedHandler` — компилятор следит за полнотой) и держит общий - `SecurityHandler` (cookie `subgen_admin`) и `ErrorHandler`. -- **ogen-сервер — единственный `http.Handler` (корень).** В `cmd/service` он монтируется - в `/` на stdlib `http.ServeMux`; рядом — **единственный** внешний роут `/admin/static/*` - (файловый хендлер, не типизированная ручка — по гайду ogen про static router). - **`gorilla/mux` не используется.** Даже браузерные страницы (login-страница - `GET /admin/login`, SPA-shell `GET /admin` и `GET /admin/{view}`) — это ogen-операции, - отдающие сырой HTML или `302`; сессионная кука у них — **ogen-параметр** (`in: cookie`), - валидируется `web.Session.Valid` (для страниц нужен redirect, а не 401 от security-схемы). -- **Контракт ответов идиоматичный:** мутации — `2xx {message}` / `4xx {errMessage}` - (`common.yaml`: `MessageResponse`/`ErrorResponse`); read-ручки — типизированный JSON. - Гейт `/admin/api/*` → `401` через ogen-`SecurityHandler`. **Admin всегда включён** - (`SUBGEN_ADMIN_PASSWORD` обязателен в конфиге; `AdminEnabled`/опциональной панели нет). - Логин — `POST /admin/api/login` (200 + `Set-Cookie`), логаут — `POST /admin/api/logout` - (204). Никаких `{ok,msg|err}`-конвертов и серверных редиректов на JSON-путях. -- **Валидация значений — в сервисе, не в схеме.** В OpenAPI кладём только форму контракта — - `required`/`type`/`format` (ogen генерит по ним декод/типы и проверку присутствия). А - **value-constraints** (`minLength`/`minItems`/`minimum`/`maxLength`/`pattern`/…) в схему - **не** кладём: ogen на них отдаёт общий невнятный `400 "Некорректный запрос"`. Вместо - этого валидируем в **сервисном слое** sentinel-ошибками (`entity.ErrValidation*`); хендлер - тонкий — мапит sentinel в типизированный 4xx с локальной текст-константой. Нет сервиса — - заводим (`internal/service/nodes` владеет валидацией узла). **Суррогатные id (PK) не - валидируем** — несуществующий id (хоть отрицательный, хоть валидный-но-отсутствующий) - одинаково даёт not-found, отдельная проверка `id≥1` бессмысленна. См. + 1555}; modernc enables extended codes on every connection — **no string comparison**), + and the repository translates it into a domain sentinel (`entity.ErrNameTaken` / `ErrNodeNameTaken` / + `ErrInboundDuplicate` / `ErrRuleProviderNameTaken`). `users.NameTaken`-like pre-checks + must not exist. A PK gives 1555, an ordinary UNIQUE — 2067; the detector matches both. + +### Handlers — ogen from openapi, typed dependencies, structured log + +- **The endpoints' contract is described in the OpenAPI schema (`openapi/`), the code is generated by ogen + (`internal/oas`).** Adding/changing an endpoint = editing the spec (`openapi/.yaml` + + `$ref` in `openapi/openapi.yaml`), then `go generate ./internal/oas/`, then writing + a handler against the generated interface. **We do not register routes by hand** — the + path+method→operation mapping is owned by the generated router. One package per operation in + `internal/handlers/`, implementing the `oas.Handler` method; a thin composite + `internal/handlers/api` forwards each operation to its handler (without + `UnimplementedHandler` — the compiler enforces completeness) and holds the shared + `SecurityHandler` (cookie `subgen_admin`) and `ErrorHandler`. +- **The ogen server is the only `http.Handler` (the root).** In `cmd/service` it is mounted + at `/` on a stdlib `http.ServeMux`; alongside it — the **only** external route `/admin/static/*` + (a file handler, not a typed endpoint — per the ogen guide on the static router). + **`gorilla/mux` is not used.** Even browser pages (the login page + `GET /admin/login`, the SPA shell `GET /admin` and `GET /admin/{view}`) are ogen operations + that return raw HTML or `302`; their session cookie is an **ogen parameter** (`in: cookie`), + validated by `web.Session.Valid` (pages need a redirect, not a 401 from the security scheme). +- **The response contract is idiomatic:** mutations — `2xx {message}` / `4xx {errMessage}` + (`common.yaml`: `MessageResponse`/`ErrorResponse`); read endpoints — typed JSON. + The `/admin/api/*` gate → `401` via the ogen `SecurityHandler`. **Admin is always on** + (`SUBGEN_ADMIN_PASSWORD` is mandatory in the config; there is no `AdminEnabled`/optional panel). + Login — `POST /admin/api/login` (200 + `Set-Cookie`), logout — `POST /admin/api/logout` + (204). No `{ok,msg|err}` envelopes and no server redirects on JSON paths. +- **Value validation — in the service, not in the schema.** Into OpenAPI we put only the shape of the contract — + `required`/`type`/`format` (ogen generates decode/types and a presence check from them). But + **value constraints** (`minLength`/`minItems`/`minimum`/`maxLength`/`pattern`/…) we do **not** + put into the schema: on them ogen returns a generic vague `400 "Bad request"`. Instead + we validate in the **service layer** with sentinel errors (`entity.ErrValidation*`); the handler is + thin — it maps the sentinel into a typed 4xx with a local text constant. No service — + we create one (`internal/service/nodes` owns node validation). **Surrogate ids (PK) we do not + validate** — a non-existent id (whether negative or valid-but-absent) + yields not-found either way, a separate `id≥1` check is meaningless. See [ADR-0003](docs/decisions/0003-validation-in-code.md). -- Зависимость хендлера — конкретный интерфейс на нужные данные. - **Анти-паттерн `cfgReader{ Cfg() *config.Config }` запрещён** — нужно конкретное поле, - прокидывай конкретное поле. -- `slog` на уровне хендлера: сообщение в форме `"handler : "`; переменные — - **только полями** лога, не в тексте сообщения - (`slog.Warn("handler node_delete: delete failed", "id", id, "err", err)`). Доменный - 4xx — `Warn`, инфраструктурный 5xx — `Error` (см. «Ошибки»). Нижние слои не логируют. - **Центральный `ErrorHandler` (`internal/handlers/api`) логирует только то, что прошло - мимо хендлеров** — ошибки security/декодинга запроса; handler-овые 5xx он не - перелогирует (их уже залогировал сам хендлер с контекстом операции). -- Бэк — **чистые JSON-ручки** (`/admin/api/*`) + отдача статики; серверных шаблонов - нет. Фронт — минимальный SPA на Vue 3 (global build, без сборки) в - `internal/handlers/web/static/` (`index.html` + `app.js` + `app.css`), данные - тянутся фетчем. **Отдача статики (`render.go`):** по умолчанию из **embed**-копии - (`//go:embed static`, самодостаточный прод-образ), либо **живьём с диска**, если задан - `SUBGEN_STATIC_DIR` (путь к каталогу относительно cwd) — тогда правки CSS/JS видны по - reload без пересборки Go (локальная разработка; `assetFS()` выбирает источник). - **Либы:** локально-вшитые (`vue.global.prod.js`, `Sortable.min.js`, `js-yaml.min.js`) - + **с CDN** — Monaco (`monaco-editor@0.52.2`, AMD-loader). Внимание к порядку скриптов: UMD-либы (`js-yaml`) грузятся **до** - Monaco-loader'а, иначе их UMD увидит `define.amd` и зарегистрируется модулем вместо - выставления глобала. Поле base-YAML — компонент `yaml-editor` на **Monaco** - (`loadMonaco()` лениво поднимает движок с CDN, `defineSubgenTheme` — тёмная тема под - палитру; язык `yaml`, подсветка/Tab/текущая строка из коробки). **Валидация синтаксиса - на лету — `jsyaml.load`** с debounce: ошибка кладётся маркером в Monaco - (`setModelMarkers`, squiggle+hover) + строка статуса (line:col + reason). - **Тема админки повторяет 3x-ui v3+** (React + Ant Design 6 dark) — это «скин» Ant-токенов - поверх Bootstrap в `app.css`: bg `#1a1b1f` / card `#23252b` (radius 12) / header `#15161a` - / modal `#2d2f37`, primary Ant-blue `#1668dc`, бордеры `rgba(255,255,255,.06–.12)`, - **системные шрифты** (никаких webfont'ов). Любой новый UI держи в этих токенах. - Read-ручки (`users_get`/`nodes_get`/`config_get`) отдают типизированный JSON; - мутации (`user_*`/`node_*`/`config_save`) принимают JSON и отвечают `2xx {message}` / - `4xx {errMessage}` (фронт читает по статусу + полю). - -### Композиция и слои - -- **Нет оракула `App`.** Композиция зависимостей и сборка роутера — в `cmd/service`. -- Данные идут **снизу вверх**: `repository`/`clients` → `service` → `handler`. Нижний - слой не знает о верхнем. -- **Кеш — узкий слой** вокруг конкретного репозитория/клиента (или внутри конкретного - сервиса), не глобальный снапшот всего. -- `ruleset/mirror`, `fleet/build` и подобная логика — это сервисный слой - (`internal/service/*`); генерация mihomo-YAML (резолв `PolicyRef`, сборка - proxy-groups/rules) — `internal/mihomo/render`. Не «магия сбоку». - -### Инфраструктура - -- Рейтлимита нет (отказались). TLS-cert-релоадер — `internal/cert`. Деплой — Docker - (не systemd). - -## Структура каталогов +- A handler's dependency — a concrete interface for the data it needs. + **The anti-pattern `cfgReader{ Cfg() *config.Config }` is forbidden** — if you need a concrete field, + pass the concrete field. +- `slog` at the handler level: the message in the form `"handler : "`; variables — + **only as log fields**, not in the message text + (`slog.Warn("handler node_delete: delete failed", "id", id, "err", err)`). A domain + 4xx — `Warn`, an infrastructure 5xx — `Error` (see «Errors»). Lower layers do not log. + **The central `ErrorHandler` (`internal/handlers/api`) logs only what slipped past the + handlers** — security/request-decoding errors; it does not re-log + handler-level 5xx (those were already logged by the handler itself with the operation context). +- The backend — **pure JSON endpoints** (`/admin/api/*`) + serving static content; there are no server templates. + The frontend — a minimal SPA on Vue 3 (global build, no bundler) in + `internal/handlers/web/static/` (`index.html` + `app.js` + `app.css`), data is + pulled by fetch. **Serving static content (`render.go`):** by default from the **embed** copy + (`//go:embed static`, a self-contained prod image), or **live from disk** if + `SUBGEN_STATIC_DIR` is set (a path to the directory relative to cwd) — then CSS/JS edits are visible on + reload without rebuilding Go (local development; `assetFS()` picks the source). + **Libs:** locally vendored (`vue.global.prod.js`, `Sortable.min.js`, `js-yaml.min.js`) + + **from a CDN** — Monaco (`monaco-editor@0.52.2`, AMD loader). Mind the script order: UMD libs (`js-yaml`) load **before** + the Monaco loader, otherwise their UMD sees `define.amd` and registers itself as a module instead of + exposing the global. The base-YAML field — a `yaml-editor` component on **Monaco** + (`loadMonaco()` lazily brings up the engine from the CDN, `defineSubgenTheme` — a dark theme matching the + palette; language `yaml`, highlighting/Tab/current-line out of the box). **Live syntax + validation — `jsyaml.load`** with debounce: the error is placed as a marker in Monaco + (`setModelMarkers`, squiggle+hover) + a status line (line:col + reason). + **The admin theme mirrors 3x-ui v3+** (React + Ant Design 6 dark) — it is a «skin» of Ant tokens + over Bootstrap in `app.css`: bg `#1a1b1f` / card `#23252b` (radius 12) / header `#15161a` + / modal `#2d2f37`, primary Ant-blue `#1668dc`, borders `rgba(255,255,255,.06–.12)`, + **system fonts** (no webfonts). Keep any new UI within these tokens. + Read endpoints (`users_get`/`nodes_get`/`config_get`) return typed JSON; + mutations (`user_*`/`node_*`/`config_save`) accept JSON and respond with `2xx {message}` / + `4xx {errMessage}` (the frontend reads by status + field). + +### Composition and layers + +- **No `App` oracle.** Dependency composition and router assembly — in `cmd/service`. +- Data flows **bottom-up**: `repository`/`clients` → `service` → `handler`. The lower + layer does not know about the upper one. +- **The cache is a narrow layer** around a specific repository/client (or inside a specific + service), not a global snapshot of everything. +- `ruleset/mirror`, `fleet/build` and similar logic is the service layer + (`internal/service/*`); generating mihomo YAML (resolving `PolicyRef`, assembling + proxy-groups/rules) — `internal/mihomo/render`. Not «magic on the side». + +### Infrastructure + +- There is no rate limit (dropped). The TLS cert reloader — `internal/cert`. Deploy — Docker + (not systemd). + +## Directory structure ``` -cmd/service/main.go — сам сервис (entrypoint) -cmd//main.go — прочие бинари (CLI-утилиты, воркеры, cron) -internal/config/ — загрузка/валидация конфига (env + .env) -internal/clients// — клиенты к внешним сетевым зависимостям (xui, …) -internal/repository// — репозитории, разбиты по сущностям (users, nodes, …) -internal/service// — сервисный слой (бизнес-логика) -internal/handlers//handler.go — HTTP-хендлеры (один пакет на действие) -internal/entity/ — общие kernel-типы домена (вход/выход слоёв), без I/O -internal/mihomo/ — поддомен mihomo-конфига (схема + decode/validate), без I/O и net/http -internal/mihomo/render/ — генерация mihomo-YAML из схемы + subscriber -migrations/0001-init.sql — базлайн схемы (первая миграция; CREATE … IF NOT EXISTS) -migrations/NNNN-*.sql — последующие миграции (ALTER/CREATE), по имени = по порядку -migrations/{embed,run}.go — раннер Apply(): накат по имени, учёт в schema_migrations +cmd/service/main.go — the service itself (entrypoint) +cmd//main.go — other binaries (CLI utilities, workers, cron) +internal/config/ — loading/validating the config (env + .env) +internal/clients// — clients to external network dependencies (xui, …) +internal/repository// — repositories, split by entity (users, nodes, …) +internal/service// — the service layer (business logic) +internal/handlers//handler.go — HTTP handlers (one package per action) +internal/entity/ — shared domain kernel types (layer in/out), without I/O +internal/mihomo/ — the mihomo-config subdomain (schema + decode/validate), without I/O and net/http +internal/mihomo/render/ — generating mihomo YAML from the schema + subscriber +migrations/0001-init.sql — the schema baseline (the first migration; CREATE … IF NOT EXISTS) +migrations/NNNN-*.sql — subsequent migrations (ALTER/CREATE), by name = by order +migrations/{embed,run}.go — the Apply() runner: applying by name, tracking in schema_migrations ``` -Правила слоёв: -- **Поток зависимостей сверху вниз:** `handlers → service → repository | clients`. - Нижний слой не знает о верхнем. Хендлер зависит от сервиса, сервис — от - репозиториев/клиентов. -- **Один пакет на действие/сущность.** Хендлер действия — отдельный пакет - `internal/handlers/do_some/`; репозиторий сущности — `internal/repository/users/`. -- **Кеш — это слой репозитория** для конкретной сущности (тот же контракт, что и - у «настоящего» репозитория; кеш оборачивает/реализует его). Не отдельная - «магия» сбоку. -- **`internal/entity`** — общие kernel-структуры домена (`Node`, `Inbound`, `User`, - `Proxy`, `Subscriber`, `Panel*`, `Fleet`, `Connection`); без сетевых вызовов и без I/O. -- **`internal/mihomo`** — выделенный поддомен mihomo-конфига: модель схемы - (`RoutingRule`/`ProxyGroup`/`PolicyRef`/`RuleProvider` + каталоги), её - decode/validate (форма→типы, sentinel-ошибки) и `render/` (YAML). Это - **осознанное исключение** из «единого плоского `entity`»: схема mihomo-конфига — - отдельный связный домен, который притекает и в БД (`mihomo_`-таблицы), и в - admin-схему, и в рендер. Жёсткие правила пакета: `mihomo` **не импортирует** - `entity` и `net/http`; ссылки на инбаунд/группу — только по `int64`-id (поэтому - цикла нет); человеко-текста ошибок в `mihomo` нет — только sentinel-константы - (`ErrGroupCycle`, `ErrMatchNotLast`, …), маппинг в русский текст — на хендлере - (`web.UserMessage`). `render/` — единственный, кому можно импортировать и - `entity` (Proxy/Subscriber), и `mihomo`. - -Раскладка уже приведена к этой цели: клиенты — `internal/clients/xui` (тонкий -адаптер, один метод — один файл); репозитории — -`internal/repository/{users,nodes,routing,configs}` (один метод — один файл; `configs` -— тип-агностичный якорь владения конфигом, см. ниже); сервисы — +Layer rules: +- **Dependency flow top-down:** `handlers → service → repository | clients`. + The lower layer does not know about the upper one. The handler depends on the service, the service — on + the repositories/clients. +- **One package per action/entity.** An action's handler is a separate package + `internal/handlers/do_some/`; an entity's repository — `internal/repository/users/`. +- **The cache is a repository layer** for a specific entity (the same contract as + the «real» repository; the cache wraps/implements it). Not separate + «magic» on the side. +- **`internal/entity`** — shared domain kernel structs (`Node`, `Inbound`, `User`, + `Proxy`, `Subscriber`, `Panel*`, `Fleet`, `Connection`); without network calls and without I/O. +- **`internal/mihomo`** — a carved-out mihomo-config subdomain: the schema model + (`RoutingRule`/`ProxyGroup`/`PolicyRef`/`RuleProvider` + catalogs), its + decode/validate (form→types, sentinel errors) and `render/` (YAML). This is a + **deliberate exception** to the «single flat `entity`»: the mihomo-config schema is a + separate cohesive domain that flows both into the DB (`mihomo_` tables), into the + admin schema, and into the render. The package's hard rules: `mihomo` does **not import** + `entity` and `net/http`; references to an inbound/group — only by `int64` id (hence + no cycle); there is no human error text in `mihomo` — only sentinel constants + (`ErrGroupCycle`, `ErrMatchNotLast`, …), the mapping into Russian text — on the handler + (`web.UserMessage`). `render/` — the only one allowed to import both + `entity` (Proxy/Subscriber) and `mihomo`. + +The layout has already been brought to this target: clients — `internal/clients/xui` (a thin +adapter, one method — one file); repositories — +`internal/repository/{users,nodes,routing,configs}` (one method — one file; `configs` +— a type-agnostic config-ownership anchor, see below); services — `internal/service/{fleet,ruleset,provisioning}` -(`fleet` владеет TTL-кешем флита; `ruleset` — миррор провайдеров); хендлеры — -`internal/handlers/`; TLS-релоадер — `internal/cert`; композиция (ogen-сервер -из `internal/oas` + статика на stdlib `http.ServeMux`) — в `cmd/service`. Пакетов -`internal/server`/`App`, `internal/{model,cache,ruleset}` и `config.FromStore`/seed больше нет. -**Бандла `repository.Store` тоже нет** — `repository.Open()` возвращает `*sql.DB`, -а per-entity репозитории (`users.New(db)`, …) собираются в композиционном корне. - -**mihomo-конфиг (роутинг) — структурные данные, не строки.** Доменные типы в -`internal/mihomo`: `RoutingRule`, `ProxyGroup`(добавить элемент), и единый типизированный +(`fleet` owns the fleet TTL cache; `ruleset` — the provider mirror); handlers — +`internal/handlers/`; the TLS reloader — `internal/cert`; composition (the ogen server +from `internal/oas` + static content on the stdlib `http.ServeMux`) — in `cmd/service`. The packages +`internal/server`/`App`, `internal/{model,cache,ruleset}` and `config.FromStore`/seed no longer exist. +**There is no `repository.Store` bundle either** — `repository.Open()` returns a `*sql.DB`, +and the per-entity repositories (`users.New(db)`, …) are assembled in the composition root. + +**The mihomo config (routing) — structured data, not strings.** The domain types are in +`internal/mihomo`: `RoutingRule`, `ProxyGroup`(an added element), and a single typed **`PolicyRef`** {`PolicyKind` direct|reject|…|inbound|group, `InboundID`, -`GroupID`} — общий для цели правила и элемента группы; `RuleType`/`ProxyGroupType` — -типы-константы. **Никаких магических строк** — резолв в имя прокси -по типу/id делает per-subscriber резолвер `internal/mihomo/render/policy.go` -(проставляет `entity.Proxy.InboundID` в `fleet/build.go`); недоступные клиенту -инбаунды выкидываются, пустая группа → `DIRECT`. `repository/routing` пишет всё -атомарно (`SaveMihomoConfig(configID, …)`: группы+элементы+правила+провайдеры+base); -таблицы mihomo-конфига — с префиксом `mihomo_`, **скоупятся по `config_id`** (FK на -`subscription_configs` CASCADE; на `node_inbounds` — RESTRICT). Ссылки на -группу на границе HTTP/save — по **индексу массива** (реальные id наружу не выходят); -decode формы (`mihomo.DecodeConfig(raw json.RawMessage)` — хендлер достаёт сырой -body) и её валидация (вкл. ацикличность графа групп) живут в `internal/mihomo` -(`decode.go`/`validate.go`) и возвращают sentinel-ошибки. -Фронт — два визуальных конструктора (группы добавить правила) с общим `policy-picker` и -drag-n-drop (вендорный SortableJS) в `internal/handlers/web/static`. **Фронт ничего -не хардкодит** — включая **таксономию ссылок**: на что может указывать цель -правила / элемент группы, объявляет схема per-type. Каталоги живут в `mihomo` +`GroupID`} — shared by the rule target and the group element; `RuleType`/`ProxyGroupType` — +type-constants. **No magic strings** — the resolution into a proxy name +by type/id is done by the per-subscriber resolver `internal/mihomo/render/policy.go` +(it sets `entity.Proxy.InboundID` in `fleet/build.go`); inbounds unavailable to the +client are dropped, an empty group → `DIRECT`. `repository/routing` writes everything +atomically (`SaveMihomoConfig(configID, …)`: groups+elements+rules+providers+base); +the mihomo-config tables — with a `mihomo_` prefix, **scoped by `config_id`** (FK to +`subscription_configs` CASCADE; to `node_inbounds` — RESTRICT). References to a +group at the HTTP/save boundary — by **array index** (real ids do not leak outside); +decoding the form (`mihomo.DecodeConfig(raw json.RawMessage)` — the handler extracts the raw +body) and validating it (incl. the acyclicity of the group graph) live in `internal/mihomo` +(`decode.go`/`validate.go`) and return sentinel errors. +The frontend — two visual builders (groups and rules) with a shared `policy-picker` and +drag-n-drop (vendored SortableJS) in `internal/handlers/web/static`. **The frontend hardcodes +nothing** — including the **reference taxonomy**: what the rule target / group element +can point to is declared by the schema per-type. The catalogs live in `mihomo` (`RuleTypeCatalog`/`ProxyGroupTypeCatalog`/`BuiltinPolicyKinds`/`RuleProviderBehaviors`/ -`RuleProviderFormats`/`GeneratedKeys` + `PolicyCategory`/`PolicyCategories` — единый -источник) и отдаются хендлером `config_schema` (`GET /admin/api/config/mihomo/schema`, -сортировка опций по имени — в хендлере) по секциям: `actions` (built-in -с лейблами), `ruleProvider`, -`proxyGroup.types[]` (опции + `items` — категории элементов), `rules.types[]` (опции + -`destinations` — категории цели), `generatedKeys`. **Категория ссылки** — -`actions`/`inbounds`/`groups`; `policy-picker` рисует только объявленные (категория -`inbounds` = все инбаунды флита, с лейблами). -Все mihomo-ручки — под `/admin/api/config/mihomo` (read / `…/schema` / `…/save` / +`RuleProviderFormats`/`GeneratedKeys` + `PolicyCategory`/`PolicyCategories` — a single +source) and are served by the `config_schema` handler (`GET /admin/api/config/mihomo/schema`, +sorting the options by name — in the handler) by sections: `actions` (built-in +with labels), `ruleProvider`, +`proxyGroup.types[]` (options + `items` — element categories), `rules.types[]` (options + +`destinations` — target categories), `generatedKeys`. **A reference category** is +`actions`/`inbounds`/`groups`; `policy-picker` draws only the declared ones (the `inbounds` +category = all fleet inbounds, with labels). +All mihomo endpoints — under `/admin/api/config/mihomo` (read / `…/schema` / `…/save` / `…/customs` / `…/custom/create` / `…/custom/delete`). -**Базовый + per-user кастомные конфиги; движок — типизированный, не предполагается -единственным.** Владение конфигом — обобщённый якорь `subscription_configs(id, -user_id, kind, created_at)`: `user_id NULL` = базовый (на всех), иначе персональный -кастом; `kind` — `entity.ConfigKind` (движок: `mihomo` сейчас, далее xray/sing-box) — -**типизированная константа, не магическая строка**. На каждый `kind` свой базовый + -максимум один кастом на юзера (unique-индекс `COALESCE(user_id,0), kind`). Контент -движка (`mihomo_*`) висит на якоре через `config_id`. Слои: -- **`internal/repository/configs`** — тип-агностичный якорь (Base/User ConfigID, - Ensure, List, Create, Delete), параметризован `entity.ConfigKind`, **ничего про - mihomo не знает**. Клон контента базового в новый кастом — делегируется - content-репозиторию через узкий `cloner`-контракт (`routing.CloneConfig`, - в общей tx). Кастом = **снимок**: после клона независим, правки базового не долетают. -- **`internal/repository/routing`** — контент mihomo, все чтения/`SaveMihomoConfig` - скоупятся `configID`; `AllRuleProviders` (по всем конфигам) — для миррора. -- **Подписка** — маршрут `/sub/{kind}/{token}`; `kind` валидируется по реестру - рендереров `map[entity.ConfigKind]sub.EngineRenderer` (собирается в `cmd/service`, - сейчас зарегистрирован mihomo). Хендлер: токен → юзер (`users.IDBySubID`) → его - кастом для `kind`, иначе базовый → `EngineRenderer.Render(sub, configID)`. Чтения - mihomo-контента спрятаны **внутрь** `sub.MihomoRenderer`, общий хендлер - engine-generic. Добавить xray = новый `EngineRenderer` + `xray_*` таблицы + - content-репозиторий + одна строка регистрации; якорь/роутер/admin-API не меняются. -- **Admin** — вкладка Config несёт область (`?user=` на read; `userId` в save-теле); - фронт — селектор «Пользователи: Все | <ник>» + «Добавить кастомный конфиг…» (клон), - баннер кастома с «Удалить». URL движка в пути (`/config/mihomo/*`). - -**Миграции БД — упорядоченный раннер, не вручную.** `migrations.Apply(ctx, db)` (пакет -`migrations`: `embed.go` + `run.go`) зовётся из `repository.Open` на старте: `0001-init.sql` -— иммутабельный **базлайн**, далее `0002-*.sql`, …; все файлы `NNNN-`-префиксные, так что -обычная сортировка имени = порядок наката (без спец-логики). Каждый файл применяется ровно -раз (учёт в `schema_migrations`), в своей транзакции, при ошибке — `slog` + падение -(`main` делает `log.Fatal`). Структурное изменение схемы = **новый `NNNN-*.sql`** — не -правка базлайна (иначе разъедется с уже усыновлёнными базами), не in-code миграции, не -`*.manual.sql` (паттерн удалён). Миграции — **чистый DDL**: connection-PRAGMA -(`journal_mode=WAL`, `foreign_keys`, `busy_timeout`) живут в DSN (`open.go`), т.к. `PRAGMA -journal_mode` нельзя выполнить внутри транзакции раннера. Откатов нет (forward-only). Если -миграция перестраивает таблицу через RENAME — ставь `PRAGMA legacy_alter_table=ON` перед -RENAME (иначе SQLite переписывает FK в чужих таблицах и оставляет висячие ссылки). См. +**A base + per-user custom configs; the engine is typed, not assumed to be the +only one.** Config ownership — a generalized anchor `subscription_configs(id, +user_id, kind, created_at)`: `user_id NULL` = base (for everyone), otherwise a personal +custom; `kind` — `entity.ConfigKind` (the engine: `mihomo` now, later xray/sing-box) — +**a typed constant, not a magic string**. Each `kind` has its own base + +at most one custom per user (the unique index `COALESCE(user_id,0), kind`). The engine's +content (`mihomo_*`) hangs on the anchor via `config_id`. The layers: +- **`internal/repository/configs`** — a type-agnostic anchor (Base/User ConfigID, + Ensure, List, Create, Delete), parameterized by `entity.ConfigKind`, **knowing nothing about + mihomo**. Cloning the base content into a new custom — is delegated to the + content repository via a narrow `cloner` contract (`routing.CloneConfig`, + in a shared tx). A custom = a **snapshot**: after the clone it is independent, edits to the base do not reach it. +- **`internal/repository/routing`** — the mihomo content, all reads/`SaveMihomoConfig` + scoped by `configID`; `AllRuleProviders` (across all configs) — for the mirror. +- **Subscription** — the route `/sub/{kind}/{token}`; `kind` is validated against the renderer registry + `map[entity.ConfigKind]sub.EngineRenderer` (assembled in `cmd/service`, + currently mihomo is registered). The handler: token → user (`users.IDBySubID`) → its + custom for `kind`, otherwise the base → `EngineRenderer.Render(sub, configID)`. The reads of + mihomo content are hidden **inside** `sub.MihomoRenderer`, the shared handler is + engine-generic. Adding xray = a new `EngineRenderer` + `xray_*` tables + + a content repository + one registration line; the anchor/router/admin API do not change. +- **Admin** — the Config tab carries a scope (`?user=` on read; `userId` in the save body); + the frontend — a selector «Users: All | » + «Add custom config…» (clone), + a custom banner with «Delete». The engine URL is in the path (`/config/mihomo/*`). + +**DB migrations — an ordered runner, not by hand.** `migrations.Apply(ctx, db)` (the package +`migrations`: `embed.go` + `run.go`) is called from `repository.Open` on start: `0001-init.sql` +— an immutable **baseline**, then `0002-*.sql`, …; all files are `NNNN-`-prefixed, so +an ordinary name sort = the apply order (without special logic). Each file is applied exactly +once (tracked in `schema_migrations`), in its own transaction; on error — `slog` + crash +(`main` does `log.Fatal`). A structural schema change = a **new `NNNN-*.sql`** — not an +edit of the baseline (otherwise it diverges from already-adopted bases), not in-code migrations, not +`*.manual.sql` (the pattern was removed). Migrations are **pure DDL**: connection PRAGMA +(`journal_mode=WAL`, `foreign_keys`, `busy_timeout`) live in the DSN (`open.go`), since `PRAGMA +journal_mode` cannot be executed inside the runner's transaction. There are no rollbacks (forward-only). If +a migration rebuilds a table via RENAME — set `PRAGMA legacy_alter_table=ON` before the +RENAME (otherwise SQLite rewrites FKs in other tables and leaves dangling references). See [ADR-0002](docs/decisions/0002-ordered-migration-runner.md). -## Зависимости: `contract.go` + mockgen +## Dependencies: `contract.go` + mockgen -Зависимости сущности (хендлера, сервиса, …) объявляются **приватным интерфейсом -в том пакете, где используются**, в файле `contract.go`, с директивой генерации -мока. Интерфейс описывает ровно те методы, что нужны этому пакету (interface -segregation), и **именуется по конкретной зависимости, на которую ссылается** (а не по -абстрактной роли). По имени должно быть видно, что это: репозиторий → `<сущность>Repo` -(`usersRepo`, `nodesRepo`, `configsRepo`, `routingRepo`), сервис → `<сущность>Service` -(`fleetService`, `provisioningService`, `sublinksService`), клиент → `<сущность>Client` -(`panelClient`, `itemPlatformClient`). Роль-имена, по которым не видно repo/service/client -(`subLinker`, `configResolver`, `mihomoReader`, `creator`, `deleter`), **запрещены** — -они усложняют чтение. +An entity's (handler's, service's, …) dependencies are declared as a **private interface +in the package where they are used**, in a `contract.go` file, with a mock-generation +directive. The interface describes exactly the methods this package needs (interface +segregation), and is **named after the concrete dependency it points at** (not an abstract +role). The name must make clear what it is: a repository → `Repo` (`usersRepo`, +`nodesRepo`, `configsRepo`, `routingRepo`), a service → `Service` (`fleetService`, +`provisioningService`, `sublinksService`), a client → `Client` (`panelClient`, +`itemPlatformClient`). Role names that don't reveal repo/service/client (`subLinker`, +`configResolver`, `mihomoReader`, `creator`, `deleter`) are **forbidden** — they make the +code harder to read. ```go // contract.go @@ -405,51 +415,51 @@ type curlService interface { } ``` -- `contract.go` + mockgen — для **сервисов, entity и клиентов** (клиент — по - своему generated SDK). Моки лежат рядом (`contract_mocks.go`), генерируются - `go generate ./...` (mockgen подключается как `go tool`). -- В тестах сервиса/entity используются **только локальные `Mock*`** из своего - `contract.go`. **Не** тащить `clients.MockClient` / `clients.New(mock)` чужого - пакета в тест сервиса — у сервиса свой приватный контракт на клиента. -- Конструктор принимает зависимости интерфейсами: `func New(c itemPlatformClient, …) *Service`. - -## Клиенты внешних API: DTO → domain - -Клиент в `internal/clients//` держит **приватные wire-DTO** с json-тегами под -ответ зависимости «как есть» (со всеми квирками: вложенность, строко-в-строке, -чужие имена полей) и **маппит их в доменные типы** (`internal/entity`) на выходе. -Наружу клиент отдаёт только `entity.*`, а DTO/декодинг — приватная деталь пакета -(anti-corruption boundary). Пример: `clients/xui` анмаршалит в приватный -`inbound`/`streamSettings` (settings приходят JSON-строкой внутри JSON — `decode()` -их разворачивает) и конвертит в `entity.PanelInbound`. Так домен не знает про -формат панели, а сервис-слой тривиально мокается по `contract.go`. - -## Публичный API - -- Тестируются и вызываются снаружи **только экспортируемые методы**. Приватные - хелперы/рендеры проверяются **через** публичный метод, который их использует. - -## Врапинг ошибок и логирование - -- **Врапинг — всегда.** При вызове зависимости оборачивай: - `fmt.Errorf("<имя поля/зависимости>.<Метод>: %w", err)` — напр. - `fmt.Errorf("economicEntitiesClient.GetByUserIDs: %w", err)`. Так стек читается - по цепочке вызовов. -- Ошибки **приватных методов того же пакета** пробрасывай **без повторного** - wrap (они уже обёрнуты внутри). -- **Тексты ошибок для пользователя формируются только на слое представления - (handler).** Репозиторий/сервис возвращают «технические» обёрнутые ошибки и - **не** генерируют человеко-читаемых текстов. Понятные сообщения — константы в - пакете хендлеров (см. `error_messages.go` у эталонов: +- `contract.go` + mockgen — for **services, entity and clients** (a client — by + its generated SDK). The mocks lie alongside (`contract_mocks.go`), generated by + `go generate ./...` (mockgen is wired in as a `go tool`). +- In service/entity tests, **only the local `Mock*`** from one's own + `contract.go` are used. **Do not** drag another package's `clients.MockClient` / `clients.New(mock)` + into a service test — the service has its own private contract for the client. +- The constructor takes dependencies as interfaces: `func New(c itemPlatformClient, …) *Service`. + +## External-API clients: DTO → domain + +A client in `internal/clients//` holds **private wire DTOs** with json tags matching the +dependency's response «as is» (with all its quirks: nesting, string-in-string, +foreign field names) and **maps them into domain types** (`internal/entity`) on output. +Outward, the client returns only `entity.*`, while the DTOs/decoding are a private detail of the package +(anti-corruption boundary). Example: `clients/xui` unmarshals into private +`inbound`/`streamSettings` (settings arrive as a JSON string inside JSON — `decode()` +unwraps them) and converts to `entity.PanelInbound`. This way the domain does not know about the +panel's format, and the service layer is trivially mocked via `contract.go`. + +## Public API + +- Only exported methods are **tested and called from the outside**. Private + helpers/renderers are checked **through** the public method that uses them. + +## Error wrapping and logging + +- **Wrapping — always.** When calling a dependency, wrap: + `fmt.Errorf(".: %w", err)` — e.g. + `fmt.Errorf("economicEntitiesClient.GetByUserIDs: %w", err)`. This way the stack reads + along the call chain. +- Errors from **private methods of the same package** propagate **without re-wrapping** + (they are already wrapped inside). +- **User-facing error texts are formed only on the presentation layer + (handler).** The repository/service return «technical» wrapped errors and + do **not** generate human-readable texts. Understandable messages — constants in + the handlers package (see `error_messages.go` in the references: `MessageEntityNotFound`, `MessageInternalError`, …). -- **Логирование — `slog`, на уровне хендлера.** Хендлер логирует ошибку (с - контекстом запроса) и отдаёт пользователю понятный текст. Нижние слои не - логируют — только возвращают обёрнутую ошибку. +- **Logging — `slog`, at the handler level.** The handler logs the error (with + the request context) and returns an understandable text to the user. Lower layers do not + log — they only return a wrapped error. -## Юнит-тесты +## Unit tests -Строгая table-driven структура. Один `Test{Type}_{Method}` на **метод** (не -дробить на `Test*_Success` / `Test*_Error`). +A strict table-driven structure. One `Test{Type}_{Method}` per **method** (do not +split into `Test*_Success` / `Test*_Error`). ```go func TestClient_Method(t *testing.T) { @@ -457,11 +467,11 @@ func TestClient_Method(t *testing.T) { tt := []struct { name string - // вход - buildMock func(mock *MockClient) // buildMocks(...) если зависимостей несколько + // input + buildMock func(mock *MockClient) // buildMocks(...) if there are several dependencies result SomeOut err error - wantErr bool // только если err — не sentinel + wantErr bool // only if err is not a sentinel }{ {name: "empty"}, {name: "success.one_user", buildMock: func(m *MockClient) { /* EXPECT */ }, result: SomeOut{ /*…*/ }}, @@ -489,47 +499,47 @@ func TestClient_Method(t *testing.T) { } ``` -| Правило | Суть | +| Rule | Essence | |---|---| -| `contract.go` + mockgen | Интерфейсы зависимостей — в `contract.go` пакета; `//go:generate go tool mockgen -source=contract.go` для сервисов, entity и клиентов | -| Публичный API | Тестируем только экспортируемые методы; приватные хелперы — через публичный | -| Table-driven | Один `Test{Type}_{Method}` на метод; `tt []struct{ name, … }`; **не** дробить на `*_Success`/`*_Error` | -| Именование кейсов | `name` **без пробелов**: `success.one_user`, `error.invalid_user_id`, `empty_fields` | -| Параллельность | `t.Parallel()` на таблице **и** в `t.Run`. Исключение: если codegen зависимостей не потокобезопасен (data race в сгенерированном `New()`/декларации) — без параллельного конструирования | -| Ветки | Минимум: `empty` (если применимо), `success` с проверкой маппинга, `error` от downstream; доменные edge-кейсы — по смыслу | -| Моки | `mockgen` по `contract.go` своего пакета → `Mock*`; **не** тянуть чужой `clients.MockClient` в тест сервиса/entity | -| Проверки | `require.ErrorIs` / `ErrorAs` / `NoError` для ошибок; `assert.Equal` для результата; `wantErr` / `ErrorContains` — только когда нет конкретного sentinel | - -## Интеграционные / API-тесты - -Живут отдельно в `apitest/` (см. `apitest/README.md`): testify-suite против -настоящего 3x-ui в docker, под build-тегом `apitest`, переиспользуемый `Base` + -суит на область (`UserSuite`, далее `NodeSuite`/`ConfigSuite`), по файлу на -сценарий. Запуск «под ключ»: `make -C apitest test`. - -- **Это чёрный ящик — тела запросов НЕ строятся из generated-структур (`internal/oas`).** - Только `map[string]any` / hand-rolled структуры / сырой JSON. Иначе тест слал бы тем же - типом, которым сервер декодит, и баг в маппинге запроса (переименованное поле, неверный - тег) был бы не виден — encode+decode одним типом дают согласованную, но неверную пару. - Так apitest проверяет реальный wire-контракт (имена полей, статус-коды, тексты ошибок). -- Required-массивы (`groups`/`rules`/`providers`/`inbounds`/…) сервер декодит строго: `null` - (во что JSON-кодируется nil-слайс) отвергается — слать пустые `[]`. - -## Документирование изменений — CHANGELOG + ADR - -Каждый PR оставляет след, чтобы «что и почему поменяли» не терялось в истории -git/GitHub. Это правило репозитория, не опция: - -- **`CHANGELOG.md`** (корень) — **одна запись на каждый PR**, обратно-хронологически. - Формат: `## YYYY-MM-DD — <короткий заголовок> (#)` + 1–2 строки сути + ссылка на - ADR, если он есть. **Без секций-версий** — у сервиса нет релизов/тегов, деплой - непрерывный. Запись добавляется в том же PR, что и изменение. -- **ADR** — для **нетривиальных** изменений (есть проектное решение, выбор между - вариантами, неочевидный trade-off): `docs/decisions/NNNN-.md`, сквозная - 4-значная нумерация, по шаблону `docs/decisions/0000-template.md` (секции **Context / - Considered Options / Decision / Consequences**) — проблема, рассмотренные варианты, - обоснование выбора. Запись в CHANGELOG ссылается на ADR. -- **Тривиальные** изменения (опечатка, бамп зависимости, мелочь без развилок) — только - строка в CHANGELOG, без ADR. -- ADR **иммутабелен после мёрджа**. Решение, отменяющее прежнее, — это новый ADR со - ссылкой `Supersedes 000X`; у старого статус меняется на `Superseded by 000Y`. +| `contract.go` + mockgen | Dependency interfaces — in the package's `contract.go`; `//go:generate go tool mockgen -source=contract.go` for services, entity and clients | +| Public API | We test only exported methods; private helpers — through the public one | +| Table-driven | One `Test{Type}_{Method}` per method; `tt []struct{ name, … }`; do **not** split into `*_Success`/`*_Error` | +| Case naming | `name` **without spaces**: `success.one_user`, `error.invalid_user_id`, `empty_fields` | +| Parallelism | `t.Parallel()` on the table **and** in `t.Run`. Exception: if the dependency codegen is not thread-safe (a data race in the generated `New()`/declaration) — without parallel construction | +| Branches | At a minimum: `empty` (if applicable), `success` with a mapping check, `error` from downstream; domain edge cases — by sense | +| Mocks | `mockgen` by one's own package `contract.go` → `Mock*`; do **not** drag a foreign `clients.MockClient` into a service/entity test | +| Assertions | `require.ErrorIs` / `ErrorAs` / `NoError` for errors; `assert.Equal` for the result; `wantErr` / `ErrorContains` — only when there is no specific sentinel | + +## Integration / API tests + +They live separately in `apitest/` (see `apitest/README.md`): a testify suite against +a real 3x-ui in docker, under the `apitest` build tag, a reusable `Base` + +a suite per area (`UserSuite`, then `NodeSuite`/`ConfigSuite`), one file per +scenario. Turnkey run: `make -C apitest test`. + +- **This is a black box — request bodies are NOT built from generated structs (`internal/oas`).** + Only `map[string]any` / hand-rolled structs / raw JSON. Otherwise the test would send with the same + type the server decodes with, and a bug in request mapping (a renamed field, a wrong + tag) would be invisible — encode+decode with one type give a consistent but wrong pair. + This way apitest checks the real wire contract (field names, status codes, error texts). +- Required arrays (`groups`/`rules`/`providers`/`inbounds`/…) are decoded strictly by the server: `null` + (what a nil slice is JSON-encoded into) is rejected — send empty `[]`. + +## Documenting changes — CHANGELOG + ADR + +Every PR leaves a trace, so that «what and why we changed» does not get lost in the +git/GitHub history. This is a repository rule, not an option: + +- **`CHANGELOG.md`** (root) — **one entry per PR**, reverse-chronologically. + Format: `## YYYY-MM-DD — (#)` + 1–2 lines of essence + a link to + an ADR, if there is one. **No version sections** — the service has no releases/tags, the deploy is + continuous. The entry is added in the same PR as the change. +- **ADR** — for **non-trivial** changes (there is a design decision, a choice between + options, a non-obvious trade-off): `docs/decisions/NNNN-.md`, a continuous + 4-digit numbering, by the template `docs/decisions/0000-template.md` (sections **Context / + Considered Options / Decision / Consequences**) — the problem, the considered options, + the rationale for the choice. The CHANGELOG entry links to the ADR. +- **Trivial** changes (a typo, a dependency bump, a small thing without forks) — only + a line in CHANGELOG, without an ADR. +- An ADR is **immutable after merge**. A decision that cancels a previous one is a new ADR with a + `Supersedes 000X` link; the old one's status changes to `Superseded by 000Y`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c65339..5b2d415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,93 +1,102 @@ # Changelog -Изменения subgen — одна запись на PR, обратно-хронологически. Нетривиальные изменения -ссылаются на ADR в [`docs/decisions/`](docs/decisions/). Правило и формат — -в [`AGENTS.md`](AGENTS.md) (раздел «Документирование изменений»). Версий/тегов нет: -сервис не релизится, деплой непрерывный. - -## 2026-06-17 — Подписка: попап со ссылками из бэкенда (raw URL + clashmi-диплинк) (#116) - -Колонка «Подписка» в списке пользователей теперь открывает попап со списком копируемых -ссылок, а не одну кнопку Mihomo: сейчас это сырой URL подписки Mihomo и диплинк -`clashmi://install-config?url=&name=&overwrite=false`. Состав ссылок и их -тайтлы целиком приходят с бэка — новый сервис `internal/service/sublinks` владеет -каталогом, фронт ничего не хардкодит (добавить движок/приложение = одна строка каталога); -в попапе показываются только тайтл и кнопка «Копировать» (значение приватное). Форма `sub` -в `GET /admin/api/users` сменилась с `{id,url}` на `{links:[{title,value}]}`; `name` -clashmi-диплинка = profile title эффективного (кастомного, иначе базового) конфига -пользователя. См. [ADR-0008](docs/decisions/0008-subscription-link-catalog.md). - -Заодно в этом PR: блок «Параметры подписки» поднят первым на вкладке «Конфиг Mihomo»; -убраны остатки githooks (таргет `make hooks` и локальный `core.hooksPath` — каталог -`.githooks/` был удалён ранее); по ревью — нейминг интерфейсов во всех `contract.go` -приведён к имени конкретной зависимости (repo → `<сущность>Repo`, service → -`<сущность>Service`, client → `<сущность>Client`) вместо ролевых имён -(`subLinker`/`configResolver`/`creator`/…); правило закреплено в `AGENTS.md`. Линтинг -сведён к единому источнику правды: `make lint` гоняет golangci-lint в pinned Docker-образе -`golangci/golangci-lint`, и **CI (`ci.yml`/`deploy.yml`) теперь вызывает тот же `make lint`** -вместо `golangci-lint-action` — локальный и CI-линт не расходятся. Платформа — нативная для -хоста (CI amd64 / Apple Silicon arm64): все включённые линтеры арх-независимы на 64-бит, так -что результат идентичный без эмуляции (максимум скорости); кэш модулей/анализа лежит в -gitignored `.lintcache/` (на CI — через `actions/cache`). Это и закрыло прежний рассинхрон -по `wsl_v5`. - -## 2026-06-16 — Логические правила mihomo (AND/OR/NOT) с рекурсивным tree-UI (#114) - -Маршрутное правило теперь умеет логические операторы `AND`/`OR`/`NOT` с произвольно -вложенными под-правилами. Правило сделано рекурсивным (`RoutingRule.Children` — той же -структуры, `Target` опционален: у под-правила его нет), без отдельной сущности «условие»; -хранение — самоссылочная `mihomo_routing_rules` (`parent_id`), не JSON-блоб. Рендер выдаёт -вложенный синтаксис дословно (`AND,((NETWORK,UDP),(DST-PORT,443)),REJECT-DROP`). Добавлены -четыре матчера паритета с вики (`SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, -`PROCESS-NAME-WILDCARD`) и `sub-rules` в `GeneratedKeys` (оператор больше не может задать -секцию в base YAML). Под-правила не несут `no-resolve` (mihomo их не парсит). UI — -рекурсивный конструктор-дерево; SUB-RULE не реализован. Схема — миграция -`migrations/0004-mihomo-rule-children.notx.sql`. См. +subgen changes — one entry per PR, reverse-chronological. Non-trivial changes +link to an ADR in [`docs/decisions/`](docs/decisions/). The rule and format are +in [`AGENTS.md`](AGENTS.md) (section "Documenting changes"). There are no versions/tags: +the service is not released, deploy is continuous. + +## 2026-06-18 — public-ready: README overhaul, MIT license, English docs & UI (#117) + +Prepared the repository for a public release. Rewrote `README.md` for newcomers (a clear +"what/why" hook, a Features list, a screenshot gallery, an env-var table) and fixed a stale +`gorilla/mux` mention. Added a `LICENSE` (MIT) and `CONTRIBUTING.md`. Translated everything +human-facing to English — `AGENTS.md`, `CHANGELOG.md`, all ADRs, `apitest/README.md`, +`docs/subgen.md`, the admin UI (`internal/handlers/web/static/`), and all user-facing +handler/error messages (unit tests and `apitest` assertions updated in lockstep). See +[ADR-0009](docs/decisions/0009-public-ready-and-english-docs.md). + +## 2026-06-17 — Subscription: a popup of links from the backend (raw URL + clashmi deeplink) (#116) + +The "Subscription" column in the users list now opens a popup with a list of copyable links +instead of a single Mihomo button: currently the raw Mihomo subscription URL and the deeplink +`clashmi://install-config?url=<enc>&name=<title>&overwrite=false`. The set of links and their +titles come entirely from the backend — a new `internal/service/sublinks` service owns the +catalog and the frontend hardcodes nothing (adding an engine/app = one catalog line); the popup +shows only the title and a "Copy" button (the value is private). The `sub` shape in +`GET /admin/api/users` changed from `{id,url}` to `{links:[{title,value}]}`; the clashmi +deeplink's `name` = the profile title of the user's effective (custom, else base) config. See +[ADR-0008](docs/decisions/0008-subscription-link-catalog.md). + +Also in this PR: the "Subscription settings" block was moved to the top of the "Mihomo config" +tab; leftover githooks were removed (the `make hooks` target and the local `core.hooksPath` — the +`.githooks/` directory was deleted earlier); per review, the interface naming in every +`contract.go` was aligned with the concrete dependency (repo → `<entity>Repo`, service → +`<entity>Service`, client → `<entity>Client`) instead of role names +(`subLinker`/`configResolver`/`creator`/…); the rule is recorded in `AGENTS.md`. Linting was +reduced to a single source of truth: `make lint` runs golangci-lint in the pinned Docker image +`golangci/golangci-lint`, and **CI (`ci.yml`/`deploy.yml`) now calls the same `make lint`** +instead of `golangci-lint-action`, so local and CI lint don't drift. The platform is host-native +(CI amd64 / Apple Silicon arm64): all enabled linters are arch-independent on 64-bit, so the +result is identical without emulation (max speed); the module/analysis cache lives in the +gitignored `.lintcache/` (on CI via `actions/cache`). This closed the previous `wsl_v5` mismatch. + +## 2026-06-16 — mihomo logical rules (AND/OR/NOT) with a recursive tree UI (#114) + +A routing rule can now use the logical operators `AND`/`OR`/`NOT` with arbitrarily +nested sub-rules. The rule was made recursive (`RoutingRule.Children` — of the same +structure, `Target` optional: a sub-rule has none), without a separate "condition" entity; +storage is the self-referential `mihomo_routing_rules` (`parent_id`), not a JSON blob. The renderer emits the +nested syntax verbatim (`AND,((NETWORK,UDP),(DST-PORT,443)),REJECT-DROP`). Four +matchers were added for parity with the wiki (`SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, +`PROCESS-NAME-WILDCARD`) and `sub-rules` in `GeneratedKeys` (the operator can no longer set this +section in the base YAML). Sub-rules carry no `no-resolve` (mihomo does not parse it for them). UI — +a recursive tree constructor; SUB-RULE is not implemented. Schema — migration +`migrations/0004-mihomo-rule-children.notx.sql`. See [ADR-0006](docs/decisions/0006-recursive-routing-rules.md). -## 2026-06-11 — Строгие ссылки mihomo: RULE-SET → rule-provider по id (#17) - -`RoutingRule` больше не хранит имя провайдера строкой в `value` — `RULE-SET` ссылается на -rule-provider по суррогатному id (`provider_id` FK); save-вход и domain/read разведены на -отдельные типы (draft с индексами vs domain с реальными id), что убирает двойной смысл -`PolicyRef.GroupID`. Опциональные поля (`value`/`interval`/`tolerance`/`lazy`/`noResolve`) — -указатели. Схема мигрируется раннером (`migrations/0003-strict-mihomo-refs.notx.sql` — -rebuild с FK off вне транзакции). См. [ADR-0005](docs/decisions/0005-strict-mihomo-refs.md). - -## 2026-06-11 — Пользователь: опциональное описание для админки (#15) - -У пользователя появилось опциональное свободнотекстовое описание (`*string`, nillable; -видно только в админ-UI): задаётся при создании/редактировании, показывается иконкой с -тултипом в таблице. Колонка `users.description` (nullable) добавляется миграцией -`migrations/0002-users-description.sql` через раннер. Сервисные входы вынесены в структуры -`entity.UserCreateParams` / `entity.UserEditParams` (убрал `entity.ConnectionSelection`). -См. [ADR-0004](docs/decisions/0004-optional-user-description.md). - -## 2026-06-11 — Валидация запросов — в сервисном слое, не в OpenAPI (#19) - -Из `openapi/*.yaml` убраны все value-constraints (`minLength`/`minItems`/`minimum`) — -ogen больше не генерит серверные валидаторы значений (общий невнятный 400). Валидация — -в сервисном слое sentinel-ошибками (`entity.ErrValidation*`), хендлеры тонкие. Заведён -`internal/service/nodes` (валидация узла + save/delete); node-валидация и `web.ValidateNode` -переехали туда. Ссылочную целостность инбаунда **не предчекаем** — её держит FK БД -(RESTRICT), репозиторий переводит нарушение в `entity.ErrInboundReferenced`. Пустой URL -provider-check — не спец-кейс (как и кривой URL → `RulesetCheckUnreachable`). Суррогатные id -(PK) **не** валидируем (несуществующий id → not-found). Тексты сообщений хендлеров сделаны -публичными и импортируются в apitest (без дублирования). В тестах `gomock.Any()` оставлен -только для контекста — остальные аргументы проверяются точно (матчеры для random uuid/subId). -`required`/`type`/`format` оставлены (форма контракта). См. +## 2026-06-11 — Strict mihomo references: RULE-SET → rule-provider by id (#17) + +`RoutingRule` no longer stores the provider name as a string in `value` — `RULE-SET` references a +rule-provider by surrogate id (`provider_id` FK); the save input and domain/read were split into +separate types (a draft with indices vs a domain with real ids), which removes the double meaning of +`PolicyRef.GroupID`. Optional fields (`value`/`interval`/`tolerance`/`lazy`/`noResolve`) are +pointers. The schema is migrated by the runner (`migrations/0003-strict-mihomo-refs.notx.sql` — +rebuild with FK off outside a transaction). See [ADR-0005](docs/decisions/0005-strict-mihomo-refs.md). + +## 2026-06-11 — User: optional description for the admin panel (#15) + +A user gained an optional free-text description (`*string`, nillable; +visible only in the admin UI): set on create/edit, shown as an icon with a +tooltip in the table. The `users.description` column (nullable) is added by migration +`migrations/0002-users-description.sql` via the runner. Service inputs were moved into the structs +`entity.UserCreateParams` / `entity.UserEditParams` (removed `entity.ConnectionSelection`). +See [ADR-0004](docs/decisions/0004-optional-user-description.md). + +## 2026-06-11 — Request validation — in the service layer, not in OpenAPI (#19) + +All value-constraints (`minLength`/`minItems`/`minimum`) were removed from `openapi/*.yaml` — +ogen no longer generates server-side value validators (the generic, vague 400). Validation is +in the service layer via sentinel errors (`entity.ErrValidation*`), the handlers are thin. A +`internal/service/nodes` was introduced (node validation + save/delete); node validation and `web.ValidateNode` +moved there. Inbound referential integrity is **not pre-checked** — it is held by the DB FK +(RESTRICT), the repository translates a violation into `entity.ErrInboundReferenced`. An empty +provider-check URL is not a special case (nor is a malformed URL → `RulesetCheckUnreachable`). Surrogate ids +(PK) are **not** validated (a nonexistent id → not-found). Handler message texts were made +public and are imported in apitest (no duplication). In tests `gomock.Any()` was kept +only for the context — the other arguments are checked exactly (matchers for random uuid/subId). +`required`/`type`/`format` were kept (the contract shape). See [ADR-0003](docs/decisions/0003-validation-in-code.md). -## 2026-06-11 — Упорядоченный раннер миграций (#18) +## 2026-06-11 — Ordered migration runner (#18) -Ручные `*.manual.sql` заменены раннером `migrations.Apply` (`repository.Open` зовёт его -вместо `ExecContext(Schema)`): `0001-init.sql` — иммутабельный базлайн, далее `NNNN-*.sql` -по имени, факт применения — в `schema_migrations`, каждая миграция в транзакции, -fail-fast + лог. Connection-PRAGMA (вкл. `journal_mode=WAL`) переехали в DSN. Раздел про -миграции в `AGENTS.md` переписан. См. [ADR-0002](docs/decisions/0002-ordered-migration-runner.md). +Manual `*.manual.sql` was replaced by the runner `migrations.Apply` (`repository.Open` calls it +instead of `ExecContext(Schema)`): `0001-init.sql` — the immutable baseline, then `NNNN-*.sql` +by name, the application fact — in `schema_migrations`, each migration in a transaction, +fail-fast + log. Connection PRAGMA (incl. `journal_mode=WAL`) moved into the DSN. The section on +migrations in `AGENTS.md` was rewritten. See [ADR-0002](docs/decisions/0002-ordered-migration-runner.md). -## 2026-06-11 — Конвенция документирования: CHANGELOG + ADR (#16) +## 2026-06-11 — Documenting convention: CHANGELOG + ADR (#16) -Заведены `CHANGELOG.md` (этот файл) и каталог ADR `docs/decisions/`; правило записано в -`AGENTS.md`. Выбран формат «одна запись на PR, без версий». -См. [ADR-0001](docs/decisions/0001-adopt-changelog-and-adr.md). +Introduced `CHANGELOG.md` (this file) and the ADR catalog `docs/decisions/`; the rule is recorded in +`AGENTS.md`. The format "one entry per PR, no versions" was chosen. +See [ADR-0001](docs/decisions/0001-adopt-changelog-and-adr.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bba08ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to subgen + +Thanks for your interest in subgen! This guide covers the dev setup, how to run the +checks, code generation, and the project conventions. + +## Prerequisites + +- **Go 1.25+** +- **Docker** — only for the black-box API tests (`apitest`), which spin up real 3x-ui panels. +- A 3x-ui panel is **not** required for local development: the admin UI and the routing + builder work against an empty store; you only need a panel to provision real users. + +## Local setup + +```sh +cp .env.example .env # set SUBGEN_SECRET (openssl rand -hex 32) and SUBGEN_ADMIN_PASSWORD + # leave SUBGEN_TLS_* empty → plain HTTP +go run ./cmd/service # http://127.0.0.1:2097/admin +``` + +For live frontend edits (no Go rebuild on CSS/JS changes), set in `.env`: + +```sh +SUBGEN_STATIC_DIR=internal/handlers/web/static +``` + +## Tests, lint, codegen + +A `Makefile` wraps the common tasks: + +```sh +make generate # go generate ./... (mockgen + ogen) +make lint # golangci-lint +make test # unit tests (go test ./...) +make integration # integration tests (-tags integration) +make apitest # black-box API tests against real 3x-ui in Docker +make all # generate + lint + test + integration + apitest +``` + +Or directly: + +```sh +go test ./... # unit +go test -tags integration ./... # integration (real SQLite) +go generate ./... # regenerate mocks + ogen server +go generate ./internal/oas/ # regenerate just the ogen server from openapi/ +``` + +CI (`.github/workflows/ci.yml`) runs lint + unit + integration + apitest on every PR; all +must be green. + +## Code generation + +- **HTTP layer** is generated by [ogen](https://github.com/ogen-go/ogen) from the OpenAPI + spec. To add or change an endpoint: edit `openapi/<endpoint>.yaml` (+ a `$ref` in + `openapi/openapi.yaml`), run `go generate ./internal/oas/`, then implement the handler + against the generated interface. **Do not register routes by hand.** +- **Mocks** are generated by [mockgen](https://github.com/uber-go/mock) from each package's + `contract.go`. After changing a `contract.go`, run `go generate ./...`. + +Commit the regenerated code together with your change. + +## Conventions + +Read **[`AGENTS.md`](AGENTS.md)** — it is the canonical style guide (layering, `contract.go` ++ mockgen dependencies, table-driven tests, sentinel errors, per-handler error +classification, the mihomo-config subdomain, migrations). New code follows it; when you +touch old code, bring it in line in the same change. + +Highlights: + +- **Layers flow top-down:** `handlers → service → repository | clients`. The lower layer + never knows about the upper one. +- **Errors:** domain sentinels in `internal/entity`; lower layers wrap technical errors + (`fmt.Errorf("dep.Method: %w", err)`); user-facing text lives as constants in the handler + package, mapped from sentinels (domain → 4xx, infra → 5xx). +- **User-facing text is English.** Don't introduce non-English strings in code or docs (the + admin UI's product labels are English too). See + [ADR-0009](docs/decisions/0009-public-ready-and-english-docs.md). +- **Validation lives in the service layer** (sentinel errors), not in the OpenAPI schema. + +## Documenting changes — CHANGELOG + ADR + +Every PR leaves a trail: + +- **`CHANGELOG.md`** — one entry per PR, reverse-chronological: + `## YYYY-MM-DD — <short title> (#PR)` + 1–2 lines, plus an ADR link if there is one. +- **ADRs** — for non-trivial changes (a design decision / trade-off), add + `docs/decisions/NNNN-<slug>.md` from `docs/decisions/0000-template.md` (Context / + Considered Options / Decision / Consequences). ADRs are immutable once merged; a later + decision that reverses one is a new ADR that `Supersedes` it. + +## Security & secrets + +Never commit secrets. `.env` is gitignored (only `.env.example` is tracked), and so is the +SQLite store (`db/`). Panel API tokens, `SUBGEN_SECRET`, admin credentials and rendered +per-client configs must never land in git. + +## Pull requests + +- Branch off `main`, keep the change focused, and make sure `make all` (or at least lint + + unit + integration) is green. +- Include the `CHANGELOG.md` entry (and an ADR if warranted) in the same PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e3b4095 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 postlog + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 22627f2..11115c4 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,128 @@ # subgen -Per-client **mihomo (Clash.Meta) subscription server**. It renders a full mihomo -YAML config per subscriber and serves it at `/sub/{kind}/{token}`, and ships a small admin -panel for managing nodes, the mihomo config (proxy-groups + routing rules) and users. - -There is one shared **base** config plus optional **per-user custom configs** (a full -snapshot of the base that the operator then edits freely); a subscriber is served their -custom config when one exists, else the base. The engine is a URL segment (`{kind}`, -`mihomo` today) so the same token can serve other formats later (xray/sing-box). - -It exists because 3x-ui's built-in Clash subscription only emits a flat `proxies` -list — no proxy-groups, rules or rule-providers — so it can't express the routing -UX we want. See [`docs/subgen.md`](docs/subgen.md) for the full design. - -## Configuration model - -Two clean halves: - -- **Bootstrap** (listener, TLS, secret, admin creds, db path) → environment - variables, loaded from a local [`.env`](.env.example) file. Nothing secret in git. -- **Operational data** (nodes/panels, proxy-groups, routing rules, rule-providers, - the base YAML, users, per-user custom configs) → the **SQLite store** (`db/subgen.db`), - edited entirely through the admin panel at `/admin`. A fresh store starts **empty** — - the operator fills in everything through the panel. No defaults are seeded. Inside the - one file the tables are split logically: the config-ownership anchor is - `subscription_configs`, mihomo-config tables are prefixed `mihomo_` (scoped by - `config_id`), subgen-admin tables (nodes/inbounds/users) are not (SQLite has no in-file - schemas and FKs can't cross attached DBs, so a single file keeps the inbound↔rule/member - FKs intact). - -There is no `routing.yaml` anymore — it was split into the two halves above. - -## Routing config (proxy-groups + rules) - -The **Конфиг Mihomo** tab is a visual constructor with two halves, both reordered by -drag-and-drop: - -- **Proxy-groups** — operator-defined mihomo proxy-groups (a `select` group named - e.g. `🎯 Подключение` is the connection switcher; there is no hardcoded group - anymore). Each group has a type and an ordered list of **members**. -- **Правила** — ordered routing rules; each has a mihomo matcher type, a value, an - optional `no-resolve`, and a **target**. - -A rule target and a group member are the **same typed reference** — a `PolicyRef`: -a built-in policy (`DIRECT`/`REJECT`/…/`PASS`), an **inbound** (by id), or another -**group**. There are no magic strings — the target is resolved by typed `PolicyKind`. -At render time each ref is resolved for the subscriber; an `inbound` ref the subscriber -lacks is **dropped** (from rules and from group members), and a group left empty -falls back to `DIRECT`, so the config always stays referentially intact and -auto-scales as nodes/inbounds are added. - -## Per-user custom configs - -By default every subscriber renders from the shared **base** config. The **Конфиг -Mihomo** tab has a scope selector (**Пользователи: Все** = base, or a specific user); -**Добавить кастомный конфиг…** clones the base into an independent **custom config** -bound to that user, which you then edit like any other config. It is a **snapshot** — -later base edits do not propagate to existing custom configs. **Удалить** drops the -custom config and the user falls back to the base. - -Ownership lives in a small engine-agnostic anchor table `subscription_configs` -(`user_id NULL` = base, one custom per user per engine); the `mihomo_*` content tables -are scoped to it by `config_id`. On a `/sub/{kind}/{token}` request subgen resolves the -token → user → their custom config for that engine, else the base. Engine selection is -a per-`kind` renderer registry (mihomo today) — adding xray/sing-box is a new renderer -+ content tables on the same anchor. - -## Run locally +**Self-hosted, per-client [mihomo](https://github.com/MetaCubeX/mihomo) (Clash.Meta) subscription server with a visual routing builder, on top of [3x-ui](https://github.com/MHSanaei/3x-ui).** + +[![CI](https://github.com/Postlog/subgen/actions/workflows/ci.yml/badge.svg)](https://github.com/Postlog/subgen/actions/workflows/ci.yml) +![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white) +![License: MIT](https://img.shields.io/badge/License-MIT-green.svg) +![Engine: mihomo / Clash.Meta](https://img.shields.io/badge/engine-mihomo%20%2F%20Clash.Meta-1668dc) + +subgen turns a fleet of [3x-ui](https://github.com/MHSanaei/3x-ui) panels into a proper +**Clash/mihomo subscription service**: it renders a full mihomo YAML config per subscriber +— with operator-defined **proxy-groups**, **routing rules** and **rule-providers** — and +serves it at `/sub/{kind}/{token}`. A small built-in admin panel manages nodes, users and +the routing config. + +![subgen admin panel — Users](docs/img/users_overview.png) + +## What is it, and why? + +3x-ui already ships a built-in Clash subscription, but it only emits a **flat `proxies` +list** — no proxy-groups, no rules, no rule-providers. That is not enough to express any +real routing UX (a connection switcher, ad-blocking, split-tunnel by region, QUIC drop, +RULE-SET providers, …). + +subgen sits in front of your 3x-ui panels and fixes that: + +- it reads your panels over the **3x-ui HTTP API** (Bearer token — no login/CSRF), +- lets you build a **rich mihomo config** visually (proxy-groups + rules + providers), +- and serves each subscriber a **complete, per-client mihomo YAML** at a tokenised URL. + +It is meant for operators who run one or more 3x-ui nodes and want a single place to manage +users and a shared (or per-user) routing config — without hand-writing YAML per client. + +> subgen is a standalone product spun out of the [`Postlog/vpn-toolchain`](https://github.com/Postlog/vpn-toolchain) +> monorepo. See [`docs/subgen.md`](docs/subgen.md) for the full design and operations guide. + +## Features + +- **Per-client mihomo/Clash.Meta config** rendered fresh per request and served at + `/sub/{kind}/{token}` (`kind` = engine; `mihomo` today, designed for xray/sing-box later). +- **Visual routing builder** — operator-defined **proxy-groups** (`select` / `url-test` / + `fallback` / `load-balance` / `relay`) and **routing rules**, both reorderable by + drag-and-drop. +- **Typed references, no magic strings** — a rule target and a group member are the same + typed `PolicyRef` (a built-in policy, an **inbound** by id, or another group); refs the + subscriber can't reach are dropped at render time and the config stays referentially + intact. +- **Logical rules (AND/OR/NOT)** with recursive nesting, edited as a tree. +- **Rule-providers** with optional **mirroring** — subgen fetches the upstream ruleset and + re-serves it from `/rules/...`, so clients keep working when the upstream is unreachable. +- **Shared base + per-user custom configs** — every subscriber gets the shared base config; + optionally clone it into an independent **per-user snapshot** and edit it freely. +- **3x-ui fleet management** — register nodes/panels, read inbounds over the Bearer API, + one-click **user provisioning** onto all selected inbounds (one client / one uuid per + panel, shared `subId`). +- **Tokenised subscription links** — `token = HMAC-SHA256(secret, subId)`; proxy UUIDs + never appear in the URL. +- **Schema-driven admin UI** — the rule/group/policy/provider taxonomy comes from the + backend; nothing is hardcoded in the frontend. +- **Single static binary** — embedded Vue 3 SPA (no build step), pure-Go SQLite + (`modernc.org/sqlite`, CGO-free), shipped as a distroless Docker image. +- **Ordered DB migrations**, `slog` structured logging, table-driven unit tests + + black-box API tests against real 3x-ui panels in CI. + +## Screenshots + +The admin panel is a dark Vue 3 SPA themed after 3x-ui v3 (Ant Design dark). + +### Routing builder (Mihomo config) + +Visual **proxy-groups** with typed members, drag-and-drop reorder: + +![Mihomo config — proxy-groups builder](docs/img/mihomo_config_overview_1.png) + +**Routing rules** (including logical `AND`/`OR`/`NOT` with nested sub-rules), **rule-providers**, subscription profile, and the base-YAML editor: + +![Mihomo config — rules, rule-providers and base YAML](docs/img/mihomo_config_overview_2.png) + +A **rule-provider** with optional mirroring through subgen and a built-in URL/format check: + +![Rule-provider editor with mirroring](docs/img/mihomo_rule_provider_config.png) + +### Nodes + +| Node registry | Add / edit a node | +| --- | --- | +| ![Nodes overview](docs/img/nodes_overview.png) | ![Edit node](docs/img/node_conifg.png) | + +### Users + +| Users list & subscriptions | Assign a user's inbounds | +| --- | --- | +| ![Users overview](docs/img/users_overview.png) | ![Edit user](docs/img/user_config.png) | + +## How it works + +``` +node registry (SQLite) + 3x-ui /panel/api/inbounds/list -> BuildFleet -> render mihomo YAML + (Bearer token) | +client GET /sub/{kind}/{token} --(token=HMAC(secret,subId))--> resolve subId ---------+--> YAML + headers +``` + +- Panels (3x-ui >= 3.2) are read with `Authorization: Bearer <token>` — no login/CSRF. +- `settings` / `streamSettings` may be JSON objects (3.x) or strings (legacy); both handled. +- The fleet is built **fresh per request**; an unreachable panel is skipped, only a total + outage errors. +- On a `/sub` request subgen resolves `token → subId → user`, picks the user's custom + config for that engine (else the base), and renders it to YAML plus the subscription + headers (`Profile-Update-Interval`, `Profile-Title`, `Subscription-Userinfo`, filename). +- Mirrored rule-providers are fetched in the background and served from `/rules/<name><ext>`. + +## Quick start (local) + +Requires **Go 1.25+**. No 3x-ui panel is needed to boot the admin UI and build a routing +config; you only need a panel to provision real users. ```sh -cp .env.example .env # then set SUBGEN_SECRET (openssl rand -hex 32) - # leave SUBGEN_TLS_* empty → plain HTTP, no cert needed +cp .env.example .env # then set SUBGEN_SECRET (openssl rand -hex 32) + # and SUBGEN_ADMIN_PASSWORD; leave SUBGEN_TLS_* empty → plain HTTP go run ./cmd/service # reads ./.env, creates db/subgen.db, listens on 127.0.0.1:2097 ``` -Open <http://127.0.0.1:2097/admin> (admin / your `SUBGEN_ADMIN_PASSWORD`). Add a -node under **Узлы**, then create a user under **Пользователи** and copy its -subscription link. The store lives in `db/` (gitignored); delete it to start fresh. +Open <http://127.0.0.1:2097/admin> and sign in (`admin` / your `SUBGEN_ADMIN_PASSWORD`). +Add a node under **Nodes**, create a user under **Users**, then copy its subscription link. +The store lives in `db/` (gitignored) — delete it to start fresh. Debug helpers: @@ -85,15 +131,41 @@ go run ./cmd/subctl -dump-fleet # print every subId, its token and proxie go run ./cmd/subctl -print <subId> # render one config to stdout ``` -Node, user and routing-config edits take effect immediately — the next `/sub` -request reads the store live, and the fleet cache is invalidated on writes. The -rule-provider **mirror** set is fixed at startup, so changing which providers are -mirrored needs a restart. +Node, user and routing-config edits take effect immediately — the next `/sub` request reads +the store live. The rule-provider **mirror** set is fixed at startup, so changing which +providers are mirrored needs a restart. + +## Configuration + +subgen splits config into two clean halves: + +- **Bootstrap** (listener, TLS, secret, admin creds, db path) → **environment variables**, + loaded from a local [`.env`](.env.example) file. Nothing secret in git. +- **Operational data** (nodes, proxy-groups, rules, rule-providers, base YAML, users, + per-user custom configs) → the **SQLite store** (`db/subgen.db`), edited entirely through + the admin panel. A fresh store starts **empty** — no defaults are seeded. + +### Environment variables + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `SUBGEN_SECRET` | **yes** | — | HMAC-SHA256 key for `/sub` tokens and admin sessions. `openssl rand -hex 32`. Rotating it invalidates every subscription link and logs admins out. | +| `SUBGEN_ADMIN_PASSWORD` | **yes** | — | Admin panel password (gates `/admin/api/*`). | +| `SUBGEN_ADMIN_USER` | no | `admin` | Admin login username. | +| `SUBGEN_LISTEN` | no | `0.0.0.0:2097` | HTTP(S) listen address. Use `127.0.0.1:2097` locally. | +| `SUBGEN_TLS_CERT` | no | — | TLS cert path. Set **both** cert+key for HTTPS, or leave **both** empty for plain HTTP. | +| `SUBGEN_TLS_KEY` | no | — | TLS private key path. | +| `SUBGEN_PUBLIC_BASE` | no | — | External base URL (scheme+host+port, no path) written into subscription links, e.g. `https://subgen.example.com:2097`. | +| `SUBGEN_DB_PATH` | no | `db/subgen.db` | SQLite path (relative to cwd); created if missing. | +| `SUBGEN_STATIC_DIR` | no | — | Serve the admin UI live from this on-disk dir instead of the embedded copy (local dev: edit + reload, no Go rebuild). Leave empty in production. | + +Subscription-profile knobs (title, filename, update interval) are **not** env vars — they +are per-config settings edited in the admin **Mihomo config** tab. ## Docker -Production runs subgen as a **Docker container** (`Dockerfile` → multi-stage, -distroless static, nonroot). Run it locally with compose: +Production runs subgen as a **distroless, nonroot static** container (`Dockerfile`, +multi-stage). Run it locally with compose: ```sh docker compose build # static binary → distroless image @@ -101,87 +173,66 @@ mkdir -p db && sudo chown -R 65532:65532 db # nonroot (uid 65532) writes the S docker compose up -d # reads ./.env, persists ./db, listens on :2097 ``` -Set `SUBGEN_LISTEN=0.0.0.0:2097` in `.env`. For TLS, point `SUBGEN_TLS_CERT/KEY` -under `/certs` and mount the cert dir (see `docker-compose.yml`). +Set `SUBGEN_LISTEN=0.0.0.0:2097` in `.env`. For TLS, point `SUBGEN_TLS_CERT/KEY` under +`/certs` and mount the cert dir (see `docker-compose.yml`). -## Deploy +## Production deploy Production deploy is a **manual GitHub Actions workflow** -([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)): the image is built on -the runner (the node is RAM-starved and can't), streamed to the server over SSH, and -run with `docker compose`. Tests gate the deploy; bootstrap secrets are injected into -the server-side `.env` from the `prod` Environment. +([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)): tests gate the deploy, +the image is built on the runner, streamed to the server over SSH (`docker save | ssh | +docker load`), and run with `docker compose`. The `db/` bind-mount (panel tokens, nodes, +users) **persists across deploys**. ```sh gh workflow run deploy.yml -f ref=main # or: Actions → Deploy → Run workflow ``` -It runs `lint + unit + integration`, then on the server: `docker load` the image, -render `.env`, `docker compose up -d` (with `docker-compose.prod.yml`), and a -`/healthz` check. The `db/` bind-mount (panel tokens, nodes, users) **persists across -deploys** — CD never touches it. - -**One-time setup** (operator): - -- **GitHub → Settings → Environments → `prod`:** - - **Secrets:** `SUBGEN_SECRET`, `SUBGEN_ADMIN_USER`, `SUBGEN_ADMIN_PASSWORD` (the - *live* values — don't rotate `SUBGEN_SECRET`, it would invalidate every subscription - link; the admin login is treated as a credential too), `DEPLOY_SSH_KEY` (a dedicated - deploy private key). - - **Variables:** `DEPLOY_HOST`, `DEPLOY_PORT`, `DEPLOY_USER`, `DEPLOY_DIR` (`subgen`), - `DEPLOY_KNOWN_HOSTS` (`ssh-keyscan -p <port> <host>`), `SUBGEN_PUBLIC_BASE`, - `SUBGEN_TLS_CERT`, `SUBGEN_TLS_KEY` (paths under `/certs`), `CERT_HOST_DIR` (host - cert dir, e.g. `/root/cert/<domain>`). -- **Server:** Docker installed + the deploy user in the `docker` group; append the - deploy key's public half to `~/.ssh/authorized_keys`; once, - `mkdir -p ~/subgen/db && sudo chown -R 65532:65532 ~/subgen/db`. No git checkout - needed — the workflow ships the compose + `.env`. -- **Cert perms:** the image runs **nonroot** (uid 65532), so the TLS privkey in - `CERT_HOST_DIR` must be world-readable (`chmod 644`) for the container to read it — - acme's `--reloadcmd` keeps it that way and restarts the container on renewal. - -Roll back by re-running the workflow with an older `ref`. Once the node moves to a -beefier host, you can drop the build/ship steps and switch to server-side -`docker compose up -d --build`. - -## How it flows +The one-time GitHub Environment / server setup (secrets, deploy key, cert perms) is +documented in [`docs/subgen.md`](docs/subgen.md). -``` -node registry (SQLite) + 3x-ui /panel/api/inbounds/list -> BuildFleet -> render mihomo YAML - (Bearer token) | -client GET /sub/{kind}/{token} --(token=HMAC(secret,subId))--> resolve subId ---------+--> YAML + headers -``` - -- Panels (3x-ui >= 3.2) are read with `Authorization: Bearer <token>` — no login/CSRF. -- `settings` / `streamSettings` may be JSON objects (3.x) or strings (legacy); both handled. -- `token = HMAC-SHA256(secret, subId)` — proxy UUIDs never appear in the URL. -- Fleet is built fresh per request (no cache); an unreachable panel is skipped, only a total outage errors. -- Mirrored rule-providers are fetched in the background and served from `/rules/<name><ext>`. - -## Layout +## Architecture ``` -cmd/service/ composition root: load config, wire services, gorilla/mux router, TLS, shutdown +cmd/service/ composition root: load config, wire services, ogen server + static, TLS, shutdown cmd/subctl/ CLI utility: -dump-fleet / -print <subId> migrations/0001-init.sql baseline schema (first migration; embedded) migrations/NNNN-*.sql ordered migrations, run in filename order by migrations.Apply on open -migrations/{embed,run}.go migration runner (tracks applied files in schema_migrations) -internal/entity/ kernel domain types + sentinel errors (User, Node, Inbound, - Fleet, Subscriber, Proxy, …) -internal/mihomo/ mihomo-config subdomain: schema (RoutingRule, ProxyGroup, PolicyRef, - RuleProvider, catalogs) + decode/validate (sentinel errors); no entity/net-http import +internal/entity/ kernel domain types + sentinel errors (User, Node, Inbound, Fleet, Subscriber, Proxy, …) +internal/mihomo/ mihomo-config subdomain: schema (RoutingRule, ProxyGroup, PolicyRef, RuleProvider) + decode/validate internal/mihomo/render/ mihomo YAML generation (proxies, proxy-groups, rules; per-subscriber PolicyRef resolver) internal/config/ .env bootstrap load (env tags) + validation internal/clients/xui/ 3x-ui API client (stateless; panel passed per call; one method per file) internal/repository/ SQLite: Open() -> *sql.DB; users/ nodes/ routing/ configs/ (per-entity, one method per file) - configs/ is the engine-agnostic config-ownership anchor (base vs per-user custom) -internal/service/fleet/ fetch panels + BuildFleet + narrow TTL cache (stale-on-error) -internal/service/ruleset/ background mirror of rule-provider files -internal/service/provisioning/ user create/edit/delete/recreate + panel reconcile -internal/handlers/web/ shared HTTP kit: renderer (static SPA), session, JSON, user-facing message mapping -internal/handlers/<action>/ one package per action (contract.go + handler.go) +internal/service/ fleet (fetch panels + BuildFleet + TTL cache) / ruleset (mirror) / provisioning (user CRUD + panel reconcile) +internal/oas/ ogen-generated typed server from openapi/ +internal/handlers/<action>/ one package per HTTP action (contract.go + handler.go) +internal/handlers/api/ thin composite: forwards each ogen operation to its handler; SecurityHandler + ErrorHandler +internal/handlers/web/ shared HTTP kit + the embedded Vue 3 SPA (static/) internal/cert/ TLS cert reloader (reloads on file change) internal/token/ HMAC sub tokens ``` -Code style & conventions for this layout: **[AGENTS.md](AGENTS.md)**. +The HTTP layer is **ogen-generated from the OpenAPI spec** (`openapi/` → `internal/oas`), +mounted at `/` on a stdlib `http.ServeMux`; the only side route is `/admin/static/*` for +assets. There is no `App` oracle — dependencies flow bottom-up +(`repository`/`clients` → `service` → `handler`), wired in `cmd/service`. + +## Documentation + +- [`docs/subgen.md`](docs/subgen.md) — design rationale, operations, admin-panel tour, deploy. +- [`AGENTS.md`](AGENTS.md) — code style & conventions (layers, contracts, testing, errors). +- [`docs/decisions/`](docs/decisions) — Architecture Decision Records (ADRs). +- [`CHANGELOG.md`](CHANGELOG.md) — one entry per PR. +- [`openapi/`](openapi) — the HTTP contract (source of the generated server). +- [`apitest/README.md`](apitest/README.md) — black-box API tests against real 3x-ui. + +## Contributing + +Contributions are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the dev setup, +how to run the tests/lint and code generation, and the project conventions (start with +[`AGENTS.md`](AGENTS.md)). + +## License + +[MIT](LICENSE) © postlog diff --git a/apitest/README.md b/apitest/README.md index b7d21f1..42c5d40 100644 --- a/apitest/README.md +++ b/apitest/README.md @@ -1,25 +1,25 @@ -# subgen API-тесты (чёрный ящик: настоящий сервер + настоящий 3x-ui) +# subgen API tests (black box: a real server + a real 3x-ui) -Интеграционные тесты, которые гоняют **настоящий сервер subgen** через его HTTP-API и -проверяют как ответы API, так и фактическое состояние клиентов на инбаундах **настоящих** -панелей 3x-ui 3.2.6, поднятых в docker. +Integration tests that drive the **real subgen server** through its HTTP API and +check both the API responses and the actual state of clients on the inbounds of **real** +3x-ui 3.2.6 panels brought up in docker. -Это честный чёрный ящик: тест собирает бинарь subgen (`go build`), запускает его -**отдельным процессом** (временная SQLite-БД, тестовые админ-креды, свободный -loopback-порт, plain HTTP), логинится и дёргает реальные эндпоинты -(`/admin/api/...`, `/sub/{kind}/{token}`, `/rules/{file}`, `/healthz`). Никакого доступа к -сервисам/репозиториям изнутри — единственный вход тот же API, что у оператора и SPA. -Так ловятся именно те баги, что unit-тесты поймать не могут (семантика `del/:email`, -multi-inbound клиент, поведение хендлеров и контракт ответов — `2xx {message}` / -`4xx {errMessage}` (ogen), статус-коды, точный текст ошибок и т.п.). +This is an honest black box: the test builds the subgen binary (`go build`), starts it as a +**separate process** (a temporary SQLite DB, test admin creds, a free +loopback port, plain HTTP), logs in and hits the real endpoints +(`/admin/api/...`, `/sub/{kind}/{token}`, `/rules/{file}`, `/healthz`). No access to the +services/repositories from the inside — the only entry is the same API the operator and the SPA use. +This way exactly those bugs are caught that unit tests cannot (the semantics of `del/:email`, +a multi-inbound client, handler behavior and the response contract — `2xx {message}` / +`4xx {errMessage}` (ogen), status codes, the exact error text, etc.). -## Раскладка по пакетам +## Layout by package ``` -apitest/api/ — общий support-пакет (НЕ _test, под тегом apitest): - SDK Client + старт сервера + Base-суит + проверка «земли» 3x-ui. -apitest/auth/ — POST /admin/api/login + GET /admin/login (страница), POST /admin/api/logout, - гейт сессии (401), статика+SPA-shell. +apitest/api/ — the shared support package (NOT _test, under the apitest tag): + the SDK Client + server start + the Base suite + the 3x-ui «ground truth» check. +apitest/auth/ — POST /admin/api/login + GET /admin/login (the page), POST /admin/api/logout, + the session gate (401), static + the SPA shell. apitest/users/ — /admin/api/users/{create,edit,delete,recreate} + GET /admin/api/users. apitest/nodes/ — /admin/api/nodes/{save,delete} + GET /admin/api/nodes. apitest/config/ — /admin/api/config/mihomo (read / schema / save / provider/check; @@ -27,118 +27,118 @@ apitest/config/ — /admin/api/config/mihomo (read / schema / save / provide apitest/sub/ — /healthz, /sub/{kind}/{token}, /rules/{file}. ``` -Каждый `apitest/<area>` — отдельный `*_test`-пакет, импортирующий `apitest/api`. Внутри — -по файлу на эндпоинт/сценарий; угловые случаи (corner cases) — точечные сабтесты -(`s.Run("dotted.case", …)`). В начале каждого файла — **чек-лист** всех рассмотренных -угловых случаев, и на каждый написан тест (happy-path + все ошибки валидации, авторизация, -not-found, нарушение constraint'ов, граничный ввод, идемпотентность). +Each `apitest/<area>` is a separate `*_test` package importing `apitest/api`. Inside — +one file per endpoint/scenario; corner cases — pinpoint subtests +(`s.Run("dotted.case", …)`). At the start of each file — a **checklist** of all considered +corner cases, and a test is written for each one (happy path + all validation errors, authorization, +not-found, constraint violations, boundary input, idempotency). -## Что нужно и не нужно docker +## What docker is and is not needed for -Часть областей **не требует панелей** — они поднимают subgen и работают сами; их можно -гонять в обычном CI без docker: +Some areas **do not require panels** — they bring up subgen and work on their own; they can be +run in ordinary CI without docker: -| Область | Нужны панели? | Что внутри | +| Area | Need panels? | What is inside | |---|---|---| -| `auth` | нет | логин/логаут/гейт/shell — панель не нужна | -| `config` | нет | роутинг-конфиг в своём store; provider/check — против **локального** httptest-сервера в самом тесте | -| `sub` (`SubSuite`) | нет | `/healthz`, `/sub` 404-пути, и **зеркало** `/rules/<file>` через локальный upstream | -| `sub` (`SubPanelSuite`) | да | валидный `/sub` для реально заведённого пользователя | -| `users` | да | провижининг клиентов на панели | -| `nodes` | да | базовый флот N1/N2 заводится на панелях в `SetupSuite` | +| `auth` | no | login/logout/gate/shell — the panel is not needed | +| `config` | no | the routing config in its own store; provider/check — against a **local** httptest server in the test itself | +| `sub` (`SubSuite`) | no | `/healthz`, the `/sub` 404 paths, and the **mirror** `/rules/<file>` via a local upstream | +| `sub` (`SubPanelSuite`) | yes | a valid `/sub` for an actually provisioned user | +| `users` | yes | provisioning clients on the panel | +| `nodes` | yes | the basic fleet N1/N2 is set up on the panels in `SetupSuite` | -Гейт — `api.SkipUnlessConfigured(t)` в раннере суита: без `SUBGEN_APITEST_PANEL1_URL` -панель-зависимые суиты **скипаются**, а негейтнутые (auth/config/SubSuite) всё равно -выполняются. +The gate — `api.SkipUnlessConfigured(t)` in the suite runner: without `SUBGEN_APITEST_PANEL1_URL` +the panel-dependent suites are **skipped**, while the ungated ones (auth/config/SubSuite) still +run. -## Запуск +## Running -**Без docker** (выполнит auth/config/SubSuite, остальное скипнет): +**Without docker** (runs auth/config/SubSuite, skips the rest): ``` go test -tags apitest -count=1 ./apitest/... ``` -**Под ключ** (с панелями — выполнит и панель-зависимые суиты): +**Turnkey** (with panels — also runs the panel-dependent suites): ``` make -C subgen/apitest test ``` -Цель `test`: `docker compose up -d` (две чистые панели) → ждёт готовности → забирает у -каждой авто-сгенерированный API-токен (`x-ui setting -getApiToken`) → прокидывает в env → -`go test -tags apitest ./apitest/...` → `docker compose down -v` (даже при падении). -Требует docker + `docker compose` + свободные порты **13053/13054**. Сборку бинаря subgen и -запуск процесса делает уже сам тест. +The `test` target: `docker compose up -d` (two clean panels) → waits for readiness → grabs from +each the auto-generated API token (`x-ui setting -getApiToken`) → forwards it into env → +`go test -tags apitest ./apitest/...` → `docker compose down -v` (even on failure). +It requires docker + `docker compose` + free ports **13053/13054**. Building the subgen binary and +starting the process is done by the test itself. -Ручной режим: `make -C subgen/apitest up`, затем тесты против любой готовой панели через env +Manual mode: `make -C subgen/apitest up`, then the tests against any ready panel via env (`SUBGEN_APITEST_PANEL1_URL`, `_PANEL1_TOKEN`, `_PANEL2_URL`, `_PANEL2_TOKEN`); -`make -C subgen/apitest down` для остановки. Без тега `apitest` обычный `go test ./...` эти -пакеты не трогает вовсе. - -## `apitest/api` — общий support - -- **`Client` (SDK)** — типизированный HTTP-SDK к запущенному серверу. Ядро - `do(method, path, reqBody, &out)` + куки-jar и **захват сессионной куки** из ответа - логина (кука `Secure`, поэтому jar не вернёт её по plain HTTP — SDK подставляет её сам; - продакшн при этом не трогается). На каждый эндпоинт — типизированный метод. **Тела - запросов строятся НЕ из generated-структур** (`internal/oas`), а из `map[string]any` / - hand-rolled структур / сырого JSON — это чёрный ящик: если бы тест слал тем же типом, - которым сервер декодит, ошибка в маппинге запроса (переименованное поле и т.п.) была бы - не видна (encode+decode одним типом дают согласованную, но неверную пару). Мутации - нормализуются в `Result{Status, OK, Msg, Err}` поверх ogen-контракта (`2xx {message}` → - `Msg`, `4xx {errMessage}` → `Err`), read-ручки декодятся в hand-rolled - `User`/`Node`/`Config`/`Schema`. Для угловых случаев есть «сырые» формы (`PostRaw`, - `LoginRaw`, `SaveConfigRaw`, `Get`/`GetURL`), отдающие статус/тело/заголовки. **Гочи - (схема ogen):** required-массивы (`groups`/`rules`/`providers`/`inbounds`/…) сервер - декодит строго — `null` (то, во что JSON-кодируется nil-слайс) отвергается, поэтому SDK - шлёт пустые массивы `[]`, а не `null`. - -- **Старт сервера** — `StartServer(t)` / `StartServerWith(t, Options)`: собирает бинарь - (`go build` в `t.TempDir()`), запускает процессом с временной БД, тестовыми кредами и - свободным портом, ждёт `/healthz`, вешает cleanup. `Options{DBPath}` позволяет переиспользовать - store между двумя стартами — это нужно для **зеркала** rule-provider'ов: набор отдаваемых - файлов фиксируется на старте, поэтому тест стартует сервер, сохраняет mirror-провайдер через - API, гасит, и стартует второй раз на той же БД. - -- **`Base`-суит** — встраивается областями (`api.Base`): `SetupSuite` поднимает весь стек один - раз (сидит инбаунды на панелях, собирает+стартует сервер, логинится, регистрирует N1/N2 через - API). Отдаёт `API()` (SDK), `XC()` (прямой 3x-ui клиент для «земли»), `Pan1()/Pan2()`, - и инструментарий — `ClientUUID`/`RequireClient`/`RequireNoClient` (читают панель напрямую), +`make -C subgen/apitest down` to stop. Without the `apitest` tag, an ordinary `go test ./...` does not touch these +packages at all. + +## `apitest/api` — the shared support + +- **`Client` (the SDK)** — a typed HTTP SDK to the running server. The core is + `do(method, path, reqBody, &out)` + a cookie jar and **capturing the session cookie** from the + login response (the cookie is `Secure`, so the jar will not return it over plain HTTP — the SDK substitutes it + itself; production is not touched by this). Per endpoint — a typed method. **Request + bodies are built NOT from generated structs** (`internal/oas`), but from `map[string]any` / + hand-rolled structs / raw JSON — this is a black box: if the test sent with the same type + the server decodes with, a bug in request mapping (a renamed field, etc.) would be + invisible (encode+decode with one type give a consistent but wrong pair). Mutations + are normalized into `Result{Status, OK, Msg, Err}` over the ogen contract (`2xx {message}` → + `Msg`, `4xx {errMessage}` → `Err`), read endpoints are decoded into hand-rolled + `User`/`Node`/`Config`/`Schema`. For corner cases there are «raw» forms (`PostRaw`, + `LoginRaw`, `SaveConfigRaw`, `Get`/`GetURL`) returning the status/body/headers. **A gotcha + (the ogen schema):** required arrays (`groups`/`rules`/`providers`/`inbounds`/…) are decoded + strictly by the server — `null` (what a nil slice is JSON-encoded into) is rejected, so the SDK + sends empty arrays `[]`, not `null`. + +- **Server start** — `StartServer(t)` / `StartServerWith(t, Options)`: builds the binary + (`go build` in `t.TempDir()`), starts it as a process with a temporary DB, test creds and + a free port, waits for `/healthz`, hangs a cleanup. `Options{DBPath}` allows reusing the + store between two starts — this is needed for the **mirror** of rule-providers: the set of served + files is fixed at start, so the test starts the server, saves a mirror provider through the + API, shuts it down, and starts a second time on the same DB. + +- **The `Base` suite** — embedded by the areas (`api.Base`): `SetupSuite` brings up the whole stack once + (seeds inbounds on the panels, builds+starts the server, logs in, registers N1/N2 through + the API). It exposes `API()` (the SDK), `XC()` (a direct 3x-ui client for the «ground truth»), `Pan1()/Pan2()`, + and tooling — `ClientUUID`/`RequireClient`/`RequireNoClient` (they read the panel directly), `InboundID(node, inbound)`, `PanelInboundID`, `UniqueName(prefix)`. - Области, которым панель не нужна (auth/config/SubSuite), **не** встраивают `Base` — они сами - делают `api.StartServer(t)` + `api.New(...)` без регистрации узлов. + The areas that do not need a panel (auth/config/SubSuite) **do not** embed `Base` — they themselves + do `api.StartServer(t)` + `api.New(...)` without registering nodes. -## Топология теста (для панель-зависимых суитов) +## Test topology (for the panel-dependent suites) -- **N1** (панель 1): smart-инбаунд :4433, force-инбаунд :8443. -- **N2** (панель 2): smart-инбаунд :9443, force-инбаунд :9444. +- **N1** (panel 1): a smart inbound :4433, a force inbound :8443. +- **N2** (panel 2): a smart inbound :9443, a force inbound :9444. -Инбаунды создаёт сам тест (`Base.SetupSuite`) через `POST /panel/api/inbounds/add`; узлы — -через `POST /admin/api/nodes/save`. Пользователь = один 3x-ui клиент на панель (один uuid, -email = nickname, общий subId), привязанный ко всем своим инбаундам этой панели. +The inbounds are created by the test itself (`Base.SetupSuite`) via `POST /panel/api/inbounds/add`; the nodes — +via `POST /admin/api/nodes/save`. A user = one 3x-ui client per panel (one uuid, +email = nickname, shared subId), bound to all of its inbounds on this panel. -## Текст ошибок +## Error texts -Тесты проверяют **точный** русский текст, который отдаёт API (`Result.Err`). Продакшн-строки -живут на слое представления и не экспортируются, поэтому в каждой области рядом лежит -`messages_test.go` с константами, зеркалящими эти строки, — это и есть шов между -(неэкспортируемым) продакшн-текстом и ассертами. Интерполированные сообщения валидатора узла -(`web.ValidateNode`) проверяются стабильной подстрокой. +The tests check the **exact** text the API returns (`Result.Err`). The production strings +live on the presentation layer and are not exported, so in each area there is a neighboring +`messages_test.go` with constants mirroring those strings — this is the seam between +the (unexported) production text and the assertions. The interpolated messages of the node validator +(`web.ValidateNode`) are checked by a stable substring. -## Изоляция +## Isolation -Панель-зависимые суиты переживают весь прогон; каждый кейс берёт уникальное имя и подчищает -своих пользователей/узлы в `t.Cleanup`. Негейтнутые суиты поднимают **свой** процесс с чистой -БД, поэтому записи конфига/мусор не мешают другим. Суиты не помечены `t.Parallel()` намеренно: -внутри суита кейсы делят один сервер и один store, а codegen старта процесса не должен гоняться -конкурентно (исключение из общего правила параллельности — как и в unit-стайле). +The panel-dependent suites survive the whole run; each case takes a unique name and cleans up +its own users/nodes in `t.Cleanup`. The ungated suites bring up **their own** process with a clean +DB, so config records/garbage do not interfere with others. The suites are intentionally not marked `t.Parallel()`: +within a suite the cases share one server and one store, and the codegen of the process start must not run +concurrently (an exception to the general parallelism rule — as in the unit style). -## Один нюанс с plain HTTP +## One nuance with plain HTTP -Сессионная кука админки помечена `Secure` (см. `internal/handlers/web/auth.go`), поэтому -Go-шный cookie-jar **не отправит** её обратно по `http://`. Чтобы остаться на plain HTTP и **не -менять продакшн**, SDK сам захватывает куку из ответа логина и подставляет её в каждый запрос — -ровно то, что сделал бы браузер по HTTPS. Всё остальное (создание узлов, провижининг, подписка, -зеркало правил) драйвится строго через публичный HTTP-API. +The admin session cookie is marked `Secure` (see `internal/handlers/web/auth.go`), so the +Go cookie jar **will not send** it back over `http://`. To stay on plain HTTP and **not +change production**, the SDK captures the cookie from the login response itself and substitutes it into every request — +exactly what a browser would do over HTTPS. Everything else (creating nodes, provisioning, the subscription, +the rules mirror) is driven strictly through the public HTTP API. diff --git a/apitest/api/client.go b/apitest/api/client.go index 9491e4a..525064e 100644 --- a/apitest/api/client.go +++ b/apitest/api/client.go @@ -242,8 +242,8 @@ func (c *Client) getJSON(path string, out any) error { // JSON, an empty required string, a non-positive id, an empty required array). // - MsgUnauthorized — an absent/invalid admin session on a gated operation (401). const ( - MsgBadRequest = "Некорректный запрос" - MsgUnauthorized = "Требуется авторизация" + MsgBadRequest = "Bad request" + MsgUnauthorized = "Authorization required" ) // DecodeResult unmarshals a raw {message|errMessage} body into a Result (for the *Raw diff --git a/apitest/config/provider_check_test.go b/apitest/config/provider_check_test.go index e66fdec..650189f 100644 --- a/apitest/config/provider_check_test.go +++ b/apitest/config/provider_check_test.go @@ -16,9 +16,9 @@ import ( // started INSIDE the test serves sample mrs/yaml/text so the content checks need NO // docker: // - ok.mrs / ok.yaml / ok.text — reachable URL whose body matches the declared format -// → {ok:true} "Доступен". +// → {ok:true} "Available". // - format_mismatch — body present but wrong shape for the format → {ok:false}. -// - http_404 — server returns 404 → {ok:false} "Сервер вернул HTTP 404". +// - http_404 — server returns 404 → {ok:false} "The server returned HTTP 404". // - empty_body — 200 with an empty body → {ok:false} (no file). // - unreachable — connection refused on a closed port → {ok:false}. // - empty_url — "" is just an un-probeable URL (same category as a @@ -57,21 +57,21 @@ func (s *ConfigSuite) TestProviderCheck() { res, err := s.api.CheckProvider(srv.URL+"/good.mrs", "mrs") s.Require().NoError(err) s.True(res.OK, "a valid mrs must be accepted: %s", res.Message()) - s.Contains(res.Msg, "Доступен") + s.Contains(res.Msg, "Available") }) s.Run("ok.yaml", func() { res, err := s.api.CheckProvider(srv.URL+"/good.yaml", "yaml") s.Require().NoError(err) s.True(res.OK, "a valid yaml must be accepted: %s", res.Message()) - s.Contains(res.Msg, "Доступен") + s.Contains(res.Msg, "Available") }) s.Run("ok.text", func() { res, err := s.api.CheckProvider(srv.URL+"/good.text", "text") s.Require().NoError(err) s.True(res.OK, "valid rule text must be accepted: %s", res.Message()) - s.Contains(res.Msg, "Доступен") + s.Contains(res.Msg, "Available") }) s.Run("format_mismatch", func() { @@ -79,7 +79,7 @@ func (s *ConfigSuite) TestProviderCheck() { res, err := s.api.CheckProvider(srv.URL+"/good.yaml", "mrs") s.Require().NoError(err) s.False(res.OK, "a format mismatch must be rejected") - s.Contains(res.Err, "не похоже на формат") + s.Contains(res.Err, "does not look like") }) s.Run("http_404", func() { diff --git a/apitest/config/save_test.go b/apitest/config/save_test.go index a952242..1e66c11 100644 --- a/apitest/config/save_test.go +++ b/apitest/config/save_test.go @@ -10,21 +10,21 @@ import ( // Corner cases considered for POST /admin/api/config/mihomo/save. Validation is ordered // (base YAML → groups → rules → providers → RULE-SET refs) and short-circuits, so each // rejected case is built to PASS every earlier check and trip exactly the one under -// test, and asserts the EXACT Russian message: +// test, and asserts the EXACT message: // - happy.round_trip — a small valid config saves and reads back identically. // - happy.logical_round_trip — an AND rule with sub-rules (children) round-trips intact. -// - err.match_not_last — a MATCH followed by another rule → "MATCH должно быть последним". -// - err.sub_rules_in_base — base YAML carrying `sub-rules:` → "Уберите ... генерируемые разделы". -// - err.rule_value_required — a non-MATCH rule with no value → "не указано значение". -// - err.group_no_members — a proxy-group with no members → "Пустая proxy-группа". -// - err.group_name_taken — two groups with the same name → "...уже существует". -// - err.group_cycle — A→B and B→A by index → "циклическую ссылку". -// - err.group_ref_range — a rule target group index out of range → "несуществующую группу". -// - err.provider_nameless — a provider with an empty name → "Укажите название rule-provider". -// - err.provider_dup_name — two valid providers sharing a name → "...уже существует" (DB UNIQUE). -// - err.ruleset_unknown — a RULE-SET with an out-of-range provider index → "RULE-SET ссылается...". -// - err.generated_key — base YAML carrying `proxies:` → "Уберите ... генерируемые разделы". -// - err.base_yaml_invalid — unparseable base YAML → "YAML невалиден". +// - err.match_not_last — a MATCH followed by another rule → "The MATCH rule must be last". +// - err.sub_rules_in_base — base YAML carrying `sub-rules:` → "Remove the generated sections". +// - err.rule_value_required — a non-MATCH rule with no value → "has no value". +// - err.group_no_members — a proxy-group with no members → "Empty proxy-group". +// - err.group_name_taken — two groups with the same name → "...already exists". +// - err.group_cycle — A→B and B→A by index → "cyclic reference". +// - err.group_ref_range — a rule target group index out of range → "non-existent group". +// - err.provider_nameless — a provider with an empty name → "Enter a rule-provider name". +// - err.provider_dup_name — two valid providers sharing a name → "...already exists" (DB UNIQUE). +// - err.ruleset_unknown — a RULE-SET with an out-of-range provider index → "RULE-SET references...". +// - err.generated_key — base YAML carrying `proxies:` → "Remove the generated sections". +// - err.base_yaml_invalid — unparseable base YAML → "Invalid YAML". // - err.malformed_json — a non-JSON body → MsgBadRequest. // TestSaveRoundTrip covers the happy path: a fresh store accepts a valid config and diff --git a/apitest/nodes/save_test.go b/apitest/nodes/save_test.go index 4e3b126..1f269ff 100644 --- a/apitest/nodes/save_test.go +++ b/apitest/nodes/save_test.go @@ -13,14 +13,14 @@ import ( // - update.keep_token — update with an EMPTY token preserves the stored token // (verified end-to-end: a user still provisions onto the panel). // - update.replace_token — update with a NEW token replaces it (the node still works). -// - err.bad_vpn_host — host with a scheme/port → rejected ("невалиден"). -// - err.no_inbounds — zero inbounds → rejected ("хотя бы один инбаунд"). -// - err.bad_inbound_name — inbound name with illegal chars → rejected ("имя инбаунда"). -// - err.bad_node_name — node name with illegal chars → rejected ("имя узла"). -// - err.duplicate_node_name — second node with an existing name → "Узел ... уже существует". -// - err.duplicate_inbound_name — two inbounds same name in one payload → "повторяющееся имя инбаунда" +// - err.bad_vpn_host — host with a scheme/port → rejected ("invalid"). +// - err.no_inbounds — zero inbounds → rejected ("at least one inbound"). +// - err.bad_inbound_name — inbound name with illegal chars → rejected ("inbound name"). +// - err.bad_node_name — node name with illegal chars → rejected ("node name"). +// - err.duplicate_node_name — second node with an existing name → "A node ... already exists". +// - err.duplicate_inbound_name — two inbounds same name in one payload → "Duplicate inbound name" // (web.ValidateNode catches the in-payload dup before the DB). -// - err.duplicate_inbound_port — two inbounds same port in one payload → "повторяющийся порт инбаунда". +// - err.duplicate_inbound_port — two inbounds same port in one payload → "Duplicate inbound port". // - err.malformed_json — non-JSON body → MsgBadRequest. // TestSaveCreateUpdate covers the create + update happy paths against the registry. diff --git a/apitest/users/create_test.go b/apitest/users/create_test.go index 8273abd..d7cf8f5 100644 --- a/apitest/users/create_test.go +++ b/apitest/users/create_test.go @@ -22,8 +22,8 @@ import ( // - err.name_bad_chars — spaces / "!" → friendly charset message (reaches the handler). // - err.name_too_long — >32 chars → friendly charset message (no maxLength in schema). // - err.no_connections — absent inbound-id list (null) → generic 400 (kept `required`). -// - err.unknown_inbound_id — id with no node_inbounds row → "инбаунд не найден". -// - err.duplicate_name — second create with same nickname → "имя занято" (store PK). +// - err.unknown_inbound_id — id with no node_inbounds row → "inbound not found". +// - err.duplicate_name — second create with same nickname → "name already taken" (store PK). // - err.email_exists_on_panel — a foreign client already owns the email on a target // panel → PanelClientExistsError naming the node; the // foreign client is left untouched. @@ -81,7 +81,7 @@ func (s *UserSuite) TestCreateValidation() { }) s.Run("name_bad_chars", func() { - for _, bad := range []string{"bad name", "bad!char", "Привет"} { + for _, bad := range []string{"bad name", "bad!char", "naïve"} { res, err := s.API().CreateUser(bad, []int64{smartN1}) s.Require().NoError(err) s.False(res.OK, "nickname %q must be rejected", bad) @@ -99,7 +99,7 @@ func (s *UserSuite) TestCreateValidation() { s.Run("no_connections", func() { // An empty inbound-id list trips the schema's minItems:1 → 400 generic, before - // the handler's own "выберите подключение" check. + // the handler's own "select a connection" check. name := s.userName() res, err := s.API().CreateUser(name, nil) s.Require().NoError(err) @@ -124,7 +124,7 @@ func (s *UserSuite) TestCreateValidation() { // Re-create the same nickname selecting an inbound on N2, where that email is NOT // on the panel — so the panel pre-check passes and it's the users.name DB - // constraint that rejects it (→ "Имя занято"). The same-node panel-collision path + // constraint that rejects it (→ "Name already taken"). The same-node panel-collision path // is covered separately by TestCreateRejectsExistingEmail. res, err := s.API().CreateUser(u.Name, []int64{s.InboundID("N2", "force")}) s.Require().NoError(err) @@ -165,7 +165,7 @@ func (s *UserSuite) TestCreateRejectsExistingEmail() { s.Require().NoError(err) s.False(res.OK, "create must be rejected when a foreign client owns the email") s.Contains(res.Err, "N1", "rejection must name the offending panel") - s.Contains(res.Err, "уже есть клиент", "rejection must use the friendly panel-collision text") + s.Contains(res.Err, "already has a client", "rejection must use the friendly panel-collision text") // …and the foreign client must be left intact (same uuid — not deleted/re-added). s.Equal(orphan.String(), s.RequireClient(s.Pan1(), api.N1Smart, name), "foreign client must be untouched") diff --git a/apitest/users/description_test.go b/apitest/users/description_test.go index 0c8e419..8be94e1 100644 --- a/apitest/users/description_test.go +++ b/apitest/users/description_test.go @@ -22,26 +22,26 @@ func (s *UserSuite) TestDescription() { s.Run("set_on_create", func() { name := s.userName() - res, err := s.API().CreateUserWith(name, sel, "рабочий ноутбук") + res, err := s.API().CreateUserWith(name, sel, "work laptop") s.Require().NoError(err) s.Require().True(res.OK, "create with description: %s", res.Message()) s.T().Cleanup(func() { u, _ := s.API().FindUser(name); s.deleteIfFound(u) }) u, err := s.API().MustFindUser(name) s.Require().NoError(err) - s.Equal("рабочий ноутбук", u.Description) + s.Equal("work laptop", u.Description) }) s.Run("trimmed", func() { name := s.userName() - res, err := s.API().CreateUserWith(name, sel, " с пробелами ") + res, err := s.API().CreateUserWith(name, sel, " with spaces ") s.Require().NoError(err) s.Require().True(res.OK, "create with padded description: %s", res.Message()) s.T().Cleanup(func() { u, _ := s.API().FindUser(name); s.deleteIfFound(u) }) u, err := s.API().MustFindUser(name) s.Require().NoError(err) - s.Equal("с пробелами", u.Description) + s.Equal("with spaces", u.Description) }) s.Run("omitted_is_empty", func() { @@ -52,13 +52,13 @@ func (s *UserSuite) TestDescription() { s.Run("edit_replaces", func() { u := s.createUser(s.userName(), "N1") - res, err := s.API().EditUserWith(u.ID, sel, "после правки") + res, err := s.API().EditUserWith(u.ID, sel, "after edit") s.Require().NoError(err) s.Require().True(res.OK, "edit set description: %s", res.Message()) got, err := s.API().MustFindUser(u.Name) s.Require().NoError(err) - s.Equal("после правки", got.Description) + s.Equal("after edit", got.Description) // editing with an empty description clears it res, err = s.API().EditUserWith(u.ID, sel, "") @@ -72,7 +72,7 @@ func (s *UserSuite) TestDescription() { s.Run("too_long", func() { name := s.userName() - res, err := s.API().CreateUserWith(name, sel, strings.Repeat("я", 501)) + res, err := s.API().CreateUserWith(name, sel, strings.Repeat("a", 501)) s.Require().NoError(err) s.Require().False(res.OK, "over-length description must be rejected") s.NotEmpty(res.Message()) diff --git a/apitest/users/edit_test.go b/apitest/users/edit_test.go index 3fcbccc..85bc5d1 100644 --- a/apitest/users/edit_test.go +++ b/apitest/users/edit_test.go @@ -20,7 +20,7 @@ import ( // - noop — identical selection must NOT churn the panel (uuid stable). // - err.no_connection — edit with an absent inbound list (null) → generic 400 (kept `required`), no change. // - err.unknown_user — id with no user row → failure, technical error surfaced. -// - err.unknown_inbound — selection includes a bad inbound id → "инбаунд не найден". +// - err.unknown_inbound — selection includes a bad inbound id → "inbound not found". // TestEditReconcile covers the add/remove + same-node + swap reconciliations, asserting // the panel ends in the expected state and uuids are preserved where they should be. diff --git a/docs/decisions/0000-template.md b/docs/decisions/0000-template.md index 6bf9888..97881a6 100644 --- a/docs/decisions/0000-template.md +++ b/docs/decisions/0000-template.md @@ -1,24 +1,24 @@ -# NNNN — <Заголовок решения> +# NNNN — <Decision title> -- **Статус:** Proposed | Accepted | Superseded by 000Y -- **Дата:** YYYY-MM-DD -- **PR:** #<номер> +- **Status:** Proposed | Accepted | Superseded by 000Y +- **Date:** YYYY-MM-DD +- **PR:** #<number> ## Context -Проблема/нужда, что её спровоцировало, какие ограничения. Почему вообще что-то меняем. +The problem/need, what triggered it, what constraints. Why we are changing anything at all. ## Considered Options -- **Вариант A** — суть; плюсы/минусы. -- **Вариант B** — суть; плюсы/минусы. -- (по необходимости — ещё) +- **Option A** — the gist; pros/cons. +- **Option B** — the gist; pros/cons. +- (more, as needed) ## Decision -Что выбрали и **почему именно это** — какой trade-off перевесил. +What we chose and **why exactly this** — which trade-off tipped the scale. ## Consequences -Что следует из выбора: что станет проще/сложнее, что теперь нельзя, на что ещё влияет, -какие дальнейшие шаги/ограничения (и положительные, и отрицательные). +What follows from the choice: what becomes easier/harder, what is now impossible, what else it affects, +what further steps/constraints (both positive and negative). diff --git a/docs/decisions/0001-adopt-changelog-and-adr.md b/docs/decisions/0001-adopt-changelog-and-adr.md index 534e80e..bb7cd79 100644 --- a/docs/decisions/0001-adopt-changelog-and-adr.md +++ b/docs/decisions/0001-adopt-changelog-and-adr.md @@ -1,42 +1,43 @@ -# 0001 — Конвенция документирования: CHANGELOG + ADR +# 0001 — Documentation convention: CHANGELOG + ADR -- **Статус:** Accepted -- **Дата:** 2026-06-11 +- **Status:** Accepted +- **Date:** 2026-06-11 - **PR:** #16 ## Context -«Почему так решили» в subgen терялось: контекст изменения жил только в обсуждении PR и -в голове автора, а в репозитории оставались лишь диффы и однострочные сообщения -коммитов. Через пару месяцев мотивацию архитектурного выбора восстановить уже трудно. -Нужен лёгкий, но обязательный механизм, фиксирующий и *что* поменялось, и *почему*. +«Why we decided this way» was getting lost in subgen: the context of a change lived only in the PR +discussion and in the author's head, while only diffs and one-line commit messages remained in the +repository. A couple of months later it is already hard to reconstruct the motivation of an +architectural choice. We need a lightweight but mandatory mechanism that records both *what* +changed and *why*. ## Considered Options -- **Только CHANGELOG (Keep a Changelog)** — стандарт keepachangelog.com: секции - `[Unreleased]` + `Added/Changed/Fixed/Removed`, привязка к версиям. Узнаваемо, но - рассчитано на версионированные релизы, которых у subgen нет (непрерывный деплой, без - тегов), и не место для развёрнутого «почему» с разбором вариантов. -- **Только ADR** — детальные решения в `docs/decisions/`, но без сводного списка - изменений: нет быстрого «что вообще произошло за последнее время». -- **CHANGELOG (по-PR, без версий) + ADR на нетривиальное** *(выбрано)* — короткая - запись на каждый PR как индекс изменений плюс отдельный ADR с проблемой/вариантами/ - обоснованием там, где есть проектное решение. CHANGELOG ссылается на ADR. -- **Оставить как есть** — полагаться на историю PR. Ровно эту потерю контекста и чиним. +- **CHANGELOG only (Keep a Changelog)** — the keepachangelog.com standard: `[Unreleased]` + + `Added/Changed/Fixed/Removed` sections, tied to versions. Recognizable, but + designed for versioned releases, which subgen does not have (continuous deploy, without + tags), and it is not the place for an elaborate «why» with an analysis of options. +- **ADR only** — detailed decisions in `docs/decisions/`, but without a consolidated list + of changes: there is no quick «what even happened recently». +- **CHANGELOG (per-PR, without versions) + ADR for the non-trivial** *(chosen)* — a short + entry per PR as an index of changes plus a separate ADR with the problem/options/ + rationale wherever there is a design decision. The CHANGELOG links to the ADR. +- **Leave it as is** — rely on the PR history. This is exactly the loss of context we are fixing. ## Decision -Вводим **оба** артефакта: `CHANGELOG.md` (одна запись на PR, обратно-хронологически, -без секций-версий) и каталог ADR `docs/decisions/NNNN-slug.md` (Context / Considered -Options / Decision / Consequences) для нетривиальных изменений. Правило — в `AGENTS.md`. -Формат «по-PR без версий» выбран потому, что деплой непрерывный и привязывать -версионные секции Keep a Changelog не к чему. +We introduce **both** artifacts: `CHANGELOG.md` (one entry per PR, reverse-chronologically, +without version sections) and an ADR catalog `docs/decisions/NNNN-slug.md` (Context / Considered +Options / Decision / Consequences) for non-trivial changes. The rule is in `AGENTS.md`. +The «per-PR without versions» format was chosen because the deploy is continuous and there is nothing +to tie the version sections of Keep a Changelog to. ## Consequences -- Каждый PR обязан добавлять запись в CHANGELOG; нетривиальный — ещё и ADR. Небольшой - постоянный оверхед, окупающийся сохранённым контекстом. -- Появляется стабильная ссылочная единица решения (`ADR-NNNN`), на которую ссылаются - CHANGELOG, код-ревью и последующие ADR. -- ADR иммутабельны: пересмотр — новый ADR со `Supersedes`, а не правка старого. -- Этот PR — первый по конвенции и сам ей следует (запись в CHANGELOG + данный ADR). +- Every PR is obliged to add a CHANGELOG entry; a non-trivial one — also an ADR. A small + constant overhead, paid back by the preserved context. +- A stable referenceable unit of a decision appears (`ADR-NNNN`), referenced by the + CHANGELOG, code reviews and subsequent ADRs. +- ADRs are immutable: a revision is a new ADR with `Supersedes`, not an edit of the old one. +- This PR is the first one under the convention and follows it itself (a CHANGELOG entry + this ADR). diff --git a/docs/decisions/0002-ordered-migration-runner.md b/docs/decisions/0002-ordered-migration-runner.md index 1ba3469..97859e0 100644 --- a/docs/decisions/0002-ordered-migration-runner.md +++ b/docs/decisions/0002-ordered-migration-runner.md @@ -1,66 +1,66 @@ -# 0002 — Упорядоченный раннер миграций вместо ручных +# 0002 — An ordered migration runner instead of manual ones -- **Статус:** Accepted -- **Дата:** 2026-06-11 +- **Status:** Accepted +- **Date:** 2026-06-11 - **PR:** #18 ## Context -Схема БД накатывалась одним embedded `init.sql` (`migrations.Schema`, -`CREATE … IF NOT EXISTS`) на старте, а структурные изменения существующих таблиц -(`ALTER TABLE …`) делались **руками**: разовый файл `*.manual.sql`, который никакой код -не запускает — оператор должен вспомнить и выполнить его на проде. Это хрупко: легко -забыть, нет следа «что уже накатано», порядок и атомичность — на совести человека. -Нужен механизм, который сам и в правильном порядке доводит любую базу (свежую и -существующую) до актуальной схемы, падая громко при ошибке. +The DB schema was applied with a single embedded `init.sql` (`migrations.Schema`, +`CREATE … IF NOT EXISTS`) on start, while structural changes to existing tables +(`ALTER TABLE …`) were done **by hand**: a one-off `*.manual.sql` file that no code +runs — the operator must remember to execute it on prod. This is fragile: easy to +forget, there is no trace of «what is already applied», ordering and atomicity are on the human's +conscience. We need a mechanism that itself, and in the right order, brings any base (fresh or +existing) up to the current schema, failing loudly on error. ## Considered Options -- **Оставить ручные миграции** (`init.sql` + `*.manual.sql`) — минимум кода, но ровно та - хрупкость, что чиним: ручной шаг, нет учёта применённого, легко словить дрейф схемы. -- **`CREATE/ALTER … IF NOT EXISTS`, гонять всё каждый старт** — без таблицы учёта. Но - SQLite не умеет `ADD COLUMN IF NOT EXISTS`, так что `ALTER` на повторном старте падал - бы; пришлось бы «прощупывать» `PRAGMA table_info` — самодельный недо-мигратор. -- **Внешняя библиотека миграций** (goose / golang-migrate) — функционально, но тянет - зависимость и свою модель файлов/CLI ради нескольких файлов; для embedded-схемы из - одного бинаря избыточно. -- **Свой раннер упорядоченных файлов + таблица учёта** *(выбрано)* — `0001-init.sql` как - базлайн, далее `NNNN-*.sql`; применяются по имени-порядку, каждый в транзакции, факт - применения пишется в `schema_migrations`. Идемпотентно, без зависимостей. +- **Keep manual migrations** (`init.sql` + `*.manual.sql`) — minimal code, but exactly the + fragility we are fixing: a manual step, no record of what was applied, easy to get schema drift. +- **`CREATE/ALTER … IF NOT EXISTS`, run everything every start** — without a tracking table. But + SQLite cannot do `ADD COLUMN IF NOT EXISTS`, so an `ALTER` on a repeat start would fail; + one would have to «probe» `PRAGMA table_info` — a homemade half-migrator. +- **An external migration library** (goose / golang-migrate) — functional, but pulls in a + dependency and its own model of files/CLI for the sake of a few files; for an embedded schema in + a single binary it is overkill. +- **Our own runner of ordered files + a tracking table** *(chosen)* — `0001-init.sql` as the + baseline, then `NNNN-*.sql`; applied by name order, each in a transaction, the fact + of application written to `schema_migrations`. Idempotent, without dependencies. ## Decision -Вводим раннер `migrations.Apply(ctx, db)` (пакет `migrations`, файлы `embed.go` + -`run.go`), который `repository.Open` вызывает вместо `ExecContext(Schema)`: +We introduce a runner `migrations.Apply(ctx, db)` (the package `migrations`, files `embed.go` + +`run.go`), which `repository.Open` calls instead of `ExecContext(Schema)`: -- `0001-init.sql` — **базлайн** (полная схема-на-сейчас); далее `0002-*.sql`, …. Все файлы - `NNNN-`-префиксные, поэтому обычная лексикографическая сортировка имени = порядок наката - (без спец-логики и пина базлайна). -- Таблица `schema_migrations(name PRIMARY KEY, applied_at)` хранит применённое; каждый - файл применяется **один раз**, повторный старт — no-op. -- Каждая миграция — в **своей транзакции** вместе с записью в `schema_migrations` - (атомарно: краш посреди файла не оставляет «полупримененного» и не пишет факт). -- При ошибке `Apply` возвращает её → `main` падает (`log.Fatal`); каждый накат - логируется (`slog.Info`). -- **Connection-PRAGMA переехали в DSN** (`open.go`: `busy_timeout`, `foreign_keys`, - `journal_mode=WAL`), а не в `0001-init.sql` — `PRAGMA journal_mode=WAL` нельзя выполнить - внутри транзакции, в которую раннер оборачивает файл, поэтому миграции — чистый DDL. +- `0001-init.sql` — the **baseline** (the full schema-as-of-now); then `0002-*.sql`, …. All files are + `NNNN-`-prefixed, so the ordinary lexicographic name sort = the apply order + (without special logic and without pinning the baseline). +- The table `schema_migrations(name PRIMARY KEY, applied_at)` stores what was applied; each + file is applied **once**, a repeat start is a no-op. +- Each migration — in **its own transaction** together with the write to `schema_migrations` + (atomically: a crash mid-file leaves no «half-applied» state and does not write the fact). +- On error `Apply` returns it → `main` crashes (`log.Fatal`); each apply is + logged (`slog.Info`). +- **The connection PRAGMA have moved to the DSN** (`open.go`: `busy_timeout`, `foreign_keys`, + `journal_mode=WAL`), not into `0001-init.sql` — `PRAGMA journal_mode=WAL` cannot be executed + inside the transaction the runner wraps the file in, therefore migrations are pure DDL. -Это **отменяет** прежнее «миграции БД — только вручную» (раздел в `AGENTS.md` переписан). -Своё, а не библиотека — потому что объём (embedded-схема одного бинаря) не оправдывает -зависимость, а семантика тривиальна. +This **cancels** the prior «DB migrations — by hand only» (the section in `AGENTS.md` was rewritten). +Our own, not a library — because the volume (an embedded schema of a single binary) does not justify a +dependency, and the semantics are trivial. ## Consequences -- Любая база доводится до актуальной схемы автоматически и в порядке; `*.manual.sql` - больше не нужны (паттерн удалён из правил). Структурное изменение = новый - `NNNN-*.sql`, и всё. -- Существующая прод-база усыновляется безопасно: `schema_migrations` создаётся пустой, - базлайн (`CREATE … IF NOT EXISTS`) повторно — no-op и помечается применённым, далее - `NNNN-*.sql` ложатся сверху. Отдельный «бэкофилл учёта» не нужен. -- `0001-init.sql` теперь **иммутабельный базлайн**: правки схемы идут только новыми - файлами, не редактированием базлайна (иначе разъедется с уже усыновлёнными базами). -- PRAGMA — единым местом в DSN; миграции обязаны быть чистым DDL (PRAGMA, меняющие режим - журнала, в файл миграции класть нельзя). -- Откат миграций не реализуем (forward-only) — осознанно: для непрерывного деплоя - «вперёд + новый фикс-файл» проще и безопаснее down-скриптов. +- Any base is brought up to the current schema automatically and in order; `*.manual.sql` + are no longer needed (the pattern was removed from the rules). A structural change = a new + `NNNN-*.sql`, and that is all. +- An existing prod base is adopted safely: `schema_migrations` is created empty, the + baseline (`CREATE … IF NOT EXISTS`) is a no-op on a repeat run and is marked as applied, then + `NNNN-*.sql` land on top. A separate «tracking backfill» is not needed. +- `0001-init.sql` is now an **immutable baseline**: schema edits go only via new + files, not by editing the baseline (otherwise it diverges from already-adopted bases). +- PRAGMA — in one place, in the DSN; migrations are obliged to be pure DDL (PRAGMA that change the + journal mode must not be put into a migration file). +- Rolling back migrations is not implemented (forward-only) — deliberately: for a continuous deploy + «forward + a new fix file» is simpler and safer than down scripts. diff --git a/docs/decisions/0003-validation-in-code.md b/docs/decisions/0003-validation-in-code.md index 5c9aa19..b4a5b1f 100644 --- a/docs/decisions/0003-validation-in-code.md +++ b/docs/decisions/0003-validation-in-code.md @@ -1,66 +1,68 @@ -# 0003 — Валидация запросов в коде, а не в OpenAPI-схеме +# 0003 — Request validation in code, not in the OpenAPI schema -- **Статус:** Accepted -- **Дата:** 2026-06-11 +- **Status:** Accepted +- **Date:** 2026-06-11 - **PR:** #19 ## Context -ogen генерирует серверные валидаторы из schema-ограничений (`minLength`, `minItems`, -`minimum`, …) и зовёт их на декоде запроса. При нарушении ответ — общий -`400 {"errMessage":"Некорректный запрос"}` (центральный `ErrorHandler`), **без привязки -к полю**: пользователь, редактирующий конкретное поле, не понимает, что не так. +ogen generates server validators from schema constraints (`minLength`, `minItems`, +`minimum`, …) and calls them on request decode. On a violation the response is a generic +`400 {"errMessage":"Bad request"}` (the central `ErrorHandler`), **without a binding +to the field**: a user editing a specific field does not understand what is wrong. -Отключить **генерацию** этих валидаторов, оставив сами ограничения в `.yaml` как -декларацию контракта, ogen не умеет: `server/request/validation`-фичи нет, а -`x-ogen-validate` лишь добавляет кастомные валидаторы. Значит выбор бинарный: либо -ограничение в схеме (ogen валидирует, общий месседж), либо его там нет (валидируем в -коде, точный месседж). Понятное локализованное сообщение признано важнее декларативности. +Disabling the **generation** of these validators while keeping the constraints themselves in the +`.yaml` as a contract declaration is something ogen cannot do: there is no +`server/request/validation` feature, and `x-ogen-validate` only adds custom validators. So the choice is +binary: either the constraint is in the schema (ogen validates, generic message), or it is not there +(we validate in code, a precise message). An understandable localized message was deemed more +important than declarativeness. ## Considered Options -- **Валидация схемой (как было)** — декларативно, бесплатно (codegen), отсекает на краю; - но месседж общий («Некорректный запрос»), плохой UX, и часть правил схемой не выразить. -- **Дубль (схема + код)** — и контракт, и точный месседж; но с ограничением в схеме ogen - режет запрос **до** хендлера, и код-проверка по HTTP-пути мертва → формальный дубль. -- **Кастомные ogen-шаблоны** — переопределить request-decode, чтобы не звать `.Validate()`; - глобально, хрупко (ресинк на каждом апгрейде ogen), высокий мейнтенанс. -- **Вся валидация значений — в коде** *(выбрано)* — убрать value-constraints из схемы, - проверять в хендлере/сервисе с sentinel + локализованным сообщением. +- **Validation by schema (as it was)** — declarative, free (codegen), cuts off at the edge; + but the message is generic («Bad request»), bad UX, and some rules cannot be expressed by the schema. +- **Duplicate (schema + code)** — both the contract and a precise message; but with the constraint in the + schema ogen cuts the request **before** the handler, and the code check on the HTTP path is + dead → a formal duplicate. +- **Custom ogen templates** — override the request decode so as not to call `.Validate()`; + global, fragile (a resync on every ogen upgrade), high maintenance. +- **All value validation — in code** *(chosen)* — remove value constraints from the schema, + check in the handler/service with a sentinel + a localized message. ## Decision -- Из `openapi/*.yaml` убраны **все value-constraints** (`minLength`, `minItems`, - `minimum`). **Оставлены** `required`, `type`, `format` — они определяют форму контракта - и сгенерированные Go-типы (это не «валидация значений», и ogen по ним валидирует - присутствие/тип, что нам нужно). -- **Валидация — в сервисном слое**, sentinel-ошибками в `entity`; хендлеры тонкие и мапят - sentinel в типизированный 4xx с локальной константой-сообщением. Где сервиса не было — он - заведён: **`internal/service/nodes`** владеет валидацией узла (семейство - `entity.ErrValidation*` — имя/хост/URL/base-path/инбаунды) плюс save/delete. **Ссылочную - целостность инбаунда НЕ предчекаем**: удаление узла/снятие инбаунда, на который ещё есть - ссылка (user-подключение или mihomo-правило/группа), отвергает FK БД (RESTRICT), а - репозиторий переводит нарушение в `entity.ErrInboundReferenced` — хендлер мапит его в - 400. Часть валидаций уже была в сервисе/домене и оставлена: `validateName`, +- **All value constraints** (`minLength`, `minItems`, + `minimum`) were removed from `openapi/*.yaml`. **Kept** are `required`, `type`, `format` — they define the shape of the contract + and the generated Go types (this is not «value validation», and ogen validates + presence/type by them, which is what we need). +- **Validation — in the service layer**, with sentinel errors in `entity`; the handlers are thin and map a + sentinel into a typed 4xx with a local message constant. Where there was no service — it was + introduced: **`internal/service/nodes`** owns node validation (the + `entity.ErrValidation*` family — name/host/URL/base-path/inbounds) plus save/delete. **Inbound + referential integrity we do NOT pre-check**: deleting a node/removing an inbound that still has a + reference (a user connection or a mihomo rule/group) is rejected by the DB FK (RESTRICT), and the + repository translates the violation into `entity.ErrInboundReferenced` — the handler maps it to + 400. Some validations were already in the service/domain and were kept: `validateName`, `ErrNoConnectionSelected`, `PolicyRef.Valid()`. -- **Суррогатные id (PK) НЕ валидируем.** Проверять `id ≥ 1` бессмысленно: несуществующий id - (хоть `-100`, хоть валидный-но-отсутствующий `1233`) одинаково даёт not-found на чтении — - отдельная «валидация формата id» не несёт смысла. Удалена. -- Где старое поведение уже корректно отвергало вход без openapi-гарда — оставлено: пустые - креды → 401 (constant-time compare), пустые path-сегменты не матчат роут, пустой/неизвестный - `PolicyRef.kind` → `validateRef`. Пустой URL provider-check **не** валидируем отдельно: он - ничем не отличается от кривого URL — оба непробиваемы и дают `RulesetCheckUnreachable` - (гарда на пустоту в хендлере нет). +- **Surrogate ids (PK) we do NOT validate.** Checking `id ≥ 1` is meaningless: a non-existent id + (whether `-100` or a valid-but-absent `1233`) yields not-found on read either way — + a separate «id format validation» carries no meaning. Removed. +- Where the old behavior already correctly rejected the input without an openapi guard — it was kept: empty + creds → 401 (constant-time compare), empty path segments do not match the route, an empty/unknown + `PolicyRef.kind` → `validateRef`. An empty provider-check URL we do **not** validate separately: it is + no different from a malformed URL — both are unreachable and yield `RulesetCheckUnreachable` + (there is no emptiness guard in the handler). ## Consequences -- Ошибки валидации — точные, локализованные, привязанные к полю; в сервисе, покрыты - юнит-тестами. Хендлеры остаются тонкими (вызов сервиса + маппинг sentinel'ов). -- `openapi/*.yaml` перестаёт быть источником enforced-ограничений значений (только форма - контракта). **Конвенция на будущее**: значения валидируются в сервисе sentinel-ошибками, - в схему кладём только `required`/`type`/`format`; суррогатные id не валидируем. -- Node-валидация переехала из `web` (handler-layer) в `internal/service/nodes`; `web` больше - не держит маппинг сообщений — человеко-текст ошибок живёт локальными константами в каждом - хендлере (`node_save`/`node_delete` и т.д.), экспортированными для apitest. -- Небольшой минус: лимиты больше не самодокументируются в схеме для стороннего тулинга. -- Снят риск «дубля» и общий невнятный 400 на schema-ошибке. +- Validation errors are precise, localized, bound to the field; in the service, covered by + unit tests. The handlers stay thin (a service call + sentinel mapping). +- `openapi/*.yaml` stops being a source of enforced value constraints (only the shape of the + contract). **A convention for the future**: values are validated in the service with sentinel errors, + into the schema we put only `required`/`type`/`format`; surrogate ids we do not validate. +- Node validation moved from `web` (the handler layer) into `internal/service/nodes`; `web` no longer + holds the message mapping — the human error text lives in local constants in each + handler (`node_save`/`node_delete` etc.), exported for apitest. +- A small downside: the limits no longer self-document in the schema for third-party tooling. +- The risk of a «duplicate» and the generic vague 400 on a schema error are removed. diff --git a/docs/decisions/0004-optional-user-description.md b/docs/decisions/0004-optional-user-description.md index 418cefd..571bd26 100644 --- a/docs/decisions/0004-optional-user-description.md +++ b/docs/decisions/0004-optional-user-description.md @@ -1,58 +1,58 @@ -# 0004 — Опциональное описание пользователя: nillable + вход-структуры +# 0004 — Optional user description: nillable + input structs -- **Статус:** Accepted -- **Дата:** 2026-06-11 +- **Status:** Accepted +- **Date:** 2026-06-11 - **PR:** #15 ## Context -Админу нужна возможность повесить на пользователя произвольную текстовую заметку -(«рабочий ноут», «выдан тогда-то»), видную только в админ-UI и ни на что в провижининге -3x-ui не влияющую. Поле необязательное — у большинства пользователей его нет. Вопрос: как -представить «нет описания» в домене и в БД, и как протащить ещё один необязательный -параметр через `CreateUser`/`EditUser`, не превращая их в простыни позиционных аргументов. +The admin needs the ability to attach an arbitrary text note to a user +(«work laptop», «issued on such-and-such date»), visible only in the admin UI and not affecting +anything in the 3x-ui provisioning. The field is optional — most users do not have it. The question: how +to represent «no description» in the domain and in the DB, and how to thread one more optional +parameter through `CreateUser`/`EditUser` without turning them into walls of positional arguments. ## Considered Options -- **`NOT NULL DEFAULT ''` + `Description string`** — простейшее: пустая строка = «нет - описания». Но «optional» тогда не отражён в типе — `""` и «не задано» неразличимы, легко - забыть нормализацию, домен врёт о необязательности. (Так было в первой версии PR; - отклонено на ревью.) -- **Nullable-колонка + `Description *string`** *(выбрано для представления)* — `NULL`/`nil` - = «не задано», непустая строка = значение. «optional ⇒ nillable» честно в типе; одно - каноничное представление пустоты (NULL). -- **Лишний позиционный аргумент** `CreateUser(ctx, name, description, sel)` — - два соседних `string` рядом (name/description) легко перепутать местами, сигнатура - растёт. Отклонено на ревью. -- **Вход-структура** `UserCreateParams`/`UserEditParams` *(выбрано)* — именованные поля, - расширяется без ломки сигнатуры. +- **`NOT NULL DEFAULT ''` + `Description string`** — the simplest: an empty string = «no + description». But «optional» is then not reflected in the type — `""` and «not set» are indistinguishable, easy + to forget the normalization, the domain lies about optionality. (This was the first version of the PR; + rejected at review.) +- **A nullable column + `Description *string`** *(chosen for the representation)* — `NULL`/`nil` + = «not set», a non-empty string = a value. «optional ⇒ nillable» honestly in the type; one + canonical representation of emptiness (NULL). +- **An extra positional argument** `CreateUser(ctx, name, description, sel)` — + two adjacent `string`s next to each other (name/description) are easy to swap, the signature + grows. Rejected at review. +- **An input struct** `UserCreateParams`/`UserEditParams` *(chosen)* — named fields, + extensible without breaking the signature. ## Decision -- **`Description *string`** сквозь слои (entity → repo → service → handlers): `nil` = - не задано. Колонка `users.description` — **nullable**; репозиторий **сканирует её прямо - в `*string`** (стандартный `database/sql` + modernc кладут `NULL`→`nil`, значение→строку - сам — `sql.Null*`-прокси не нужен). Сервис нормализует пустое/пробелы в `nil` (одно - представление = NULL). Read-API (`GET /admin/api/users`) отдаёт `description` **только - когда задано** (поле опциональное в схеме), запись (`create`/`edit`) принимает - опциональную строку. -- **Сервисные входы — структуры** `entity.UserCreateParams{Name, Description, InboundIDs}` - и `entity.UserEditParams{ID, Description, InboundIDs}` вместо позиционных аргументов. - Прежний враппер `entity.ConnectionSelection` (пережиток старой архитектуры) удалён — - набор инбаундов едет голым `[]int64`. -- **Длину валидирует сервис** (`validateDescription` → `entity.ErrDescriptionTooLong`, - ≤500 рун), хендлер мапит в 400 — не «невидимым» `maxLength` в OpenAPI-схеме. Это - согласуется с тем, как валидируется `name` (`validateName` в сервисе). -- Колонка добавляется миграцией **`migrations/0002-users-description.sql`** через раннер - ([ADR-0002](0002-ordered-migration-runner.md)) — не правкой базлайна `0001-init.sql`. +- **`Description *string`** through the layers (entity → repo → service → handlers): `nil` = + not set. The column `users.description` is **nullable**; the repository **scans it straight + into a `*string`** (the standard `database/sql` + modernc put `NULL`→`nil`, a value→a string + themselves — a `sql.Null*` proxy is not needed). The service normalizes empty/whitespace to `nil` (one + representation = NULL). The read API (`GET /admin/api/users`) returns `description` **only + when set** (the field is optional in the schema), the write (`create`/`edit`) accepts + an optional string. +- **Service inputs — structs** `entity.UserCreateParams{Name, Description, InboundIDs}` + and `entity.UserEditParams{ID, Description, InboundIDs}` instead of positional arguments. + The former wrapper `entity.ConnectionSelection` (a relic of the old architecture) was removed — the + set of inbounds travels as a bare `[]int64`. +- **The length is validated by the service** (`validateDescription` → `entity.ErrDescriptionTooLong`, + ≤500 runes), the handler maps it to 400 — not by an «invisible» `maxLength` in the OpenAPI schema. This is + consistent with how `name` is validated (`validateName` in the service). +- The column is added by the migration **`migrations/0002-users-description.sql`** via the runner + ([ADR-0002](0002-ordered-migration-runner.md)) — not by editing the `0001-init.sql` baseline. ## Consequences -- «Нет описания» имеет единственное представление (NULL/`nil`) — нет двусмысленности - `""` vs не задано; меньше шансов забыть нормализацию. -- `CreateUser`/`EditUser` расширяемы новыми полями без изменения сигнатуры (через структуру). -- Валидация длины — в одном месте (сервис), видима и покрыта юнит-тестом; OpenAPI её не - дублирует. -- UI показывает описание иконкой с тултипом рядом с ником; в форме create/edit — textarea. -- Это первая фича-миграция поверх раннера (`0002-*.sql`) — подтверждает, что схема растёт - новыми файлами, а не правкой базлайна. +- «No description» has a single representation (NULL/`nil`) — there is no ambiguity of + `""` vs not set; less chance of forgetting the normalization. +- `CreateUser`/`EditUser` are extensible with new fields without changing the signature (via the struct). +- Length validation — in one place (the service), visible and covered by a unit test; OpenAPI does not + duplicate it. +- The UI shows the description as an icon with a tooltip next to the nickname; in the create/edit form — a textarea. +- This is the first feature migration on top of the runner (`0002-*.sql`) — it confirms that the schema grows + by new files, not by editing the baseline. diff --git a/docs/decisions/0005-strict-mihomo-refs.md b/docs/decisions/0005-strict-mihomo-refs.md index 460f60c..b45426b 100644 --- a/docs/decisions/0005-strict-mihomo-refs.md +++ b/docs/decisions/0005-strict-mihomo-refs.md @@ -1,86 +1,86 @@ -# 0002 — Строгие типизированные ссылки в mihomo-конфиге (RULE-SET → provider по id) +# 0005 — Strict typed references in the mihomo config (RULE-SET → provider by id) -- **Статус:** Accepted -- **Дата:** 2026-06-11 +- **Status:** Accepted +- **Date:** 2026-06-11 - **PR:** #17 ## Context -`RoutingRule` адресовал rule-provider грязно: для `RULE-SET` имя провайдера лежало -строкой в `RoutingRule.Value` (валидация — матч по имени, рендер — имя уходило в YAML -как есть). Это нарушало правило проекта «ссылка на сущность = по id; строка — только -если это и есть строка-пейлоад» (AGENTS.md, «entity — самодокументируемые типы»): -провайдер — это entity, а `Value` обязан быть чистым строковым пейлоадом (domain/ip/ -port), пустым для `RULE-SET` и `MATCH`. +`RoutingRule` addressed a rule-provider dirtily: for `RULE-SET` the provider name lay as a +string in `RoutingRule.Value` (validation — a match by name, render — the name went into the YAML +as is). This violated the project rule «a reference to an entity = by id; a string — only +if it is itself a string payload» (AGENTS.md, «entity — self-documenting types»): +a provider is an entity, and `Value` must be a pure string payload (domain/ip/ +port), empty for `RULE-SET` and `MATCH`. -Группы и инбаунды уже адресуются по id (`PolicyRef.GroupID`/`InboundID`), но у группы -был скрытый wart: доменное поле `PolicyRef.GroupID` несло РАЗНЫЙ смысл по направлению — -на входе в `SaveMihomoConfig` это индекс в массиве (id ещё не присвоены), на чтении из -БД это реальный id. Одно поле — два смысла. +Groups and inbounds were already addressed by id (`PolicyRef.GroupID`/`InboundID`), but a group +had a hidden wart: the domain field `PolicyRef.GroupID` carried a DIFFERENT meaning by direction — +on input into `SaveMihomoConfig` it is an index into the array (ids are not assigned yet), +on read from the DB it is a real id. One field — two meanings. -У mihomo числовых id нет: в выходном YAML прокси-группы и rule-providers адресуются по -ИМЕНИ (rule-providers — это map, ключ = имя). То есть имя — PK выходного документа. -Внутри subgen, который этот документ редактирует, имя — мутабельное поле формы. +mihomo has no numeric ids: in the output YAML proxy-groups and rule-providers are addressed by +NAME (rule-providers are a map, the key = the name). That is, the name is the PK of the output +document. Inside subgen, which edits this document, the name is a mutable form field. ## Considered Options -- **Оставить имя как ссылку, но типизировать** (`ProviderRef{Name}` вместо строки в - `Value`) — честно к тому, что имя есть PK документа mihomo; но join-ключом внутри - редактора становится мутабельная строка: переименование провайдера рвёт ссылку (это и - было текущее поведение + предупреждение «несуществующий провайдер»). -- **Round-trip за id на каждый «+»** (ручка new-provider возвращает id) — даёт реальные - id на этапе редактирования, но ломает атомарность (save сейчас — wholesale-replace в - одной транзакции), оставляет в БД полу-собранные конфиги (нужен GC сирот), и id всё - равно эфемерны (следующий полный save их пересоздаёт). Чатность без durable-выгоды. -- **Суррогатный id + ссылка по индексу на wire** *(выбрано)* — провайдер получает - суррогатный `id`, `RULE-SET` ссылается по нему; имя резолвится в YAML-ключ только на - рендере (как уже сделано для групп). На границе с фронтом ссылка едет индексом массива - (correlation id), резолвится в id внутри `SaveMihomoConfig`. Rename-safe, даёт - FK-целостность, симметрично группам. -- **Перейти на incremental-diff persistence** (durable id, UPDATE на месте) — снял бы - эфемерность id и сам wart индекс/id; но усложняет save (диф, удаления, порядок FK) и - при размере конфигов (горстка групп/провайдеров) не окупается. +- **Keep the name as the reference, but type it** (`ProviderRef{Name}` instead of a string in + `Value`) — honest to the fact that the name is the PK of the mihomo document; but the join key inside the + editor becomes a mutable string: renaming a provider breaks the reference (this was the current + behavior + the warning «non-existent provider»). +- **A round-trip for an id on every «+»** (the new-provider endpoint returns an id) — gives real + ids at the editing stage, but breaks atomicity (save is currently a wholesale replace in + one transaction), leaves half-assembled configs in the DB (orphan GC is needed), and the ids are + ephemeral anyway (the next full save recreates them). Chattiness without a durable benefit. +- **A surrogate id + a reference by index on the wire** *(chosen)* — the provider gets a + surrogate `id`, `RULE-SET` references it; the name is resolved into the YAML key only on + render (as already done for groups). At the boundary with the frontend the reference travels as an array + index (a correlation id), resolved into an id inside `SaveMihomoConfig`. Rename-safe, gives + FK integrity, symmetric to groups. +- **Switch to incremental-diff persistence** (durable ids, in-place UPDATE) — would remove the + id ephemerality and the index/id wart itself; but it complicates save (the diff, deletions, FK order) and + at the config sizes (a handful of groups/providers) does not pay off. ## Decision -Берём суррогатный id + ссылку по индексу, и при этом **разводим типы save-входа и -domain/read**, чтобы убрать двойной смысл поля: +We take a surrogate id + a reference by index, and at the same time **split the types of the save input and +domain/read** in order to remove the field's double meaning: -- **`RuleProvider`** += `ID int64`; **`RoutingRule`** += `ProviderID *int64` (nil кроме - `RULE-SET`), `Value` — чистый пейлоад. -- Доменные типы (`RoutingRule`/`ProxyGroup`/`RuleProvider`/`PolicyRef`) несут только - реальные id — их потребляют чтения из БД и рендер. -- Новое семейство **draft**-типов (`ConfigDraft`/`RuleDraft`/`GroupDraft`/`RefDraft`) - несёт индексы — его производит `DecodeConfig` и потребляет `SaveMihomoConfig`. Индекс→ - id резолвится внутри save (в локальных слайсах), не в типе. -- БД: `mihomo_rule_providers` получает `id INTEGER PRIMARY KEY AUTOINCREMENT` + - `UNIQUE(config_id,name)`; `mihomo_routing_rules` += `provider_id`; опциональные колонки - (`value`/`interval`/`tolerance`/`lazy`) становятся nullable. Схема едет раннером - миграций — `migrations/0003-strict-mihomo-refs.notx.sql`: rebuild трёх таблиц + - backfill `provider_id` из имён + очистка `value`. Так как rebuild требует - `PRAGMA foreign_keys=OFF` (no-op внутри транзакции), раннер расширен **`.notx`-режимом** - (миграция выполняется вне транзакции, сама пишет себя в `schema_migrations`) — отступление - от ADR-0002 «каждая в транзакции», осознанное и задокументированное. -- Wire не раздваивается (он уже индексный в обе стороны) — в `MihomoRule` добавлен - только `providerIdx`. Имя провайдера на проводе как ссылка не используется. +- **`RuleProvider`** += `ID int64`; **`RoutingRule`** += `ProviderID *int64` (nil except for + `RULE-SET`), `Value` — a pure payload. +- The domain types (`RoutingRule`/`ProxyGroup`/`RuleProvider`/`PolicyRef`) carry only + real ids — they are consumed by reads from the DB and the render. +- A new **draft** type family (`ConfigDraft`/`RuleDraft`/`GroupDraft`/`RefDraft`) + carries indices — it is produced by `DecodeConfig` and consumed by `SaveMihomoConfig`. Index→ + id is resolved inside save (in local slices), not in the type. +- DB: `mihomo_rule_providers` gets `id INTEGER PRIMARY KEY AUTOINCREMENT` + + `UNIQUE(config_id,name)`; `mihomo_routing_rules` += `provider_id`; the optional columns + (`value`/`interval`/`tolerance`/`lazy`) become nullable. The schema travels via the migration + runner — `migrations/0003-strict-mihomo-refs.notx.sql`: a rebuild of the three tables + + a backfill of `provider_id` from names + a cleanup of `value`. Since the rebuild requires + `PRAGMA foreign_keys=OFF` (a no-op inside a transaction), the runner was extended with a **`.notx` mode** + (the migration runs outside a transaction, writes itself into `schema_migrations`) — a departure + from ADR-0002 «each in a transaction», deliberate and documented. +- The wire does not split (it is already index-based both ways) — only `providerIdx` was added to + `MihomoRule`. The provider name is not used on the wire as a reference. -Почему индекс/correlation-id, а не имя: при wholesale-replace провайдеры/правила -пересоздаются каждым save, durable id'а нет в принципе, поэтому ссылка на этапе -редактирования обязана быть либо по имени (мутабельно), либо по позиции (correlation -id). Позиция rename-safe и симметрична группам. +Why an index/correlation id, and not the name: with a wholesale replace providers/rules are +recreated by every save, there is no durable id in principle, so a reference at the editing +stage must be either by name (mutable) or by position (a correlation +id). Position is rename-safe and symmetric to groups. ## Consequences -- Переименование провайдера больше не рвёт RULE-SET-ссылки (как и у групп). -- Появляется FK-целостность `rule → provider` в пределах снапшота; дубль имени ловится - как `UNIQUE` (2067), переводится в `entity.ErrRuleProviderNameTaken` (детектор матчит и - PK 1555, и UNIQUE 2067 — поведение сохранилось). -- Draft и domain никогда не сосуществуют в одном графе (save: wire→ConfigDraft→БД; read: - БД→domain), поэтому ни одно поле не несёт «индекс на входе / id на выходе». Цена — - параллельное семейство draft-типов (заводится только там, где есть ссылка по индексу; - провайдер ссылок не несёт → переиспользует доменный `RuleProvider` с пустым ID). -- Прод-миграция ручная и одноразовая, требует бэкапа и read-only сверки (есть ли - RULE-SET-правила, совпадают ли имена); порядок и проверки описаны в самом файле миграции. -- Чистота разделения держится на wholesale-replace; переход на incremental-diff - потребовал бы гибридного ref-типа (или индекс, или id) — сознательно отложен. -- AND/OR/NOT (логические правила) лягут на эту очищенную модель отдельной работой. +- Renaming a provider no longer breaks RULE-SET references (as with groups). +- FK integrity `rule → provider` within a snapshot appears; a duplicate name is caught + as `UNIQUE` (2067), translated into `entity.ErrRuleProviderNameTaken` (the detector matches both + PK 1555 and UNIQUE 2067 — the behavior is preserved). +- Draft and domain never coexist in one graph (save: wire→ConfigDraft→DB; read: + DB→domain), so no field carries «an index on input / an id on output». The cost — a + parallel family of draft types (introduced only where there is a reference by index; the + provider carries no references → it reuses the domain `RuleProvider` with an empty ID). +- The prod migration is manual and one-off, requires a backup and a read-only check (whether there are + RULE-SET rules, whether the names match); the order and the checks are described in the migration file itself. +- The cleanliness of the separation rests on the wholesale replace; switching to incremental-diff + would require a hybrid ref type (either an index or an id) — deliberately deferred. +- AND/OR/NOT (logical rules) will land on this cleaned-up model as separate work. diff --git a/docs/decisions/0006-recursive-routing-rules.md b/docs/decisions/0006-recursive-routing-rules.md index e576e1b..a3daefb 100644 --- a/docs/decisions/0006-recursive-routing-rules.md +++ b/docs/decisions/0006-recursive-routing-rules.md @@ -1,111 +1,111 @@ -# 0006 — Логические правила маршрутизации (AND/OR/NOT) как рекурсивное правило +# 0006 — Logical routing rules (AND/OR/NOT) as a recursive rule -- **Статус:** Accepted -- **Дата:** 2026-06-16 +- **Status:** Accepted +- **Date:** 2026-06-16 - **PR:** #114 ## Context -Модель маршрутного правила была плоским одиночным матчером: -`RoutingRule{Type, Value, ProviderID, NoResolve, Target}` — один тип, одно значение, один -таргет. Она не выражает логические/составные правила mihomo (`AND`/`OR`/`NOT`), у которых -payload — это вложенный список под-правил: `LOGIC,((TYPE,VAL),(TYPE,VAL)),TARGET`. Это -блокировало реальную операционную потребность — глушение QUIC +The routing-rule model was a flat single matcher: +`RoutingRule{Type, Value, ProviderID, NoResolve, Target}` — one type, one value, one +target. It does not express mihomo's logical/composite rules (`AND`/`OR`/`NOT`), whose +payload is a nested list of sub-rules: `LOGIC,((TYPE,VAL),(TYPE,VAL)),TARGET`. This +blocked a real operational need — silencing QUIC ``` AND,((NETWORK,UDP),(DST-PORT,443)),REJECT-DROP ``` -(заставляет HTTP/3 откатиться на TCP; чинит подвисания App Store / YouTube под TUN+fake-ip) -— его нельзя было собрать из UI. +(it forces HTTP/3 to fall back to TCP; fixes App Store / YouTube hangs under TUN+fake-ip) +— it could not be assembled from the UI. -Таргеты уже типизированы через `PolicyRef` (см. [ADR-0005](0005-strict-mihomo-refs.md)) — -пробел был исключительно на стороне матчеров. Дополнительно: оператор мог вписать секцию -`sub-rules` в base YAML и словить коллизию с генерацией (ключ не был в `GeneratedKeys`). +Targets are already typed via `PolicyRef` (see [ADR-0005](0005-strict-mihomo-refs.md)) — +the gap was exclusively on the matchers' side. Additionally: the operator could write a +`sub-rules` section into the base YAML and hit a collision with generation (the key was not in `GeneratedKeys`). -Сверка реестра `ruleTypes` с вики mihomo показала ещё четыре отсутствующих простых -матчера: `SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, `PROCESS-NAME-WILDCARD`. +Cross-checking the `ruleTypes` registry against the mihomo wiki revealed four more missing simple +matchers: `SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, `PROCESS-NAME-WILDCARD`. -Ограничения, выясненные из исходника mihomo (`rules/logic`): `NOT` принимает ровно одно -под-правило; под-правила парсятся через `ParseRulePayload(payload, parseParams=false)` — -**параметры (в т.ч. `no-resolve`) внутри логического под-правила не извлекаются**, то есть -под-правило не несёт `no-resolve`. `SUB-RULE` (ссылка на именованную группу sub-rule) — -отдельная крупная фича (именованные группы под-правил = второй редактор); по решению -владельца в этой итерации **не реализуется**, только закрывается дыра base YAML. +Constraints discovered from the mihomo source (`rules/logic`): `NOT` takes exactly one +sub-rule; sub-rules are parsed via `ParseRulePayload(payload, parseParams=false)` — +**parameters (incl. `no-resolve`) inside a logical sub-rule are not extracted**, that is, +a sub-rule does not carry `no-resolve`. `SUB-RULE` (a reference to a named sub-rule group) is +a separate large feature (named sub-rule groups = a second editor); by the +owner's decision in this iteration it is **not implemented**, only the base-YAML hole is closed. ## Considered Options -### Модель типа под-правила - -- **A. Единый рекурсивный тип.** `RoutingRule` (и save-`RuleDraft`) рекурсивны: логическое - правило несёт под-правила в `Children []RoutingRule` — той же структуры; `Target` - становится опциональным (`*PolicyRef`) — у верхнего уровня есть, у под-правила нет. - Плюсы: один тип, никакой «второй сущности»; ровно та модель, что у самого mihomo (правило - и под-правило — одно). Минусы: под-правило несёт неприменимые поля (`Target`/`NoResolve`), - которые запрещает валидация (позиционный инвариант top-level vs child). -- **B. Отдельный тип под-условия** (`RuleCondition`/`ConditionDraft`) — только нужные поля - (`Type/Value/Provider/Children`), без таргета. Плюсы: каждый тип несёт только своё. Минусы: - два параллельных рекурсивных типа + отдельная таблица + дублирование decode/render/clone; - переусложнение для по сути одной сущности. - -### Хранение - -- **A. Самоссылочная `mihomo_routing_rules`** (`parent_id` → self, `target_kind` nullable). - Под-правило — строка той же таблицы с `parent_id` и без таргета. CHECK пинит инвариант - `(parent_id IS NULL) = (target_kind IS NOT NULL)`. Плюсы: одна таблица, save/read/clone - — один проход; `DELETE … WHERE config_id` сносит дерево целиком (у всех строк есть - `config_id`). Минусы: нужен rebuild таблицы (target_kind → nullable) — `.notx`-миграция. -- **B. Отдельная таблица** `mihomo_rule_conditions`. Минусы: «вторая сущность» в БД, три - места рекурсии (save/read/clone) + отдельный provider-FK; противоречит единому типу. -- **C. JSON-блоб** под-правил. Минусы: ссылки на провайдер внутри блоба — не FK; нарушает - инвариант типизированных ссылок (AGENTS). Отвергнут сразу. +### The sub-rule type model + +- **A. A single recursive type.** `RoutingRule` (and the save-side `RuleDraft`) are recursive: a logical + rule carries sub-rules in `Children []RoutingRule` — of the same structure; `Target` + becomes optional (`*PolicyRef`) — the top level has it, a sub-rule does not. + Pros: one type, no «second entity»; exactly the model mihomo itself has (a rule + and a sub-rule are one). Cons: a sub-rule carries inapplicable fields (`Target`/`NoResolve`), + which validation forbids (a positional invariant top-level vs child). +- **B. A separate sub-condition type** (`RuleCondition`/`ConditionDraft`) — only the needed fields + (`Type/Value/Provider/Children`), without a target. Pros: each type carries only its own. Cons: + two parallel recursive types + a separate table + duplication of decode/render/clone; + over-engineering for what is essentially one entity. + +### Storage + +- **A. A self-referential `mihomo_routing_rules`** (`parent_id` → self, `target_kind` nullable). + A sub-rule — a row of the same table with `parent_id` and without a target. A CHECK pins the invariant + `(parent_id IS NULL) = (target_kind IS NOT NULL)`. Pros: one table, save/read/clone + — one pass; `DELETE … WHERE config_id` removes the whole tree (every row has + `config_id`). Cons: a rebuild of the table is needed (target_kind → nullable) — a `.notx` migration. +- **B. A separate table** `mihomo_rule_conditions`. Cons: a «second entity» in the DB, three + places of recursion (save/read/clone) + a separate provider FK; it contradicts the single type. +- **C. A JSON blob** of sub-rules. Cons: references to a provider inside the blob are not FKs; it violates + the typed-references invariant (AGENTS). Rejected immediately. ## Decision -- **Единый рекурсивный тип (A) + самоссылочная таблица (A).** `RoutingRule.Children - []RoutingRule`, `Target *PolicyRef` (nil только у под-правила); save-зеркало — - `RuleDraft.Children []RuleDraft`, `Target *RefDraft`. Отдельные `RuleCondition`/ - `ConditionDraft` и таблица `mihomo_rule_conditions` **не вводятся** — это было - переусложнение (правило и под-правило — одна сущность, как в mihomo). Хранение — - `mihomo_routing_rules` с `parent_id` (миграция `0004-*.notx.sql`, rebuild с nullable - `target_kind`). JSON-блоб отвергнут как нарушающий типизированные ссылки. -- **Инвариант — в коде, позиционно.** `RuleDraft.Valid()` — позиционно-независимая - per-type проверка (логическое не несёт value/provider/no-resolve; `Children` только у - логического). Рекурсивный `validateRule(r, top, …)` пинит позиционное: top-level обязан - иметь таргет (`ErrTargetRequired`), под-правило — не иметь (`ErrChildTarget`); `NOT` - ровно одно, `AND`/`OR` ≥2; `MATCH` нельзя как под-правило (`ErrMatchChild`); provider-индекс - в диапазоне; под-правило не несёт `no-resolve`. -- **`no-resolve` у под-правил — нет.** Совпадает с парсером mihomo и убирает поле из - под-правила (несёт только top-level). -- **Wire — `MihomoRule` рекурсивный.** `target` опционален (не `required`), `children[]` — - self-`$ref`; отдельного `MihomoCondition` нет. Инвариант top-level/child — в сервисе, не - в схеме (по конвенции AGENTS). -- **`sub-rules` → `GeneratedKeys`.** Закрывает дыру base YAML (save отвергает, render - стрипает); subgen именованные sub-rule-группы не генерирует. SUB-RULE как тип правила не - добавлен. -- **Паритет матчеров:** + `SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, - `PROCESS-NAME-WILDCARD` (простые, без особых опций). -- **UI:** рекурсивный компонент `rule-node` (дерево с отступами). Переупорядочивание - под-правил **намеренно не реализовано** — AND/OR/NOT коммутативны, порядок не влияет на - матчинг (и это снимает конфликт вложенных SortableJS). Перетаскивание правил верхнего - уровня сохранено (строка+под-правила обёрнуты в единый перетаскиваемый `.rule-item`). +- **A single recursive type (A) + a self-referential table (A).** `RoutingRule.Children + []RoutingRule`, `Target *PolicyRef` (nil only on a sub-rule); the save mirror — + `RuleDraft.Children []RuleDraft`, `Target *RefDraft`. Separate `RuleCondition`/ + `ConditionDraft` and a `mihomo_rule_conditions` table are **not introduced** — that was + over-engineering (a rule and a sub-rule are one entity, as in mihomo). Storage — + `mihomo_routing_rules` with `parent_id` (the migration `0004-*.notx.sql`, a rebuild with a nullable + `target_kind`). The JSON blob was rejected as violating typed references. +- **The invariant — in code, positionally.** `RuleDraft.Valid()` — a position-independent + per-type check (a logical one carries no value/provider/no-resolve; `Children` only on a + logical one). A recursive `validateRule(r, top, …)` pins the positional part: a top-level one must + have a target (`ErrTargetRequired`), a sub-rule — must not (`ErrChildTarget`); `NOT` + exactly one, `AND`/`OR` ≥2; `MATCH` cannot be a sub-rule (`ErrMatchChild`); the provider index + in range; a sub-rule does not carry `no-resolve`. +- **`no-resolve` on sub-rules — no.** Matches the mihomo parser and removes the field from the + sub-rule (only the top-level one carries it). +- **The wire — `MihomoRule` is recursive.** `target` is optional (not `required`), `children[]` — + a self-`$ref`; there is no separate `MihomoCondition`. The top-level/child invariant — in the service, not + in the schema (per the AGENTS convention). +- **`sub-rules` → `GeneratedKeys`.** Closes the base-YAML hole (save rejects, render + strips); subgen does not generate named sub-rule groups. SUB-RULE as a rule type was not + added. +- **Matcher parity:** + `SRC-IP-ASN`, `SRC-IP-SUFFIX`, `PROCESS-PATH-WILDCARD`, + `PROCESS-NAME-WILDCARD` (simple, without special options). +- **UI:** a recursive `rule-node` component (a tree with indents). Reordering + sub-rules is **intentionally not implemented** — AND/OR/NOT are commutative, order does not affect + matching (and it removes the conflict of nested SortableJS). Dragging top-level + rules is preserved (a row + its sub-rules are wrapped in a single draggable `.rule-item`). ## Consequences -- Произвольная рекурсия `AND(AND(OR(...),NOT(...)),...)` round-trip'ит БД ↔ render ↔ - decode; QUIC-правило собирается из UI и рендерится дословно. -- Одна таблица для правил и под-правил: save — рекурсивный `insertRules` (по-узловая - вставка, `LastInsertId` → `parent_id`); read — сборка дерева в памяти из одного запроса; - clone — один проход по `ORDER BY id` (родитель раньше ребёнка) с remap - parent/provider/group. `DELETE … WHERE config_id` сносит всё дерево. -- Под-правило несёт неприменимые поля (`Target`/`NoResolve`), которые валидатор запрещает; - это осознанная цена единого типа (меньше типов важнее, чем «каждое поле применимо»). -- Под-правила не ссылаются на инбаунды (это матчеры, не таргеты) → per-subscriber drop - работает только по `Target` правила; единственный «сбой» рендера под-правила — - нерезолвнутый провайдер RULE-SET (config-ошибка, лог + drop правила). -- Под-правила не несут `no-resolve` — если он понадобится логике mihomo в будущем, это - потребует и поддержки в парсере mihomo, и расширения модели. -- Порядок под-правил в UI не редактируется; если когда-нибудь понадобится — добавляется - тривиально (модель уже упорядочена `position`). -- SUB-RULE (именованные группы под-правил) остаётся будущей задачей; `sub-rules` - зарезервирован за subgen, так что ввести его позже можно без миграции ключа. +- An arbitrary recursion `AND(AND(OR(...),NOT(...)),...)` round-trips DB ↔ render ↔ + decode; the QUIC rule is assembled from the UI and rendered verbatim. +- One table for rules and sub-rules: save — a recursive `insertRules` (a per-node + insert, `LastInsertId` → `parent_id`); read — assembling the tree in memory from a single query; + clone — one pass over `ORDER BY id` (parent before child) with a remap of + parent/provider/group. `DELETE … WHERE config_id` removes the whole tree. +- A sub-rule carries inapplicable fields (`Target`/`NoResolve`), which the validator forbids; + this is a deliberate cost of the single type (fewer types matters more than «every field is applicable»). +- Sub-rules do not reference inbounds (they are matchers, not targets) → the per-subscriber drop + works only by the rule's `Target`; the only render «failure» of a sub-rule is + an unresolved RULE-SET provider (a config error, log + drop of the rule). +- Sub-rules do not carry `no-resolve` — if it is ever needed by mihomo's logic in the future, it + will require both support in the mihomo parser and an extension of the model. +- The sub-rule order in the UI is not editable; if it is ever needed — it is added + trivially (the model is already ordered by `position`). +- SUB-RULE (named sub-rule groups) remains a future task; `sub-rules` + is reserved for subgen, so it can be introduced later without a key migration. diff --git a/docs/decisions/0008-subscription-link-catalog.md b/docs/decisions/0008-subscription-link-catalog.md index a8936c6..3df8ebd 100644 --- a/docs/decisions/0008-subscription-link-catalog.md +++ b/docs/decisions/0008-subscription-link-catalog.md @@ -1,58 +1,58 @@ -# 0008 — Каталог ссылок подписки на бэкенде +# 0008 — Subscription-link catalog on the backend -- **Статус:** Accepted -- **Дата:** 2026-06-17 +- **Status:** Accepted +- **Date:** 2026-06-17 - **PR:** #116 ## Context -В списке пользователей колонка «Подписка» была одной кнопкой «Mihomo», копирующей единственный -URL подписки (`sub.url` в ответе `GET /admin/api/users`). Нужно копировать **несколько** -вещей на одного пользователя: сам URL подписки Mihomo и диплинк приложения Clashmi -(`clashmi://install-config?url=<enc>&name=<title>&overwrite=false`), а в будущем — и другие -(новые движки, новые приложения-клиенты). +In the users list the "Subscription" column was a single "Mihomo" button that copied the one +subscription URL (`sub.url` in the `GET /admin/api/users` response). We need to copy **several** +things per user: the Mihomo subscription URL itself and the Clashmi app deeplink +(`clashmi://install-config?url=<enc>&name=<title>&overwrite=false`), and in the future others +(new engines, new client apps). -Жёсткое требование: **фронт не должен хардкодить, какие ссылки бывают и какие у них тайтлы** — -иначе каждый новый клиент/движок тянет правку SPA. Это противоречит и общему стилю репозитория -(никаких магических строк, признаки — типами/каталогами на бэке). +Hard requirement: **the frontend must not hardcode which links exist or what their titles are** — +otherwise every new client/engine drags a SPA edit. That also conflicts with the repo's general +style (no magic strings; signals expressed as types/catalogs on the backend). ## Considered Options -- **A. Хардкод на фронте.** SPA сам строит clashmi-диплинк из `sub.url` и знает тайтлы. - Минусы: прямое нарушение требования; формат диплинка и список ссылок живут в JS; каждый - новый клиент = правка фронта; дубль логики экранирования. -- **B. `sub.url` + параллельное поле `deeplinks`.** Оставить URL, добавить рядом список - диплинков. Минусы: фронт всё равно знает форму каждого спец-поля; не единообразно; - добавление вида ссылки не каталог-driven — снова правки контракта и фронта. -- **C. Плоский список `sub.links: [{title, value}]`, каталог — в сервисе на бэке (выбрано).** - Ответ несёт готовый упорядоченный список копируемых пар «тайтл → значение»; фронт рендерит - его как есть. Каталог (какие ссылки, их тайтлы, формат диплинка) — в новом +- **A. Hardcode on the frontend.** The SPA builds the clashmi deeplink from `sub.url` itself and + knows the titles. Cons: a direct violation of the requirement; the deeplink format and the link + list live in JS; every new client = a frontend edit; duplicated escaping logic. +- **B. `sub.url` + a parallel `deeplinks` field.** Keep the URL, add a list of deeplinks beside it. + Cons: the frontend still knows the shape of each special field; not uniform; adding a link kind + isn't catalog-driven — again contract and frontend edits. +- **C. A flat `sub.links: [{title, value}]` list, the catalog in a backend service (chosen).** The + response carries a ready, ordered list of copyable "title → value" pairs; the frontend renders it + as is. The catalog (which links, their titles, the deeplink format) lives in a new `internal/service/sublinks`. -- Источник `name` для clashmi-диплинка: **(i) profile title эффективного конфига (выбрано)**, - (ii) отдельное env-поле сервиса, (iii) ник пользователя. (i) семантически совпадает с тем, - что подписка уже отдаёт в заголовке `Profile-Title`, и не плодит новой конфигурации. +- Source of the clashmi deeplink's `name`: **(i) the profile title of the effective config (chosen)**, + (ii) a separate service env field, (iii) the user's nickname. (i) matches semantically what the + subscription already returns in the `Profile-Title` header and adds no new configuration. ## Decision -Выбран вариант **C**. Новый сервис `internal/service/sublinks` владеет упорядоченным каталогом -`[]linkSpec{ title, kind, build(subURL, profileTitle) }`: для Mihomo `build` — тождество -(сам URL), для Clashmi — формат диплинка с `url.QueryEscape`. `Links(users)` строит на каждого -пользователя его URL (`base + /sub/<kind>/<token>`) и, для диплинков, подставляет `name` = -profile title **эффективного** конфига пользователя (кастомный, иначе базовый). Резолв тайтлов -эффективен: базовый title читается один раз на движок, кастомные — только у тех, у кого они есть. +Option **C** was chosen. A new `internal/service/sublinks` service owns an ordered catalog +`[]linkSpec{ title, kind, build(subURL, profileTitle) }`: for Mihomo `build` is the identity (the +URL itself), for Clashmi it is the deeplink format with `url.QueryEscape`. `Links(users)` builds +each user's URL (`base + /sub/<kind>/<token>`) and, for deeplinks, substitutes `name` = the profile +title of the user's **effective** config (custom, else base). Title resolution is efficient: the +base title is read once per engine, custom ones only for users who have them. -Контракт `sub` в `GET /admin/api/users` меняется с `{id, url}` на `{links: [{title, value}]}` -(`id`/`url` убраны — `id` фронтом не использовался, `url` заменён списком). Хендлер `users_get` -делегирует сборку сервису и больше не строит URL сам. +The `sub` contract in `GET /admin/api/users` changes from `{id, url}` to `{links: [{title, value}]}` +(`id`/`url` removed — `id` was unused by the frontend, `url` is replaced by the list). The +`users_get` handler delegates assembly to the service and no longer builds the URL itself. ## Consequences -- **Добавить движок/приложение = одна строка в `catalog`** — без правок фронта, контракта и - admin-API. Фронт рендерит `sub.links` вербатим (тайтл + кнопка «Копировать»). -- `sub.id`/`sub.url` исчезли из ответа. apitest (чёрный ящик) выбирает «сырой» URL подписки по - схеме (`http…`), а не по тайтлу (`UserSub.SubURL()`), оставаясь агностичным к составу ссылок. -- Резолв тайтлов на страницу — несколько чтений (база + кастомы), не одно-на-пользователя; для - admin-страницы это незаметно. -- Сегодня каталог завязан на mihomo (диплинк Clashmi — это клиент Clash; `sublinks` импортирует - `mihomo.Profile` ради title). Это осознанно: при добавлении xray/sing-box каталог и его - зависимости расширяются явно, без скрытой «магии». +- **Adding an engine/app = one line in the `catalog`** — no frontend, contract, or admin-API edits. + The frontend renders `sub.links` verbatim (title + a "Copy" button). +- `sub.id`/`sub.url` are gone from the response. apitest (black box) selects the "raw" subscription + URL by scheme (`http…`), not by title (`UserSub.SubURL()`), staying agnostic to the link set. +- Title resolution per page is several reads (base + customs), not one-per-user; for an admin page + that is unnoticeable. +- Today the catalog is tied to mihomo (the Clashmi deeplink is a Clash client; `sublinks` imports + `mihomo.Profile` for the title). This is deliberate: when xray/sing-box are added, the catalog and + its dependencies expand explicitly, without hidden "magic". diff --git a/docs/decisions/0009-public-ready-and-english-docs.md b/docs/decisions/0009-public-ready-and-english-docs.md new file mode 100644 index 0000000..cf11c3e --- /dev/null +++ b/docs/decisions/0009-public-ready-and-english-docs.md @@ -0,0 +1,55 @@ +# 0009 — Public-ready: English-only docs & UI, MIT license + +- **Status:** Accepted +- **Date:** 2026-06-18 +- **PR:** #TBD + +## Context + +subgen is being published as a public repository. Two project-level decisions had to be +made and recorded so future contributors share the same defaults: + +1. **Language.** Human-facing text was inconsistent: `README.md` and `docs/subgen.md` were + English, while `CHANGELOG.md`, the ADRs, `AGENTS.md`, the admin UI labels and the + user-facing handler messages were Russian. A public repo needs one language. +2. **License.** There was no `LICENSE` file. Without one the code is, by default, "all + rights reserved" — not open-source; nobody may legally fork or reuse it. + +## Considered Options + +- **Language** + - **English everywhere** — docs, ADRs, `AGENTS.md`, `CHANGELOG`, admin UI, user-facing + messages. Widest reach for an open-source audience; one language to maintain. + - **Russian everywhere** — matches the original UI/market, but narrows the contributor + and user base for a public project. + - **Bilingual** (e.g. `README.md` + `README.ru.md`) — broadest, but double the + maintenance and easy to let drift. +- **License** + - **MIT** — short, maximally permissive, by far the most common; reuse with attribution. + - **Apache-2.0** — permissive plus an explicit patent grant; more ceremony. + - **GPL-3.0** — copyleft; forks must stay open. + - **None** — remain proprietary. + +## Decision + +**English for all human-facing text** (docs, ADRs, `AGENTS.md`, `CHANGELOG`, admin UI +labels, and user-facing handler/error messages), and the **MIT** license. + +English maximizes reach and keeps a single source of truth without the drift risk of a +bilingual setup. MIT is the lowest-friction choice for a small self-hosted tool we want +people to freely run and fork; the patent and copyleft concerns of Apache/GPL do not apply +here. + +## Consequences + +- A one-time sweep translated `AGENTS.md`, `CHANGELOG.md`, every ADR, `apitest/README.md`, + `docs/subgen.md`, the admin SPA (`internal/handlers/web/static/`), and all user-facing + message constants in `internal/handlers/*` to English. Unit tests and the black-box + `apitest` assertions were updated in lockstep (they assert the exact text). +- New code and docs must stay English — including user-facing strings. This is the standing + rule; `CONTRIBUTING.md` states it, and a `grep -I '[А-Яа-я]'` over the tree should stay + empty (operator-entered data in the running store is exempt). +- A `LICENSE` (MIT) is added; GitHub now recognises the project as MIT-licensed, and the + `README` carries the badge and a license section. +- The product can still serve Russian-market users; only the codebase, UI chrome and docs + are English. diff --git a/docs/img/mihomo_config_overview_1.png b/docs/img/mihomo_config_overview_1.png new file mode 100644 index 0000000..5338f1b Binary files /dev/null and b/docs/img/mihomo_config_overview_1.png differ diff --git a/docs/img/mihomo_config_overview_2.png b/docs/img/mihomo_config_overview_2.png new file mode 100644 index 0000000..77fc404 Binary files /dev/null and b/docs/img/mihomo_config_overview_2.png differ diff --git a/docs/img/mihomo_rule_provider_config.png b/docs/img/mihomo_rule_provider_config.png new file mode 100644 index 0000000..dcb1b47 Binary files /dev/null and b/docs/img/mihomo_rule_provider_config.png differ diff --git a/docs/img/node_conifg.png b/docs/img/node_conifg.png new file mode 100644 index 0000000..857d733 Binary files /dev/null and b/docs/img/node_conifg.png differ diff --git a/docs/img/nodes_overview.png b/docs/img/nodes_overview.png new file mode 100644 index 0000000..2307204 Binary files /dev/null and b/docs/img/nodes_overview.png differ diff --git a/docs/img/social-preview.png b/docs/img/social-preview.png new file mode 100644 index 0000000..a5104c2 Binary files /dev/null and b/docs/img/social-preview.png differ diff --git a/docs/img/user_config.png b/docs/img/user_config.png new file mode 100644 index 0000000..a6ca96e Binary files /dev/null and b/docs/img/user_config.png differ diff --git a/docs/img/users_overview.png b/docs/img/users_overview.png new file mode 100644 index 0000000..0b81d6d Binary files /dev/null and b/docs/img/users_overview.png differ diff --git a/docs/subgen.md b/docs/subgen.md index c8b9838..c912a9d 100644 --- a/docs/subgen.md +++ b/docs/subgen.md @@ -52,7 +52,7 @@ client GET /sub/{kind}/{token} ──(token = HMAC(secret, subId))──► res - **Auth:** Bearer API token per panel (3x-ui >= 3.2). `Authorization: Bearer <token>` — no login/cookie/CSRF. Issue with `x-ui setting -getApiToken`. - **Routing is operator-defined, typed, per-subscriber:** the operator builds - mihomo **proxy-groups** (e.g. a `select` switcher `🎯 Подключение`) and **routing + mihomo **proxy-groups** (e.g. a `select` switcher `🎯 Proxy`) and **routing rules** in the panel. A rule's target and a group's members are the **same typed `PolicyRef`** — a built-in policy, an **inbound** (by id), or another group (no magic strings; resolved by typed `PolicyKind`). `render` resolves each ref @@ -85,7 +85,7 @@ client GET /sub/{kind}/{token} ──(token = HMAC(secret, subId))──► res rule-provider has **two independent TTLs**: `interval` (the mihomo client's ruleset auto-update, always rendered into the YAML) and `mirror_interval` (subgen's mirror refresh, used only when mirroring is on) — both edited in the per-provider **edit - modal** on the Конфиг Mihomo page. A **«проверить»** button there probes the URL + modal** on the Mihomo config page. A **"check"** button there probes the URL (`POST /admin/api/config/mihomo/provider/check`, saves nothing): reachable? file present? content matches the declared format? — `.mrs` is detected by the **zstd** frame magic `28 B5 2F FD` (an `.mrs` is a zstd container, so no zstd dependency is @@ -110,7 +110,7 @@ client GET /sub/{kind}/{token} ──(token = HMAC(secret, subId))──► res from `db/`, TLS via the acme cert mounted read-only, public port `2097/tcp`). The legacy systemd unit is stopped/disabled (its `systemd/` dir was removed from the repo). - **Edit routing/nodes/users:** all in the `/admin` panel (see below). A single - Save on the Конфиг Mihomo page persists proxy-groups + rules + providers + base + Save on the Mihomo config page persists proxy-groups + rules + providers + base YAML in one transaction, for the **selected scope** (the shared base, or a user's custom config); it takes effect on the next `/sub` request (the store is read live). Node/user actions invalidate the fleet cache so proxies refresh immediately. @@ -142,44 +142,44 @@ set). The backend serves a static shell (`index.html`) + pure JSON read endpoint under `/admin/api/*`; the SPA fetches them and posts mutations back as JSON. Login over the same TLS; session is a 12h HMAC-signed cookie. Three sections: -- **Пользователи** — list (nickname, `subId`, connections, traffic, subscription - link, health). **Новый пользователь** is a collapsed panel (click to expand): a +- **Users** — list (nickname, `subId`, connections, traffic, subscription + link, health). **New user** is a collapsed panel (click to expand): a unique nickname (`^[a-z0-9_-]{1,32}$`, also the 3x-ui client email) + any number - of inbounds (checkboxes, ≥1). **Изменить** opens a modal to re-assign connections — + of inbounds (checkboxes, ≥1). **Edit** opens a modal to re-assign connections — `EditUser` reconciles per panel and re-binds that panel's single client to the new inbound set (preserving its uuid). **Delete** removes the user's client from every panel (by email = nickname) then the store row. Drifted clients get a - **Пересоздать** button (full per-panel re-bind). **Collision guard:** if a target + **Recreate** button (full per-panel re-bind). **Collision guard:** if a target panel already has a client with this nickname (`email`) — an orphan, or a foreign/ manual client — on a panel the user doesn't yet own, create/edit **abort and change nothing** (no store row, no panel write) with an error naming the panel; subgen **never deletes a client it doesn't own**, so resolve it on that panel. We only - delete-and-re-add on panels the user already owns (re-bind / Пересоздать). + delete-and-re-add on panels the user already owns (re-bind / Recreate). `subId`/`uuid` are random per user and never collide in practice. -- **Узлы** — the node registry (CRUD): name, 3x-ui base URL + path, Bearer token +- **Nodes** — the node registry (CRUD): name, 3x-ui base URL + path, Bearer token (write-only), and an **inbound editor** — add **any number** of inbounds, each a `name` + `port` row (`name` is an ASCII `[a-z0-9-]` label like `force`/`smart`, unique within the node; the port is likewise unique within the - node). **Новый узел** / **Изменить** + node). **New node** / **Edit** open in a **modal popup**. Existing inbounds round-trip by `node_inbounds.id` (the form sends it back), so editing a port keeps the id stable and the bound - users intact. The **Панель** column is a link to the panel UI. Fields are + users intact. The **Panel** column is a link to the panel UI. Fields are validated server-side (`validateNode`): host for clients = bare host **or IP** (no scheme/port), base URL = `https://host:port` only (host may be IP; no path), ports 1–65535, ≥1 inbound required. Deleting a node — or removing an inbound from it — is **blocked while that inbound is still referenced**: by a user connection **or** by a mihomo rule / proxy-group member (FK RESTRICT + a pre-check returns a clear error naming the node/inbound and the reason); detach those first. A new - node's inbounds become available as **inbound PolicyRef options** in the Конфиг - Mihomo constructors (wire them into a group/rule to route to them). -- **Конфиг Mihomo** — two visual **constructors** plus rule-providers and base YAML: + node's inbounds become available as **inbound PolicyRef options** in the Mihomo + config constructors (wire them into a group/rule to route to them). +- **Mihomo config** — two visual **constructors** plus rule-providers and base YAML: - **Proxy-groups** — operator-defined mihomo proxy-groups (the former hardcoded group and the connection selector are now ordinary rows). Each group has a name, a type (`select`/`url-test`/`fallback`/`load-balance`/`relay`, with health-check url/interval/tolerance shown by type) and an **ordered list of members**, each a typed **PolicyRef** (a built-in policy / an inbound / another group). Drag-to-reorder (SortableJS). - - **Правила** — ordered rule rows: a **type** select (mihomo matcher, grouped), + - **Rules** — ordered rule rows: a **type** select (mihomo matcher, grouped), a **value** (a rule-provider select for `RULE-SET`, hidden for `MATCH`, else a text input), a **no-resolve** toggle (IP matchers / `RULE-SET`), and a **target** PolicyRef picker. Drag-to-reorder; inline hints flag a missing/misplaced `MATCH`, @@ -207,11 +207,11 @@ over the same TLS; session is a 12h HMAC-signed cookie. Three sections: all fleet inbounds, with labels), so the taxonomy isn't baked into the UI. The mihomo-config endpoints live under **`/admin/api/config/mihomo`** (read), `…/schema`, `…/save`. - - **Scope selector (base vs per-user custom):** a **Пользователи** dropdown at the - top of the page picks the edited config — **Все** (the shared base) or a specific - user's custom config; **Добавить кастомный конфиг…** opens a user picker and + - **Scope selector (base vs per-user custom):** a **Users** dropdown at the + top of the page picks the edited config — **All** (the shared base) or a specific + user's custom config; **Add custom config…** opens a user picker and **clones the base** into a new custom config bound to that user (a snapshot — - independent thereafter). A banner on a custom scope offers **Удалить** (drop it; the + independent thereafter). A banner on a custom scope offers **Delete** (drop it; the user falls back to the base). Read/save carry the scope (`?user=<id>` / `userId` in the save body); management is `…/customs` (list users with a custom config), `…/custom/create`, `…/custom/delete`. The engine is the URL segment @@ -235,21 +235,21 @@ YAML editor (Monaco) loads from a CDN. Rotate the admin password by editing ## Issuing a subscription to a client -1. `/admin` → **Пользователи** → create (name + one or more inbounds) → **Копировать** +1. `/admin` → **Users** → create (name + one or more inbounds) → **Copy** the link, or read it from the row. 2. Hand them `https://ru1.freedom.postlog.ru:2097/sub/mihomo/<token>`. 3. They add it as a subscription in ClashMi (see [clash-clients.md](https://github.com/Postlog/vpn-toolchain/blob/main/docs/clash-clients.md)). -**Пересоздать** keeps the same `subId` (it only re-binds the panel clients), so +**Recreate** keeps the same `subId` (it only re-binds the panel clients), so the subscription link stays valid. Only deleting and creating a new user mints a new `subId` → a new link. ## Adding a node to the subscription -1. `/admin` → **Узлы** → add the panel (base URL + secret path + Bearer token) and +1. `/admin` → **Nodes** → add the panel (base URL + secret path + Bearer token) and one or more inbounds (`name` + `port`). -2. `/admin` → **Конфиг Mihomo** → reference the new inbound where you want it: as an - **inbound** member of your `🎯 Подключение` switcher group, and/or as a rule target. +2. `/admin` → **Mihomo config** → reference the new inbound where you want it: as an + **inbound** member of your `🎯 Proxy` switcher group, and/or as a rule target. (Unlike the old auto-built selector, group membership is now explicit data.) 3. Save — it takes effect on the next `/sub` request; no file edits, no restart. The proxy's wire-name is the inbound's label `<node>-<inbound>`, unique across the fleet. diff --git a/internal/handlers/api/server.go b/internal/handlers/api/server.go index 85677f2..eb6a4e8 100644 --- a/internal/handlers/api/server.go +++ b/internal/handlers/api/server.go @@ -200,17 +200,17 @@ func (s *Server) HandleAdminSession(ctx context.Context, _ oas.OperationName, t // plain error a handler returned; the handler already logged it with its own operation // context, so it is NOT re-logged generically. func (s *Server) ErrorHandler(_ context.Context, w http.ResponseWriter, r *http.Request, err error) { - status, msg := http.StatusInternalServerError, "Внутренняя ошибка" + status, msg := http.StatusInternalServerError, "Internal error" var secErr *ogenerrors.SecurityError switch { case errors.Is(err, errUnauthorized), errors.As(err, &secErr): - status, msg = http.StatusUnauthorized, "Требуется авторизация" + status, msg = http.StatusUnauthorized, "Authorization required" slog.Warn("api: unauthorized request", "path", r.URL.Path) case isBadRequest(err): - status, msg = http.StatusBadRequest, "Некорректный запрос" + status, msg = http.StatusBadRequest, "Bad request" slog.Warn("api: malformed request", "path", r.URL.Path, "err", err) } diff --git a/internal/handlers/config_save/handler.go b/internal/handlers/config_save/handler.go index 131b3f6..9e990dc 100644 --- a/internal/handlers/config_save/handler.go +++ b/internal/handlers/config_save/handler.go @@ -18,43 +18,43 @@ import ( // decode/validate sentinels (4xx invalid config), plus the two store-level conflicts. // Exported so apitest can assert against them without duplicating the text. const ( - MsgSaved = "Конфиг сохранён" - - MsgGroupNameEmpty = "Укажите название proxy-группы" - MsgGroupNameTaken = "Proxy-группа с таким названием уже существует" - MsgGroupUnknownType = "Неизвестный тип proxy-группы" - MsgGroupNoMembers = "Пустая proxy-группа" - MsgGroupCycle = "Proxy-группы образуют циклическую ссылку" - MsgGroupFieldNA = "Параметр неприменим к этому типу proxy-группы" - MsgBadRef = "Некорректная цель правила/элемента группы" - MsgGroupRefRange = "Ссылка на несуществующую группу" - MsgUnknownRuleType = "Неизвестный тип правила" - MsgMatchNotLast = "Правило MATCH должно быть последним" - MsgRuleValueReq = "У правила не указано значение" - MsgRulePayloadNA = "Тип правила не принимает это значение" - MsgNoResolveNA = "no-resolve неприменим к этому типу правила" - MsgChildrenNA = "Вложенные правила допустимы только у логических (AND/OR/NOT)" - MsgNotArity = "NOT должен содержать ровно одно вложенное правило" - MsgLogicalArity = "AND/OR должны содержать минимум два вложенных правила" - MsgMatchChild = "MATCH нельзя использовать как вложенное правило" - MsgTargetRequired = "У правила не указана цель" - MsgChildTarget = "У вложенного правила не должно быть цели" - MsgBaseYAMLInvalid = "YAML невалиден — проверьте синтаксис" - MsgGeneratedKey = "Уберите из YAML генерируемые разделы" - - MsgProviderNameEmpty = "Укажите название rule-provider" - MsgProviderBadBehavior = "Неизвестный behavior у rule-provider" - MsgProviderBadFormat = "Неизвестный format у rule-provider" - MsgProviderURLEmpty = "Укажите URL у rule-provider" - MsgRuleSetUnknownProv = "RULE-SET ссылается на несуществующего rule-provider" - - MsgProfileTitleEmpty = "Укажите название профиля (Profile title)" - MsgProfileFilenameEmpty = "Укажите имя файла подписки" - MsgProfileFilenameInvalid = "Имя файла не должно содержать / \\ или управляющие символы" - MsgProfileIntervalInvalid = "Интервал обновления — положительное число часов" - - MsgUserConfigMissing = "У пользователя нет кастомного конфига" - MsgProviderNameTaken = "Rule-provider с таким именем уже существует" + MsgSaved = "Config saved" + + MsgGroupNameEmpty = "Enter a proxy-group name" + MsgGroupNameTaken = "A proxy-group with this name already exists" + MsgGroupUnknownType = "Unknown proxy-group type" + MsgGroupNoMembers = "Empty proxy-group" + MsgGroupCycle = "Proxy-groups form a cyclic reference" + MsgGroupFieldNA = "This field does not apply to this proxy-group type" + MsgBadRef = "Invalid rule/group-member target" + MsgGroupRefRange = "Reference to a non-existent group" + MsgUnknownRuleType = "Unknown rule type" + MsgMatchNotLast = "The MATCH rule must be last" + MsgRuleValueReq = "The rule has no value" + MsgRulePayloadNA = "This rule type does not accept a value" + MsgNoResolveNA = "no-resolve does not apply to this rule type" + MsgChildrenNA = "Nested rules are allowed only for logical rules (AND/OR/NOT)" + MsgNotArity = "NOT must contain exactly one nested rule" + MsgLogicalArity = "AND/OR must contain at least two nested rules" + MsgMatchChild = "MATCH cannot be used as a nested rule" + MsgTargetRequired = "The rule has no target" + MsgChildTarget = "A nested rule must not have a target" + MsgBaseYAMLInvalid = "Invalid YAML — check the syntax" + MsgGeneratedKey = "Remove the generated sections from the YAML" + + MsgProviderNameEmpty = "Enter a rule-provider name" + MsgProviderBadBehavior = "Unknown rule-provider behavior" + MsgProviderBadFormat = "Unknown rule-provider format" + MsgProviderURLEmpty = "Enter the rule-provider URL" + MsgRuleSetUnknownProv = "RULE-SET references a non-existent rule-provider" + + MsgProfileTitleEmpty = "Enter the profile title (Profile title)" + MsgProfileFilenameEmpty = "Enter the subscription filename" + MsgProfileFilenameInvalid = "The filename must not contain / \\ or control characters" + MsgProfileIntervalInvalid = "Update interval must be a positive number of hours" + + MsgUserConfigMissing = "The user has no custom config" + MsgProviderNameTaken = "A rule-provider with this name already exists" ) // Handler saves a mihomo config (base or a user's custom). diff --git a/internal/handlers/custom_create/handler.go b/internal/handlers/custom_create/handler.go index ce57705..6837490 100644 --- a/internal/handlers/custom_create/handler.go +++ b/internal/handlers/custom_create/handler.go @@ -12,7 +12,7 @@ import ( "github.com/postlog/subgen/internal/oas" ) -const msgConfigExists = "У пользователя уже есть кастомный конфиг" +const msgConfigExists = "The user already has a custom config" // Handler clones the base config into a new per-user custom config. type Handler struct { @@ -36,5 +36,5 @@ func (h *Handler) CustomCreate(ctx context.Context, req *oas.CustomCreateReq) (o return nil, err } - return &oas.MessageResponse{Message: "Кастомный конфиг создан"}, nil + return &oas.MessageResponse{Message: "Custom config created"}, nil } diff --git a/internal/handlers/custom_create/handler_test.go b/internal/handlers/custom_create/handler_test.go index 269d603..df96eed 100644 --- a/internal/handlers/custom_create/handler_test.go +++ b/internal/handlers/custom_create/handler_test.go @@ -33,7 +33,7 @@ func TestHandler_CustomCreate(t *testing.T) { CreateUserConfig(gomock.Any(), int64(7), entity.ConfigKindMihomo). Return(int64(42), nil) }, - result: &oas.MessageResponse{Message: "Кастомный конфиг создан"}, + result: &oas.MessageResponse{Message: "Custom config created"}, }, { name: "error.exists", diff --git a/internal/handlers/custom_delete/handler.go b/internal/handlers/custom_delete/handler.go index 0f73fc2..38f8ba4 100644 --- a/internal/handlers/custom_delete/handler.go +++ b/internal/handlers/custom_delete/handler.go @@ -12,7 +12,7 @@ import ( "github.com/postlog/subgen/internal/oas" ) -const msgConfigMissing = "У пользователя нет кастомного конфига" +const msgConfigMissing = "The user has no custom config" // Handler drops a user's custom config. type Handler struct { @@ -36,5 +36,5 @@ func (h *Handler) CustomDelete(ctx context.Context, req *oas.CustomDeleteReq) (o return nil, err } - return &oas.MessageResponse{Message: "Кастомный конфиг удалён"}, nil + return &oas.MessageResponse{Message: "Custom config deleted"}, nil } diff --git a/internal/handlers/custom_delete/handler_test.go b/internal/handlers/custom_delete/handler_test.go index 8f8f355..9708265 100644 --- a/internal/handlers/custom_delete/handler_test.go +++ b/internal/handlers/custom_delete/handler_test.go @@ -33,7 +33,7 @@ func TestHandler_CustomDelete(t *testing.T) { DeleteUserConfig(gomock.Any(), int64(7), entity.ConfigKindMihomo). Return(nil) }, - result: &oas.MessageResponse{Message: "Кастомный конфиг удалён"}, + result: &oas.MessageResponse{Message: "Custom config deleted"}, }, { name: "error.missing", diff --git a/internal/handlers/login/handler.go b/internal/handlers/login/handler.go index 7bbc0c3..3e050da 100644 --- a/internal/handlers/login/handler.go +++ b/internal/handlers/login/handler.go @@ -18,7 +18,7 @@ import ( // Exported so apitest can assert against it without duplicating the text. // //nolint:gosec // G101 false positive: a user-facing message, not a hardcoded credential. -const MsgBadCredentials = "Неверный логин или пароль" +const MsgBadCredentials = "Invalid username or password" // Handler renders the login page (GET) and processes the sign-in action (POST). type Handler struct { diff --git a/internal/handlers/node_delete/handler.go b/internal/handlers/node_delete/handler.go index 9b8097e..3bc67fe 100644 --- a/internal/handlers/node_delete/handler.go +++ b/internal/handlers/node_delete/handler.go @@ -15,9 +15,9 @@ import ( // User-facing messages. Exported so apitest can assert against them without duplicating // the text. const ( - MsgDeleted = "Узел удалён" - MsgNotFound = "Узел не найден" - MsgInboundReferenced = "Узел используется — сначала отвяжите его инбаунды от пользователей и правил" + MsgDeleted = "Node deleted" + MsgNotFound = "Node not found" + MsgInboundReferenced = "Node is in use — first detach its inbounds from users and rules" ) // Handler deletes a node via the nodes service. diff --git a/internal/handlers/node_save/handler.go b/internal/handlers/node_save/handler.go index 5beffd2..d2fb957 100644 --- a/internal/handlers/node_save/handler.go +++ b/internal/handlers/node_save/handler.go @@ -17,19 +17,19 @@ import ( // nodes service returns (400), and the FK refusal when an update drops a still-referenced // inbound (400). Exported so apitest can assert against them without duplicating the text. const ( - MsgNodeNameTaken = "Узел с таким именем уже существует" - MsgInboundDuplicate = "Имя или порт инбаунда уже заняты на этом узле" + MsgNodeNameTaken = "A node with this name already exists" + MsgInboundDuplicate = "Inbound name or port already taken on this node" - MsgNodeName = "Имя узла: разрешены a-z, 0-9, -, пробел и флаги стран" - MsgHost = "Адрес VPN-хоста невалиден — ожидается хост или IP (без схемы и порта)" - MsgPanelURL = "3x-ui base URL невалиден — ожидается https://host:port (без пути)" - MsgBasePath = "Укажите base path панели (например /secret/)" - MsgNoInbounds = "Укажите хотя бы один инбаунд" - MsgInboundName = "Имя инбаунда: разрешены a-z, 0-9 и -" - MsgInboundPort = "Порт инбаунда должен быть числом 1–65535" - MsgInboundNameUq = "Повторяющееся имя инбаунда" - MsgInboundPortUq = "Повторяющийся порт инбаунда" - MsgInboundReferenced = "Инбаунд используется — сначала отвяжите от него пользователей и правила" + MsgNodeName = "Node name: allowed characters are a-z, 0-9, -, space and country flags" + MsgHost = "VPN host address is invalid — expected a host or IP (no scheme or port)" + MsgPanelURL = "3x-ui base URL is invalid — expected https://host:port (no path)" + MsgBasePath = "Enter the panel base path (e.g. /secret/)" + MsgNoInbounds = "Add at least one inbound" + MsgInboundName = "Inbound name: allowed characters are a-z, 0-9 and -" + MsgInboundPort = "Inbound port must be a number between 1 and 65535" + MsgInboundNameUq = "Duplicate inbound name" + MsgInboundPortUq = "Duplicate inbound port" + MsgInboundReferenced = "Inbound is in use — first detach users and rules from it" ) // Handler creates or updates a node from the node form. @@ -64,7 +64,7 @@ func (h *Handler) NodeSave(ctx context.Context, req *oas.NodeSaveReq) (oas.NodeS return h.mapErr(n.Name, err) } - return &oas.MessageResponse{Message: "Узел сохранён: " + n.Name}, nil + return &oas.MessageResponse{Message: "Node saved: " + n.Name}, nil } // mapErr classifies a Save failure: name/inbound clash → 409; validation / still-referenced diff --git a/internal/handlers/node_save/handler_test.go b/internal/handlers/node_save/handler_test.go index d803a15..1149286 100644 --- a/internal/handlers/node_save/handler_test.go +++ b/internal/handlers/node_save/handler_test.go @@ -38,7 +38,7 @@ func TestHandler_NodeSave(t *testing.T) { result oas.NodeSaveRes err error }{ - {name: "success", result: &oas.MessageResponse{Message: "Узел сохранён: RU1"}}, + {name: "success", result: &oas.MessageResponse{Message: "Node saved: RU1"}}, {name: "error.name_taken", saveErr: entity.ErrNodeNameTaken, result: &oas.NodeSaveConflict{ErrMessage: MsgNodeNameTaken}}, {name: "error.inbound_duplicate", saveErr: entity.ErrInboundDuplicate, result: &oas.NodeSaveConflict{ErrMessage: MsgInboundDuplicate}}, {name: "error.inbound_referenced", saveErr: entity.ErrInboundReferenced, result: &oas.NodeSaveBadRequest{ErrMessage: MsgInboundReferenced}}, diff --git a/internal/handlers/provider_check/handler.go b/internal/handlers/provider_check/handler.go index 6817b1d..3894991 100644 --- a/internal/handlers/provider_check/handler.go +++ b/internal/handlers/provider_check/handler.go @@ -16,9 +16,9 @@ import ( // URL (like a malformed one) and surfaces as RulesetCheckUnreachable — the handler only // maps checker outcomes to text, it does not validate the URL. const ( - MsgUnreachable = "Не удалось подключиться к URL" - MsgEmpty = "Ответ пустой — по URL нет файла" - MsgUnreachableP = "Не удалось подключиться: " // + technical detail + MsgUnreachable = "Could not connect to the URL" + MsgEmpty = "The response is empty — no file at the URL" + MsgUnreachableP = "Could not connect: " // + technical detail ) // Handler probes a rule-provider URL via the checker service. @@ -47,13 +47,13 @@ func (h *Handler) ProviderCheck(ctx context.Context, req *oas.ProviderCheckReq) func describe(res entity.RulesetCheckResult, format string) (bool, string) { switch res.Outcome { case entity.RulesetCheckOK: - return true, fmt.Sprintf("Доступен: формат «%s», %s", format, humanSize(res.Size)) + return true, fmt.Sprintf("Available: format %q, %s", format, humanSize(res.Size)) case entity.RulesetCheckHTTPError: - return false, fmt.Sprintf("Сервер вернул HTTP %d — файла нет или нет доступа", res.Status) + return false, fmt.Sprintf("The server returned HTTP %d — no file or no access", res.Status) case entity.RulesetCheckEmpty: return false, MsgEmpty case entity.RulesetCheckFormatMismatch: - return false, fmt.Sprintf("Скачалось (%s), но содержимое не похоже на формат «%s»", humanSize(res.Size), format) + return false, fmt.Sprintf("Downloaded (%s), but the content does not look like the %q format", humanSize(res.Size), format) default: // RulesetCheckUnreachable if res.Detail != "" { return false, MsgUnreachableP + res.Detail diff --git a/internal/handlers/provider_check/handler_test.go b/internal/handlers/provider_check/handler_test.go index e5108d8..102b015 100644 --- a/internal/handlers/provider_check/handler_test.go +++ b/internal/handlers/provider_check/handler_test.go @@ -33,7 +33,7 @@ func TestHandler_ProviderCheck(t *testing.T) { m.EXPECT().Check(gomock.Any(), url, format). Return(entity.RulesetCheckResult{Outcome: entity.RulesetCheckOK, Size: 1024}) }, - result: &oas.MessageResponse{Message: "Доступен: формат «yaml», 1.0 KB"}, + result: &oas.MessageResponse{Message: "Available: format \"yaml\", 1.0 KB"}, }, { name: "http_error", @@ -42,7 +42,7 @@ func TestHandler_ProviderCheck(t *testing.T) { m.EXPECT().Check(gomock.Any(), url, format). Return(entity.RulesetCheckResult{Outcome: entity.RulesetCheckHTTPError, Status: 404}) }, - result: &oas.ProviderCheckBadRequest{ErrMessage: "Сервер вернул HTTP 404 — файла нет или нет доступа"}, + result: &oas.ProviderCheckBadRequest{ErrMessage: "The server returned HTTP 404 — no file or no access"}, }, { name: "empty", @@ -60,7 +60,7 @@ func TestHandler_ProviderCheck(t *testing.T) { m.EXPECT().Check(gomock.Any(), url, format). Return(entity.RulesetCheckResult{Outcome: entity.RulesetCheckFormatMismatch, Size: 512}) }, - result: &oas.ProviderCheckBadRequest{ErrMessage: "Скачалось (512 B), но содержимое не похоже на формат «yaml»"}, + result: &oas.ProviderCheckBadRequest{ErrMessage: "Downloaded (512 B), but the content does not look like the \"yaml\" format"}, }, { name: "unreachable", diff --git a/internal/handlers/user_create/handler.go b/internal/handlers/user_create/handler.go index 9bf31e4..f47031e 100644 --- a/internal/handlers/user_create/handler.go +++ b/internal/handlers/user_create/handler.go @@ -13,13 +13,13 @@ import ( // User-facing messages. Exported so apitest can assert against them without duplicating // the text. const ( - MsgCreated = "Создан пользователь" - MsgInvalidName = "Имя клиента: разрешены символы a-z, 0-9, _ и -. От 1 до 32 символов" - MsgNameTaken = "Имя занято" - MsgNoConnection = "Выберите хотя бы одно подключение" - MsgInboundNotFound = "Указанный инбаунд не найден" - MsgNodeNotFound = "Узел не найден" - MsgDescTooLong = "Описание слишком длинное (максимум 500 символов)" + MsgCreated = "User created" + MsgInvalidName = "Client name: allowed characters are a-z, 0-9, _ and -. From 1 to 32 characters" + MsgNameTaken = "Name already taken" + MsgNoConnection = "Select at least one connection" + MsgInboundNotFound = "The specified inbound was not found" + MsgNodeNotFound = "Node not found" + MsgDescTooLong = "Description is too long (max 500 characters)" ) // Handler provisions a new user. @@ -55,7 +55,7 @@ func (h *Handler) UserCreate(ctx context.Context, req *oas.UserCreateReq) (oas.U return &oas.UserCreateConflict{ErrMessage: MsgNameTaken}, nil case errors.As(err, &pce): slog.Warn("handler user_create: email exists on panel", "name", req.Name, "node", pce.Node) - return &oas.UserCreateConflict{ErrMessage: "на панели «" + pce.Node + "» уже есть клиент с таким именем — удалите его там вручную или выберите другое имя"}, nil + return &oas.UserCreateConflict{ErrMessage: "panel \"" + pce.Node + "\" already has a client with this name — delete it there manually or pick another name"}, nil case errors.Is(err, entity.ErrInvalidUserName): slog.Warn("handler user_create: invalid name", "name", req.Name) return &oas.UserCreateBadRequest{ErrMessage: MsgInvalidName}, nil diff --git a/internal/handlers/user_create/handler_test.go b/internal/handlers/user_create/handler_test.go index fb5baf3..e7a7562 100644 --- a/internal/handlers/user_create/handler_test.go +++ b/internal/handlers/user_create/handler_test.go @@ -33,10 +33,10 @@ func TestHandler_UserCreate(t *testing.T) { }{ { name: "success", - req: &oas.UserCreateReq{Name: "alice", Description: oas.NewOptString("заметка"), InboundIDs: []int64{1, 2}}, + req: &oas.UserCreateReq{Name: "alice", Description: oas.NewOptString("note"), InboundIDs: []int64{1, 2}}, buildCreatorMock: func(m *MockprovisioningService) { m.EXPECT().CreateUser(gomock.Any(), entity.UserCreateParams{ - Name: "alice", Description: utils.Ptr("заметка"), InboundIDs: []int64{1, 2}, + Name: "alice", Description: utils.Ptr("note"), InboundIDs: []int64{1, 2}, }).Return(&entity.User{ID: 7}, nil) }, result: &oas.MessageResponse{Message: MsgCreated}, @@ -55,7 +55,7 @@ func TestHandler_UserCreate(t *testing.T) { buildCreatorMock: func(m *MockprovisioningService) { m.EXPECT().CreateUser(gomock.Any(), params("bob", 1)).Return(nil, entity.PanelClientExistsError{Node: "N1"}) }, - result: &oas.UserCreateConflict{ErrMessage: "на панели «N1» уже есть клиент с таким именем — удалите его там вручную или выберите другое имя"}, + result: &oas.UserCreateConflict{ErrMessage: "panel \"N1\" already has a client with this name — delete it there manually or pick another name"}, }, { name: "error.invalid_name", diff --git a/internal/handlers/user_delete/handler.go b/internal/handlers/user_delete/handler.go index a8c5a6d..3db9f12 100644 --- a/internal/handlers/user_delete/handler.go +++ b/internal/handlers/user_delete/handler.go @@ -10,7 +10,7 @@ import ( // MsgDeleted is the success message. Exported so apitest can assert against it without // duplicating the text. -const MsgDeleted = "Пользователь удалён" +const MsgDeleted = "User deleted" // Handler deletes a user and deprovisions its panel clients. type Handler struct { diff --git a/internal/handlers/user_edit/handler.go b/internal/handlers/user_edit/handler.go index a434d3d..de4b858 100644 --- a/internal/handlers/user_edit/handler.go +++ b/internal/handlers/user_edit/handler.go @@ -13,10 +13,10 @@ import ( // User-facing messages. Exported so apitest can assert against them without duplicating // the text. const ( - MsgUpdated = "Подключения обновлены" - MsgNoConnection = "Выберите хотя бы одно подключение" - MsgInboundNotFound = "Указанный инбаунд не найден" - MsgDescTooLong = "Описание слишком длинное (максимум 500 символов)" + MsgUpdated = "Connections updated" + MsgNoConnection = "Select at least one connection" + MsgInboundNotFound = "The specified inbound was not found" + MsgDescTooLong = "Description is too long (max 500 characters)" ) // Handler re-binds a user to a new inbound set. diff --git a/internal/handlers/user_edit/handler_test.go b/internal/handlers/user_edit/handler_test.go index d28c0a6..f5f8ed1 100644 --- a/internal/handlers/user_edit/handler_test.go +++ b/internal/handlers/user_edit/handler_test.go @@ -33,10 +33,10 @@ func TestHandler_UserEdit(t *testing.T) { }{ { name: "success", - req: &oas.UserEditReq{ID: 7, Description: oas.NewOptString("заметка"), InboundIDs: []int64{1, 2}}, + req: &oas.UserEditReq{ID: 7, Description: oas.NewOptString("note"), InboundIDs: []int64{1, 2}}, buildEditorMock: func(m *MockprovisioningService) { m.EXPECT().EditUser(gomock.Any(), entity.UserEditParams{ - ID: 7, Description: utils.Ptr("заметка"), InboundIDs: []int64{1, 2}, + ID: 7, Description: utils.Ptr("note"), InboundIDs: []int64{1, 2}, }).Return(nil) }, result: &oas.MessageResponse{Message: MsgUpdated}, diff --git a/internal/handlers/user_recreate/handler.go b/internal/handlers/user_recreate/handler.go index 4f2ba36..6a5aab6 100644 --- a/internal/handlers/user_recreate/handler.go +++ b/internal/handlers/user_recreate/handler.go @@ -11,7 +11,7 @@ import ( // MsgRecreated is the success message. Exported so apitest can assert against it without // duplicating the text. -const MsgRecreated = "Клиенты пересозданы" +const MsgRecreated = "Clients recreated" // Handler re-provisions a user's panel clients from the store. type Handler struct { diff --git a/internal/handlers/web/static/app.js b/internal/handlers/web/static/app.js index f493aad..ea91304 100644 --- a/internal/handlers/web/static/app.js +++ b/internal/handlers/web/static/app.js @@ -53,7 +53,7 @@ const app = createApp({ } return out; }, - // Total pages for the users table (at least 1, so the pager always shows "1 из 1"). + // Total pages for the users table (at least 1, so the pager always shows "1 of 1"). userPageCount() { return Math.max(1, Math.ceil(this.userTotal / this.userPerPage)); }, // Windowed page list for the pager. Always a CONSTANT 7 slots when there are >7 // pages (first, last, a 3-wide window, and "…" fillers), so the layout width — and @@ -77,16 +77,16 @@ const app = createApp({ cfgWarnings() { const w = []; const matches = this.cfg.rules.filter((r) => r.type === "MATCH"); - if (!matches.length) w.push("Нет правила MATCH — добавьте catch-all в конце."); - else if (this.cfg.rules[this.cfg.rules.length - 1].type !== "MATCH") w.push("Правило MATCH должно быть последним."); + if (!matches.length) w.push("No MATCH rule — add a catch-all at the end."); + else if (this.cfg.rules[this.cfg.rules.length - 1].type !== "MATCH") w.push("The MATCH rule must be last."); const pUids = new Set(this.cfg.providers.map((p) => p._uid)); for (const r of this.cfg.rules) { - if (r.type === "RULE-SET" && (r.providerUid == null || !pUids.has(r.providerUid))) w.push("RULE-SET: не выбран провайдер или он удалён."); + if (r.type === "RULE-SET" && (r.providerUid == null || !pUids.has(r.providerUid))) w.push("RULE-SET: no provider selected or it was removed."); } const uids = new Set(this.cfg.groups.map((g) => g._uid)); const dangling = (pref) => pref.startsWith("group:") && !uids.has(+pref.slice(6)); if (this.cfg.rules.some((r) => dangling(r.pref)) || this.cfg.groups.some((g) => g.members.some((m) => dangling(m.pref)))) - w.push("Есть ссылка на удалённую группу."); + w.push("There is a reference to a removed group."); return w; }, }, @@ -111,7 +111,7 @@ const app = createApp({ } await this.loadScopeConfig(); } - } catch (e) { this.toast(false, "Загрузка: " + e); } + } catch (e) { this.toast(false, "Loading: " + e); } }, async loadNodes() { this.nodes = (await this.getJSON("/admin/api/nodes")).nodes || []; }, @@ -200,7 +200,7 @@ const app = createApp({ // deleteCustom drops the active user's custom config and returns to the base. async deleteCustom() { if (this.cfgScope.kind !== "user") return; - if (!confirm("Удалить кастомный конфиг пользователя " + this.cfgScope.name + "?")) return; + if (!confirm("Delete the custom config of user " + this.cfgScope.name + "?")) return; const d = await this.post("/admin/api/config/mihomo/custom/delete", { userId: this.cfgScope.userId }); if (!d.ok) return; await this.loadCustoms(); @@ -283,7 +283,7 @@ const app = createApp({ // right format) and toasts the outcome. Saves nothing; a per-row _checking flag // drives the inline spinner. async checkProvider(p) { - if (!p.url) { this.toast(false, "Сначала укажите URL у провайдера"); return; } + if (!p.url) { this.toast(false, "Set the provider URL first"); return; } p._checking = true; try { const r = await fetch("/admin/api/config/mihomo/provider/check", { @@ -293,8 +293,8 @@ const app = createApp({ }); if (r.status === 401 || r.status === 403) { location.assign("/admin/login"); return; } const d = await r.json().catch(() => ({})); - this.toast(r.ok, r.ok ? (d.message || "OK") : (d.errMessage || "Ошибка проверки")); - } catch (e) { this.toast(false, "Сеть: " + e); } + this.toast(r.ok, r.ok ? (d.message || "OK") : (d.errMessage || "Check failed")); + } catch (e) { this.toast(false, "Network: " + e); } finally { p._checking = false; } }, reorder(arr, oldIndex, newIndex) { const [m] = arr.splice(oldIndex, 1); arr.splice(newIndex, 0, m); }, @@ -398,10 +398,10 @@ const app = createApp({ if (r.status === 401 || r.status === 403) { location.assign("/admin/login"); return { ok: false }; } const d = r.status === 204 ? {} : await r.json().catch(() => ({})); const ok = r.ok; - const msg = ok ? (d.message || "Готово") : (d.errMessage || "Ошибка"); + const msg = ok ? (d.message || "Done") : (d.errMessage || "Error"); this.toast(ok, msg); return { ok, msg, data: d }; - } catch (e) { this.toast(false, "Сеть: " + e); return { ok: false }; } + } catch (e) { this.toast(false, "Network: " + e); return { ok: false }; } finally { this.busy = false; } }, // logout clears the session (POST), then navigates to the login page itself. @@ -416,8 +416,8 @@ const app = createApp({ }, copy(text) { navigator.clipboard.writeText(text).then( - () => this.toast(true, "Скопировано"), - () => this.toast(false, "Не удалось скопировать"), + () => this.toast(true, "Copied"), + () => this.toast(false, "Failed to copy"), ); }, hsize(b) { @@ -447,7 +447,7 @@ const app = createApp({ if (d.ok) { this.uForm.open = false; this.loadUsers(); } }, async deleteUser(u) { - if (!confirm("Удалить " + u.name + "?")) return; + if (!confirm("Delete " + u.name + "?")) return; this.actingId = u.id; try { const d = await this.post("/admin/api/users/delete", { id: u.id }); if (d.ok) await this.loadUsers(); } finally { this.actingId = 0; } @@ -480,7 +480,7 @@ const app = createApp({ if (d.ok) { this.nodeForm.open = false; this.load("nodes"); } }, async deleteNode(n) { - if (!confirm("Удалить узел " + n.name + "?")) return; + if (!confirm("Delete node " + n.name + "?")) return; const d = await this.post("/admin/api/nodes/delete", { id: n.id }); if (d.ok) this.load("nodes"); }, @@ -518,14 +518,14 @@ app.component("policy-picker", { methods: { has(cat) { return (this.allowed || []).includes(cat); } }, template: ` <select class="form-select form-select-sm" :value="modelValue" @change="$emit('update:modelValue', $event.target.value)"> - <optgroup label="Действия" v-if="has('actions')"> + <optgroup label="Actions" v-if="has('actions')"> <option v-for="a in actions" :key="a.kind" :value="a.kind">{{ a.label }}</option> </optgroup> - <optgroup label="Инбаунды" v-if="has('inbounds')"> + <optgroup label="Inbounds" v-if="has('inbounds')"> <option v-for="f in inbounds" :key="f.id" :value="'inbound:'+f.id">{{ f.label }}</option> </optgroup> - <optgroup label="Группы" v-if="has('groups') && groups.length"> - <option v-for="(g,i) in groups" :key="g._uid" :value="'group:'+g._uid">{{ g.name || ('группа '+(i+1)) }}</option> + <optgroup label="Groups" v-if="has('groups') && groups.length"> + <option v-for="(g,i) in groups" :key="g._uid" :value="'group:'+g._uid">{{ g.name || ('group '+(i+1)) }}</option> </optgroup> </select>`, }); @@ -565,16 +565,16 @@ app.component("rule-node", { <option v-for="t in types" :key="t.type" :value="t.type">{{ t.type }}</option> </select> <select v-if="isRuleSet(node.type)" class="form-select form-select-sm grow" v-model="node.providerUid"> - <option :value="null" disabled>— провайдер —</option> - <option v-for="(p,pi) in providers" :key="p._uid" :value="p._uid">{{ p.name || ('провайдер '+(pi+1)) }}</option> + <option :value="null" disabled>— provider —</option> + <option v-for="(p,pi) in providers" :key="p._uid" :value="p._uid">{{ p.name || ('provider '+(pi+1)) }}</option> </select> - <input v-else-if="!isLogical(node.type)" class="form-control form-control-sm grow" v-model="node.value" placeholder="значение"> - <span v-else class="grow text-dim small">вложенные правила</span> - <button class="btn btn-sm btn-danger-soft act" @click="$emit('remove')" title="удалить вложенное правило">✕</button> + <input v-else-if="!isLogical(node.type)" class="form-control form-control-sm grow" v-model="node.value" placeholder="value"> + <span v-else class="grow text-dim small">nested rules</span> + <button class="btn btn-sm btn-danger-soft act" @click="$emit('remove')" title="delete nested rule">✕</button> </div> <div v-if="isLogical(node.type)" class="cond-children"> <rule-node v-for="(c,ci) in node.children" :key="c._uid" :node="c" :schema="schema" :providers="providers" @remove="delChild(ci)"></rule-node> - <button class="btn btn-sm btn-outline-secondary mt-1" @click="addChild()">Добавить вложенное правило</button> + <button class="btn btn-sm btn-outline-secondary mt-1" @click="addChild()">Add nested rule</button> </div> </div>`, }); @@ -605,10 +605,10 @@ app.component("duration-input", { <div class="dur-input"> <input class="form-control" type="number" min="0" v-model.number="num" @input="emit"> <select class="form-select" v-model.number="unit" @change="emit"> - <option :value="1">сек</option> - <option :value="60">мин</option> - <option :value="3600">час</option> - <option :value="86400">дн</option> + <option :value="1">sec</option> + <option :value="60">min</option> + <option :value="3600">hour</option> + <option :value="86400">day</option> </select> </div>`, }); @@ -624,7 +624,7 @@ app.component("modal", { <div class="modal-card" :class="{lg}" role="dialog" aria-modal="true"> <div class="modal-head"> <h5>{{ title }}</h5> - <button class="icon-btn" @click="$emit('close')" aria-label="Закрыть">✕</button> + <button class="icon-btn" @click="$emit('close')" aria-label="Close">✕</button> </div> <div class="modal-body"><slot></slot></div> <div class="modal-foot"><slot name="footer"></slot></div> @@ -770,9 +770,9 @@ app.component("yaml-editor", { }, template: ` <div class="ye"> - <div class="ye-mon" ref="host"><span v-if="!ready" class="ye-loading">загрузка редактора…</span></div> + <div class="ye-mon" ref="host"><span v-if="!ready" class="ye-loading">loading editor…</span></div> <div class="ye-status" :class="err ? 'is-err' : ''" @click="gotoErr"> - <template v-if="err"><span class="ye-dot"></span>строка {{ err.line }}:{{ err.col }} — {{ err.msg }}</template> + <template v-if="err"><span class="ye-dot"></span>line {{ err.line }}:{{ err.col }} — {{ err.msg }}</template> </div> </div>`, }); diff --git a/internal/handlers/web/static/index.html b/internal/handlers/web/static/index.html index 62941fc..a7e8ae8 100644 --- a/internal/handlers/web/static/index.html +++ b/internal/handlers/web/static/index.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="ru" data-bs-theme="dark"> +<html lang="en" data-bs-theme="dark"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @@ -13,11 +13,11 @@ <div class="container"> <span class="navbar-brand mb-0">sub<span class="dot">gen</span></span> <ul class="navbar-nav me-auto gap-1 flex-row"> - <li class="nav-item"><a class="nav-link" :class="{active:tab==='users'}" @click="go('users')">Пользователи</a></li> - <li class="nav-item"><a class="nav-link" :class="{active:tab==='nodes'}" @click="go('nodes')">Узлы</a></li> - <li class="nav-item"><a class="nav-link" :class="{active:tab==='config'}" @click="go('config')">Конфиг Mihomo</a></li> + <li class="nav-item"><a class="nav-link" :class="{active:tab==='users'}" @click="go('users')">Users</a></li> + <li class="nav-item"><a class="nav-link" :class="{active:tab==='nodes'}" @click="go('nodes')">Nodes</a></li> + <li class="nav-item"><a class="nav-link" :class="{active:tab==='config'}" @click="go('config')">Mihomo config</a></li> </ul> - <button type="button" class="btn btn-sm btn-outline-secondary" @click="logout">Выйти</button> + <button type="button" class="btn btn-sm btn-outline-secondary" @click="logout">Log out</button> </div> </nav> @@ -26,50 +26,50 @@ <!-- ============ USERS ============ --> <section v-if="tab==='users'"> <div class="page-head"> - <h1 class="page-title">Пользователи</h1> - <button class="btn btn-primary btn-add" @click="openCreateUser()"><span class="plus">+</span> Новый пользователь</button> + <h1 class="page-title">Users</h1> + <button class="btn btn-primary btn-add" @click="openCreateUser()"><span class="plus">+</span> New user</button> </div> <div class="users-toolbar"> - <input class="form-control form-control-sm tb-search" v-model="userSearch" @input="onUserSearch" placeholder="Поиск по имени…"> + <input class="form-control form-control-sm tb-search" v-model="userSearch" @input="onUserSearch" placeholder="Search by name…"> <div class="tb-filter"> <button class="btn btn-sm btn-outline-secondary" @click="inboundFilterOpen=!inboundFilterOpen"> - Подключения: <b v-if="userInboundFilter.length">{{ userInboundFilter.length }}</b><span v-else>все</span> <span class="caret">▾</span> + Connections: <b v-if="userInboundFilter.length">{{ userInboundFilter.length }}</b><span v-else>all</span> <span class="caret">▾</span> </button> <div v-if="inboundFilterOpen" class="filter-backdrop" @click="inboundFilterOpen=false"></div> <div v-if="inboundFilterOpen" class="filter-pop"> <div class="filter-pop-head"> - <span class="text-dim small">Фильтр по подключениям</span> - <span v-if="userInboundFilter.length" class="reset" @click="clearInboundFilter()">сбросить</span> + <span class="text-dim small">Filter by connection</span> + <span v-if="userInboundFilter.length" class="reset" @click="clearInboundFilter()">reset</span> </div> <div class="pick-list"> <label v-for="o in inboundOptions" :key="o.id" class="pick" :class="{on: userInboundFilter.includes(o.id)}"> <input class="form-check-input" type="checkbox" :checked="userInboundFilter.includes(o.id)" @change="toggleInboundFilter(o.id)"> <span class="pname">{{ o.label }}</span><span class="pport">:{{ o.port }}</span> </label> - <span v-if="!inboundOptions.length" class="inb-empty">нет инбаундов</span> + <span v-if="!inboundOptions.length" class="inb-empty">no inbounds</span> </div> </div> </div> - <span class="tb-count text-dim small">Всего: {{ userTotal }}</span> + <span class="tb-count text-dim small">Total: {{ userTotal }}</span> </div> <div class="card"> <table class="table table-hover"> <colgroup><col style="width:23%"><col style="width:21%"><col style="width:15%"><col style="width:11%"><col style="width:30%"></colgroup> - <thead><tr><th>Имя</th><th>Подключения</th><th>Трафик</th><th>Подписка</th><th></th></tr></thead> + <thead><tr><th>Name</th><th>Connections</th><th>Traffic</th><th>Subscription</th><th></th></tr></thead> <tbody> <tr v-for="u in users" :key="u.id" :class="{acting:actingId===u.id}"> <td class="uname">{{ u.name }}<span v-if="u.description" class="udesc"><svg class="udesc-ic" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="currentColor" opacity=".16"></circle><circle cx="8" cy="4.7" r="1" fill="currentColor"></circle><rect x="7" y="6.7" width="2" height="5" rx="1" fill="currentColor"></rect></svg><span class="udesc-pop">{{ u.description }}</span></span></td> <td> <div v-if="u.inbounds.length" class="conn"> <span class="badge tag" :class="u.inbounds[0].missing ? 'miss' : 'inb'"> - <span v-if="u.inbounds[0].missing" title="нет клиента на панели">⚠</span>{{ u.inbounds[0].label }}<span class="port">:{{ u.inbounds[0].port }}</span> + <span v-if="u.inbounds[0].missing" title="no client on the panel">⚠</span>{{ u.inbounds[0].label }}<span class="port">:{{ u.inbounds[0].port }}</span> </span> <span v-if="u.inbounds.length>1" class="badge tag more">+{{ u.inbounds.length-1 }}</span> <div v-if="u.inbounds.length>1" class="conn-pop"> <div v-for="i in u.inbounds" :key="i.id" class="conn-pop-row"> - <span v-if="i.missing" class="cp-warn" title="нет клиента на панели">⚠</span> + <span v-if="i.missing" class="cp-warn" title="no client on the panel">⚠</span> <span class="cp-label">{{ i.label }}</span><span class="cp-port">:{{ i.port }}</span> </div> </div> @@ -77,17 +77,17 @@ <h1 class="page-title">Пользователи</h1> <span v-else class="text-dim small">—</span> </td> <td class="traffic"><span class="up">↑{{ hsize(u.stats.up) }}</span><span class="down">↓{{ hsize(u.stats.down) }}</span></td> - <td><button class="btn btn-sm btn-outline-secondary act" @click="openSubLinks(u)">Ссылки</button></td> + <td><button class="btn btn-sm btn-outline-secondary act" @click="openSubLinks(u)">Links</button></td> <td class="actions"> <span v-if="actingId===u.id" class="spin me-2"></span> <div class="act-group"> - <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="openEdit(u)">Изменить</button> - <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="recreateUser(u)">Пересоздать</button> - <button class="btn btn-sm btn-danger-soft act" :disabled="busy" @click="deleteUser(u)">Удалить</button> + <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="openEdit(u)">Edit</button> + <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="recreateUser(u)">Recreate</button> + <button class="btn btn-sm btn-danger-soft act" :disabled="busy" @click="deleteUser(u)">Delete</button> </div> </td> </tr> - <tr v-if="!users.length"><td colspan="5" class="text-dim text-center py-4">{{ (userSearch.trim() || userInboundFilter.length) ? 'Ничего не найдено' : 'Пользователей нет' }}</td></tr> + <tr v-if="!users.length"><td colspan="5" class="text-dim text-center py-4">{{ (userSearch.trim() || userInboundFilter.length) ? 'Nothing found' : 'No users' }}</td></tr> </tbody> </table> </div> @@ -105,19 +105,19 @@ <h1 class="page-title">Пользователи</h1> <!-- ============ NODES ============ --> <section v-if="tab==='nodes'"> <div class="page-head"> - <h1 class="page-title">Узлы</h1> - <button class="btn btn-primary btn-add" @click="openCreateNode()"><span class="plus">+</span> Новый узел</button> + <h1 class="page-title">Nodes</h1> + <button class="btn btn-primary btn-add" @click="openCreateNode()"><span class="plus">+</span> New node</button> </div> <div class="card"> <table class="table table-hover"> <colgroup><col style="width:16%"><col style="width:23%"><col style="width:12%"><col style="width:31%"><col style="width:18%"></colgroup> - <thead><tr><th>Имя</th><th>Домен</th><th>Панель</th><th>Инбаунды</th><th></th></tr></thead> + <thead><tr><th>Name</th><th>Domain</th><th>Panel</th><th>Inbounds</th><th></th></tr></thead> <tbody> <tr v-for="n in nodes" :key="n.id"> <td class="uname cell-ellipsis">{{ n.name }}</td> <td class="mono small cell-ellipsis">{{ n.vpnHost }}</td> - <td><a class="panel-link" :href="n.panelBaseURL + n.panelBasePath" target="_blank" rel="noopener" :title="n.panelBaseURL + n.panelBasePath">Панель ↗</a></td> + <td><a class="panel-link" :href="n.panelBaseURL + n.panelBasePath" target="_blank" rel="noopener" :title="n.panelBaseURL + n.panelBasePath">Panel ↗</a></td> <td> <div class="chips"> <span v-for="i in n.inbounds" :key="i.id" class="badge tag inb">{{ i.name }}<span class="port">:{{ i.port }}</span></span> @@ -126,12 +126,12 @@ <h1 class="page-title">Узлы</h1> </td> <td class="actions"> <div class="act-group"> - <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="openNode(n)">Изменить</button> - <button class="btn btn-sm btn-danger-soft act" :disabled="busy" @click="deleteNode(n)">Удалить</button> + <button class="btn btn-sm btn-outline-secondary act" :disabled="busy" @click="openNode(n)">Edit</button> + <button class="btn btn-sm btn-danger-soft act" :disabled="busy" @click="deleteNode(n)">Delete</button> </div> </td> </tr> - <tr v-if="!nodes.length"><td colspan="5" class="text-dim text-center py-4">Узлов нет</td></tr> + <tr v-if="!nodes.length"><td colspan="5" class="text-dim text-center py-4">No nodes</td></tr> </tbody> </table> </div> @@ -140,26 +140,26 @@ <h1 class="page-title">Узлы</h1> <!-- ============ CONFIG ============ --> <section v-if="tab==='config'"> <div class="page-head"> - <h1 class="page-title">Конфиг</h1> + <h1 class="page-title">Config</h1> <div class="scope-pick ms-auto"> - <label class="text-dim small me-1">Пользователи:</label> + <label class="text-dim small me-1">Users:</label> <select class="form-select form-select-sm" :value="cfgScope.kind==='base' ? 'base' : ('user:'+cfgScope.userId)" @change="onScopeChange($event.target.value)"> - <option value="base">Все</option> - <optgroup label="Кастомные" v-if="customs.length"> + <option value="base">All</option> + <optgroup label="Custom" v-if="customs.length"> <option v-for="c in customs" :key="c.userId" :value="'user:'+c.userId">{{ c.name }}</option> </optgroup> - <option value="+new">Добавить кастомный конфиг…</option> + <option value="+new">Add custom config…</option> </select> </div> </div> <div v-if="cfgScope.kind==='user'" class="scope-banner"> - <span>Кастомный конфиг — <b>{{ cfgScope.name }}</b></span> - <button class="btn btn-sm btn-danger-soft ms-auto" :disabled="busy" @click="deleteCustom()">Удалить</button> + <span>Custom config — <b>{{ cfgScope.name }}</b></span> + <button class="btn btn-sm btn-danger-soft ms-auto" :disabled="busy" @click="deleteCustom()">Delete</button> </div> <!-- subscription profile --> <div class="card mb-3"> - <div class="card-header">Параметры подписки</div> + <div class="card-header">Subscription settings</div> <div class="card-body"> <div class="d-flex flex-wrap gap-3"> <label class="flex-fill"> @@ -167,11 +167,11 @@ <h1 class="page-title">Конфиг</h1> <input class="form-control form-control-sm" v-model="cfg.profileTitle" placeholder="Freedom"> </label> <label class="flex-fill"> - <span class="text-dim small d-block mb-1">Имя файла</span> + <span class="text-dim small d-block mb-1">Filename</span> <input class="form-control form-control-sm" v-model="cfg.filename" placeholder="freedom.yaml"> </label> <label style="max-width:200px"> - <span class="text-dim small d-block mb-1">Интервал обновления, ч</span> + <span class="text-dim small d-block mb-1">Update interval, h</span> <input class="form-control form-control-sm" type="number" min="1" v-model.number="cfg.profileUpdateInterval" placeholder="1"> </label> </div> @@ -181,14 +181,14 @@ <h1 class="page-title">Конфиг</h1> <!-- proxy-groups --> <div class="card mb-3"> <div class="card-header">proxy-groups - <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addGroup()">Добавить группу</button> + <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addGroup()">Add group</button> </div> <div class="card-body"> <div v-sortable="{handle:'.h-grp', item:'.grp-card', end:(o,n)=>reorder(cfg.groups,o,n)}"> <div v-for="(g,gi) in cfg.groups" :key="g._uid" class="grp-card"> <div class="grp-head"> - <span class="drag h-grp" title="перетащить">⋮⋮</span> - <input class="form-control form-control-sm grow" v-model="g.name" placeholder="имя группы"> + <span class="drag h-grp" title="drag">⋮⋮</span> + <input class="form-control form-control-sm grow" v-model="g.name" placeholder="group name"> <select class="form-select form-select-sm" style="max-width:140px" v-model="g.type"> <option v-for="gt in (schema?.proxyGroup?.types||[])" :key="gt.type" :value="gt.type">{{ gt.type }}</option> </select> @@ -197,26 +197,26 @@ <h1 class="page-title">Конфиг</h1> <input class="form-control form-control-sm" style="max-width:90px" type="number" v-model.number="g.interval" placeholder="interval"> <input v-if="groupTolerance(g.type)" class="form-control form-control-sm" style="max-width:90px" type="number" v-model.number="g.tolerance" placeholder="tol, ms"> </template> - <button class="btn btn-sm btn-danger-soft act" @click="delGroup(gi)" title="удалить группу">✕</button> + <button class="btn btn-sm btn-danger-soft act" @click="delGroup(gi)" title="delete group">✕</button> </div> <div class="grp-members" v-sortable="{handle:'.h-mbr', item:'.mbr-row', end:(o,n)=>reorder(g.members,o,n)}"> <div v-for="(m,mi) in g.members" :key="m._uid" class="mbr-row"> - <span class="drag h-mbr" title="перетащить">⋮⋮</span> + <span class="drag h-mbr" title="drag">⋮⋮</span> <policy-picker v-model="m.pref" :allowed="groupItems(g.type)" :actions="schema?.actions||[]" :inbounds="inboundOptions" :groups="cfg.groups"></policy-picker> <button class="btn btn-sm btn-danger-soft act" @click="delMember(g,mi)">✕</button> </div> </div> - <button class="btn btn-sm btn-outline-secondary mt-1" @click="addMember(g)">Добавить элемент</button> + <button class="btn btn-sm btn-outline-secondary mt-1" @click="addMember(g)">Add item</button> </div> </div> - <span v-if="!cfg.groups.length" class="text-dim small">групп нет</span> + <span v-if="!cfg.groups.length" class="text-dim small">no groups</span> </div> </div> <!-- rules --> <div class="card mb-3"> <div class="card-header">rules - <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addRule()">Добавить правило</button> + <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addRule()">Add rule</button> </div> <div class="card-body"> <div v-if="cfgWarnings.length" class="cfg-warn"> @@ -225,16 +225,16 @@ <h1 class="page-title">Конфиг</h1> <div v-sortable="{handle:'.h-rule', item:'.rule-item', end:(o,n)=>reorder(cfg.rules,o,n)}"> <div v-for="(r,ri) in cfg.rules" :key="r._uid" class="rule-item"> <div class="rule-row"> - <span class="drag h-rule" title="перетащить">⋮⋮</span> + <span class="drag h-rule" title="drag">⋮⋮</span> <select class="form-select form-select-sm" style="max-width:170px" v-model="r.type"> <option v-for="rt in (schema?.rules?.types||[])" :key="rt.type" :value="rt.type">{{ rt.type }}</option> </select> - <span v-if="isLogical(r.type)" class="grow text-dim small">вложенные правила ниже</span> + <span v-if="isLogical(r.type)" class="grow text-dim small">nested rules below</span> <select v-else-if="isRuleSet(r.type)" class="form-select form-select-sm grow" v-model="r.providerUid"> - <option :value="null" disabled>— провайдер —</option> - <option v-for="(p,pi) in cfg.providers" :key="p._uid" :value="p._uid">{{ p.name || ('провайдер '+(pi+1)) }}</option> + <option :value="null" disabled>— provider —</option> + <option v-for="(p,pi) in cfg.providers" :key="p._uid" :value="p._uid">{{ p.name || ('provider '+(pi+1)) }}</option> </select> - <input v-else-if="!isMatch(r.type)" class="form-control form-control-sm grow" v-model="r.value" placeholder="значение"> + <input v-else-if="!isMatch(r.type)" class="form-control form-control-sm grow" v-model="r.value" placeholder="value"> <span v-else class="grow text-dim small">catch-all</span> <label v-if="!isLogical(r.type) && supportsNoResolve(r.type)" class="form-check mb-0 nr" title="no-resolve"> <input class="form-check-input" type="checkbox" v-model="r.noResolve"><span class="form-check-label small">no-resolve</span> @@ -245,55 +245,55 @@ <h1 class="page-title">Конфиг</h1> </div> <div v-if="isLogical(r.type)" class="rule-conds"> <rule-node v-for="(c,ci) in r.children" :key="c._uid" :node="c" :schema="schema" :providers="cfg.providers" @remove="r.children.splice(ci,1)"></rule-node> - <button class="btn btn-sm btn-outline-secondary mt-1" @click="addChild(r)">Добавить вложенное правило</button> + <button class="btn btn-sm btn-outline-secondary mt-1" @click="addChild(r)">Add nested rule</button> </div> </div> </div> - <span v-if="!cfg.rules.length" class="text-dim small">правил нет</span> + <span v-if="!cfg.rules.length" class="text-dim small">no rules</span> </div> </div> <!-- rule-providers --> <div class="card mb-3"> <div class="card-header">rule-providers - <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addProvider()">Добавить провайдера</button> + <button class="btn btn-sm btn-outline-secondary ms-auto" @click="addProvider()">Add provider</button> </div> <div class="card-body"> <div v-for="(p,i) in cfg.providers" :key="p._uid" class="prov-item"> <span class="prov-name">{{ p.name }}</span> <span class="prov-meta">{{ p.behavior }} · {{ p.format }}</span> - <span v-if="p.mirror" class="prov-meta" title="зеркалируется через subgen">· зеркало</span> - <button class="btn btn-sm btn-outline-secondary act prov-check" :disabled="p._checking" @click="checkProvider(p)" title="проверить доступность и формат URL"><span v-if="p._checking" class="spin me-1"></span>проверить</button> - <button class="btn btn-sm btn-outline-secondary act prov-edit" @click="openProvider(i)">изменить</button> - <button class="btn btn-sm btn-danger-soft act" @click="cfg.providers.splice(i,1)" title="удалить">✕</button> + <span v-if="p.mirror" class="prov-meta" title="mirrored through subgen">· mirror</span> + <button class="btn btn-sm btn-outline-secondary act prov-check" :disabled="p._checking" @click="checkProvider(p)" title="check URL availability and format"><span v-if="p._checking" class="spin me-1"></span>check</button> + <button class="btn btn-sm btn-outline-secondary act prov-edit" @click="openProvider(i)">edit</button> + <button class="btn btn-sm btn-danger-soft act" @click="cfg.providers.splice(i,1)" title="delete">✕</button> </div> - <span v-if="!cfg.providers.length" class="text-dim small">провайдеров нет</span> + <span v-if="!cfg.providers.length" class="text-dim small">no providers</span> </div> </div> <div class="card"> - <div class="card-header">Прочие настройки (YAML) <span class="sub">без {{ (schema?.generatedKeys||[]).join(' / ') }} — они генерируются</span></div> + <div class="card-header">Other settings (YAML) <span class="sub">without {{ (schema?.generatedKeys||[]).join(' / ') }} — they are generated</span></div> <div class="card-body"> <yaml-editor v-model="cfg.baseYAML"></yaml-editor> </div> </div> - <div class="savebar"><button class="btn btn-primary" :disabled="busy" @click="saveConfig()">{{ cfgScope.kind==='user' ? ('Сохранить конфиг для ' + cfgScope.name) : 'Сохранить базовый' }}</button></div> + <div class="savebar"><button class="btn btn-primary" :disabled="busy" @click="saveConfig()">{{ cfgScope.kind==='user' ? ('Save config for ' + cfgScope.name) : 'Save base' }}</button></div> </section> <!-- ============ CUSTOM-CONFIG USER PICKER MODAL ============ --> - <modal :open="customPick.open" title="Кастомный конфиг для пользователя" @close="customPick.open=false"> - <p class="text-dim small mb-2">Будет создана независимая копия базового конфига, привязанная к пользователю.</p> + <modal :open="customPick.open" title="Custom config for user" @close="customPick.open=false"> + <p class="text-dim small mb-2">An independent copy of the base config will be created, bound to the user.</p> <div v-if="usersWithoutCustom.length" class="user-pick"> <div v-for="u in usersWithoutCustom" :key="u.id" class="user-pick-row" :class="{active: customPick.userId===u.id}" @click="customPick.userId=u.id" @dblclick="createCustom()"> {{ u.name }} </div> </div> - <span v-else class="text-dim small">у всех пользователей уже есть кастомный конфиг</span> + <span v-else class="text-dim small">all users already have a custom config</span> <template #footer> - <button class="btn btn-secondary" @click="customPick.open=false">Отмена</button> - <button class="btn btn-primary" :disabled="busy || !customPick.userId" @click="createCustom()">Создать</button> + <button class="btn btn-secondary" @click="customPick.open=false">Cancel</button> + <button class="btn btn-primary" :disabled="busy || !customPick.userId" @click="createCustom()">Create</button> </template> </modal> @@ -302,7 +302,7 @@ <h1 class="page-title">Конфиг</h1> <template v-if="editProv"> <div class="row g-3"> <div class="col-12"> - <label class="form-label">Имя</label> + <label class="form-label">Name</label> <input class="form-control" v-model="editProv.name" placeholder="google-ai" @keyup.enter="provForm.open=false"> </div> <div class="col-md-6"> @@ -318,13 +318,13 @@ <h1 class="page-title">Конфиг</h1> </select> </div> <div class="col-12"> - <label class="form-label">URL источника</label> + <label class="form-label">Source URL</label> <input class="form-control" v-model="editProv.url" placeholder="https://…"> </div> <div class="col-12"> - <label class="form-label">Автообновление ruleset на клиенте</label> + <label class="form-label">Client-side ruleset auto-update</label> <duration-input v-model="editProv.interval"></duration-input> - <div class="form-text">как часто клиент перетягивает ruleset</div> + <div class="form-text">how often the client re-pulls the ruleset</div> </div> </div> @@ -332,45 +332,45 @@ <h1 class="page-title">Конфиг</h1> <label class="pick" :class="{on:editProv.mirror}"> <input class="form-check-input" type="checkbox" v-model="editProv.mirror"> - <span class="pname">Зеркалировать через SubGen</span> + <span class="pname">Mirror through subgen</span> </label> - <p class="text-dim small mt-2 mb-0">subgen скачивает upstream-файл и отдаёт клиентам с <code>/rules/…</code> — полезно, когда upstream недоступен из РФ.</p> + <p class="text-dim small mt-2 mb-0">subgen downloads the upstream file and serves it to clients from <code>/rules/…</code> — useful when the upstream is unreachable from the client's network.</p> <div v-if="editProv.mirror" class="mt-3"> - <label class="form-label">Период обновления зеркала</label> + <label class="form-label">Mirror refresh interval</label> <duration-input v-model="editProv.mirrorInterval"></duration-input> </div> </template> - <template #footer><button class="btn btn-primary" @click="provForm.open=false">Готово</button></template> + <template #footer><button class="btn btn-primary" @click="provForm.open=false">Done</button></template> </modal> <!-- ============ USER MODAL (create / edit) ============ --> - <modal :open="uForm.open" :title="uForm.id ? 'Изменить пользователя' : 'Новый пользователь'" @close="uForm.open=false"> + <modal :open="uForm.open" :title="uForm.id ? 'Edit user' : 'New user'" @close="uForm.open=false"> <div v-if="!uForm.id" class="mb-3"> - <label class="form-label">Имя (a-z, 0-9, _ -)</label> + <label class="form-label">Name (a-z, 0-9, _ -)</label> <input class="form-control" v-model="uForm.name" maxlength="32" @keyup.enter="submitUser()"> </div> - <div v-else class="mb-3 text-dim">Пользователь <span class="fw-semibold">{{ uForm.name }}</span></div> + <div v-else class="mb-3 text-dim">User <span class="fw-semibold">{{ uForm.name }}</span></div> <div class="mb-3"> - <label class="form-label">Описание <span class="text-dim small">(необязательно)</span></label> - <textarea class="form-control" v-model="uForm.description" maxlength="500" rows="2" placeholder="Заметка, видна только в админке"></textarea> + <label class="form-label">Description <span class="text-dim small">(optional)</span></label> + <textarea class="form-control" v-model="uForm.description" maxlength="500" rows="2" placeholder="Note, visible only in the admin panel"></textarea> </div> <div> - <label class="form-label">Инбаунды</label> + <label class="form-label">Inbounds</label> <div class="pick-list"> <label v-for="o in inboundOptions" :key="o.id" class="pick" :class="{on:uForm.inbounds.includes(o.id)}"> <input class="form-check-input" type="checkbox" :value="o.id" v-model="uForm.inbounds"> <span class="pname">{{ o.label }}</span><span class="pport">:{{ o.port }}</span> </label> - <span v-if="!inboundOptions.length" class="inb-empty">нет инбаундов</span> + <span v-if="!inboundOptions.length" class="inb-empty">no inbounds</span> </div> </div> <template #footer> - <button class="btn btn-outline-secondary" @click="uForm.open=false">Отмена</button> + <button class="btn btn-outline-secondary" @click="uForm.open=false">Cancel</button> <button class="btn btn-primary" :disabled="busy" @click="submitUser()"> - <span v-if="busy" class="spin me-1"></span>{{ uForm.id ? 'Сохранить' : 'Создать' }} + <span v-if="busy" class="spin me-1"></span>{{ uForm.id ? 'Save' : 'Create' }} </button> </template> </modal> @@ -379,24 +379,24 @@ <h1 class="page-title">Конфиг</h1> <!-- The list of links comes entirely from the users API — nothing about which links exist or their titles is hardcoded here. The value is private and not shown: title + a copy button only. --> - <modal :open="subLinks.open" :title="'Подписка — ' + subLinks.name" @close="subLinks.open=false"> + <modal :open="subLinks.open" :title="'Subscription — ' + subLinks.name" @close="subLinks.open=false"> <div class="sublinks"> <div v-for="(l,i) in subLinks.links" :key="i" class="sublink"> <div class="sublink-title">{{ l.title }}</div> - <button class="btn btn-sm btn-outline-secondary sublink-copy" @click="copy(l.value)">Копировать</button> + <button class="btn btn-sm btn-outline-secondary sublink-copy" @click="copy(l.value)">Copy</button> </div> - <div v-if="!subLinks.links.length" class="inb-empty">ссылок нет</div> + <div v-if="!subLinks.links.length" class="inb-empty">no links</div> </div> <template #footer> - <button class="btn btn-outline-secondary" @click="subLinks.open=false">Закрыть</button> + <button class="btn btn-outline-secondary" @click="subLinks.open=false">Close</button> </template> </modal> <!-- ============ NODE MODAL (create / edit) ============ --> - <modal :open="nodeForm.open" lg :title="nodeForm.id ? 'Изменить узел' : 'Новый узел'" @close="nodeForm.open=false"> + <modal :open="nodeForm.open" lg :title="nodeForm.id ? 'Edit node' : 'New node'" @close="nodeForm.open=false"> <div class="row g-3"> - <div class="col-md-4"><label class="form-label">Имя</label><input class="form-control" v-model="nodeForm.name" placeholder="RU1"></div> - <div class="col-md-8"><label class="form-label">Домен (для клиентов)</label><input class="form-control" v-model="nodeForm.vpnHost" placeholder="ru1.example.com"></div> + <div class="col-md-4"><label class="form-label">Name</label><input class="form-control" v-model="nodeForm.name" placeholder="RU1"></div> + <div class="col-md-8"><label class="form-label">Domain (for clients)</label><input class="form-control" v-model="nodeForm.vpnHost" placeholder="ru1.example.com"></div> <div class="col-md-8"><label class="form-label">3x-ui base URL</label><input class="form-control" v-model="nodeForm.panelBaseURL" placeholder="https://host:2096"></div> <div class="col-md-4"><label class="form-label">base path</label><input class="form-control" v-model="nodeForm.panelBasePath" placeholder="/secret/"></div> <div class="col-md-12"><label class="form-label">API token</label> @@ -404,19 +404,19 @@ <h1 class="page-title">Конфиг</h1> </div> <hr class="my-3" style="border-color:var(--bs-border-color)"> - <label class="form-label">Инбаунд</label> + <label class="form-label">Inbound</label> <div v-for="(inb,i) in nodeForm.inbounds" :key="i" class="inb-row"> - <input class="form-control" v-model="inb.name" placeholder="имя (a-z/0-9/-)"> - <input class="form-control" type="number" v-model="inb.port" placeholder="порт"> + <input class="form-control" v-model="inb.name" placeholder="name (a-z/0-9/-)"> + <input class="form-control" type="number" v-model="inb.port" placeholder="port"> <button class="btn btn-sm btn-danger-soft act" @click="nodeForm.inbounds.splice(i,1)">✕</button> </div> - <div v-if="!nodeForm.inbounds.length" class="inb-empty">добавьте хотя бы один инбаунд</div> - <button class="btn btn-sm btn-outline-secondary" @click="addInbound()">Добавить инбаунд</button> + <div v-if="!nodeForm.inbounds.length" class="inb-empty">add at least one inbound</div> + <button class="btn btn-sm btn-outline-secondary" @click="addInbound()">Add inbound</button> <template #footer> - <button class="btn btn-outline-secondary" @click="nodeForm.open=false">Отмена</button> + <button class="btn btn-outline-secondary" @click="nodeForm.open=false">Cancel</button> <button class="btn btn-primary" :disabled="busy" @click="saveNode()"> - <span v-if="busy" class="spin me-1"></span>Сохранить + <span v-if="busy" class="spin me-1"></span>Save </button> </template> </modal> diff --git a/internal/handlers/web/static/login.html b/internal/handlers/web/static/login.html index 0a91e17..5053cf7 100644 --- a/internal/handlers/web/static/login.html +++ b/internal/handlers/web/static/login.html @@ -1,9 +1,9 @@ <!DOCTYPE html> -<html lang="ru" data-bs-theme="dark"> +<html lang="en" data-bs-theme="dark"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>subgen — вход +subgen — sign in @@ -13,9 +13,9 @@

subgen

-
-
- +
+
+
@@ -33,7 +33,7 @@

subgen

} catch (_) { /* show generic error below */ } if (ok) { location.assign("/admin/users"); return; } const el = document.getElementById("loginErr"); - el.textContent = errMsg || "Неверный логин или пароль"; + el.textContent = errMsg || "Invalid username or password"; el.hidden = false; }); diff --git a/internal/service/provisioning/service_test.go b/internal/service/provisioning/service_test.go index 4a649eb..7aabe22 100644 --- a/internal/service/provisioning/service_test.go +++ b/internal/service/provisioning/service_test.go @@ -98,7 +98,7 @@ func TestService_CreateUser(t *testing.T) { { // Description is validated (length) before the registry is touched → no mocks. name: "error.description_too_long", - in: entity.UserCreateParams{Name: "postlog", Description: utils.Ptr(strings.Repeat("я", maxDescriptionLen+1)), InboundIDs: []int64{10}}, + in: entity.UserCreateParams{Name: "postlog", Description: utils.Ptr(strings.Repeat("a", maxDescriptionLen+1)), InboundIDs: []int64{10}}, err: entity.ErrDescriptionTooLong, }, { @@ -157,11 +157,11 @@ func TestService_CreateUser(t *testing.T) { // Description is trimmed before storage: the leading/trailing spaces are dropped, // so Create receives the normalised value. name: "success.two_inbounds_same_node", - in: entity.UserCreateParams{Name: "postlog", Description: utils.Ptr(" рабочий ноутбук "), InboundIDs: []int64{10, 11}}, + in: entity.UserCreateParams{Name: "postlog", Description: utils.Ptr(" work laptop "), InboundIDs: []int64{10, 11}}, wantConns: 2, buildMocks: func(m *mocks) { m.nodes.EXPECT().List(gomock.Any()).Return(n1(), nil) - m.users.EXPECT().Create(gomock.Any(), &entity.User{Name: "postlog", SubID: fixedSubID, Description: utils.Ptr("рабочий ноутбук"), Connections: []entity.Connection{{InboundID: 10}, {InboundID: 11}}}).Return(nil) + m.users.EXPECT().Create(gomock.Any(), &entity.User{Name: "postlog", SubID: fixedSubID, Description: utils.Ptr("work laptop"), Connections: []entity.Connection{{InboundID: 10}, {InboundID: 11}}}).Return(nil) // ListInbounds twice: pre-flight email check + syncPanels lookup; the // panel has no "postlog" client → free. m.client.EXPECT().ListInbounds(gomock.Any(), n1Target()).Return(panelInbounds(), nil).Times(2)