// Package lua — 服务路径/适配器名冲突裁决(用户要求 A + B) // // 用户要求:当两个东西声明同一个「服务路径」时,内核**不许崩溃、不许拒绝 // 启动**,而是报冲突、把候选都列出来、让运维在 WebUI/WebAPI 里选一个保留, // 选择持久化生效。两类冲突: // // A) 两个插件在 plugin.http 里声明同一条服务路径(如都声明 "stats")。 // 改造前分发器按加载顺序静默选一个 —— 装个新插件就可能改变既有路径归属。 // B) 一个插件安装的适配器,名字撞上内置适配器或另一个插件已装的适配器。 // 改造前这是硬错误(ErrAdapterNameReserved / owned by 另一插件)。 // // 统一处理: // // - 冲突是**运维策略**,与 .access.json / .kernel-perms.json 同源,单独落 // .route-conflicts.json。放单独文件而不是塞进既有策略文件,是因为那些文件 // 形状不同(map[string]string 权限 spec),混进去会让旧二进制读到无法 // round-trip 的键。 // - 「安全默认」是**保持现任**:A 类未裁决时该路径回 409 并列出候选(不静默 // 选一个);B 类未裁决时现任适配器(内置/先装的插件)继续服务,新候选挂起 // (incumbent 永不被悄悄换掉,这保住了「第三方插件不得篡改内核发行物」这条 // 信任边界 —— 只有运维显式选了插件版本才会覆盖内置)。 package lua import ( "encoding/json" "os" "sort" "strings" ) // RouteConflict 描述一条被多于一个插件认领的服务路径(A 类)。 type RouteConflict struct { Path string `json:"path"` Candidates []string `json:"candidates"` Resolved string `json:"resolved"` // 运维选定的赢家插件名;"" = 未裁决(分发回 409) } // AdapterConflict 描述一个被挂起的适配器安装(B 类):某插件想装的适配器名 // 撞上了现任(内置或另一插件)。现任继续服务,候选挂起等运维裁决。 type AdapterConflict struct { Name string `json:"name"` Incumbent string `json:"incumbent"` // "operator"(含内置)或 "plugin:<名>" Candidate string `json:"candidate"` // 想装它的插件名 Resolved string `json:"resolved"` // "incumbent" / "candidate" / "" 未裁决 candidateCode string // 候选代码,运维选 candidate 时才真正装(仅内存) } const routeConflictsFileName = ".route-conflicts.json" // persistedConflicts 是 .route-conflicts.json 的磁盘形态:两张裁决表。 // routes: 归一化路径 -> 赢家插件名 // adapters: 适配器名 -> "incumbent" | "candidate:<插件名>" type persistedConflicts struct { Routes map[string]string `json:"routes,omitempty"` Adapters map[string]string `json:"adapters,omitempty"` } func (ps *Plugins) routeConflictsFile() string { if ps.dir == "" { return "" } return ps.dir + string(os.PathSeparator) + routeConflictsFileName } // normalizeRoutePath 把声明路径归一化:去首尾斜杠。裁决键与分发键共用它, // 不可能对不上。 func normalizeRoutePath(p string) string { return strings.Trim(strings.TrimSpace(p), "/") } // ---- A 类:插件 HTTP 服务路径冲突 ---- // RouteConflicts 扫描已加载插件的 http 声明,返回被多于一个插件认领的路径。 // 只看 loaded 且未禁用的插件:加载失败/被关掉的插件不占路由。 func (ps *Plugins) RouteConflicts() []RouteConflict { ps.mu.RLock() defer ps.mu.RUnlock() byPath := map[string]map[string]bool{} for _, p := range ps.plugins { if p.LoadError != "" || p.Disabled { continue } for _, h := range p.http { key := normalizeRoutePath(h.Path) if key == "" { continue } if byPath[key] == nil { byPath[key] = map[string]bool{} } byPath[key][p.Info.Name] = true } } var out []RouteConflict for path, owners := range byPath { if len(owners) < 2 { continue } cands := make([]string, 0, len(owners)) for name := range owners { cands = append(cands, name) } sort.Strings(cands) resolved := ps.routeWinners[path] if resolved != "" && !owners[resolved] { resolved = "" // 赢家已不再声明这条路径 → 回到未裁决,而非指向不存在的赢家 } out = append(out, RouteConflict{Path: path, Candidates: cands, Resolved: resolved}) } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out } // RouteWinner 返回某条路径运维选定的赢家插件名;无冲突/未裁决时 ""。 func (ps *Plugins) RouteWinner(path string) string { key := normalizeRoutePath(path) ps.mu.RLock() defer ps.mu.RUnlock() return ps.routeWinners[key] } // ResolveRouteConflict 记录运维对某条冲突路径的裁决并落盘。winner 必须是当前 // 确实声明了这条路径的候选之一 —— 否则分发会永远 404,所以直接拒绝。 func (ps *Plugins) ResolveRouteConflict(path, winner string) error { key := normalizeRoutePath(path) if key == "" { return &ConflictError{Reason: "empty path"} } var cands []string for _, c := range ps.RouteConflicts() { if c.Path == key { cands = c.Candidates break } } if cands == nil { return &ConflictError{Reason: "path " + key + " is not contested"} } ok := false for _, c := range cands { if c == winner { ok = true break } } if !ok { return &ConflictError{Reason: "plugin " + winner + " does not serve " + key} } ps.mu.Lock() if ps.routeWinners == nil { ps.routeWinners = map[string]string{} } ps.routeWinners[key] = winner ps.saveConflictsLocked() ps.mu.Unlock() return nil } // ---- B 类:适配器名冲突 ---- // recordAdapterConflict 挂起一个撞名的适配器安装。调用方(InstallAdapter)在 // 检测到现任存在时调用。若运维此前已裁决「candidate 赢」,返回 shouldApply=true // 让调用方直接安装(重启后插件再装会自动套用上次裁决,不再二次挂起)。 func (ps *Plugins) recordAdapterConflict(name, incumbent, candidate, code string) (shouldApply bool) { ps.mu.Lock() defer ps.mu.Unlock() if d := ps.adapterWinners[name]; d != "" { // 已有持久裁决。candidate:<此插件> → 直接应用。否则把它重新挂回冲突 // 列表:持久裁决是**默认**,不是终审 —— 新的安装尝试要把冲突重新 // 摆到运维面前(否则裁决过 incumbent 后冲突消失,运维想改选 candidate // 就再也没有入口了)。 if d == "candidate:"+candidate { return true } } if ps.adapterConflicts == nil { ps.adapterConflicts = map[string]*AdapterConflict{} } ps.adapterConflicts[name] = &AdapterConflict{ Name: name, Incumbent: incumbent, Candidate: candidate, candidateCode: code, } return false } // AdapterConflicts 列出当前挂起的适配器撞名冲突。 func (ps *Plugins) AdapterConflicts() []AdapterConflict { ps.mu.RLock() defer ps.mu.RUnlock() out := make([]AdapterConflict, 0, len(ps.adapterConflicts)) for _, c := range ps.adapterConflicts { out = append(out, AdapterConflict{ Name: c.Name, Incumbent: c.Incumbent, Candidate: c.Candidate, Resolved: c.Resolved, }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // ResolveAdapterConflict 裁决一个挂起的适配器撞名冲突。 // // winner == "incumbent" → 保持现任,丢弃挂起候选。 // winner == "candidate" → 真正安装候选插件的适配器(覆盖现任),记录归属。 // 这是运维显式授权一个插件覆盖内置/他人适配器的唯一入口。 // // 裁决持久化:下次该插件再装同名适配器会自动套用(不再挂起)。 func (ps *Plugins) ResolveAdapterConflict(name, winner string) error { ps.mu.Lock() c := ps.adapterConflicts[name] ps.mu.Unlock() if c == nil { return &ConflictError{Reason: "adapter " + name + " has no pending conflict"} } switch winner { case "incumbent": ps.mu.Lock() if ps.adapterWinners == nil { ps.adapterWinners = map[string]string{} } ps.adapterWinners[name] = "incumbent" delete(ps.adapterConflicts, name) ps.saveConflictsLocked() ps.mu.Unlock() return nil case "candidate": // 真正安装候选代码。绕过 reserved 门禁:这正是运维显式授权覆盖的语义。 if ps.vm == nil { return &ConflictError{Reason: "no adapter VM available"} } if err := ps.vm.LoadAdapterSource(name, c.candidateCode); err != nil { return err } effective := ps.vm.EffectiveAdapterName(name) ps.adapterOwner().set(effective, c.Candidate) if effective != name { ps.adapterOwner().set(name, c.Candidate) } ps.mu.Lock() if ps.adapterWinners == nil { ps.adapterWinners = map[string]string{} } ps.adapterWinners[name] = "candidate:" + c.Candidate delete(ps.adapterConflicts, name) ps.saveConflictsLocked() ps.mu.Unlock() return nil default: return &ConflictError{Reason: "winner must be \"incumbent\" or \"candidate\""} } } // ConflictError 是裁决动作被拒的原因,点名性质(用户要求「避免静默错误」)。 type ConflictError struct{ Reason string } func (e *ConflictError) Error() string { return "conflict: " + e.Reason } // ErrAdapterConflictPending 是 InstallAdapter 在撞名时返回的 typed 错误: // 安装没有成功,但也不是硬失败 —— 它被挂起等运维裁决。插件/UI 据此区分 // 「代码写错」与「撞名待裁决」。 type ErrAdapterConflictPending struct { Name string Incumbent string } func (e *ErrAdapterConflictPending) Error() string { return "adapter " + e.Name + " conflicts with the current " + e.Incumbent + "; the incumbent keeps serving until an operator chooses a winner (admin UI → Conflicts)" } // ---- 持久化 ---- // LoadRouteConflicts 读取持久化的裁决(A 的路径赢家 + B 的适配器赢家)。 // 缺文件是全新安装的正常态;损坏文件记录后忽略而非致命。 func (ps *Plugins) LoadRouteConflicts() error { path := ps.routeConflictsFile() if path == "" { return nil } b, err := os.ReadFile(path) if os.IsNotExist(err) { return nil } if err != nil { return err } var pc persistedConflicts if err := json.Unmarshal(b, &pc); err != nil { return err } ps.mu.Lock() defer ps.mu.Unlock() if ps.routeWinners == nil { ps.routeWinners = map[string]string{} } for k, v := range pc.Routes { ps.routeWinners[normalizeRoutePath(k)] = v } if ps.adapterWinners == nil { ps.adapterWinners = map[string]string{} } for k, v := range pc.Adapters { ps.adapterWinners[k] = v } return nil } // saveConflictsLocked 落盘两张裁决表。调用方持 ps.mu。 func (ps *Plugins) saveConflictsLocked() { path := ps.routeConflictsFile() if path == "" { return } pc := persistedConflicts{} if len(ps.routeWinners) > 0 { pc.Routes = map[string]string{} for k, v := range ps.routeWinners { pc.Routes[k] = v } } if len(ps.adapterWinners) > 0 { pc.Adapters = map[string]string{} for k, v := range ps.adapterWinners { pc.Adapters[k] = v } } if pc.Routes == nil && pc.Adapters == nil { _ = os.Remove(path) return } writeJSONAtomic(path, pc) }