Compare commits

..
Author SHA1 Message Date
Yuki Guo ecedaf8c21 feat(container): support batch upgrades with bounded concurrency and recovery (#14231) 2026-10-28 18:45:13 +08:00
ssongliu d7c42e3e8b fix(ai): unify model deletion prompts and register template route (#14233) 2026-10-28 18:20:06 +08:00
蘭 d32cbb40f6 feat(mcp): add Panel MCP task configuration and website binding structures (#14229)
* feat(mcp): add Panel MCP task configuration and website binding structures

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
CityFun 1798f9768e fix(ai): preserve legacy Feishu and safely migrate OpenClaw 2026.10.7 upgrades (#14214)
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades

* fix(ai): override inherited Feishu allowlists when cleared
2026-10-28 11:24:03 +08:00
Yuki Guo eb4beb50c4 fix: docker image pull and stop (#14202) 2026-10-27 23:07:21 +08:00
ssongliu 3a7cd211af fix: unify database credential validation (#14216) 2026-10-27 23:06:13 +08:00
ssongliu bd0bc0079e refactor(ai): remove obsolete tokenizer directory patterns (#14225) 2026-10-27 23:05:54 +08:00
ssongliu 93ed58d784 fix(terminal): improve dock button border in dark mode (#14224) 2026-10-27 17:22:51 +08:00
ssongliu 8d320ef2ca feat(node): add translations for existing node packages (#14222) 2026-10-27 17:12:14 +08:00
ssongliu b3c1e0df42 refactor(frontend): use DialogPro for task logs (#14212) 2026-10-27 14:34:20 +08:00
232 changed files with 9785 additions and 1616 deletions
+20
View File
@@ -826,6 +826,26 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags AI
// @Summary Switch to the bundled OpenClaw Feishu plugin
// @Accept json
// @Param request body dto.AgentFeishuSwitchReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/feishu/switch [post]
func (b *BaseApi) SwitchAgentFeishuPlugin(c *gin.Context) {
var req dto.AgentFeishuSwitchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.SwitchFeishuPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Get Agent Telegram channel config // @Summary Get Agent Telegram channel config
// @Accept json // @Accept json
-16
View File
@@ -8,7 +8,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -161,25 +160,10 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-18
View File
@@ -1,11 +1,7 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -340,25 +336,11 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-17
View File
@@ -2,12 +2,9 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -101,25 +98,11 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-33
View File
@@ -3,12 +3,9 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -22,11 +19,6 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -40,17 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -531,25 +512,11 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+137
View File
@@ -0,0 +1,137 @@
package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
type mcpBusinessAPI struct {
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
}
func MCPBusinessDispatch() gin.HandlerFunc {
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
}
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
return func(c *gin.Context) {
var handler gin.HandlerFunc
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/v2/databases":
handler = api.createMysql
case "POST /api/v2/databases/status":
handler = api.mysqlStatus
case "GET /api/v2/databases/db/list/:type":
handler = api.databases
case "POST /api/v2/apps/install":
handler = api.install
case "GET /api/v2/apps/installed/info/:appInstallId":
handler = api.appInfo
default:
c.Next()
return
}
present, trusted := mcpapi.Check(c.Request)
if !present {
c.Next()
return
}
if !trusted {
c.AbortWithStatus(http.StatusForbidden)
return
}
handler(c)
c.Abort()
}
}
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
}
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (api *mcpBusinessAPI) databases(c *gin.Context) {
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
func (api *mcpBusinessAPI) install(c *gin.Context) {
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
installed, err := api.installApp(req, true)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, installed)
}
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
id, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+29
View File
@@ -0,0 +1,29 @@
package v2
import (
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
func PanelMCPTaskAccess() gin.HandlerFunc {
return func(c *gin.Context) {
if hasPanelMCPTaskAccess(c.Request) {
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
}
c.Next()
}
}
func hasPanelMCPTaskAccess(req *http.Request) bool {
if !global.IsMaster {
return false
}
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
values := req.Header.Values("X-Panel-MCP-Task-Access")
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
}
+12 -3
View File
@@ -5,6 +5,8 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
total, list, err := taskService.Page(req) total, list, err := taskServiceForRequest(c).Page(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return return
} }
var res *response.FileLineContent var res *response.FileLineContent
res, err := taskService.ReadByLine(req) res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /logs/tasks/executing/count [get] // @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) { func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask() count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, count) helper.SuccessWithData(c, count)
} }
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
+43 -22
View File
@@ -433,32 +433,53 @@ type AgentFeishuConfigReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
} }
type AgentFeishuTransition struct {
Confirmed bool `json:"confirmed"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope"`
RequireMention string `json:"requireMention"`
}
type AgentFeishuSwitchReq struct {
AgentID uint `json:"agentId" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender group_topic group_topic_sender"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false"`
}
type AgentFeishuConfigUpdateReq struct { type AgentFeishuConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"` GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
Enabled bool `json:"enabled"` AgentID uint `json:"agentId" validate:"required"`
ThreadSession bool `json:"threadSession"` Enabled bool `json:"enabled"`
ReplyMode string `json:"replyMode" validate:"required"` ThreadSession bool `json:"threadSession"`
Streaming bool `json:"streaming"` ReplyMode string `json:"replyMode" validate:"required"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"` Streaming bool `json:"streaming"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"` RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupAllowFrom []string `json:"groupAllowFrom"` GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
Domain string `json:"domain"` GroupAllowFrom []string `json:"groupAllowFrom"`
ConnectionMode string `json:"connectionMode"` Domain string `json:"domain"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"` ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
} }
type AgentFeishuConfig struct { type AgentFeishuConfig struct {
Enabled bool `json:"enabled"` BuiltinManaged bool `json:"builtinManaged"`
ThreadSession bool `json:"threadSession"` LegacyConfigured bool `json:"legacyConfigured"`
ReplyMode string `json:"replyMode"` GroupSessionScope string `json:"groupSessionScope"`
Streaming bool `json:"streaming"` BuiltinAvailable bool `json:"builtinAvailable"`
RequireMention string `json:"requireMention"` Legacy bool `json:"legacy"`
GroupPolicy string `json:"groupPolicy"` Enabled bool `json:"enabled"`
GroupAllowFrom []string `json:"groupAllowFrom"` ThreadSession bool `json:"threadSession"`
Domain string `json:"domain"` ReplyMode string `json:"replyMode"`
ConnectionMode string `json:"connectionMode"` Streaming bool `json:"streaming"`
Bots []AgentFeishuBot `json:"bots"` RequireMention string `json:"requireMention"`
Installed bool `json:"installed"` GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Domain string `json:"domain"`
ConnectionMode string `json:"connectionMode"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
} }
type AgentTelegramConfigReq struct { type AgentTelegramConfigReq struct {
+5 -3
View File
@@ -43,9 +43,11 @@ type AppOssConfig struct {
} }
type AppVersion struct { type AppVersion struct {
Version string `json:"version"` FeishuLegacy bool `json:"feishuLegacy,omitempty"`
DetailId uint `json:"detailId"` FeishuConfigured bool `json:"feishuConfigured,omitempty"`
DockerCompose string `json:"dockerCompose"` Version string `json:"version"`
DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"`
} }
type AppList struct { type AppList struct {
+22 -22
View File
@@ -27,8 +27,6 @@ type AppInstallCreate struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
@@ -89,31 +87,33 @@ type AppBackupDelete struct {
} }
type AppInstalledOperate struct { type AppInstalledOperate struct {
InstallId uint `json:"installId" validate:"required"` FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
BackupId uint `json:"backupId"` InstallId uint `json:"installId" validate:"required"`
DetailId uint `json:"detailId"` BackupId uint `json:"backupId"`
Operate constant.AppOperate `json:"operate" validate:"required"` DetailId uint `json:"detailId"`
ForceDelete bool `json:"forceDelete"` Operate constant.AppOperate `json:"operate" validate:"required"`
DeleteBackup bool `json:"deleteBackup"` ForceDelete bool `json:"forceDelete"`
DeleteDB bool `json:"deleteDB"` DeleteBackup bool `json:"deleteBackup"`
Backup bool `json:"backup"` DeleteDB bool `json:"deleteDB"`
PullImage bool `json:"pullImage"` Backup bool `json:"backup"`
DockerCompose string `json:"dockerCompose"` PullImage bool `json:"pullImage"`
TaskID string `json:"taskID"` DockerCompose string `json:"dockerCompose"`
DeleteImage bool `json:"deleteImage"` TaskID string `json:"taskID"`
Favorite bool `json:"favorite"` DeleteImage bool `json:"deleteImage"`
Favorite bool `json:"favorite"`
UseLifecycleScripts bool `json:"-"` UseLifecycleScripts bool `json:"-"`
} }
type AppInstallUpgrade struct { type AppInstallUpgrade struct {
InstallID uint `json:"installId"` FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
DetailID uint `json:"detailId"` InstallID uint `json:"installId"`
Backup bool `json:"backup"` DetailID uint `json:"detailId"`
PullImage bool `json:"pullImage"` Backup bool `json:"backup"`
DeleteImage bool `json:"deleteImage"` PullImage bool `json:"pullImage"`
DockerCompose string `json:"dockerCompose"` DeleteImage bool `json:"deleteImage"`
TaskID string `json:"taskID"` DockerCompose string `json:"dockerCompose"`
TaskID string `json:"taskID"`
} }
type AppInstallDelete struct { type AppInstallDelete struct {
+14
View File
@@ -0,0 +1,14 @@
package request
type MCPWebsiteBinding struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
+17
View File
@@ -0,0 +1,17 @@
package response
type MCPWebsiteBinding struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason"`
Code string `json:"code,omitempty"`
Error string `json:"error,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
+7
View File
@@ -38,6 +38,7 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error) LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
} }
func NewIAppInstallRepo() IAppInstallRepo { func NewIAppInstallRepo() IAppInstallRepo {
@@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err return install, err
} }
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error { func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{}) db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error return db.Omit(clause.Associations).Create(&install).Error
+29
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error) Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{} return &DatabaseRepo{}
} }
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) { func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database var database model.Database
db := global.DB db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error) List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error return getTx(ctx).Create(mysql).Error
} }
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error { func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
} }
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface { type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error) Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil return user, nil
} }
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) { func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{}) db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error return global.DB.Save(user).Error
} }
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error { func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB db := global.DB
for _, opt := range opts { for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error) Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error return global.DB.Save(grant).Error
} }
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error { func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
+19
View File
@@ -15,10 +15,13 @@ type SettingRepo struct{}
type ISettingRepo interface { type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error) GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
@@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err return settings, err
} }
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) { func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+19 -6
View File
@@ -19,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error) GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask() (int64, error) CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll() error DeleteAll() error
@@ -29,6 +29,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
} }
func NewITaskRepo() ITaskRepo { func NewITaskRepo() ITaskRepo {
@@ -110,18 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error return getTaskTx(ctx).Save(&task).Error
} }
func (t TaskRepo) UpdateRunningTaskToFailed() error { func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{ opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(), "status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error }).Error
} }
func (t TaskRepo) CountExecutingTask() (int64, error) { func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64 var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err return count, err
} }
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error { func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB db := global.TaskDB
for _, opt := range opts { for _, opt := range opts {
+5
View File
@@ -88,6 +88,7 @@ type IAgentService interface {
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error) GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error) GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
@@ -1591,6 +1592,10 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err != nil { if err != nil {
return err return err
} }
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil { if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
return err return err
} }
+38 -3
View File
@@ -45,9 +45,29 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
if err != nil { if err != nil {
return nil, err return nil, err
} }
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
result := extractFeishuConfig(conf)
result.Installed, _ = checkPluginInstalled(install.GetPath(), "feishu")
return &result, nil
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return nil, err
}
result := extractFeishuConfig(conf) result := extractFeishuConfig(conf)
installed, _ := checkPluginInstalled(install.GetPath(), "feishu") if !legacy {
result.Installed = installed result = extractBundledFeishuConfig(conf)
}
result.BuiltinManaged = true
result.BuiltinAvailable = hasBundledFeishu(install.ContainerName)
result.Installed = result.BuiltinAvailable
result.Legacy, result.LegacyConfigured = legacy, configured
if legacy {
result.GroupSessionScope = "group"
if result.ThreadSession {
result.GroupSessionScope = "group_topic"
}
}
return &result, nil return &result, nil
} }
@@ -72,6 +92,9 @@ func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) err
}) })
}) })
} }
if agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return saveBundledFeishuConfig(agent, install, req)
}
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
config := dto.AgentFeishuConfig{ config := dto.AgentFeishuConfig{
Enabled: req.Enabled, Enabled: req.Enabled,
@@ -358,6 +381,9 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -403,6 +429,9 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -443,6 +472,9 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -538,10 +570,13 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
} }
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) { func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID) agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return &dto.AgentPluginStatus{Installed: hasBundledFeishu(install.ContainerName)}, nil
}
installed, err := checkPluginInstalled(install.GetPath(), req.Type) installed, err := checkPluginInstalled(install.GetPath(), req.Type)
if err != nil { if err != nil {
return nil, err return nil, err
+677
View File
@@ -0,0 +1,677 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
)
// Keep panel configuration writes out of the backup/activation/recovery window.
var feishuSwitches sync.Map
func isOpenclawBundledFeishuVersion(version string) bool {
return common.CompareAppVersion(version, "2026.10.7")
}
func hasBundledFeishu(container string) bool {
image, err := feishuContainerImage(container)
if err != nil {
return false
}
_, err = bundledFeishuImage(image)
return err == nil
}
func usesBundledFeishu(conf map[string]interface{}) bool {
f := getChannelConfig(conf, "feishu")
for _, key := range []string{"threadSession", "replyMode"} {
if _, old := f[key]; old {
return false
}
}
if _, old := f["streaming"].(bool); old {
return false
}
entries := childMap(childMap(conf, "plugins"), "entries")
if extractBoolValue(childMap(entries, "openclaw-lark")["enabled"], false) {
return false
}
_, streaming := f["streaming"].(map[string]interface{})
_, selected := entries["feishu"]
return selected && (extractBoolValue(childMap(entries, "feishu")["enabled"], false) || f["groupSessionScope"] != nil || streaming)
}
func hasLegacyFeishu(conf map[string]interface{}, installPath string) bool {
legacy, _, err := classifyLegacyFeishu(conf, installPath)
return err != nil || legacy
}
func selectBundledFeishu(conf map[string]interface{}, enabled bool) {
plugins := ensureChildMap(conf, "plugins")
entries := ensureChildMap(plugins, "entries")
if legacy, ok := entries["openclaw-lark"]; ok {
if entry, ok := legacy.(map[string]interface{}); ok {
entry["enabled"] = false
}
}
ensureChildMap(entries, "feishu")["enabled"] = enabled
// Preserve an absent allowlist; creating one would disable unrelated plugins.
if allow, ok := plugins["allow"]; ok && len(extractStringList(allow)) > 0 {
ids := slices.DeleteFunc(extractStringList(allow), func(id string) bool { return id == "openclaw-lark" })
if !slices.Contains(ids, "feishu") {
ids = append(ids, "feishu")
}
plugins["allow"] = ids
}
if deny, ok := plugins["deny"]; ok {
plugins["deny"] = slices.DeleteFunc(extractStringList(deny), func(id string) bool { return id == "feishu" })
}
}
func feishuConfigError(field string) error { return buserr.WithDetail("ErrFeishuConfig", field, nil) }
func convertLegacyFeishu(conf map[string]interface{}, req dto.AgentFeishuSwitchReq) error {
f := getChannelConfig(conf, "feishu")
if len(f) == 0 {
if raw, exists := childMap(conf, "channels")["feishu"]; exists && raw != nil {
if _, ok := raw.(map[string]interface{}); !ok {
return feishuConfigError("channels.feishu")
}
}
f = ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
f["enabled"], f["dmPolicy"], f["groupPolicy"] = false, "pairing", "allowlist"
}
if err := checkLegacyFeishuFields(f, "channels.feishu", false); err != nil {
return err
}
if err := convertFeishuAccess(f, "channels.feishu"); err != nil {
return err
}
delete(f, "threadSession")
delete(f, "replyMode")
f["streaming"] = map[string]interface{}{"mode": "off"}
if req.Streaming {
f["streaming"] = map[string]interface{}{"mode": "partial"}
}
f["groupSessionScope"] = req.GroupSessionScope
f["requireMention"] = req.RequireMention == "true"
delete(f, "topicSessionMode")
// Preserve legacy defaults explicitly; bundled defaults are more restrictive.
if _, ok := f["groupPolicy"]; !ok {
f["groupPolicy"] = "open"
}
entries := ensureChildMap(ensureChildMap(conf, "plugins"), "entries")
ensureChildMap(entries, "openclaw-lark")["enabled"] = false
selectBundledFeishu(conf, extractBoolValue(f["enabled"], true))
return nil
}
// Limit automatic conversion to the shared channel fields; custom plugin options require review.
func checkLegacyFeishuFields(f map[string]interface{}, field string, group bool) error {
allowed := strings.Fields("enabled name appId appSecret encryptKey verificationToken domain connectionMode webhookPath defaultAccount dmPolicy allowFrom groupPolicy groupAllowFrom groupSenderAllowFrom requireMention accounts groups streaming threadSession replyMode groupSessionScope topicSessionMode replyInThread")
if group {
allowed = strings.Fields("enabled requireMention allowFrom groupSessionScope topicSessionMode replyInThread")
}
for _, key := range sortedChildKeys(f) {
if !slices.Contains(allowed, key) {
return feishuConfigError(field + "." + key)
}
}
if enabled, exists := f["enabled"]; exists {
if _, ok := enabled.(bool); !ok {
return feishuConfigError(field + ".enabled")
}
}
for _, key := range []string{"dmPolicy", "groupPolicy"} {
if raw, exists := f[key]; exists {
value, ok := raw.(string)
options := []string{"open", "allowlist", "disabled"}
if key == "dmPolicy" {
options = append(options, "pairing")
}
if !ok || !slices.Contains(options, value) {
return feishuConfigError(field + "." + key)
}
}
}
for _, key := range []string{"allowFrom", "groupSenderAllowFrom"} {
if raw, exists := f[key]; exists {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + "." + key)
}
for _, value := range values {
if _, ok := value.(string); !ok {
return feishuConfigError(field + "." + key)
}
}
}
}
for _, section := range []string{"accounts", "groups"} {
raw, exists := f[section]
if !exists {
continue
}
entries, ok := raw.(map[string]interface{})
if !ok {
return feishuConfigError(field + "." + section)
}
for _, id := range sortedChildKeys(entries) {
entry, ok := entries[id].(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
if err := checkLegacyFeishuFields(entry, name, section == "groups"); err != nil {
return err
}
}
}
return nil
}
func convertFeishuAccess(f map[string]interface{}, field string) error {
if raw, ok := f["groupAllowFrom"]; ok {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
groups, senders := []string{}, []string{}
for _, value := range values {
id, ok := value.(string)
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
switch {
case strings.HasPrefix(id, "oc_"):
groups = append(groups, id)
case strings.HasPrefix(id, "ou_"):
senders = append(senders, id)
default:
return feishuConfigError(field + ".groupAllowFrom")
}
}
// A sender-only legacy allowlist has different group admission semantics.
if len(senders) > 0 && (len(groups) == 0 || f["groupSenderAllowFrom"] != nil) {
return feishuConfigError(field + ".groupAllowFrom")
}
f["groupAllowFrom"] = groups
if len(senders) > 0 {
f["groupSenderAllowFrom"] = senders
}
}
for _, section := range []string{"accounts", "groups"} {
for id, value := range childMap(f, section) {
entry, ok := value.(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
// Account/group overrides must be resolved explicitly, not flattened.
for _, key := range []string{"threadSession", "replyMode"} {
if _, ok := entry[key]; ok {
return feishuConfigError(name + "." + key)
}
}
if mention, ok := entry["requireMention"]; ok {
if _, ok := mention.(bool); !ok {
return feishuConfigError(name + ".requireMention")
}
}
if raw, exists := entry["streaming"]; exists {
streaming, ok := raw.(bool)
if !ok || streaming {
return feishuConfigError(name + ".streaming")
}
entry["streaming"] = map[string]interface{}{"mode": "off"}
}
if err := convertFeishuAccess(entry, name); err != nil {
return err
}
}
}
return nil
}
func extractBundledFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig {
result := extractFeishuConfig(conf)
f := getChannelConfig(conf, "feishu")
result.Enabled = extractBoolValue(f["enabled"], true) && extractBoolValue(childMap(childMap(childMap(conf, "plugins"), "entries"), "feishu")["enabled"], true)
result.GroupSessionScope = extractStringValue(f["groupSessionScope"])
if result.GroupSessionScope == "" {
result.GroupSessionScope = "group"
}
result.Streaming = extractStringValue(childMap(f, "streaming")["mode"]) != "off"
result.GroupPolicy = extractStringValue(f["groupPolicy"])
if result.GroupPolicy == "" {
result.GroupPolicy = "allowlist"
}
if len(f) == 0 {
result.Streaming = false
result.RequireMention = "true"
} else if _, ok := f["requireMention"]; !ok {
result.RequireMention = "open"
}
accounts := childMap(f, "accounts")
ids := sortedChildKeys(accounts)
if (f["appId"] != nil || f["appSecret"] != nil) && !slices.Contains(ids, "default") {
ids = append([]string{"default"}, ids...)
}
result.Bots = []dto.AgentFeishuBot{}
for _, id := range ids {
effective := make(map[string]interface{}, len(f))
for k, v := range f {
effective[k] = v
}
for k, v := range childMap(accounts, id) {
effective[k] = v
}
bot := defaultFeishuBot()
bot.AccountID, bot.IsDefault = id, id == "default"
bot.Name = extractDisplayName(childMap(accounts, id), "", id)
bot.Enabled = extractBoolValue(childMap(accounts, id)["enabled"], true)
bot.AppID, bot.AppSecret = extractStringValue(effective["appId"]), extractStringValue(effective["appSecret"])
bot.DmPolicy = extractStringValue(effective["dmPolicy"])
if bot.DmPolicy == "" {
bot.DmPolicy = "pairing"
}
bot.AllowFrom = extractStringList(effective["allowFrom"])
result.Bots = append(result.Bots, bot)
}
return result
}
func setBundledFeishuConfig(conf map[string]interface{}, req dto.AgentFeishuConfigUpdateReq) error {
if !slices.Contains([]string{"group", "group_sender", "group_topic", "group_topic_sender"}, req.GroupSessionScope) {
return feishuConfigError("groupSessionScope")
}
f := ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
accounts := childMap(f, "accounts")
seen := map[string]bool{}
appIDs := map[string]bool{}
for _, bot := range req.Bots {
if bot.AccountID == "" || bot.AccountID != strings.ToLower(strings.TrimSpace(bot.AccountID)) || seen[bot.AccountID] || appIDs[bot.AppID] || bot.AppID == "" || !slices.Contains([]string{"pairing", "open", "allowlist", "disabled"}, bot.DmPolicy) || (bot.DmPolicy == "allowlist" && len(bot.AllowFrom) == 0) {
return feishuConfigError("accounts")
}
seen[bot.AccountID] = true
appIDs[bot.AppID] = true
entry := childMap(accounts, bot.AccountID)
if _, exists := entry["appSecret"]; !exists && bot.AppSecret == "" {
entry["appSecret"] = f["appSecret"]
}
// SecretRef credentials cannot be represented by the form: keep them intact.
if _, secretRef := entry["appSecret"].(map[string]interface{}); !secretRef && bot.AppSecret == "" {
return feishuConfigError("accounts." + bot.AccountID + ".appSecret")
}
entry["appId"], entry["name"], entry["enabled"], entry["dmPolicy"] = bot.AppID, bot.Name, bot.Enabled, bot.DmPolicy
if bot.AppSecret != "" {
entry["appSecret"] = bot.AppSecret
}
switch bot.DmPolicy {
case "open":
entry["allowFrom"] = []string{"*"}
case "allowlist":
entry["allowFrom"] = bot.AllowFrom
default:
if len(bot.AllowFrom) > 0 {
entry["allowFrom"] = bot.AllowFrom
} else {
entry["allowFrom"] = []string{}
}
}
accounts[bot.AccountID] = entry
}
for id := range accounts {
if !seen[id] {
delete(accounts, id)
}
}
if !seen["default"] {
delete(f, "appId")
delete(f, "appSecret")
}
if id := extractStringValue(f["defaultAccount"]); id != "" && !seen[id] {
return feishuConfigError("defaultAccount")
}
if len(accounts) > 0 {
f["accounts"] = accounts
} else {
delete(f, "accounts")
}
f["enabled"], f["groupSessionScope"], f["groupPolicy"] = req.Enabled, req.GroupSessionScope, req.GroupPolicy
if req.RequireMention == "open" {
delete(f, "requireMention")
} else {
f["requireMention"] = req.RequireMention == "true"
}
if req.GroupPolicy == "allowlist" {
f["groupAllowFrom"] = req.GroupAllowFrom
} else {
delete(f, "groupAllowFrom")
}
streaming := ensureChildMap(f, "streaming")
streaming["mode"] = "off"
if req.Streaming {
streaming["mode"] = "partial"
}
selectBundledFeishu(conf, req.Enabled && hasEnabledBots(req.Bots))
return nil
}
// Write beside the original so validation and rename preserve the mounted filesystem and owner.
func stageFeishuConfig(configPath string, payload []byte) (string, error) {
info, err := os.Lstat(configPath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", feishuConfigError("openclaw.json")
}
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-*.json")
if err != nil {
return "", err
}
name := f.Name()
defer func() {
f.Close()
if err != nil {
os.Remove(name)
}
}()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
err = f.Chown(int(stat.Uid), int(stat.Gid))
}
if err == nil {
err = f.Chmod(info.Mode().Perm())
}
if err == nil {
_, err = f.Write(payload)
}
if err == nil {
err = f.Sync()
}
if err == nil {
err = f.Close()
}
return name, err
}
func validateFeishuConfig(container, configPath string) error {
out, err := cmd.RunDockerExecWithStdout(time.Minute, container, "env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath), "openclaw", "config", "validate", "--json")
return feishuValidationResult(out, err)
}
func replaceFeishuConfig(configPath string, expected, payload []byte) error {
tmp, err := stageFeishuConfig(configPath, payload)
if err != nil {
return err
}
defer os.Remove(tmp)
current, err := os.ReadFile(configPath)
if err != nil {
return err
}
if !bytes.Equal(current, expected) {
return buserr.New("ErrFeishuConfigChanged")
}
return os.Rename(tmp, configPath)
}
func (a AgentService) SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
started := false
defer func() {
if !started {
feishuSwitches.Delete(agent.ConfigPath)
}
}()
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
if err := prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
switchTask, err := task.NewTaskWithOps("Feishu", task.TaskUpdate, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
switchTask.AddSubTaskWithAlias("AgentFeishuSwitch", func(t *task.Task) error { return a.runFeishuSwitch(agent, install, req, t) }, nil)
started = true
go func() { defer feishuSwitches.Delete(agent.ConfigPath); executeAgentPluginTask(switchTask) }()
return nil
}
func (a AgentService) runFeishuSwitch(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuSwitchReq, t *task.Task) (result error) {
original, conf, err := readFeishuConfigBytes(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
image, err := feishuContainerImage(install.ContainerName)
if err != nil {
return err
}
image, err = bundledFeishuImage(image)
if err != nil {
return err
}
if err = prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
recovery, err := feishuRecoveryPayload(payload, configured)
if err != nil {
return err
}
// Validate both candidates without invoking Doctor or mutating retained state.
for _, candidate := range [][]byte{payload, recovery} {
staged, stageErr := stageFeishuConfig(agent.ConfigPath, candidate)
if stageErr != nil {
return stageErr
}
validateErr := validateFeishuImageConfig(image, staged)
os.Remove(staged)
if validateErr != nil {
return validateErr
}
}
if _, err = compose.Stop(install.GetComposePath()); err != nil {
return err
}
replaced := false
defer func() {
if !replaced {
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
}
return
}
if result == nil {
return
}
if _, stopErr := compose.Stop(install.GetComposePath()); stopErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", stopErr))
return
}
if restoreErr := replaceFeishuConfig(agent.ConfigPath, payload, recovery); restoreErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", restoreErr))
return
}
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
return
}
if healthErr := verifyFeishuActivation(install.ContainerName, false); healthErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", healthErr))
return
}
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}()
current, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
if !bytes.Equal(current, original) {
return buserr.New("ErrFeishuConfigChanged")
}
backup, err := backupFeishuConfig(agent.ConfigPath, original)
if err != nil {
return err
}
t.Log(i18n.GetWithName("AgentFeishuBackup", filepath.Base(backup)))
if err = replaceFeishuConfig(agent.ConfigPath, original, payload); err != nil {
return err
}
replaced = true
if _, err = compose.Operate(install.GetComposePath(), "start"); err != nil {
return err
}
enabled := configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
if err = verifyFeishuActivation(install.ContainerName, enabled); err != nil {
return err
}
if enabled {
probeFeishuConnection(install.ContainerName, t)
}
if configured {
t.Log(i18n.GetMsgByKey("AgentFeishuVerifyMessages"))
} else {
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}
return nil
}
func saveBundledFeishuConfig(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuConfigUpdateReq) error {
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
original, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
var conf map[string]interface{}
if err = json.Unmarshal(original, &conf); err != nil {
return err
}
if hasLegacyFeishu(conf, install.GetPath()) {
return buserr.New("ErrFeishuSwitchRequired")
}
if err = setBundledFeishuConfig(conf, req); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
staged, err := stageFeishuConfig(agent.ConfigPath, payload)
if err != nil {
return err
}
defer os.Remove(staged)
if err = validateFeishuConfig(install.ContainerName, staged); err != nil {
return err
}
return replaceFeishuConfig(agent.ConfigPath, original, payload)
}
func feishuProbeOK(output string) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var status struct {
ChannelAccounts map[string][]struct {
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
Running bool `json:"running"`
Probe struct {
OK bool `json:"ok"`
} `json:"probe"`
} `json:"channelAccounts"`
}
if json.Unmarshal(raw, &status) != nil {
return false
}
accounts, ok := status.ChannelAccounts["feishu"]
if !ok {
return false
}
for _, account := range accounts {
if account.Enabled && (!account.Configured || !account.Running || !account.Probe.OK) {
return false
}
}
return true
}
func feishuGatewayReady(output string, enabled bool) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var health struct {
OK bool `json:"ok"`
Plugins struct {
Loaded []string `json:"loaded"`
} `json:"plugins"`
}
return json.Unmarshal(raw, &health) == nil && health.OK && !slices.Contains(health.Plugins.Loaded, "openclaw-lark") && (!enabled || slices.Contains(health.Plugins.Loaded, "feishu"))
}
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
)
// Inspect a stopped disposable container; its entrypoint never touches retained state.
func bundledFeishuImage(image string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, _, err := cli.ImageInspectWithRaw(ctx, image)
if err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
created, err := cli.ContainerCreate(ctx, &container.Config{Image: info.ID, Entrypoint: []string{"/bin/true"}}, &container.HostConfig{NetworkMode: "none"}, nil, nil, "")
if err != nil {
return "", err
}
defer func() {
cleanup, done := context.WithTimeout(context.Background(), 10*time.Second)
defer done()
_ = cli.ContainerRemove(cleanup, created.ID, container.RemoveOptions{Force: true, RemoveVolumes: true})
}()
if _, err = cli.ContainerStatPath(ctx, created.ID, "/app/.1panel-feishu-bundled-v1"); err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
return info.ID, nil
}
func feishuContainerImage(name string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, name)
if err != nil {
return "", err
}
return info.Image, nil
}
// The original state is read-only and the normal Doctor entrypoint is bypassed.
func validateFeishuImageConfig(image, configPath string) error {
info, err := os.Stat(configPath)
if err != nil {
return err
}
uid, gid := uint32(1000), uint32(1000)
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid, gid = stat.Uid, stat.Gid
}
user := fmt.Sprintf("%d:%d", uid, gid)
tmpfs := fmt.Sprintf("/tmp:rw,mode=0700,uid=%d,gid=%d", uid, gid)
name := "1panel-feishu-" + strings.TrimSuffix(filepath.Base(configPath), ".json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
defer func() { _ = cmd.NewCommandMgr(cmd.WithTimeout(15*time.Second)).Run("docker", "rm", "-f", "-v", name) }()
out, runErr := mgr.RunWithStdout("docker", "run", "--name", name, "--rm", "--network", "none", "--read-only", "--user", user,
"--tmpfs", tmpfs,
"--mount", "type=bind,src="+filepath.Dir(configPath)+",dst=/home/node/.openclaw,readonly",
"--env", "HOME=/home/node", "--env", "TMPDIR=/tmp", "--env", "OPENCLAW_STATE_DIR=/tmp/state",
"--env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath),
"--entrypoint", "sh", image, "-c", "mkdir -m 700 /tmp/state && ln -s /home/node/.openclaw/extensions /tmp/state/extensions && exec node /app/openclaw.mjs config validate --json")
return feishuValidationResult(out, runErr)
}
func feishuValidationResult(out string, runErr error) error {
raw, _ := extractEmbeddedJSON(out)
var result struct {
Valid bool `json:"valid"`
Issues []struct {
Path string `json:"path"`
} `json:"issues"`
}
if json.Unmarshal(raw, &result) == nil && result.Valid && runErr == nil {
return nil
}
fields := []string{}
for _, issue := range result.Issues {
if issue.Path != "" {
fields = append(fields, issue.Path)
}
}
if len(fields) == 0 {
fields = append(fields, "openclaw.json")
}
return feishuConfigError(strings.Join(fields, ", "))
}
func backupFeishuConfig(configPath string, original []byte) (string, error) {
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-backup-*.json")
if err != nil {
return "", err
}
if _, err = f.Write(original); err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return "", err
}
if closeErr != nil {
return "", closeErr
}
return f.Name(), nil
}
// Doctor can spend 94 seconds waiting for the previous container's owner lease.
func waitOpenclawReady(name string) error {
script := `const fs=require('fs');const c=JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));const tls=c.gateway?.tls?.enabled===true;const h=require(tls?'https':'http');function check(p){const r=h.get({hostname:'127.0.0.1',port:c.gateway?.port||18789,path:p,rejectUnauthorized:false,timeout:3000},s=>{s.resume();if(s.statusCode===404&&p==='/readyz')return check('/healthz');process.exitCode=s.statusCode===200?0:1});r.on('timeout',()=>r.destroy());r.on('error',()=>process.exitCode=1)}check('/readyz')`
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
if cmd.RunDockerExec(8*time.Second, name, "node", "-e", script) == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return buserr.New("ErrFeishuActivation")
}
func verifyFeishuActivation(name string, enabled bool) error {
if err := waitOpenclawReady(name); err != nil {
return err
}
output, err := cmd.RunDockerExecWithStdout(time.Minute, name, "openclaw", "plugins", "list", "--json")
if err != nil {
return buserr.New("ErrFeishuActivation")
}
raw, _ := extractEmbeddedJSON(output)
var result struct {
Plugins []struct {
ID string `json:"id"`
Status string `json:"status"`
Origin string `json:"origin"`
} `json:"plugins"`
}
if json.Unmarshal(raw, &result) != nil {
return buserr.New("ErrFeishuActivation")
}
found := false
for _, p := range result.Plugins {
if p.ID == "openclaw-lark" && p.Status != "disabled" {
return buserr.New("ErrFeishuActivation")
}
if p.ID == "feishu" && p.Origin == "bundled" && (p.Status == "loaded" || (!enabled && p.Status == "disabled")) {
found = true
}
}
if !found {
return buserr.New("ErrFeishuActivation")
}
health, err := cmd.RunDockerExecWithStdout(20*time.Second, name, "openclaw", "gateway", "health", "--json")
if err != nil || !feishuGatewayReady(health, enabled) {
return buserr.New("ErrFeishuActivation")
}
return nil
}
func readFeishuConfigBytes(file string) ([]byte, map[string]interface{}, error) {
info, err := os.Lstat(file)
if err != nil {
return nil, nil, err
}
if !info.Mode().IsRegular() {
return nil, nil, feishuConfigError("openclaw.json")
}
f, err := os.Open(file)
if err != nil {
return nil, nil, err
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return nil, nil, err
}
var conf map[string]interface{}
if err = json.Unmarshal(raw, &conf); err != nil || conf == nil {
return nil, nil, feishuConfigError("openclaw.json")
}
return raw, conf, nil
}
+6
View File
@@ -107,6 +107,9 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (strings.TrimSpace(req.Package) == "@larksuite/openclaw-lark" || strings.TrimSpace(req.Package) == "@openclaw/feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -132,6 +135,9 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (req.PluginID == "openclaw-lark" || req.PluginID == "feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
+4
View File
@@ -148,6 +148,10 @@ func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
} }
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error { func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
if _, busy := feishuSwitches.LoadOrStore(configPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(configPath)
ensureGatewaySecurityDefaults(conf) ensureGatewaySecurityDefaults(conf)
ensureOpenclawUpdateDefaults(conf) ensureOpenclawUpdateDefaults(conf)
payload, err := json.MarshalIndent(conf, "", " ") payload, err := json.MarshalIndent(conf, "", " ")
+3 -2
View File
@@ -59,7 +59,8 @@ type IAppService interface {
} }
type appInstallHooks struct { type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
} }
func NewIAppService() IAppService { func NewIAppService() IAppService {
@@ -614,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} }
} }
if app.Key == "openresty" { if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil { if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err return err
} }
if err = handleOpenrestyFile(appInstall); err != nil { if err = handleOpenrestyFile(appInstall); err != nil {
+26 -10
View File
@@ -305,13 +305,14 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, true) return syncAppInstallStatus(&install, true)
case constant.Upgrade: case constant.Upgrade:
upgradeReq := request.AppInstallUpgrade{ upgradeReq := request.AppInstallUpgrade{
InstallID: install.ID, FeishuTransition: req.FeishuTransition,
DetailID: req.DetailId, InstallID: install.ID,
Backup: req.Backup, DetailID: req.DetailId,
PullImage: req.PullImage, Backup: req.Backup,
DeleteImage: req.DeleteImage, PullImage: req.PullImage,
DockerCompose: req.DockerCompose, DeleteImage: req.DeleteImage,
TaskID: req.TaskID, DockerCompose: req.DockerCompose,
TaskID: req.TaskID,
} }
return upgradeInstall(upgradeReq) return upgradeInstall(upgradeReq)
case constant.Reload: case constant.Reload:
@@ -716,6 +717,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if err != nil { if err != nil {
return versions, err return versions, err
} }
var feishuLegacy, feishuConfigured, feishuChecked bool
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID)) details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
if err != nil { if err != nil {
return versions, err return versions, err
@@ -740,6 +742,18 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage) canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
} }
if canUpgrade { if canUpgrade {
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil {
return versions, readErr
}
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return versions, err
}
feishuChecked = true
}
var newCompose string var newCompose string
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() { if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl) filename := filepath.Base(detail.DownloadUrl)
@@ -774,9 +788,11 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
} }
versions = append(versions, dto.AppVersion{ versions = append(versions, dto.AppVersion{
Version: detail.Version, FeishuLegacy: feishuTarget && feishuLegacy,
DetailId: detail.ID, FeishuConfigured: feishuTarget && feishuConfigured,
DockerCompose: newCompose, Version: detail.Version,
DetailId: detail.ID,
DockerCompose: newCompose,
}) })
} }
} }
+10 -44
View File
@@ -1,53 +1,19 @@
package service package service
import ( import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error { func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
changed := errors.New("MCP website directory changed; inspect the installation before retrying") return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
directory, ok := req.Params["WEBSITE_DIR"].(string) }
if !ok || directory == "" || global.DB == nil {
return changed func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
if !managesOpenrestySiteDirectory(app, detail) {
return nil
} }
return global.DB.Transaction(func(tx *gorm.DB) error { directory, _ := req.Params["WEBSITE_DIR"].(string)
var setting model.Setting return ensureCurrentWebsiteDirectory(directory)
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
} }
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func ensureCurrentWebsiteDirectory(directory string) error {
changed := errors.New("website directory changed; inspect the installation before retrying")
if directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
return err
}
} else if setting.Value == "" {
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
if err != nil {
return err
}
if updated != 1 {
return changed
}
}
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
})
}
+74 -4
View File
@@ -60,10 +60,16 @@ type upgradeFileSnapshot struct {
} }
type appUpgradeContext struct { type appUpgradeContext struct {
req request.AppInstallUpgrade openclawOriginal []byte
original model.AppInstall feishuOriginal []byte
candidate model.AppInstall feishuImage string
detail model.AppDetail feishuOldImage string
feishuEnabled bool
feishuLockPath string
req request.AppInstallUpgrade
original model.AppInstall
candidate model.AppInstall
detail model.AppDetail
phase appUpgradePhase phase appUpgradePhase
stopAttempted bool stopAttempted bool
@@ -102,6 +108,19 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
} }
}() }()
feishuLockPath := ""
if install.App.Key == constant.AppOpenclaw {
feishuLockPath = path.Join(install.GetPath(), "data/conf/openclaw.json")
if _, busy := feishuSwitches.LoadOrStore(feishuLockPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer func() {
if releaseLock {
feishuSwitches.Delete(feishuLockPath)
}
}()
}
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID)) detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil { if err != nil {
return err return err
@@ -129,6 +148,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
return err return err
} }
ctx := &appUpgradeContext{ ctx := &appUpgradeContext{
feishuLockPath: feishuLockPath,
req: req, req: req,
original: install, original: install,
candidate: install, candidate: install,
@@ -222,6 +242,10 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil { if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err return err
} }
u.openclawOriginal, err = os.ReadFile(path.Join(u.stageDir, "data/conf/openclaw.json"))
if err != nil {
return err
}
} }
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{ for _, relativePath := range []string{
@@ -330,6 +354,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if u.original.App.Resource == constant.AppResourceRemote { if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl) go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
} }
if err = u.prepareFeishuUpgrade(); err != nil {
return err
}
u.phase = appUpgradePrepared u.phase = appUpgradePrepared
return nil return nil
} }
@@ -387,6 +414,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} }
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop")) t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped u.phase = appUpgradeStopped
if err := u.checkFeishuUpgradeSource(); err != nil {
return err
}
var err error var err error
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
@@ -454,6 +484,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else { } else {
t.Log("WARNING: no containers found for the upgraded application") t.Log("WARNING: no containers found for the upgraded application")
} }
if err = u.verifyFeishuUpgrade(t); err != nil {
return err
}
u.candidate.Status = constant.StatusRunning u.candidate.Status = constant.StatusRunning
u.candidate.Message = "" u.candidate.Message = ""
@@ -579,8 +612,34 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if out != "" { if out != "" {
err = fmt.Errorf("%s: %w", out, err) err = fmt.Errorf("%s: %w", out, err)
} }
if u.feishuOriginal != nil {
return err
}
rollbackErr = err rollbackErr = err
} }
if u.feishuOriginal != nil {
if u.backupFile == "" {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err := u.restoreManagedFiles(); err != nil {
return err
}
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", "", false); err != nil {
return err
}
if err := pinFeishuRollbackImage(u.original.GetComposePath(), u.feishuOldImage); err != nil {
return err
}
restoredCompose, err := os.ReadFile(u.original.GetComposePath())
if err != nil {
return err
}
u.original.DockerCompose = string(restoredCompose)
if _, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
return err
}
return u.finishRollback()
}
if u.backupFile != "" { if u.backupFile != "" {
_ = u.restoreManagedFiles() _ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil { if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
@@ -602,6 +661,11 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
} }
func (u *appUpgradeContext) finishRollback() error { func (u *appUpgradeContext) finishRollback() error {
if u.feishuOriginal != nil {
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
return err
}
}
restored := u.original restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil { if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err return err
@@ -643,6 +707,12 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
} }
func (u *appUpgradeContext) cleanup() { func (u *appUpgradeContext) cleanup() {
if u.feishuLockPath != "" {
feishuSwitches.Delete(u.feishuLockPath)
}
if u.feishuOriginal != nil && u.rollbackErr != nil {
return
}
if u.snapshot != nil { if u.snapshot != nil {
u.snapshot.Cleanup() u.snapshot.Cleanup()
} }
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"gopkg.in/yaml.v3"
)
func classifyLegacyFeishu(conf map[string]interface{}, installPath string) (legacy, configured bool, err error) {
for _, field := range []string{"channels", "channels.feishu", "plugins", "plugins.entries", "plugins.entries.openclaw-lark", "plugins.entries.feishu"} {
parts := strings.Split(field, ".")
parent := conf
for _, part := range parts[:len(parts)-1] {
parent = childMap(parent, part)
}
if raw, exists := parent[parts[len(parts)-1]]; exists {
if _, ok := raw.(map[string]interface{}); !ok {
return false, false, feishuConfigError(field)
}
}
}
plugins := childMap(conf, "plugins")
for _, key := range []string{"allow", "deny"} {
if raw, exists := plugins[key]; exists {
if _, ok := raw.([]string); ok {
continue
}
ids, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("plugins." + key)
}
for _, id := range ids {
if _, ok := id.(string); !ok {
return false, false, feishuConfigError("plugins." + key)
}
}
}
}
for _, id := range []string{"openclaw-lark", "feishu"} {
if raw, exists := childMap(childMap(plugins, "entries"), id)["enabled"]; exists {
if _, ok := raw.(bool); !ok {
return false, false, feishuConfigError("plugins.entries." + id + ".enabled")
}
}
}
if usesBundledFeishu(conf) {
return false, false, nil
}
f := getChannelConfig(conf, "feishu")
configured = len(f) > 0
if raw, exists := conf["bindings"]; exists {
bindings, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
for _, raw := range bindings {
binding, ok := raw.(map[string]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
if match, exists := binding["match"]; exists {
if _, ok := match.(map[string]interface{}); !ok {
return false, false, feishuConfigError("bindings.match")
}
}
if extractStringValue(childMap(binding, "match")["channel"]) == "feishu" {
configured = true
}
}
}
entries := childMap(childMap(conf, "plugins"), "entries")
entry, selected := entries["openclaw-lark"]
if selected {
old, ok := entry.(map[string]interface{})
if !ok {
return false, configured, feishuConfigError("plugins.entries.openclaw-lark")
}
// A completed disable-only repair must not repeat on every upgrade.
if enabled, ok := old["enabled"].(bool); ok && !enabled && !configured {
return false, false, nil
}
}
installed, err := checkPluginInstalled(installPath, "feishu")
if err != nil {
return false, configured, err
}
_, oldThread := f["threadSession"]
_, oldReply := f["replyMode"]
_, oldStreaming := f["streaming"].(bool)
return selected || installed || oldThread || oldReply || oldStreaming, configured, nil
}
func prepareLegacyFeishu(conf map[string]interface{}, configured bool, transition *dto.AgentFeishuTransition) error {
if !configured {
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return nil
}
if transition == nil || !transition.Confirmed {
return buserr.New("ErrFeishuUpgradeConfirm")
}
if !validFeishuTransition(transition.GroupSessionScope, transition.RequireMention) {
return feishuConfigError("feishuTransition")
}
return convertLegacyFeishu(conf, dto.AgentFeishuSwitchReq{Streaming: transition.Streaming, GroupSessionScope: transition.GroupSessionScope, RequireMention: transition.RequireMention})
}
func validFeishuTransition(scope, mention string) bool {
return (scope == "group" || scope == "group_sender" || scope == "group_topic" || scope == "group_topic_sender") && (mention == "true" || mention == "false")
}
func feishuRecoveryPayload(candidate []byte, configured bool) ([]byte, error) {
var conf map[string]interface{}
if err := json.Unmarshal(candidate, &conf); err != nil {
return nil, err
}
if configured {
ensureChildMap(ensureChildMap(conf, "channels"), "feishu")["enabled"] = false
selectBundledFeishu(conf, false)
}
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return json.MarshalIndent(conf, "", " ")
}
func (u *appUpgradeContext) prepareFeishuUpgrade() error {
if u.original.App.Key != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(u.candidate.Version) {
return nil
}
source := filepath.Join(u.original.GetPath(), "data/conf/openclaw.json")
staged := filepath.Join(u.stageDir, "data/conf/openclaw.json")
// The staged copy predates protocol migration and is the exact cutover comparison source.
original := u.openclawOriginal
current, err := os.ReadFile(source)
if err != nil {
return err
}
if !bytes.Equal(original, current) {
return buserr.New("ErrFeishuConfigChanged")
}
_, conf, err := readFeishuConfigBytes(staged)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, u.original.GetPath())
if err != nil || !legacy {
return err
}
if !u.req.Backup {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err = prepareLegacyFeishu(conf, configured, u.req.FeishuTransition); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.original.GetPath(), []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
if len(project.Services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, service := range project.Services {
if service.Build != nil {
return buserr.New("ErrFeishuImageRequired")
}
u.feishuImage, err = bundledFeishuImage(service.Image)
if err != nil {
return err
}
configMounted := false
for _, v := range service.Volumes {
if v.Target == "/etc/localtime" {
continue
}
if v.Type != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), v.Source); err != nil {
return err
}
if v.Target == "/home/node/.openclaw" {
configMounted = filepath.Clean(v.Source) == filepath.Dir(source)
}
}
if !configMounted {
return buserr.New("ErrFeishuUpgradeMounts")
}
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, u.original.ContainerName)
if err != nil {
return err
}
u.feishuOldImage = info.Image
for _, m := range info.Mounts {
if m.Destination == "/etc/localtime" {
continue
}
if string(m.Type) != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), m.Source); err != nil {
return err
}
}
// Tar preserves symlinks: an external target would not be in the application backup.
if err = filepath.WalkDir(filepath.Join(u.original.GetPath(), "data"), func(file string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return feishuBackupPath(u.original.GetPath(), file)
}
return nil
}); err != nil {
return err
}
u.feishuOriginal = original
u.feishuEnabled = configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
before, err := os.ReadFile(staged)
if err != nil {
return err
}
if err = replaceFeishuConfig(staged, before, payload); err != nil {
return err
}
targetCompose := filepath.Join(u.stageDir, "docker-compose.yml")
if err = pinFeishuRollbackImage(targetCompose, u.feishuImage); err != nil {
return err
}
pinned, err := os.ReadFile(targetCompose)
if err != nil {
return err
}
u.candidate.DockerCompose = string(pinned)
return nil
}
func feishuBackupPath(root, source string) error {
root, err := filepath.EvalSymlinks(root)
if err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(source)
if err != nil {
return buserr.New("ErrFeishuUpgradeMounts")
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return buserr.New("ErrFeishuUpgradeMounts")
}
return nil
}
func (u *appUpgradeContext) checkFeishuUpgradeSource() error {
if u.feishuOriginal == nil {
return nil
}
current, err := os.ReadFile(filepath.Join(u.original.GetPath(), "data/conf/openclaw.json"))
if err != nil {
return err
}
if !bytes.Equal(current, u.feishuOriginal) {
return buserr.New("ErrFeishuConfigChanged")
}
return nil
}
func (u *appUpgradeContext) verifyFeishuUpgrade(t *task.Task) error {
if u.feishuOriginal == nil {
return nil
}
if err := verifyFeishuActivation(u.candidate.ContainerName, u.feishuEnabled); err != nil {
return err
}
if u.feishuEnabled {
probeFeishuConnection(u.candidate.ContainerName, t)
}
return nil
}
func probeFeishuConnection(name string, t *task.Task) {
output, err := cmd.RunDockerExecWithStdout(30*time.Second, name, "openclaw", "channels", "status", "--channel", "feishu", "--probe", "--json")
if err != nil || !feishuProbeOK(output) {
t.Log(i18n.GetMsgByKey("AgentFeishuProbeWarning"))
}
}
// Pin the restored service to the actual old image, even if its registry tag moved.
func pinFeishuRollbackImage(composeFile string, image string) error {
raw, err := os.ReadFile(composeFile)
if err != nil {
return err
}
var conf map[string]interface{}
if err = yaml.Unmarshal(raw, &conf); err != nil {
return err
}
services := childMap(conf, "services")
if len(services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, raw := range services {
service, ok := raw.(map[string]interface{})
if !ok {
return buserr.New("ErrFeishuUpgradeMounts")
}
service["image"] = image
}
payload, err := yaml.Marshal(conf)
if err != nil {
return err
}
return writeUpgradeFile(composeFile, payload, constant.FilePerm)
}
+12 -4
View File
@@ -2033,11 +2033,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
return res, nil return res, nil
} }
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { if managesOpenrestySiteDirectory(app, appDetail) {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+16 -4
View File
@@ -197,7 +197,8 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
return nil return nil
} }
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error { func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
// Validate AI Gateway backups before stopping the app, creating rollback // Validate AI Gateway backups before stopping the app, creating rollback
// backups, or touching its files/database. Keep the validated extraction for // backups, or touching its files/database. Keep the validated extraction for
// the restore so uploaded/encrypted archives are not unpacked twice. // the restore so uploaded/encrypted archives are not unpacked twice.
@@ -246,12 +247,15 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err return err
} }
defer func() { defer func() {
defer os.RemoveAll(tmpPath)
if !startAfter {
return
}
if isRollback { if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath()) _, _ = compose.UpWithoutBuild(install.GetComposePath())
} else { } else {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
} }
_ = os.RemoveAll(tmpPath)
}() }()
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") { if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
@@ -352,8 +356,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
appDir := install.GetPath() appDir := install.GetPath()
backPath := fmt.Sprintf("%s_bak", appDir) backPath := fmt.Sprintf("%s_bak", appDir)
_ = fileOp.Rename(appDir, backPath) if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
_ = fileOp.CreateDir(appDir, constant.DirPerm) return err
}
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
return err
}
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz") deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
t.LogStart(deCompressName) t.LogStart(deCompressName)
@@ -376,6 +384,10 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
_, _ = file.WriteString(mergedEnvContent) _, _ = file.WriteString(mergedEnvContent)
} }
// The upgrade transaction verifies readiness before saving its restored install.
if !startAfter {
return nil
}
backupInstall.ID = install.ID backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId backupInstall.AppId = install.AppId
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err return err
} }
nginxConfFile := GetWebsiteConfigPath(*website) nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil { if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err return err
} }
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+11 -3
View File
@@ -1660,6 +1660,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
} }
func checkImageExist(client *client.Client, imageItem string) bool { func checkImageExist(client *client.Client, imageItem string) bool {
return checkImageExistContext(context.Background(), client, imageItem)
}
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
if client == nil { if client == nil {
var err error var err error
client, err = docker.NewDockerClient() client, err = docker.NewDockerClient()
@@ -1667,7 +1671,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false return false
} }
} }
images, err := client.ImageList(context.Background(), image.ListOptions{}) images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil { if err != nil {
return false return false
} }
@@ -1709,6 +1713,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
} }
func pullImages(task *task.Task, client *client.Client, imageName string) error { func pullImages(task *task.Task, client *client.Client, imageName string) error {
return pullImagesContext(context.Background(), task, client, imageName)
}
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client) dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List() repos, err := imageRepoRepo.List()
if err != nil { if err != nil {
@@ -1716,7 +1724,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
} }
imageRepo := selectImageRepo(imageName, repos) imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth { if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName) return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
} }
options := image.PullOptions{} options := image.PullOptions{}
@@ -1729,7 +1737,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err return err
} }
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options) return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo { func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
+270 -71
View File
@@ -91,81 +91,95 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return nil return nil
} }
const containerUpgradeConcurrency = 2
// Shared by all upgrade requests handled by this agent.
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error { func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {
_ = client.Close() _ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
go func() { taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
defer client.Close() ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error { defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image)) taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull { if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImages(taskItem, client, req.Image); err != nil { if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
if !req.ForcePull { return fmt.Errorf("pull image %s failed: %w", req.Image, err)
return err }
} }
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err) return nil
} }, nil)
var upgradeErrors []error
for _, item := range req.Names {
item := item
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
defer cancel()
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
if lockErr != nil {
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
return lockErr
}
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t, true)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
} }
return nil return nil
}, nil) })
var upgradeErrors []error }
for _, item := range req.Names { taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
item := item return errors.Join(upgradeErrors...)
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error { }, nil)
t.Logf("----------------- %s -----------------", item) // Stage contexts own the deadlines, including a separate recovery budget.
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item)) for _, subTask := range taskItem.SubTasks {
unlock := containerOperationLock.lock(item) subTask.Timeout = 0
defer unlock() }
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil) if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
oldContainer, inspectErr := client.ContainerInspect(ctx, item) return err
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr) }
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil return nil
} }
@@ -192,11 +206,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct { type containerOperationLockEntry struct {
mutex sync.Mutex token chan struct{}
references int references int
} }
func (l *containerOperationMutex) lock(names ...string) func() { func (l *containerOperationMutex) lock(names ...string) func() {
unlock, _ := l.lockContext(context.Background(), names...)
return unlock
}
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
nameSet := make(map[string]struct{}, len(names)) nameSet := make(map[string]struct{}, len(names))
for _, name := range names { for _, name := range names {
if name != "" { if name != "" {
@@ -209,7 +228,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
sort.Strings(orderedNames) sort.Strings(orderedNames)
if len(orderedNames) == 0 { if len(orderedNames) == 0 {
return func() {} return func() {}, nil
} }
l.mutex.Lock() l.mutex.Lock()
@@ -220,7 +239,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames { for _, name := range orderedNames {
entry := l.locks[name] entry := l.locks[name]
if entry == nil { if entry == nil {
entry = &containerOperationLockEntry{} entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry l.locks[name] = entry
} }
entry.references++ entry.references++
@@ -228,12 +247,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
l.mutex.Unlock() l.mutex.Unlock()
for _, entry := range entries { acquired := 0
entry.mutex.Lock() release := func() {
} for index := acquired - 1; index >= 0; index-- {
return func() { <-entries[index].token
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
} }
l.mutex.Lock() l.mutex.Lock()
defer l.mutex.Unlock() defer l.mutex.Unlock()
@@ -245,6 +262,20 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
} }
} }
for _, entry := range entries {
select {
case entry.token <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
} }
type containerNetworkAttachment struct { type containerNetworkAttachment struct {
@@ -272,6 +303,131 @@ func switchContainer(
createNew func() (container.CreateResponse, error), createNew func() (container.CreateResponse, error),
tty bool, tty bool,
logger containerSwitchLogger, logger containerSwitchLogger,
) (cleanupErr error, err error) {
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
containerUpgradeRecoveryTimeout = 5 * time.Minute
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
)
// Reconcile against Docker's actual state: a failed request may already have
// changed names, created a container, or disconnected a network on the daemon.
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
original, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("inspect original %s: %w", old.ID, err)
}
if original.State == nil {
return fmt.Errorf("original container state unavailable")
}
replacement, inspectErr := cli.ContainerInspect(ctx, name)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
}
if inspectErr == nil && replacement.ID != old.ID {
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
}
if newID != "" && replacement.ID != newID {
return fmt.Errorf("replacement ID changed; refusing to remove it")
}
newID = replacement.ID
}
if newID != "" {
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
}
if inspectErr == nil {
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("replacement identity could not be confirmed")
}
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("remove replacement: %w", err)
}
}
}
if strings.TrimPrefix(original.Name, "/") != name {
err = cli.ContainerRename(ctx, old.ID, name)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
if err != nil {
return fmt.Errorf("restore original name: %w", err)
}
}
// Reload after mutations; connect only networks which are actually missing.
original, err = cli.ContainerInspect(ctx, old.ID)
if err != nil {
return err
}
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
wanted := make(map[string]*network.EndpointSettings)
if primary != nil {
for n, endpoint := range primary.EndpointsConfig {
if n != "bridge" {
wanted[n] = endpoint
}
}
}
for n, endpoint := range extras {
if n != "bridge" {
wanted[n] = endpoint
}
}
var reconnectErr error
for n, endpoint := range wanted {
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
continue
}
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
}
}
if reconnectErr != nil {
return reconnectErr
}
if old.State != nil && old.State.Running {
if original.State == nil {
return fmt.Errorf("original state unavailable after recovery")
}
if !original.State.Running {
err = restartOriginalContainer(ctx, cli, old.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
if err != nil {
return err
}
}
}
verified, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("verify recovery: %w", err)
}
if strings.TrimPrefix(verified.Name, "/") != name {
return fmt.Errorf("original name was not restored")
}
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
return fmt.Errorf("original container is not running after recovery")
}
for n := range wanted {
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
return fmt.Errorf("network %s was not restored", n)
}
}
return nil
}
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
tty bool, logger containerSwitchLogger, boundedRecovery bool,
) (cleanupErr error, err error) { ) (cleanupErr error, err error) {
if oldContainer.ID == "" { if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty") return nil, fmt.Errorf("original container ID is empty")
@@ -281,9 +437,32 @@ func switchContainer(
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name) return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
} }
recoverUpgrade := func(newID string, cause error) error {
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
if newID != "" {
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
}
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
if ctx.Err() != nil {
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
}
if recoveryErr != nil {
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
if logger != nil {
logger.Log(message)
}
return errors.Join(cause, errors.New(message))
}
return cause
}
if wasRunning { if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil { if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err) logContainerSwitchStep(logger, "ContainerStopOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
}
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID) current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running { if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -298,6 +477,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID) backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil { if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err) logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning { if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr) logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -309,6 +491,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer) disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr) logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil { if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr) return nil, errors.Join(disconnectErr, rollbackErr)
} }
@@ -316,11 +501,17 @@ func switchContainer(
created, createErr := createNew() created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr) logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil { if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr) return nil, errors.Join(createErr, rollbackErr)
} }
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err) logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
@@ -329,6 +520,9 @@ func switchContainer(
if wasRunning { if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil { if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
@@ -336,6 +530,11 @@ func switchContainer(
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
} }
if boundedRecovery {
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
ctx = cleanupCtx
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false}) cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr) logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil return cleanupErr, nil
+11
View File
@@ -5,12 +5,14 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/mysql" "github.com/1Panel-dev/1Panel/agent/utils/mysql"
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client" "github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
@@ -126,6 +128,9 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
} }
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool { func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return false
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -180,6 +185,9 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
} }
func (u *DatabaseService) Create(req dto.DatabaseCreate) error { func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -303,6 +311,9 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
} }
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error { func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
switch req.Type { switch req.Type {
case constant.AppPostgresql: case constant.AppPostgresql:
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{ if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{
+4 -4
View File
@@ -64,7 +64,7 @@ func (u *MongodbService) UpdateDescription(req dto.UpdateDescription) error {
} }
func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) { func (u *MongodbService) Create(ctx context.Context, req dto.MongodbDBCreate) (*model.DatabaseMongodb, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
if !isSupportedMongodbPrivilege(req.Permission) { if !isSupportedMongodbPrivilege(req.Permission) {
@@ -146,7 +146,7 @@ func (u *MongodbService) LoadFromRemote(req dto.MongodbLoadDB) error {
} }
func (u *MongodbService) BindUser(req dto.MongodbBind) error { func (u *MongodbService) BindUser(req dto.MongodbBind) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Database, req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -168,7 +168,7 @@ func (u *MongodbService) BindUser(req dto.MongodbBind) error {
} }
func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error { func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
if cmd.CheckIllegal(req.Database, req.Name, req.Password) { if cmd.CheckIllegal(req.Database, req.Name, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -190,7 +190,7 @@ func (u *MongodbService) ChangePassword(req dto.MongodbPassword) error {
} }
func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error { func (u *MongodbService) ChangeRootPassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if req.From != constant.AppResourceLocal { if req.From != constant.AppResourceLocal {
+82 -70
View File
@@ -433,11 +433,46 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
} }
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
return u.create(ctx, req, false) creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
req = creation.request
cli, version, err := LoadMysqlClientByFrom(req.Database)
if err != nil {
return nil, err
}
defer cli.Close()
if err := cli.Create(creation.createInfo(version)); err != nil {
return nil, err
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range creation.permissionHosts {
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
} }
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) { type mysqlCreation struct {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) { request dto.MysqlDBCreate
dbType string
permissionHosts []string
item model.DatabaseMysql
}
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
if len(req.Username) != 0 && len(req.Password) == 0 { if len(req.Username) != 0 && len(req.Password) == 0 {
@@ -474,13 +509,13 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
return nil, errors.New("cannot set root as user name") return nil, errors.New("cannot set root as user name")
} }
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
defer cli.Close() return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
createInfo := client.CreateInfo{ }
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
req := creation.request
return client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -490,55 +525,16 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
Version: version, Version: version,
Timeout: 300, Timeout: 300,
} }
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err
}
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range permissionHosts {
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
} }
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error { func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, constant.DB, tx)
if req.Username != "" { if req.Username != "" {
key := global.CONF.Base.EncryptKey key := global.CONF.Base.EncryptKey
if key == "" { if key == "" {
var setting model.Setting setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { if err != nil {
return err return err
} }
key = setting.Value key = setting.Value
@@ -548,8 +544,7 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
return err return err
} }
for _, host := range hosts { for _, host := range hosts {
var user model.DatabaseUser user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -558,16 +553,16 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
if req.Description != "" { if req.Description != "" {
user.Description = req.Description user.Description = req.Description
} }
if err := tx.Save(&user).Error; err != nil { if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
return err return err
} }
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host} grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil { if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
return err return err
} }
} }
} }
return tx.Create(item).Error return mysqlRepo.CreateMetadata(txCtx, item)
}) })
} }
@@ -680,7 +675,7 @@ func (u *MysqlService) ListGrantSummary(req dto.MysqlGrantSummarySearch) (map[st
} }
func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error { func (u *MysqlService) CreateUser(req dto.MysqlUserCreate) error {
if cmd.CheckIllegal(req.Username, req.Password, req.Host) { if cmd.CheckIllegal(req.Username, req.Password, req.Host) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
dbs := make([]string, 0, len(req.DBs)) dbs := make([]string, 0, len(req.DBs))
@@ -883,7 +878,7 @@ func (u *MysqlService) UpdateUser(req dto.MysqlUserUpdate) error {
} }
func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error { func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
if cmd.CheckIllegal(req.Username, req.Host, req.Password) { if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if err := checkMysqlNormalUser(req.Username); err != nil { if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -936,7 +931,7 @@ func (u *MysqlService) ChangeUserPassword(req dto.MysqlUserPassword) error {
} }
func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error { func (u *MysqlService) SaveUserPassword(req dto.MysqlUserPassword) error {
if cmd.CheckIllegal(req.Username, req.Host, req.Password) { if cmd.CheckIllegal(req.Username, req.Host, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
if err := checkMysqlNormalUser(req.Username); err != nil { if err := checkMysqlNormalUser(req.Username); err != nil {
@@ -1302,7 +1297,7 @@ func isMysqlDatabaseResourceInUseError(err error) bool {
} }
func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error { func (u *MysqlService) ChangePassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
cli, version, err := LoadMysqlClientByFrom(req.Database) cli, version, err := LoadMysqlClientByFrom(req.Database)
@@ -1603,10 +1598,34 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
} }
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false) databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
return newMysqlClientForDatabase(databaseItem, database)
} }
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) { func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
return nil, "", errors.New("an installed local MySQL instance is required")
}
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
if err != nil {
return nil, "", err
}
local, ok := cli.(*client.Local)
if !ok {
cli.Close()
return nil, "", errors.New("an installed local MySQL instance is required")
}
return local, version, nil
}
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1614,13 +1633,6 @@ func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClien
) )
dbInfo.Timeout = 300 dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+27 -1
View File
@@ -16,9 +16,35 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate)
} }
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel() defer cancel()
created, err := u.create(writeContext, req, true) created, err := createMCPMysqlDatabase(writeContext, req)
if err != nil { if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
} }
return created, nil return created, nil
} }
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
if err != nil {
return nil, err
}
defer local.Close()
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
cancel()
if err != nil {
return nil, err
}
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
+3 -3
View File
@@ -87,7 +87,7 @@ func (u *PostgresqlService) ListDBOption() ([]dto.PostgresqlOption, error) {
} }
func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error { func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
if cmd.CheckIllegal(req.Name, req.Username, req.Password) { if cmd.CheckIllegal(req.Name, req.Username, req.Password) || strings.Contains(req.Password, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name)) dbItem, err := postgresqlRepo.Get(postgresqlRepo.WithByPostgresqlName(req.Database), repo.WithByName(req.Name))
@@ -123,7 +123,7 @@ func (u *PostgresqlService) BindUser(req dto.PostgresqlBindUser) error {
} }
func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) { func (u *PostgresqlService) Create(ctx context.Context, req dto.PostgresqlDBCreate) (*model.DatabasePostgresql, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
@@ -366,7 +366,7 @@ func (u *PostgresqlService) ChangePrivileges(req dto.PostgresqlPrivileges) error
} }
func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error { func (u *PostgresqlService) ChangePassword(req dto.ChangeDBInfo) error {
if cmd.CheckIllegal(req.Value) { if cmd.CheckIllegal(req.Value) || strings.Contains(req.Value, " ") {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
cli, err := LoadPostgresqlClientByFrom(req.Database) cli, err := LoadPostgresqlClientByFrom(req.Database)
+10
View File
@@ -0,0 +1,10 @@
package service
import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
)
func ConfigurePanelMCPTasks() error {
return taskscope.Configure(taskscope.Rule{Scope: constant.TaskScopePanelMCP, Restricted: true, ExternalExecutor: true})
}
+821
View File
@@ -0,0 +1,821 @@
package service
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
"github.com/docker/docker/api/types/container"
"github.com/google/uuid"
"golang.org/x/net/idna"
"gorm.io/gorm"
)
const (
mcpWebsitePrivateDir = ".1panel-mcp"
mcpWebsiteProxyName = "proxy.conf"
mcpWebsiteOwnerName = "binding.json"
)
var mcpWebsiteMutation sync.Mutex
func init() {
configureWebsiteManagedConfig(websiteManagedConfigPolicy{
protectedDirectories: func() []string {
return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)}
},
backupIncludes: mcpWebsiteBackupIncludes,
})
}
type MCPWebsiteBindingService struct {
createWebsite func(request.WebsiteCreate) error
updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error
applyInclude func(model.Website) error
removeInclude func(model.Website) error
checkReload func() error
openresty func() (model.AppInstall, error)
now func() time.Time
}
func NewMCPWebsiteBindingService() *MCPWebsiteBindingService {
s := &MCPWebsiteBindingService{
createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) },
updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error {
if req.Enable {
current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID)
if err != nil {
return err
}
defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID)
if current.Enable {
req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm
req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3
req.HttpConfig = current.HttpConfig
} else {
defaults.WebsiteID = req.WebsiteID
req = defaults
}
}
_, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req)
return err
},
checkReload: func() error {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
if installed.Status != constant.StatusRunning {
return nil
}
if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil {
return err
}
return opNginx(installed.ContainerName, constant.NginxReload)
},
openresty: mcpWebsiteOpenresty,
now: time.Now,
}
s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) }
s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) }
return s
}
func mcpWebsiteOpenresty() (model.AppInstall, error) {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return installed, err
}
client, err := docker.NewDockerClient()
if err != nil {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
inspected, err := client.ContainerInspect(ctx, installed.ContainerName)
if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
return installed, nil
}
func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return false
}
for _, mount := range mounts {
if filepath.Clean(mount.Destination) != "/www" {
continue
}
resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source)
if sourceErr == nil && resolvedSource == resolvedRoot {
return true
}
}
return false
}
type mcpWebsiteOwner struct {
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
KeyHash string `json:"keyHash"`
BindHash string `json:"bindHash"`
ProxyHash string `json:"proxyHash"`
RequestID string `json:"requestId"`
RequestHash string `json:"requestHash"`
Operation string `json:"operation"`
}
func mcpWebsiteHash(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}
func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID }
func mcpWebsitePrivatePath(site model.Website, name string) string {
return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name)
}
func mcpWebsiteInclude(site model.Website) string {
return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName
}
func mcpWebsiteBackupIncludes(site model.Website) []string {
bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-")
if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) {
return nil
}
return []string{mcpWebsiteInclude(site)}
}
func mcpWebsitePrivateDirectory(site model.Website, create bool) error {
for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} {
info, err := os.Lstat(directory)
if os.IsNotExist(err) && create {
if err = os.Mkdir(directory, 0700); err != nil {
return err
}
info, err = os.Lstat(directory)
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
return errors.New("invalid MCP website private directory")
}
}
return nil
}
func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error {
configPath := GetSitePath(site, SiteConf)
old, err := os.ReadFile(configPath)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(old)).Parse()
if err != nil || len(config.FindServers()) == 0 {
return errors.New("invalid website configuration")
}
server := config.FindServers()[0]
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
server.RemoveDirective("include", parameters)
}
}
if enabled {
server.UpdateDirective("include", []string{include})
}
updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
if string(updated) == string(old) {
return nil
}
if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil {
return err
}
if err = s.checkReload(); err != nil {
_ = mcpWebsiteAtomicWrite(configPath, old)
_ = s.checkReload()
return err
}
return nil
}
func mcpWebsiteRemark(req request.MCPWebsiteBinding) string {
return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey)
}
func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding {
code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable"
if definitive {
code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration"
}
if conflict {
code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed"
}
return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive}
}
func validMCPWebsiteID(value string) bool {
parsed, err := uuid.Parse(value)
return err == nil && parsed != uuid.Nil && parsed.String() == value
}
func mcpWebsiteDomain(value string) (string, int, error) {
if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") {
return "", 0, errors.New("invalid domain")
}
host, port := value, 0
if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil {
host = parsedHost
port, err = strconv.Atoi(parsedPort)
if err != nil || port < 1 || port > 65535 {
return "", 0, errors.New("invalid port")
}
} else if strings.ContainsAny(value, ":[]") {
return "", 0, errors.New("invalid domain")
}
host, err := idna.Lookup.ToASCII(strings.ToLower(host))
if err != nil || len(host) > 253 || net.ParseIP(host) != nil {
return "", 0, errors.New("invalid domain")
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", 0, errors.New("invalid domain")
}
for _, char := range label {
if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return "", 0, errors.New("invalid domain")
}
}
}
return host, port, nil
}
// Core can select a locally bound panel IP, but an external upstream is never
// accepted even on this private API. Host/SNI is a literal hostname, not Nginx code.
func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool {
if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") {
return false
}
u, err := url.Parse(req.Upstream)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" {
return false
}
ip := net.ParseIP(u.Hostname())
if ip == nil || ip.IsUnspecified() {
return false
}
port, err := strconv.Atoi(u.Port())
if err != nil || port < 1 || port > 65535 {
return false
}
local := ip.IsLoopback()
if !local {
addresses, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, address := range addresses {
if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) {
local = true
break
}
}
}
if !local || len(req.ProxyKey) != 64 {
return false
}
if _, err = hex.DecodeString(req.ProxyKey); err != nil {
return false
}
if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") {
return false
}
host := req.CoreHost
if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil {
host = parsed
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.Trim(host, "[]")
}
if net.ParseIP(host) == nil {
if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil {
return false
}
}
return true
}
func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte {
host := req.CoreHost
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
host = strings.Trim(host, "[]")
var tlsDirectives string
if strings.HasPrefix(req.Upstream, "https://") {
tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n"
}
return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" +
"location = /mcp {\n" +
" proxy_pass " + req.Upstream + ";\n" +
" proxy_http_version 1.1;\n" +
" proxy_set_header Host " + req.CoreHost + ";\n" +
" proxy_set_header Connection \"\";\n" +
" proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" +
" proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" +
" proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" +
" proxy_set_header X-Real-IP $http_x_real_ip;\n" +
" proxy_set_header Forwarded $http_forwarded;\n" +
" proxy_set_header Authorization $http_authorization;\n" +
" proxy_set_header Origin $http_origin;\n" +
" proxy_set_header Accept $http_accept;\n" +
" proxy_set_header Content-Type $http_content_type;\n" +
" proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" +
" proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" +
" proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" +
" proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n")
}
func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool {
certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id))
if err != nil {
return false
}
pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey))
if err != nil || len(pair.Certificate) == 0 {
return false
}
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil {
return false
}
if len(leaf.ExtKeyUsage) == 0 {
return true
}
for _, usage := range leaf.ExtKeyUsage {
if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny {
return true
}
}
return false
}
func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) {
var owner mcpWebsiteOwner
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return owner, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 {
return owner, errors.New("invalid MCP website ownership")
}
data, err := os.ReadFile(path)
if err != nil || json.Unmarshal(data, &owner) != nil {
return owner, errors.New("invalid MCP website ownership")
}
return owner, nil
}
func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error {
data, err := json.Marshal(owner)
if err != nil {
return err
}
return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data)
}
func mcpWebsiteAtomicWrite(path string, data []byte) error {
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
return errors.New("managed file is not regular")
} else if err != nil && !os.IsNotExist(err) {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*")
if err != nil {
return err
}
defer os.Remove(temp.Name())
if err = temp.Chmod(0600); err == nil {
_, err = temp.Write(data)
}
if closeErr := temp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(temp.Name(), path)
}
func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) {
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return nil, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is missing or invalid")
}
data, err := os.ReadFile(path)
if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash {
return nil, errors.New("managed proxy was modified")
}
return data, nil
}
func mcpWebsiteReadProxy(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is not a regular file")
}
return os.ReadFile(path)
}
func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID))
}
func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID))
}
func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
return false
}
if port == 0 {
installed, installErr := s.openresty()
if installErr != nil {
return false
}
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == port {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) {
if req.WebsiteID > 0 {
return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
}
return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
}
func mcpWebsiteProxyIncluded(site model.Website) bool {
server, err := getServer(site)
if err != nil {
return false
}
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding {
result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID,
Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status}
if !result.EnableSSL {
result.SSLID = 0
}
if owner.Operation == "unbind" {
result.State = "unbound"
return result
}
if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil {
result.State, result.Reason = "unavailable", "proxy_modified"
return result
}
if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash {
result.State, result.Reason = "unavailable", "upstream_changed"
return result
}
if site.Status != constant.WebRunning {
result.State, result.Reason = "unavailable", "website_stopped"
return result
}
if !mcpWebsiteProxyIncluded(site) {
result.State, result.Reason = "unavailable", "proxy_not_loaded"
return result
}
host, _, err := mcpWebsiteDomain(site.PrimaryDomain)
if err != nil {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) {
result.State, result.Reason = "unavailable", "certificate_invalid"
return result
}
installed, err := s.openresty()
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
result.State, result.Reason = "unavailable", "openresty_unavailable"
return result
}
matching, matchingSSL := 0, 0
for _, domain := range site.Domains {
if domain.Domain != host || domain.Port < 1 {
continue
}
if matching == 0 || !domain.SSL {
matching = domain.Port
}
if domain.SSL {
matchingSSL = domain.Port
}
}
if matching == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
port := matching
if result.EnableSSL {
port = matchingSSL
if port == 0 {
plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort)
if slices.Contains(plan.httpsPorts, installed.HttpsPort) {
port = installed.HttpsPort
} else if len(plan.httpsPorts) > 0 {
port = plan.httpsPorts[0]
}
}
}
if port == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
scheme := "http"
if result.EnableSSL {
scheme = "https"
}
address := host
if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 {
address = net.JoinHostPort(host, strconv.Itoa(port))
}
result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String()
return result
}
func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) {
mcpWebsiteMutation.Lock()
defer mcpWebsiteMutation.Unlock()
mutationStarted := false
defer func() {
if mutationStarted && result.Error != "" {
result.Definitive = false
}
}()
if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) {
_, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
_, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID))
mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr)
}
if ctx.Err() != nil {
return mcpWebsiteFailure("request_cancelled", true, false)
}
if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" {
return mcpWebsiteFailure("invalid_operation", true, false)
}
if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" {
return response.MCPWebsiteBinding{State: "unbound"}
}
if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) {
return mcpWebsiteFailure("invalid_configuration", true, false)
}
if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) {
return mcpWebsiteFailure("invalid_request", true, false)
}
if req.Operation == "bind" {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
req.Domain = host
if port != 0 {
req.Domain = net.JoinHostPort(host, strconv.Itoa(port))
}
}
site, err := s.load(req)
if errors.Is(err, gorm.ErrRecordNotFound) {
if req.Operation == "status" || req.Operation == "unbind" {
if req.WebsiteID == 0 || req.Operation == "unbind" {
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
}
return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"}
}
if req.Operation != "bind" {
return mcpWebsiteFailure("website_missing", true, true)
}
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
}
group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault())
if groupErr != nil || group.ID == 0 {
return mcpWebsiteFailure("website_group_unavailable", true, false)
}
domain, port, _ := mcpWebsiteDomain(req.Domain)
if port == 0 {
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
if existing, conflictErr := websiteDomainRepo.GetFirst(websiteDomainRepo.WithDomain(domain), websiteDomainRepo.WithPort(port)); conflictErr == nil && existing.ID > 0 {
return mcpWebsiteFailure("domain_conflict", true, true)
} else if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: req.EnableSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
mutationStarted = true
if createErr := s.createWebsite(create); createErr != nil {
return mcpWebsiteFailure("website_create_failed", false, false)
}
site, err = s.load(req)
}
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if site.Alias != mcpWebsiteAlias(req.BindingID) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
owner, ownerErr := mcpWebsiteReadOwner(site)
if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" {
mutationStarted = true
}
if req.Operation == "update" {
if req.Domain != "" && req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
req.Domain = site.PrimaryDomain
host, _, domainErr := mcpWebsiteDomain(req.Domain)
if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
}
if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
return mcpWebsiteFailure("website_directory_changed", true, true)
} else if dirErr != nil && !os.IsNotExist(dirErr) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if ownerErr != nil {
if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "bind" && !s.matchesInitialSite(req, site) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)}
} else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "status" {
return s.status(req, site, owner)
}
operationHash := mcpWebsitePayloadHash(req)
if owner.RequestID == req.RequestID && req.RequestID != "" {
if owner.RequestHash != operationHash {
return mcpWebsiteFailure("operation_request_changed", true, true)
}
if owner.Operation == "unbind" || owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) {
return s.status(req, site, owner)
}
}
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
return mcpWebsiteFailure("proxy_not_loaded", true, true)
}
proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
if err = mcpWebsitePrivateDirectory(site, true); err != nil {
return mcpWebsiteFailure("proxy_directory_changed", true, true)
}
if ownerErr != nil {
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
}
var oldProxy []byte
if owner.ProxyHash != "" && owner.Operation != "unbind" {
oldProxy, err = mcpWebsiteCurrentProxy(site, owner)
if err != nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if req.Operation == "unbind" && os.IsNotExist(readErr) {
oldProxy = nil
} else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) {
oldProxy = actual
} else {
return mcpWebsiteFailure("proxy_modified", true, true)
}
}
} else if _, statErr := os.Lstat(proxyPath); statErr == nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) {
return mcpWebsiteFailure("proxy_already_exists", true, true)
}
oldProxy = actual
} else if !os.IsNotExist(statErr) {
return mcpWebsiteFailure("proxy_unavailable", false, false)
}
if req.Operation == "unbind" {
if mcpWebsiteProxyIncluded(site) {
mutationStarted = true
if err = s.removeInclude(site); err != nil {
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
}
if len(oldProxy) > 0 {
mutationStarted = true
if err = os.Remove(proxyPath); err != nil {
return mcpWebsiteFailure("proxy_remove_failed", false, false)
}
}
owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
if req.Operation == "update" {
if req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
mutationStarted = true
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
return mcpWebsiteFailure("certificate_update_failed", false, false)
}
site, err = s.load(req)
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
}
}
newProxy := mcpWebsiteProxy(req)
mutationStarted = true
if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil {
return mcpWebsiteFailure("proxy_write_failed", false, false)
}
if err = s.applyInclude(site); err != nil {
if len(oldProxy) == 0 {
_ = os.Remove(proxyPath)
} else {
_ = mcpWebsiteAtomicWrite(proxyPath, oldProxy)
}
_ = s.checkReload()
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
owner.ProxyHash = mcpWebsiteHash(string(newProxy))
if req.Operation != "sync" || req.RequestID != "" {
owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation
}
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
+23 -2
View File
@@ -647,7 +647,7 @@ func (r *RuntimeService) Get(id uint) (*response.RuntimeDTO, error) {
return &res, nil return &res, nil
} }
func (r *RuntimeService) Update(req request.RuntimeUpdate) error { func (r *RuntimeService) Update(req request.RuntimeUpdate) (err error) {
runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID)) runtime, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
if err != nil { if err != nil {
return err return err
@@ -656,6 +656,7 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
runtime.Version = req.Version runtime.Version = req.Version
return runtimeRepo.Save(runtime) return runtimeRepo.Save(runtime)
} }
oldRuntime := *runtime
appDetail, err := getRuntimeUpdateAppDetail(runtime, req) appDetail, err := getRuntimeUpdateAppDetail(runtime, req)
if err != nil { if err != nil {
return err return err
@@ -700,6 +701,20 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = downloadApp(app, appDetail, nil, nil); err != nil { if err = downloadApp(app, appDetail, nil, nil); err != nil {
return err return err
} }
var snapshot *runtimeUpdateSnapshot
if runtime.Type != constant.RuntimePHP {
snapshot, err = snapshotRuntimeUpdate(&oldRuntime)
if err != nil {
return err
}
defer func() {
if err != nil {
if restoreErr := snapshot.restoreFiles(); restoreErr != nil {
err = fmt.Errorf("%w; restore runtime configuration: %v", err, restoreErr)
}
}
}()
}
if versionChanged { if versionChanged {
if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil { if err = updateRuntimeVersionFiles(runtime, appVersionDir); err != nil {
return err return err
@@ -773,7 +788,13 @@ func (r *RuntimeService) Update(req request.RuntimeUpdate) error {
if err = runtimeRepo.Save(runtime); err != nil { if err = runtimeRepo.Save(runtime); err != nil {
return err return err
} }
go reCreateRuntime(runtime) go func() {
if err := prepareRuntimeUpdate(runtime, snapshot); err != nil {
global.LOG.Errorf("prepare runtime [%s] update failed: %v", runtime.Name, err)
return
}
reCreateRuntime(runtime)
}()
} }
return nil return nil
} }
+86
View File
@@ -1,6 +1,9 @@
package service package service
import ( import (
"errors"
"fmt"
"os"
"path/filepath" "path/filepath"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
@@ -12,6 +15,89 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Keep the on-disk configuration as well as the model: files may contain edits
// that have not been reflected in the database.
type runtimeUpdateSnapshot struct {
runtime model.Runtime
files []runtimeUpdateFile
}
type runtimeUpdateFile struct {
path string
content []byte
mode os.FileMode
exists bool
}
func snapshotRuntimeUpdate(runtime *model.Runtime) (*runtimeUpdateSnapshot, error) {
snapshot := &runtimeUpdateSnapshot{runtime: *runtime}
for _, name := range []string{"docker-compose.yml", ".env", "run.sh"} {
file := runtimeUpdateFile{path: filepath.Join(runtime.GetPath(), name)}
info, err := os.Stat(file.path)
if errors.Is(err, os.ErrNotExist) {
snapshot.files = append(snapshot.files, file)
continue
}
if err != nil {
return nil, err
}
file.content, err = os.ReadFile(file.path)
if err != nil {
return nil, err
}
file.exists, file.mode = true, info.Mode().Perm()
snapshot.files = append(snapshot.files, file)
}
return snapshot, nil
}
func (s *runtimeUpdateSnapshot) restoreFiles() error {
var result error
for _, file := range s.files {
var err error
if file.exists {
err = os.WriteFile(file.path, file.content, file.mode)
if err == nil {
err = os.Chmod(file.path, file.mode)
}
} else {
err = os.Remove(file.path)
if errors.Is(err, os.ErrNotExist) {
err = nil
}
}
if err != nil {
result = errors.Join(result, fmt.Errorf("restore %s: %w", file.path, err))
}
}
return result
}
// This must finish before reCreateRuntime stops the existing container. A
// failed preparation restores the previous version without touching Docker.
func prepareRuntimeUpdate(runtime *model.Runtime, snapshot *runtimeUpdateSnapshot) error {
if err := pullRuntimeComposeImages(runtime.GetComposePath(), false, nil); err != nil {
result := fmt.Errorf("prepare runtime images: %w", err)
restoreErr := snapshot.restoreFiles()
*runtime = snapshot.runtime
if restoreErr != nil {
result = errors.Join(result, restoreErr)
runtime.Status = constant.StatusError
}
runtime.Message = result.Error()
if saveErr := runtimeRepo.Save(runtime); saveErr != nil {
result = errors.Join(result, fmt.Errorf("restore runtime record: %w", saveErr))
}
logFile, logErr := os.OpenFile(runtime.GetLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, constant.FilePerm)
if logErr == nil {
_, logErr = fmt.Fprintln(logFile, result.Error())
logErr = errors.Join(logErr, logFile.Close())
}
return errors.Join(result, logErr)
}
return nil
}
func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) { func getRuntimeUpdateAppDetail(runtime *model.Runtime, req request.RuntimeUpdate) (model.AppDetail, error) {
current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID)) current, err := appDetailRepo.GetFirst(repo.WithByID(runtime.AppDetailID))
if err != nil { if err != nil {
+26 -6
View File
@@ -8,9 +8,12 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
) )
type TaskLogService struct{} type TaskLogService struct {
grantedScopes []string
}
type ITaskLogService interface { type ITaskLogService interface {
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
@@ -19,13 +22,18 @@ type ITaskLogService interface {
CountExecutingTask() (int64, error) CountExecutingTask() (int64, error)
} }
func NewITaskService() ITaskLogService { func NewITaskService(grantedScopes ...string) ITaskLogService {
return &TaskLogService{} return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
} }
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) { func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, nil, err
}
opts := []repo.DBOption{ opts := []repo.DBOption{
repo.WithOrderDesc("created_at"), repo.WithOrderDesc("created_at"),
taskRepo.WithoutScopes(excluded...),
} }
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
@@ -53,7 +61,11 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
} }
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) { func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
opts := []repo.DBOption{} excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return nil, err
}
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
} else { } else {
@@ -112,9 +124,17 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
} }
func (u *TaskLogService) SyncForRestart() error { func (u *TaskLogService) SyncForRestart() error {
return taskRepo.UpdateRunningTaskToFailed() excluded, err := taskscope.RecoveryExclusions()
if err != nil {
return err
}
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
} }
func (u *TaskLogService) CountExecutingTask() (int64, error) { func (u *TaskLogService) CountExecutingTask() (int64, error) {
return taskRepo.CountExecutingTask() excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, err
}
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
} }
+100
View File
@@ -0,0 +1,100 @@
package service
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
)
type websiteManagedConfigPolicy struct {
protectedDirectories func() []string
backupIncludes func(model.Website) []string
}
var websiteManagedConfigs websiteManagedConfigPolicy
func configureWebsiteManagedConfig(policy websiteManagedConfigPolicy) {
if !policy.configured() || websiteManagedConfigs.configured() {
panic("invalid website managed configuration policy")
}
websiteManagedConfigs = policy
}
func (p websiteManagedConfigPolicy) configured() bool {
return p.protectedDirectories != nil && p.backupIncludes != nil
}
func (p websiteManagedConfigPolicy) pathAllowed(target string) bool {
if !p.configured() {
return false
}
resolved, err := websiteProxyResolvePath(target)
if err != nil {
return false
}
for _, directory := range p.protectedDirectories() {
if directory == "" {
return false
}
root, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
if relative, err := filepath.Rel(root, resolved); err != nil || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return false
}
}
return true
}
func exportWebsiteNginxConfig(site model.Website, source, destination string) error {
return websiteManagedConfigs.exportNginxConfig(site, source, destination)
}
func (p websiteManagedConfigPolicy) exportNginxConfig(site model.Website, source, destination string) error {
if !p.configured() {
return errors.New("website managed configuration policy is unavailable")
}
includes := p.backupIncludes(site)
if len(includes) == 0 {
return files.NewFileOp().CopyFile(source, destination)
}
excluded := make(map[string]struct{}, len(includes))
for _, include := range includes {
if include == "" {
return errors.New("invalid managed website backup include")
}
excluded[include] = struct{}{}
}
content, err := os.ReadFile(source)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(content)).Parse()
if err != nil {
return errors.New("invalid managed website backup configuration")
}
changed := false
for _, server := range config.FindServers() {
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) != 1 {
continue
}
if _, found := excluded[strings.Trim(parameters[0], "\"'")]; found {
server.RemoveDirective("include", parameters)
changed = true
}
}
}
if changed {
content = []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
}
return os.WriteFile(filepath.Join(destination, filepath.Base(source)), content, 0600)
}
+81 -3
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +25,63 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/1Panel-dev/1Panel/agent/utils/re"
) )
func websiteProxyPathAllowed(directory, name string) bool {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return false
}
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
return false
}
for _, extension := range []string{".conf", ".bak"} {
filename := filepath.Join(directory, name+extension)
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
resolved, resolveErr := websiteProxyResolvePath(filename)
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
return false
}
}
return true
}
func websiteProxyDirectoryAllowed(directory string) bool {
resolved, err := websiteProxyResolvePath(directory)
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
}
func websiteProxyPathWithin(target, directory string) bool {
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
relative, err := filepath.Rel(resolvedDirectory, target)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func websiteProxyResolvePath(target string) (string, error) {
absolute, err := filepath.Abs(target)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
return "", err
}
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("invalid proxy symlink")
}
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
if err != nil {
return "", err
}
return filepath.Join(parent, filepath.Base(absolute)), nil
}
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) { func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
switch req.Operate { switch req.Operate {
case "delete": case "delete":
@@ -51,6 +110,9 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
_ = fileOp.CreateDir(includeDir, constant.DirPerm) _ = fileOp.CreateDir(includeDir, constant.DirPerm)
} }
@@ -270,6 +332,9 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !websiteProxyDirectoryAllowed(includeDir) {
return nil, errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
} }
@@ -282,12 +347,16 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
config *components.Config config *components.Config
) )
for _, configFile := range fileList.Items { for _, configFile := range fileList.Items {
extension := filepath.Ext(configFile.Name)
name := strings.TrimSuffix(configFile.Name, extension)
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
continue
}
proxyConfig := request.WebsiteProxyConfig{ proxyConfig := request.WebsiteProxyConfig{
ID: website.ID, ID: website.ID,
} }
parts := strings.Split(configFile.Name, ".") proxyConfig.Name = name
proxyConfig.Name = parts[0] if extension == ".conf" {
if parts[1] == "conf" {
proxyConfig.Enable = true proxyConfig.Enable = true
} else { } else {
proxyConfig.Enable = false proxyConfig.Enable = false
@@ -360,6 +429,9 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
if err != nil { if err != nil {
return err return err
} }
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
return errors.New("invalid proxy path")
}
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name) absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
oldRewriteContent, err = fileOp.GetContent(absolutePath) oldRewriteContent, err = fileOp.GetContent(absolutePath)
@@ -405,6 +477,9 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
@@ -425,6 +500,9 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
+26
View File
@@ -292,10 +292,36 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task) _ = t.taskRepo.Update(context.Background(), task)
} }
// ExecuteQueued registers the task before returning and limits concurrent execution.
// Subtasks must use Timeout = 0 and manage their own operation and recovery deadlines
// so that a slot is held until the action and its cleanup have actually returned.
func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish func()) error {
t.Task.CurrentStep = waitingStep
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
t.Log(i18n.GetMsgByKey(waitingStep))
go func() {
slots <- struct{}{}
defer func() { <-slots }()
defer onFinish()
if err := t.executeRegistered(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
return err return err
} }
return t.executeRegistered()
}
func (t *Task) executeRegistered() error {
var err error var err error
t.Log(i18n.GetWithName("TaskStart", t.Name)) t.Log(i18n.GetWithName("TaskStart", t.Name))
for i := 0; i < len(t.SubTasks); i++ { for i := 0; i < len(t.SubTasks); i++ {
+2
View File
@@ -4,3 +4,5 @@ const (
TaskInstall = "installApp" TaskInstall = "installApp"
TaskCreateWebsite = "createWebsite" TaskCreateWebsite = "createWebsite"
) )
const TaskScopePanelMCP = "PanelMCP"
+1 -1
View File
@@ -7,6 +7,7 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
require ( require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.15.0 github.com/compose-spec/compose-go/v2 v2.15.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/docker/cli v29.9.0+incompatible github.com/docker/cli v29.9.0+incompatible
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -104,7 +105,6 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Delete virtual machine snapshot [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Resolve incompatible configuration before continuing: {{ .detail }}"
ErrFeishuConfigChanged: "Configuration changed during switching. Retry after reloading."
ErrFeishuImageRequired: "Use an image containing bundled Feishu."
ErrFeishuAlreadySwitched: "Feishu has already switched to the built-in plugin."
ErrFeishuSwitchRequired: "Switch to the built-in plugin from the Feishu channel page first."
ErrFeishuManaged: "Manage Feishu from its channel page; the plugin is updated with the image."
ErrFeishuRestore: "Failed to restore Feishu configuration: {{ .err }}"
ErrFeishuActivation: "The built-in Feishu plugin or gateway failed to start."
AgentFeishuSwitch: "Switch Feishu plugin"
AgentFeishuBackup: "Configuration backup: {{ .name }}"
AgentFeishuRestored: "Previous configuration restored."
AgentFeishuProbeWarning: "Feishu connection check failed. Check the network and credentials; the switch has been retained."
AgentFeishuVerifyMessages: "Local activation completed. Verify pairing, DMs, groups, bot routing and streaming in Feishu."
ErrFeishuUpgradeConfirm: "Confirm the Feishu transition options on the upgrade page before retrying."
ErrFeishuUpgradeBackup: "A full application backup is required for the Feishu compatibility upgrade."
ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory."
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Eliminar instantánea de máquina virtual [{{ .snapshot }
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Corrija la configuración incompatible antes de continuar: {{ .detail }}"
ErrFeishuConfigChanged: "La configuración cambió durante la transición. Recargue y reintente."
ErrFeishuImageRequired: "Use una imagen que incluya Feishu."
ErrFeishuAlreadySwitched: "Feishu ya utiliza el complemento integrado."
ErrFeishuSwitchRequired: "Cambie primero al complemento integrado desde la página del canal Feishu."
ErrFeishuManaged: "Gestione Feishu desde su canal; el complemento se actualiza con la imagen."
ErrFeishuRestore: "Error al restaurar la configuración de Feishu: {{ .err }}"
ErrFeishuActivation: "No se pudo iniciar el complemento Feishu integrado o la puerta de enlace."
AgentFeishuSwitch: "Cambiar el complemento Feishu"
AgentFeishuBackup: "Copia de configuración: {{ .name }}"
AgentFeishuRestored: "Se restauró la configuración anterior."
AgentFeishuProbeWarning: "Falló la comprobación de conexión de Feishu. Revise la red y las credenciales; se conservó el cambio."
AgentFeishuVerifyMessages: "Activación local completada. Verifique el emparejamiento, chats, grupos, rutas de bots y transmisión en Feishu."
ErrFeishuUpgradeConfirm: "Confirme las opciones de transición de Feishu en la página de actualización."
ErrFeishuUpgradeBackup: "La actualización de compatibilidad de Feishu requiere una copia completa de la aplicación."
ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación."
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -778,3 +778,28 @@ VMSnapshotDeleteStep: "حذف اسنپ‌شات ماشین مجازی [{{ .snaps
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "پیش از ادامه تنظیمات ناسازگار را اصلاح کنید: {{ .detail }}"
ErrFeishuConfigChanged: "تنظیمات هنگام تغییر عوض شد. صفحه را تازه و دوباره تلاش کنید."
ErrFeishuImageRequired: "از تصویری با Feishu داخلی استفاده کنید."
ErrFeishuAlreadySwitched: "Feishu قبلاً به افزونه داخلی تغییر کرده است."
ErrFeishuSwitchRequired: "ابتدا در صفحه کانال Feishu به افزونه داخلی تغییر دهید."
ErrFeishuManaged: "Feishu را از صفحه کانال مدیریت کنید؛ افزونه همراه تصویر به‌روز می‌شود."
ErrFeishuRestore: "بازیابی تنظیمات Feishu ناموفق بود: {{ .err }}"
ErrFeishuActivation: "راه‌اندازی افزونه داخلی Feishu یا دروازه ناموفق بود."
AgentFeishuSwitch: "تغییر افزونه Feishu"
AgentFeishuBackup: "پشتیبان تنظیمات: {{ .name }}"
AgentFeishuRestored: "تنظیمات قبلی بازیابی شد."
AgentFeishuProbeWarning: "بررسی اتصال Feishu ناموفق بود. شبکه و اطلاعات ورود را بررسی کنید؛ تغییر حفظ شد."
AgentFeishuVerifyMessages: "فعال‌سازی محلی انجام شد. جفت‌سازی، پیام خصوصی و گروهی، مسیریابی ربات و پاسخ جریانی را در Feishu بررسی کنید."
ErrFeishuUpgradeConfirm: "گزینه‌های انتقال Feishu را در صفحه ارتقا تأیید کنید."
ErrFeishuUpgradeBackup: "ارتقای سازگاری Feishu به پشتیبان کامل برنامه نیاز دارد."
ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کاملاً قابل پشتیبان‌گیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید."
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "仮想マシンのスナップショット [{{ .snapshot
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "続行前に互換性のない設定を修正してください:{{ .detail }}"
ErrFeishuConfigChanged: "切り替え中に設定が変更されました。再読み込みして再試行してください。"
ErrFeishuImageRequired: "Feishu を内蔵したイメージを使用してください。"
ErrFeishuAlreadySwitched: "Feishu は組み込みプラグインに切り替え済みです。"
ErrFeishuSwitchRequired: "先に Feishu チャネル画面で組み込みプラグインに切り替えてください。"
ErrFeishuManaged: "Feishu はチャネル画面で管理してください。プラグインはイメージとともに更新されます。"
ErrFeishuRestore: "Feishu 設定の復元に失敗しました:{{ .err }}"
ErrFeishuActivation: "組み込み Feishu プラグインまたはゲートウェイを起動できませんでした。"
AgentFeishuSwitch: "Feishu プラグインの切り替え"
AgentFeishuBackup: "設定のバックアップ:{{ .name }}"
AgentFeishuRestored: "以前の設定を復元しました。"
AgentFeishuProbeWarning: "Feishu の接続確認に失敗しました。ネットワークと認証情報を確認してください。切り替え結果は保持しています。"
AgentFeishuVerifyMessages: "ローカルの有効化が完了しました。Feishu でペアリング、個別・グループチャット、ボットルーティング、ストリーミングを確認してください。"
ErrFeishuUpgradeConfirm: "更新画面で Feishu の切り替え設定を確認してください。"
ErrFeishuUpgradeBackup: "Feishu 互換更新にはアプリ全体のバックアップが必要です。"
ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。"
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "가상 머신 스냅샷 [{{ .snapshot }}] 삭제"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "계속하기 전에 호환되지 않는 설정을 수정하세요: {{ .detail }}"
ErrFeishuConfigChanged: "전환 중 설정이 변경되었습니다. 새로고침 후 다시 시도하세요."
ErrFeishuImageRequired: "Feishu가 내장된 이미지를 사용하세요."
ErrFeishuAlreadySwitched: "이미 내장 Feishu 플러그인으로 전환했습니다."
ErrFeishuSwitchRequired: "먼저 Feishu 채널 페이지에서 내장 플러그인으로 전환하세요."
ErrFeishuManaged: "Feishu 채널 페이지에서 관리하세요. 플러그인은 이미지와 함께 업데이트됩니다."
ErrFeishuRestore: "Feishu 설정 복구 실패: {{ .err }}"
ErrFeishuActivation: "내장 Feishu 플러그인 또는 게이트웨이 시작에 실패했습니다."
AgentFeishuSwitch: "Feishu 플러그인 전환"
AgentFeishuBackup: "설정 백업: {{ .name }}"
AgentFeishuRestored: "이전 설정을 복구했습니다."
AgentFeishuProbeWarning: "Feishu 연결 확인에 실패했습니다. 네트워크와 자격 증명을 확인하세요. 전환 결과는 유지됩니다."
AgentFeishuVerifyMessages: "로컬 활성화가 완료되었습니다. Feishu에서 페어링, 개인 및 그룹 채팅, 봇 라우팅과 스트리밍을 확인하세요."
ErrFeishuUpgradeConfirm: "업그레이드 화면에서 Feishu 전환 옵션을 확인하세요."
ErrFeishuUpgradeBackup: "Feishu 호환 업그레이드에는 전체 앱 백업이 필요합니다."
ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요."
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -768,3 +768,28 @@ VMSnapshotDeleteStep: "ລຶບສະແນັບຊັອດເຄື່ອງ
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າທີ່ບໍ່ເຂົ້າກັນກ່ອນ: {{ .detail }}"
ErrFeishuConfigChanged: "ການຕັ້ງຄ່າປ່ຽນໃນລະຫວ່າງການສະຫຼັບ. ໂຫຼດໃໝ່ແລ້ວລອງອີກ."
ErrFeishuImageRequired: "ໃຊ້ອິມເມຈທີ່ມີ Feishu ໃນຕົວ."
ErrFeishuAlreadySwitched: "Feishu ໄດ້ສະຫຼັບເປັນປລັກອິນໃນຕົວແລ້ວ."
ErrFeishuSwitchRequired: "ກະລຸນາສະຫຼັບໃນໜ້າຊ່ອງ Feishu ກ່ອນ."
ErrFeishuManaged: "ຈັດການ Feishu ໃນໜ້າຊ່ອງ; ປລັກອິນອັບເດດພ້ອມອິມເມຈ."
ErrFeishuRestore: "ກູ້ຄືນການຕັ້ງຄ່າ Feishu ບໍ່ສຳເລັດ: {{ .err }}"
ErrFeishuActivation: "ປລັກອິນ Feishu ໃນຕົວ ຫຼື ເກດເວເລີ່ມບໍ່ສຳເລັດ."
AgentFeishuSwitch: "ສະຫຼັບປລັກອິນ Feishu"
AgentFeishuBackup: "ສຳຮອງການຕັ້ງຄ່າ: {{ .name }}"
AgentFeishuRestored: "ກູ້ຄືນການຕັ້ງຄ່າເດີມແລ້ວ."
AgentFeishuProbeWarning: "ກວດການເຊື່ອມຕໍ່ Feishu ບໍ່ຜ່ານ. ກວດເຄືອຂ່າຍ ແລະ ຂໍ້ມູນເຂົ້າໃຊ້; ຮັກສາຜົນການສະຫຼັບໄວ້."
AgentFeishuVerifyMessages: "ເປີດໃຊ້ພາຍໃນສຳເລັດ. ກວດການຈັບຄູ່, ແຊັດສ່ວນຕົວ, ກຸ່ມ, ເສັ້ນທາງບອດ ແລະ ສະຕຣີມໃນ Feishu."
ErrFeishuUpgradeConfirm: "ຢືນຢັນຕົວເລືອກການປ່ຽນ Feishu ໃນໜ້າອັບເກຣດ."
ErrFeishuUpgradeBackup: "ການອັບເກຣດ Feishu ຕ້ອງສຳຮອງແອັບທັງໝົດ."
ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ."
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Padam syot kilat mesin maya [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Betulkan konfigurasi tidak serasi sebelum meneruskan: {{ .detail }}"
ErrFeishuConfigChanged: "Konfigurasi berubah semasa pertukaran. Muat semula dan cuba lagi."
ErrFeishuImageRequired: "Gunakan imej yang mengandungi Feishu terbina dalam."
ErrFeishuAlreadySwitched: "Feishu sudah menggunakan pemalam terbina dalam."
ErrFeishuSwitchRequired: "Tukar dahulu kepada pemalam terbina dalam pada halaman saluran Feishu."
ErrFeishuManaged: "Urus Feishu pada halaman salurannya; pemalam dikemas kini bersama imej."
ErrFeishuRestore: "Gagal memulihkan konfigurasi Feishu: {{ .err }}"
ErrFeishuActivation: "Pemalam Feishu terbina dalam atau gerbang gagal dimulakan."
AgentFeishuSwitch: "Tukar pemalam Feishu"
AgentFeishuBackup: "Sandaran konfigurasi: {{ .name }}"
AgentFeishuRestored: "Konfigurasi sebelumnya dipulihkan."
AgentFeishuProbeWarning: "Semakan sambungan Feishu gagal. Semak rangkaian dan kelayakan; pertukaran dikekalkan."
AgentFeishuVerifyMessages: "Pengaktifan setempat selesai. Sahkan pasangan, mesej peribadi, kumpulan, penghalaan bot dan penstriman dalam Feishu."
ErrFeishuUpgradeConfirm: "Sahkan pilihan peralihan Feishu pada halaman naik taraf."
ErrFeishuUpgradeBackup: "Naik taraf keserasian Feishu memerlukan sandaran aplikasi penuh."
ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi."
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Excluir snapshot da máquina virtual [{{ .snapshot }}]"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Corrija a configuração incompatível antes de continuar: {{ .detail }}"
ErrFeishuConfigChanged: "A configuração mudou durante a troca. Recarregue e tente novamente."
ErrFeishuImageRequired: "Use uma imagem com Feishu integrado."
ErrFeishuAlreadySwitched: "O Feishu já usa o plugin integrado."
ErrFeishuSwitchRequired: "Primeiro troque para o plugin integrado na página do canal Feishu."
ErrFeishuManaged: "Gerencie o Feishu pela página do canal; o plugin é atualizado com a imagem."
ErrFeishuRestore: "Falha ao restaurar a configuração do Feishu: {{ .err }}"
ErrFeishuActivation: "Falha ao iniciar o plugin Feishu integrado ou o gateway."
AgentFeishuSwitch: "Trocar plugin Feishu"
AgentFeishuBackup: "Backup da configuração: {{ .name }}"
AgentFeishuRestored: "Configuração anterior restaurada."
AgentFeishuProbeWarning: "Falha na verificação de conexão do Feishu. Verifique a rede e as credenciais; a troca foi mantida."
AgentFeishuVerifyMessages: "Ativação local concluída. Verifique pareamento, conversas, grupos, rotas de bots e streaming no Feishu."
ErrFeishuUpgradeConfirm: "Confirme as opções de transição do Feishu na página de atualização."
ErrFeishuUpgradeBackup: "A atualização de compatibilidade do Feishu exige backup completo do aplicativo."
ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo."
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Удалить снимок виртуальной маш
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Исправьте несовместимые настройки перед продолжением: {{ .detail }}"
ErrFeishuConfigChanged: "Настройки изменились во время переключения. Обновите страницу и повторите."
ErrFeishuImageRequired: "Используйте образ со встроенным Feishu."
ErrFeishuAlreadySwitched: "Feishu уже переключён на встроенный плагин."
ErrFeishuSwitchRequired: "Сначала перейдите на встроенный плагин на странице канала Feishu."
ErrFeishuManaged: "Управляйте Feishu на странице канала; плагин обновляется с образом."
ErrFeishuRestore: "Не удалось восстановить настройки Feishu: {{ .err }}"
ErrFeishuActivation: "Не удалось запустить встроенный плагин Feishu или шлюз."
AgentFeishuSwitch: "Переключить плагин Feishu"
AgentFeishuBackup: "Резервная копия настроек: {{ .name }}"
AgentFeishuRestored: "Прежние настройки восстановлены."
AgentFeishuProbeWarning: "Проверка подключения Feishu не пройдена. Проверьте сеть и учётные данные; переключение сохранено."
AgentFeishuVerifyMessages: "Локальная активация завершена. Проверьте сопряжение, личные и групповые сообщения, маршрутизацию ботов и потоковые ответы в Feishu."
ErrFeishuUpgradeConfirm: "Подтвердите параметры перехода Feishu на странице обновления."
ErrFeishuUpgradeBackup: "Для обновления совместимости Feishu нужна полная резервная копия приложения."
ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения."
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "Sanal makine anlık görüntüsü [{{ .snapshot }}] sil"
ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm." ErrCAUpload: "Upload a ZIP (up to 10 MB) containing a valid self-signed CA certificate ca.crt and its matching private key ca.key, with a supported RSA or EC algorithm."
ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm." ErrCAImport: "Provide a valid self-signed root CA certificate and matching private key (up to 1 MB each), using a supported RSA or EC algorithm."
# Built-in Feishu
ErrFeishuConfig: "Devam etmeden önce uyumsuz ayarları düzeltin: {{ .detail }}"
ErrFeishuConfigChanged: "Geçiş sırasında ayarlar değişti. Yenileyip tekrar deneyin."
ErrFeishuImageRequired: "Yerleşik Feishu içeren bir imaj kullanın."
ErrFeishuAlreadySwitched: "Feishu zaten yerleşik eklentiye geçti."
ErrFeishuSwitchRequired: "Önce Feishu kanal sayfasından yerleşik eklentiye geçin."
ErrFeishuManaged: "Feishu kanal sayfasından yönetin; eklenti imajla güncellenir."
ErrFeishuRestore: "Feishu ayarları geri yüklenemedi: {{ .err }}"
ErrFeishuActivation: "Yerleşik Feishu eklentisi veya ağ geçidi başlatılamadı."
AgentFeishuSwitch: "Feishu eklentisini değiştir"
AgentFeishuBackup: "Ayar yedeği: {{ .name }}"
AgentFeishuRestored: "Önceki ayarlar geri yüklendi."
AgentFeishuProbeWarning: "Feishu bağlantı kontrolü başarısız. Ağı ve kimlik bilgilerini kontrol edin; geçiş korundu."
AgentFeishuVerifyMessages: "Yerel etkinleştirme tamamlandı. Feishu üzerinden eşleştirmeyi, özel ve grup mesajlarını, bot yönlendirmesini ve akışı doğrulayın."
ErrFeishuUpgradeConfirm: "Yükseltme sayfasında Feishu geçiş seçeneklerini onaylayın."
ErrFeishuUpgradeBackup: "Feishu uyumluluk yükseltmesi tam uygulama yedeği gerektirir."
ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın."
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "刪除虛擬機快照 [{{ .snapshot }}]"
ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。" ErrCAUpload: "請上傳包含有效自簽根憑證 ca.crt 及匹配私鑰 ca.key 的 ZIP 檔案(最大 10 MB),金鑰演算法須為支援的 RSA 或 EC。"
ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。" ErrCAImport: "請提供有效的自簽根憑證及匹配私鑰(每個最大 1 MB),金鑰演算法須為支援的 RSA 或 EC。"
# Built-in Feishu
ErrFeishuConfig: "請先處理不相容的設定:{{ .detail }}"
ErrFeishuConfigChanged: "切換期間設定已變更,請重新整理後重試。"
ErrFeishuImageRequired: "請使用包含內建飛書外掛的映像。"
ErrFeishuAlreadySwitched: "飛書已切換至內建外掛。"
ErrFeishuSwitchRequired: "請先在飛書管道頁面切換至內建外掛。"
ErrFeishuManaged: "請在飛書管道頁面管理,外掛隨映像更新。"
ErrFeishuRestore: "還原飛書設定失敗:{{ .err }}"
ErrFeishuActivation: "內建飛書外掛或閘道啟動失敗。"
AgentFeishuSwitch: "切換飛書外掛"
AgentFeishuBackup: "設定備份:{{ .name }}"
AgentFeishuRestored: "已還原切換前的設定。"
AgentFeishuProbeWarning: "飛書連線檢查未通過,請檢查網路和憑證;已保留切換結果。"
AgentFeishuVerifyMessages: "本機啟用完成,請在飛書驗證配對、私聊、群聊、機器人路由和串流回覆。"
ErrFeishuUpgradeConfirm: "請在升級頁面確認飛書切換選項後重試。"
ErrFeishuUpgradeBackup: "飛書相容升級必須開啟完整應用程式備份。"
ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。"
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
+25
View File
@@ -777,3 +777,28 @@ VMSnapshotDeleteStep: "删除虚拟机快照 [{{ .snapshot }}]"
ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。" ErrCAUpload: "请上传包含有效自签根证书 ca.crt 及匹配私钥 ca.key 的 ZIP 文件(最大 10 MB),密钥算法须为支持的 RSA 或 EC。"
ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。" ErrCAImport: "请提供有效的自签根证书及匹配私钥(每个最大 1 MB),密钥算法须为支持的 RSA 或 EC。"
# Built-in Feishu
ErrFeishuConfig: "请先处理不兼容的配置:{{ .detail }}"
ErrFeishuConfigChanged: "切换期间配置已改变,请刷新后重试。"
ErrFeishuImageRequired: "请使用包含内置飞书插件的镜像。"
ErrFeishuAlreadySwitched: "飞书已切换到内置插件。"
ErrFeishuSwitchRequired: "请先在飞书渠道页面切换到内置插件。"
ErrFeishuManaged: "请在飞书渠道页面管理,插件随镜像更新。"
ErrFeishuRestore: "恢复飞书配置失败:{{ .err }}"
ErrFeishuActivation: "内置飞书插件或网关启动失败。"
AgentFeishuSwitch: "切换飞书插件"
AgentFeishuBackup: "配置备份:{{ .name }}"
AgentFeishuRestored: "已恢复切换前的配置。"
AgentFeishuProbeWarning: "飞书连接检查未通过,请检查网络和凭据;已保留切换结果。"
AgentFeishuVerifyMessages: "本地启用完成,请在飞书中验证配对、私聊、群聊、机器人路由和流式回复。"
ErrFeishuUpgradeConfirm: "请在升级页面确认飞书切换选项后重试。"
ErrFeishuUpgradeBackup: "飞书兼容升级必须开启完整应用备份。"
ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。"
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
+63 -1
View File
@@ -2,11 +2,15 @@ package router
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
"net/http" "net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
@@ -17,9 +21,38 @@ import (
func registerMCPRuntime(engine *gin.Engine) { func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New() controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler()) engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
} }
type mcpWebsiteController interface {
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
}
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
if err != nil || len(data) > 16384 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req request.MCPWebsiteBinding
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
}
}
func mcpCapabilitiesHandler() gin.HandlerFunc { func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) { if !mcpapi.TrustedTransport(ctx.Request) {
@@ -32,7 +65,12 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
} }
} }
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc { type mcpDockerController interface {
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
}
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" { if !global.IsMaster || !ok || local.Network() != "unix" {
@@ -51,6 +89,30 @@ func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
ctx.AbortWithStatus(http.StatusBadRequest) ctx.AbortWithStatus(http.StatusBadRequest)
return return
} }
if req.StreamProgress {
if req.Operation != "pull" {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
return
}
ctx.Header("Content-Type", "application/x-ndjson")
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusOK)
encoder := json.NewEncoder(ctx.Writer)
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
return err
}
ctx.Writer.Flush()
return nil
})
result := mcpruntime.PullEvent{Type: "result"}
if err != nil {
result.Error = err.Error()
}
_ = encoder.Encode(result)
ctx.Writer.Flush()
return
}
response, err := controller.Handle(ctx.Request.Context(), req) response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
+6
View File
@@ -2,6 +2,7 @@ package router
import ( import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
@@ -14,6 +15,9 @@ var (
) )
func Routers() *gin.Engine { func Routers() *gin.Engine {
if err := service.ConfigurePanelMCPTasks(); err != nil {
panic(err)
}
Router = gin.Default() Router = gin.Default()
Router.Use(i18n.UseI18n()) Router.Use(i18n.UseI18n())
registerMCPRuntime(Router) registerMCPRuntime(Router)
@@ -23,6 +27,8 @@ func Routers() *gin.Engine {
PrivateGroup.Use(middleware.Certificate()) PrivateGroup.Use(middleware.Certificate())
} }
PrivateGroup.Use(middleware.OperationResolveMeta()) PrivateGroup.Use(middleware.OperationResolveMeta())
PrivateGroup.Use(v2.PanelMCPTaskAccess())
PrivateGroup.Use(v2.MCPBusinessDispatch())
for _, router := range rou.RouterGroupApp { for _, router := range rou.RouterGroupApp {
router.InitRouter(PrivateGroup) router.InitRouter(PrivateGroup)
} }
+1
View File
@@ -91,6 +91,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile) aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig) aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig) aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/switch", baseApi.SwitchAgentFeishuPlugin)
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig) aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig) aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig) aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
+1
View File
@@ -104,6 +104,7 @@ func pullComposeImages(filePath string, forcePull bool, task *task.Task, project
if err != nil { if err != nil {
return err return err
} }
defer dockerCLi.Close()
for _, image := range images { for _, image := range images {
if !forcePull { if !forcePull {
if exist, _ := dockerCLi.ImageExists(image); exist { if exist, _ := dockerCLi.ImageExists(image); exist {
+32 -24
View File
@@ -13,6 +13,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -197,12 +198,22 @@ func setLog(id, newLastLine string, task *task.Task) error {
} }
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error { func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(context.Background(), imageName, options) return c.PullImageWithProcessAndOptionsContext(context.Background(), task, imageName, options)
}
func (c Client) PullImageWithProcessAndOptionsContext(ctx context.Context, task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(ctx, imageName, options)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
return handlePullImageProcess(out, task) stopClose := context.AfterFunc(ctx, func() { _ = out.Close() })
defer stopClose()
err = handlePullImageProcess(out, task)
if ctx.Err() != nil {
return ctx.Err()
}
return err
} }
func handlePullImageProcess(out io.Reader, task *task.Task) error { func handlePullImageProcess(out io.Reader, task *task.Task) error {
@@ -319,11 +330,15 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
} }
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error { func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
return c.PullImageWithProcessContext(context.Background(), task, imageName)
}
func (c Client) PullImageWithProcessContext(ctx context.Context, task *task.Task, imageName string) error {
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
return c.PullImageWithProcessAndOptions(task, imageName, options) return c.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func logProcess(progress map[string]interface{}, task *task.Task) { func logProcess(progress map[string]interface{}, task *task.Task) {
@@ -342,26 +357,31 @@ func PullImage(imageName string) error {
} }
defer cli.Close() defer cli.Close()
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
if _, err := cli.ImagePull(context.Background(), imageName, options); err != nil { out, err := cli.ImagePull(context.Background(), imageName, options)
if err != nil {
return err return err
} }
return nil defer out.Close()
return handlePullImageProcess(out, nil)
} }
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) { func RegistryAuthForImage(imageName string) (string, bool) {
registryHost, hasRegistry := extractRegistryHost(imageName) named, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return "", false
}
registryHost := normalizeRegistryHost(reference.Domain(named))
cfg := config.LoadDefaultConfigFile(io.Discard) cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil { if cfg == nil {
return "", false return "", false
} }
candidates := make([]string, 0) candidates := make([]string, 0)
if hasRegistry { if !isDockerHubRegistry(registryHost) {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost) candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
} } else {
if !hasRegistry || isDockerHubRegistry(registryHost) {
candidates = append(candidates, candidates = append(candidates,
"https://index.docker.io/v1/", "https://index.docker.io/v1/",
"index.docker.io", "index.docker.io",
@@ -411,18 +431,6 @@ func isDockerHubRegistry(host string) bool {
} }
} }
func extractRegistryHost(imageName string) (string, bool) {
parts := strings.Split(imageName, "/")
if len(parts) < 2 {
return "", false
}
first := parts[0]
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
return normalizeRegistryHost(first), true
}
return "", false
}
func normalizeRegistryHost(registryKey string) string { func normalizeRegistryHost(registryKey string) string {
key := strings.TrimSpace(registryKey) key := strings.TrimSpace(registryKey)
if key == "" { if key == "" {
+68 -34
View File
@@ -3,6 +3,7 @@ package mcpruntime
import ( import (
"bytes" "bytes"
"context" "context"
_ "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -15,19 +16,21 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/system"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
) )
const ( const (
ImageRepository = "1panel/1panel-mcp-server" ImageRepository = "1panel/mcp-server"
ContainerName = "1panel-mcp" ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous" previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime" SocketDirectory = "/etc/1panel/mcp-runtime"
@@ -48,12 +51,13 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping") var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct { type Request struct {
Operation string `json:"operation"` Operation string `json:"operation"`
Image string `json:"image"` Image string `json:"image"`
Port int `json:"port"` Port int `json:"port"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Restart bool `json:"restart"` Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"` ImageID string `json:"imageID,omitempty"`
StreamProgress bool `json:"streamProgress,omitempty"`
} }
type State struct { type State struct {
@@ -106,6 +110,7 @@ type Controller struct {
platform string platform string
health func(context.Context, int) (metadata, error) health func(context.Context, int) (metadata, error)
checkDirectory func() error checkDirectory func() error
registryAuth func(string) (string, bool)
mutation chan struct{} mutation chan struct{}
probes chan struct{} probes chan struct{}
cacheMu sync.Mutex cacheMu sync.Mutex
@@ -118,25 +123,40 @@ func New() *Controller {
func newController(factory func() (DockerClient, error)) *Controller { func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth, return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)} checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage,
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
} }
func ValidateImage(value string) error { func ValidateImage(value string) error {
if len(value) > 180 { if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 {
return errors.New("invalid MCP Docker image") return errors.New("invalid MCP Docker image")
} }
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) { parsed, err := reference.ParseNormalizedNamed(value)
if err != nil {
return errors.New("invalid MCP Docker image")
}
if digested, ok := parsed.(reference.Digested); ok {
if digestPattern.MatchString(digested.Digest().String()) {
return nil
}
return errors.New("MCP Docker image must use a SHA256 digest")
}
if _, ok := parsed.(reference.Tagged); ok {
return nil return nil
} }
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) { return errors.New("MCP Docker image must include a tag or SHA256 digest")
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
} }
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) { func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
return c.HandleWithProgress(ctx, req, nil)
}
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
if req.StreamProgress && req.Operation != "pull" {
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
}
switch req.Operation { switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs": case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default: default:
return Response{}, errors.New("invalid MCP Docker operation") return Response{}, errors.New("invalid MCP Docker operation")
} }
@@ -147,7 +167,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
return Response{}, errors.New("MCP listen port must be between 1024 and 65535") return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
} }
timeout := 60 * time.Second timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" { if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if req.Operation == "status" { if req.Operation == "status" {
@@ -176,19 +196,23 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
} }
switch req.Operation { switch req.Operation {
case "prepare": case "prepare":
case "catalog", "candidate_catalog": case "catalog", "apply_catalog", "candidate_catalog":
var img image.InspectResponse var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image) if req.Operation != "candidate_catalog" {
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID { if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog") err = errors.New("MCP installed runtime does not match the configured image")
} }
if err == nil {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
} else {
img, err = c.resolve(ctx, cli, req.Image)
} }
if err == nil { if err == nil {
var verified verifiedImage var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image) verified, err = c.verify(ctx, cli, img.ID)
if err == nil { if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
} }
@@ -204,7 +228,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
case "rollback_absent": case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req) err = c.rollbackAbsent(ctx, cli, req)
case "pull": case "pull":
err = c.pullImage(ctx, cli, req) err = c.pullImage(ctx, cli, req, progress)
case "logs": case "logs":
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
@@ -259,6 +283,20 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
return img, nil return img, nil
} }
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
if current == nil || current.Config.Labels[imageLabel] != ref {
return c.resolve(ctx, cli, ref)
}
if !digestPattern.MatchString(current.Image) {
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
}
img, err := c.resolve(ctx, cli, current.Image)
if err != nil || img.ID != current.Image {
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) { func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name) info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
@@ -279,10 +317,6 @@ func isRunning(info *container.InspectResponse) bool {
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response { func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image} state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName) current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil { if inspectErr != nil {
state.Status = "unhealthy" state.Status = "unhealthy"
@@ -290,9 +324,11 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
if current == nil { if current == nil {
img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
state.Reason = err.Error() state.Reason = err.Error()
} else { } else {
state.ImageID = img.ID
state.Reason = "MCP image is loaded; install it to create the managed container" state.Reason = "MCP image is loaded; install it to create the managed container"
} }
return Response{State: state} return Response{State: state}
@@ -312,12 +348,13 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
state.Reason = "MCP access is disabled" state.Reason = "MCP access is disabled"
return Response{State: state} return Response{State: state}
} }
if err != nil || current.Image != img.ID { img, err := c.resolveActiveImage(ctx, cli, req.Image, current)
if err != nil || current.Config.Labels[imageLabel] != req.Image {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port" state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state} return Response{State: state}
} }
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image) verified, verifyErr := c.verify(ctx, cli, current.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image" state.Reason = "MCP container configuration or metadata does not match the verified image"
@@ -334,7 +371,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) { func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) {
c.cacheMu.Lock() c.cacheMu.Lock()
cached, ok := c.cache[id] cached, ok := c.cache[id]
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -366,9 +403,6 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref strin
c.cache[id] = cached c.cache[id] = cached
c.cacheMu.Unlock() c.cacheMu.Unlock()
} }
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil return cached, nil
} }
+57 -11
View File
@@ -10,49 +10,90 @@ import (
"strings" "strings"
"time" "time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
) )
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error {
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
return err
}
_, err := cli.ImageInspect(ctx, req.Image) _, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) { if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed") return errors.New("MCP Docker image inspection failed")
} }
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
return err
}
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{}) options := image.PullOptions{}
if auth, ok := c.registryAuth(req.Image); ok {
options.RegistryAuth = auth
}
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
if pullErr != nil { if pullErr != nil {
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
defer reader.Close() defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20)) limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1}
decoder := json.NewDecoder(limited)
for { for {
var message struct { var message struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"` Error string `json:"error"`
ErrorDetail *struct { ErrorDetail *struct {
Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} `json:"errorDetail"` } `json:"errorDetail"`
} }
if err = decoder.Decode(&message); err == io.EOF { err = decoder.Decode(&message)
if limited.N == 0 {
return errors.New("MCP Docker image download progress exceeds permitted size")
}
if err == io.EOF {
break break
} else if err != nil { } else if err != nil {
return errors.New("MCP Docker image download was interrupted") return errors.New("MCP Docker image download was interrupted")
} }
if message.Error != "" || message.ErrorDetail != nil { if message.Error != "" || message.ErrorDetail != nil {
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
if err = reportPullProgress(ctx, progress, normalized); err != nil {
return err
}
}
} }
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
return err
}
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
return err
} }
img, err := c.resolve(ctx, cli, req.Image) img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
return err return err
} }
_, err = c.verify(ctx, cli, img.ID, req.Image) _, err = c.verify(ctx, cli, img.ID)
return err if err != nil {
return err
}
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
} }
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
@@ -176,14 +217,19 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
if err = c.checkDirectory(); err != nil { if err = c.checkDirectory(); err != nil {
return err return err
} }
img, err := c.resolve(ctx, cli, req.Image) var img image.InspectResponse
if req.Operation == "install" {
img, err = c.resolve(ctx, cli, req.Image)
} else {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
if err != nil { if err != nil {
return err return err
} }
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation") return errors.New("MCP image identity changed after catalog validation; retry the operation")
} }
verified, err := c.verify(ctx, cli, img.ID, req.Image) verified, err := c.verify(ctx, cli, img.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -354,7 +400,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
} }
return errors.New("the previous MCP runtime instance is unavailable for rollback") return errors.New("the previous MCP runtime instance is unavailable for rollback")
} }
verified, err := c.verify(ctx, cli, target.Image, req.Image) verified, err := c.verify(ctx, cli, target.Image)
if err != nil { if err != nil {
return err return err
} }
@@ -402,7 +448,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
if _, err = c.resolve(ctx, cli, current.Image); err != nil { if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err return err
} }
verified, err := c.verify(ctx, cli, current.Image, req.Image) verified, err := c.verify(ctx, cli, current.Image)
if err != nil { if err != nil {
return err return err
} }
+63
View File
@@ -0,0 +1,63 @@
package mcpruntime
import (
"context"
"errors"
"regexp"
)
type PullProgress struct {
Status string `json:"status,omitempty"`
ID string `json:"id,omitempty"`
Current int64 `json:"current,omitempty"`
Total int64 `json:"total,omitempty"`
}
type PullEvent struct {
Type string `json:"type"`
PullProgress
Error string `json:"error,omitempty"`
}
var pullLayerID = regexp.MustCompile(`^[a-f0-9]{12,64}$`)
func reportPullProgress(ctx context.Context, report func(PullProgress) error, progress PullProgress) error {
if err := ctx.Err(); err != nil {
return err
}
if report != nil && report(progress) != nil {
return errors.New("MCP Docker progress connection closed")
}
return nil
}
func normalizePullProgress(status, id string, current, total int64) (PullProgress, bool) {
var normalized string
switch status {
case "Pulling fs layer":
normalized = "pulling"
case "Waiting":
normalized = "waiting"
case "Downloading":
normalized = "downloading"
case "Verifying Checksum":
normalized = "verifying_checksum"
case "Download complete":
normalized = "download_complete"
case "Extracting":
normalized = "extracting"
case "Pull complete":
normalized = "pull_complete"
case "Already exists":
normalized = "already_exists"
default:
return PullProgress{}, false
}
if !pullLayerID.MatchString(id) {
id = ""
}
if current < 0 || total < 0 || (total > 0 && current > total) {
current, total = 0, 0
}
return PullProgress{Status: normalized, ID: id, Current: current, Total: total}, true
}
-2
View File
@@ -6,7 +6,6 @@ import (
) )
const ( const (
TokenizerDownloadNamePattern = `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}-[0-9a-f]{12}$`
ModelDownloaderRepoIDPattern = `^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$` ModelDownloaderRepoIDPattern = `^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`
VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$` VMNameValidationPattern = `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`
VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$` VMCommonPattern = `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}$`
@@ -68,7 +67,6 @@ var regexMap = make(map[string]*regexp.Regexp)
func Init() { func Init() {
patterns := []string{ patterns := []string{
TokenizerDownloadNamePattern,
ModelDownloaderRepoIDPattern, ModelDownloaderRepoIDPattern,
VMNameValidationPattern, VMNameValidationPattern,
VMCommonPattern, VMCommonPattern,
+98
View File
@@ -0,0 +1,98 @@
// Package taskscope separates task visibility and executor ownership from task storage.
package taskscope
import (
"context"
"errors"
"slices"
"sort"
"strings"
"sync/atomic"
)
type Rule struct {
Scope string
Restricted bool
ExternalExecutor bool
}
type policy struct{ rules []Rule }
var configured atomic.Pointer[policy]
var ErrUnavailable = errors.New("task scope policy is unavailable")
func Configure(rules ...Rule) error {
copyRules := append([]Rule(nil), rules...)
if len(copyRules) == 0 {
return ErrUnavailable
}
sort.Slice(copyRules, func(i, j int) bool { return copyRules[i].Scope < copyRules[j].Scope })
for i, rule := range copyRules {
if rule.Scope == "" || strings.TrimSpace(rule.Scope) != rule.Scope || strings.ContainsAny(rule.Scope, "\x00\r\n") ||
i > 0 && copyRules[i-1].Scope == rule.Scope {
return errors.New("invalid task scope policy")
}
}
next := &policy{rules: copyRules}
if configured.CompareAndSwap(nil, next) {
return nil
}
if !slices.Equal(configured.Load().rules, next.rules) {
return errors.New("task scope policy is already configured")
}
return nil
}
func ReadExclusions(granted ...string) ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
allowed := make(map[string]bool, len(granted))
for _, scope := range granted {
found := false
for _, rule := range p.rules {
if rule.Scope == scope && rule.Restricted {
found = true
break
}
}
if !found || allowed[scope] {
return nil, errors.New("invalid task scope grant")
}
allowed[scope] = true
}
var excluded []string
for _, rule := range p.rules {
if rule.Restricted && !allowed[rule.Scope] {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
func RecoveryExclusions() ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
var excluded []string
for _, rule := range p.rules {
if rule.ExternalExecutor {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
type grantKey struct{}
func WithGrants(ctx context.Context, granted ...string) context.Context {
return context.WithValue(ctx, grantKey{}, append([]string(nil), granted...))
}
func Grants(ctx context.Context) []string {
granted, _ := ctx.Value(grantKey{}).([]string)
return append([]string(nil), granted...)
}
+63 -3
View File
@@ -1,6 +1,7 @@
package v2 package v2
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -32,6 +33,41 @@ func mcpRead(c *gin.Context, value any) bool {
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid() err = contract.Invalid()
} }
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
var fields struct {
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
err = contract.Invalid()
}
case *dto.MCPRuntimeInstall:
var fields struct {
AccessPolicy json.RawMessage `json:"accessPolicy"`
AllowedIPs json.RawMessage `json:"allowedIPs"`
Website *struct {
Domain json.RawMessage `json:"domain"`
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
} `json:"website"`
}
if json.Unmarshal(data, &fields) != nil {
err = contract.Invalid()
} else {
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
if fields.Website != nil {
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
} }
} }
if err != nil { if err != nil {
@@ -179,6 +215,21 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
} }
helper.Success(c) helper.Success(c)
} }
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Delete(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) { func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false) s, owner, ok := mcpManagement(c, false, false)
if !ok { if !ok {
@@ -207,8 +258,17 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) if result.Website == nil {
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
}
result.CanManageService = owner.IsSuperAdmin result.CanManageService = owner.IsSuperAdmin
if owner.IsSuperAdmin {
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
}
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
func (b *BaseApi) MCPSettings(c *gin.Context) { func (b *BaseApi) MCPSettings(c *gin.Context) {
@@ -268,9 +328,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
if !mcpRead(c, &req) { if !mcpRead(c, &req) {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel() defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req) result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req)
if err != nil { if err != nil {
mcpServiceError(c, err) mcpServiceError(c, err)
return return
+10 -1
View File
@@ -55,7 +55,16 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language")) endpoint := mcpPublicEndpoint(c, settings.ExternalURL)
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
if err != nil {
mcpManagementError(c, err)
return
}
if website.State != "unbound" {
endpoint = website.Endpoint
}
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
if err != nil { if err != nil {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
+6
View File
@@ -31,6 +31,12 @@ func mcpManagementMessage(code string) string {
key = "ErrMCPAuditUnavailable" key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE": case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable" key = "ErrMCPEndpointUnavailable"
case "MCP_WEBSITE_PENDING":
key = "ErrMCPWebsitePending"
case "MCP_WEBSITE_UNAVAILABLE":
key = "ErrMCPWebsiteUnavailable"
case "MCP_WEBSITE_SERVICE_REQUIRED":
key = "ErrMCPWebsiteServiceRequired"
} }
return i18n.GetMsgWithMap(key, nil) return i18n.GetMsgWithMap(key, nil)
} }
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"context"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
if _, _, ok := mcpManagement(c, false, true); !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteBind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUpdate
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUnbind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+35 -19
View File
@@ -14,28 +14,44 @@ type MCPRuntimeSettings struct {
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
type MCPRuntimeInstall struct { type MCPRuntimeInstall struct {
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
AccessPolicy string `json:"accessPolicy"`
AllowedIPs []string `json:"allowedIPs"`
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
}
type MCPRuntimeInstallWebsite struct {
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPRuntimeInstallTask struct {
TaskID string `json:"taskID"`
} }
type MCPRuntimeStatus struct { type MCPRuntimeStatus struct {
RuntimeType string `json:"runtimeType"` RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"` ContainerName string `json:"containerName"`
ImageID string `json:"imageId"` ImageID string `json:"imageId"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Status string `json:"status"` Status string `json:"status"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Reason string `json:"reason"` Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"` AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"` ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"` InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"` CanManageService bool `json:"canManageService"`
InstallTaskID string `json:"installTaskID,omitempty"`
Installing bool `json:"installing"`
Website *MCPWebsiteStatus `json:"website,omitempty"`
} }
type MCPRuntimeLogs struct { type MCPRuntimeLogs struct {
+43
View File
@@ -0,0 +1,43 @@
package dto
type MCPWebsiteBind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUpdate struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUnbind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
}
type MCPWebsitePendingRequest struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteStatus struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Reason string `json:"reason,omitempty"`
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
}
+12 -9
View File
@@ -52,13 +52,16 @@ type MCPInvocation struct {
} }
type MCPSetting struct { type MCPSetting struct {
RuntimeType string `gorm:"not null;default:docker"` RuntimeType string `gorm:"not null;default:docker"`
DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"` DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"`
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Enabled bool `gorm:"not null;default:false"` Enabled bool `gorm:"not null;default:false"`
ListenPort int `gorm:"not null;default:18081"` ListenPort int `gorm:"not null;default:18081"`
ExternalURL string ExternalURL string
AccessPolicy string `gorm:"not null;default:inherit"` AccessPolicy string `gorm:"not null;default:inherit"`
AllowedIPs string `gorm:"type:text;not null;default:'[]'"` AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
Revision uint64 `gorm:"not null;default:1"` Revision uint64 `gorm:"not null;default:1"`
WebsiteID uint `gorm:"not null;default:0"`
WebsiteBindingID string `gorm:"not null;default:''"`
WebsiteOperation string `gorm:"type:text;not null;default:''"`
} }
+169 -1
View File
@@ -1,12 +1,89 @@
package repo package repo
import ( import (
"time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
type MCPRepo struct{ DB *gorm.DB } type MCPRepo struct{ DB *gorm.DB }
func mcpOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
}
func (r MCPRepo) Credential(ownerType, ownerID, id string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ?", id).First(&key).Error
return key, err
}
func (r MCPRepo) CredentialByRequest(ownerType, ownerID, requestID string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("request_id = ?", requestID).First(&key).Error
return key, err
}
func (r MCPRepo) SearchCredentials(ownerType, ownerID string, page, pageSize int, excludeRevoked bool) ([]model.MCPCredential, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
keys := []model.MCPCredential{}
err := q.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&keys).Error
return keys, total, err
}
func (r MCPRepo) CountActiveName(ownerType, ownerID, name, excludeID string) (int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeID == "" {
q = q.Where("active_name = ?", name)
} else {
q = q.Where("active_name = ? AND id <> ?", name, excludeID)
}
var count int64
err := q.Count(&count).Error
return count, err
}
func (r MCPRepo) CreateCredential(key *model.MCPCredential) error {
return r.DB.Create(key).Error
}
func (r MCPRepo) UpdateCredential(key *model.MCPCredential, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, revision).
Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(key)
return result.RowsAffected, result.Error
}
func (r MCPRepo) DeleteCredential(ownerType, ownerID, id string, revision uint64) (int64, error) {
result := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ? AND revision = ?", id, revision).Delete(&model.MCPCredential{})
return result.RowsAffected, result.Error
}
func (r MCPRepo) AdmissionCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").Take(&key).Error
return key, err
}
func (r MCPRepo) InvocationCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&key).Error
return key, err
}
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) { func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
var key model.MCPCredential var key model.MCPCredential
err := r.DB.Where("token_hash = ?", hash).First(&key).Error err := r.DB.Where("token_hash = ?", hash).First(&key).Error
@@ -17,8 +94,13 @@ func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
return grants, err return grants, err
} }
func (r MCPRepo) DeleteGrants(id string) error {
return r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error
}
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error { func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil { if err := r.DeleteGrants(id); err != nil {
return err return err
} }
for _, grant := range grants { for _, grant := range grants {
@@ -29,3 +111,89 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
} }
return nil return nil
} }
func (r MCPRepo) Setting() (model.MCPSetting, error) {
var setting model.MCPSetting
err := r.DB.First(&setting, 1).Error
return setting, err
}
func (r MCPRepo) Enabled() (bool, error) {
var setting struct{ Enabled bool }
err := r.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
return setting.Enabled, err
}
func (r MCPRepo) AdmissionEnabled() (bool, error) {
var setting model.MCPSetting
err := r.DB.Select("enabled").Take(&setting, 1).Error
return setting.Enabled, err
}
func (r MCPRepo) PanelSetting(key string) (model.Setting, error) {
var setting model.Setting
err := r.DB.Where("key = ?", key).First(&setting).Error
return setting, err
}
func (r MCPRepo) CreateSettingIfAbsent(setting *model.MCPSetting) error {
return r.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(setting).Error
}
func (r MCPRepo) UpdateRuntimeSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
"runtime_type": setting.RuntimeType, "docker_image": setting.DockerImage,
"enabled": setting.Enabled, "listen_port": setting.ListenPort, "external_url": setting.ExternalURL, "revision": setting.Revision,
"access_policy": setting.AccessPolicy, "allowed_ips": setting.AllowedIPs,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) UpdateWebsiteSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = 1 AND revision = ?", revision).Updates(map[string]any{
"website_id": setting.WebsiteID, "website_binding_id": setting.WebsiteBindingID, "website_operation": setting.WebsiteOperation, "revision": setting.Revision,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) CreateEnrollmentAudit(record *model.OperationLog) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) CreateInvocation(record *model.MCPInvocation) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) SearchInvocations(ownerType, ownerID string, page, pageSize int, credentialID, toolID, status string) ([]model.MCPInvocation, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPInvocation{}), ownerType, ownerID)
if credentialID != "" {
q = q.Where("credential_id = ?", credentialID)
}
if toolID != "" {
q = q.Where("tool_id = ?", toolID)
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r MCPRepo) UpdateRunningInvocation(record model.MCPInvocation) error {
return r.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
}
func (r MCPRepo) UpdateInterruptedInvocations(processStartedAt time.Time, status, code string, finishedAt time.Time) error {
return r.DB.Model(&model.MCPInvocation{}).
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": status, "code": code, "finished_at": finishedAt}).Error
}
+24 -48
View File
@@ -96,12 +96,11 @@ var NewMCPService = func() *MCPService {
defer cancel() defer cancel()
return NewMCPRuntimeService().Catalog(ctx) return NewMCPRuntimeService().Catalog(ctx)
}, Enabled: func() (bool, error) { }, Enabled: func() (bool, error) {
var setting struct{ Enabled bool } enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled()
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
return setting.Enabled, err return enabled, err
}} }}
} }
@@ -149,9 +148,6 @@ func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwn
} }
return owner, nil return owner, nil
} }
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
}
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 } func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
func (s *MCPService) catalog() ([]contract.Tool, error) { func (s *MCPService) catalog() ([]contract.Tool, error) {
unavailable := func() error { unavailable := func() error {
@@ -294,16 +290,8 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch)
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner) keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked)
if req.ExcludeRevoked { if err != nil {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
keys := []model.MCPCredential{}
if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil {
return nil, err return nil, err
} }
items := []dto.MCPCredentialItem{} items := []dto.MCPCredentialItem{}
@@ -344,7 +332,9 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
repeated := false repeated := false
mcpMutationMu.Lock() mcpMutationMu.Lock()
err = s.DB.Transaction(func(tx *gorm.DB) error { err = s.DB.Transaction(func(tx *gorm.DB) error {
err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error store := repo.MCPRepo{DB: tx}
var err error
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
if err == nil { if err == nil {
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used") return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
@@ -355,8 +345,8 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
var count int64 count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "")
if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil { if err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -371,10 +361,10 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if err = s.sealCredentialToken(&key, token); err != nil { if err = s.sealCredentialToken(&key, token); err != nil {
return err return err
} }
if err = tx.Create(&key).Error; err != nil { if err = store.CreateCredential(&key); err != nil {
return err return err
} }
if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil { if err = store.ReplaceTools(key.ID, grants); err != nil {
return err return err
} }
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil) return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
@@ -397,8 +387,9 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
mcpMutationMu.Lock() mcpMutationMu.Lock()
defer mcpMutationMu.Unlock() defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
var key model.MCPCredential store := repo.MCPRepo{DB: tx}
if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { key, err := store.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden() return contract.Forbidden()
} }
@@ -415,11 +406,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
key.SecretVersion = 0 key.SecretVersion = 0
} }
key.Revision++ key.Revision++
result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key) affected, err := store.UpdateCredential(&key, req.Revision)
if result.Error != nil { if err != nil {
return result.Error return err
} }
if result.RowsAffected != 1 { if affected != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
} }
return nil return nil
@@ -434,8 +425,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate)
return err return err
} }
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
var count int64 count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID)
if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil { if err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -628,7 +619,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
record.Revision = key.Revision record.Revision = key.Revision
} }
record.Status = "running" record.Status = "running"
if err := s.DB.Create(&record).Error; err != nil { if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
} }
record.Status = "denied" record.Status = "denied"
@@ -707,10 +698,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
if enabledErr != nil || !enabled { if enabledErr != nil || !enabled {
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil) return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
} }
var admitted model.MCPCredential admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision)
admissionErr := s.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error
if errors.Is(admissionErr, gorm.ErrRecordNotFound) { if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
return finish(contract.Forbidden(), nil) return finish(contract.Forbidden(), nil)
} }
@@ -753,25 +741,13 @@ func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSe
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
if req.CredentialID != "" {
q = q.Where("credential_id = ?", req.CredentialID)
}
if req.ToolID != "" {
q = q.Where("tool_id = ?", req.ToolID)
}
if req.Status != "" { if req.Status != "" {
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" { if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q = q.Where("status = ?", req.Status)
} }
var total int64 items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status)
if err := q.Count(&total).Error; err != nil { if err != nil {
return nil, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil {
return nil, err return nil, err
} }
return map[string]any{"items": items, "total": total}, nil return map[string]any{"items": items, "total": total}, nil
+8 -6
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -97,7 +98,7 @@ func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission)
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
record.Code = known.Code record.Code = known.Code
} }
if auditErr := s.DB.Create(&record).Error; auditErr != nil { if auditErr := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); auditErr != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
} }
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
@@ -164,15 +165,16 @@ func newMCPAdmissionID() string { return uuid.NewString() }
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error { func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
var latest model.MCPCredential data := repo.MCPRepo{DB: tx}
if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil { latest, err := data.AdmissionCredential(key.ID, key.Revision)
if err != nil {
return contract.Forbidden() return contract.Forbidden()
} }
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) { if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
return contract.Forbidden() return contract.Forbidden()
} }
var setting model.MCPSetting enabled, err := data.AdmissionEnabled()
if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled { if err != nil || !enabled {
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
} }
@@ -236,7 +238,7 @@ func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion)
now := s.Now() now := s.Now()
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode) outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds() outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
if err := s.DB.Create(outcome).Error; err != nil { if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(outcome); err != nil {
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically") return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
} }
result.InvocationID = outcome.ID result.InvocationID = outcome.ID
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"errors"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
"gorm.io/gorm"
)
func (s *MCPService) Delete(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (resultErr error) {
defer func() {
if resultErr != nil {
if err := s.auditEnrollment(s.DB, owner, "delete", req, resultErr); err != nil {
resultErr = err
}
}
}()
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return err
}
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
return contract.Invalid()
}
mcpMutationMu.Lock()
defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error {
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return err
}
data := repo.MCPRepo{DB: tx}
key, err := data.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden()
}
return err
}
if key.Revision != req.Revision {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
}
if err := data.DeleteGrants(key.ID); err != nil {
return err
}
deleted, err := data.DeleteCredential(owner.Type, owner.ID, key.ID, req.Revision)
if err != nil {
return err
}
if deleted != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
}
return s.auditEnrollment(tx, owner, "delete", req, nil)
})
}
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
paneli18n "github.com/1Panel-dev/1Panel/core/i18n" paneli18n "github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -92,7 +93,9 @@ func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredential
if err := s.authorize(owner, "mcp_access_manage"); err != nil { if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return key, err return key, err
} }
if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { var err error
key, err = (repo.MCPRepo{DB: s.DB}).Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return key, contract.Forbidden() return key, contract.Forbidden()
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/constant"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -29,7 +30,7 @@ func mcpEnrollmentAuditCode(err error) string {
func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error { func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error {
failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned") failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned")
if action != "create" && action != "reveal" && action != "rotate" && action != "export" { if action != "create" && action != "reveal" && action != "rotate" && action != "export" && action != "delete" {
return failed return failed
} }
id := "invalid" id := "invalid"
@@ -58,7 +59,7 @@ func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action
if s.EnrollmentAudit != nil { if s.EnrollmentAudit != nil {
err = s.EnrollmentAudit(db, record) err = s.EnrollmentAudit(db, record)
} else { } else {
err = db.Create(record).Error err = (repo.MCPRepo{DB: db}).CreateEnrollmentAudit(record)
} }
if err != nil { if err != nil {
return failed return failed
+206
View File
@@ -0,0 +1,206 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"sync"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/app/task"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"github.com/google/uuid"
"gorm.io/gorm"
)
func (s *MCPRuntimeService) SubmitInstall(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeInstallTask, error) {
if !mcpInstalling.CompareAndSwap(false, true) {
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "An MCP installation is already running")
}
if !mcpRuntimeMutation.TryLock() {
mcpInstalling.Store(false)
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running")
}
accepted := false
defer func() {
if !accepted {
mcpInstalling.Store(false)
mcpRuntimeMutation.Unlock()
}
}()
if err := ctx.Err(); err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if _, _, err := s.installSettings(ctx, req); err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
req.AllowedIPs = append([]string{}, req.AllowedIPs...)
if req.Website != nil {
website := *req.Website
domain, err := normalizeMCPWebsiteDomain(website.Domain)
if err != nil {
return dto.MCPRuntimeInstallTask{}, contract.Invalid()
}
parsed, _ := url.Parse("http://" + domain)
if net.ParseIP(parsed.Hostname()) != nil || website.EnableSSL != (website.SSLID != 0) {
return dto.MCPRuntimeInstallTask{}, contract.Invalid()
}
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if row.WebsiteID != 0 || row.WebsiteBindingID != "" || row.WebsiteOperation != "" {
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "Review the existing MCP website configuration before installation")
}
website.Domain = domain
req.Website = &website
}
installation, err := task.NewTaskWithOps("1Panel MCP", task.TaskInstall, task.TaskScopePanelMCP, "", 0)
if err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if err := repo.NewITaskRepo().Save(ctx, installation.Task); err != nil {
installation.Close()
installation.DeleteLogFile()
return dto.MCPRuntimeInstallTask{}, err
}
installation.AddSubTaskWithContext(i18n.GetMsgByKey("MCPInstallPrepare"), func(ctx context.Context, t *task.Task) (resultErr error) {
defer func() {
if recovered := recover(); recovered != nil {
resultErr = errors.New(i18n.GetMsgByKey("MCPInstallFailed"))
}
}()
return s.executeInstallTask(ctx, req, t)
}, nil, 0, 8*time.Minute)
accepted = true
go func() {
defer mcpRuntimeMutation.Unlock()
defer mcpInstalling.Store(false)
defer installation.Close()
if err := installation.Execute(); err != nil {
if installation.Task.Status == constant.StatusExecuting {
installation.Task.Status = constant.StatusFailed
installation.Task.ErrorMsg = i18n.GetMsgByKey("MCPInstallFailed")
installation.Task.EndAt = time.Now()
}
if global.LOG != nil {
global.LOG.Errorf("MCP installation task %s failed: %v", installation.TaskID, err)
}
}
if err := repo.NewITaskRepo().Update(context.Background(), installation.Task); err != nil && global.LOG != nil {
global.LOG.Errorf("MCP installation task %s status could not be saved: %v", installation.TaskID, err)
}
}()
return dto.MCPRuntimeInstallTask{TaskID: installation.TaskID}, nil
}
func (s *MCPRuntimeService) executeInstallTask(ctx context.Context, req dto.MCPRuntimeInstall, t *task.Task) error {
report := mcpInstallProgressLogger(t)
ctx = mcpruntime.WithProgress(ctx, report)
installCtx, cancel := context.WithTimeout(ctx, 6*time.Minute)
_, err := s.install(installCtx, req)
cancel()
if err != nil {
return safeMCPInstallTaskError(err)
}
if req.Website == nil {
return nil
}
t.Log(i18n.GetMsgByKey("MCPInstallBindWebsite"))
websiteCtx, websiteCancel := context.WithTimeout(ctx, 90*time.Second)
defer websiteCancel()
settings, err := s.settings(websiteCtx)
if err == nil {
_, err = s.mutateWebsiteLocked(websiteCtx, dto.MCPWebsitePendingRequest{
Operation: "bind", RequestID: uuid.NewString(), Domain: req.Website.Domain,
EnableSSL: req.Website.EnableSSL, SSLID: req.Website.SSLID,
}, settings.Revision)
}
if err != nil {
t.Log(i18n.GetMsgByKey("MCPInstallWebsiteFailed"))
return errors.New(i18n.GetMsgByKey("MCPInstallWebsiteFailed"))
}
return nil
}
func safeMCPInstallTaskError(err error) error {
var recovery *mcpRuntimeRecoveryError
if errors.As(err, &recovery) {
return errors.New(i18n.GetMsgByKey("MCPInstallRecoveryFailed"))
}
var operation *mcpruntime.OperationError
if errors.As(err, &operation) {
return operation
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return errors.New(i18n.GetMsgByKey("MCPInstallFailed"))
}
func mcpInstallProgressLogger(t *task.Task) func(mcpruntime.Progress) {
var mu sync.Mutex
last := make(map[string]time.Time)
return func(progress mcpruntime.Progress) {
mu.Lock()
defer mu.Unlock()
key := progress.Status + ":" + progress.ID
now := time.Now()
if (progress.Status == "downloading" || progress.Status == "extracting") &&
(progress.Total == 0 || progress.Current != progress.Total) && now.Sub(last[key]) < time.Second {
return
}
last[key] = now
stage := map[string]string{
"snapshot": "MCPInstallPrepare", "checking_image": "MCPInstallPullImage",
"local_image": "MCPInstallLocalImage", "verifying_image": "MCPInstallVerifyImage", "verified_image": "MCPInstallVerifiedImage",
"catalog": "MCPInstallCatalog", "starting": "MCPInstallStarting",
"saving": "MCPInstallSaving", "installed": "MCPInstallCompleted", "rollback": "MCPInstallRollback",
"recovery_failed": "MCPInstallRecoveryFailed",
}[progress.Status]
if stage != "" {
t.Log(i18n.GetMsgByKey(stage))
return
}
message := map[string]string{
"pulling": "Pulling fs layer", "waiting": "Waiting", "downloading": "Downloading",
"extracting": "Extracting", "verifying_checksum": "Verifying Checksum",
"download_complete": "Download complete", "pull_complete": "Pull complete", "already_exists": "Already exists",
}[progress.Status]
if message == "" {
return
}
if progress.ID != "" {
message = progress.ID + ": " + message
}
if progress.Total > 0 {
message += fmt.Sprintf(" %d/%d bytes (%.0f%%)", progress.Current, progress.Total, 100*float64(progress.Current)/float64(progress.Total))
} else if progress.Current > 0 {
message += fmt.Sprintf(" %d bytes", progress.Current)
}
t.Log(message)
}
}
func (s *MCPRuntimeService) InstallationTask(ctx context.Context) (string, bool, error) {
var latest model.Task
err := global.TaskDB.WithContext(ctx).Where("type = ?", task.TaskScopePanelMCP).Order("created_at DESC").First(&latest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
return latest.ID, latest.Status == constant.StatusExecuting, err
}
func (s *MCPRuntimeService) RecoverInstallationTasks(processStartedAt time.Time) error {
return global.TaskDB.Model(&model.Task{}).Where("type = ? AND status = ? AND created_at < ?", task.TaskScopePanelMCP, constant.StatusExecuting, processStartedAt).
Updates(map[string]any{"status": constant.StatusFailed, "error_msg": i18n.GetMsgByKey("MCPInstallInterrupted"), "end_at": time.Now()}).Error
}
+3 -15
View File
@@ -4,23 +4,16 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
) )
// SQLite stores time.Time with the server's offset. Compare absolute seconds
// and integer nanoseconds so offset changes and repeated local hours remain
// chronological without changing the stored timestamps or losing precision.
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error { func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
var err error var err error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 { if attempt > 0 {
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond) time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
} }
// Only compensate the audit write. The business operation must never be replayed. err = (repo.MCPRepo{DB: s.DB}).UpdateRunningInvocation(record)
err = s.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
if err == nil { if err == nil {
return nil return nil
} }
@@ -28,12 +21,7 @@ func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
return err return err
} }
// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed
// process-start boundary prevents a later invocation from being marked interrupted.
func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error { func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error {
now := s.Now() now := s.Now()
return s.DB.Model(&model.MCPInvocation{}). return (repo.MCPRepo{DB: s.DB}).UpdateInterruptedInvocations(processStartedAt, "failed", "MCP_OUTCOME_UNKNOWN", now)
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error
} }
+138 -31
View File
@@ -8,20 +8,35 @@ import (
"reflect" "reflect"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/global"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime" "github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var mcpRuntimeMutation sync.Mutex var mcpRuntimeMutation sync.Mutex
var mcpInstalling atomic.Bool
var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving") var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving")
func lockMCPRuntimeMutation() error {
conflict := func() error { return contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running") }
if mcpInstalling.Load() {
return conflict()
}
mcpRuntimeMutation.Lock()
if mcpInstalling.Load() {
mcpRuntimeMutation.Unlock()
return conflict()
}
return nil
}
type mcpRuntimeManager interface { type mcpRuntimeManager interface {
Prepare(context.Context) error Prepare(context.Context) error
Catalog(context.Context) ([]contract.Tool, error) Catalog(context.Context) ([]contract.Tool, error)
@@ -40,6 +55,14 @@ type mcpRuntimeSnapshot struct {
unavailable bool unavailable bool
} }
type mcpRuntimeRecoveryError struct{ cause error }
func (e *mcpRuntimeRecoveryError) Error() string {
return "MCP runtime recovery is incomplete; check the service state before retrying"
}
func (e *mcpRuntimeRecoveryError) Unwrap() error { return e.cause }
func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) { func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) {
snapshot := mcpRuntimeSnapshot{settings: settings} snapshot := mcpRuntimeSnapshot{settings: settings}
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
@@ -51,7 +74,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
} }
snapshot.installed = state.Installed snapshot.installed = state.Installed
if state.Installed { if state.Installed {
if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil { if state.AdapterVersion == "" || !mcpruntime.ValidDockerImageID(state.ImageID) {
snapshot.unavailable = true snapshot.unavailable = true
return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity") return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity")
} }
@@ -65,6 +88,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
type MCPRuntimeService struct { type MCPRuntimeService struct {
newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager
websiteTransport mcpWebsiteTransport
} }
func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager { func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager {
@@ -96,8 +120,7 @@ func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSetting
ListenPort: mcpruntime.DefaultPort, Revision: 1, ListenPort: mcpruntime.DefaultPort, Revision: 1,
AccessPolicy: "inherit", AllowedIPs: []string{}, AccessPolicy: "inherit", AllowedIPs: []string{},
} }
var saved model.MCPSetting saved, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting()
err := global.DB.WithContext(ctx).First(&saved, 1).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return settings, nil return settings, nil
} }
@@ -121,7 +144,9 @@ func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error
} }
func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) { func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeSettings{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel() defer cancel()
@@ -136,6 +161,13 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
if req.Revision != old.Revision { if req.Revision != old.Revision {
return old, ErrMCPSettingsRevision return old, ErrMCPSettingsRevision
} }
if req.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(req); err != nil { if err := validateMCPRuntimeSettings(req); err != nil {
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
} }
@@ -166,19 +198,20 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error { func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1} seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { if err := store.CreateSettingIfAbsent(&seed); err != nil {
return err return err
} }
result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{ rowsAffected, err := store.UpdateRuntimeSetting(model.MCPSetting{
"runtime_type": "docker", "docker_image": req.DockerImage, RuntimeType: "docker", DockerImage: req.DockerImage,
"enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1, Enabled: req.Enabled, ListenPort: req.ListenPort, ExternalURL: req.ExternalURL, Revision: revision + 1,
"access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs), AccessPolicy: req.AccessPolicy, AllowedIPs: encodeMCPIPs(req.AllowedIPs),
}) }, revision)
if result.Error != nil { if err != nil {
return result.Error return err
} }
if result.RowsAffected != 1 { if rowsAffected != 1 {
return ErrMCPSettingsRevision return ErrMCPSettingsRevision
} }
return nil return nil
@@ -212,16 +245,27 @@ func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, e
} }
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
return dto.MCPRuntimeStatus{ result := dto.MCPRuntimeStatus{
RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID, RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID,
Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason, Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason,
AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL, AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL,
InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort), InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort),
}, nil }
website, websiteErr := s.WebsiteStatus(ctx)
if websiteErr != nil {
return result, websiteErr
}
if website.State != "unbound" {
result.Website = &website
result.Endpoint = website.Endpoint
}
return result, nil
} }
func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
@@ -242,20 +286,46 @@ func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.
} }
func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
old, err := s.settings(ctx) return s.install(ctx, req)
}
func (s *MCPRuntimeService) installSettings(ctx context.Context, req dto.MCPRuntimeInstall) (old, next dto.MCPRuntimeSettings, err error) {
old, err = s.settings(ctx)
if err != nil {
return old, next, err
}
if req.Revision != old.Revision {
return old, next, ErrMCPSettingsRevision
}
next = old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
next.AccessPolicy, next.AllowedIPs = req.AccessPolicy, append([]string{}, req.AllowedIPs...)
if next.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, next, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, next, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
if err := normalizeMCPAccessSettings(&next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
return old, next, nil
}
func (s *MCPRuntimeService) install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
old, next, err := s.installSettings(ctx, req)
if err != nil { if err != nil {
return dto.MCPRuntimeStatus{}, err return dto.MCPRuntimeStatus{}, err
} }
if req.Revision != old.Revision { mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "snapshot"})
return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision
}
next := old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
if err := validateMCPRuntimeSettings(next); err != nil {
return dto.MCPRuntimeStatus{}, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
snapshot, err := s.snapshot(ctx, old) snapshot, err := s.snapshot(ctx, old)
if err != nil { if err != nil {
return dto.MCPRuntimeStatus{}, err return dto.MCPRuntimeStatus{}, err
@@ -263,6 +333,15 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
if !snapshot.installed { if !snapshot.installed {
next.Enabled = true next.Enabled = true
} }
prepared := old
policyPrepared := !snapshot.installed || next.AccessPolicy != old.AccessPolicy || !reflect.DeepEqual(next.AllowedIPs, old.AllowedIPs)
if policyPrepared {
prepared.AccessPolicy, prepared.AllowedIPs = next.AccessPolicy, next.AllowedIPs
if err := s.persist(prepared, old.Revision); err != nil {
return dto.MCPRuntimeStatus{}, err
}
prepared.Revision++
}
manager := s.manager(next) manager := s.manager(next)
rollback := func(authoritative dto.MCPRuntimeSettings) error { rollback := func(authoritative dto.MCPRuntimeSettings) error {
if !snapshot.installed && reflect.DeepEqual(authoritative, old) { if !snapshot.installed && reflect.DeepEqual(authoritative, old) {
@@ -273,21 +352,49 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
return s.restore(authoritative, snapshot) return s.restore(authoritative, snapshot)
} }
recoverFailure := func(operationErr error) error { recoverFailure := func(operationErr error) error {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "rollback"})
current, loadErr := s.Settings() current, loadErr := s.Settings()
if loadErr != nil { if loadErr != nil {
return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available")) mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, loadErr)}
} }
return errors.Join(operationErr, rollback(current)) if policyPrepared && current.Revision == prepared.Revision {
if recoveryErr := rollback(old); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
if recoveryErr := s.persist(old, prepared.Revision); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
}
if recoveryErr := rollback(current); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
} }
if err := manager.InstallImage(ctx, next.Enabled); err != nil { if err := installMCPRuntimeImage(ctx, manager, next.Enabled); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
if err := s.persist(next, old.Revision); err != nil { mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "saving"})
if err := s.persist(next, prepared.Revision); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "installed"})
return s.Status(ctx) return s.Status(ctx)
} }
func installMCPRuntimeImage(ctx context.Context, manager mcpRuntimeManager, enabled bool) (err error) {
defer func() {
if recover() != nil {
err = errors.New("MCP Docker image installation failed")
}
}()
return manager.InstallImage(ctx, enabled)
}
func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) { func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) {
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
+483
View File
@@ -0,0 +1,483 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
"golang.org/x/net/idna"
"gorm.io/gorm"
)
type mcpWebsiteAgentRequest struct {
Operation string `json:"operation"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
type mcpWebsiteAgentResponse struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
type mcpWebsiteTransport interface {
Call(context.Context, mcpWebsiteAgentRequest) (mcpWebsiteAgentResponse, error)
}
type mcpWebsiteHTTPTransport struct{}
func (mcpWebsiteHTTPTransport) Call(ctx context.Context, request mcpWebsiteAgentRequest) (mcpWebsiteAgentResponse, error) {
var result mcpWebsiteAgentResponse
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", "/etc/1panel/agent.sock")
}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
data, err := json.Marshal(request)
if err != nil {
return result, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://agent/internal/mcp/website", bytes.NewReader(data))
if err != nil {
return result, err
}
req.Header.Set("Content-Type", "application/json")
response, err := client.Do(req)
if err != nil {
return result, errors.New("MCP website operation could not be confirmed")
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
if err != nil || len(body) > 64<<10 || json.Unmarshal(body, &result) != nil {
return mcpWebsiteAgentResponse{}, errors.New("MCP website operation could not be confirmed")
}
if response.StatusCode != http.StatusOK && result.Error == "" {
return mcpWebsiteAgentResponse{}, errors.New("MCP website operation could not be confirmed")
}
return result, nil
}
type mcpWebsiteOperation struct {
dto.MCPWebsitePendingRequest
Hash string `json:"hash"`
BindingID string `json:"bindingId"`
State string `json:"state"`
ErrorCode string `json:"errorCode,omitempty"`
}
func (s *MCPRuntimeService) websiteAgent() mcpWebsiteTransport {
if s.websiteTransport != nil {
return s.websiteTransport
}
return mcpWebsiteHTTPTransport{}
}
func (s *MCPRuntimeService) websiteSetting(ctx context.Context) (model.MCPSetting, error) {
row, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting()
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.MCPSetting{Revision: 1}, nil
}
return row, err
}
func decodeMCPWebsiteOperation(value string) (*mcpWebsiteOperation, error) {
if value == "" {
return nil, nil
}
var operation mcpWebsiteOperation
if json.Unmarshal([]byte(value), &operation) != nil || !validMCPWebsiteRequestID(operation.RequestID) ||
!validMCPWebsiteRequestID(operation.BindingID) || len(operation.Hash) != 64 ||
(operation.State != "pending" && operation.State != "succeeded" && operation.State != "failed") ||
(operation.Operation != "bind" && operation.Operation != "update" && operation.Operation != "unbind") {
return nil, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
return &operation, nil
}
func validMCPWebsiteRequestID(id string) bool {
parsed, err := uuid.Parse(id)
return err == nil && parsed != uuid.Nil && parsed.String() == id
}
func normalizeMCPWebsiteDomain(domain string) (string, error) {
if domain == "" || domain != strings.TrimSpace(domain) || len(domain) > 320 || strings.ContainsAny(domain, "\\\r\n\t /?#@\x00") {
return "", contract.Invalid()
}
u, err := url.Parse("http://" + domain)
if err != nil || u.Host == "" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", contract.Invalid()
}
host := strings.ToLower(u.Hostname())
if ip := net.ParseIP(host); ip == nil {
host, err = idna.Lookup.ToASCII(host)
if err != nil || len(host) > 253 {
return "", contract.Invalid()
}
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", contract.Invalid()
}
for _, ch := range label {
if !(ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '-') {
return "", contract.Invalid()
}
}
}
} else {
host = ip.String()
}
if port := u.Port(); port != "" {
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return "", contract.Invalid()
}
return net.JoinHostPort(host, strconv.Itoa(number)), nil
}
if strings.HasSuffix(domain, ":") {
return "", contract.Invalid()
}
if strings.Contains(host, ":") {
return "[" + host + "]", nil
}
return host, nil
}
func validMCPWebsiteMetadata(response mcpWebsiteAgentResponse) bool {
if !mcpDocumentEndpoint(response.Endpoint) || response.EnableSSL && response.SSLID == 0 || !response.EnableSSL && response.SSLID != 0 {
return false
}
domain, err := normalizeMCPWebsiteDomain(response.Domain)
if err != nil {
return false
}
domainURL, _ := url.Parse("http://" + domain)
endpoint, _ := url.Parse(response.Endpoint)
return strings.EqualFold(domainURL.Hostname(), endpoint.Hostname()) && (endpoint.Scheme == "https") == response.EnableSSL
}
func (s *MCPRuntimeService) websiteAgentRequest(ctx context.Context, row model.MCPSetting, operation string, operationID string) (mcpWebsiteAgentRequest, error) {
masterKey, err := encrypt.APIKeyEncryptionKey()
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
key, err := contract.WebsiteProxyKey(masterKey, row.WebsiteBindingID)
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
host := strings.Trim(global.CONF.Conn.BindAddress, "[]")
ip := net.ParseIP(host)
if host == "" || host == "localhost" || host == "0.0.0.0" {
host = "127.0.0.1"
} else if ip != nil && ip.IsUnspecified() {
host = "::1"
} else if ip == nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
port, err := strconv.Atoi(global.CONF.Conn.Port)
if err != nil || port < 1 || port > 65535 {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
scheme := "http"
if global.CONF.Conn.SSL == constant.StatusEnable || global.CONF.Conn.SSL == constant.StatusMux {
scheme = "https"
}
address := net.JoinHostPort(host, strconv.Itoa(port))
coreHost := address
if binding, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).PanelSetting("BindDomain"); err == nil && binding.Value != "" {
coreHost, err = normalizeMCPWebsiteDomain(binding.Value)
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return mcpWebsiteAgentRequest{}, err
}
return mcpWebsiteAgentRequest{Operation: operation, WebsiteID: row.WebsiteID, BindingID: row.WebsiteBindingID,
RequestID: operationID, Upstream: (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String(), CoreHost: coreHost, ProxyKey: key}, nil
}
func (s *MCPRuntimeService) WebsiteStatus(ctx context.Context) (dto.MCPWebsiteStatus, error) {
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
return s.websiteStatus(ctx, row)
}
func (s *MCPRuntimeService) websiteStatus(ctx context.Context, row model.MCPSetting) (dto.MCPWebsiteStatus, error) {
result := dto.MCPWebsiteStatus{State: "unbound", Revision: row.Revision, WebsiteID: row.WebsiteID}
operation, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return result, err
}
if operation != nil {
result.RequestID = operation.RequestID
}
if row.WebsiteID == 0 && row.WebsiteBindingID == "" && (operation == nil || operation.State != "pending") {
return result, nil
}
result.State = "unavailable"
if operation != nil && operation.State == "pending" {
result.State = "pending"
pending := operation.MCPWebsitePendingRequest
result.PendingRequest = &pending
result.Domain, result.EnableSSL, result.SSLID = pending.Domain, pending.EnableSSL, pending.SSLID
}
request, err := s.websiteAgentRequest(ctx, row, "status", result.RequestID)
if err != nil {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
return result, nil
}
response, err := s.websiteAgent().Call(ctx, request)
if err != nil || response.Error != "" || response.BindingID != row.WebsiteBindingID ||
(row.WebsiteID != 0 && response.WebsiteID != row.WebsiteID) {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
return result, nil
}
result.WebsiteID, result.Domain = response.WebsiteID, response.Domain
result.EnableSSL, result.SSLID, result.WebsiteStatus = response.EnableSSL, response.SSLID, response.WebsiteStatus
if result.State == "pending" {
return result, nil
}
if row.WebsiteID != 0 && response.State == "bound" && response.WebsiteID != 0 && validMCPWebsiteMetadata(response) {
result.State, result.Endpoint = "bound", response.Endpoint
} else {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
}
return result, nil
}
func (s *MCPRuntimeService) BindWebsite(ctx context.Context, req dto.MCPWebsiteBind) (dto.MCPWebsiteStatus, error) {
domain, err := normalizeMCPWebsiteDomain(req.Domain)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
parsed, _ := url.Parse("http://" + domain)
if net.ParseIP(parsed.Hostname()) != nil {
return dto.MCPWebsiteStatus{}, contract.Invalid()
}
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "bind", RequestID: req.RequestID, Domain: domain, EnableSSL: req.EnableSSL, SSLID: req.SSLID}, req.Revision)
}
func (s *MCPRuntimeService) UpdateWebsite(ctx context.Context, req dto.MCPWebsiteUpdate) (dto.MCPWebsiteStatus, error) {
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "update", RequestID: req.RequestID, EnableSSL: req.EnableSSL, SSLID: req.SSLID}, req.Revision)
}
func (s *MCPRuntimeService) UnbindWebsite(ctx context.Context, req dto.MCPWebsiteUnbind) (dto.MCPWebsiteStatus, error) {
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "unbind", RequestID: req.RequestID}, req.Revision)
}
func mcpWebsiteOperationError(code string, uncertain bool) error {
if uncertain {
return contract.Fail("MCP_WEBSITE_PENDING", 503, "MCP website operation could not be confirmed")
}
switch code {
case "MCP_INVALID_ARGUMENTS", "MCP_WEBSITE_INVALID", "MCP_WEBSITE_INVALID_ARGUMENTS":
return contract.Invalid()
case "MCP_CONFLICT", "MCP_WEBSITE_CONFLICT":
return contract.Fail("MCP_CONFLICT", 409, "MCP website configuration changed")
case "MCP_FORBIDDEN":
return contract.Forbidden()
default:
return contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website operation failed")
}
}
func (s *MCPRuntimeService) mutateWebsite(ctx context.Context, pending dto.MCPWebsitePendingRequest, revision uint64) (dto.MCPWebsiteStatus, error) {
if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPWebsiteStatus{}, err
}
defer mcpRuntimeMutation.Unlock()
return s.mutateWebsiteLocked(ctx, pending, revision)
}
func (s *MCPRuntimeService) mutateWebsiteLocked(ctx context.Context, pending dto.MCPWebsitePendingRequest, revision uint64) (dto.MCPWebsiteStatus, error) {
if !validMCPWebsiteRequestID(pending.RequestID) || revision == 0 ||
(pending.Operation != "unbind" && (pending.EnableSSL && pending.SSLID == 0 || !pending.EnableSSL && pending.SSLID != 0)) {
return dto.MCPWebsiteStatus{}, contract.Invalid()
}
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
previous, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
encoded, _ := json.Marshal(pending)
digest := sha256.Sum256(encoded)
hash := hex.EncodeToString(digest[:])
operation := mcpWebsiteOperation{MCPWebsitePendingRequest: pending, Hash: hash, State: "pending", BindingID: row.WebsiteBindingID}
if previous != nil && previous.RequestID == pending.RequestID {
if previous.Hash != hash {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP website request was already used")
}
if previous.State == "succeeded" {
return s.websiteStatus(ctx, row)
}
if previous.State == "failed" {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError(previous.ErrorCode, false)
}
operation = *previous
} else {
cancelPendingChange := previous != nil && previous.State == "pending" &&
(previous.Operation == "bind" || previous.Operation == "update") && pending.Operation == "unbind"
if previous != nil && previous.State == "pending" && !cancelPendingChange || row.Revision != revision {
return dto.MCPWebsiteStatus{}, ErrMCPSettingsRevision
}
if pending.Operation == "bind" {
if row.WebsiteID != 0 {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP already has a bound website")
}
settings, err := s.settings(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
if !s.manager(settings).Status(ctx, settings.Enabled).Installed {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_WEBSITE_SERVICE_REQUIRED", 409, "Install the MCP service before binding a website")
}
operation.BindingID = pending.RequestID
row.WebsiteBindingID = operation.BindingID
} else if row.WebsiteID == 0 && !cancelPendingChange || !validMCPWebsiteRequestID(row.WebsiteBindingID) {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP has no bound website")
}
if _, err := s.websiteAgentRequest(ctx, row, pending.Operation, pending.RequestID); err != nil {
return dto.MCPWebsiteStatus{}, err
}
if err := s.saveWebsiteOperation(ctx, &row, operation, row.WebsiteID, row.WebsiteBindingID); err != nil {
return dto.MCPWebsiteStatus{}, err
}
}
request, err := s.websiteAgentRequest(ctx, row, pending.Operation, pending.RequestID)
if err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
request.Domain, request.EnableSSL, request.SSLID = pending.Domain, pending.EnableSSL, pending.SSLID
response, callErr := s.websiteAgent().Call(ctx, request)
if callErr != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
if response.Error != "" {
if !response.Definitive || pending.Operation == "unbind" && row.WebsiteID == 0 && row.WebsiteBindingID != "" {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
operation.State, operation.ErrorCode = "failed", response.Code
bindingID := row.WebsiteBindingID
if pending.Operation == "bind" && row.WebsiteID == 0 {
bindingID = ""
}
if err := s.saveWebsiteOperation(ctx, &row, operation, row.WebsiteID, bindingID); err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError(response.Code, false)
}
if response.BindingID != operation.BindingID || response.RequestID != pending.RequestID ||
(pending.Operation != "unbind" && (response.State != "bound" || response.WebsiteID == 0 || !validMCPWebsiteMetadata(response))) ||
(pending.Operation != "unbind" && (response.EnableSSL != pending.EnableSSL || response.SSLID != pending.SSLID)) ||
(pending.Operation == "update" && response.WebsiteID != row.WebsiteID) ||
(pending.Operation == "unbind" && response.State != "unbound") {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
websiteID, bindingID := response.WebsiteID, operation.BindingID
if pending.Operation == "unbind" {
websiteID, bindingID = 0, ""
}
operation.State = "succeeded"
if err := s.saveWebsiteOperation(ctx, &row, operation, websiteID, bindingID); err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
if pending.Operation == "unbind" {
return dto.MCPWebsiteStatus{State: "unbound", Revision: row.Revision, RequestID: pending.RequestID}, nil
}
return dto.MCPWebsiteStatus{State: "bound", Revision: row.Revision, WebsiteID: response.WebsiteID, Domain: response.Domain,
Endpoint: response.Endpoint, EnableSSL: response.EnableSSL, SSLID: response.SSLID, WebsiteStatus: response.WebsiteStatus, RequestID: pending.RequestID}, nil
}
func (s *MCPRuntimeService) saveWebsiteOperation(ctx context.Context, row *model.MCPSetting, operation mcpWebsiteOperation, websiteID uint, bindingID string) error {
data, err := json.Marshal(operation)
if err != nil {
return err
}
err = global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
if err := store.CreateSettingIfAbsent(&model.MCPSetting{ID: 1}); err != nil {
return err
}
rowsAffected, err := store.UpdateWebsiteSetting(model.MCPSetting{
WebsiteID: websiteID, WebsiteBindingID: bindingID, WebsiteOperation: string(data), Revision: row.Revision + 1,
}, row.Revision)
if err != nil {
return err
}
if rowsAffected != 1 {
return ErrMCPSettingsRevision
}
return nil
})
if err == nil {
row.ID, row.WebsiteID, row.WebsiteBindingID, row.WebsiteOperation, row.Revision = 1, websiteID, bindingID, string(data), row.Revision+1
}
return err
}
func (s *MCPRuntimeService) SyncWebsite(ctx context.Context) error {
mcpRuntimeMutation.Lock()
defer mcpRuntimeMutation.Unlock()
row, err := s.websiteSetting(ctx)
if err != nil {
return err
}
operation, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return err
}
if row.WebsiteID == 0 || operation != nil && operation.State == "pending" {
return nil
}
request, err := s.websiteAgentRequest(ctx, row, "sync", "")
if err != nil {
return err
}
response, err := s.websiteAgent().Call(ctx, request)
if err != nil || response.Error != "" || response.State != "bound" || response.WebsiteID != row.WebsiteID || response.BindingID != row.WebsiteBindingID {
return contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website synchronization failed")
}
return nil
}
+7
View File
@@ -80,6 +80,7 @@ const (
TaskScopeCluster = "Cluster" TaskScopeCluster = "Cluster"
TaskScopeAppInstall = "AppInstallTask" TaskScopeAppInstall = "AppInstallTask"
TaskScopeAI = "AI" TaskScopeAI = "AI"
TaskScopePanelMCP = "PanelMCP"
) )
func GetTaskName(resourceName, operate, scope string) string { func GetTaskName(resourceName, operate, scope string) string {
@@ -321,6 +322,12 @@ func (t *Task) DeleteLogFile() {
_ = os.Remove(t.Task.LogFile) _ = os.Remove(t.Task.LogFile)
} }
func (t *Task) Close() {
if t.logFile != nil {
_ = t.logFile.Close()
}
}
func (t *Task) LogWithStatus(msg string, err error) { func (t *Task) LogWithStatus(msg string, err error) {
if err != nil { if err != nil {
t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err)) t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err))
+137
View File
@@ -1171,6 +1171,41 @@ const docTemplate = `{
] ]
} }
}, },
"/ai/agents/channel/feishu/switch": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentFeishuSwitchReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Switch to the bundled OpenClaw Feishu plugin",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/feishu/update": { "/ai/agents/channel/feishu/update": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -30117,6 +30152,33 @@ const docTemplate = `{
} }
}, },
"definitions": { "definitions": {
"dto.AgentFeishuTransition": {
"type": "object",
"properties": {
"confirmed": {
"type": "boolean"
},
"streaming": {
"type": "boolean"
},
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"requireMention": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
},
"dto.AgentAccountCreateReq": { "dto.AgentAccountCreateReq": {
"properties": { "properties": {
"apiKey": { "apiKey": {
@@ -31215,12 +31277,18 @@ const docTemplate = `{
}, },
"dto.AgentFeishuConfig": { "dto.AgentFeishuConfig": {
"properties": { "properties": {
"builtinManaged": {
"type": "boolean"
},
"bots": { "bots": {
"items": { "items": {
"$ref": "#/definitions/dto.AgentFeishuBot" "$ref": "#/definitions/dto.AgentFeishuBot"
}, },
"type": "array" "type": "array"
}, },
"builtinAvailable": {
"type": "boolean"
},
"connectionMode": { "connectionMode": {
"type": "string" "type": "string"
}, },
@@ -31239,9 +31307,21 @@ const docTemplate = `{
"groupPolicy": { "groupPolicy": {
"type": "string" "type": "string"
}, },
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"installed": { "installed": {
"type": "boolean" "type": "boolean"
}, },
"legacy": {
"type": "boolean"
},
"replyMode": { "replyMode": {
"type": "string" "type": "string"
}, },
@@ -31253,10 +31333,49 @@ const docTemplate = `{
}, },
"threadSession": { "threadSession": {
"type": "boolean" "type": "boolean"
},
"legacyConfigured": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
}, },
"dto.AgentFeishuSwitchReq": {
"properties": {
"agentId": {
"type": "integer"
},
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"requireMention": {
"type": "string",
"enum": [
"true",
"false"
]
},
"streaming": {
"type": "boolean"
},
"taskID": {
"type": "string"
}
},
"required": [
"agentId",
"taskID",
"groupSessionScope",
"requireMention"
],
"type": "object"
},
"dto.AgentFeishuConfigReq": { "dto.AgentFeishuConfigReq": {
"properties": { "properties": {
"agentId": { "agentId": {
@@ -31303,6 +31422,15 @@ const docTemplate = `{
], ],
"type": "string" "type": "string"
}, },
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"replyMode": { "replyMode": {
"type": "string" "type": "string"
}, },
@@ -33167,6 +33295,12 @@ const docTemplate = `{
}, },
"version": { "version": {
"type": "string" "type": "string"
},
"feishuLegacy": {
"type": "boolean"
},
"feishuConfigured": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -42953,6 +43087,9 @@ const docTemplate = `{
}, },
"taskID": { "taskID": {
"type": "string" "type": "string"
},
"feishuTransition": {
"$ref": "#/definitions/dto.AgentFeishuTransition"
} }
}, },
"required": [ "required": [
+137
View File
@@ -1167,6 +1167,41 @@
] ]
} }
}, },
"/ai/agents/channel/feishu/switch": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentFeishuSwitchReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Switch to the bundled OpenClaw Feishu plugin",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/feishu/update": { "/ai/agents/channel/feishu/update": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -30113,6 +30148,33 @@
} }
}, },
"definitions": { "definitions": {
"dto.AgentFeishuTransition": {
"type": "object",
"properties": {
"confirmed": {
"type": "boolean"
},
"streaming": {
"type": "boolean"
},
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"requireMention": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
},
"dto.AgentAccountCreateReq": { "dto.AgentAccountCreateReq": {
"properties": { "properties": {
"apiKey": { "apiKey": {
@@ -31211,12 +31273,18 @@
}, },
"dto.AgentFeishuConfig": { "dto.AgentFeishuConfig": {
"properties": { "properties": {
"builtinManaged": {
"type": "boolean"
},
"bots": { "bots": {
"items": { "items": {
"$ref": "#/definitions/dto.AgentFeishuBot" "$ref": "#/definitions/dto.AgentFeishuBot"
}, },
"type": "array" "type": "array"
}, },
"builtinAvailable": {
"type": "boolean"
},
"connectionMode": { "connectionMode": {
"type": "string" "type": "string"
}, },
@@ -31235,9 +31303,21 @@
"groupPolicy": { "groupPolicy": {
"type": "string" "type": "string"
}, },
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"installed": { "installed": {
"type": "boolean" "type": "boolean"
}, },
"legacy": {
"type": "boolean"
},
"replyMode": { "replyMode": {
"type": "string" "type": "string"
}, },
@@ -31249,10 +31329,49 @@
}, },
"threadSession": { "threadSession": {
"type": "boolean" "type": "boolean"
},
"legacyConfigured": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
}, },
"dto.AgentFeishuSwitchReq": {
"properties": {
"agentId": {
"type": "integer"
},
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"requireMention": {
"type": "string",
"enum": [
"true",
"false"
]
},
"streaming": {
"type": "boolean"
},
"taskID": {
"type": "string"
}
},
"required": [
"agentId",
"taskID",
"groupSessionScope",
"requireMention"
],
"type": "object"
},
"dto.AgentFeishuConfigReq": { "dto.AgentFeishuConfigReq": {
"properties": { "properties": {
"agentId": { "agentId": {
@@ -31299,6 +31418,15 @@
], ],
"type": "string" "type": "string"
}, },
"groupSessionScope": {
"type": "string",
"enum": [
"group",
"group_sender",
"group_topic",
"group_topic_sender"
]
},
"replyMode": { "replyMode": {
"type": "string" "type": "string"
}, },
@@ -33163,6 +33291,12 @@
}, },
"version": { "version": {
"type": "string" "type": "string"
},
"feishuLegacy": {
"type": "boolean"
},
"feishuConfigured": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -42949,6 +43083,9 @@
}, },
"taskID": { "taskID": {
"type": "string" "type": "string"
},
"feishuTransition": {
"$ref": "#/definitions/dto.AgentFeishuTransition"
} }
}, },
"required": [ "required": [
+3
View File
@@ -73,11 +73,14 @@ var WebUrlMap = map[string]struct{}{
"/ai/benchmark": {}, "/ai/benchmark": {},
"/ai/model/account": {}, "/ai/model/account": {},
"/ai/model/local": {}, "/ai/model/local": {},
"/ai/model/local/templates": {},
"/ai/model/files": {}, "/ai/model/files": {},
"/ai/gpu": {}, "/ai/gpu": {},
"/ai/gpu/current": {}, "/ai/gpu/current": {},
"/ai/gpu/history": {}, "/ai/gpu/history": {},
"/ai/mcp": {}, "/ai/mcp": {},
"/ai/mcp/server": {},
"/ai/mcp/1panel": {},
"/ai/agents/agent": {}, "/ai/agents/agent": {},
"/containers": {}, "/containers": {},
+2
View File
@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/crewjam/saml v0.5.1 github.com/crewjam/saml v0.5.1
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/gzip v1.2.6 github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
@@ -106,6 +107,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.1 // indirect github.com/pdfcpu/pdfcpu v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
+4
View File
@@ -31,6 +31,8 @@ github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhy
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
@@ -200,6 +202,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+31
View File
@@ -296,6 +296,16 @@ SyncAgentBaseInfo: "Sync basic node info"
GenerateSSLInfo: "Generate node SSL info" GenerateSSLInfo: "Generate node SSL info"
ConnInfoNotMatch: "Connection info mismatch" ConnInfoNotMatch: "Connection info mismatch"
MakeAgentPackage: "Generate node installation package" MakeAgentPackage: "Generate node installation package"
PrepareNodePackage: "Prepare existing package on child node"
PrepareNodePackageDetail: "Use existing package on child node: {{ .name }}"
SyncNodeConfig: "Sync node configuration"
ErrNodePackagePath: "Enter the absolute file path to the package on the child node"
ErrNodePackageFile: "Failed to process package file {{ .name }}: {{ .err }}"
ErrNodePackageMember: "Package contains an invalid or duplicate entry: {{ .name }}"
ErrNodePackageMismatch: "Package must match main node version {{ .version }} and child node architecture {{ .arch }}"
ErrNodePackageMissing: "Required file missing from package: {{ .name }}"
ErrNodePackageVersion: "Package version {{ .version }} does not match main node version {{ .expected }}"
ErrNodePackageArch: "Agent executable architecture does not match child node architecture {{ .arch }}"
SendAgent: "Distribute node installation package" SendAgent: "Distribute node installation package"
StartService: "Start service" StartService: "Start service"
NoBackupNode: "Backup node is empty. Select one and save first" NoBackupNode: "Backup node is empty. Select one and save first"
@@ -532,3 +542,24 @@ ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the T
ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked." ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked."
ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing." ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing."
ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first." ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first."
ErrMCPWebsitePending: "The website operation result is uncertain. Refresh the binding status before retrying the original operation."
ErrMCPWebsiteUnavailable: "The website binding is unavailable. Check the website status and OpenResty configuration."
ErrMCPWebsiteServiceRequired: "Install the 1Panel MCP service first."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Check the current MCP service"
MCPInstallPullImage: "Check and pull the image"
MCPInstallCatalog: "Validate the tool contract"
MCPInstallStarting: "Start the container and check its health"
MCPInstallSaving: "Save the service configuration"
MCPInstallCompleted: "MCP service installation completed"
MCPInstallRollback: "Installation failed; restore the previous MCP service"
MCPInstallBindWebsite: "Create the bound website"
MCPInstallWebsiteFailed: "The MCP service is installed, but website binding failed. Resolve the website configuration in the service settings."
MCPInstallInterrupted: "Installation was interrupted by a panel restart. Check the service status before continuing."
MCPInstallFailed: "MCP installation failed. Check Docker, the image, and the panel service logs."
MCPInstallRecoveryFailed: "Installation failed and the previous service could not be fully restored. Check the MCP service status before continuing."
MCPInstallLocalImage: "The image exists locally; skip downloading"
MCPInstallVerifyImage: "Validate the image version and managed service contract"
MCPInstallVerifiedImage: "Image validation passed"
+31
View File
@@ -296,6 +296,16 @@ SyncAgentBaseInfo: "Sincronizar información básica del nodo"
GenerateSSLInfo: "Generar información SSL del nodo" GenerateSSLInfo: "Generar información SSL del nodo"
ConnInfoNotMatch: "La información de conexión no coincide" ConnInfoNotMatch: "La información de conexión no coincide"
MakeAgentPackage: "Generar paquete de instalación del nodo" MakeAgentPackage: "Generar paquete de instalación del nodo"
PrepareNodePackage: "Preparar el paquete existente en el nodo secundario"
PrepareNodePackageDetail: "Usar el paquete existente en el nodo secundario: {{ .name }}"
SyncNodeConfig: "Sincronizar la configuración del nodo"
ErrNodePackagePath: "Introduzca la ruta absoluta del archivo del paquete en el nodo secundario"
ErrNodePackageFile: "Error al procesar el archivo del paquete {{ .name }}: {{ .err }}"
ErrNodePackageMember: "El paquete contiene una entrada no válida o duplicada: {{ .name }}"
ErrNodePackageMismatch: "El paquete debe coincidir con la versión {{ .version }} del nodo principal y la arquitectura {{ .arch }} del nodo secundario"
ErrNodePackageMissing: "Falta un archivo necesario en el paquete: {{ .name }}"
ErrNodePackageVersion: "La versión {{ .version }} del paquete no coincide con la versión {{ .expected }} del nodo principal"
ErrNodePackageArch: "La arquitectura del ejecutable Agent no coincide con la arquitectura {{ .arch }} del nodo secundario"
SendAgent: "Distribuir paquete de instalación del nodo" SendAgent: "Distribuir paquete de instalación del nodo"
StartService: "Iniciar servicio" StartService: "Iniciar servicio"
NoBackupNode: "Actualmente no hay nodo de respaldo. Seleccione uno para guardar e intente de nuevo." NoBackupNode: "Actualmente no hay nodo de respaldo. Seleccione uno para guardar e intente de nuevo."
@@ -532,3 +542,24 @@ ErrMCPSecretInvalid: "No se puede descifrar el Token MCP almacenado. Genere un T
ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado." ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado."
ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar." ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar."
ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida." ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida."
ErrMCPWebsitePending: "El resultado de la operación del sitio es incierto. Actualice el estado de la vinculación antes de reintentar la operación original."
ErrMCPWebsiteUnavailable: "La vinculación del sitio no está disponible. Revise el estado del sitio y la configuración de OpenResty."
ErrMCPWebsiteServiceRequired: "Instale primero el servicio 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Comprobar el servicio MCP actual"
MCPInstallPullImage: "Comprobar y descargar la imagen"
MCPInstallCatalog: "Validar el contrato de herramientas"
MCPInstallStarting: "Iniciar el contenedor y comprobar su estado"
MCPInstallSaving: "Guardar la configuración del servicio"
MCPInstallCompleted: "Instalación del servicio MCP completada"
MCPInstallRollback: "La instalación falló; restaurar el servicio MCP anterior"
MCPInstallBindWebsite: "Crear el sitio web vinculado"
MCPInstallWebsiteFailed: "El servicio MCP está instalado, pero la vinculación del sitio web falló. Revise la configuración del sitio web en los ajustes del servicio."
MCPInstallInterrupted: "Un reinicio del panel interrumpió la instalación. Compruebe el estado del servicio antes de continuar."
MCPInstallFailed: "La instalación de MCP falló. Revise Docker, la imagen y los registros del servicio del panel."
MCPInstallRecoveryFailed: "La instalación falló y el servicio anterior no se pudo restaurar por completo. Compruebe el estado del servicio MCP antes de continuar."
MCPInstallLocalImage: "La imagen ya existe localmente; omitir la descarga"
MCPInstallVerifyImage: "Validar la versión de la imagen y el contrato del servicio administrado"
MCPInstallVerifiedImage: "La imagen superó la validación"
+31
View File
@@ -296,6 +296,16 @@ SyncAgentBaseInfo: "همگام‌سازی اطلاعات پایه گره"
GenerateSSLInfo: "تولید اطلاعات SSL گره" GenerateSSLInfo: "تولید اطلاعات SSL گره"
ConnInfoNotMatch: "اطلاعات اتصال مطابقت ندارد" ConnInfoNotMatch: "اطلاعات اتصال مطابقت ندارد"
MakeAgentPackage: "تولید بسته نصب گره" MakeAgentPackage: "تولید بسته نصب گره"
PrepareNodePackage: "آماده‌سازی بسته موجود در گره فرزند"
PrepareNodePackageDetail: "استفاده از بسته موجود در گره فرزند: {{ .name }}"
SyncNodeConfig: "همگام‌سازی پیکربندی گره"
ErrNodePackagePath: "مسیر مطلق فایل بسته در گره فرزند را وارد کنید"
ErrNodePackageFile: "پردازش فایل بسته {{ .name }} ناموفق بود: {{ .err }}"
ErrNodePackageMember: "بسته دارای ورودی نامعتبر یا تکراری است: {{ .name }}"
ErrNodePackageMismatch: "بسته باید با نسخه گره اصلی {{ .version }} و معماری گره فرزند {{ .arch }} مطابقت داشته باشد"
ErrNodePackageMissing: "فایل ضروری در بسته موجود نیست: {{ .name }}"
ErrNodePackageVersion: "نسخه بسته {{ .version }} با نسخه گره اصلی {{ .expected }} مطابقت ندارد"
ErrNodePackageArch: "معماری فایل اجرایی Agent با معماری گره فرزند {{ .arch }} مطابقت ندارد"
SendAgent: "توزیع بسته نصب گره" SendAgent: "توزیع بسته نصب گره"
StartService: "شروع سرویس" StartService: "شروع سرویس"
NoBackupNode: "گره پشتیبان خالی است. ابتدا یکی را انتخاب کرده و ذخیره کنید" NoBackupNode: "گره پشتیبان خالی است. ابتدا یکی را انتخاب کرده و ذخیره کنید"
@@ -532,3 +542,24 @@ ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن ن
ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است." ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است."
ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید." ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید."
ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید." ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید."
ErrMCPWebsitePending: "نتیجه عملیات وب‌سایت تأیید نشده است. پیش از تلاش دوباره برای عملیات اصلی، وضعیت اتصال را تازه‌سازی کنید."
ErrMCPWebsiteUnavailable: "اتصال وب‌سایت در دسترس نیست. وضعیت وب‌سایت و تنظیمات OpenResty را بررسی کنید."
ErrMCPWebsiteServiceRequired: "ابتدا سرویس 1Panel MCP را نصب کنید."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "بررسی سرویس فعلی MCP"
MCPInstallPullImage: "بررسی و دریافت تصویر"
MCPInstallCatalog: "اعتبارسنجی قرارداد ابزارها"
MCPInstallStarting: "راه‌اندازی کانتینر و بررسی سلامت آن"
MCPInstallSaving: "ذخیره پیکربندی سرویس"
MCPInstallCompleted: "نصب سرویس MCP تکمیل شد"
MCPInstallRollback: "نصب ناموفق بود؛ بازیابی سرویس قبلی MCP"
MCPInstallBindWebsite: "ایجاد وب‌سایت متصل"
MCPInstallWebsiteFailed: "سرویس MCP نصب شده است، اما اتصال وب‌سایت ناموفق بود. پیکربندی وب‌سایت را در تنظیمات سرویس اصلاح کنید."
MCPInstallInterrupted: "راه‌اندازی مجدد پنل باعث قطع نصب شد. پیش از ادامه، وضعیت سرویس را بررسی کنید."
MCPInstallFailed: "نصب MCP ناموفق بود. Docker، تصویر و گزارش‌های سرویس پنل را بررسی کنید."
MCPInstallRecoveryFailed: "نصب ناموفق بود و سرویس قبلی به‌طور کامل بازیابی نشد. پیش از ادامه، وضعیت سرویس MCP را بررسی کنید."
MCPInstallLocalImage: "تصویر به‌صورت محلی موجود است؛ دریافت انجام نمی‌شود"
MCPInstallVerifyImage: "اعتبارسنجی نسخه تصویر و قرارداد سرویس مدیریت‌شده"
MCPInstallVerifiedImage: "اعتبارسنجی تصویر موفق بود"

Some files were not shown because too many files have changed in this diff Show More