mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix: harden plugin runtime and UI bridge
This commit is contained in:
@@ -77,7 +77,7 @@ func (h *PluginHandler) Upload(c *gin.Context) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, plugin)
|
||||
response.Created(c, plugin)
|
||||
}
|
||||
|
||||
type pluginEnableRequest struct {
|
||||
@@ -235,7 +235,7 @@ func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
|
||||
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
|
||||
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
|
||||
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
pluginConfigMaxBytes = 4 * 1024 * 1024
|
||||
pluginUIAssetMaxBytes = 32 * 1024 * 1024
|
||||
pluginReconcilePeriod = time.Second
|
||||
pluginHealthTimeout = 5 * time.Second
|
||||
pluginUITokenPrefix = "sub2api:plugin-ui:v1:"
|
||||
)
|
||||
|
||||
@@ -267,6 +268,7 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error {
|
||||
m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取")
|
||||
return fmt.Errorf("读取插件启用状态: %w", err)
|
||||
}
|
||||
m.cleanupStaleLocalInstallations(installations)
|
||||
var enabled *PluginInstallation
|
||||
for _, installation := range installations {
|
||||
if !hasEnabledOpenAIBinding(installation.Bindings) {
|
||||
@@ -304,6 +306,13 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error {
|
||||
!current.runtime.client.Exited() && current.rolloutPercent == rollout &&
|
||||
current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 &&
|
||||
current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted {
|
||||
healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout)
|
||||
healthErr := current.runtime.checkHealth(healthCtx)
|
||||
cancel()
|
||||
if healthErr != nil {
|
||||
m.markRuntimeUnavailable(current, healthErr.Error())
|
||||
return healthErr
|
||||
}
|
||||
if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) {
|
||||
return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted)
|
||||
}
|
||||
@@ -455,6 +464,32 @@ func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installatio
|
||||
return local, nil
|
||||
}
|
||||
|
||||
// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。
|
||||
// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。
|
||||
func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) {
|
||||
persisted := make(map[int64]*PluginInstallation, len(installations))
|
||||
for _, installation := range installations {
|
||||
if installation != nil {
|
||||
persisted[installation.ID] = installation
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
stale := make([]*PluginInstallation, 0)
|
||||
for id, local := range m.localInstallations {
|
||||
current := persisted[id]
|
||||
if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version {
|
||||
stale = append(stale, local)
|
||||
delete(m.localInstallations, id)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, local := range stale {
|
||||
if err := m.cleanupInstallationFiles(local); err != nil {
|
||||
slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation {
|
||||
merged := *persisted
|
||||
merged.ArtifactData = nil
|
||||
@@ -669,6 +704,12 @@ func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMe
|
||||
if err := json.Unmarshal(raw, &normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized == nil {
|
||||
return nil, errors.New("插件配置 JSON 根节点必须是对象")
|
||||
}
|
||||
if _, ok := normalized.(map[string]any); !ok {
|
||||
return nil, errors.New("插件配置 JSON 根节点必须是对象")
|
||||
}
|
||||
canonical, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -123,6 +123,12 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co
|
||||
if err := json.Unmarshal(configJSON, &normalized); err != nil {
|
||||
return nil, fmt.Errorf("解析插件规范化配置: %w", err)
|
||||
}
|
||||
if normalized == nil {
|
||||
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
|
||||
}
|
||||
if _, ok := normalized.(map[string]any); !ok {
|
||||
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
|
||||
}
|
||||
configJSON, err = json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化插件规范化配置: %w", err)
|
||||
@@ -137,6 +143,24 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co
|
||||
return configJSON, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) checkHealth(ctx context.Context) error {
|
||||
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
|
||||
return errors.New("插件进程已退出")
|
||||
}
|
||||
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("插件健康检查失败: %w", err)
|
||||
}
|
||||
if health == nil || !health.Healthy {
|
||||
message := "插件报告不健康"
|
||||
if health != nil && strings.TrimSpace(health.Message) != "" {
|
||||
message = "插件不健康: " + health.Message
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) beginRequest() bool {
|
||||
if r == nil || r.draining.Load() {
|
||||
return false
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
:title="
|
||||
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
|
||||
"
|
||||
@load="uiLoading = false"
|
||||
@load="handlePluginFrameLoad"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
@@ -378,6 +378,7 @@ const pluginFrame = ref<HTMLIFrameElement | null>(null);
|
||||
const uiLoading = ref(false);
|
||||
const uiError = ref("");
|
||||
const iframeHeight = ref(640);
|
||||
const pendingBridgeRequests = new Map<string, number>();
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
@@ -535,25 +536,52 @@ async function openConfiguration(plugin: PluginInstallation): Promise<void> {
|
||||
}
|
||||
|
||||
function closeConfiguration(): void {
|
||||
clearPendingBridgeRequests();
|
||||
configPlugin.value = null;
|
||||
uiSession.value = null;
|
||||
uiLoading.value = false;
|
||||
uiError.value = "";
|
||||
}
|
||||
|
||||
function clearPendingBridgeRequests(): void {
|
||||
for (const timeout of pendingBridgeRequests.values()) window.clearTimeout(timeout);
|
||||
pendingBridgeRequests.clear();
|
||||
}
|
||||
|
||||
function handlePluginFrameLoad(): void {
|
||||
// A load can also be caused by a plugin navigating its iframe. Drop all
|
||||
// outstanding responses so a late config response is never sent to the new document.
|
||||
clearPendingBridgeRequests();
|
||||
uiLoading.value = false;
|
||||
}
|
||||
|
||||
function registerBridgeRequest(requestID: string): void {
|
||||
const timeout = window.setTimeout(() => {
|
||||
pendingBridgeRequests.delete(requestID);
|
||||
}, 30_000);
|
||||
pendingBridgeRequests.set(requestID, timeout);
|
||||
}
|
||||
|
||||
function postBridgeResult(
|
||||
request: PluginBridgeMessage,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
|
||||
const requestID = typeof request.request_id === "string" ? request.request_id.trim() : "";
|
||||
const timeout = pendingBridgeRequests.get(requestID);
|
||||
if (!requestID || timeout === undefined) return;
|
||||
window.clearTimeout(timeout);
|
||||
pendingBridgeRequests.delete(requestID);
|
||||
pluginFrame.value.contentWindow.postMessage(
|
||||
{
|
||||
source: "sub2api-plugin-host",
|
||||
bridge_token: uiSession.value.bridge_token,
|
||||
type: `${request.type}.result`,
|
||||
request_id: request.request_id,
|
||||
request_id: requestID,
|
||||
...payload,
|
||||
},
|
||||
// The sandboxed iframe has an opaque origin, so no fixed target origin exists.
|
||||
// Pending request tracking plus load invalidation prevents cross-navigation leaks.
|
||||
"*",
|
||||
);
|
||||
}
|
||||
@@ -562,7 +590,8 @@ async function handleBridgeMessage(event: MessageEvent): Promise<void> {
|
||||
if (
|
||||
!uiSession.value ||
|
||||
!configPlugin.value ||
|
||||
event.source !== pluginFrame.value?.contentWindow
|
||||
event.source !== pluginFrame.value?.contentWindow ||
|
||||
event.origin !== "null"
|
||||
)
|
||||
return;
|
||||
const message = event.data as PluginBridgeMessage;
|
||||
@@ -573,6 +602,16 @@ async function handleBridgeMessage(event: MessageEvent): Promise<void> {
|
||||
)
|
||||
return;
|
||||
|
||||
const requestID = typeof message.request_id === "string" ? message.request_id.trim() : "";
|
||||
const expectsResponse =
|
||||
message.type === "config.load" ||
|
||||
message.type === "config.save" ||
|
||||
message.type === "config.test";
|
||||
if (expectsResponse) {
|
||||
if (!requestID || pendingBridgeRequests.has(requestID)) return;
|
||||
registerBridgeRequest(requestID);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "sub2api.plugin.ready":
|
||||
@@ -667,5 +706,6 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("message", handleBridgeMessage);
|
||||
clearPendingBridgeRequests();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user