From d32cbb40f6d11de7c9f7f0dfa6ef0eca9256e9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=98=AD?= Date: Wed, 28 Oct 2026 14:04:38 +0800 Subject: [PATCH] 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 --- agent/app/api/v2/app.go | 16 - agent/app/api/v2/app_install.go | 18 - agent/app/api/v2/database.go | 17 - agent/app/api/v2/database_mysql.go | 33 - agent/app/api/v2/mcp_business.go | 137 +++ agent/app/api/v2/mcp_tasks.go | 29 + agent/app/api/v2/task.go | 15 +- agent/app/dto/request/app.go | 2 - agent/app/dto/request/mcp_website.go | 14 + agent/app/dto/response/mcp_website.go | 17 + agent/app/repo/app_install.go | 7 + agent/app/repo/database.go | 29 + agent/app/repo/database_mysql.go | 5 + agent/app/repo/database_user.go | 13 + agent/app/repo/database_user_grant.go | 7 + agent/app/repo/setting.go | 19 + agent/app/repo/task.go | 25 +- agent/app/service/app.go | 5 +- agent/app/service/app_mcp.go | 54 +- agent/app/service/app_site_directory.go | 49 ++ agent/app/service/app_utils.go | 16 +- agent/app/service/backup_website.go | 2 +- agent/app/service/database_mysql.go | 142 +-- agent/app/service/database_mysql_mcp.go | 28 +- agent/app/service/mcp_tasks.go | 10 + agent/app/service/mcp_website_binding.go | 821 ++++++++++++++++++ agent/app/service/task.go | 32 +- agent/app/service/website_managed_config.go | 100 +++ agent/app/service/website_proxy.go | 84 +- agent/constant/task.go | 2 + agent/go.mod | 2 +- agent/init/router/mcp_runtime.go | 64 +- agent/init/router/router.go | 6 + agent/utils/docker/docker.go | 30 +- agent/utils/mcpruntime/docker.go | 102 ++- agent/utils/mcpruntime/lifecycle.go | 68 +- agent/utils/mcpruntime/progress.go | 63 ++ agent/utils/taskscope/policy.go | 98 +++ core/app/api/v2/mcp.go | 66 +- core/app/api/v2/mcp_enrollment.go | 11 +- core/app/api/v2/mcp_errors.go | 6 + core/app/api/v2/mcp_website.go | 84 ++ core/app/dto/mcp_runtime.go | 54 +- core/app/dto/mcp_website.go | 43 + core/app/model/mcp.go | 21 +- core/app/repo/mcp.go | 170 +++- core/app/service/mcp.go | 72 +- core/app/service/mcp_call_admission.go | 14 +- core/app/service/mcp_credential_delete.go | 57 ++ core/app/service/mcp_enrollment.go | 5 +- core/app/service/mcp_enrollment_audit.go | 5 +- core/app/service/mcp_install_task.go | 206 +++++ core/app/service/mcp_invocation_audit.go | 18 +- core/app/service/mcp_runtime.go | 169 +++- core/app/service/mcp_website.go | 483 +++++++++++ core/app/task/task.go | 7 + core/constant/common.go | 2 + core/go.mod | 2 + core/go.sum | 4 + core/i18n/lang/en.yaml | 21 + core/i18n/lang/es-ES.yaml | 21 + core/i18n/lang/fa.yaml | 21 + core/i18n/lang/ja.yaml | 21 + core/i18n/lang/ko.yaml | 21 + core/i18n/lang/lo.yaml | 21 + core/i18n/lang/ms.yaml | 21 + core/i18n/lang/pt-BR.yaml | 21 + core/i18n/lang/ru.yaml | 21 + core/i18n/lang/tr.yaml | 21 + core/i18n/lang/zh-Hant.yaml | 21 + core/i18n/lang/zh.yaml | 21 + core/init/migration/migrations/mcp.go | 5 +- core/init/router/mcp_tasks.go | 32 + core/init/router/proxy.go | 18 +- core/init/router/router.go | 4 +- core/middleware/bind_domain.go | 15 +- core/middleware/ip_limit.go | 6 +- core/middleware/mcp_domain.go | 11 + core/middleware/mcp_website.go | 76 ++ core/router/ro_mcp.go | 5 + core/server/server.go | 8 + core/utils/mcp/website_proxy.go | 25 + core/utils/mcpruntime/docker.go | 57 +- core/utils/mcpruntime/progress.go | 28 + core/utils/mcpruntime/pull.go | 97 +++ frontend/src/api/interface/panel-mcp.ts | 48 + frontend/src/api/modules/panel-mcp.ts | 14 +- frontend/src/lang/modules/en.ts | 50 +- frontend/src/lang/modules/es-es.ts | 52 +- frontend/src/lang/modules/fa.ts | 48 +- frontend/src/lang/modules/ja.ts | 50 +- frontend/src/lang/modules/ko.ts | 47 +- frontend/src/lang/modules/lo.ts | 46 +- frontend/src/lang/modules/ms.ts | 50 +- frontend/src/lang/modules/pt-br.ts | 52 +- frontend/src/lang/modules/ru.ts | 51 +- frontend/src/lang/modules/tr.ts | 51 +- frontend/src/lang/modules/zh-Hant.ts | 45 +- frontend/src/lang/modules/zh.ts | 45 +- frontend/src/utils/panel-mcp.ts | 74 +- .../views/ai/mcp/panel/credential-editor.vue | 27 +- frontend/src/views/ai/mcp/panel/index.vue | 263 ++++-- frontend/src/views/ai/mcp/panel/service.vue | 347 ++++++-- .../views/ai/mcp/panel/website-binding.vue | 542 ++++++++++++ .../src/views/ai/mcp/server/config/index.vue | 3 +- 105 files changed, 5407 insertions(+), 817 deletions(-) create mode 100644 agent/app/api/v2/mcp_business.go create mode 100644 agent/app/api/v2/mcp_tasks.go create mode 100644 agent/app/dto/request/mcp_website.go create mode 100644 agent/app/dto/response/mcp_website.go create mode 100644 agent/app/service/app_site_directory.go create mode 100644 agent/app/service/mcp_tasks.go create mode 100644 agent/app/service/mcp_website_binding.go create mode 100644 agent/app/service/website_managed_config.go create mode 100644 agent/utils/mcpruntime/progress.go create mode 100644 agent/utils/taskscope/policy.go create mode 100644 core/app/api/v2/mcp_website.go create mode 100644 core/app/dto/mcp_website.go create mode 100644 core/app/service/mcp_credential_delete.go create mode 100644 core/app/service/mcp_install_task.go create mode 100644 core/app/service/mcp_website.go create mode 100644 core/init/router/mcp_tasks.go create mode 100644 core/middleware/mcp_domain.go create mode 100644 core/middleware/mcp_website.go create mode 100644 core/utils/mcp/website_proxy.go create mode 100644 core/utils/mcpruntime/progress.go create mode 100644 core/utils/mcpruntime/pull.go create mode 100644 frontend/src/views/ai/mcp/panel/website-binding.vue diff --git a/agent/app/api/v2/app.go b/agent/app/api/v2/app.go index 7686155ad..cc2aa54fc 100644 --- a/agent/app/api/v2/app.go +++ b/agent/app/api/v2/app.go @@ -8,7 +8,6 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/utils/appicon" - "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -161,25 +160,10 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) { // @Router /apps/install [post] // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} 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 if err := helper.CheckBindAndValidate(&req, c); err != nil { 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) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/app_install.go b/agent/app/api/v2/app_install.go index b165a88cf..1ced903ce 100644 --- a/agent/app/api/v2/app_install.go +++ b/agent/app/api/v2/app_install.go @@ -1,11 +1,7 @@ package v2 import ( - "net/http" - "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/dto" @@ -340,25 +336,11 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) { // @Security Timestamp // @Router /apps/installed/info/:appInstallId [get] 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") if err != nil { helper.BadRequest(c, err) 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) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/database.go b/agent/app/api/v2/database.go index 15bcb8ebc..6e16478c8 100644 --- a/agent/app/api/v2/database.go +++ b/agent/app/api/v2/database.go @@ -2,12 +2,9 @@ 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/service" - "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -101,25 +98,11 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) { // @Param type path string true "type" // @Router /databases/db/list/:type [get] 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") if err != nil { helper.BadRequest(c, err) 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) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/database_mysql.go b/agent/app/api/v2/database_mysql.go index 4d7219974..92f1a52d1 100644 --- a/agent/app/api/v2/database_mysql.go +++ b/agent/app/api/v2/database_mysql.go @@ -3,12 +3,9 @@ package v2 import ( "context" "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/service" - "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -22,11 +19,6 @@ import ( // @Router /databases [post] // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} 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 if err := helper.CheckBindAndValidate(&req, c); err != nil { return @@ -40,17 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) { } 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 { helper.InternalServer(c, err) return @@ -531,25 +512,11 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) { // @Security Timestamp // @Router /databases/status [post] 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 if err := helper.CheckBindAndValidate(&req, c); err != nil { 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) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/mcp_business.go b/agent/app/api/v2/mcp_business.go new file mode 100644 index 000000000..5cfd64e6e --- /dev/null +++ b/agent/app/api/v2/mcp_business.go @@ -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) +} diff --git a/agent/app/api/v2/mcp_tasks.go b/agent/app/api/v2/mcp_tasks.go new file mode 100644 index 000000000..743aabfdf --- /dev/null +++ b/agent/app/api/v2/mcp_tasks.go @@ -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" +} diff --git a/agent/app/api/v2/task.go b/agent/app/api/v2/task.go index 6b0db3680..343f3b836 100644 --- a/agent/app/api/v2/task.go +++ b/agent/app/api/v2/task.go @@ -5,6 +5,8 @@ import ( "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/response" + "github.com/1Panel-dev/1Panel/agent/app/service" + "github.com/1Panel-dev/1Panel/agent/utils/taskscope" "github.com/gin-gonic/gin" ) @@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) { if err := helper.CheckBindAndValidate(&req, c); err != nil { return } - total, list, err := taskService.Page(req) + total, list, err := taskServiceForRequest(c).Page(req) if err != nil { helper.InternalServer(c, err) return @@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) { return } var res *response.FileLineContent - res, err := taskService.ReadByLine(req) + res, err := taskServiceForRequest(c).ReadByLine(req) if err != nil { helper.InternalServer(c, err) return @@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) { // @Security Timestamp // @Router /logs/tasks/executing/count [get] func (b *BaseApi) CountExecutingTasks(c *gin.Context) { - count, err := taskService.CountExecutingTask() + count, err := taskServiceForRequest(c).CountExecutingTask() if err != nil { helper.InternalServer(c, err) return } 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 +} diff --git a/agent/app/dto/request/app.go b/agent/app/dto/request/app.go index 9f4204546..e69c00d8d 100644 --- a/agent/app/dto/request/app.go +++ b/agent/app/dto/request/app.go @@ -27,8 +27,6 @@ type AppInstallCreate struct { ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURL string `json:"modelAccountBaseURL"` - MCPPreserveWebsiteDir bool `json:"-"` - AppContainerConfig NodePushConfig } diff --git a/agent/app/dto/request/mcp_website.go b/agent/app/dto/request/mcp_website.go new file mode 100644 index 000000000..1e2e78a38 --- /dev/null +++ b/agent/app/dto/request/mcp_website.go @@ -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"` +} diff --git a/agent/app/dto/response/mcp_website.go b/agent/app/dto/response/mcp_website.go new file mode 100644 index 000000000..e89e7c11e --- /dev/null +++ b/agent/app/dto/response/mcp_website.go @@ -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"` +} diff --git a/agent/app/repo/app_install.go b/agent/app/repo/app_install.go index 20ea7707f..c07ebdb80 100644 --- a/agent/app/repo/app_install.go +++ b/agent/app/repo/app_install.go @@ -38,6 +38,7 @@ type IAppInstallRepo interface { LoadBaseInfo(key string, name string) (*RootInfo, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) + GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) } func NewIAppInstallRepo() IAppInstallRepo { @@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m 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 { db := getTx(ctx).Model(&model.AppInstall{}) return db.Omit(clause.Associations).Create(&install).Error diff --git a/agent/app/repo/database.go b/agent/app/repo/database.go index 47aa09729..1b5cef500 100644 --- a/agent/app/repo/database.go +++ b/agent/app/repo/database.go @@ -6,6 +6,7 @@ import ( "strings" "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/utils/encrypt" "gorm.io/gorm" @@ -20,6 +21,8 @@ type IDatabaseRepo interface { Update(id uint, vars map[string]interface{}) error Delete(ctx context.Context, opts ...DBOption) 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 WithAppInstallID(appInstallID uint) DBOption WithTypeList(dbType string) DBOption @@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo { 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) { var database model.Database db := global.DB diff --git a/agent/app/repo/database_mysql.go b/agent/app/repo/database_mysql.go index 60c6a4dd7..782713279 100644 --- a/agent/app/repo/database_mysql.go +++ b/agent/app/repo/database_mysql.go @@ -18,6 +18,7 @@ type IMysqlRepo interface { List(opts ...DBOption) ([]model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []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 Update(id uint, vars map[string]interface{}) 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 } +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 { return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error } diff --git a/agent/app/repo/database_user.go b/agent/app/repo/database_user.go index 1f4e68824..493d78590 100644 --- a/agent/app/repo/database_user.go +++ b/agent/app/repo/database_user.go @@ -14,8 +14,10 @@ type DatabaseUserRepo struct{} type IDatabaseUserRepo interface { Get(opts ...DBOption) (model.DatabaseUser, error) + GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error) Save(user *model.DatabaseUser) error + SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error Delete(opts ...DBOption) error DeleteBy(ctx context.Context, 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 } +// 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) { var users []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 } +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 { db := global.DB for _, opt := range opts { diff --git a/agent/app/repo/database_user_grant.go b/agent/app/repo/database_user_grant.go index 18b2a4993..5415d1b4a 100644 --- a/agent/app/repo/database_user_grant.go +++ b/agent/app/repo/database_user_grant.go @@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface { Get(opts ...DBOption) (model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error) Save(grant *model.DatabaseUserGrant) error + CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error Delete(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 } +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 { return global.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { diff --git a/agent/app/repo/setting.go b/agent/app/repo/setting.go index 638112450..58fe6e64e 100644 --- a/agent/app/repo/setting.go +++ b/agent/app/repo/setting.go @@ -15,10 +15,13 @@ type SettingRepo struct{} type ISettingRepo interface { GetList(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) GetCoreValueByKey(key string) (string, error) Create(key, value string) error + CreateWithContext(ctx context.Context, setting *model.Setting) error Update(key, value string) error + UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) WithByKey(key string) DBOption UpdateOrCreate(key, value string) error @@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) { 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) { var setting model.Setting if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { diff --git a/agent/app/repo/task.go b/agent/app/repo/task.go index d0450c78a..0ddb519b8 100644 --- a/agent/app/repo/task.go +++ b/agent/app/repo/task.go @@ -19,8 +19,8 @@ type ITaskRepo interface { GetFirst(opts ...DBOption) (model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Update(ctx context.Context, task *model.Task) error - UpdateRunningTaskToFailed() error - CountExecutingTask() (int64, error) + UpdateRunningTaskToFailed(opts ...DBOption) error + CountExecutingTask(opts ...DBOption) (int64, error) Delete(opts ...DBOption) error DeleteAll() error @@ -29,6 +29,7 @@ type ITaskRepo interface { WithResourceID(id uint) DBOption WithOperate(taskOperate string) DBOption WithByStatus(status string) DBOption + WithoutScopes(scopes ...string) DBOption } func NewITaskRepo() ITaskRepo { @@ -110,18 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error { return getTaskTx(ctx).Save(&task).Error } -func (t TaskRepo) UpdateRunningTaskToFailed() error { - return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{ +func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error { + 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(), }).Error } -func (t TaskRepo) CountExecutingTask() (int64, error) { +func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) { 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 } +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 { db := global.TaskDB for _, opt := range opts { diff --git a/agent/app/service/app.go b/agent/app/service/app.go index 891adfd87..9dde9b482 100644 --- a/agent/app/service/app.go +++ b/agent/app/service/app.go @@ -59,7 +59,8 @@ type IAppService interface { } 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 { @@ -614,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript } } 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 } if err = handleOpenrestyFile(appInstall); err != nil { diff --git a/agent/app/service/app_mcp.go b/agent/app/service/app_mcp.go index 38cb9afff..2af943717 100644 --- a/agent/app/service/app_mcp.go +++ b/agent/app/service/app_mcp.go @@ -1,53 +1,19 @@ package service import ( - "errors" - "os" - "path" - "github.com/1Panel-dev/1Panel/agent/app/dto/request" "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" + "github.com/1Panel-dev/1Panel/agent/app/task" ) -func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error { - changed := errors.New("MCP website directory changed; inspect the installation before retrying") - directory, ok := req.Params["WEBSITE_DIR"].(string) - if !ok || directory == "" || global.DB == nil { - return changed +func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) { + return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory}) +} + +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 { - var setting model.Setting - 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 - }) + directory, _ := req.Params["WEBSITE_DIR"].(string) + return ensureCurrentWebsiteDirectory(directory) } diff --git a/agent/app/service/app_site_directory.go b/agent/app/service/app_site_directory.go new file mode 100644 index 000000000..af19fad3c --- /dev/null +++ b/agent/app/service/app_site_directory.go @@ -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) + }) +} diff --git a/agent/app/service/app_utils.go b/agent/app/service/app_utils.go index 2919aec42..f8837465e 100644 --- a/agent/app/service/app_utils.go +++ b/agent/app/service/app_utils.go @@ -2033,11 +2033,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) { 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 { - if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { - if req.MCPPreserveWebsiteDir { - return prepareMCPWebsiteDirectory(req) - } + if managesOpenrestySiteDirectory(app, appDetail) { if dir, ok := req.Params["WEBSITE_DIR"]; ok { siteDir := dir.(string) if siteDir == "" || !strings.HasPrefix(siteDir, "/") { diff --git a/agent/app/service/backup_website.go b/agent/app/service/backup_website.go index b9d6c3a7e..9e5c31327 100644 --- a/agent/app/service/backup_website.go +++ b/agent/app/service/backup_website.go @@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f return err } nginxConfFile := GetWebsiteConfigPath(*website) - if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil { + if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil { return err } parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) diff --git a/agent/app/service/database_mysql.go b/agent/app/service/database_mysql.go index d6e988477..4e2bc4892 100644 --- a/agent/app/service/database_mysql.go +++ b/agent/app/service/database_mysql.go @@ -433,10 +433,45 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, 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 { + 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") } @@ -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") } - cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded) - if err != nil { - return nil, err - } createItem.MysqlName = req.Database - defer cli.Close() - createInfo := client.CreateInfo{ + return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil +} + +func (creation *mysqlCreation) createInfo(version string) client.CreateInfo { + req := creation.request + return client.CreateInfo{ Name: req.Name, Format: req.Format, Collation: req.Collation, @@ -490,55 +525,16 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou Version: version, 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 { + txCtx := context.WithValue(ctx, constant.DB, tx) if req.Username != "" { key := global.CONF.Base.EncryptKey if key == "" { - var setting model.Setting - if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { + setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey")) + if err != nil { return err } key = setting.Value @@ -548,8 +544,7 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [ return err } for _, host := range hosts { - var user model.DatabaseUser - err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error + user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host)) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } @@ -558,16 +553,16 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [ if req.Description != "" { user.Description = req.Description } - if err := tx.Save(&user).Error; err != nil { + if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil { return err } 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 tx.Create(item).Error + return mysqlRepo.CreateMetadata(txCtx, item) }) } @@ -1603,10 +1598,34 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{ } 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 ( dbInfo client.DBInfo version string @@ -1614,13 +1633,6 @@ func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClien ) 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.From = databaseItem.From dbInfo.Database = database diff --git a/agent/app/service/database_mysql_mcp.go b/agent/app/service/database_mysql_mcp.go index dbbb0ee04..6263c1567 100644 --- a/agent/app/service/database_mysql_mcp.go +++ b/agent/app/service/database_mysql_mcp.go @@ -16,9 +16,35 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) } writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) defer cancel() - created, err := u.create(writeContext, req, true) + created, err := createMCPMysqlDatabase(writeContext, req) if err != nil { return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") } 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 +} diff --git a/agent/app/service/mcp_tasks.go b/agent/app/service/mcp_tasks.go new file mode 100644 index 000000000..5735bcf4c --- /dev/null +++ b/agent/app/service/mcp_tasks.go @@ -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}) +} diff --git a/agent/app/service/mcp_website_binding.go b/agent/app/service/mcp_website_binding.go new file mode 100644 index 000000000..4b945cfe9 --- /dev/null +++ b/agent/app/service/mcp_website_binding.go @@ -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) +} diff --git a/agent/app/service/task.go b/agent/app/service/task.go index a8136f681..36686e65d 100644 --- a/agent/app/service/task.go +++ b/agent/app/service/task.go @@ -8,9 +8,12 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/repo" "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 { Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) @@ -19,13 +22,18 @@ type ITaskLogService interface { CountExecutingTask() (int64, error) } -func NewITaskService() ITaskLogService { - return &TaskLogService{} +func NewITaskService(grantedScopes ...string) ITaskLogService { + return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)} } 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{ repo.WithOrderDesc("created_at"), + taskRepo.WithoutScopes(excluded...), } if 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) { - opts := []repo.DBOption{} + excluded, err := taskscope.ReadExclusions(u.grantedScopes...) + if err != nil { + return nil, err + } + opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)} if req.TaskID != "" { opts = append(opts, taskRepo.WithByID(req.TaskID)) } else { @@ -112,9 +124,17 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL } 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) { - return taskRepo.CountExecutingTask() + excluded, err := taskscope.ReadExclusions(u.grantedScopes...) + if err != nil { + return 0, err + } + return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...)) } diff --git a/agent/app/service/website_managed_config.go b/agent/app/service/website_managed_config.go new file mode 100644 index 000000000..06bd87bcf --- /dev/null +++ b/agent/app/service/website_managed_config.go @@ -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) +} diff --git a/agent/app/service/website_proxy.go b/agent/app/service/website_proxy.go index 601628029..2cbbccfd5 100644 --- a/agent/app/service/website_proxy.go +++ b/agent/app/service/website_proxy.go @@ -4,7 +4,9 @@ import ( "bytes" "errors" "fmt" + "os" "path" + "path/filepath" "strconv" "strings" @@ -23,6 +25,63 @@ import ( "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) { switch req.Operate { case "delete": @@ -51,6 +110,9 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) } fileOp := files.NewFileOp() includeDir := GetSitePath(website, SiteProxyDir) + if !websiteProxyPathAllowed(includeDir, req.Name) { + return errors.New("invalid proxy path") + } if !fileOp.Stat(includeDir) { _ = fileOp.CreateDir(includeDir, constant.DirPerm) } @@ -270,6 +332,9 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e } includeDir := GetSitePath(website, SiteProxyDir) fileOp := files.NewFileOp() + if !websiteProxyDirectoryAllowed(includeDir) { + return nil, errors.New("invalid proxy path") + } if !fileOp.Stat(includeDir) { return } @@ -282,12 +347,16 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e config *components.Config ) 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{ ID: website.ID, } - parts := strings.Split(configFile.Name, ".") - proxyConfig.Name = parts[0] - if parts[1] == "conf" { + proxyConfig.Name = name + if extension == ".conf" { proxyConfig.Enable = true } else { proxyConfig.Enable = false @@ -360,6 +429,9 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error if err != nil { 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) fileOp := files.NewFileOp() oldRewriteContent, err = fileOp.GetContent(absolutePath) @@ -405,6 +477,9 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) { return } includeDir := GetSitePath(website, SiteProxyDir) + if !websiteProxyPathAllowed(includeDir, req.Name) { + return errors.New("invalid proxy path") + } fileOp := files.NewFileOp() if !fileOp.Stat(includeDir) { return @@ -425,6 +500,9 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate) return } includeDir := GetSitePath(website, SiteProxyDir) + if !websiteProxyPathAllowed(includeDir, req.Name) { + return errors.New("invalid proxy path") + } fileOp := files.NewFileOp() if !fileOp.Stat(includeDir) { return diff --git a/agent/constant/task.go b/agent/constant/task.go index 67e84335b..5eaad4dbb 100644 --- a/agent/constant/task.go +++ b/agent/constant/task.go @@ -4,3 +4,5 @@ const ( TaskInstall = "installApp" TaskCreateWebsite = "createWebsite" ) + +const TaskScopePanelMCP = "PanelMCP" diff --git a/agent/go.mod b/agent/go.mod index d2b60328d..1f215f4a2 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -7,6 +7,7 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1 require ( github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible 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/docker/cli v29.9.0+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/v2 v2.7.0 // 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/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect diff --git a/agent/init/router/mcp_runtime.go b/agent/init/router/mcp_runtime.go index 486d38344..27b9f6e7c 100644 --- a/agent/init/router/mcp_runtime.go +++ b/agent/init/router/mcp_runtime.go @@ -2,11 +2,15 @@ package router import ( "bytes" + "context" "encoding/json" "io" "net" "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/middleware" "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" @@ -17,9 +21,38 @@ import ( func registerMCPRuntime(engine *gin.Engine) { controller := mcpruntime.New() engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) + engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService())) 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 { return func(ctx *gin.Context) { 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) { local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) if !global.IsMaster || !ok || local.Network() != "unix" { @@ -51,6 +89,30 @@ func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc { ctx.AbortWithStatus(http.StatusBadRequest) 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) if err != nil { ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) diff --git a/agent/init/router/router.go b/agent/init/router/router.go index 911e12532..791ddf196 100644 --- a/agent/init/router/router.go +++ b/agent/init/router/router.go @@ -2,6 +2,7 @@ package router import ( 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/i18n" "github.com/1Panel-dev/1Panel/agent/middleware" @@ -14,6 +15,9 @@ var ( ) func Routers() *gin.Engine { + if err := service.ConfigurePanelMCPTasks(); err != nil { + panic(err) + } Router = gin.Default() Router.Use(i18n.UseI18n()) registerMCPRuntime(Router) @@ -23,6 +27,8 @@ func Routers() *gin.Engine { PrivateGroup.Use(middleware.Certificate()) } PrivateGroup.Use(middleware.OperationResolveMeta()) + PrivateGroup.Use(v2.PanelMCPTaskAccess()) + PrivateGroup.Use(v2.MCPBusinessDispatch()) for _, router := range rou.RouterGroupApp { router.InitRouter(PrivateGroup) } diff --git a/agent/utils/docker/docker.go b/agent/utils/docker/docker.go index cdab983c4..c76220396 100644 --- a/agent/utils/docker/docker.go +++ b/agent/utils/docker/docker.go @@ -13,6 +13,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/global" + "github.com/distribution/reference" "github.com/docker/cli/cli/config" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -320,7 +321,7 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos func (c Client) PullImageWithProcess(task *task.Task, imageName string) error { options := image.PullOptions{} - if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { + if authStr, ok := RegistryAuthForImage(imageName); ok { options.RegistryAuth = authStr } return c.PullImageWithProcessAndOptions(task, imageName, options) @@ -342,7 +343,7 @@ func PullImage(imageName string) error { } defer cli.Close() options := image.PullOptions{} - if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { + if authStr, ok := RegistryAuthForImage(imageName); ok { options.RegistryAuth = authStr } out, err := cli.ImagePull(context.Background(), imageName, options) @@ -353,17 +354,20 @@ func PullImage(imageName string) error { return handlePullImageProcess(out, nil) } -func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) { - registryHost, hasRegistry := extractRegistryHost(imageName) +func RegistryAuthForImage(imageName string) (string, bool) { + named, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return "", false + } + registryHost := normalizeRegistryHost(reference.Domain(named)) cfg := config.LoadDefaultConfigFile(io.Discard) if cfg == nil { return "", false } candidates := make([]string, 0) - if hasRegistry { + if !isDockerHubRegistry(registryHost) { candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost) - } - if !hasRegistry || isDockerHubRegistry(registryHost) { + } else { candidates = append(candidates, "https://index.docker.io/v1/", "index.docker.io", @@ -413,18 +417,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 { key := strings.TrimSpace(registryKey) if key == "" { diff --git a/agent/utils/mcpruntime/docker.go b/agent/utils/mcpruntime/docker.go index 8fc323bc2..0e852d3e1 100644 --- a/agent/utils/mcpruntime/docker.go +++ b/agent/utils/mcpruntime/docker.go @@ -3,6 +3,7 @@ package mcpruntime import ( "bytes" "context" + _ "crypto/sha256" "encoding/binary" "encoding/json" "errors" @@ -15,19 +16,21 @@ import ( "strings" "sync" "time" + "unicode" "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/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/system" "github.com/docker/docker/client" - "github.com/docker/docker/errdefs" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) const ( - ImageRepository = "1panel/1panel-mcp-server" + ImageRepository = "1panel/mcp-server" ContainerName = "1panel-mcp" previousName = "1panel-mcp-previous" 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") type Request struct { - Operation string `json:"operation"` - Image string `json:"image"` - Port int `json:"port"` - Enabled bool `json:"enabled"` - Restart bool `json:"restart"` - ImageID string `json:"imageID,omitempty"` + Operation string `json:"operation"` + Image string `json:"image"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Restart bool `json:"restart"` + ImageID string `json:"imageID,omitempty"` + StreamProgress bool `json:"streamProgress,omitempty"` } type State struct { @@ -106,6 +110,7 @@ type Controller struct { platform string health func(context.Context, int) (metadata, error) checkDirectory func() error + registryAuth func(string) (string, bool) mutation chan struct{} probes chan struct{} cacheMu sync.Mutex @@ -118,25 +123,40 @@ func New() *Controller { func newController(factory func() (DockerClient, error)) *Controller { 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 { - 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") } - 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 } - if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) { - return nil - } - return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest") + return errors.New("MCP Docker image must include a tag or SHA256 digest") } 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 { - 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: 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") } 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 } if req.Operation == "status" { @@ -176,19 +196,23 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) } switch req.Operation { case "prepare": - case "catalog", "candidate_catalog": + case "catalog", "apply_catalog", "candidate_catalog": var img image.InspectResponse - img, err = c.resolve(ctx, cli, req.Image) - if err == nil && req.Operation == "catalog" { + if req.Operation != "candidate_catalog" { var current *container.InspectResponse current, err = c.owned(ctx, cli, ContainerName) - if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID { - err = errors.New("MCP image reference changed; restart the adapter before reading its catalog") + if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image { + 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 { var verified verifiedImage - verified, err = c.verify(ctx, cli, img.ID, req.Image) + verified, err = c.verify(ctx, cli, img.ID) if err == 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": err = c.rollbackAbsent(ctx, cli, req) case "pull": - err = c.pullImage(ctx, cli, req) + err = c.pullImage(ctx, cli, req, progress) case "logs": var current *container.InspectResponse 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 } +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) { info, err := cli.ContainerInspect(ctx, name) 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 { 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) if inspectErr != nil { state.Status = "unhealthy" @@ -290,9 +324,11 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) return Response{State: state} } if current == nil { + img, err := c.resolve(ctx, cli, req.Image) if err != nil { state.Reason = err.Error() } else { + state.ImageID = img.ID state.Reason = "MCP image is loaded; install it to create the managed container" } 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" 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.Reason = "MCP container does not match the configured image or listen port" 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) { state.Status = "unhealthy" 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} } -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() cached, ok := c.cache[id] c.cacheMu.Unlock() @@ -366,9 +403,6 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref strin c.cache[id] = cached 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 } diff --git a/agent/utils/mcpruntime/lifecycle.go b/agent/utils/mcpruntime/lifecycle.go index 6da1f3660..7fae857b7 100644 --- a/agent/utils/mcpruntime/lifecycle.go +++ b/agent/utils/mcpruntime/lifecycle.go @@ -10,49 +10,90 @@ import ( "strings" "time" + "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "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) if err != nil && !errdefs.IsNotFound(err) { return errors.New("MCP Docker image inspection failed") } if errdefs.IsNotFound(err) { + if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil { + return err + } pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) 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 errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) { + return errors.New("MCP Docker image registry authentication failed") + } return errors.New("MCP Docker image download failed") } 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 { 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"` ErrorDetail *struct { + Code int `json:"code"` Message string `json:"message"` } `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 } else if err != nil { return errors.New("MCP Docker image download was interrupted") } 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") } + 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) if err != nil { return err } - _, err = c.verify(ctx, cli, img.ID, req.Image) - return err + _, err = c.verify(ctx, cli, img.ID) + 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 { @@ -176,14 +217,19 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e if err = c.checkDirectory(); err != nil { 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 { return err } if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { 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 { 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") } - verified, err := c.verify(ctx, cli, target.Image, req.Image) + verified, err := c.verify(ctx, cli, target.Image) if err != nil { 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 { return err } - verified, err := c.verify(ctx, cli, current.Image, req.Image) + verified, err := c.verify(ctx, cli, current.Image) if err != nil { return err } diff --git a/agent/utils/mcpruntime/progress.go b/agent/utils/mcpruntime/progress.go new file mode 100644 index 000000000..00fabbe82 --- /dev/null +++ b/agent/utils/mcpruntime/progress.go @@ -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 +} diff --git a/agent/utils/taskscope/policy.go b/agent/utils/taskscope/policy.go new file mode 100644 index 000000000..0bacfe023 --- /dev/null +++ b/agent/utils/taskscope/policy.go @@ -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...) +} diff --git a/core/app/api/v2/mcp.go b/core/app/api/v2/mcp.go index 662ff2e06..cc980ce3d 100644 --- a/core/app/api/v2/mcp.go +++ b/core/app/api/v2/mcp.go @@ -1,6 +1,7 @@ package v2 import ( + "bytes" "context" "encoding/json" "errors" @@ -32,6 +33,41 @@ func mcpRead(c *gin.Context, value any) bool { if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { 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 { @@ -179,6 +215,21 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) { } 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) { s, owner, ok := mcpManagement(c, false, false) if !ok { @@ -207,8 +258,17 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) { mcpManagementError(c, err) return } - result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) + if result.Website == nil { + result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) + } 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) } func (b *BaseApi) MCPSettings(c *gin.Context) { @@ -268,9 +328,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) { if !mcpRead(c, &req) { return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() - result, err := service.NewMCPRuntimeService().Install(ctx, req) + result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req) if err != nil { mcpServiceError(c, err) return diff --git a/core/app/api/v2/mcp_enrollment.go b/core/app/api/v2/mcp_enrollment.go index 6b58f8d6a..e17e0d0e6 100644 --- a/core/app/api/v2/mcp_enrollment.go +++ b/core/app/api/v2/mcp_enrollment.go @@ -55,7 +55,16 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) { mcpManagementError(c, err) 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 { mcpManagementError(c, err) return diff --git a/core/app/api/v2/mcp_errors.go b/core/app/api/v2/mcp_errors.go index 2ea337be3..6f40b971c 100644 --- a/core/app/api/v2/mcp_errors.go +++ b/core/app/api/v2/mcp_errors.go @@ -31,6 +31,12 @@ func mcpManagementMessage(code string) string { key = "ErrMCPAuditUnavailable" case "MCP_ENDPOINT_UNAVAILABLE": 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) } diff --git a/core/app/api/v2/mcp_website.go b/core/app/api/v2/mcp_website.go new file mode 100644 index 000000000..964821a85 --- /dev/null +++ b/core/app/api/v2/mcp_website.go @@ -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) +} diff --git a/core/app/dto/mcp_runtime.go b/core/app/dto/mcp_runtime.go index 2f0a8b5ef..c669a1075 100644 --- a/core/app/dto/mcp_runtime.go +++ b/core/app/dto/mcp_runtime.go @@ -14,28 +14,44 @@ type MCPRuntimeSettings struct { type MCPRuntimeSettingsUpdate = MCPRuntimeSettings type MCPRuntimeInstall struct { - DockerImage string `json:"dockerImage"` - ListenPort int `json:"listenPort"` - ExternalURL string `json:"externalUrl"` - Revision uint64 `json:"revision"` + DockerImage string `json:"dockerImage"` + ListenPort int `json:"listenPort"` + ExternalURL string `json:"externalUrl"` + 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 { - RuntimeType string `json:"runtimeType"` - DockerImage string `json:"dockerImage"` - ContainerName string `json:"containerName"` - ImageID string `json:"imageId"` - Enabled bool `json:"enabled"` - Installed bool `json:"installed"` - Status string `json:"status"` - Ready bool `json:"ready"` - Reason string `json:"reason"` - AdapterVersion string `json:"adapterVersion"` - ContractVersion int `json:"contractVersion"` - Protocol string `json:"protocol"` - InternalEndpoint string `json:"internalEndpoint"` - Endpoint string `json:"endpoint"` - CanManageService bool `json:"canManageService"` + RuntimeType string `json:"runtimeType"` + DockerImage string `json:"dockerImage"` + ContainerName string `json:"containerName"` + ImageID string `json:"imageId"` + Enabled bool `json:"enabled"` + Installed bool `json:"installed"` + Status string `json:"status"` + Ready bool `json:"ready"` + Reason string `json:"reason"` + AdapterVersion string `json:"adapterVersion"` + ContractVersion int `json:"contractVersion"` + Protocol string `json:"protocol"` + InternalEndpoint string `json:"internalEndpoint"` + Endpoint string `json:"endpoint"` + CanManageService bool `json:"canManageService"` + InstallTaskID string `json:"installTaskID,omitempty"` + Installing bool `json:"installing"` + Website *MCPWebsiteStatus `json:"website,omitempty"` } type MCPRuntimeLogs struct { diff --git a/core/app/dto/mcp_website.go b/core/app/dto/mcp_website.go new file mode 100644 index 000000000..f95fefbb5 --- /dev/null +++ b/core/app/dto/mcp_website.go @@ -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"` +} diff --git a/core/app/model/mcp.go b/core/app/model/mcp.go index d725ea4e1..2e4832683 100644 --- a/core/app/model/mcp.go +++ b/core/app/model/mcp.go @@ -52,13 +52,16 @@ type MCPInvocation struct { } type MCPSetting struct { - RuntimeType string `gorm:"not null;default:docker"` - DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"` - ID uint `gorm:"primaryKey"` - Enabled bool `gorm:"not null;default:false"` - ListenPort int `gorm:"not null;default:18081"` - ExternalURL string - AccessPolicy string `gorm:"not null;default:inherit"` - AllowedIPs string `gorm:"type:text;not null;default:'[]'"` - Revision uint64 `gorm:"not null;default:1"` + RuntimeType string `gorm:"not null;default:docker"` + DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"` + ID uint `gorm:"primaryKey"` + Enabled bool `gorm:"not null;default:false"` + ListenPort int `gorm:"not null;default:18081"` + ExternalURL string + AccessPolicy string `gorm:"not null;default:inherit"` + AllowedIPs string `gorm:"type:text;not null;default:'[]'"` + 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:''"` } diff --git a/core/app/repo/mcp.go b/core/app/repo/mcp.go index af939b172..93abe8847 100644 --- a/core/app/repo/mcp.go +++ b/core/app/repo/mcp.go @@ -1,12 +1,89 @@ package repo import ( + "time" + "github.com/1Panel-dev/1Panel/core/app/model" "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 } +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) { var key model.MCPCredential 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 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 { - if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil { + if err := r.DeleteGrants(id); err != nil { return err } for _, grant := range grants { @@ -29,3 +111,89 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error { } 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 +} diff --git a/core/app/service/mcp.go b/core/app/service/mcp.go index c45dabb33..ebab9c00d 100644 --- a/core/app/service/mcp.go +++ b/core/app/service/mcp.go @@ -96,12 +96,11 @@ var NewMCPService = func() *MCPService { defer cancel() return NewMCPRuntimeService().Catalog(ctx) }, Enabled: func() (bool, error) { - var setting struct{ Enabled bool } - err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error + enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled() if errors.Is(err, gorm.ErrRecordNotFound) { 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 } -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 (s *MCPService) catalog() ([]contract.Tool, error) { unavailable := func() error { @@ -294,16 +290,8 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch) if !validMCPPage(req.Page, req.PageSize) { return nil, contract.Invalid() } - q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner) - if req.ExcludeRevoked { - 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 { + keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked) + if err != nil { return nil, err } items := []dto.MCPCredentialItem{} @@ -344,7 +332,9 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate) repeated := false mcpMutationMu.Lock() 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 key.RequestHash != requestHash { 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) { return err } - var count int64 - if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil { + count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "") + if err != nil { return err } 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 { return err } - if err = tx.Create(&key).Error; err != nil { + if err = store.CreateCredential(&key); err != nil { 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 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() defer mcpMutationMu.Unlock() return s.DB.Transaction(func(tx *gorm.DB) error { - var key model.MCPCredential - if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { + store := repo.MCPRepo{DB: tx} + key, err := store.Credential(owner.Type, owner.ID, req.ID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return contract.Forbidden() } @@ -415,11 +406,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio key.SecretVersion = 0 } 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) - if result.Error != nil { - return result.Error + affected, err := store.UpdateCredential(&key, req.Revision) + if err != nil { + return err } - if result.RowsAffected != 1 { + if affected != 1 { return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") } return nil @@ -434,8 +425,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate) return err } return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { - var count int64 - if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil { + count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID) + if err != nil { return err } if count > 0 { @@ -628,7 +619,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) ( record.Revision = key.Revision } 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") } record.Status = "denied" @@ -707,10 +698,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) ( if enabledErr != nil || !enabled { return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil) } - var admitted model.MCPCredential - 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 + admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision) if errors.Is(admissionErr, gorm.ErrRecordNotFound) { 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) { 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 != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" { return nil, contract.Invalid() } - q = q.Where("status = ?", req.Status) } - var total int64 - if err := q.Count(&total).Error; 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 { + 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 != nil { return nil, err } return map[string]any{"items": items, "total": total}, nil diff --git a/core/app/service/mcp_call_admission.go b/core/app/service/mcp_call_admission.go index b7beb3859..3b466f817 100644 --- a/core/app/service/mcp_call_admission.go +++ b/core/app/service/mcp_call_admission.go @@ -8,6 +8,7 @@ import ( "time" "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" "github.com/gin-gonic/gin" "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 { 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") } 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 { return s.DB.Transaction(func(tx *gorm.DB) error { - var latest model.MCPCredential - if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil { + data := repo.MCPRepo{DB: tx} + latest, err := data.AdmissionCredential(key.ID, key.Revision) + if err != nil { return contract.Forbidden() } if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) { return contract.Forbidden() } - var setting model.MCPSetting - if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled { + enabled, err := data.AdmissionEnabled() + if err != nil || !enabled { 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() outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode) 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") } result.InvocationID = outcome.ID diff --git a/core/app/service/mcp_credential_delete.go b/core/app/service/mcp_credential_delete.go new file mode 100644 index 000000000..914740fc8 --- /dev/null +++ b/core/app/service/mcp_credential_delete.go @@ -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) + }) +} diff --git a/core/app/service/mcp_enrollment.go b/core/app/service/mcp_enrollment.go index 6def17bf8..c23ce2174 100644 --- a/core/app/service/mcp_enrollment.go +++ b/core/app/service/mcp_enrollment.go @@ -17,6 +17,7 @@ import ( "github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/repo" paneli18n "github.com/1Panel-dev/1Panel/core/i18n" contract "github.com/1Panel-dev/1Panel/core/utils/mcp" "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 { 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) { return key, contract.Forbidden() } diff --git a/core/app/service/mcp_enrollment_audit.go b/core/app/service/mcp_enrollment_audit.go index b6a057e08..788752d26 100644 --- a/core/app/service/mcp_enrollment_audit.go +++ b/core/app/service/mcp_enrollment_audit.go @@ -7,6 +7,7 @@ import ( "github.com/1Panel-dev/1Panel/core/app/auth" "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" contract "github.com/1Panel-dev/1Panel/core/utils/mcp" "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 { 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 } id := "invalid" @@ -58,7 +59,7 @@ func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action if s.EnrollmentAudit != nil { err = s.EnrollmentAudit(db, record) } else { - err = db.Create(record).Error + err = (repo.MCPRepo{DB: db}).CreateEnrollmentAudit(record) } if err != nil { return failed diff --git a/core/app/service/mcp_install_task.go b/core/app/service/mcp_install_task.go new file mode 100644 index 000000000..2f4b8ae03 --- /dev/null +++ b/core/app/service/mcp_install_task.go @@ -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 +} diff --git a/core/app/service/mcp_invocation_audit.go b/core/app/service/mcp_invocation_audit.go index 2912c3bf9..bab45a9fb 100644 --- a/core/app/service/mcp_invocation_audit.go +++ b/core/app/service/mcp_invocation_audit.go @@ -4,23 +4,16 @@ import ( "time" "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 { var err error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { time.Sleep(time.Duration(attempt) * 50 * time.Millisecond) } - // Only compensate the audit write. The business operation must never be replayed. - 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 + err = (repo.MCPRepo{DB: s.DB}).UpdateRunningInvocation(record) if err == nil { return nil } @@ -28,12 +21,7 @@ func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error { 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 { now := s.Now() - return s.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": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error + return (repo.MCPRepo{DB: s.DB}).UpdateInterruptedInvocations(processStartedAt, "failed", "MCP_OUTCOME_UNKNOWN", now) } diff --git a/core/app/service/mcp_runtime.go b/core/app/service/mcp_runtime.go index caa9d1884..29bde1dd9 100644 --- a/core/app/service/mcp_runtime.go +++ b/core/app/service/mcp_runtime.go @@ -8,20 +8,35 @@ import ( "reflect" "strings" "sync" + "sync/atomic" "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/global" contract "github.com/1Panel-dev/1Panel/core/utils/mcp" "github.com/1Panel-dev/1Panel/core/utils/mcpruntime" "gorm.io/gorm" - "gorm.io/gorm/clause" ) var mcpRuntimeMutation sync.Mutex +var mcpInstalling atomic.Bool 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 { Prepare(context.Context) error Catalog(context.Context) ([]contract.Tool, error) @@ -40,6 +55,14 @@ type mcpRuntimeSnapshot struct { 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) { snapshot := mcpRuntimeSnapshot{settings: settings} 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 if state.Installed { - if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil { + if state.AdapterVersion == "" || !mcpruntime.ValidDockerImageID(state.ImageID) { snapshot.unavailable = true 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 { newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager + websiteTransport mcpWebsiteTransport } 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, AccessPolicy: "inherit", AllowedIPs: []string{}, } - var saved model.MCPSetting - err := global.DB.WithContext(ctx).First(&saved, 1).Error + saved, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting() if errors.Is(err, gorm.ErrRecordNotFound) { 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) { - mcpRuntimeMutation.Lock() + if err := lockMCPRuntimeMutation(); err != nil { + return dto.MCPRuntimeSettings{}, err + } defer mcpRuntimeMutation.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) defer cancel() @@ -136,6 +161,13 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin if req.Revision != old.Revision { 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 { 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 { 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} - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { + if err := store.CreateSettingIfAbsent(&seed); err != nil { return err } - result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{ - "runtime_type": "docker", "docker_image": req.DockerImage, - "enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1, - "access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs), - }) - if result.Error != nil { - return result.Error + rowsAffected, err := store.UpdateRuntimeSetting(model.MCPSetting{ + RuntimeType: "docker", DockerImage: req.DockerImage, + Enabled: req.Enabled, ListenPort: req.ListenPort, ExternalURL: req.ExternalURL, Revision: revision + 1, + AccessPolicy: req.AccessPolicy, AllowedIPs: encodeMCPIPs(req.AllowedIPs), + }, revision) + if err != nil { + return err } - if result.RowsAffected != 1 { + if rowsAffected != 1 { return ErrMCPSettingsRevision } return nil @@ -212,16 +245,27 @@ func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, e } 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, 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, 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) { - mcpRuntimeMutation.Lock() + if err := lockMCPRuntimeMutation(); err != nil { + return dto.MCPRuntimeStatus{}, err + } defer mcpRuntimeMutation.Unlock() settings, err := s.Settings() 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) { - mcpRuntimeMutation.Lock() + if err := lockMCPRuntimeMutation(); err != nil { + return dto.MCPRuntimeStatus{}, err + } 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 { return dto.MCPRuntimeStatus{}, err } - if req.Revision != old.Revision { - 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()) - } + mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "snapshot"}) snapshot, err := s.snapshot(ctx, old) if err != nil { return dto.MCPRuntimeStatus{}, err @@ -263,6 +333,15 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta if !snapshot.installed { 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) rollback := func(authoritative dto.MCPRuntimeSettings) error { 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) } recoverFailure := func(operationErr error) error { + mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "rollback"}) current, loadErr := s.Settings() 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) } - 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) } + mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "installed"}) 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) { settings, err := s.Settings() if err != nil { diff --git a/core/app/service/mcp_website.go b/core/app/service/mcp_website.go new file mode 100644 index 000000000..954729a96 --- /dev/null +++ b/core/app/service/mcp_website.go @@ -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 +} diff --git a/core/app/task/task.go b/core/app/task/task.go index 2bc5d866d..96c4a043e 100644 --- a/core/app/task/task.go +++ b/core/app/task/task.go @@ -80,6 +80,7 @@ const ( TaskScopeCluster = "Cluster" TaskScopeAppInstall = "AppInstallTask" TaskScopeAI = "AI" + TaskScopePanelMCP = "PanelMCP" ) func GetTaskName(resourceName, operate, scope string) string { @@ -321,6 +322,12 @@ func (t *Task) DeleteLogFile() { _ = os.Remove(t.Task.LogFile) } +func (t *Task) Close() { + if t.logFile != nil { + _ = t.logFile.Close() + } +} + func (t *Task) LogWithStatus(msg string, err error) { if err != nil { t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err)) diff --git a/core/constant/common.go b/core/constant/common.go index 3f0f9e7af..3ae70b328 100644 --- a/core/constant/common.go +++ b/core/constant/common.go @@ -78,6 +78,8 @@ var WebUrlMap = map[string]struct{}{ "/ai/gpu/current": {}, "/ai/gpu/history": {}, "/ai/mcp": {}, + "/ai/mcp/server": {}, + "/ai/mcp/1panel": {}, "/ai/agents/agent": {}, "/containers": {}, diff --git a/core/go.mod b/core/go.mod index c6a1813b9..fca525c3e 100644 --- a/core/go.mod +++ b/core/go.mod @@ -7,6 +7,7 @@ require ( github.com/coreos/go-oidc/v3 v3.20.0 github.com/creack/pty v1.1.24 github.com/crewjam/saml v0.5.1 + github.com/distribution/reference v0.6.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gin-contrib/gzip v1.2.6 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/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.3.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect diff --git a/core/go.sum b/core/go.sum index c17df174a..bedbd71f6 100644 --- a/core/go.sum +++ b/core/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/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/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/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml index a318788e4..6147d3205 100644 --- a/core/i18n/lang/en.yaml +++ b/core/i18n/lang/en.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the T ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked." ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing." 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" diff --git a/core/i18n/lang/es-ES.yaml b/core/i18n/lang/es-ES.yaml index f3024edc6..1858892f6 100644 --- a/core/i18n/lang/es-ES.yaml +++ b/core/i18n/lang/es-ES.yaml @@ -542,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." 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." +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" diff --git a/core/i18n/lang/fa.yaml b/core/i18n/lang/fa.yaml index 058531947..92a4cabb4 100644 --- a/core/i18n/lang/fa.yaml +++ b/core/i18n/lang/fa.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن ن ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است." ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات 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: "اعتبارسنجی تصویر موفق بود" diff --git a/core/i18n/lang/ja.yaml b/core/i18n/lang/ja.yaml index 81d23fd10..6da636238 100644 --- a/core/i18n/lang/ja.yaml +++ b/core/i18n/lang/ja.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。" ErrMCPAuditUnavailable: "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: "連携する Web サイトを作成" +MCPInstallWebsiteFailed: "MCP サービスはインストールされましたが、Web サイトの連携に失敗しました。サービス設定で Web サイトの設定を確認してください。" +MCPInstallInterrupted: "パネルの再起動によりインストールが中断されました。操作を続ける前にサービスの状態を確認してください。" +MCPInstallFailed: "MCP のインストールに失敗しました。Docker、イメージ、パネルサービスのログを確認してください。" +MCPInstallRecoveryFailed: "インストールに失敗し、以前のサービスを完全に復元できませんでした。操作を続ける前に MCP サービスの状態を確認してください。" +MCPInstallLocalImage: "ローカルにイメージが存在するため、ダウンロードをスキップ" +MCPInstallVerifyImage: "イメージのバージョンと管理対象サービスの契約を検証" +MCPInstallVerifiedImage: "イメージの検証に成功しました" diff --git a/core/i18n/lang/ko.yaml b/core/i18n/lang/ko.yaml index b4a027f69..31bb72dd7 100644 --- a/core/i18n/lang/ko.yaml +++ b/core/i18n/lang/ko.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Toke ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다." ErrMCPAuditUnavailable: "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: "이미지 검증 통과" diff --git a/core/i18n/lang/lo.yaml b/core/i18n/lang/lo.yaml index 6740045d0..9371d3057 100644 --- a/core/i18n/lang/lo.yaml +++ b/core/i18n/lang/lo.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ." ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ 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: "ກວດສອບອິມເມດຜ່ານແລ້ວ" diff --git a/core/i18n/lang/ms.yaml b/core/i18n/lang/ms.yaml index c97b32e1e..86902d445 100644 --- a/core/i18n/lang/ms.yaml +++ b/core/i18n/lang/ms.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Token MCP yang disimpan tidak dapat dinyahsulit. Jana semu ErrMCPCredentialInactive: "Kelayakan MCP telah dinyahaktifkan, tamat tempoh atau dibatalkan." ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan." ErrMCPEndpointUnavailable: "Konfigurasikan alamat luaran MCP yang sah terlebih dahulu." +ErrMCPWebsitePending: "Hasil operasi laman web belum dapat dipastikan. Muat semula status pautan sebelum mencuba semula operasi asal." +ErrMCPWebsiteUnavailable: "Pautan laman web tidak tersedia. Semak status laman web dan konfigurasi OpenResty." +ErrMCPWebsiteServiceRequired: "Pasang perkhidmatan 1Panel MCP terlebih dahulu." + +# MCP installation tasks +PanelMCP: "1Panel MCP" +MCPInstallPrepare: "Semak perkhidmatan MCP semasa" +MCPInstallPullImage: "Semak dan tarik imej" +MCPInstallCatalog: "Sahkan kontrak alat" +MCPInstallStarting: "Mulakan bekas dan semak kesihatannya" +MCPInstallSaving: "Simpan konfigurasi perkhidmatan" +MCPInstallCompleted: "Pemasangan perkhidmatan MCP selesai" +MCPInstallRollback: "Pemasangan gagal; pulihkan perkhidmatan MCP sebelumnya" +MCPInstallBindWebsite: "Cipta laman web yang dipautkan" +MCPInstallWebsiteFailed: "Perkhidmatan MCP telah dipasang, tetapi pautan laman web gagal. Betulkan konfigurasi laman web dalam tetapan perkhidmatan." +MCPInstallInterrupted: "Pemasangan terganggu kerana panel dimulakan semula. Semak status perkhidmatan sebelum meneruskan." +MCPInstallFailed: "Pemasangan MCP gagal. Semak Docker, imej dan log perkhidmatan panel." +MCPInstallRecoveryFailed: "Pemasangan gagal dan perkhidmatan sebelumnya tidak dapat dipulihkan sepenuhnya. Semak status perkhidmatan MCP sebelum meneruskan." +MCPInstallLocalImage: "Imej sudah tersedia secara setempat; langkau muat turun" +MCPInstallVerifyImage: "Sahkan versi imej dan kontrak perkhidmatan terurus" +MCPInstallVerifiedImage: "Pengesahan imej berjaya" diff --git a/core/i18n/lang/pt-BR.yaml b/core/i18n/lang/pt-BR.yaml index 9a0aec558..1dae6a82b 100644 --- a/core/i18n/lang/pt-BR.yaml +++ b/core/i18n/lang/pt-BR.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Não foi possível descriptografar o Token MCP armazenado. ErrMCPCredentialInactive: "A credencial MCP está desabilitada, expirada ou revogada." ErrMCPAuditUnavailable: "A auditoria de operações MCP está indisponível. Verifique o resultado antes de continuar." ErrMCPEndpointUnavailable: "Configure primeiro um endereço externo MCP válido." +ErrMCPWebsitePending: "O resultado da operação do site é incerto. Atualize o estado do vínculo antes de repetir a operação original." +ErrMCPWebsiteUnavailable: "O vínculo do site está indisponível. Verifique o estado do site e a configuração do OpenResty." +ErrMCPWebsiteServiceRequired: "Instale primeiro o serviço 1Panel MCP." + +# MCP installation tasks +PanelMCP: "1Panel MCP" +MCPInstallPrepare: "Verificar o serviço MCP atual" +MCPInstallPullImage: "Verificar e baixar a imagem" +MCPInstallCatalog: "Validar o contrato das ferramentas" +MCPInstallStarting: "Iniciar o contêiner e verificar sua integridade" +MCPInstallSaving: "Salvar a configuração do serviço" +MCPInstallCompleted: "Instalação do serviço MCP concluída" +MCPInstallRollback: "A instalação falhou; restaurar o serviço MCP anterior" +MCPInstallBindWebsite: "Criar o site vinculado" +MCPInstallWebsiteFailed: "O serviço MCP está instalado, mas a vinculação do site falhou. Resolva a configuração do site nas configurações do serviço." +MCPInstallInterrupted: "Uma reinicialização do painel interrompeu a instalação. Verifique o estado do serviço antes de continuar." +MCPInstallFailed: "A instalação do MCP falhou. Verifique o Docker, a imagem e os logs do serviço do painel." +MCPInstallRecoveryFailed: "A instalação falhou e o serviço anterior não pôde ser totalmente restaurado. Verifique o estado do serviço MCP antes de continuar." +MCPInstallLocalImage: "A imagem já existe localmente; ignorar o download" +MCPInstallVerifyImage: "Validar a versão da imagem e o contrato do serviço gerenciado" +MCPInstallVerifiedImage: "A imagem passou na validação" diff --git a/core/i18n/lang/ru.yaml b/core/i18n/lang/ru.yaml index 7015c6bbe..46510b890 100644 --- a/core/i18n/lang/ru.yaml +++ b/core/i18n/lang/ru.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Не удалось расшифровать сохран ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны." ErrMCPAuditUnavailable: "Аудит операций 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: "Образ успешно проверен" diff --git a/core/i18n/lang/tr.yaml b/core/i18n/lang/tr.yaml index 9676fe3c7..25f46e08f 100644 --- a/core/i18n/lang/tr.yaml +++ b/core/i18n/lang/tr.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Kaydedilen MCP Token çözülemiyor. Token'ı yeniden olu ErrMCPCredentialInactive: "MCP kimlik bilgileri devre dışı, süresi dolmuş veya iptal edilmiş." ErrMCPAuditUnavailable: "MCP işlem denetimi kullanılamıyor. Devam etmeden önce işlem sonucunu doğrulayın." ErrMCPEndpointUnavailable: "Önce geçerli bir harici MCP adresi yapılandırın." +ErrMCPWebsitePending: "Web sitesi işleminin sonucu doğrulanamadı. İlk işlemi yeniden denemeden önce bağlantı durumunu yenileyin." +ErrMCPWebsiteUnavailable: "Web sitesi bağlantısı kullanılamıyor. Site durumunu ve OpenResty yapılandırmasını kontrol edin." +ErrMCPWebsiteServiceRequired: "Önce 1Panel MCP hizmetini yükleyin." + +# MCP installation tasks +PanelMCP: "1Panel MCP" +MCPInstallPrepare: "Geçerli MCP hizmetini kontrol et" +MCPInstallPullImage: "İmajı kontrol et ve indir" +MCPInstallCatalog: "Araç sözleşmesini doğrula" +MCPInstallStarting: "Konteyneri başlat ve sağlık durumunu kontrol et" +MCPInstallSaving: "Hizmet yapılandırmasını kaydet" +MCPInstallCompleted: "MCP hizmetinin kurulumu tamamlandı" +MCPInstallRollback: "Kurulum başarısız oldu; önceki MCP hizmetini geri yükle" +MCPInstallBindWebsite: "Bağlı web sitesini oluştur" +MCPInstallWebsiteFailed: "MCP hizmeti kuruldu ancak web sitesi bağlanamadı. Web sitesi yapılandırmasını hizmet ayarlarından düzenleyin." +MCPInstallInterrupted: "Panelin yeniden başlatılması kurulumu kesintiye uğrattı. Devam etmeden önce hizmet durumunu kontrol edin." +MCPInstallFailed: "MCP kurulumu başarısız oldu. Docker, imaj ve panel hizmeti günlüklerini kontrol edin." +MCPInstallRecoveryFailed: "Kurulum başarısız oldu ve önceki hizmet tam olarak geri yüklenemedi. Devam etmeden önce MCP hizmetinin durumunu kontrol edin." +MCPInstallLocalImage: "İmaj yerel olarak mevcut; indirmeyi atla" +MCPInstallVerifyImage: "İmaj sürümünü ve yönetilen hizmet sözleşmesini doğrula" +MCPInstallVerifiedImage: "İmaj doğrulaması başarılı" diff --git a/core/i18n/lang/zh-Hant.yaml b/core/i18n/lang/zh-Hant.yaml index c9c68d10f..d5ad312f4 100644 --- a/core/i18n/lang/zh-Hant.yaml +++ b/core/i18n/lang/zh-Hant.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "儲存的 MCP Token 無法解密,請重新產生 Token ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。" ErrMCPAuditUnavailable: "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: "映像驗證通過" diff --git a/core/i18n/lang/zh.yaml b/core/i18n/lang/zh.yaml index 1953d2ffb..43c3b590e 100644 --- a/core/i18n/lang/zh.yaml +++ b/core/i18n/lang/zh.yaml @@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "存储的 MCP Token 无法解密,请重新生成 Token ErrMCPCredentialInactive: "MCP 凭据已停用、过期或撤销。" ErrMCPAuditUnavailable: "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: "镜像校验通过" diff --git a/core/init/migration/migrations/mcp.go b/core/init/migration/migrations/mcp.go index 860c439ac..75929dcb0 100644 --- a/core/init/migration/migrations/mcp.go +++ b/core/init/migration/migrations/mcp.go @@ -7,8 +7,11 @@ import ( ) var AddPanelMCP = &gormigrate.Migration{ - ID: "20261008-panel-mcp", + ID: "20261009-panel-mcp", Migrate: func(tx *gorm.DB) error { + if err := tx.Migrator().DropTable(&model.MCPSetting{}); err != nil { + return err + } return tx.AutoMigrate(&model.MCPCredential{}, &model.MCPGrant{}, &model.MCPInvocation{}, &model.MCPSetting{}) }, } diff --git a/core/init/router/mcp_tasks.go b/core/init/router/mcp_tasks.go new file mode 100644 index 000000000..37d261fee --- /dev/null +++ b/core/init/router/mcp_tasks.go @@ -0,0 +1,32 @@ +package router + +import ( + "net/http" + + "github.com/1Panel-dev/1Panel/core/app/service" + "github.com/gin-gonic/gin" +) + +const panelMCPTaskAccessHeader = "X-Panel-MCP-Task-Access" + +func panelMCPTaskProxyPolicy() agentProxyPolicy { + return agentProxyPolicy{ + clear: func(req *http.Request) { req.Header.Del(panelMCPTaskAccessHeader) }, + grant: setPanelMCPTaskAccess, + } +} + +func setPanelMCPTaskAccess(c *gin.Context, local bool) { + if !local || !isPanelMCPTaskEndpoint(c.Request.Method, c.Request.URL.Path) { + return + } + owner, err := service.NewMCPService().ManagementOwner(c, false) + if err == nil && owner.IsSuperAdmin { + c.Request.Header.Set(panelMCPTaskAccessHeader, "1") + } +} + +func isPanelMCPTaskEndpoint(method, reqPath string) bool { + return method == http.MethodPost && (reqPath == "/api/v2/logs/tasks/search" || reqPath == "/api/v2/logs/tasks/read") || + method == http.MethodGet && reqPath == "/api/v2/logs/tasks/executing/count" +} diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index 653603e37..13dac611d 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -32,10 +32,21 @@ var loadLocalTerminalCapabilities = func(ctx context.Context) (interface{}, erro return proxy_local.NewLocalClientWithContext(ctx, terminalsession.CapabilityPath, http.MethodGet, nil, nil, 5*time.Second) } -func Proxy() gin.HandlerFunc { +type agentProxyPolicy struct { + clear func(*http.Request) + grant func(*gin.Context, bool) +} + +func Proxy(policies ...agentProxyPolicy) gin.HandlerFunc { + policies = append([]agentProxyPolicy(nil), policies...) return func(c *gin.Context) { terminalsession.ClearForwardedHeaders(c) mcpapi.ClearForwardedHeaders(c.Request.Header) + for _, policy := range policies { + if policy.clear != nil { + policy.clear(c.Request) + } + } if mcpapi.HasIdentity(c.Request) && !mcpapi.IsRequest(c.Request) { c.AbortWithStatus(http.StatusForbidden) return @@ -105,6 +116,11 @@ func Proxy() gin.HandlerFunc { helper.ErrorWithDetail(c, http.StatusForbidden, "ErrProxy", errInternalOnlyAgentEndpoint) return } + for _, policy := range policies { + if policy.grant != nil { + policy.grant(c, useLocalAgent) + } + } if reqPath == "/api/v2/hosts/terminal/local" && useLocalAgent { proxyLocalAgent(c) diff --git a/core/init/router/router.go b/core/init/router/router.go index 7c289e965..0b6a0ee0b 100644 --- a/core/init/router/router.go +++ b/core/init/router/router.go @@ -74,7 +74,7 @@ func Routers() *gin.Engine { Router.Use(middleware.MCPAPIIdentity()) Router.Use(i18n.UseI18n()) Router.Use(middleware.WhiteAllow()) - Router.Use(middleware.BindDomain()) + Router.Use(middleware.BindDomain(middleware.MCPDomainPolicy)) swaggerRouter := Router.Group("1panel") docs.SwaggerInfo.BasePath = "/api/v2" @@ -98,7 +98,7 @@ func Routers() *gin.Engine { Router.Use(middleware.PasswordExpired()) Router.Use(middleware.CSRFTokenGuard()) Router.Use(xpack.AuthProvider.CoreRBACMiddlewares()...) - Router.Use(Proxy()) + Router.Use(Proxy(panelMCPTaskProxyPolicy())) PrivateGroup := Router.Group("/api/v2/core") PrivateGroup.Use(middleware.SetPasswordPublicKey()) diff --git a/core/middleware/bind_domain.go b/core/middleware/bind_domain.go index 09a29af92..65bbdd362 100644 --- a/core/middleware/bind_domain.go +++ b/core/middleware/bind_domain.go @@ -5,16 +5,21 @@ import ( "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" "github.com/1Panel-dev/1Panel/core/app/repo" - "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/1Panel-dev/1Panel/core/utils/security" "github.com/gin-gonic/gin" ) -func BindDomain() gin.HandlerFunc { +// DomainPolicy is configured by the router and must only trust server-verified state. +type DomainPolicy func(*gin.Context) bool + +func BindDomain(policies ...DomainPolicy) gin.HandlerFunc { + policies = append([]DomainPolicy(nil), policies...) return func(c *gin.Context) { - if mcpapi.IsRequest(c.Request) && c.GetBool("MCP_API_AUTH") { - c.Next() - return + for _, policy := range policies { + if policy != nil && policy(c) { + c.Next() + return + } } localRequest := c.GetBool("LOCAL_REQUEST") if localRequest { diff --git a/core/middleware/ip_limit.go b/core/middleware/ip_limit.go index 3382d895b..3fa362b7c 100644 --- a/core/middleware/ip_limit.go +++ b/core/middleware/ip_limit.go @@ -37,7 +37,11 @@ func WhiteAllow() gin.HandlerFunc { helper.InternalServer(c, err) return } - clientIP := common.ResolveClientIP(c, trustedProxies) + clientIP, err := resolveMCPWebsiteClientIP(c, trustedProxies) + if err != nil { + rejectMCPWebsiteSource(c, err) + return + } if c.Request.URL.Path == "/mcp" { c.Set("MCP_CLIENT_IP", clientIP) var policy model.MCPSetting diff --git a/core/middleware/mcp_domain.go b/core/middleware/mcp_domain.go new file mode 100644 index 000000000..e0e8d8085 --- /dev/null +++ b/core/middleware/mcp_domain.go @@ -0,0 +1,11 @@ +package middleware + +import ( + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" + "github.com/gin-gonic/gin" +) + +func MCPDomainPolicy(c *gin.Context) bool { + return c.Request.URL.Path == "/mcp" && c.GetBool("MCP_WEBSITE_PROXY") || + mcpapi.IsRequest(c.Request) && c.GetBool("MCP_API_AUTH") +} diff --git a/core/middleware/mcp_website.go b/core/middleware/mcp_website.go new file mode 100644 index 000000000..b7e94b719 --- /dev/null +++ b/core/middleware/mcp_website.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "crypto/hmac" + "errors" + "net" + "strings" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/common" + "github.com/1Panel-dev/1Panel/core/utils/encrypt" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" +) + +func resolveMCPWebsiteClientIP(c *gin.Context, trustedProxies string) (string, error) { + keys := c.Request.Header.Values(contract.WebsiteProxyKeyHeader) + peers := c.Request.Header.Values(contract.WebsiteProxyPeerHeader) + c.Request.Header.Del(contract.WebsiteProxyKeyHeader) + c.Request.Header.Del(contract.WebsiteProxyPeerHeader) + if c.Request.URL.Path != "/mcp" || len(keys) == 0 && len(peers) == 0 { + return common.ResolveClientIP(c, trustedProxies), nil + } + if len(keys) != 1 || len(peers) != 1 || !isLocalMCPWebsitePeer(common.GetRealClientIP(c)) { + return "", contract.Forbidden() + } + peer := net.ParseIP(strings.TrimSpace(peers[0])) + if peer == nil { + return "", contract.Forbidden() + } + if global.DB == nil { + return "", contract.Fail("MCP_POLICY_UNAVAILABLE", 503, "MCP source policy is unavailable") + } + var setting model.MCPSetting + if err := global.DB.Select("website_id", "website_binding_id").First(&setting, 1).Error; err != nil { + return "", contract.Fail("MCP_POLICY_UNAVAILABLE", 503, "MCP source policy is unavailable") + } + if setting.WebsiteID == 0 { + return "", contract.Forbidden() + } + masterKey, err := encrypt.APIKeyEncryptionKey() + if err != nil { + return "", contract.Fail("MCP_POLICY_UNAVAILABLE", 503, "MCP source policy is unavailable") + } + expected, err := contract.WebsiteProxyKey(masterKey, setting.WebsiteBindingID) + if err != nil || !hmac.Equal([]byte(expected), []byte(keys[0])) { + return "", contract.Forbidden() + } + request := c.Request.Clone(c.Request.Context()) + request.RemoteAddr = net.JoinHostPort(peer.String(), "0") + proxyContext := &gin.Context{Request: request} + c.Set("MCP_WEBSITE_PROXY", true) + return common.ResolveClientIP(proxyContext, trustedProxies), nil +} + +func isLocalMCPWebsitePeer(value string) bool { + peer := net.ParseIP(value) + if peer == nil { + return false + } + if peer.IsLoopback() { + return true + } + bound := net.ParseIP(strings.Trim(global.CONF.Conn.BindAddress, "[]")) + return bound != nil && !bound.IsUnspecified() && bound.Equal(peer) +} + +func rejectMCPWebsiteSource(c *gin.Context, err error) { + var known *contract.Error + if errors.As(err, &known) && known.Status == 503 { + c.AbortWithStatusJSON(503, gin.H{"code": "MCP_POLICY_UNAVAILABLE", "message": "MCP source policy is unavailable"}) + return + } + c.AbortWithStatusJSON(403, gin.H{"code": "MCP_SOURCE_DENIED", "message": "MCP website proxy identity is invalid"}) +} diff --git a/core/router/ro_mcp.go b/core/router/ro_mcp.go index 16966e927..4bb7ac7d4 100644 --- a/core/router/ro_mcp.go +++ b/core/router/ro_mcp.go @@ -19,11 +19,16 @@ func (*PanelMCPRouter) InitRouter(group *gin.RouterGroup) { r.POST("service/operate", b.OperateMCPService) r.POST("service/install", b.InstallMCPService) r.GET("service/logs", b.MCPServiceLogs) + r.GET("website/status", b.MCPWebsiteStatus) + r.POST("website/bind", b.BindMCPWebsite) + r.POST("website/update", b.UpdateMCPWebsite) + r.POST("website/unbind", b.UnbindMCPWebsite) r.POST("credentials/search", b.SearchMCPCredentials) r.POST("credentials/create", b.CreateMCPCredential) r.POST("credentials/update", b.UpdateMCPCredential) r.POST("credentials/status", b.StatusMCPCredential) r.POST("credentials/revoke", b.RevokeMCPCredential) + r.POST("credentials/delete", b.DeleteMCPCredential) r.POST("credentials/reveal", b.RevealMCPCredential) r.POST("credentials/rotate", b.RotateMCPCredential) r.POST("credentials/export", b.ExportMCPCredential) diff --git a/core/server/server.go b/core/server/server.go index d9315dfc9..47c840d5d 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -90,6 +90,9 @@ func Start() { } else if err := router.StartMCPBridge(); err != nil { global.LOG.Errorf("MCP runtime bridge is unavailable: %v", err) } + if err := service.NewMCPRuntimeService().RecoverInstallationTasks(processStartedAt); err != nil { + global.LOG.Errorf("MCP installation task recovery failed: %v", err) + } if runtime.GOOS == "linux" { go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -97,6 +100,11 @@ func Start() { if err := service.NewMCPRuntimeService().PrepareOnStartup(ctx); err != nil { global.LOG.Warnf("MCP Docker runtime preparation failed: %v", err) } + websiteCtx, websiteCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer websiteCancel() + if err := service.NewMCPRuntimeService().SyncWebsite(websiteCtx); err != nil { + global.LOG.Warnf("MCP website proxy synchronization failed: %v", err) + } }() } type tcpKeepAliveListener struct { diff --git a/core/utils/mcp/website_proxy.go b/core/utils/mcp/website_proxy.go new file mode 100644 index 000000000..d118186a1 --- /dev/null +++ b/core/utils/mcp/website_proxy.go @@ -0,0 +1,25 @@ +package mcp + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/google/uuid" +) + +const ( + WebsiteProxyKeyHeader = "X-Panel-MCP-Website-Key" + WebsiteProxyPeerHeader = "X-Panel-MCP-Website-Peer" +) + +func WebsiteProxyKey(masterKey, bindingID string) (string, error) { + id, err := uuid.Parse(bindingID) + if masterKey == "" || err != nil || id == uuid.Nil || id.String() != bindingID { + return "", errors.New("MCP website proxy identity is unavailable") + } + mac := hmac.New(sha256.New, []byte(masterKey)) + mac.Write([]byte("1panel-mcp-website:v1:" + bindingID)) + return hex.EncodeToString(mac.Sum(nil)), nil +} diff --git a/core/utils/mcpruntime/docker.go b/core/utils/mcpruntime/docker.go index 4402de0d7..f09d101aa 100644 --- a/core/utils/mcpruntime/docker.go +++ b/core/utils/mcpruntime/docker.go @@ -9,16 +9,17 @@ import ( "net" "net/http" "regexp" - "strings" "sync" "time" + "unicode" contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/distribution/reference" ) const ( - DockerImageRepository = "1panel/1panel-mcp-server" - DefaultDockerImage = DockerImageRepository + ":v0.0.1" + DockerImageRepository = "1panel/mcp-server" + DefaultDockerImage = DockerImageRepository + ":v1.0.0" DockerContainerName = "1panel-mcp" dockerAgentEndpoint = "http://agent/internal/mcp/docker" ) @@ -26,18 +27,33 @@ const ( var dockerDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) func ValidateDockerImage(value string) error { - if len(value) > 180 { - return errors.New("invalid MCP Docker image") + invalid := errors.New("MCP Docker image must be a valid image reference with an explicit tag or SHA256 digest") + if value == "" || len(value) > 512 { + return invalid } - if strings.HasPrefix(value, DockerImageRepository+"@") && dockerDigestPattern.MatchString(strings.TrimPrefix(value, DockerImageRepository+"@")) { + for _, char := range value { + if unicode.IsSpace(char) || unicode.IsControl(char) { + return invalid + } + } + named, err := reference.ParseNormalizedNamed(value) + if err != nil { + return invalid + } + if digested, ok := named.(reference.Digested); ok { + if !ValidDockerImageID(digested.Digest().String()) { + return invalid + } return nil } - if strings.HasPrefix(value, DockerImageRepository+":") && versionPattern.MatchString(strings.TrimPrefix(value, DockerImageRepository+":")) { - return nil + if _, tagged := named.(reference.Tagged); !tagged { + return invalid } - return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest") + return nil } +func ValidDockerImageID(value string) bool { return dockerDigestPattern.MatchString(value) } + type DockerManager struct { port int image string @@ -47,12 +63,13 @@ type DockerManager struct { } type dockerRequest struct { - Operation string `json:"operation"` - Image string `json:"image"` - Port int `json:"port"` - Enabled bool `json:"enabled"` - Restart bool `json:"restart"` - ImageID string `json:"imageID,omitempty"` + Operation string `json:"operation"` + Image string `json:"image"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Restart bool `json:"restart"` + ImageID string `json:"imageID,omitempty"` + StreamProgress bool `json:"streamProgress,omitempty"` } type dockerResponse struct { @@ -86,7 +103,7 @@ func (m *DockerManager) call(ctx context.Context, operation string, enabled, res return dockerResponse{}, errors.New("MCP listen port must be between 1024 and 65535") } timeout := 65 * time.Second - if operation == "catalog" || operation == "candidate_catalog" || operation == "logs" || operation == "prepare" { + if operation == "catalog" || operation == "apply_catalog" || operation == "candidate_catalog" || operation == "logs" || operation == "prepare" { timeout = 5 * time.Second } if operation == "status" { @@ -126,7 +143,7 @@ func (m *DockerManager) call(ctx context.Context, operation string, enabled, res if len(result.Error) > 1024 { return dockerResponse{}, errors.New("MCP Docker operation failed") } - return dockerResponse{}, errors.New(result.Error) + return dockerResponse{}, operationError(result.Error) } if response.StatusCode != http.StatusOK { return dockerResponse{}, errors.New("MCP Docker operation was rejected by the local agent") @@ -182,7 +199,7 @@ func (m *DockerManager) Apply(ctx context.Context, enabled, restart bool) error var imageID string if enabled { var err error - if _, imageID, err = m.readCatalog(ctx, "candidate_catalog"); err != nil { + if _, imageID, err = m.readCatalog(ctx, "apply_catalog"); err != nil { return err } } @@ -194,9 +211,10 @@ func (m *DockerManager) InstallImage(ctx context.Context, enabled bool) error { m.installMu.Lock() m.installImageID = "" m.installMu.Unlock() - if _, err := m.call(ctx, "pull", false, false, ""); err != nil { + if err := m.pull(ctx); err != nil { return err } + ReportProgress(ctx, Progress{Status: "catalog"}) _, imageID, err := m.readCatalog(ctx, "candidate_catalog") if err != nil { return err @@ -204,6 +222,7 @@ func (m *DockerManager) InstallImage(ctx context.Context, enabled bool) error { m.installMu.Lock() m.installImageID = imageID m.installMu.Unlock() + ReportProgress(ctx, Progress{Status: "starting"}) _, err = m.call(ctx, "install", enabled, true, imageID) return err } diff --git a/core/utils/mcpruntime/progress.go b/core/utils/mcpruntime/progress.go new file mode 100644 index 000000000..0bb033b1f --- /dev/null +++ b/core/utils/mcpruntime/progress.go @@ -0,0 +1,28 @@ +package mcpruntime + +import "context" + +type Progress struct { + Status string `json:"status,omitempty"` + ID string `json:"id,omitempty"` + Current int64 `json:"current,omitempty"` + Total int64 `json:"total,omitempty"` +} + +type progressContextKey struct{} + +func WithProgress(ctx context.Context, report func(Progress)) context.Context { + return context.WithValue(ctx, progressContextKey{}, report) +} + +func ReportProgress(ctx context.Context, progress Progress) { + if report, ok := ctx.Value(progressContextKey{}).(func(Progress)); ok { + report(progress) + } +} + +type OperationError struct{ message string } + +func (e *OperationError) Error() string { return e.message } + +func operationError(message string) error { return &OperationError{message: message} } diff --git a/core/utils/mcpruntime/pull.go b/core/utils/mcpruntime/pull.go new file mode 100644 index 000000000..ea5a01c37 --- /dev/null +++ b/core/utils/mcpruntime/pull.go @@ -0,0 +1,97 @@ +package mcpruntime + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "time" + + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" +) + +var pullLayerID = regexp.MustCompile(`^[a-f0-9]{1,64}$`) + +func (m *DockerManager) pull(ctx context.Context) error { + if err := ValidateDockerImage(m.image); err != nil { + return err + } + if m.port < 1024 || m.port > 65535 { + return errors.New("MCP listen port must be between 1024 and 65535") + } + ctx, cancel := context.WithTimeout(ctx, 4*time.Minute+5*time.Second) + defer cancel() + data, err := json.Marshal(dockerRequest{Operation: "pull", Image: m.image, Port: m.port, StreamProgress: true}) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, dockerAgentEndpoint, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + response, err := m.client.Do(req) + defer m.client.CloseIdleConnections() + if err != nil { + return operationError("MCP Docker runtime agent is unavailable or timed out") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.Header.Get("Content-Type") != "application/x-ndjson" { + return operationError("MCP Docker image download was rejected by the local agent") + } + reader := bufio.NewScanner(io.LimitReader(response.Body, (16<<20)+1)) + reader.Buffer(make([]byte, 4096), 4096) + var completed bool + var operationErr error + var size int + for reader.Scan() { + line := reader.Bytes() + size += len(line) + 1 + if size > 16<<20 || completed { + return operationError("MCP Docker image progress response is invalid or too large") + } + var event struct { + Progress + Type string `json:"type"` + Error string `json:"error,omitempty"` + } + if contract.DecodeObjectLimit(line, &event, 4096) != nil { + return operationError("MCP Docker image progress response is invalid") + } + switch event.Type { + case "progress": + if event.Error != "" || !validPullProgress(event.Progress) { + return operationError("MCP Docker image progress response is invalid") + } + ReportProgress(ctx, event.Progress) + case "result": + if event.Status != "" || event.ID != "" || event.Current != 0 || event.Total != 0 || len(event.Error) > 1024 { + return operationError("MCP Docker image progress result is invalid") + } + completed = true + if event.Error != "" { + operationErr = operationError(event.Error) + } + default: + return operationError("MCP Docker image progress response is invalid") + } + } + if reader.Err() != nil || !completed { + return operationError("MCP Docker image download was interrupted") + } + return operationErr +} + +func validPullProgress(progress Progress) bool { + switch progress.Status { + case "checking_image", "local_image", "pulling", "downloading", "extracting", "waiting", "verifying_checksum", "download_complete", "pull_complete", "already_exists", "verifying_image", "verified_image": + default: + return false + } + return (progress.ID == "" || pullLayerID.MatchString(progress.ID)) && progress.Current >= 0 && progress.Total >= 0 && + (progress.Total == 0 || progress.Current <= progress.Total) +} diff --git a/frontend/src/api/interface/panel-mcp.ts b/frontend/src/api/interface/panel-mcp.ts index 7e5aa41bb..77ccac05d 100644 --- a/frontend/src/api/interface/panel-mcp.ts +++ b/frontend/src/api/interface/panel-mcp.ts @@ -96,12 +96,60 @@ export namespace PanelMCP { endpoint: string; internalEndpoint: string; canManageService: boolean; + installing: boolean; + installTaskID: string; + website?: WebsiteBinding; + } + export interface WebsiteDraft { + requested: boolean; + domain: string; + enableSSL: boolean; + sslID: number; + } + export interface WebsitePendingRequest { + operation: 'bind' | 'update' | 'unbind'; + requestId: string; + domain?: string; + enableSSL?: boolean; + sslID?: number; + } + export interface WebsiteBinding { + state: 'unbound' | 'pending' | 'bound' | 'unavailable'; + websiteId: number; + domain: string; + endpoint: string; + sslID: number; + enableSSL: boolean; + websiteStatus: string; + requestId: string; + revision: number; + reason?: string; + pendingRequest?: WebsitePendingRequest; + } + export interface WebsiteMutation { + requestId: string; + revision: number; + } + export interface WebsiteBind extends WebsiteMutation { + domain: string; + enableSSL: boolean; + sslID: number; + } + export interface WebsiteUpdate extends WebsiteMutation { + enableSSL: boolean; + sslID: number; } export interface Installation { dockerImage: string; listenPort: number; externalUrl: string; revision: number; + accessPolicy: Settings['accessPolicy']; + allowedIPs: string[]; + website?: Pick; + } + export interface InstallationTask { + taskID: string; } export type Operation = 'start' | 'stop' | 'restart'; export interface Invocation { diff --git a/frontend/src/api/modules/panel-mcp.ts b/frontend/src/api/modules/panel-mcp.ts index ef7e9ae72..87d99d7f7 100644 --- a/frontend/src/api/modules/panel-mcp.ts +++ b/frontend/src/api/modules/panel-mcp.ts @@ -1,18 +1,28 @@ import http from '@/api'; import type { ReqPage, ResPage } from '@/api/interface'; import type { PanelMCP } from '@/api/interface/panel-mcp'; +import type { Container } from '@/api/interface/container'; const prefix = '/core/mcp'; const local = { headers: { CurrentNode: 'local' } }; export const getPanelMCPStatus = () => http.get(`${prefix}/status`, {}, local); +export const getPanelMCPDockerStatus = () => + http.get('/containers/docker/status', {}, { ...local, timeout: 5000 }); export const getPanelMCPSettings = () => http.get(`${prefix}/settings`, {}, local); +export const getPanelMCPWebsite = () => http.get(`${prefix}/website/status`, {}, local); +export const bindPanelMCPWebsite = (params: PanelMCP.WebsiteBind) => + http.postWithConfig(`${prefix}/website/bind`, params, { ...local, timeout: 120000 }); +export const updatePanelMCPWebsite = (params: PanelMCP.WebsiteUpdate) => + http.postWithConfig(`${prefix}/website/update`, params, { ...local, timeout: 120000 }); +export const unbindPanelMCPWebsite = (params: PanelMCP.WebsiteMutation) => + http.postWithConfig(`${prefix}/website/unbind`, params, { ...local, timeout: 120000 }); export const savePanelMCPSettings = (params: PanelMCP.Settings) => http.postWithConfig(`${prefix}/settings`, params, { ...local, timeout: 60000 }); export const operatePanelMCPService = (operation: PanelMCP.Operation) => http.postWithConfig(`${prefix}/service/operate`, { operation }, { ...local, timeout: 60000 }); export const installPanelMCPService = (params: PanelMCP.Installation) => - http.postWithConfig(`${prefix}/service/install`, params, { ...local, timeout: 420000 }); + http.postWithConfig(`${prefix}/service/install`, params, local); export const getPanelMCPLogs = () => http.get<{ content: string }>(`${prefix}/service/logs`, {}, local); export const getPanelMCPCatalog = () => http.get(`${prefix}/catalog`, {}, local); export const getPanelMCPNodes = () => http.get(`${prefix}/nodes`, {}, local); @@ -26,6 +36,8 @@ export const setPanelMCPCredentialStatus = (params: PanelMCP.Reference & { statu http.postWithConfig(`${prefix}/credentials/status`, params, local); export const revokePanelMCPCredential = (params: PanelMCP.Reference) => http.postWithConfig(`${prefix}/credentials/revoke`, params, local); +export const deletePanelMCPCredential = (params: PanelMCP.Reference) => + http.postWithConfig(`${prefix}/credentials/delete`, params, local); export const revealPanelMCPCredential = (params: PanelMCP.Reference, signal?: AbortSignal) => http.postWithConfig(`${prefix}/credentials/reveal`, params, { ...local, signal }); export const rotatePanelMCPCredential = (params: PanelMCP.Reference, signal?: AbortSignal) => diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 41b942807..431862237 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1743,15 +1743,9 @@ const message = { baseUrlHelper: 'For example: http://192.168.1.2:8000, without the MCP path', ssePath: 'SSE Path', ssePathHelper: 'For example: /sse, note not to duplicate with other servers', - environment: 'Environment Variables', - envKey: 'Variable Name', - envValue: 'Variable Value', externalUrl: 'External Connection Address', operatorHelper: 'Will perform {1} operation on {0}, continue?', - domain: 'Default Access Address', - domainHelper: 'For example: 192.168.1.1 or example.com', bindDomain: 'Bind Website', - commandPlaceHolder: 'Currently supports npx, uvx, and binary startup commands', importMcpJson: 'Import MCP Server Configuration', importMcpJsonError: 'mcpServers structure is incorrect', bindDomainHelper: @@ -1782,8 +1776,30 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Cancel binding', + websiteBinding: 'Website binding', + websiteBind: 'Bind website', + websiteBindRequested: 'Bind a website after installation', + websiteBound: 'Bound', + websiteUnbound: 'Not bound', + websitePending: 'Outcome pending', + websiteUnavailable: 'Binding unavailable', + websiteDomainInvalid: 'Enter a valid domain, optionally including a port.', + websiteCertificateRequired: 'Select a valid certificate covering this domain.', + websiteCertificateEmpty: 'No matching certificate. Apply for or import one in certificate management.', + websiteUnbind: 'Unbind website', + websiteUnbindConfirm: + 'Remove the MCP reverse proxy and binding while retaining the website and certificate. Continue?', + websiteUncertain: + 'The website operation outcome is unconfirmed. Refresh its status; a pending operation can be retried with the original request.', + websiteRetry: 'Retry pending operation', + websiteSettings: 'Website settings', + websiteCertificateUpdate: 'Change certificate', + websiteStatusLoadFailed: 'Unable to read the website binding status. Refresh and try again.', + websiteOperationFailed: 'The website binding operation failed. Check the current status and configuration.', + websiteConflict: 'The website binding configuration changed. Refresh the status and review it.', + websiteBoundAddressHelp: 'The endpoint is generated from the bound website. Manage it in website settings.', hideToken: 'Hide Token', - revealTokenFailed: 'Could not retrieve the Token. Please try again.', copyTokenFailed: 'Could not copy the Token. Please try again.', exportFailed: 'Could not export the connection guide. Please try again.', rotateTokenFailed: 'Could not regenerate the Token. Please try again.', @@ -1808,7 +1824,6 @@ const message = { genericClient: 'Generic MCP client', revealConfigHelp: 'Reveal the Token to generate the client configuration.', downloadGuide: 'Download connection guide (.md)', - saveSecret: 'Save the Token and select "I have saved the token".', alreadyCreated: 'Credential created. View the Token in Connection details.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1835,8 +1850,6 @@ const message = { bulkHelp: 'Bulk actions affect matching tools in all categories, including hidden items, regardless of "Selected only". New tools require separate authorization.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'Clients must use the connection URL.', @@ -1861,7 +1874,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'The tool list has changed. Reopen the editor to continue.', grantsNeedReview: 'Some tool grants need to be reconfirmed.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1869,10 +1882,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Replace in the configuration with the credential Token.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + 'Delete credential “{0}”? It will become invalid immediately. Historical invocation records will be retained.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1896,10 +1910,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'The 1Panel MCP service is not installed. Install and enable it to create access credentials and invoke MCP tools.', + installTaskLog: 'Installation log', + installNow: 'Install now', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1907,11 +1923,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Use a fixed version tag or SHA-256 digest. The latest tag is not supported.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Run docker load -i on the primary node to import the image before installing.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Enter a valid Docker image reference with a tag or SHA-256 digest.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 00e41f513..c408f1e8c 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1755,15 +1755,9 @@ const message = { baseUrlHelper: 'Por ejemplo: http://192.168.1.2:8000, sin la ruta MCP', ssePath: 'Ruta SSE', ssePathHelper: 'Por ejemplo: /sse, asegúrese de que no se repita con otros servidores', - environment: 'Variables de entorno', - envKey: 'Nombre de la variable', - envValue: 'Valor de la variable', externalUrl: 'Dirección de conexión externa', operatorHelper: 'Se realizará la operación {1} en {0}, ¿desea continuar?', - domain: 'Dirección de acceso predeterminada', - domainHelper: 'Por ejemplo: 192.168.1.1 o ejemplo.com', bindDomain: 'Vincular sitio web', - commandPlaceHolder: 'Actualmente solo se admiten comandos de inicio npx, uvx y binarios', importMcpJson: 'Importar configuración del servidor MCP', importMcpJsonError: 'La estructura de mcpServers es incorrecta', bindDomainHelper: @@ -1795,8 +1789,32 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Cancelar vinculación', + websiteBinding: 'Vinculación de sitio web', + websiteBind: 'Vincular sitio web', + websiteBindRequested: 'Vincular un sitio tras la instalación', + websiteBound: 'Vinculado', + websiteUnbound: 'Sin vincular', + websitePending: 'Resultado pendiente', + websiteUnavailable: 'Vinculación no disponible', + websiteDomainInvalid: 'Introduzca un dominio válido, con un puerto opcional.', + websiteCertificateRequired: 'Seleccione un certificado válido que cubra este dominio.', + websiteCertificateEmpty: + 'No hay certificados coincidentes. Solicite o importe uno en la gestión de certificados.', + websiteUnbind: 'Desvincular sitio web', + websiteUnbindConfirm: + 'Se eliminarán el proxy inverso MCP y la vinculación; se conservarán el sitio y el certificado. ¿Continuar?', + websiteUncertain: + 'El resultado de la operación no está confirmado. Actualice el estado; puede reintentar la operación pendiente con la solicitud original.', + websiteRetry: 'Reintentar operación pendiente', + websiteSettings: 'Configuración del sitio', + websiteCertificateUpdate: 'Cambiar certificado', + websiteStatusLoadFailed: 'No se pudo leer el estado de vinculación. Actualice e inténtelo de nuevo.', + websiteOperationFailed: 'La operación de vinculación falló. Revise el estado y la configuración.', + websiteConflict: 'La configuración de vinculación cambió. Actualice el estado y revísela.', + websiteBoundAddressHelp: + 'La dirección se genera a partir del sitio vinculado. Adminístrela en la configuración del sitio.', hideToken: 'Ocultar Token', - revealTokenFailed: 'No se pudo obtener el Token. Inténtelo de nuevo.', copyTokenFailed: 'No se pudo copiar el Token. Inténtelo de nuevo.', exportFailed: 'No se pudo exportar la guía de conexión. Inténtelo de nuevo.', rotateTokenFailed: 'No se pudo regenerar el Token. Inténtelo de nuevo.', @@ -1822,7 +1840,6 @@ const message = { genericClient: 'Cliente MCP genérico', revealConfigHelp: 'Muestre el Token para generar la configuración del cliente.', downloadGuide: 'Descargar guía de conexión (.md)', - saveSecret: 'Guarde el Token y seleccione «I have saved the token».', alreadyCreated: 'Credencial creada. Consulte el Token en los detalles de conexión.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1849,8 +1866,6 @@ const message = { bulkHelp: 'Las acciones masivas afectan a las herramientas coincidentes de todas las categorías, incluidas las ocultas, independientemente de «Solo seleccionadas». Las herramientas nuevas requieren autorización adicional.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'Los clientes deben usar la URL de conexión.', @@ -1875,7 +1890,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'La lista de herramientas se ha actualizado. Vuelva a abrir el editor para continuar.', grantsNeedReview: 'Es necesario volver a confirmar algunos permisos de herramientas.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1883,10 +1898,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Sustituya en la configuración por el Token de la credencial.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + '¿Eliminar la credencial «{0}»? Dejará de ser válida inmediatamente. Se conservará el historial de llamadas.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1910,10 +1926,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'El servicio 1Panel MCP no está instalado. Instálelo y actívelo para crear credenciales de acceso e invocar herramientas MCP.', + installTaskLog: 'Registro de instalación', + installNow: 'Instalar ahora', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1921,11 +1939,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Use una etiqueta de versión fija o un resumen SHA-256. No se admite latest.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Ejecute docker load -i en el nodo principal para importar la imagen antes de instalarla.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Introduzca una referencia de imagen Docker válida con una etiqueta o un resumen SHA-256.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index a35f3a4f1..743033d97 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -1726,15 +1726,9 @@ const message = { baseUrlHelper: 'مثلاً: http://192.168.1.2:8000، بدون مسیر MCP', ssePath: 'مسیر SSE', ssePathHelper: 'مثلاً: /sse، توجه کنید که با سایر سرورها تکراری نشود', - environment: 'متغیرهای محیطی', - envKey: 'نام متغیر', - envValue: 'مقدار متغیر', externalUrl: 'آدرس اتصال خارجی', operatorHelper: 'عملیات {1} روی {0} انجام خواهد شد، ادامه می‌دهید؟', - domain: 'آدرس دسترسی پیش‌فرض', - domainHelper: 'مثلاً: 192.168.1.1 یا example.com', bindDomain: 'اتصال وب‌سایت', - commandPlaceHolder: 'در حال حاضر فقط از دستورات راه‌اندازی npx، uvx و باینری پشتیبانی می‌شود', importMcpJson: 'واردات پیکربندی سرور MCP', importMcpJsonError: 'ساختار mcpServers نادرست است', bindDomainHelper: @@ -1763,8 +1757,29 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'لغو اتصال', + websiteBinding: 'اتصال وب‌سایت', + websiteBind: 'اتصال وب‌سایت', + websiteBindRequested: 'اتصال وب‌سایت پس از نصب', + websiteBound: 'متصل', + websiteUnbound: 'بدون اتصال', + websitePending: 'در انتظار تأیید نتیجه', + websiteUnavailable: 'اتصال در دسترس نیست', + websiteDomainInvalid: 'یک دامنه معتبر وارد کنید؛ می‌توانید پورت را نیز مشخص کنید.', + websiteCertificateRequired: 'یک گواهی معتبر برای این دامنه انتخاب کنید.', + websiteCertificateEmpty: 'گواهی منطبق وجود ندارد. در مدیریت گواهی، گواهی درخواست یا وارد کنید.', + websiteUnbind: 'قطع اتصال وب‌سایت', + websiteUnbindConfirm: 'پراکسی معکوس MCP و اتصال حذف می‌شوند؛ وب‌سایت و گواهی حفظ می‌شوند. ادامه می‌دهید؟', + websiteUncertain: + 'نتیجه عملیات تأیید نشده است. وضعیت را تازه‌سازی کنید؛ عملیات در انتظار را می‌توان با درخواست اصلی دوباره اجرا کرد.', + websiteRetry: 'تلاش مجدد عملیات در انتظار', + websiteSettings: 'تنظیمات وب‌سایت', + websiteCertificateUpdate: 'تغییر گواهی', + websiteStatusLoadFailed: 'خواندن وضعیت اتصال وب‌سایت ممکن نیست. تازه‌سازی کرده و دوباره تلاش کنید.', + websiteOperationFailed: 'عملیات اتصال وب‌سایت ناموفق بود. وضعیت و تنظیمات را بررسی کنید.', + websiteConflict: 'تنظیمات اتصال تغییر کرده است. وضعیت را تازه‌سازی و بررسی کنید.', + websiteBoundAddressHelp: 'نشانی اتصال از وب‌سایت متصل ساخته می‌شود. آن را در تنظیمات وب‌سایت مدیریت کنید.', hideToken: 'پنهان کردن Token', - revealTokenFailed: 'دریافت Token انجام نشد. دوباره تلاش کنید.', copyTokenFailed: 'کپی Token انجام نشد. دوباره تلاش کنید.', exportFailed: 'صدور راهنمای اتصال انجام نشد. دوباره تلاش کنید.', rotateTokenFailed: 'تولید دوباره Token انجام نشد. دوباره تلاش کنید.', @@ -1789,7 +1804,6 @@ const message = { genericClient: 'کلاینت عمومی MCP', revealConfigHelp: 'برای ایجاد پیکربندی کلاینت، Token را نمایش دهید.', downloadGuide: 'دانلود راهنمای اتصال (.md)', - saveSecret: 'Token را ذخیره و گزینه «I have saved the token» را انتخاب کنید.', alreadyCreated: 'اعتبارنامه ایجاد شد. Token را در جزئیات اتصال مشاهده کنید.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1815,8 +1829,6 @@ const message = { bulkHelp: 'عملیات گروهی بر ابزارهای مطابق در همه دسته‌ها، از جمله موارد پنهان، بدون توجه به «فقط انتخاب‌شده‌ها» اعمال می‌شود. ابزارهای جدید به مجوز جداگانه نیاز دارند.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'کلاینت‌ها باید از نشانی اتصال استفاده کنند.', @@ -1841,7 +1853,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'فهرست ابزارها به‌روز شده است. برای ادامه، پنجره ویرایش را دوباره باز کنید.', grantsNeedReview: 'برخی مجوزهای ابزار باید دوباره تأیید شوند.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1849,10 +1861,10 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'در پیکربندی، را با Token اعتبارنامه جایگزین کنید.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: 'اطلاعات احراز هویت «{0}» حذف شود؟ بلافاصله نامعتبر خواهد شد. سوابق فراخوانی حفظ می\u200cشوند.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1876,10 +1888,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'سرویس 1Panel MCP هنوز نصب نشده است. برای ایجاد اعتبارنامه‌های دسترسی و فراخوانی ابزارهای MCP، آن را نصب و فعال کنید.', + installTaskLog: 'گزارش نصب', + installNow: 'اکنون نصب کنید', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1887,11 +1901,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'از برچسب نسخه ثابت یا خلاصه SHA-256 استفاده کنید. برچسب latest پشتیبانی نمی‌شود.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'پیش از نصب، برای وارد کردن تصویر روی گره اصلی دستور docker load -i را اجرا کنید.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'یک مرجع معتبر برای تصویر Docker با برچسب یا چکیده SHA-256 وارد کنید.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 7ebccc98f..4b2cbe2be 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1727,15 +1727,9 @@ const message = { baseUrlHelper: '例: http://192.168.1.2:8000(MCP パスを含まない)', ssePath: 'SSE パス', ssePathHelper: '例: /sse, 他のサーバーと重複しないように注意してください', - environment: '環境変数', - envKey: '変数名', - envValue: '変数値', externalUrl: '外部接続アドレス', operatorHelper: '{0} に {1} 操作を実行します、続行しますか?', - domain: 'デフォルトアクセスアドレス', - domainHelper: '例: 192.168.1.1 または example.com', bindDomain: 'ウェブサイトをバインド', - commandPlaceHolder: '現在、npx、uvx およびバイナリの起動コマンドのみをサポートしています', importMcpJson: 'MCP サーバー設定をインポート', importMcpJsonError: 'mcpServers 構造が正しくありません', bindDomainHelper: @@ -1766,8 +1760,30 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: '関連付けを取り消す', + websiteBinding: 'ウェブサイトの関連付け', + websiteBind: 'ウェブサイトを関連付け', + websiteBindRequested: 'インストール後にウェブサイトを関連付ける', + websiteBound: '関連付け済み', + websiteUnbound: '未関連付け', + websitePending: '結果確認待ち', + websiteUnavailable: '関連付けを利用できません', + websiteDomainInvalid: '有効なドメインを入力してください。ポートも指定できます。', + websiteCertificateRequired: 'このドメインを対象とする有効な証明書を選択してください。', + websiteCertificateEmpty: '一致する証明書がありません。証明書管理で申請またはインポートしてください。', + websiteUnbind: '関連付けを解除', + websiteUnbindConfirm: + 'MCP リバースプロキシと関連付けを削除します。ウェブサイトと証明書は保持されます。続行しますか?', + websiteUncertain: + '操作結果を確認できません。状態を更新してください。保留中の操作は元のリクエストで再試行できます。', + websiteRetry: '保留中の操作を再試行', + websiteSettings: 'ウェブサイト設定', + websiteCertificateUpdate: '証明書を変更', + websiteStatusLoadFailed: '関連付けの状態を取得できません。更新して再試行してください。', + websiteOperationFailed: '関連付け操作に失敗しました。状態と設定を確認してください。', + websiteConflict: '関連付けの設定が変更されました。状態を更新して確認してください。', + websiteBoundAddressHelp: '接続先は関連付けたウェブサイトから生成されます。ウェブサイト設定で管理してください。', hideToken: 'Token を隠す', - revealTokenFailed: 'Token を取得できませんでした。もう一度お試しください。', copyTokenFailed: 'Token をコピーできませんでした。もう一度お試しください。', exportFailed: '接続ガイドをエクスポートできませんでした。もう一度お試しください。', rotateTokenFailed: 'Token を再生成できませんでした。もう一度お試しください。', @@ -1792,7 +1808,6 @@ const message = { genericClient: '汎用 MCP クライアント', revealConfigHelp: 'Token を表示すると、クライアント設定を生成できます。', downloadGuide: '接続ガイドをダウンロード(.md)', - saveSecret: 'Token を保存し、「I have saved the token」を選択してください。', alreadyCreated: '認証情報を作成しました。接続の詳細で Token を確認できます。', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1819,8 +1834,6 @@ const message = { bulkHelp: '一括操作は、非表示の項目を含む全カテゴリの一致するツールに適用され、「選択済みのみ」の設定には影響されません。新しいツールには個別の認可が必要です。', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'クライアントは接続 URL を使用してください。', @@ -1845,7 +1858,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'ツール一覧が更新されました。編集画面を開き直して続行してください。', grantsNeedReview: '一部のツールの認可を再確認してください。', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1853,10 +1866,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: '設定の を認証情報の Token に置き換えてください。', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + '認証情報「{0}」を削除しますか?削除後は直ちに無効になります。過去の呼び出し記録は保持されます。', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1880,10 +1894,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + '1Panel MCP サービスはまだインストールされていません。インストールして有効にすると、アクセス認証情報を作成して MCP ツールを呼び出せます。', + installTaskLog: 'インストールログ', + installNow: '今すぐインストール', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1891,11 +1907,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: '固定のバージョンタグまたは SHA-256 ダイジェストを使用してください。latest は使用できません。', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'インストール前に主ノードで docker load -i を実行してイメージをインポートしてください。', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'タグまたは SHA-256 ダイジェストを含む有効な Docker イメージ参照を入力してください。', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index d731b3ea4..9a441fe84 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1712,15 +1712,9 @@ const message = { baseUrlHelper: '예: http://192.168.1.2:8000 (MCP 경로 제외)', ssePath: 'SSE 경로', ssePathHelper: '예: /sse, 다른 서버와 중복되지 않도록 주의하세요', - environment: '환경 변수', - envKey: '변수 이름', - envValue: '변수 값', externalUrl: '외부 연결 주소', operatorHelper: '{0}에 {1} 작업을 수행합니다. 계속하시겠습니까?', - domain: '기본 접근 주소', - domainHelper: '예: 192.168.1.1 또는 example.com', bindDomain: '웹사이트 바인딩', - commandPlaceHolder: '현재 npx, uvx 및 바이너리 시작 명령만 지원합니다', importMcpJson: 'MCP 서버 구성 가져오기', importMcpJsonError: 'mcpServers 구조가 올바르지 않습니다', bindDomainHelper: @@ -1749,8 +1743,29 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: '연결 취소', + websiteBinding: '웹사이트 연결', + websiteBind: '웹사이트 연결', + websiteBindRequested: '설치 후 웹사이트 연결', + websiteBound: '연결됨', + websiteUnbound: '연결 안 됨', + websitePending: '결과 확인 대기', + websiteUnavailable: '연결 사용 불가', + websiteDomainInvalid: '유효한 도메인을 입력하세요. 포트를 포함할 수 있습니다.', + websiteCertificateRequired: '이 도메인에 유효한 인증서를 선택하세요.', + websiteCertificateEmpty: '일치하는 인증서가 없습니다. 인증서 관리에서 신청하거나 가져오세요.', + websiteUnbind: '웹사이트 연결 해제', + websiteUnbindConfirm: 'MCP 리버스 프록시와 연결을 제거하고 웹사이트와 인증서는 유지합니다. 계속하시겠습니까?', + websiteUncertain: + '작업 결과가 확인되지 않았습니다. 상태를 새로 고치세요. 대기 중인 작업은 원래 요청으로 다시 시도할 수 있습니다.', + websiteRetry: '대기 작업 다시 시도', + websiteSettings: '웹사이트 설정', + websiteCertificateUpdate: '인증서 변경', + websiteStatusLoadFailed: '웹사이트 연결 상태를 읽을 수 없습니다. 새로 고친 후 다시 시도하세요.', + websiteOperationFailed: '웹사이트 연결 작업에 실패했습니다. 현재 상태와 설정을 확인하세요.', + websiteConflict: '연결 설정이 변경되었습니다. 상태를 새로 고친 후 확인하세요.', + websiteBoundAddressHelp: '접속 주소는 연결된 웹사이트에서 생성됩니다. 웹사이트 설정에서 관리하세요.', hideToken: 'Token 숨기기', - revealTokenFailed: 'Token을 가져오지 못했습니다. 다시 시도하세요.', copyTokenFailed: 'Token을 복사하지 못했습니다. 다시 시도하세요.', exportFailed: '연결 안내서를 내보내지 못했습니다. 다시 시도하세요.', rotateTokenFailed: 'Token을 다시 생성하지 못했습니다. 다시 시도하세요.', @@ -1775,7 +1790,6 @@ const message = { genericClient: '일반 MCP 클라이언트', revealConfigHelp: 'Token을 표시하면 클라이언트 구성을 생성할 수 있습니다.', downloadGuide: '연결 안내 다운로드 (.md)', - saveSecret: 'Token을 저장하고 "I have saved the token"을 선택하세요.', alreadyCreated: '자격 증명이 생성되었습니다. 연결 상세 정보에서 Token을 확인할 수 있습니다.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1801,8 +1815,6 @@ const message = { bulkHelp: '일괄 작업은 "선택한 항목만" 설정과 관계없이 숨겨진 항목을 포함하여 모든 범주의 일치하는 도구에 적용됩니다. 새 도구는 별도 승인이 필요합니다.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: '클라이언트는 연결 주소를 사용해야 합니다.', @@ -1827,7 +1839,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: '도구 목록이 업데이트되었습니다. 편집 창을 다시 열어 계속하세요.', grantsNeedReview: '일부 도구 권한을 다시 확인해야 합니다.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1835,10 +1847,10 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: '구성의 을 자격 증명 Token으로 바꾸세요.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: '자격 증명 “{0}”을 삭제하시겠습니까? 삭제 즉시 무효화되며 이전 호출 기록은 보존됩니다.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1862,10 +1874,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + '1Panel MCP 서비스가 아직 설치되지 않았습니다. 설치하고 활성화하면 액세스 자격 증명을 생성하고 MCP 도구를 호출할 수 있습니다.', + installTaskLog: '설치 로그', + installNow: '지금 설치', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1873,10 +1887,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: '고정 버전 태그 또는 SHA-256 다이제스트를 사용하세요. latest 태그는 지원하지 않습니다.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: '설치하기 전에 주 노드에서 docker load -i 를 실행하여 이미지를 가져오세요.', - offlineInstall: 'Offline installation', + dockerImageInvalid: '태그 또는 SHA-256 다이제스트가 포함된 올바른 Docker 이미지 참조를 입력하세요.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 94238adfb..093c74f52 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -1702,15 +1702,9 @@ const message = { baseUrlHelper: 'ຕົວຢ່າງ: http://192.168.1.2:8000, ບໍ່ລວມເສັ້ນທາງ MCP', ssePath: 'ເສັ້ນທາງ SSE', ssePathHelper: 'ຕົວຢ່າງ: /sse, ລະວັງບໍ່ໃຫ້ຊ້ຳກັບ server ອື່ນ', - environment: 'ຕົວປ່ຽນສະພາບແວດລ້ອມ', - envKey: 'ຊື່ຕົວປ່ຽນ', - envValue: 'ຄ່າຕົວປ່ຽນ', externalUrl: 'ທີ່ຢູ່ການເຊື່ອມຕໍ່ພາຍນອກ', operatorHelper: 'ຈະດຳເນີນການ {1} ໃນ {0}, ຕ້ອງການຕໍ່ຫຼືບໍ່?', - domain: 'ທີ່ຢູ່ເຂົ້າເຖິງເລີ່ມຕົ້ນ', - domainHelper: 'ຕົວຢ່າງ: 192.168.1.1 ຫຼື example.com', bindDomain: 'ຜູກເວັບໄຊ', - commandPlaceHolder: 'ປັດຈຸບັນຮອງຮັບສະເພາະຄຳສັ່ງ npx, uvx ແລະ binary', importMcpJson: 'ນຳເຂົ້າການກຳນົດຄ່າ MCP Server', importMcpJsonError: 'ໂຄງສ້າງ mcpServers ບໍ່ຖືກຕ້ອງ', bindDomainHelper: @@ -1738,8 +1732,28 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'ຍົກເລີກການຜູກ', + websiteBinding: 'ການຜູກເວັບໄຊ', + websiteBind: 'ຜູກເວັບໄຊ', + websiteBindRequested: 'ຜູກເວັບໄຊຫຼັງຕິດຕັ້ງ', + websiteBound: 'ຜູກແລ້ວ', + websiteUnbound: 'ຍັງບໍ່ຜູກ', + websitePending: 'ລໍຖ້າຢືນຢັນຜົນ', + websiteUnavailable: 'ການຜູກບໍ່ພ້ອມໃຊ້', + websiteDomainInvalid: 'ປ້ອນໂດເມນທີ່ຖືກຕ້ອງ ໂດຍສາມາດລະບຸພອດໄດ້.', + websiteCertificateRequired: 'ເລືອກໃບຢັ້ງຢືນທີ່ຍັງໃຊ້ໄດ້ ແລະຄອບຄຸມໂດເມນນີ້.', + websiteCertificateEmpty: 'ບໍ່ມີໃບຢັ້ງຢືນທີ່ກົງກັນ. ກະລຸນາຂໍ ຫຼືນຳເຂົ້າໃນການຈັດການໃບຢັ້ງຢືນ.', + websiteUnbind: 'ຍົກເລີກການຜູກ', + websiteUnbindConfirm: 'ຈະລຶບພຣັອກຊີຍ້ອນກັບ MCP ແລະການຜູກ ແຕ່ເກັບເວັບໄຊແລະໃບຢັ້ງຢືນໄວ້. ສືບຕໍ່ບໍ?', + websiteUncertain: 'ຜົນການດຳເນີນງານຍັງບໍ່ຖືກຢືນຢັນ. ກະລຸນາໂຫຼດສະຖານະໃໝ່; ສາມາດລອງຄືນດ້ວຍຄຳຂໍເດີມ.', + websiteRetry: 'ລອງຄືນການດຳເນີນງານທີ່ລໍຖ້າ', + websiteSettings: 'ການຕັ້ງຄ່າເວັບໄຊ', + websiteCertificateUpdate: 'ປ່ຽນໃບຢັ້ງຢືນ', + websiteStatusLoadFailed: 'ບໍ່ສາມາດອ່ານສະຖານະການຜູກເວັບໄຊໄດ້. ໂຫຼດໃໝ່ແລ້ວລອງອີກຄັ້ງ.', + websiteOperationFailed: 'ການຜູກເວັບໄຊລົ້ມເຫຼວ. ກວດສອບສະຖານະແລະການຕັ້ງຄ່າ.', + websiteConflict: 'ການຕັ້ງຄ່າການຜູກປ່ຽນແລ້ວ. ໂຫຼດສະຖານະໃໝ່ແລະກວດສອບ.', + websiteBoundAddressHelp: 'ທີ່ຢູ່ເຊື່ອມຕໍ່ສ້າງຈາກເວັບໄຊທີ່ຜູກ. ຈັດການໃນການຕັ້ງຄ່າເວັບໄຊ.', hideToken: 'ເຊື່ອງ Token', - revealTokenFailed: 'ບໍ່ສາມາດຮັບ Token ໄດ້. ກະລຸນາລອງໃໝ່.', copyTokenFailed: 'ບໍ່ສາມາດຄັດລອກ Token ໄດ້. ກະລຸນາລອງໃໝ່.', exportFailed: 'ບໍ່ສາມາດສົ່ງອອກຄູ່ມືເຊື່ອມຕໍ່ໄດ້. ກະລຸນາລອງໃໝ່.', rotateTokenFailed: 'ບໍ່ສາມາດສ້າງ Token ໃໝ່ໄດ້. ກະລຸນາລອງໃໝ່.', @@ -1764,7 +1778,6 @@ const message = { genericClient: 'ໄຄລເອັນ MCP ທົ່ວໄປ', revealConfigHelp: 'ສະແດງ Token ເພື່ອສ້າງການຕັ້ງຄ່າໄຄລເອັນ.', downloadGuide: 'ດາວໂຫຼດຄູ່ມືເຊື່ອມຕໍ່ (.md)', - saveSecret: 'ບັນທຶກ Token ແລ້ວເລືອກ "I have saved the token".', alreadyCreated: 'ສ້າງຂໍ້ມູນຢືນຢັນແລ້ວ. ເບິ່ງ Token ໄດ້ໃນລາຍລະອຽດການເຊື່ອມຕໍ່.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1790,8 +1803,6 @@ const message = { bulkHelp: 'ການດຳເນີນການແບບກຸ່ມມີຜົນຕໍ່ເຄື່ອງມືທີ່ກົງກັນໃນທຸກໝວດໝູ່ ລວມທັງລາຍການທີ່ເຊື່ອງໄວ້ ໂດຍບໍ່ຂຶ້ນກັບ "ສະເພາະລາຍການທີ່ເລືອກ". ເຄື່ອງມືໃໝ່ຕ້ອງໄດ້ຮັບອະນຸຍາດແຍກຕ່າງຫາກ.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'ໄຄລເອັນຕ້ອງໃຊ້ທີ່ຢູ່ເຊື່ອມຕໍ່.', @@ -1816,7 +1827,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'ລາຍການເຄື່ອງມືໄດ້ອັບເດດແລ້ວ. ກະລຸນາເປີດໜ້າຕ່າງແກ້ໄຂຄືນໃໝ່ເພື່ອດຳເນີນການຕໍ່.', grantsNeedReview: 'ຕ້ອງຢືນຢັນສິດເຄື່ອງມືບາງລາຍການອີກຄັ້ງ.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1824,10 +1835,10 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'ແທນທີ່ ໃນການຕັ້ງຄ່າດ້ວຍ Token ຂອງຂໍ້ມູນຢືນຢັນ.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: 'ລຶບຂໍ້ມູນຢັ້ງຢືນ “{0}” ຫຼືບໍ່? ຂໍ້ມູນຈະໃຊ້ບໍ່ໄດ້ທັນທີ ແລະປະຫວັດການເອີ້ນຈະຖືກເກັບໄວ້.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1851,10 +1862,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'ບໍລິການ 1Panel MCP ຍັງບໍ່ໄດ້ຕິດຕັ້ງ. ຕິດຕັ້ງ ແລະ ເປີດໃຊ້ງານເພື່ອສ້າງຂໍ້ມູນຢັ້ງຢືນການເຂົ້າເຖິງ ແລະ ເອີ້ນໃຊ້ເຄື່ອງມື MCP.', + installTaskLog: 'ບັນທຶກການຕິດຕັ້ງ', + installNow: 'ຕິດຕັ້ງດຽວນີ້', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1862,10 +1875,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'ໃຊ້ແທັກເວີຊັນຄົງທີ່ ຫຼື ຄ່າ SHA-256. ບໍ່ຮອງຮັບແທັກ latest.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: 'ກ່ອນຕິດຕັ້ງ ໃຫ້ໃຊ້ຄຳສັ່ງ docker load -i ໃນໂນດຫຼັກເພື່ອນຳເຂົ້າອິມເມຈ.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'ປ້ອນຊື່ອ້າງອີງອິມເມຈ Docker ທີ່ຖືກຕ້ອງ ພ້ອມແທັກ ຫຼືຄ່າ SHA-256.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index e84894a1b..036065f71 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1757,15 +1757,9 @@ const message = { baseUrlHelper: 'Contoh: http://192.168.1.2:8000, tanpa laluan MCP', ssePath: 'Laluan SSE', ssePathHelper: 'Contoh: /sse, berhati-hati jangan bertindan dengan pelayan lain', - environment: 'Pemboleh Ubah Persekitaran', - envKey: 'Nama Pemboleh Ubah', - envValue: 'Nilai Pemboleh Ubah', externalUrl: 'Alamat Sambungan Luar', operatorHelper: 'Akan melakukan operasi {1} pada {0}, teruskan?', - domain: 'Alamat Akses Lalai', - domainHelper: 'Contoh: 192.168.1.1 atau example.com', bindDomain: 'Sematkan Laman Web', - commandPlaceHolder: 'Kini hanya menyokong perintah pelancaran npx, uvx dan binari', importMcpJson: 'Import Konfigurasi Pelayan MCP', importMcpJsonError: 'Struktur mcpServers tidak betul', bindDomainHelper: @@ -1795,8 +1789,30 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Batalkan pautan', + websiteBinding: 'Pautan laman web', + websiteBind: 'Pautkan laman web', + websiteBindRequested: 'Pautkan laman web selepas pemasangan', + websiteBound: 'Dipautkan', + websiteUnbound: 'Tidak dipautkan', + websitePending: 'Menunggu pengesahan hasil', + websiteUnavailable: 'Pautan tidak tersedia', + websiteDomainInvalid: 'Masukkan domain yang sah, dengan port pilihan.', + websiteCertificateRequired: 'Pilih sijil sah yang meliputi domain ini.', + websiteCertificateEmpty: 'Tiada sijil sepadan. Mohon atau import sijil dalam pengurusan sijil.', + websiteUnbind: 'Nyahpaut laman web', + websiteUnbindConfirm: 'Proksi songsang MCP dan pautan akan dibuang; laman web dan sijil dikekalkan. Teruskan?', + websiteUncertain: + 'Hasil operasi belum disahkan. Muat semula status; operasi tertunda boleh dicuba semula menggunakan permintaan asal.', + websiteRetry: 'Cuba semula operasi tertunda', + websiteSettings: 'Tetapan laman web', + websiteCertificateUpdate: 'Tukar sijil', + websiteStatusLoadFailed: 'Status pautan laman web tidak dapat dibaca. Muat semula dan cuba lagi.', + websiteOperationFailed: 'Operasi pautan gagal. Semak status dan konfigurasi semasa.', + websiteConflict: 'Konfigurasi pautan telah berubah. Muat semula status dan semaknya.', + websiteBoundAddressHelp: + 'Alamat sambungan dijana daripada laman web terpaut. Uruskannya dalam tetapan laman web.', hideToken: 'Sembunyikan Token', - revealTokenFailed: 'Token tidak dapat diperoleh. Sila cuba lagi.', copyTokenFailed: 'Token tidak dapat disalin. Sila cuba lagi.', exportFailed: 'Panduan sambungan tidak dapat dieksport. Sila cuba lagi.', rotateTokenFailed: 'Token tidak dapat dijana semula. Sila cuba lagi.', @@ -1821,7 +1837,6 @@ const message = { genericClient: 'Klien MCP umum', revealConfigHelp: 'Paparkan Token untuk menjana konfigurasi klien.', downloadGuide: 'Muat turun panduan sambungan (.md)', - saveSecret: 'Simpan Token dan pilih "I have saved the token".', alreadyCreated: 'Kelayakan telah dicipta. Lihat Token dalam butiran sambungan.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1847,8 +1862,6 @@ const message = { bulkHelp: 'Tindakan pukal mempengaruhi alat yang sepadan dalam semua kategori, termasuk item tersembunyi, tanpa mengira "Yang dipilih sahaja". Alat baharu memerlukan kebenaran berasingan.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'Klien mesti menggunakan URL sambungan.', @@ -1873,7 +1886,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'Senarai alat telah dikemas kini. Buka semula editor untuk meneruskan.', grantsNeedReview: 'Sesetengah kebenaran alat perlu disahkan semula.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1881,10 +1894,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Gantikan dalam konfigurasi dengan Token kelayakan.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + 'Padam kelayakan “{0}”? Kelayakan akan menjadi tidak sah serta-merta. Sejarah panggilan akan dikekalkan.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1908,10 +1922,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'Perkhidmatan 1Panel MCP belum dipasang. Pasang dan aktifkannya untuk mencipta kelayakan akses dan memanggil alat MCP.', + installTaskLog: 'Log pemasangan', + installNow: 'Pasang sekarang', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1919,11 +1935,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Gunakan tag versi tetap atau cerna SHA-256. Tag latest tidak disokong.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Jalankan docker load -i pada nod utama untuk mengimport imej sebelum pemasangan.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Masukkan rujukan imej Docker yang sah dengan tag atau ringkasan SHA-256.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 821896dd9..e177c7290 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1756,15 +1756,9 @@ const message = { baseUrlHelper: 'Por exemplo: http://192.168.1.2:8000, sem o caminho MCP', ssePath: 'Caminho SSE', ssePathHelper: 'Por exemplo: /sse, tome cuidado para não duplicar com outros servidores', - environment: 'Variáveis de Ambiente', - envKey: 'Nome da Variável', - envValue: 'Valor da Variável', externalUrl: 'Endereço de Conexão Externo', operatorHelper: 'Será realizada a operação {1} no {0}, continuar?', - domain: 'Endereço de Acesso Padrão', - domainHelper: 'Por exemplo: 192.168.1.1 ou example.com', bindDomain: 'Vincular Site', - commandPlaceHolder: 'Atualmente, apenas comandos de inicialização npx, uvx e binários são suportados', importMcpJson: 'Importar Configuração do Servidor MCP', importMcpJsonError: 'A estrutura mcpServers está incorreta', bindDomainHelper: @@ -1795,8 +1789,32 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Cancelar vinculação', + websiteBinding: 'Vinculação de site', + websiteBind: 'Vincular site', + websiteBindRequested: 'Vincular um site após a instalação', + websiteBound: 'Vinculado', + websiteUnbound: 'Não vinculado', + websitePending: 'Resultado pendente', + websiteUnavailable: 'Vinculação indisponível', + websiteDomainInvalid: 'Informe um domínio válido, com uma porta opcional.', + websiteCertificateRequired: 'Selecione um certificado válido que cubra este domínio.', + websiteCertificateEmpty: + 'Nenhum certificado correspondente. Solicite ou importe um no gerenciamento de certificados.', + websiteUnbind: 'Desvincular site', + websiteUnbindConfirm: + 'O proxy reverso MCP e a vinculação serão removidos; o site e o certificado serão mantidos. Continuar?', + websiteUncertain: + 'O resultado da operação não foi confirmado. Atualize o status; a operação pendente pode ser repetida com a solicitação original.', + websiteRetry: 'Repetir operação pendente', + websiteSettings: 'Configurações do site', + websiteCertificateUpdate: 'Alterar certificado', + websiteStatusLoadFailed: 'Não foi possível ler o status da vinculação. Atualize e tente novamente.', + websiteOperationFailed: 'A operação de vinculação falhou. Verifique o status e a configuração.', + websiteConflict: 'A configuração da vinculação mudou. Atualize o status e revise-a.', + websiteBoundAddressHelp: + 'O endereço é gerado a partir do site vinculado. Gerencie-o nas configurações do site.', hideToken: 'Ocultar Token', - revealTokenFailed: 'Não foi possível obter o Token. Tente novamente.', copyTokenFailed: 'Não foi possível copiar o Token. Tente novamente.', exportFailed: 'Não foi possível exportar o guia de conexão. Tente novamente.', rotateTokenFailed: 'Não foi possível gerar um novo Token. Tente novamente.', @@ -1821,7 +1839,6 @@ const message = { genericClient: 'Cliente MCP genérico', revealConfigHelp: 'Exiba o Token para gerar a configuração do cliente.', downloadGuide: 'Baixar guia de conexão (.md)', - saveSecret: 'Salve o Token e selecione “I have saved the token”.', alreadyCreated: 'Credencial criada. Veja o Token nos detalhes de conexão.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1848,8 +1865,6 @@ const message = { bulkHelp: 'As ações em lote afetam as ferramentas correspondentes de todas as categorias, incluindo as ocultas, independentemente de “Somente selecionadas”. Novas ferramentas exigem autorização separada.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'Os clientes devem usar a URL de conexão.', @@ -1874,7 +1889,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'A lista de ferramentas foi atualizada. Reabra o editor para continuar.', grantsNeedReview: 'Algumas permissões de ferramentas precisam ser confirmadas novamente.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1882,10 +1897,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Substitua na configuração pelo Token da credencial.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + 'Excluir a credencial “{0}”? Ela será invalidada imediatamente. O histórico de chamadas será mantido.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1909,10 +1925,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'O serviço 1Panel MCP ainda não está instalado. Instale-o e habilite-o para criar credenciais de acesso e chamar ferramentas MCP.', + installTaskLog: 'Registro de instalação', + installNow: 'Instalar agora', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1920,11 +1938,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Use uma tag de versão fixa ou um resumo SHA-256. A tag latest não é aceita.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Execute docker load -i no nó principal para importar a imagem antes de instalar.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Informe uma referência de imagem Docker válida com tag ou resumo SHA-256.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index b96f2f56a..57f3900c5 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1746,15 +1746,9 @@ const message = { baseUrlHelper: 'Например: http://192.168.1.2:8000, без пути MCP', ssePath: 'Путь SSE', ssePathHelper: 'Например: /sse, будьте осторожны, чтобы не дублировать с другими серверами', - environment: 'Переменные среды', - envKey: 'Имя переменной', - envValue: 'Значение переменной', externalUrl: 'Внешний адрес подключения', operatorHelper: 'Будет выполнена операция {1} на {0}, продолжить?', - domain: 'Адрес доступа по умолчанию', - domainHelper: 'Например: 192.168.1.1 или example.com', bindDomain: 'Привязать сайт', - commandPlaceHolder: 'В настоящее время поддерживаются только команды запуска npx, uvx и двоичных файлов', importMcpJson: 'Импортировать конфигурацию сервера MCP', importMcpJsonError: 'Структура mcpServers некорректна', bindDomainHelper: @@ -1784,8 +1778,31 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Отменить привязку', + websiteBinding: 'Привязка сайта', + websiteBind: 'Привязать сайт', + websiteBindRequested: 'Привязать сайт после установки', + websiteBound: 'Привязан', + websiteUnbound: 'Не привязан', + websitePending: 'Результат ожидает проверки', + websiteUnavailable: 'Привязка недоступна', + websiteDomainInvalid: 'Введите допустимый домен, при необходимости с портом.', + websiteCertificateRequired: 'Выберите действующий сертификат для этого домена.', + websiteCertificateEmpty: + 'Подходящих сертификатов нет. Выпустите или импортируйте сертификат в разделе управления сертификатами.', + websiteUnbind: 'Отвязать сайт', + websiteUnbindConfirm: 'Обратный прокси MCP и привязка будут удалены, сайт и сертификат сохранятся. Продолжить?', + websiteUncertain: + 'Результат операции не подтверждён. Обновите состояние; ожидающую операцию можно повторить с исходным запросом.', + websiteRetry: 'Повторить ожидающую операцию', + websiteSettings: 'Настройки сайта', + websiteCertificateUpdate: 'Изменить сертификат', + websiteStatusLoadFailed: 'Не удалось получить состояние привязки. Обновите и повторите попытку.', + websiteOperationFailed: 'Операция привязки завершилась ошибкой. Проверьте состояние и настройки.', + websiteConflict: 'Настройки привязки изменились. Обновите состояние и проверьте их.', + websiteBoundAddressHelp: + 'Адрес подключения формируется из привязанного сайта. Изменяйте его в настройках сайта.', hideToken: 'Скрыть Token', - revealTokenFailed: 'Не удалось получить Token. Повторите попытку.', copyTokenFailed: 'Не удалось скопировать Token. Повторите попытку.', exportFailed: 'Не удалось экспортировать инструкцию по подключению. Повторите попытку.', rotateTokenFailed: 'Не удалось создать новый Token. Повторите попытку.', @@ -1811,7 +1828,6 @@ const message = { genericClient: 'Универсальный клиент MCP', revealConfigHelp: 'Покажите Token, чтобы создать конфигурацию клиента.', downloadGuide: 'Скачать инструкцию подключения (.md)', - saveSecret: 'Сохраните Token и отметьте «I have saved the token».', alreadyCreated: 'Учётные данные созданы. Token доступен в сведениях о подключении.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1838,8 +1854,6 @@ const message = { bulkHelp: 'Массовые действия применяются к подходящим инструментам во всех категориях, включая скрытые, независимо от параметра «Только выбранные». Новые инструменты требуют отдельного разрешения.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'Клиенты должны использовать адрес подключения.', @@ -1864,7 +1878,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'Список инструментов обновлён. Откройте редактор заново, чтобы продолжить.', grantsNeedReview: 'Некоторые разрешения инструментов нужно подтвердить повторно.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1872,10 +1886,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Замените в конфигурации на Token учётных данных.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + 'Удалить учетные данные «{0}»? Они немедленно станут недействительными. История вызовов будет сохранена.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1899,10 +1914,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + 'Служба 1Panel MCP ещё не установлена. Установите и включите её, чтобы создавать учётные данные доступа и вызывать инструменты MCP.', + installTaskLog: 'Журнал установки', + installNow: 'Установить сейчас', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1910,11 +1927,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Используйте фиксированный тег версии или дайджест SHA-256. Тег latest не поддерживается.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Перед установкой выполните docker load -i на основном узле для импорта образа.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Укажите корректное имя образа Docker с тегом или дайджестом SHA-256.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 4a6847262..9786aaf9e 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1753,15 +1753,9 @@ const message = { baseUrlHelper: 'Örneğin: http://192.168.1.2:8000, MCP yolu olmadan', ssePath: 'SSE Yolu', ssePathHelper: 'Örneğin: /sse, diğer sunucularla çoğaltmamaya dikkat edin', - environment: 'Ortam Değişkenleri', - envKey: 'Değişken Adı', - envValue: 'Değişken Değeri', externalUrl: 'Harici Bağlantı Adresi', operatorHelper: '{0} üzerinde {1} işlemi gerçekleştirilecek, devam edilsin mi?', - domain: 'Varsayılan Erişim Adresi', - domainHelper: 'Örneğin: 192.168.1.1 veya example.com', bindDomain: 'Web Sitesi Bağla', - commandPlaceHolder: 'Şu anda yalnızca npx, uvx ve ikili başlatma komutları desteklenir', importMcpJson: 'MCP Sunucu Yapılandırmasını İçe Aktar', importMcpJsonError: 'mcpServers yapısı yanlış', bindDomainHelper: @@ -1792,8 +1786,31 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: 'Bağlamayı iptal et', + websiteBinding: 'Web sitesi bağlama', + websiteBind: 'Web sitesi bağla', + websiteBindRequested: 'Kurulumdan sonra web sitesi bağla', + websiteBound: 'Bağlı', + websiteUnbound: 'Bağlı değil', + websitePending: 'Sonuç onayı bekleniyor', + websiteUnavailable: 'Bağlantı kullanılamıyor', + websiteDomainInvalid: 'Geçerli bir alan adı girin; isteğe bağlı olarak bağlantı noktası ekleyebilirsiniz.', + websiteCertificateRequired: 'Bu alan adını kapsayan geçerli bir sertifika seçin.', + websiteCertificateEmpty: + 'Eşleşen sertifika yok. Sertifika yönetiminden bir sertifika isteyin veya içe aktarın.', + websiteUnbind: 'Web sitesi bağlantısını kaldır', + websiteUnbindConfirm: + 'MCP ters proxy ve bağlantı kaldırılacak; web sitesi ve sertifika korunacak. Devam edilsin mi?', + websiteUncertain: + 'İşlem sonucu doğrulanmadı. Durumu yenileyin; bekleyen işlem özgün istekle yeniden denenebilir.', + websiteRetry: 'Bekleyen işlemi yeniden dene', + websiteSettings: 'Web sitesi ayarları', + websiteCertificateUpdate: 'Sertifikayı değiştir', + websiteStatusLoadFailed: 'Web sitesi bağlantı durumu okunamadı. Yenileyip tekrar deneyin.', + websiteOperationFailed: 'Web sitesi bağlama işlemi başarısız oldu. Durumu ve yapılandırmayı kontrol edin.', + websiteConflict: 'Bağlantı yapılandırması değişti. Durumu yenileyip inceleyin.', + websiteBoundAddressHelp: 'Bağlantı adresi bağlı web sitesinden oluşturulur. Web sitesi ayarlarından yönetin.', hideToken: 'Token değerini gizle', - revealTokenFailed: 'Token alınamadı. Lütfen tekrar deneyin.', copyTokenFailed: 'Token kopyalanamadı. Lütfen tekrar deneyin.', exportFailed: 'Bağlantı kılavuzu dışa aktarılamadı. Lütfen tekrar deneyin.', rotateTokenFailed: 'Token yeniden oluşturulamadı. Lütfen tekrar deneyin.', @@ -1818,7 +1835,6 @@ const message = { genericClient: 'Genel MCP istemcisi', revealConfigHelp: 'İstemci yapılandırmasını oluşturmak için Token değerini gösterin.', downloadGuide: 'Bağlantı kılavuzunu indir (.md)', - saveSecret: 'Token değerini kaydedin ve "I have saved the token" seçeneğini işaretleyin.', alreadyCreated: 'Kimlik bilgisi oluşturuldu. Token değerini bağlantı ayrıntılarında görüntüleyin.', nodes: 'Allowed nodes', primaryNode: 'Primary node', @@ -1845,8 +1861,6 @@ const message = { bulkHelp: 'Toplu işlemler, "Yalnızca seçilenler" seçeneğinden bağımsız olarak gizli öğeler dahil tüm kategorilerdeki eşleşen araçları etkiler. Yeni araçlar ayrıca yetkilendirilmelidir.', groupSelected: '{0} / {1} granted', - expandGroup: 'Expand', - collapseGroup: 'Collapse', endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', internalEndpoint: 'Internal listening address', internalEndpointHelp: 'İstemciler bağlantı adresini kullanmalıdır.', @@ -1871,7 +1885,7 @@ const message = { writeHelp: 'Write tools modify server resources. Grant access as required.', catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', catalogEmpty: 'No tools available for authorization.', - catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + catalogChanged: 'Araç listesi güncellendi. Devam etmek için düzenleyiciyi yeniden açın.', grantsNeedReview: 'Bazı araç yetkilerinin yeniden onaylanması gerekir.', acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', removeGrant: 'Remove grant', @@ -1879,10 +1893,11 @@ const message = { endpoint: 'Connection URL', clientConfig: 'Client configuration', connectionHelp: 'Yapılandırmadaki değerini kimlik bilgisinin Token değeriyle değiştirin.', - tokenSaved: 'I have saved the token', uncertain: 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + deleteConfirm: + '“{0}” kimlik bilgisi silinsin mi? Hemen geçersiz olacaktır. Geçmiş çağrı kayıtları korunacaktır.', disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', enableConfirm: 'Enable credential “{0}” with the current authorization?', conflict: 'The data has changed. Reload the latest values before submitting.', @@ -1906,10 +1921,12 @@ const message = { statusUnhealthy: 'Unhealthy', statusIncompatible: 'Incompatible version', ready: 'Service ready', - notReady: 'Service not ready', installTitle: 'Install 1Panel MCP', installEnable: 'Install and enable', - installEmpty: '1Panel MCP is not installed.', + installEmpty: + '1Panel MCP hizmeti henüz yüklü değil. Erişim kimlik bilgileri oluşturmak ve MCP araçlarını çağırmak için hizmeti yükleyip etkinleştirin.', + installTaskLog: 'Kurulum günlüğü', + installNow: 'Şimdi yükle', upgradeTitle: 'Upgrade service', serviceConfiguration: 'Service configuration', imageUpgrade: 'Image upgrade', @@ -1917,11 +1934,7 @@ const message = { serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', serviceAdminOnly: 'Only super administrators can manage the MCP service.', dockerImage: 'Docker image', - dockerImageHelp: 'Sabit bir sürüm etiketi veya SHA-256 özeti kullanın. latest etiketi desteklenmez.', - dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', - dockerOfflineHelp: - 'Kurulumdan önce imajı içe aktarmak için birincil düğümde docker load -i komutunu çalıştırın.', - offlineInstall: 'Offline installation', + dockerImageInvalid: 'Etiket veya SHA-256 özeti içeren geçerli bir Docker imajı başvurusu girin.', boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', containerName: 'Container name', imageId: 'Image ID', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 3dc4c98dd..7c4e763a7 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1653,15 +1653,9 @@ const message = { baseUrlHelper: '例:http://192.168.1.1:8000,不含 MCP 路徑', ssePath: 'SSE 路徑', ssePathHelper: '例:/sse,注意不要與其他 Server 重複', - environment: '環境變數', - envKey: '變數名', - envValue: '變數值', externalUrl: '外部連接地址', operatorHelper: '將對 {0} 進行 {1} 操作,是否繼續?', - domain: '預設存取地址', - domainHelper: '例:192.168.1.1 或 example.com', bindDomain: '綁定網站', - commandPlaceHolder: '支援 npx、uvx 和二進位程式的啟動指令', importMcpJson: '匯入 MCP Server 設定', importMcpJsonError: 'mcpServers 結構不正確', bindDomainHelper: '綁定網站之後會修改所有已安裝 MCP Server 的存取地址,並關閉埠的外部存取', @@ -1688,8 +1682,28 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: '取消綁定', + websiteBinding: '網站綁定', + websiteBind: '綁定網站', + websiteBindRequested: '安裝後綁定網站', + websiteBound: '已綁定', + websiteUnbound: '未綁定', + websitePending: '結果待確認', + websiteUnavailable: '綁定不可用', + websiteDomainInvalid: '請輸入有效網域,可包含連接埠。', + websiteCertificateRequired: '請選擇涵蓋此網域且有效的憑證。', + websiteCertificateEmpty: '暫無相符憑證,請在憑證管理中申請或匯入。', + websiteUnbind: '解除綁定', + websiteUnbindConfirm: '將移除 MCP 反向代理及綁定關係,保留網站與憑證。是否繼續?', + websiteUncertain: '網站操作結果尚未確認,請重新整理狀態;待確認操作可使用原請求重試。', + websiteRetry: '重試待確認操作', + websiteSettings: '網站設定', + websiteCertificateUpdate: '修改憑證', + websiteStatusLoadFailed: '無法讀取網站綁定狀態,請重新整理後重試。', + websiteOperationFailed: '網站綁定操作失敗,請核對狀態及設定。', + websiteConflict: '網站綁定設定已變更,請重新整理狀態後核對。', + websiteBoundAddressHelp: '接入位址由綁定網站產生,請透過網站設定管理。', hideToken: '隱藏 Token', - revealTokenFailed: '取得 Token 失敗,請重試。', copyTokenFailed: '複製 Token 失敗,請重試。', exportFailed: '匯出連線說明失敗,請重試。', rotateTokenFailed: '重新產生 Token 失敗,請重試。', @@ -1712,7 +1726,6 @@ const message = { genericClient: '通用 MCP 用戶端', revealConfigHelp: '查看 Token 後可產生用戶端設定。', downloadGuide: '下載連線說明(.md)', - saveSecret: '請儲存 Token,並勾選「已儲存 Token」。', alreadyCreated: '憑證已建立,可在連線詳細資訊中查看 Token。', nodes: '節點授權', primaryNode: '主節點', @@ -1737,8 +1750,6 @@ const message = { readOnlyVisible: '符合的 {0} 項僅保留唯讀授權', bulkHelp: '批次操作影響所有分類的符合工具,包含未顯示項目,不受「僅看已選」影響;新增工具須個別授權。', groupSelected: '已授權 {0} / {1} 項', - expandGroup: '展開', - collapseGroup: '收起', endpointUnavailable: '連線位址無法使用,請重新整理或聯絡管理員。', internalEndpoint: '內部監聽位址', internalEndpointHelp: '用戶端請使用連線位址。', @@ -1762,7 +1773,7 @@ const message = { writeHelp: '寫入工具可修改伺服器資源,請依需要授權。', catalogUnavailable: '工具清單無法使用,請檢查服務狀態後重新整理。', catalogEmpty: '暫無可授權工具。', - catalogChanged: '工具權限定義已更新,請重新開啟編輯視窗並確認授權。', + catalogChanged: '工具清單已更新,請重新開啟編輯視窗後繼續。', grantsNeedReview: '部分工具授權須重新確認。', acceptToolPolicy: '我已核對並確認目前工具授權。', removeGrant: '移除授權', @@ -1770,9 +1781,9 @@ const message = { endpoint: '連線位址', clientConfig: '用戶端設定', connectionHelp: '將設定中的 替換為憑證 Token。', - tokenSaved: '已儲存 Token', uncertain: '建立結果尚未確認。請在目前視窗重試,或先檢查憑證清單,避免重複建立。', revokeConfirm: '確認撤銷憑證「{0}」?此操作無法復原,新的呼叫將被拒絕。', + deleteConfirm: '確認刪除憑據「{0}」?刪除後憑據立即失效,歷史呼叫記錄將保留。', disableConfirm: '確認停用憑證「{0}」?停用後將拒絕新的呼叫。', enableConfirm: '確認啟用憑證「{0}」並套用目前授權?', conflict: '資料已更新,請重新載入最新內容後提交。', @@ -1796,10 +1807,11 @@ const message = { statusUnhealthy: '服務異常', statusIncompatible: '版本不相容', ready: '服務就緒', - notReady: '服務未就緒', installTitle: '安裝 1Panel MCP', installEnable: '安裝並啟用', - installEmpty: '未安裝 1Panel MCP 服務。', + installEmpty: '1Panel MCP 服務尚未安裝,安裝並啟用後即可建立存取憑證並呼叫 MCP 工具。', + installTaskLog: '安裝日誌', + installNow: '立即安裝', upgradeTitle: '升級服務', serviceConfiguration: '服務設定', imageUpgrade: '映像升級', @@ -1807,10 +1819,7 @@ const message = { serviceOperateConfirm: '確認{0} 1Panel MCP 服務?', serviceAdminOnly: '僅超級管理員可管理 MCP 服務。', dockerImage: 'Docker 映像', - dockerImageHelp: '使用固定版本標籤或 SHA-256 摘要,不支援 latest。', - dockerImageInvalid: '映像須為 1panel/1panel-mcp-server 的有效固定版本或 SHA-256 摘要。', - dockerOfflineHelp: '在主節點執行 docker load -i 匯入映像後安裝。', - offlineInstall: '離線安裝', + dockerImageInvalid: '請輸入包含標籤或 SHA-256 摘要的有效 Docker 映像位址。', boundWebsiteHelp: '在網站清單管理綁定的網站;刪除網站可停用代理。', containerName: '容器名稱', imageId: '映像 ID', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 06fcc8a9d..ab679289b 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1667,15 +1667,9 @@ const message = { baseUrlHelper: '例如:http://192.168.1.1:8000,不含 MCP 路径', ssePath: 'SSE 路径', ssePathHelper: '例如:/sse,注意不要与其他 Server 重复', - environment: '环境变量', - envKey: '变量名', - envValue: '变量值', externalUrl: '外部连接地址', operatorHelper: '将对 {0} 进行 {1} 操作,是否继续?', - domain: '默认访问地址', - domainHelper: '例如:192.168.1.1 或 example.com', bindDomain: '绑定网站', - commandPlaceHolder: '当前支持 npx、uvx 和二进制启动的命令', importMcpJson: '导入 MCP Server 配置', importMcpJsonError: 'mcpServers 结构不正确', bindDomainHelper: '绑定网站之后会修改所有已安装 MCP Server 的访问地址,并关闭端口的外部访问', @@ -1702,8 +1696,28 @@ const message = { }, }, panelMCP: { + websiteCancelPendingBind: '取消绑定', + websiteBinding: '网站绑定', + websiteBind: '绑定网站', + websiteBindRequested: '安装后绑定网站', + websiteBound: '已绑定', + websiteUnbound: '未绑定', + websitePending: '结果待确认', + websiteUnavailable: '绑定不可用', + websiteDomainInvalid: '请输入有效域名,可包含端口。', + websiteCertificateRequired: '请选择覆盖该域名且有效的证书。', + websiteCertificateEmpty: '暂无匹配证书,请在证书管理中申请或导入。', + websiteUnbind: '解除绑定', + websiteUnbindConfirm: '将移除 MCP 反向代理及绑定关系,网站和证书予以保留。是否继续?', + websiteUncertain: '网站操作结果尚未确认,请刷新状态;待确认操作可使用原请求重试。', + websiteRetry: '重试待确认操作', + websiteSettings: '网站设置', + websiteCertificateUpdate: '修改证书', + websiteStatusLoadFailed: '无法读取网站绑定状态,请刷新后重试。', + websiteOperationFailed: '网站绑定操作失败,请核对状态及配置。', + websiteConflict: '网站绑定配置已变化,请刷新状态后核对。', + websiteBoundAddressHelp: '接入地址由绑定网站生成,请通过网站设置管理。', hideToken: '隐藏 Token', - revealTokenFailed: '获取 Token 失败,请重试。', copyTokenFailed: '复制 Token 失败,请重试。', exportFailed: '导出接入说明失败,请重试。', rotateTokenFailed: '重新生成 Token 失败,请重试。', @@ -1726,7 +1740,6 @@ const message = { genericClient: '通用 MCP 客户端', revealConfigHelp: '查看 Token 后可生成客户端配置。', downloadGuide: '下载接入说明(.md)', - saveSecret: '请保存 Token,并勾选“已保存 Token”。', alreadyCreated: '凭据已创建,可在连接详情中查看 Token。', nodes: '节点授权', primaryNode: '主节点', @@ -1751,8 +1764,6 @@ const message = { readOnlyVisible: '匹配的 {0} 项仅保留只读授权', bulkHelp: '批量操作影响所有分类的匹配工具,包含未显示项,不受“仅看已选”影响;新增工具需单独授权。', groupSelected: '已授权 {0} / {1} 项', - expandGroup: '展开', - collapseGroup: '收起', endpointUnavailable: '接入地址不可用,请刷新或联系管理员。', internalEndpoint: '内部监听地址', internalEndpointHelp: '客户端请使用接入地址。', @@ -1771,7 +1782,7 @@ const message = { writeHelp: '写入工具可修改服务器资源,请按需授权。', catalogUnavailable: '工具列表不可用,请检查服务状态后刷新。', catalogEmpty: '暂无可授权工具。', - catalogChanged: '工具权限定义已更新,请重新打开编辑窗口并确认授权。', + catalogChanged: '工具列表已更新,请重新打开编辑窗口后继续。', grantsNeedReview: '部分工具授权需重新确认。', acceptToolPolicy: '我已核对并确认当前工具授权。', removeGrant: '移除授权', @@ -1779,9 +1790,9 @@ const message = { endpoint: '接入地址', clientConfig: '客户端配置', connectionHelp: '将配置中的 替换为凭据 Token。', - tokenSaved: '已保存 Token', uncertain: '创建结果尚未确认。请在当前窗口重试,或先检查凭据列表,避免重复创建。', revokeConfirm: '确认撤销凭据“{0}”?该操作不可恢复,新的调用将被拒绝。', + deleteConfirm: '确认删除凭据“{0}”?删除后凭据立即失效,历史调用记录将保留。', disableConfirm: '确认停用凭据“{0}”?停用后将拒绝新的调用。', enableConfirm: '确认启用凭据“{0}”并应用当前授权?', conflict: '数据已更新,请重新加载最新内容后提交。', @@ -1805,10 +1816,11 @@ const message = { statusUnhealthy: '服务异常', statusIncompatible: '版本不兼容', ready: '服务就绪', - notReady: '服务未就绪', installTitle: '安装 1Panel MCP', installEnable: '安装并启用', - installEmpty: '未安装 1Panel MCP 服务。', + installEmpty: '1Panel MCP 服务尚未安装,安装并启用后即可创建访问凭据并调用 MCP 工具。', + installTaskLog: '安装日志', + installNow: '立即安装', upgradeTitle: '升级服务', serviceConfiguration: '服务配置', imageUpgrade: '镜像升级', @@ -1816,10 +1828,7 @@ const message = { serviceOperateConfirm: '确认{0} 1Panel MCP 服务?', serviceAdminOnly: '仅超级管理员可管理 MCP 服务。', dockerImage: 'Docker 镜像', - dockerImageHelp: '使用固定版本标签或 SHA-256 摘要,不支持 latest。', - dockerImageInvalid: '镜像须为 1panel/1panel-mcp-server 的有效固定版本或 SHA-256 摘要。', - dockerOfflineHelp: '在主节点执行 docker load -i 导入镜像后安装。', - offlineInstall: '离线安装', + dockerImageInvalid: '请输入包含标签或 SHA-256 摘要的有效 Docker 镜像地址。', boundWebsiteHelp: '在网站列表管理绑定的网站;删除网站可取消代理。', containerName: '容器名称', imageId: '镜像 ID', diff --git a/frontend/src/utils/panel-mcp.ts b/frontend/src/utils/panel-mcp.ts index 8e2a5afca..38761fb0f 100644 --- a/frontend/src/utils/panel-mcp.ts +++ b/frontend/src/utils/panel-mcp.ts @@ -1,12 +1,29 @@ import type { PanelMCP } from '@/api/interface/panel-mcp'; import { isAxiosError } from 'axios'; -export const DEFAULT_MCP_DOCKER_IMAGE = '1panel/1panel-mcp-server:v0.0.1'; -const releaseVersionPattern = - 'v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?'; -export const isMCPDockerImage = (image: string) => - image.length <= 180 && - new RegExp(`^1panel/1panel-mcp-server(?::${releaseVersionPattern}|@sha256:[a-f0-9]{64})$`).test(image); +export const DEFAULT_MCP_DOCKER_IMAGE = '1panel/mcp-server:v1.0.0'; +const dockerPathComponent = '[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*'; +const dockerDomainComponent = '(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])'; +const dockerRegistryHost = `(?:${dockerDomainComponent}(?:\\.${dockerDomainComponent})*|\\[[a-fA-F0-9:]+\\])`; +const dockerImagePattern = new RegExp( + `^((?:${dockerRegistryHost}(?::[0-9]+)?/)?${dockerPathComponent}(?:/${dockerPathComponent})*)(?::([A-Za-z0-9_][A-Za-z0-9_.-]{0,127}))?(?:@(sha256:[a-f0-9]{64}))?$`, +); + +export const isMCPDockerImage = (image: string) => { + if (!image || image.length > 512 || /[\s\x00-\x1f\x7f-\x9f]/.test(image)) return false; + const match = dockerImagePattern.exec(image); + if (!match || (!match[2] && !match[3])) return false; + const name = match[1]; + const slash = name.indexOf('/'); + const first = slash < 0 ? '' : name.slice(0, slash); + const explicitRegistry = first === 'localhost' || /[.:A-Z]/.test(first); + const registry = explicitRegistry ? first : 'docker.io'; + let repository = explicitRegistry ? name.slice(slash + 1) : name; + if ((registry === 'docker.io' || registry === 'index.docker.io') && !repository.includes('/')) { + repository = `library/${repository}`; + } + return repository.length <= 255; +}; export const copyMCPSettings = (settings: PanelMCP.Settings): PanelMCP.Settings => ({ runtimeType: settings.runtimeType, @@ -271,3 +288,48 @@ export const isUsableMCPNodes = (nodes: unknown): nodes is PanelMCP.Node[] => node.isLocal === (node.id === 'local'), ) && new Set(nodes.map((node) => node.id)).size === nodes.length; + +export const defaultMCPWebsiteDraft = (): PanelMCP.WebsiteDraft => ({ + requested: false, + domain: '', + enableSSL: true, + sslID: 0, +}); + +export const getMCPWebsiteHostname = (domain: string): string => { + if (!domain || /[\s/@?#\\*]/.test(domain) || domain.includes('://')) return ''; + try { + const value = new URL(`http://${domain}`); + if (value.pathname !== '/' || value.username || value.password || value.search || value.hash) return ''; + if (value.port && (!Number.isInteger(Number(value.port)) || Number(value.port) < 1)) return ''; + const hostname = value.hostname.toLowerCase().replace(/\.$/, ''); + if (hostname.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return ''; + if (hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) return ''; + return hostname; + } catch { + return ''; + } +}; + +export const isMCPWebsiteCertificateMatch = ( + domain: string, + certificate: { primaryDomain: string; otherDomains?: string; expireDate?: string }, + now = Date.now(), +) => { + const hostname = getMCPWebsiteHostname(domain); + if (!hostname) return false; + if ( + certificate.expireDate && + (!Number.isFinite(Date.parse(certificate.expireDate)) || Date.parse(certificate.expireDate) <= now) + ) { + return false; + } + return [certificate.primaryDomain, certificate.otherDomains || ''] + .flatMap((value) => value.split(/[\s,;]+/)) + .some((name) => { + const normalized = name.toLowerCase().replace(/\.$/, ''); + if (!normalized.startsWith('*.')) return hostname === normalized; + const suffix = normalized.slice(2); + return hostname.endsWith(`.${suffix}`) && hostname.split('.').length === suffix.split('.').length + 1; + }); +}; diff --git a/frontend/src/views/ai/mcp/panel/credential-editor.vue b/frontend/src/views/ai/mcp/panel/credential-editor.vue index 084d7acce..5e2bd1532 100644 --- a/frontend/src/views/ai/mcp/panel/credential-editor.vue +++ b/frontend/src/views/ai/mcp/panel/credential-editor.vue @@ -8,8 +8,9 @@ > {{ $t('commons.button.cancel') }} - + {{ $t('commons.button.confirm') }} @@ -383,7 +381,7 @@ diff --git a/frontend/src/views/ai/mcp/server/config/index.vue b/frontend/src/views/ai/mcp/server/config/index.vue index d8f7821d4..3ef7fd91b 100644 --- a/frontend/src/views/ai/mcp/server/config/index.vue +++ b/frontend/src/views/ai/mcp/server/config/index.vue @@ -45,8 +45,9 @@ const handleClose = () => { const acceptParams = (mcpServer: AI.McpServer) => { jsonObj.value.mcpServers = {}; + const path = mcpServer.outputTransport === 'sse' ? mcpServer.ssePath : mcpServer.streamableHttpPath; jsonObj.value.mcpServers[mcpServer.name] = { - url: mcpServer.baseUrl + mcpServer.ssePath, + url: mcpServer.baseUrl + path, }; prettyJson.value = JSON.stringify(jsonObj.value, null, 2); open.value = true;