package gateway // 本文件是 design/09-插件生态 §5.2.1 的「唯一契约面」在网关侧的落点(切片 PLG-2): // // - GET /api/v1/admin/panels —— 按调用者角色过滤后的接入点快照 // - POST /api/v1/admin/plugins/{id}/actions/{action} —— 已声明动作的分发(鉴权 + 审计) // // 两条路都**只**经插件管理器的面板中枢(s.plugins.Panels())取事实,网关自己不读 // plugins.json、不猜插件的存储形状(§5.2.1 面板侧红线②)。中枢未接时两条路都回 // 501 panels_unavailable —— 绝不回一份空面板列表把「没接线」伪装成「没有页面」。 // // 动作面是**名字**面,不是 URL 面:插件声明的只有动作名,宿主不接受任何来自声明的 // 请求地址(§5.4.4 不给插件开 SSRF 面)。 import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // panelActionBodyLimit 限制动作请求体大小。面板动作的载荷是表单值/行标识这类小对象, // 1 MiB 已极宽裕;不设限会让一个坏客户端把整个请求体读进内存。 const panelActionBodyLimit = 1 << 20 // errPanelActionBodyTooLarge 是本地哨兵,用来把「体太大」与「不是合法 JSON」分开 // 映射到同一条 400 的不同文案。 var errPanelActionBodyTooLarge = errors.New("plugin action body too large") // AdminPanelItem 是下发给面板的一条导航条目(§5.2.1 的 nav.item + page.root)。 // // Sections 直接复用 SDK 的声明类型:那份形状(JSON tag 与 §5.2 示例逐字同形)就是 // 插件的公开声明面,网关不另立一份 DTO 去翻译它 —— 两套形状必然分叉。 type AdminPanelItem struct { // PluginID 是声明该面板的插件 id(目录命名空间)。 PluginID string `json:"pluginId"` // ID 是面板 id。 ID string `json:"id"` // Title 是页面标题(同时是侧边栏条目文案)。 Title string `json:"title"` // Description 是页面描述(page.root 消费;缺省省略)。 Description string `json:"description,omitempty"` // Icon 是宿主内置图标名(缺省省略 = 前端用默认图标)。 Icon string `json:"icon,omitempty"` // Order 是组内排序(小在前)。 Order int `json:"order"` // Route 是面板路由的**相对片段** `plugin//`,不含管理面板 // 前缀(ADMIN_PATH 可变,前缀只有前端与入口注入页知道)—— 前端拼成 // `/plugin//`,与 design/09 §5.7 C 同构。 Route string `json:"route"` // Sections 是该页面的区块声明(已按调用者角色裁剪:viewer 只留只读区块)。 Sections []sdk.Section `json:"sections"` } // AdminPanelGroup 是侧边栏折叠分组(§5.2.1 的 nav.group)。 type AdminPanelGroup struct { // ID 是分组 id(本期闭集只有 `plugins`)。 ID string `json:"id"` // Title 是分组的本地化显示名(宿主文案,见 panelGroupTitle)。 Title string `json:"title"` // Order 是分组排序(取组内最小 order)。 Order int `json:"order"` // Items 是分组内的条目。 Items []AdminPanelItem `json:"items"` } // AdminPanelsPayload 是 GET /api/v1/admin/panels 的 data 形状。 type AdminPanelsPayload struct { // Role 是本次快照按哪个角色过滤的(回显让前端能确认自己拿到的裁剪档)。 Role string `json:"role"` // Groups 是分组化后的面板集合。 Groups []AdminPanelGroup `json:"groups"` // PluginIDs 是**声明过面板**的插件 id(已排序)。它不等于已安装插件:前端拿它 // 与 GET /admin/plugins/installed 对照,把「已装、未声明任何页面」的插件如实 // 显示成「未声明管理页面」,而不是藏掉(§5.2.1 面板侧红线③)。 PluginIDs []string `json:"pluginIds"` } // adminListPanels 返回按调用者角色过滤后的面板接入点快照(§5.2.1 唯一契约面之一)。 // // 它是**读**端点,任何已认证角色都能调(路由门是 viewerAuth)—— 对 viewer 而言它 // 回的就是被角色过滤裁掉写面后的只读投影(§5.4.1),因此不存在越权读。 func (s *Server) adminListPanels(w http.ResponseWriter, r *http.Request) { role, ok := callerRole(r) if !ok { writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "缺少调用者身份")) return } hub := s.panelHub() if hub == nil { writeError(w, panelsUnavailable()) return } groups, err := hub.Groups(role) if err != nil { writeError(w, apperr.Wrap(apperr.ErrForbidden, "unknown_role", "%v", err)) return } out := AdminPanelsPayload{ Role: string(role), Groups: make([]AdminPanelGroup, 0, len(groups)), PluginIDs: hub.PluginIDs(), } if out.PluginIDs == nil { out.PluginIDs = []string{} } for _, g := range groups { out.Groups = append(out.Groups, adminPanelGroupOf(g)) } writeJSON(w, http.StatusOK, out) } // adminPanelGroupOf 把目录分组映射为其线形状。 func adminPanelGroupOf(g paneldecl.Group) AdminPanelGroup { items := make([]AdminPanelItem, 0, len(g.Panels)) for _, e := range g.Panels { items = append(items, AdminPanelItem{ PluginID: e.PluginID, ID: e.Panel.ID, Title: e.Panel.Title, Description: e.Panel.Description, Icon: e.Panel.Icon, Order: e.Panel.Order, Route: panelRoute(e.PluginID, e.Panel.ID), Sections: e.Panel.Sections, }) } return AdminPanelGroup{ ID: string(g.ID), Title: panelGroupTitle(g.ID), Order: g.Panels[0].Panel.Order, Items: items, } } // adminPluginAction 分发一个插件动作(§5.2.1 动作分发 + 鉴权 + 审计)。 // // 顺序是刻意的**先证后行**: // 1. 未声明 ⇒ 404(插件只给动作名 ⇒ 未声明的名字根本不存在,不是「没权限」); // 2. 写动作 ⇒ 必须 owner/admin(读来源动作对 viewer 开放,§5.4.1); // 3. 停止用插件 ⇒ 409; // 4. 已声明但没登记处理器 ⇒ 501(诚实作答,绝不假装执行成功)。 // // 每一步都落审计:谁在什么时候想动哪个插件的哪个动作、结果如何。 func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { pluginID := strings.TrimSpace(r.PathValue("id")) action := strings.TrimSpace(r.PathValue("action")) hub := s.panelHub() if hub == nil { s.auditPanelAction(r, pluginID, action, "unavailable", "panels_unavailable") writeError(w, panelsUnavailable()) return } kind, declared := hub.Action(pluginID, action) if !declared { s.auditPanelAction(r, pluginID, action, "not_declared", "action_not_declared") writeError(w, &apperr.AppError{ Code: "action_not_declared", Message: fmt.Sprintf("插件 %q 没有声明动作 %q(插件只给动作名,不给 URL)", pluginID, action), HTTPCode: http.StatusNotFound, }) return } if kind == pluginmgr.ActionWrite && !callerHasWrite(r) { s.auditPanelAction(r, pluginID, action, "denied", "forbidden") writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "写动作需要 owner/admin(§5.4.1)")) return } payload, derr := decodeActionPayload(r) if derr != nil { s.auditPanelAction(r, pluginID, action, "bad_request", "bad_request") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", derr)) return } res, err := hub.Dispatch(r.Context(), pluginmgr.ActionRequest{ PluginID: pluginID, Action: action, Payload: payload, }) if err != nil { status, code, msg := actionFailure(err) s.auditPanelAction(r, pluginID, action, auditResultOf(err), code) writeError(w, &apperr.AppError{Code: code, Message: msg, Cause: err, HTTPCode: status}) return } s.auditPanelAction(r, pluginID, action, "ok", "") writeJSON(w, http.StatusOK, map[string]any{ "pluginId": pluginID, "action": action, "kind": string(kind), "result": res, }) } // actionFailure 把分发失败映射为 HTTP 状态 + 结构化错误码 + 文案 // (「错误码与文案分离」,§5.4.6:前端据此本地化,不靠解析字符串)。 // // 顺序是先认宿主哨兵(未声明 / 停用 / 未接线),再认**插件侧自己给出的** // ActionError —— 插件比宿主更清楚这次失败是「请求写错了」还是「上游不可用」, // 宿主只做透传,不替它猜病因;两者都不是的内部错误才落 action_failed。 func actionFailure(err error) (status int, code, message string) { switch { case errors.Is(err, pluginmgr.ErrActionNotDeclared): return http.StatusNotFound, "action_not_declared", "该动作未在插件声明里出现过" case errors.Is(err, pluginmgr.ErrPluginDisabled): return http.StatusConflict, "plugin_disabled", "插件已停用,其动作不再分发" case errors.Is(err, pluginmgr.ErrActionUnavailable): return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未登记它的处理器" } if ae, ok := pluginmgr.AsActionError(err); ok { return actionErrorStatus(ae.Status), actionErrorCode(ae.Code), actionErrorText(ae.Message) } return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志" } // actionErrorStatus 归一结构化失败的状态码:0 或越界值一律回落到 502 // (「上游/插件侧执行失败」是这条路径最诚实的默认档,不是 200)。 func actionErrorStatus(status int) int { if status < http.StatusBadRequest || status > 599 { return http.StatusBadGateway } return status } // actionErrorCode 兜住空失败码:没有码的失败退回 action_failed, // 前端至少能显示一个稳定标识(空码会让提示行上少一段信息)。 func actionErrorCode(code string) string { if strings.TrimSpace(code) == "" { return "action_failed" } return code } // actionErrorText 兜住空文案:实现没给出人话时,回一句明确指向服务端日志的话, // 绝不把空串当文案发给前端(那会渲染成一行无来由的红字)。 func actionErrorText(message string) string { if strings.TrimSpace(message) == "" { return "插件动作执行失败,详细原因见后端日志" } return message } // auditResultOf 把分发失败归成审计链里的一档结果(**分类**,不是插件域的错误码; // 后者作为 code 附加字段进日志,见 auditPanelAction)。 func auditResultOf(err error) string { switch { case errors.Is(err, pluginmgr.ErrActionNotDeclared): return "not_declared" case errors.Is(err, pluginmgr.ErrPluginDisabled): return "disabled" case errors.Is(err, pluginmgr.ErrActionUnavailable): return "unimplemented" default: return "error" } } // auditPanelAction 把一次插件动作的结局写进审计链(who / what / when / result)。 // 附加字段只有插件 id、动作名与结构化失败码 —— 请求体可能有凭据,绝不进审计; // 失败码是机器可读的稳定标识(不是请求内容),它让「审计里那次 error 到底是什么」 // 不必翻服务端日志才能回答。 func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result, code string) { who := "anonymous" if claims, ok := auth.ClaimsFromContext(r.Context()); ok { who = claims.Sub + ":" + string(claims.Role) } extra := []any{"pluginId", pluginID, "action", action} if strings.TrimSpace(code) != "" { extra = append(extra, "code", code) } s.audit(r.Context(), who, "plugin.action", result, extra...) } // decodeActionPayload 解码可选的动作请求体:空体 ⇒ nil(多数读来源动作不需要参数), // 非法 JSON 或超限 ⇒ 显式错误。绝不把坏体静默当空体继续跑。 func decodeActionPayload(r *http.Request) (any, error) { if r.Body == nil { return nil, nil } raw, err := io.ReadAll(io.LimitReader(r.Body, panelActionBodyLimit+1)) if err != nil { return nil, fmt.Errorf("读取请求体失败: %w", err) } if len(raw) > panelActionBodyLimit { return nil, errPanelActionBodyTooLarge } if strings.TrimSpace(string(raw)) == "" { return nil, nil } var payload any if err := json.Unmarshal(raw, &payload); err != nil { return nil, fmt.Errorf("请求体不是合法 JSON: %w", err) } return payload, nil } // panelHub 返回已接线的面板中枢(未接线时 nil)。网关只经插件管理器取它, // 因此「面板中枢」与「插件生命周期」天然共享一份事实。 func (s *Server) panelHub() *pluginmgr.Panels { if s.plugins == nil { return nil } return s.plugins.Panels() } // callerRole 从请求上下文取已验证的调用者角色(路由门已保证存在;缺失即 fail-closed)。 func callerRole(r *http.Request) (auth.Role, bool) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok || claims == nil || !claims.Role.Valid() { return "", false } return claims.Role, true } // callerHasWrite 报告调用者是否具备写权(owner/admin,沿用 design/11 的 writeAuth 口径)。 func callerHasWrite(r *http.Request) bool { role, ok := callerRole(r) return ok && auth.HasRole(role, auth.RoleAdmin) } // panelsUnavailable 是「面板中枢未接线」的诚实 501:不接线的部署拿到的是 // 明确的错误码,而不是一份看起来正常但空的面板列表。 func panelsUnavailable() error { return &apperr.AppError{ Code: "panels_unavailable", Message: "插件面板中枢未接线(未注入 panel hub)", HTTPCode: http.StatusNotImplemented, } } // panelRoute 给出前端路由的相对片段(不含 ADMIN_PATH 前缀,理由见 AdminPanelItem.Route)。 func panelRoute(pluginID, panelID string) string { return "plugin/" + pluginID + "/" + panelID } // panelGroupTitle 给出分组的本地化显示名。§5.2.1 的 nav.group 消费 title/order, // 但分组本身**没有声明**(`group` 只是分组 id)⇒ 分组名由宿主文案提供; // PLG-1 报告 §2 不一致④已登记这个归属待裁决,改口径只改这一个函数。 func panelGroupTitle(id sdk.GroupID) string { if id == sdk.GroupPlugins { return "插件" } return string(id) }