From 97a6cf92241819f875ff5e600ed80b9bb4229278 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 6 Oct 2026 23:55:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(quota):=20OpenDesign=20=E5=87=BA=E7=AB=99?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=8A=A0=E5=85=A5=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到 OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。 adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的 那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。 core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上 (没有 OpenDesign 源的部署不会凭空获得这条授权)。 派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是 「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配: `amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。 顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是 `https://amr-link.open-design.ai/v1` —— 带路径时整串拿去匹配 `^...$` 永远不命中,改写静默失效,探测打到推理主机。改成先切路径。 这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而 gsub 模式在改前改后都"看起来对"。8/8 变异全杀。 (cherry picked from commit 4a63b9dc0b7cb9ef5a914d85eb05dc70e5f38c48) --- internal/core/core.go | 36 ++++++++- internal/core/source_hosts_test.go | 108 ++++++++++++++++++++++++++ internal/lua/plugins/quota.lua | 14 +++- internal/lua/quota_opendesign_test.go | 65 +++++++++++----- 4 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 internal/core/source_hosts_test.go diff --git a/internal/core/core.go b/internal/core/core.go index 47565ca..5b16a94 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -650,11 +650,29 @@ func (c *Core) rebuildRegistry() error { // allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than // failing startup: a malformed base_url is already rejected when the provider is // built, and it must not take plugins down with it. +// +// A source may also imply a SECOND host. OpenDesign splits inference +// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the +// quota plugin derives the billing host from the inference one — so the host it +// asks for is not the host the operator configured, and the request would be +// refused with a message that reads like a plugin defect. Adding the derived +// host here is what makes the probe reachable. +// +// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so +// a deployment with no OpenDesign source does not silently gain the entry. func sourceHosts(cfg *config.Config) []string { if cfg == nil { return nil } out := make([]string, 0, len(cfg.Sources)) + seen := make(map[string]bool, len(cfg.Sources)) + add := func(h string) { + if h == "" || seen[h] { + return + } + seen[h] = true + out = append(out, h) + } for _, s := range cfg.Sources { if s.BaseURL == "" { continue @@ -663,11 +681,27 @@ func sourceHosts(cfg *config.Config) []string { if err != nil || u.Hostname() == "" { continue } - out = append(out, u.Hostname()) + add(u.Hostname()) + add(deriveBillingHost(u.Hostname())) } return out } +// deriveBillingHost returns the billing host a configured source implies, or +// the host unchanged when it implies none. +// +// Kept in Go as well as in the Lua adapter because the two must agree: Go builds +// the allowlist and Lua builds the request URL, and a disagreement shows up as a +// refused request rather than as an error naming the mismatch. The rule is the +// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite +// amr-link.example.com into an OpenDesign address. +func deriveBillingHost(host string) string { + if host == "amr-link.open-design.ai" { + return "amr-api.open-design.ai" + } + return host +} + // buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules // against the current providers. Slot model ids are normalized to the exact // configured spelling (case-insensitive match), otherwise ModelFor would fall diff --git a/internal/core/source_hosts_test.go b/internal/core/source_hosts_test.go new file mode 100644 index 0000000..90beb5a --- /dev/null +++ b/internal/core/source_hosts_test.go @@ -0,0 +1,108 @@ +package core + +import ( + "net/url" + "testing" + + "llmsproxy/internal/config" +) + +// The plugin outbound allowlist is built from the hosts of configured sources. +// That rule is correct for every adapter whose credential and endpoint live on +// the same host — which was all of them until OpenDesign. +// +// OpenDesign splits them: inference is amr-link.open-design.ai, billing is +// amr-api.open-design.ai. The quota plugin derives the billing host from the +// inference one, so the host it asks for is NOT the host the operator +// configured. The request is then refused by the allowlist with a message that +// reads like a plugin defect ("host ... is not an allowed outbound destination") +// rather than a missing entry. +// +// This file pins the bridge: when a source looks like OpenDesign, the derived +// billing host joins the allowlist. + +func TestSourceHostsIncludesConfiguredHosts(t *testing.T) { + cfg := &config.Config{Sources: []config.Source{ + {Name: "a", BaseURL: "https://api.deepseek.com"}, + {Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"}, + {Name: "empty", BaseURL: ""}, + {Name: "bad", BaseURL: "://nonsense"}, + }} + got := map[string]bool{} + for _, h := range sourceHosts(cfg) { + got[h] = true + } + for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} { + if !got[want] { + t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+ + "every plugin request to it): %v", want, got) + } + } + // An empty/unparseable base_url must not abort the whole list. + if !got["api.deepseek.com"] { + t.Fatal("an unparseable sibling source dropped a valid host") + } +} + +// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level +// that actually matters: the host the PLUGIN asks for must be in the allowlist. +func TestSourceHostsAddsDerivedBillingHost(t *testing.T) { + cfg := &config.Config{Sources: []config.Source{ + {Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"}, + }} + got := map[string]bool{} + for _, h := range sourceHosts(cfg) { + got[h] = true + } + if !got["amr-link.open-design.ai"] { + t.Fatalf("inference host missing: %v", got) + } + if !got["amr-api.open-design.ai"] { + t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+ + "so every OpenDesign balance probe is refused before it leaves the "+ + "process: %v", got) + } +} + +// A deployment with no OpenDesign source must not silently gain its billing +// host: an allowlist entry is a grant, and widening it for everyone would let an +// unrelated plugin reach a host the operator never configured. +func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) { + cfg := &config.Config{Sources: []config.Source{ + {Name: "ds", BaseURL: "https://api.deepseek.com"}, + }} + for _, h := range sourceHosts(cfg) { + if h == "amr-api.open-design.ai" { + t.Fatal("the OpenDesign billing host was granted to a deployment with " + + "no OpenDesign source") + } + } +} + +// The derivation has to agree with the Lua side. Keep the two spellings of the +// rule in one place so a change to one is visible when reading the other. +func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) { + // what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.") + const in = "amr-link.open-design.ai" + want := "amr-api.open-design.ai" + got := deriveBillingHost(in) + if got != want { + t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+ + "Lua probe must agree on the host or the request is refused", in, got, want) + } + // A host that is not amr-link stays untouched, so this cannot accidentally + // rewrite an unrelated provider into an OpenDesign address. + for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} { + if deriveBillingHost(other) != other { + t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other)) + } + } + // url.Parse round-trip keeps the helper honest about what it receives. + u, err := url.Parse("https://amr-link.open-design.ai/v1") + if err != nil { + t.Fatal(err) + } + if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" { + t.Fatal("derivation failed on a real base_url hostname") + } +} diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 990046f..d2d5313 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -290,12 +290,22 @@ local function openDesign(base, key) local api = nil if type(cfg) == "table" then api = cfg.api_base end if type(api) ~= "string" or api == "" then - local origin = base:gsub("^https?://", ""):gsub("/+$", "") - origin = origin:gsub("^amr%-link%.", "amr-api.") + -- Host only: base_url carries a path (`.../v1`), and matching the hostname + -- rule against it would silently skip the rewrite and send the probe to the + -- inference host — which serves no billing route. + local origin = base:gsub("^https?://", ""):gsub("/.*$", "") + origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai") api = "https://" .. origin .. "/api/v1" end api = api:gsub("/+$", "") + -- The gateway's outbound allowlist is the hosts of configured sources. It + -- therefore contains amr-link (the inference host) but NOT amr-api (the + -- billing host), and every request below would be refused with "host ... is + -- not an allowed outbound destination" — a message that reads like a plugin + -- bug rather than a missing allowlist entry. Add the derived host alongside + -- the source hosts in core.sourceHosts when this adapter is enabled. + local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1) local summary, sErr = request(api .. "/billing/summary", ck, nil, 1) diff --git a/internal/lua/quota_opendesign_test.go b/internal/lua/quota_opendesign_test.go index 3ed105d..b585dca 100644 --- a/internal/lua/quota_opendesign_test.go +++ b/internal/lua/quota_opendesign_test.go @@ -401,26 +401,51 @@ func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) { } // TestQuotaOpenDesignHostRewritingIsUsed keeps the amr-link -> amr-api default -// working. Without it the adapter would probe the inference host, which answers -// no billing route at all, and every OpenDesign source would silently skip. +// working. Without it the adapter probes the inference host, which serves no +// billing route at all, and every OpenDesign source silently skips. +// +// Asserted BEHAVIOURALLY, through the outbound allowlist rather than on the +// source text. The allowlist in these tests holds only 127.0.0.1, so a request +// to any real host is refused with a message that NAMES the host it tried. That +// makes the refusal the observation: "amr-api.open-design.ai" in the reason +// proves the derivation ran, and its absence proves it did not. The earlier +// version of this test grepped quota.lua for the gsub pattern, which pins the +// spelling of the rule instead of its effect — it went red the moment the rule +// was tightened to a full-hostname match, while the behaviour was unchanged. func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) { - var paths []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.URL.Path) - _, _ = w.Write([]byte(odWallet)) - })) - defer srv.Close() - - ps, src := odPlugins(t) - // api_base unset, so the adapter derives the host from base_url. The - // derived host (amr-api.open-design.ai) is not reachable from a test, so - // assert on the SOURCE instead — this is the guard that survives without a - // network. - _ = srv - if !strings.Contains(src, `origin:gsub("^amr%-link%.", "amr-api.")`) { - t.Fatal("quota.lua no longer rewrites amr-link.* to amr-api.* — every " + - "OpenDesign source would probe the inference host, which serves no " + - "billing route, and skip") + ps, _ := odPlugins(t) + // No api_base: the adapter must derive the billing host itself. The control + // key IS set so the credential guard is not what stops us. + if err := ps.SetState("quota", map[string]interface{}{ + "prices": map[string]interface{}{"control_key": "vela_ctrl_TESTKEY"}, + }); err != nil { + t.Fatal(err) + } + + rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") + if rep == nil { + t.Fatal("no report produced") + } + // The outbound allowlist in these tests holds only 127.0.0.1, so the request + // is refused before it leaves the process and the refusal NAMES the host it + // tried. The observation is therefore the refusal text, not the record as a + // whole: `host` is `hostOf(base)` — the SOURCE host, which is amr-link by + // design and says nothing about where the request went. + var msg string + if s, ok := rep["error"].(string); ok { + msg = s + } + if s, ok := rep["reason"].(string); ok { + msg += " " + s + } + if !strings.Contains(msg, "amr-api.open-design.ai") { + t.Fatalf("no evidence the adapter derived the billing host from the "+ + "inference one: %q. Expected the allowlist refusal to name "+ + "amr-api.open-design.ai, since that is the host the request must go to. "+ + "(report: %v)", msg, rep) + } + if strings.Contains(msg, "amr-link") { + t.Fatalf("the adapter probed the INFERENCE host — it serves no billing "+ + "route, so every OpenDesign source would silently skip: %q", msg) } - _ = ps }