This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit e25dab1b477dad59007582d8b84098b8e87ad859 Author: Yang Luo <[email protected]> AuthorDate: Sat Sep 5 19:26:14 2026 +0800 feat(connections): carry connections in a snapshot and test one as it is made --- controllers/connector.go | 12 +++ object/connection_install.go | 36 ++++++- object/snapshot.go | 172 ++++++++++++++++++++++++++++--- web/src/components/settings/snapshot.tsx | 3 + web/src/locales/en/data.json | 5 +- web/src/locales/zh/data.json | 5 +- web/src/pages/ConnectionsPage.tsx | 11 +- web/src/types.ts | 2 + 8 files changed, 229 insertions(+), 17 deletions(-) diff --git a/controllers/connector.go b/controllers/connector.go index 6205856..2059ff4 100644 --- a/controllers/connector.go +++ b/controllers/connector.go @@ -30,6 +30,7 @@ import ( "github.com/apache/casbin-gateway/connector" "github.com/apache/casbin-gateway/mcpproxy" "github.com/apache/casbin-gateway/object" + "github.com/beego/beego" ) // ConnectorEntry is one catalog card: the connector itself, plus whether this @@ -198,6 +199,17 @@ func (c *ApiController) Connect() { c.ResponseError(err.Error()) return } + + // Testing here rather than waiting to be asked is what gives a connection + // its per-tool switches from the moment it is made. It runs beside the + // answer because installing a server from npm takes longer than anybody + // would wait on this button, and its result is read from the row later. + go func(owner string, name string) { + if _, err := object.TestConnection(owner, name); err != nil { + beego.Error("testing connection", name, "after connecting failed:", err) + } + }(form.Owner, form.Name) + c.ResponseOk(planned) } diff --git a/object/connection_install.go b/object/connection_install.go index a6a8f1a..03d5cd0 100644 --- a/object/connection_install.go +++ b/object/connection_install.go @@ -65,7 +65,8 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi planned = append(planned, items...) } - planned = append(planned, removeFrom(connection, found.Server.Name, dropped(connection.Agents, agentIds))...) + letGo := dropped(connection.Agents, agentIds) + planned = append(planned, removeFrom(connection, found.Server.Name, letGo)...) connection.Agents = installed(planned, agentIds) // Remembering which Gateway the entries name is what lets a later start @@ -79,6 +80,7 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi if err := SaveConnection(connection); err != nil { return nil, err } + revokeUnusedTokens(connection.Owner, letGo) return planned, nil } @@ -139,9 +141,41 @@ func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, er if err := DeleteConnection(owner, name); err != nil { return nil, err } + revokeUnusedTokens(owner, connection.Agents) return planned, nil } +// revokeUnusedTokens gives back the loopback credential of every agent this +// owner no longer reaches any connection from. The credential is issued per +// agent rather than per connection, so it can only go when the last connection +// in that agent does; leaving it would be a live token nothing holds. +// +// It is not the credential an agent's monitoring uses: that one is issued +// against the installation's path, and this one against no path at all, so they +// are separate entries and revoking here leaves monitoring alone. +func revokeUnusedTokens(owner string, agentIds []string) { + if len(agentIds) == 0 { + return + } + connections, err := GetConnections(owner) + if err != nil { + return + } + + remaining := map[string]bool{} + for _, connection := range connections { + for _, agentId := range connection.Agents { + remaining[agentId] = true + } + } + for _, agentId := range agentIds { + if remaining[agentId] { + continue + } + _ = agentpatch.RevokeIngestToken(agentpatch.Target{AgentId: agentId, Owner: owner}) + } +} + // ResolveConnection is what the proxy asks for when an agent starts it: the // server behind one connection, with its credentials filled in. It is answered // over loopback to a process this Gateway's own configuration launched, and the diff --git a/object/snapshot.go b/object/snapshot.go index bc84a28..6700613 100644 --- a/object/snapshot.go +++ b/object/snapshot.go @@ -19,6 +19,7 @@ import ( "fmt" "sort" + "github.com/apache/casbin-gateway/connector" "github.com/apache/casbin-gateway/util" "github.com/apache/casbin-gateway/version" ) @@ -30,11 +31,12 @@ const SnapshotVersion = 1 // The sections a snapshot is made of. They are named rather than positional // because a snapshot is often taken, and restored, one section at a time. const ( - SectionProviders = "providers" - SectionAgents = "agents" - SectionProbeCases = "probeCases" - SectionLlmPrices = "llmPrices" - SectionSetting = "setting" + SectionProviders = "providers" + SectionConnections = "connections" + SectionAgents = "agents" + SectionProbeCases = "probeCases" + SectionLlmPrices = "llmPrices" + SectionSetting = "setting" ) // How an import treats a row that is already there. @@ -62,12 +64,17 @@ const ( // SnapshotScope is which sections a snapshot carries, and whether the // credentials inside them come with it. type SnapshotScope struct { - Providers bool `json:"providers"` - Agents bool `json:"agents"` - ProbeCases bool `json:"probeCases"` - LlmPrices bool `json:"llmPrices"` - Setting bool `json:"setting"` - // Secrets carries the provider API keys and the secret half of the settings. + Providers bool `json:"providers"` + // Connections are the applications this machine is connected to. They are + // their own section rather than part of the agents, because a connection + // belongs to the account and only names the agents it was written into. + Connections bool `json:"connections"` + Agents bool `json:"agents"` + ProbeCases bool `json:"probeCases"` + LlmPrices bool `json:"llmPrices"` + Setting bool `json:"setting"` + // Secrets carries the provider API keys, the credentials of every + // connection, and the secret half of the settings. // A snapshot without them describes a configuration that cannot yet serve // traffic: every key has to be typed again on the machine it lands on. Secrets bool `json:"secrets"` @@ -76,11 +83,15 @@ type SnapshotScope struct { // FullScope is everything, which is what a backup takes: a backup that leaves // the keys out is not one anybody can restore from. func FullScope() SnapshotScope { - return SnapshotScope{Providers: true, Agents: true, ProbeCases: true, LlmPrices: true, Setting: true, Secrets: true} + return SnapshotScope{ + Providers: true, Connections: true, Agents: true, + ProbeCases: true, LlmPrices: true, Setting: true, Secrets: true, + } } func (scope SnapshotScope) isEmpty() bool { - return !scope.Providers && !scope.Agents && !scope.ProbeCases && !scope.LlmPrices && !scope.Setting + return !scope.Providers && !scope.Connections && !scope.Agents && + !scope.ProbeCases && !scope.LlmPrices && !scope.Setting } // Snapshot is the whole document, and the file an export downloads. It holds @@ -101,6 +112,7 @@ type Snapshot struct { Setting *Setting `json:"setting,omitempty"` Providers []*Provider `json:"providers,omitempty"` + Connections []*Connection `json:"connections,omitempty"` Agents []*Agent `json:"agents,omitempty"` AgentInstances []*AgentInstance `json:"agentInstances,omitempty"` // AgentPermissions travel with the agents: what an agent is allowed to ask @@ -114,6 +126,7 @@ type Snapshot struct { // without reading the whole file back to the browser. type SnapshotCounts struct { Providers int `json:"providers"` + Connections int `json:"connections"` Agents int `json:"agents"` AgentInstances int `json:"agentInstances"` ProbeCases int `json:"probeCases"` @@ -124,6 +137,7 @@ type SnapshotCounts struct { func (snapshot *Snapshot) Counts() SnapshotCounts { counts := SnapshotCounts{ Providers: len(snapshot.Providers), + Connections: len(snapshot.Connections), Agents: len(snapshot.Agents), AgentInstances: len(snapshot.AgentInstances), ProbeCases: len(snapshot.ProbeCases), @@ -201,6 +215,14 @@ func BuildSnapshot(scope SnapshotScope, reason string) (*Snapshot, error) { snapshot.Providers = providers } + if scope.Connections { + connections, err := getAllConnections() + if err != nil { + return nil, err + } + snapshot.Connections = connections + } + if scope.Agents { agents, err := GetAgents() if err != nil { @@ -269,6 +291,16 @@ func redactSnapshot(snapshot *Snapshot) { } } + for _, connection := range snapshot.Connections { + found, ok := connector.Get(connection.Name) + if !ok { + continue + } + for _, key := range found.SecretKeys() { + delete(connection.Credentials, key) + } + } + if snapshot.Setting != nil { snapshot.Setting.ApiKeyEncryptionKey = "" snapshot.Setting.ClientSecret = "" @@ -312,6 +344,12 @@ func ImportSnapshot(snapshot *Snapshot, scope SnapshotScope, mode string, dryRun return nil, err } } + // Connections name the agents they were written into, so they follow them. + if scope.Connections && snapshot.Scope.Connections { + if err := importConnections(snapshot, mode, dryRun, report); err != nil { + return nil, err + } + } if scope.ProbeCases && snapshot.Scope.ProbeCases { if err := importProbeCases(snapshot, mode, dryRun, report); err != nil { return nil, err @@ -409,6 +447,114 @@ func importProviders(snapshot *Snapshot, mode string, dryRun bool, report *Impor return nil } +// importConnections writes the connections a snapshot carries. Nothing is +// written into an agent's own configuration here: the row keeps the agents it +// names but forgets which Gateway wrote it, so EnsureConnectionsCurrent puts the +// entries in with this machine's port and executable the next time the agents +// are listed. That is also what makes a snapshot restorable onto a machine +// whose Gateway lives somewhere else entirely. +func importConnections(snapshot *Snapshot, mode string, dryRun bool, report *ImportReport) error { + stored, err := getAllConnections() + if err != nil { + return err + } + + existing := map[string]*Connection{} + for _, connection := range stored { + existing[connection.GetId()] = connection + } + + named := map[string]bool{} + for _, incoming := range snapshot.Connections { + connection := *incoming + id := connection.GetId() + named[id] = true + + current, found := existing[id] + if found && mode == ImportMerge { + report.record(SectionConnections, id, ChangeSkip, "this application is already connected here") + continue + } + if _, known := connector.Get(connection.Name); !known { + report.record(SectionConnections, id, ChangeSkip, "this Gateway has no connector by that name") + continue + } + + change := ChangeAdd + if found { + change = ChangeUpdate + } + if dryRun { + report.record(SectionConnections, id, change, credentiallessDetail(&connection)) + continue + } + + connection.Credentials = keptCredentials(&connection, current) + // The entries are written again from here, not carried over from + // wherever this snapshot was taken. + connection.Endpoint, connection.Executable = "", "" + detail := credentiallessDetail(&connection) + if err := saveWithoutRendering(&connection); err != nil { + report.record(SectionConnections, id, ChangeFail, err.Error()) + continue + } + report.record(SectionConnections, id, change, detail) + } + + if mode != ImportReplace { + return nil + } + + for _, connection := range stored { + id := connection.GetId() + if named[id] { + continue + } + if dryRun { + report.record(SectionConnections, id, ChangeDelete, "") + continue + } + if _, err := UninstallConnection(connection.Owner, connection.Name); err != nil { + report.record(SectionConnections, id, ChangeFail, err.Error()) + continue + } + report.record(SectionConnections, id, ChangeDelete, "") + } + return nil +} + +// keptCredentials layers what the snapshot carries over what is already stored. +// A snapshot taken without secrets carries none, and that means "leave them +// alone" rather than "clear them", the same way an empty provider key does. +func keptCredentials(incoming *Connection, current *Connection) map[string]string { + credentials := map[string]string{} + if current != nil { + for key, value := range current.Credentials { + credentials[key] = value + } + } + for key, value := range incoming.Credentials { + if value == "" { + continue + } + credentials[key] = value + } + return credentials +} + +// credentiallessDetail marks a connection that arrives unusable, which is the +// one thing somebody has to finish by hand after importing a redacted snapshot. +func credentiallessDetail(connection *Connection) string { + found, ok := connector.Get(connection.Name) + if !ok { + return "" + } + if _, err := found.Render(connection.Credentials); err != nil { + return "the snapshot carries no credentials for it" + } + return "" +} + // keylessDetail marks a provider that arrives with no key, which is the one // thing somebody has to finish by hand after importing a redacted snapshot. func keylessDetail(provider *Provider) string { diff --git a/web/src/components/settings/snapshot.tsx b/web/src/components/settings/snapshot.tsx index 9cae4ed..0476371 100644 --- a/web/src/components/settings/snapshot.tsx +++ b/web/src/components/settings/snapshot.tsx @@ -22,6 +22,7 @@ import type {ImportChange, ImportReport, Snapshot, SnapshotCounts, SnapshotScope /** The sections a snapshot is made of, in the order they are worth reading. */ export const SNAPSHOT_SECTIONS = [ {key: "providers", label: "setting:Providers"}, + {key: "connections", label: "connector:Connections"}, {key: "agents", label: "setting:Agent routing"}, {key: "probeCases", label: "setting:Probe cases"}, {key: "llmPrices", label: "setting:Model prices"}, @@ -32,6 +33,7 @@ export type SnapshotSection = (typeof SNAPSHOT_SECTIONS)[number]["key"]; export const FULL_SCOPE: SnapshotScope = { providers: true, + connections: true, agents: true, probeCases: true, llmPrices: true, @@ -124,6 +126,7 @@ export function countsOf(snapshot: Snapshot | null): SnapshotCounts { const length = (section: string) => (snapshot?.[section] as unknown[] | undefined)?.length ?? 0; return { providers: length("providers"), + connections: length("connections"), agents: length("agents"), agentInstances: length("agentInstances"), probeCases: length("probeCases"), diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index bb533c6..fb0c83e 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -834,14 +834,16 @@ "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.", "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.", "Install into": "Install into", - "Install into hint": "The agents this connection is written into. Unticking one takes the server out of it.", + "Install into hint": "The agents this connection is written into. Unticking one takes the server out of it. Each of them reaches the application through Gateway, so a connection works only while Gateway is running.", "Integration token": "Integration token", "Lark": "Lark", + "Last tested": "Last tested {time}", "Life": "Life", "Loading": "Loading...", "Local files": "Local files", "Mail": "Mail", "Manage": "Manage", + "Never tested": "Not tested yet", "No agent found": "No agent on this machine takes MCP servers Gateway knows how to write.", "Not authorized": "Not authorized yet", "Office": "Office", @@ -858,6 +860,7 @@ "Test hint": "Starts the server and asks what tools it offers. The first run installs it, which is slow.", "Test result": "{server} answered, offering {count} tools.", "Testing": "Testing...", + "Testing in background": "Connected. Testing it now, so its tools get a switch each.", "The agent can read and write this directory and everything under it.": "The agent can read and write this directory and everything under it.", "Tool count_one": "1 tool", "Tool count_other": "{{count}} tools", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 5aa565c..00d08e8 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -905,14 +905,16 @@ "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "把一个指定目录交给Agent读写,范围仅限你填的那个路径,不影响机器上的其他文件。", "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "把Chrome开发者工具交给Agent:录制性能剖面、检查DOM、读取网络请求和控制台报错,用于排查真实页面的问题。", "Install into": "装入哪些Agent", - "Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。", + "Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。它们都通过Gateway访问这个应用,所以Gateway没运行时连接不可用。", "Integration token": "集成令牌", "Lark": "飞书", + "Last tested": "上次测试于{time}", "Life": "生活服务", "Loading": "加载中...", "Local files": "本地文件", "Mail": "邮件服务", "Manage": "管理", + "Never tested": "尚未测试", "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。", "Not authorized": "尚未授权", "Office": "办公协同", @@ -929,6 +931,7 @@ "Test hint": "启动这个服务并问它提供哪些工具。首次运行要先安装,会比较慢。", "Test result": "{server}已响应,提供{count}个工具。", "Testing": "测试中...", + "Testing in background": "已连接。正在测试它,好给每个工具生成开关。", "The agent can read and write this directory and everything under it.": "Agent只能读写这个目录及其子目录。", "Tool count_other": "{{count}}个工具", "Unverified": "未验证", diff --git a/web/src/pages/ConnectionsPage.tsx b/web/src/pages/ConnectionsPage.tsx index 08b9a73..e613bfc 100644 --- a/web/src/pages/ConnectionsPage.tsx +++ b/web/src/pages/ConnectionsPage.tsx @@ -324,8 +324,9 @@ function ConnectDialog({ ConnectorBackend.connect({owner: account.name, name: entry.id, credentials: credentials, agents: agents}) .then(response => { if (response.status === "ok") { - Setting.showMessage("success", i18next.t("connector:Connected")); + Setting.showMessage("success", i18next.t("connector:Testing in background")); onDone(); + window.setTimeout(onDone, 12000); } else { Setting.showMessage("error", response.msg ?? ""); } @@ -439,6 +440,9 @@ function ConnectDialog({ {testing ? i18next.t("connector:Testing") : i18next.t("connector:Run test")} </Button> <div className="min-w-0 flex-1 pt-1.5 text-sm"> + {!testing && !probe.error && !probe.tools ? ( + <span className="text-muted-foreground">{i18next.t("connector:Never tested")}</span> + ) : null} {probe.error ? ( <span className="text-destructive break-words">{probe.error}</span> ) : probe.tools ? ( @@ -449,6 +453,11 @@ function ConnectDialog({ .replace("{count}", `${probe.tools.length}`)} </span> ) : null} + {!testing && entry.probedTime && !probe.error ? ( + <span className="text-muted-foreground ml-2 text-xs"> + {i18next.t("connector:Last tested").replace("{time}", new Date(entry.probedTime).toLocaleString())} + </span> + ) : null} </div> </div> {probe.tools && probe.tools.length > 0 ? ( diff --git a/web/src/types.ts b/web/src/types.ts index 2c66c55..d171fca 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -944,6 +944,7 @@ export type BackupMode = "auto" | "off"; * come with it. */ export interface SnapshotScope { providers: boolean; + connections: boolean; agents: boolean; probeCases: boolean; llmPrices: boolean; @@ -953,6 +954,7 @@ export interface SnapshotScope { export interface SnapshotCounts { providers: number; + connections: number; agents: number; agentInstances: number; probeCases: number; --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
