Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
# Lint via `make lint` — the single source of truth (pinned golangci-lint Docker
# image), byte-identical to a developer's local run. No setup-go: golangci-lint runs
# inside the container. The cache holds go modules + build/analysis cache for speed.
- name: lint cache
uses: actions/cache@v4
with:
version: v2.12.2 # pinned golangci-lint v2 — matches .golangci.yaml (version "2")
path: .lintcache
key: lint-${{ runner.os }}-${{ hashFiles('go.sum', '.golangci.yaml', 'Makefile') }}
restore-keys: lint-${{ runner.os }}-
- name: make lint
run: make lint

unit:
name: unit
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ jobs:
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
# Lint via `make lint` (pinned golangci-lint Docker image) — same single source of
# truth as CI and local; the cache holds go modules + build/analysis cache.
- name: lint cache
uses: actions/cache@v4
with:
version: v2.12.2 # pinned — matches .golangci.yaml (version "2")
path: .lintcache
key: lint-${{ runner.os }}-${{ hashFiles('go.sum', '.golangci.yaml', 'Makefile') }}
restore-keys: lint-${{ runner.os }}-
- name: lint (make lint)
run: make lint
- name: unit tests (-race)
run: make test
- name: integration tests (real temp SQLite)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ db/
coverage.out
*.coverprofile

# `make lint` Docker cache (go modules + build/analysis cache); CI persists it via cache action
.lintcache/

# Go workspace (local, per-machine)
go.work
go.work.sum
Expand Down
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,13 @@ RENAME (иначе SQLite переписывает FK в чужих таблиц
Зависимости сущности (хендлера, сервиса, …) объявляются **приватным интерфейсом
в том пакете, где используются**, в файле `contract.go`, с директивой генерации
мока. Интерфейс описывает ровно те методы, что нужны этому пакету (interface
segregation), и именуется по роли зависимости (`itemPlatformClient`,
`curlGenerator`, `usersRepository`).
segregation), и **именуется по конкретной зависимости, на которую ссылается** (а не по
абстрактной роли). По имени должно быть видно, что это: репозиторий → `<сущность>Repo`
(`usersRepo`, `nodesRepo`, `configsRepo`, `routingRepo`), сервис → `<сущность>Service`
(`fleetService`, `provisioningService`, `sublinksService`), клиент → `<сущность>Client`
(`panelClient`, `itemPlatformClient`). Роль-имена, по которым не видно repo/service/client
(`subLinker`, `configResolver`, `mihomoReader`, `creator`, `deleter`), **запрещены** —
они усложняют чтение.

```go
// contract.go
Expand All @@ -395,7 +400,7 @@ type itemPlatformClient interface {
Get(ctx context.Context, in entity.ItemGetIn) (map[int64]entity.Item, error)
}

type curlGenerator interface {
type curlService interface {
Generate(ctx context.Context, pl entity.CURLGeneratorPayload) ([]entity.CURL, error)
}
```
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@
в [`AGENTS.md`](AGENTS.md) (раздел «Документирование изменений»). Версий/тегов нет:
сервис не релизится, деплой непрерывный.

## 2026-06-17 — Подписка: попап со ссылками из бэкенда (raw URL + clashmi-диплинк) (#116)

Колонка «Подписка» в списке пользователей теперь открывает попап со списком копируемых
ссылок, а не одну кнопку Mihomo: сейчас это сырой URL подписки Mihomo и диплинк
`clashmi://install-config?url=<enc>&name=<title>&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` с произвольно
Expand Down
34 changes: 23 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
# subgen developer tasks.
#
# make generate — run go generate across the project (mockgen contracts)
# make lint — run golangci-lint (config: .golangci.yaml)
# make lint — run golangci-lint in Docker, byte-identical to CI (see below)
# make test — run the unit tests (-race)
# make integration — run the repository integration tests against a real temp
# SQLite (-tags integration)
# make apitest — run the API tests against real 3x-ui panels in docker
# (delegates to apitest/Makefile: up panels → run → down)
# make all — generate, then lint + test + integration + apitest
# make hooks — install the tracked git hooks (core.hooksPath = .githooks);
# the pre-push hook runs `make all` before every push
#
# Lint requires golangci-lint v2: https://golangci-lint.run/welcome/install/

.PHONY: generate lint test integration apitest all hooks
# Lint is the SINGLE source of truth for how golangci-lint runs: CI (.github/workflows)
# invokes `make lint` too, so local and CI never diverge. It runs the pinned
# golangci/golangci-lint image on the HOST's native architecture — every enabled linter
# is AST/type-based and identical across 64-bit arches, so linux/amd64 (CI) and
# linux/arm64 (Apple Silicon) give the same result with NO emulation (max speed). Module
# downloads and the build/analysis cache persist in the gitignored .lintcache/, so repeat
# runs are fast (CI persists it with actions/cache). Needs only Docker — no local
# golangci-lint install. If your active Docker context can't bind-mount the repo, pass a
# working one: `DOCKER_CONTEXT=desktop-linux make lint`.

.PHONY: generate lint test integration apitest all

# Single source of truth for the golangci-lint version (CI runs `make lint`, so it is
# pinned only here). Keep in sync with .golangci.yaml's `version: "2"`.
GOLANGCI_LINT_VERSION := v2.12.2
LINT_CACHE := $(CURDIR)/.lintcache

generate:
go generate ./...

lint:
golangci-lint run ./...
@mkdir -p "$(LINT_CACHE)/cache" "$(LINT_CACHE)/gomod"
docker run --rm \
-v "$(CURDIR)":/app -w /app \
-v "$(LINT_CACHE)/cache":/root/.cache \
-v "$(LINT_CACHE)/gomod":/go/pkg/mod \
golangci/golangci-lint:$(GOLANGCI_LINT_VERSION) golangci-lint run ./...

test:
go test -race ./...
Expand All @@ -31,7 +47,3 @@ apitest:
$(MAKE) -C apitest test

all: generate lint test integration apitest

hooks:
git config core.hooksPath .githooks
@echo "git hooks installed (core.hooksPath = .githooks); pre-push runs 'make all'"
28 changes: 24 additions & 4 deletions apitest/api/client_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package api
import (
"fmt"
"net/url"
"strings"
)

// The User* read DTOs below are hand-rolled to mirror the users-list response
Expand All @@ -20,11 +21,30 @@ type UserInbound struct {
Missing bool `json:"missing"`
}

// UserSub is a user's subscription coordinates: the shared subId and the absolute
// /sub URL (token-signed) the user fetches.
// UserSubLink is one copyable subscription link the users API reports: a display title
// and the literal value (a raw /sub URL or an app deeplink that embeds it).
type UserSubLink struct {
Title string `json:"title"`
Value string `json:"value"`
}

// UserSub is a user's subscription presentation: the ordered, copyable links. The
// backend decides which links exist and their titles; this is a black-box mirror of that.
type UserSub struct {
ID string `json:"id"`
URL string `json:"url"`
Links []UserSubLink `json:"links"`
}

// SubURL returns the raw, token-signed /sub URL among the links — the http(s) one a
// client GETs. It is picked by scheme (deeplinks use app schemes like clashmi://), not by
// title, so the test stays agnostic to which links the backend offers.
func (s UserSub) SubURL() string {
for _, l := range s.Links {
if strings.HasPrefix(l.Value, "http") {
return l.Value
}
}

return ""
}

// UserStats is a user's aggregated traffic, as the users API computes from the fleet
Expand Down
8 changes: 5 additions & 3 deletions apitest/sub/sub_valid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ func (s *SubPanelSuite) TestSubValid() {
u, err := s.API().MustFindUser(name)
s.Require().NoError(err)
s.T().Cleanup(func() { _, _ = s.API().DeleteUser(u.ID) })
s.Require().NotEmpty(u.Sub.URL, "user must have a /sub URL")

resp, err := s.API().GetURL(u.Sub.URL)
subURL := u.Sub.SubURL()
s.Require().NotEmpty(subURL, "user must have a /sub URL")

resp, err := s.API().GetURL(subURL)
s.Require().NoError(err)
s.Require().Equal(http.StatusOK, resp.Status, "GET %s", u.Sub.URL)
s.Require().Equal(http.StatusOK, resp.Status, "GET %s", subURL)

s.Run("headers", func() {
s.Contains(resp.Headers.Get("Content-Type"), "text/yaml")
Expand Down
15 changes: 9 additions & 6 deletions apitest/users/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import (
)

// Corner cases considered for GET /admin/api/users:
// - shape — a created user appears with id/name, a subId + absolute /sub URL,
// and one inbound row per binding (label "<node>-<inbound>", port).
// - shape — a created user appears with id/name, a non-empty subscription
// link list carrying an absolute /sub URL, and one inbound row per
// binding (label "<node>-<inbound>", port).
// - traffic — the stats field is present; a brand-new client reports 0/0
// (the users API folds fleet traffic in; fresh clients have none).
// - missing_flag — a binding whose client was deleted out-of-band on the panel is
Expand All @@ -28,10 +29,12 @@ func (s *UserSuite) TestListShape() {
s.Equal(u.Name, row.Name)
s.Positive(row.ID)

// Subscription coordinates: a non-empty subId and an absolute, token-signed URL.
s.NotEmpty(row.Sub.ID, "row must carry the subId")
s.True(strings.HasPrefix(row.Sub.URL, "http"), "sub URL must be absolute: %q", row.Sub.URL)
s.Contains(row.Sub.URL, "/sub/", "sub URL must hit the /sub route")
// Subscription presentation: a non-empty link list, and the raw /sub URL among the
// links is absolute and hits the /sub route.
s.Require().NotEmpty(row.Sub.Links, "row must carry subscription links")
subURL := row.Sub.SubURL()
s.True(strings.HasPrefix(subURL, "http"), "sub URL must be absolute: %q", subURL)
s.Contains(subURL, "/sub/", "sub URL must hit the /sub route")

// Three bindings, labelled "<node>-<inbound>" with the right ports.
s.Require().Len(row.Inbounds, 3)
Expand Down
7 changes: 4 additions & 3 deletions apitest/users/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ func (s *UserSuite) recreateUser(id int64) {
// the users API reports) and parses the rendered mihomo YAML into a proxy name->uuid
// map — the same ground truth fleet.Sub would give, obtained purely over HTTP.
func (s *UserSuite) subProxies(u *api.User) map[string]string {
s.Require().NotEmpty(u.Sub.URL, "user must have a subscription URL")
subURL := u.Sub.SubURL()
s.Require().NotEmpty(subURL, "user must have a subscription URL")

resp, err := s.API().GetURL(u.Sub.URL)
resp, err := s.API().GetURL(subURL)
s.Require().NoError(err)
s.Require().Equal(200, resp.Status, "GET %s", u.Sub.URL)
s.Require().Equal(200, resp.Status, "GET %s", subURL)

px, err := api.SubProxies(resp.Body)
s.Require().NoError(err)
Expand Down
7 changes: 6 additions & 1 deletion cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import (
nodesService "github.com/postlog/subgen/internal/service/nodes"
"github.com/postlog/subgen/internal/service/provisioning"
"github.com/postlog/subgen/internal/service/ruleset"
"github.com/postlog/subgen/internal/service/sublinks"
)

func main() {
Expand Down Expand Up @@ -189,6 +190,10 @@ func buildRouter(cfg config.Config, usersRepo *users.Repository, nodesRepo *node

nodesSvc := nodesService.New(nodesRepo)

// Subscription-link catalog (raw sub URLs + app deeplinks) for the users list; it
// resolves each user's effective profile title for the deeplink name.
subLinksSvc := sublinks.New(cfg.Secret, cfg.PublicBase, configsRepo, routingRepo)

// The login handler serves both the sign-in action (POST /admin/api/login) and the
// login PAGE (GET /admin/login).
loginHandler := login.New(sess, cfg.AdminUser, cfg.AdminPassword, cfg.StaticDir)
Expand All @@ -205,7 +210,7 @@ func buildRouter(cfg config.Config, usersRepo *users.Repository, nodesRepo *node
Logout: logout.New(sess),
AdminShell: adminShellHandler.New(sess, cfg.StaticDir),

UsersGet: usersGetHandler.New(usersRepo, fleetSvc, cfg.Secret, cfg.PublicBase),
UsersGet: usersGetHandler.New(usersRepo, fleetSvc, subLinksSvc),
UserCreate: userCreateHandler.New(prov),
UserEdit: userEditHandler.New(prov),
UserDelete: userDeleteHandler.New(prov),
Expand Down
Loading
Loading