Files
AskaEth 2e1c3ed3fd feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404
- web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/
  testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600)
- 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」
- 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行

缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback
(按 DECISIONS-2026-10-06 #1/#2 补)。
2026-10-06 14:12:23 +08:00

353 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<pluginId>/<panelId>`,不含管理面板
// 前缀(ADMIN_PATH 可变,前缀只有前端与入口注入页知道)—— 前端拼成
// `<prefix>/plugin/<pluginId>/<panelId>`,与 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)
}