From f5545913f476c2b09e00b4ef4fe4e3c18e261d47 Mon Sep 17 00:00:00 2001 From: postlog Date: Wed, 24 Jun 2026 10:35:09 +0300 Subject: [PATCH] mihomo: live delivery of nodes and rules via providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliver the node list as a proxy-provider and authored rule lists as rule-providers bound to the subscription token, so the mihomo core refreshes them on their interval while connected — operator edits reach a connected client without a profile reload (mobile apps have no background profile timer; verified against ClashMi source). - render: drop inline proxies:; emit proxy-providers pointing at /sub/mihomo/{token}/proxies (per-config proxies_interval); proxy-groups reference it via use:+filter (QuoteMeta-escaped); authored rule-providers point at /sub/mihomo/{token}/rules/{name}; new RenderProxiesPayload / RenderAuthoredProvider. - mihomo: RuleProvider.Source (external|authored) + authored matcher tree (RoutingRule, Target nil); Profile.ProxiesInterval; decode/validate + sentinels. - migration 0005 (additive): rule_providers.source, mihomo_authored_matchers (recursive), mihomo_profile.proxies_interval. - repository: save/read/clone authored matchers; source column. - ogen: subProxies/subRules endpoints reusing the HMAC token resolution; MihomoProvider.source+matchers, MihomoConfig.proxiesInterval, schema sources. - admin UI: provider source toggle + authored-list editor (reused rule-node, RULE-SET filtered out); nodes-update-interval field. See the archived OpenSpec change openspec/changes/archive/2026-06-30-live-providers/. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 + apitest/api/client_config.go | 22 +- apitest/api/probe.go | 50 ++- apitest/config/read_test.go | 2 +- apitest/sub/sub_valid_test.go | 15 +- apitest/users/suite_test.go | 11 +- internal/handlers/api/server.go | 8 + internal/handlers/config_get/handler.go | 15 +- internal/handlers/config_save/handler.go | 24 +- internal/handlers/config_save/handler_test.go | 6 +- internal/handlers/config_schema/handler.go | 2 +- internal/handlers/sub/contract.go | 15 +- internal/handlers/sub/contract_mocks.go | 39 ++- internal/handlers/sub/handler.go | 109 +++++-- internal/handlers/sub/handler_test.go | 2 +- internal/handlers/sub/mihomo_renderer.go | 41 ++- internal/handlers/web/static/app.js | 45 ++- internal/handlers/web/static/index.html | 89 ++++-- internal/mihomo/decode.go | 70 ++++- internal/mihomo/decode_test.go | 36 ++- internal/mihomo/errors.go | 18 +- internal/mihomo/profile.go | 14 +- internal/mihomo/render/authored.go | 45 +++ internal/mihomo/render/authored_test.go | 70 +++++ internal/mihomo/render/groups.go | 65 +++- internal/mihomo/render/providers.go | 42 ++- internal/mihomo/render/render.go | 64 +++- internal/mihomo/render/render_test.go | 113 ++++--- internal/mihomo/routing.go | 56 +++- internal/mihomo/validate.go | 108 ++++++- internal/mihomo/validate_test.go | 66 +++- internal/oas/oas_client_gen.go | 180 +++++++++++ internal/oas/oas_handlers_gen.go | 180 +++++++++++ internal/oas/oas_interfaces_gen.go | 8 + internal/oas/oas_json_gen.go | 168 ++++++++-- internal/oas/oas_operations_gen.go | 2 + internal/oas/oas_parameters_gen.go | 294 ++++++++++++++++++ internal/oas/oas_response_decoders_gen.go | 128 ++++++++ internal/oas/oas_response_encoders_gen.go | 90 ++++++ internal/oas/oas_router_gen.go | 199 ++++++++++-- internal/oas/oas_schemas_gen.go | 159 +++++++++- internal/oas/oas_server_gen.go | 12 + internal/oas/oas_unimplemented_gen.go | 18 ++ internal/oas/oas_validators_gen.go | 11 + .../repository/routing/all_rule_providers.go | 11 +- .../repository/routing/authored_matchers.go | 124 ++++++++ internal/repository/routing/clone_config.go | 103 +++++- internal/repository/routing/profile.go | 8 +- internal/repository/routing/rule_providers.go | 24 +- .../repository/routing/rule_providers_test.go | 39 ++- .../repository/routing/save_mihomo_config.go | 76 ++++- migrations/0005-live-providers.sql | 30 ++ openapi/common.yaml | 16 +- openapi/config_schema.yaml | 6 +- openapi/openapi.yaml | 4 + openapi/sub_proxies.yaml | 34 ++ openapi/sub_rules.yaml | 44 +++ .../2026-06-30-live-providers/design.md | 67 ++++ .../2026-06-30-live-providers/proposal.md | 43 +++ .../specs/mihomo-config/spec.md | 38 +++ .../specs/mihomo-rendering/spec.md | 55 ++++ .../specs/subscription-delivery/spec.md | 36 +++ .../2026-06-30-live-providers/tasks.md | 38 +++ openspec/specs/mihomo-config/spec.md | 38 ++- openspec/specs/mihomo-rendering/spec.md | 47 ++- openspec/specs/subscription-delivery/spec.md | 36 ++- 66 files changed, 3278 insertions(+), 360 deletions(-) create mode 100644 internal/mihomo/render/authored.go create mode 100644 internal/mihomo/render/authored_test.go create mode 100644 internal/repository/routing/authored_matchers.go create mode 100644 migrations/0005-live-providers.sql create mode 100644 openapi/sub_proxies.yaml create mode 100644 openapi/sub_rules.yaml create mode 100644 openspec/changes/archive/2026-06-30-live-providers/design.md create mode 100644 openspec/changes/archive/2026-06-30-live-providers/proposal.md create mode 100644 openspec/changes/archive/2026-06-30-live-providers/specs/mihomo-config/spec.md create mode 100644 openspec/changes/archive/2026-06-30-live-providers/specs/mihomo-rendering/spec.md create mode 100644 openspec/changes/archive/2026-06-30-live-providers/specs/subscription-delivery/spec.md create mode 100644 openspec/changes/archive/2026-06-30-live-providers/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9b948..5e62d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ ADR catalog was removed; older entries below still mention their ADR by number). 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-30 — Live delivery of nodes and rules via providers (#119) + +Nodes are now delivered as a `proxy-provider` and authored rule lists as `rule-providers`, +bound to the subscription token, so the mihomo core refreshes them on their interval while +connected — operator edits reach a connected client without a profile reload (mobile apps +have no background profile timer). proxy-groups reference the node provider via `use:`+`filter:`; +rule-providers gain a `source` (external upstream / authored-in-subgen list); a new per-config +"nodes update interval". Migration `0005` (additive). See +[the archived OpenSpec change](openspec/changes/archive/2026-06-30-live-providers/). + ## 2026-06-28 — Move the unit-test convention into a skill (#122) Extracted the unit-test rules out of `AGENTS.md` into a tracked **`go-unit-tests` skill** diff --git a/apitest/api/client_config.go b/apitest/api/client_config.go index 7478776..67d43fb 100644 --- a/apitest/api/client_config.go +++ b/apitest/api/client_config.go @@ -51,9 +51,11 @@ type ConfigGroup struct { Members []ConfigRef `json:"members"` } -// ConfigProvider is one rule-provider for read/save. +// ConfigProvider is one rule-provider for read/save. Source is required by the save +// schema (external | authored); SaveConfig defaults an empty Source to "external". type ConfigProvider struct { Name string `json:"name"` + Source string `json:"source"` Behavior string `json:"behavior"` Format string `json:"format"` URL string `json:"url"` @@ -73,6 +75,7 @@ type Config struct { ProfileTitle string `json:"profileTitle"` Filename string `json:"filename"` ProfileUpdateInterval int `json:"profileUpdateInterval"` + ProxiesInterval int `json:"proxiesInterval"` } // ReadConfig GETs /admin/api/config/mihomo. @@ -90,9 +93,10 @@ func (c *Client) ReadConfig() (Config, error) { // maps the response into a Result. The schema marks groups/rules/providers (and each // group's members) as required arrays, and the server's decoder rejects a JSON `null` // for them — which is what a nil Go slice encodes to — so a partially-built config is -// normalised to send empty arrays instead. The profile knobs are likewise defaulted to -// valid values (the server now validates them), so a case not exercising profile -// validation still passes through to the behaviour under test. +// normalised to send empty arrays instead. The profile knobs (including the nodes update +// interval) are likewise defaulted to valid values (the server now validates them) and an +// empty provider source defaults to "external", so a case not exercising those still passes +// through to the behaviour under test. func (c *Client) SaveConfig(cfg Config) (Result, error) { if cfg.Rules == nil { cfg.Rules = []ConfigRule{} @@ -102,6 +106,12 @@ func (c *Client) SaveConfig(cfg Config) (Result, error) { cfg.Providers = []ConfigProvider{} } + for i := range cfg.Providers { + if cfg.Providers[i].Source == "" { + cfg.Providers[i].Source = "external" + } + } + groups := make([]ConfigGroup, len(cfg.Groups)) for i, g := range cfg.Groups { if g.Members == nil { @@ -125,6 +135,10 @@ func (c *Client) SaveConfig(cfg Config) (Result, error) { cfg.ProfileUpdateInterval = 1 } + if cfg.ProxiesInterval <= 0 { + cfg.ProxiesInterval = 3600 + } + return c.post("/admin/api/config/mihomo/save", cfg) } diff --git a/apitest/api/probe.go b/apitest/api/probe.go index a33377b..98326ee 100644 --- a/apitest/api/probe.go +++ b/apitest/api/probe.go @@ -72,23 +72,10 @@ func (s *Base) RequireNoClient(p Panel, port int, email string) { // ensureInbound creates a minimal vless/tcp/none inbound on the port if absent. func ensureInbound(p Panel, port int, remark string) error { - body, err := panelDo(http.MethodGet, p, "/panel/api/inbounds/list", nil) - if err != nil { + if has, err := panelHasInbound(p, port); err != nil { return err - } - - var lr struct { - Obj []struct { - Port int `json:"port"` - } `json:"obj"` - } - - _ = json.Unmarshal(body, &lr) - - for _, in := range lr.Obj { - if in.Port == port { - return nil - } + } else if has { + return nil } add := map[string]any{ @@ -114,12 +101,43 @@ func ensureInbound(p Panel, port int, remark string) error { _ = json.Unmarshal(resp, &res) if !res.Success { + // The panels are shared across the parallel suites, so another suite may have + // added the same inbound between our check and our add — the UNIQUE tag then + // rejects ours. Accept it as long as the inbound now exists. + if has, herr := panelHasInbound(p, port); herr == nil && has { + return nil + } + return fmt.Errorf("add inbound :%d: %s", port, res.Msg) } return nil } +// panelHasInbound reports whether the panel already has an inbound on the given port. +func panelHasInbound(p Panel, port int) (bool, error) { + body, err := panelDo(http.MethodGet, p, "/panel/api/inbounds/list", nil) + if err != nil { + return false, err + } + + var lr struct { + Obj []struct { + Port int `json:"port"` + } `json:"obj"` + } + + _ = json.Unmarshal(body, &lr) + + for _, in := range lr.Obj { + if in.Port == port { + return true, nil + } + } + + return false, nil +} + func panelDo(method string, p Panel, path string, body []byte) ([]byte, error) { var rd io.Reader if body != nil { diff --git a/apitest/config/read_test.go b/apitest/config/read_test.go index ae8c43d..44532ae 100644 --- a/apitest/config/read_test.go +++ b/apitest/config/read_test.go @@ -64,7 +64,7 @@ func (s *ConfigSuite) TestSchema() { }) s.Run("generated_keys", func() { - s.ElementsMatch([]any{"proxies", "proxy-groups", "rules", "rule-providers", "sub-rules"}, schema["generatedKeys"]) + s.ElementsMatch([]any{"proxies", "proxy-providers", "proxy-groups", "rules", "rule-providers", "sub-rules"}, schema["generatedKeys"]) }) } diff --git a/apitest/sub/sub_valid_test.go b/apitest/sub/sub_valid_test.go index 629fb81..c178996 100644 --- a/apitest/sub/sub_valid_test.go +++ b/apitest/sub/sub_valid_test.go @@ -11,8 +11,9 @@ import ( ) // Corner cases considered for a VALID GET /sub/{token} (gated — provisions a user): -// - body — the response is the rendered mihomo YAML, and its proxies carry the -// real per-inbound client uuids. +// - body — nodes are delivered as a proxy-provider, so the node list (with the +// real per-inbound client uuids) is served at /sub/{kind}/{token}/proxies, +// not inlined in the main document. // - headers — Content-Type text/yaml; a base64 Profile-Title; a Content-Disposition // filename; a Profile-Update-Interval; and a Subscription-Userinfo line. // @@ -72,8 +73,14 @@ func (s *SubPanelSuite) TestSubValid() { }) s.Run("body", func() { - px, perr := api.SubProxies(resp.Body) - s.Require().NoError(perr, "subscription must be valid YAML") + // Nodes are delivered as a proxy-provider, not inlined: the node list lives at + // /sub/{kind}/{token}/proxies. Fetch it and assert the provisioned inbound's uuid. + presp, perr := s.API().GetURL(subURL + "/proxies") + s.Require().NoError(perr) + s.Require().Equal(http.StatusOK, presp.Status, "GET %s/proxies", subURL) + + px, perr := api.SubProxies(presp.Body) + s.Require().NoError(perr, "the proxy-provider payload must be valid YAML") s.Contains(px, "N1-smart", "the provisioned inbound must appear as a proxy") s.Equal(s.ClientUUID(s.Pan1(), api.N1Smart, name), px["N1-smart"], "proxy uuid must match the real client") }) diff --git a/apitest/users/suite_test.go b/apitest/users/suite_test.go index 9fbfa5f..2b64f35 100644 --- a/apitest/users/suite_test.go +++ b/apitest/users/suite_test.go @@ -80,16 +80,17 @@ func (s *UserSuite) recreateUser(id int64) { s.Require().True(res.OK, "RecreateUser(%d): %s", id, res.Message()) } -// subProxies fetches the user's subscription over GET /sub/{token} (the absolute URL -// 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. +// subProxies fetches the user's node list over GET /sub/{token}/proxies (the absolute +// /sub URL the users API reports, plus the proxy-provider path) and parses the payload +// into a proxy name->uuid map — the same ground truth fleet.Sub would give, obtained +// purely over HTTP. Nodes are delivered as a proxy-provider, not inlined in /sub. func (s *UserSuite) subProxies(u *api.User) map[string]string { subURL := u.Sub.SubURL() s.Require().NotEmpty(subURL, "user must have a subscription URL") - resp, err := s.API().GetURL(subURL) + resp, err := s.API().GetURL(subURL + "/proxies") s.Require().NoError(err) - s.Require().Equal(200, resp.Status, "GET %s", subURL) + s.Require().Equal(200, resp.Status, "GET %s/proxies", subURL) px, err := api.SubProxies(resp.Body) s.Require().NoError(err) diff --git a/internal/handlers/api/server.go b/internal/handlers/api/server.go index eb6a4e8..32bbc6d 100644 --- a/internal/handlers/api/server.go +++ b/internal/handlers/api/server.go @@ -99,6 +99,14 @@ func (s *Server) Sub(ctx context.Context, params oas.SubParams) (oas.SubRes, err return s.h.Sub.Sub(ctx, params) } +func (s *Server) SubProxies(ctx context.Context, params oas.SubProxiesParams) (oas.SubProxiesRes, error) { + return s.h.Sub.SubProxies(ctx, params) +} + +func (s *Server) SubRules(ctx context.Context, params oas.SubRulesParams) (oas.SubRulesRes, error) { + return s.h.Sub.SubRules(ctx, params) +} + func (s *Server) Rules(ctx context.Context, params oas.RulesParams) (oas.RulesRes, error) { return s.h.Rules.Rules(ctx, params) } diff --git a/internal/handlers/config_get/handler.go b/internal/handlers/config_get/handler.go index 4bb6fe3..8b8680b 100644 --- a/internal/handlers/config_get/handler.go +++ b/internal/handlers/config_get/handler.go @@ -89,6 +89,7 @@ func (h *Handler) ConfigGet(ctx context.Context, params oas.ConfigGetParams) (oa ProfileTitle: profile.Title, Filename: profile.Filename, ProfileUpdateInterval: profile.UpdateInterval, + ProxiesInterval: profile.ProxiesInterval, } out.Groups = make([]oas.MihomoGroup, 0, len(groups)) @@ -122,10 +123,18 @@ func (h *Handler) ConfigGet(ctx context.Context, params oas.ConfigGetParams) (oa out.Providers = make([]oas.MihomoProvider, 0, len(rps)) for _, rp := range rps { - out.Providers = append(out.Providers, oas.MihomoProvider{ - Name: rp.Name, Behavior: rp.Behavior, Format: rp.Format, + mp := oas.MihomoProvider{ + Name: rp.Name, Source: string(rp.Source), Behavior: rp.Behavior, Format: rp.Format, URL: rp.URL, Interval: rp.Interval, Mirror: rp.Mirror, MirrorInterval: rp.MirrorInterval, - }) + } + + // An authored provider's matchers are target-less rule trees; reuse ruleToView + // (the idx/provIdx maps are unused — a matcher references no group/provider). + for _, m := range rp.Matchers { + mp.Matchers = append(mp.Matchers, ruleToView(m, idx, provIdx)) + } + + out.Providers = append(out.Providers, mp) } return out, nil diff --git a/internal/handlers/config_save/handler.go b/internal/handlers/config_save/handler.go index 9e990dc..dce4061 100644 --- a/internal/handlers/config_save/handler.go +++ b/internal/handlers/config_save/handler.go @@ -43,15 +43,21 @@ const ( MsgGeneratedKey = "Remove the generated sections from the YAML" MsgProviderNameEmpty = "Enter a rule-provider name" + MsgProviderBadSource = "Unknown rule-provider source" 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" + MsgProviderAuthoredURLSet = "An authored rule-provider must not have a URL" + MsgProviderAuthoredNeedsMatchers = "Add at least one rule to the authored rule-provider" + MsgProviderMatcherUnsupported = "MATCH and RULE-SET cannot be used in an authored list" + + 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" + MsgProfileProxiesIntervalInvalid = "Nodes update interval must be a positive number of seconds" MsgUserConfigMissing = "The user has no custom config" MsgProviderNameTaken = "A rule-provider with this name already exists" @@ -188,6 +194,8 @@ func validationMessage(err error) (string, bool) { return MsgGeneratedKey, true case errors.Is(err, mihomo.ErrProviderNameEmpty): return MsgProviderNameEmpty, true + case errors.Is(err, mihomo.ErrProviderBadSource): + return MsgProviderBadSource, true case errors.Is(err, mihomo.ErrProviderBadBehavior): return MsgProviderBadBehavior, true case errors.Is(err, mihomo.ErrProviderBadFormat): @@ -196,6 +204,12 @@ func validationMessage(err error) (string, bool) { return MsgProviderURLEmpty, true case errors.Is(err, mihomo.ErrProviderRefRange): return MsgRuleSetUnknownProv, true + case errors.Is(err, mihomo.ErrProviderAuthoredURLSet): + return MsgProviderAuthoredURLSet, true + case errors.Is(err, mihomo.ErrProviderAuthoredNeedsMatchers): + return MsgProviderAuthoredNeedsMatchers, true + case errors.Is(err, mihomo.ErrProviderMatcherUnsupported): + return MsgProviderMatcherUnsupported, true case errors.Is(err, mihomo.ErrProfileTitleEmpty): return MsgProfileTitleEmpty, true case errors.Is(err, mihomo.ErrProfileFilenameEmpty): @@ -204,6 +218,8 @@ func validationMessage(err error) (string, bool) { return MsgProfileFilenameInvalid, true case errors.Is(err, mihomo.ErrProfileUpdateIntervalInvalid): return MsgProfileIntervalInvalid, true + case errors.Is(err, mihomo.ErrProfileProxiesIntervalInvalid): + return MsgProfileProxiesIntervalInvalid, true default: return "", false } diff --git a/internal/handlers/config_save/handler_test.go b/internal/handlers/config_save/handler_test.go index 782100f..d444723 100644 --- a/internal/handlers/config_save/handler_test.go +++ b/internal/handlers/config_save/handler_test.go @@ -29,6 +29,7 @@ func TestHandler_ConfigSave(t *testing.T) { ProfileTitle: "My VPN", Filename: "my.yaml", ProfileUpdateInterval: 6, + ProxiesInterval: 3600, } if userID != 0 { req.UserId = oas.NewOptInt64(userID) @@ -43,7 +44,7 @@ func TestHandler_ConfigSave(t *testing.T) { Rules: []mihomo.RuleDraft{{Type: mihomo.RuleMatch, Target: &mihomo.RefDraft{Kind: mihomo.PolicyDirect}}}, Groups: []mihomo.GroupDraft{}, Providers: []mihomo.RuleProvider{}, - Profile: mihomo.Profile{Title: "My VPN", Filename: "my.yaml", UpdateInterval: 6}, + Profile: mihomo.Profile{Title: "My VPN", Filename: "my.yaml", UpdateInterval: 6, ProxiesInterval: 3600}, } tt := []struct { @@ -86,6 +87,7 @@ func TestHandler_ConfigSave(t *testing.T) { ProfileTitle: "My VPN", Filename: "my.yaml", ProfileUpdateInterval: 6, + ProxiesInterval: 3600, }, buildConfigsMock: func(m *MockconfigsRepo) { m.EXPECT().EnsureBaseConfigID(gomock.Any(), entity.ConfigKindMihomo).Return(int64(3), nil) @@ -101,7 +103,7 @@ func TestHandler_ConfigSave(t *testing.T) { }, Groups: []mihomo.GroupDraft{}, Providers: []mihomo.RuleProvider{}, - Profile: mihomo.Profile{Title: "My VPN", Filename: "my.yaml", UpdateInterval: 6}, + Profile: mihomo.Profile{Title: "My VPN", Filename: "my.yaml", UpdateInterval: 6, ProxiesInterval: 3600}, }).Return(nil) }, result: &oas.MessageResponse{Message: MsgSaved}, diff --git a/internal/handlers/config_schema/handler.go b/internal/handlers/config_schema/handler.go index acd0f77..488c12c 100644 --- a/internal/handlers/config_schema/handler.go +++ b/internal/handlers/config_schema/handler.go @@ -56,7 +56,7 @@ func build() oas.ConfigSchemaOK { return oas.ConfigSchemaOK{ Actions: actions, - RuleProvider: oas.ConfigSchemaOKRuleProvider{Behaviors: mihomo.RuleProviderBehaviors(), Formats: mihomo.RuleProviderFormats()}, + RuleProvider: oas.ConfigSchemaOKRuleProvider{Sources: mihomo.RuleProviderSources(), Behaviors: mihomo.RuleProviderBehaviors(), Formats: mihomo.RuleProviderFormats()}, ProxyGroup: oas.ConfigSchemaOKProxyGroup{Types: groups}, Rules: oas.ConfigSchemaOKRules{Types: rules}, GeneratedKeys: mihomo.GeneratedKeys(), diff --git a/internal/handlers/sub/contract.go b/internal/handlers/sub/contract.go index f180570..68dfa27 100644 --- a/internal/handlers/sub/contract.go +++ b/internal/handlers/sub/contract.go @@ -27,13 +27,18 @@ type configsRepo interface { BaseConfigID(ctx context.Context, kind entity.ConfigKind) (int64, bool, error) } -// EngineRenderer renders one engine's subscription body for a subscriber + config id, -// returning the bytes plus the response metadata (content type, filename) that engine -// needs. The registry (built in the composition root) keys these by Kind; today only -// mihomo is registered. +// EngineRenderer renders one engine's subscription artifacts for a subscriber + config id. +// Render returns the main config body plus its response metadata; token is the subscriber's +// subscription token, which the engine weaves into the per-token provider URLs it emits. +// RenderProxies returns the node-list payload served at /sub/{kind}/{token}/proxies. +// RenderRuleProvider returns an authored rule-provider's body (served at .../rules/{name}) +// and whether such a provider exists. The registry (built in the composition root) keys +// these by Kind; today only mihomo is registered. type EngineRenderer interface { Kind() entity.ConfigKind - Render(ctx context.Context, sub *entity.Subscriber, configID int64) ([]byte, RenderMeta, error) + Render(ctx context.Context, sub *entity.Subscriber, configID int64, token string) ([]byte, RenderMeta, error) + RenderProxies(ctx context.Context, sub *entity.Subscriber) ([]byte, error) + RenderRuleProvider(ctx context.Context, configID int64, name string) ([]byte, bool, error) } // routingRepo reads one config's mihomo content (scoped by config id) for the mihomo diff --git a/internal/handlers/sub/contract_mocks.go b/internal/handlers/sub/contract_mocks.go index ddbac94..6871283 100644 --- a/internal/handlers/sub/contract_mocks.go +++ b/internal/handlers/sub/contract_mocks.go @@ -206,9 +206,9 @@ func (mr *MockEngineRendererMockRecorder) Kind() *gomock.Call { } // Render mocks base method. -func (m *MockEngineRenderer) Render(ctx context.Context, sub *entity.Subscriber, configID int64) ([]byte, RenderMeta, error) { +func (m *MockEngineRenderer) Render(ctx context.Context, sub *entity.Subscriber, configID int64, token string) ([]byte, RenderMeta, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Render", ctx, sub, configID) + ret := m.ctrl.Call(m, "Render", ctx, sub, configID, token) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(RenderMeta) ret2, _ := ret[2].(error) @@ -216,9 +216,40 @@ func (m *MockEngineRenderer) Render(ctx context.Context, sub *entity.Subscriber, } // Render indicates an expected call of Render. -func (mr *MockEngineRendererMockRecorder) Render(ctx, sub, configID any) *gomock.Call { +func (mr *MockEngineRendererMockRecorder) Render(ctx, sub, configID, token any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockEngineRenderer)(nil).Render), ctx, sub, configID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Render", reflect.TypeOf((*MockEngineRenderer)(nil).Render), ctx, sub, configID, token) +} + +// RenderProxies mocks base method. +func (m *MockEngineRenderer) RenderProxies(ctx context.Context, sub *entity.Subscriber) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenderProxies", ctx, sub) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RenderProxies indicates an expected call of RenderProxies. +func (mr *MockEngineRendererMockRecorder) RenderProxies(ctx, sub any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderProxies", reflect.TypeOf((*MockEngineRenderer)(nil).RenderProxies), ctx, sub) +} + +// RenderRuleProvider mocks base method. +func (m *MockEngineRenderer) RenderRuleProvider(ctx context.Context, configID int64, name string) ([]byte, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenderRuleProvider", ctx, configID, name) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RenderRuleProvider indicates an expected call of RenderRuleProvider. +func (mr *MockEngineRendererMockRecorder) RenderRuleProvider(ctx, configID, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderRuleProvider", reflect.TypeOf((*MockEngineRenderer)(nil).RenderRuleProvider), ctx, configID, name) } // MockroutingRepo is a mock of routingRepo interface. diff --git a/internal/handlers/sub/handler.go b/internal/handlers/sub/handler.go index 43a07bd..f10b79a 100644 --- a/internal/handlers/sub/handler.go +++ b/internal/handlers/sub/handler.go @@ -51,46 +51,124 @@ func New(users usersRepo, fleet fleetService, configs configsRepo, renderers map // Note: ogen pins the 200 media type from the spec (application/yaml), so RenderMeta's // content type is not echoed per-request — every engine here serves YAML. func (h *Handler) Sub(ctx context.Context, params oas.SubParams) (oas.SubRes, error) { - kind := entity.ConfigKind(params.Kind) + renderer, sub, configID, ok, err := h.resolve(ctx, params.Kind, params.Token) + if err != nil { + return nil, err + } - renderer, ok := h.renderers[kind] - if !ok || params.Token == "" { + if !ok { return &oas.SubNotFound{Data: strings.NewReader("not found\n")}, nil } + body, meta, err := renderer.Render(ctx, sub, configID, params.Token) + if err != nil { + return nil, fmt.Errorf("renderer.Render: %w", err) + } + + return &oas.SubOKHeaders{ + ProfileUpdateInterval: oas.NewOptString(fmt.Sprintf("%d", meta.UpdateInterval)), + ProfileTitle: oas.NewOptString("base64:" + base64.StdEncoding.EncodeToString([]byte(meta.ProfileTitle))), + ContentDisposition: oas.NewOptString(fmt.Sprintf("attachment; filename=%q", meta.Filename)), + SubscriptionUserinfo: oas.NewOptString(userinfo(sub.Up, sub.Down, sub.Total, sub.Expiry)), + Response: oas.SubOK{Data: bytes.NewReader(body)}, + }, nil +} + +// SubProxies implements oas.Handler: GET /sub/{kind}/{token}/proxies — the subscriber's +// node list as a proxy-provider payload. Same token resolution as Sub; an unknown +// kind/token is a 404. +func (h *Handler) SubProxies(ctx context.Context, params oas.SubProxiesParams) (oas.SubProxiesRes, error) { + renderer, sub, _, ok, err := h.resolve(ctx, params.Kind, params.Token) + if err != nil { + return nil, err + } + + if !ok { + return &oas.SubProxiesNotFound{Data: strings.NewReader("not found\n")}, nil + } + + body, err := renderer.RenderProxies(ctx, sub) + if err != nil { + return nil, fmt.Errorf("renderer.RenderProxies: %w", err) + } + + return &oas.SubProxiesOK{Data: bytes.NewReader(body)}, nil +} + +// SubRules implements oas.Handler: GET /sub/{kind}/{token}/rules/{name} — an authored +// rule-provider's classical-text list. Same token resolution as Sub; an unknown +// kind/token, or a name that is not an authored provider of this config, is a 404. +func (h *Handler) SubRules(ctx context.Context, params oas.SubRulesParams) (oas.SubRulesRes, error) { + renderer, _, configID, ok, err := h.resolve(ctx, params.Kind, params.Token) + if err != nil { + return nil, err + } + + if !ok { + return &oas.SubRulesNotFound{Data: strings.NewReader("not found\n")}, nil + } + + body, found, err := renderer.RenderRuleProvider(ctx, configID, params.Name) + if err != nil { + return nil, fmt.Errorf("renderer.RenderRuleProvider: %w", err) + } + + if !found { + return &oas.SubRulesNotFound{Data: strings.NewReader("not found\n")}, nil + } + + return &oas.SubRulesOKHeaders{ + XContentTypeOptions: oas.NewOptString("nosniff"), + Response: oas.SubRulesOK{Data: bytes.NewReader(body)}, + }, nil +} + +// resolve runs the shared token→subscriber chain used by all three subscription routes: +// it validates the engine kind, matches the HMAC token against a service-owned user's +// sub_id, picks that user's config (custom or base) and returns the subscriber snapshot. +// ok=false (with nil error) means "serve a 404"; a non-nil error is an infrastructure 5xx. +// A provisioned client missing from the fleet yields an empty subscriber, not a 404. +func (h *Handler) resolve(ctx context.Context, kindStr, tokenStr string) (EngineRenderer, *entity.Subscriber, int64, bool, error) { + kind := entity.ConfigKind(kindStr) + + renderer, ok := h.renderers[kind] + if !ok || tokenStr == "" { + return nil, nil, 0, false, nil + } + // Resolve the token against service-owned users only — clients created // directly on a panel are not served. subIDs, err := h.users.SubIDs(ctx) if err != nil { - return nil, fmt.Errorf("users.SubIDs: %w", err) + return nil, nil, 0, false, fmt.Errorf("users.SubIDs: %w", err) } var subID string for _, id := range subIDs { - if token.Match(h.secret, id, params.Token) { + if token.Match(h.secret, id, tokenStr) { subID = id break } } if subID == "" { - return &oas.SubNotFound{Data: strings.NewReader("not found\n")}, nil + return nil, nil, 0, false, nil } userID, err := h.users.IDBySubID(ctx, subID) if err != nil { - return nil, fmt.Errorf("users.IDBySubID: %w", err) + return nil, nil, 0, false, fmt.Errorf("users.IDBySubID: %w", err) } configID, err := h.configID(ctx, userID, kind) if err != nil { - return nil, err + return nil, nil, 0, false, err } fleet, err := h.fleet.Fleet(ctx) if err != nil { - return nil, fmt.Errorf("fleet.Fleet: %w", err) + return nil, nil, 0, false, fmt.Errorf("fleet.Fleet: %w", err) } sub := fleet.Sub(subID) @@ -98,18 +176,7 @@ func (h *Handler) Sub(ctx context.Context, params oas.SubParams) (oas.SubRes, er sub = &entity.Subscriber{SubID: subID} // provisioned clients missing; serve an empty profile } - body, meta, err := renderer.Render(ctx, sub, configID) - if err != nil { - return nil, fmt.Errorf("renderer.Render: %w", err) - } - - return &oas.SubOKHeaders{ - ProfileUpdateInterval: oas.NewOptString(fmt.Sprintf("%d", meta.UpdateInterval)), - ProfileTitle: oas.NewOptString("base64:" + base64.StdEncoding.EncodeToString([]byte(meta.ProfileTitle))), - ContentDisposition: oas.NewOptString(fmt.Sprintf("attachment; filename=%q", meta.Filename)), - SubscriptionUserinfo: oas.NewOptString(userinfo(sub.Up, sub.Down, sub.Total, sub.Expiry)), - Response: oas.SubOK{Data: bytes.NewReader(body)}, - }, nil + return renderer, sub, configID, true, nil } // configID picks the config to render: a user's custom config for this engine when diff --git a/internal/handlers/sub/handler_test.go b/internal/handlers/sub/handler_test.go index 2021e4b..afc3466 100644 --- a/internal/handlers/sub/handler_test.go +++ b/internal/handlers/sub/handler_test.go @@ -73,7 +73,7 @@ func TestHandler_Sub(t *testing.T) { }, }, nil) m.renderer.EXPECT(). - Render(gomock.Any(), &entity.Subscriber{SubID: "sub1", Up: 10, Down: 20, Total: 100, Expiry: 5000}, int64(3)). + Render(gomock.Any(), &entity.Subscriber{SubID: "sub1", Up: 10, Down: 20, Total: 100, Expiry: 5000}, int64(3), matchingToken). Return([]byte("yaml"), RenderMeta{Filename: "f.yaml", ProfileTitle: "Profile", UpdateInterval: 300}, nil) }, assertRes: func(t *testing.T, res oas.SubRes) { diff --git a/internal/handlers/sub/mihomo_renderer.go b/internal/handlers/sub/mihomo_renderer.go index 8ab5dcc..a7cab91 100644 --- a/internal/handlers/sub/mihomo_renderer.go +++ b/internal/handlers/sub/mihomo_renderer.go @@ -4,6 +4,7 @@ import ( "context" "github.com/postlog/subgen/internal/entity" + "github.com/postlog/subgen/internal/mihomo" "github.com/postlog/subgen/internal/mihomo/render" ) @@ -25,21 +26,25 @@ func NewMihomoRenderer(routing routingRepo, publicBase string) *MihomoRenderer { func (m *MihomoRenderer) Kind() entity.ConfigKind { return entity.ConfigKindMihomo } // Render builds the mihomo YAML for one subscriber against the given config and reads -// the config's profile knobs (filename, title, update interval) for the response meta. -// The knobs are served as stored — there are no code defaults; an unconfigured config -// yields empty header values. -func (m *MihomoRenderer) Render(ctx context.Context, sub *entity.Subscriber, configID int64) ([]byte, RenderMeta, error) { +// the config's profile knobs (filename, title, update/proxies interval) for the response +// meta and the proxy-provider. token is woven into the per-token provider URLs. The knobs +// are served as stored — there are no code defaults; an unconfigured config yields empty +// header values. +func (m *MihomoRenderer) Render(ctx context.Context, sub *entity.Subscriber, configID int64, token string) ([]byte, RenderMeta, error) { opts, err := m.options(ctx, configID) if err != nil { return nil, RenderMeta{}, err } - body, err := render.Render(sub, opts) + profile, err := m.routing.Profile(ctx, configID) if err != nil { return nil, RenderMeta{}, err } - profile, err := m.routing.Profile(ctx, configID) + opts.Token = token + opts.ProxiesInterval = profile.ProxiesInterval + + body, err := render.Render(sub, opts) if err != nil { return nil, RenderMeta{}, err } @@ -54,6 +59,30 @@ func (m *MihomoRenderer) Render(ctx context.Context, sub *entity.Subscriber, con return body, meta, nil } +// RenderProxies builds the proxy-provider payload served at /sub/mihomo/{token}/proxies: +// the subscriber's node list. It is config-independent (the nodes come from the fleet). +func (m *MihomoRenderer) RenderProxies(_ context.Context, sub *entity.Subscriber) ([]byte, error) { + return render.RenderProxiesPayload(sub) +} + +// RenderRuleProvider builds the classical-text body served at /sub/mihomo/{token}/rules/{name}: +// the named authored rule-provider's matcher list. Returns found=false when the config has +// no authored provider by that name (a 404), so an external provider's name is not served. +func (m *MihomoRenderer) RenderRuleProvider(ctx context.Context, configID int64, name string) ([]byte, bool, error) { + provs, err := m.routing.RuleProviders(ctx, configID) + if err != nil { + return nil, false, err + } + + for _, rp := range provs { + if rp.Name == name && rp.Source == mihomo.RuleProviderAuthored { + return render.RenderAuthoredProvider(rp.Matchers), true, nil + } + } + + return nil, false, nil +} + // options assembles the config's mihomo content render needs from the store. func (m *MihomoRenderer) options(ctx context.Context, configID int64) (render.Options, error) { rules, err := m.routing.Rules(ctx, configID) diff --git a/internal/handlers/web/static/app.js b/internal/handlers/web/static/app.js index dfb0c42..1edd958 100644 --- a/internal/handlers/web/static/app.js +++ b/internal/handlers/web/static/app.js @@ -32,7 +32,7 @@ const app = createApp({ // cfg: structured mihomo config. groups/rules carry a client-side _uid; a // policy ref is encoded as a string `pref` (direct|reject|…|smart|force:| // group:) so it binds straight to a -