Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
22 changes: 18 additions & 4 deletions apitest/api/client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand All @@ -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{}
Expand All @@ -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 {
Expand All @@ -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)
}

Expand Down
50 changes: 34 additions & 16 deletions apitest/api/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apitest/config/read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
}

Expand Down
15 changes: 11 additions & 4 deletions apitest/sub/sub_valid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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")
})
Expand Down
11 changes: 6 additions & 5 deletions apitest/users/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/handlers/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 12 additions & 3 deletions internal/handlers/config_get/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions internal/handlers/config_save/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions internal/handlers/config_save/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion internal/handlers/config_schema/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 10 additions & 5 deletions internal/handlers/sub/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading