mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-28 08:00:19 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+19
-6
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, "/") {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,5 @@ const (
|
||||
TaskInstall = "installApp"
|
||||
TaskCreateWebsite = "createWebsite"
|
||||
)
|
||||
|
||||
const TaskScopePanelMCP = "PanelMCP"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package taskscope separates task visibility and executor ownership from task storage.
|
||||
package taskscope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Scope string
|
||||
Restricted bool
|
||||
ExternalExecutor bool
|
||||
}
|
||||
|
||||
type policy struct{ rules []Rule }
|
||||
|
||||
var configured atomic.Pointer[policy]
|
||||
|
||||
var ErrUnavailable = errors.New("task scope policy is unavailable")
|
||||
|
||||
func Configure(rules ...Rule) error {
|
||||
copyRules := append([]Rule(nil), rules...)
|
||||
if len(copyRules) == 0 {
|
||||
return ErrUnavailable
|
||||
}
|
||||
sort.Slice(copyRules, func(i, j int) bool { return copyRules[i].Scope < copyRules[j].Scope })
|
||||
for i, rule := range copyRules {
|
||||
if rule.Scope == "" || strings.TrimSpace(rule.Scope) != rule.Scope || strings.ContainsAny(rule.Scope, "\x00\r\n") ||
|
||||
i > 0 && copyRules[i-1].Scope == rule.Scope {
|
||||
return errors.New("invalid task scope policy")
|
||||
}
|
||||
}
|
||||
next := &policy{rules: copyRules}
|
||||
if configured.CompareAndSwap(nil, next) {
|
||||
return nil
|
||||
}
|
||||
if !slices.Equal(configured.Load().rules, next.rules) {
|
||||
return errors.New("task scope policy is already configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadExclusions(granted ...string) ([]string, error) {
|
||||
p := configured.Load()
|
||||
if p == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
allowed := make(map[string]bool, len(granted))
|
||||
for _, scope := range granted {
|
||||
found := false
|
||||
for _, rule := range p.rules {
|
||||
if rule.Scope == scope && rule.Restricted {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || allowed[scope] {
|
||||
return nil, errors.New("invalid task scope grant")
|
||||
}
|
||||
allowed[scope] = true
|
||||
}
|
||||
var excluded []string
|
||||
for _, rule := range p.rules {
|
||||
if rule.Restricted && !allowed[rule.Scope] {
|
||||
excluded = append(excluded, rule.Scope)
|
||||
}
|
||||
}
|
||||
return excluded, nil
|
||||
}
|
||||
|
||||
func RecoveryExclusions() ([]string, error) {
|
||||
p := configured.Load()
|
||||
if p == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
var excluded []string
|
||||
for _, rule := range p.rules {
|
||||
if rule.ExternalExecutor {
|
||||
excluded = append(excluded, rule.Scope)
|
||||
}
|
||||
}
|
||||
return excluded, nil
|
||||
}
|
||||
|
||||
type grantKey struct{}
|
||||
|
||||
func WithGrants(ctx context.Context, granted ...string) context.Context {
|
||||
return context.WithValue(ctx, grantKey{}, append([]string(nil), granted...))
|
||||
}
|
||||
|
||||
func Grants(ctx context.Context) []string {
|
||||
granted, _ := ctx.Value(grantKey{}).([]string)
|
||||
return append([]string(nil), granted...)
|
||||
}
|
||||
+63
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
|
||||
"github.com/1Panel-dev/1Panel/core/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/core/app/service"
|
||||
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
|
||||
if _, _, ok := mcpManagement(c, false, true); !ok {
|
||||
return
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
mcpManagementError(c, contract.Invalid())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
|
||||
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
|
||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPWebsiteBind
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
|
||||
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
|
||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPWebsiteUpdate
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
|
||||
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
|
||||
if _, _, ok := mcpManagement(c, true, true); !ok {
|
||||
return
|
||||
}
|
||||
var req dto.MCPWebsiteUnbind
|
||||
if !mcpRead(c, &req) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
|
||||
defer cancel()
|
||||
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
|
||||
if err != nil {
|
||||
mcpManagementError(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, result)
|
||||
}
|
||||
+35
-19
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package dto
|
||||
|
||||
type MCPWebsiteBind struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Domain string `json:"domain"`
|
||||
EnableSSL bool `json:"enableSSL"`
|
||||
SSLID uint `json:"sslID"`
|
||||
}
|
||||
|
||||
type MCPWebsiteUpdate struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
EnableSSL bool `json:"enableSSL"`
|
||||
SSLID uint `json:"sslID"`
|
||||
}
|
||||
|
||||
type MCPWebsiteUnbind struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
type MCPWebsitePendingRequest struct {
|
||||
Operation string `json:"operation"`
|
||||
RequestID string `json:"requestId"`
|
||||
Domain string `json:"domain"`
|
||||
EnableSSL bool `json:"enableSSL"`
|
||||
SSLID uint `json:"sslID"`
|
||||
}
|
||||
|
||||
type MCPWebsiteStatus struct {
|
||||
State string `json:"state"`
|
||||
WebsiteID uint `json:"websiteId"`
|
||||
Domain string `json:"domain"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
EnableSSL bool `json:"enableSSL"`
|
||||
SSLID uint `json:"sslID"`
|
||||
WebsiteStatus string `json:"websiteStatus"`
|
||||
RequestID string `json:"requestId"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
|
||||
}
|
||||
+12
-9
@@ -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:''"`
|
||||
}
|
||||
|
||||
+169
-1
@@ -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
|
||||
}
|
||||
|
||||
+24
-48
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+138
-31
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "اعتبارسنجی تصویر موفق بود"
|
||||
|
||||
@@ -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: "イメージの検証に成功しました"
|
||||
|
||||
@@ -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: "이미지 검증 통과"
|
||||
|
||||
@@ -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: "ກວດສອບອິມເມດຜ່ານແລ້ວ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "Образ успешно проверен"
|
||||
|
||||
@@ -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ı"
|
||||
|
||||
@@ -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: "映像驗證通過"
|
||||
|
||||
@@ -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: "镜像校验通过"
|
||||
|
||||
@@ -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{})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<WebsiteDraft, 'domain' | 'enableSSL' | 'sslID'>;
|
||||
}
|
||||
export interface InstallationTask {
|
||||
taskID: string;
|
||||
}
|
||||
export type Operation = 'start' | 'stop' | 'restart';
|
||||
export interface Invocation {
|
||||
|
||||
@@ -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<PanelMCP.ServiceStatus>(`${prefix}/status`, {}, local);
|
||||
export const getPanelMCPDockerStatus = () =>
|
||||
http.get<Container.DockerStatus>('/containers/docker/status', {}, { ...local, timeout: 5000 });
|
||||
export const getPanelMCPSettings = () => http.get<PanelMCP.Settings>(`${prefix}/settings`, {}, local);
|
||||
export const getPanelMCPWebsite = () => http.get<PanelMCP.WebsiteBinding>(`${prefix}/website/status`, {}, local);
|
||||
export const bindPanelMCPWebsite = (params: PanelMCP.WebsiteBind) =>
|
||||
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/bind`, params, { ...local, timeout: 120000 });
|
||||
export const updatePanelMCPWebsite = (params: PanelMCP.WebsiteUpdate) =>
|
||||
http.postWithConfig<PanelMCP.WebsiteBinding>(`${prefix}/website/update`, params, { ...local, timeout: 120000 });
|
||||
export const unbindPanelMCPWebsite = (params: PanelMCP.WebsiteMutation) =>
|
||||
http.postWithConfig<PanelMCP.WebsiteBinding>(`${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<PanelMCP.ServiceStatus>(`${prefix}/service/install`, params, { ...local, timeout: 420000 });
|
||||
http.postWithConfig<PanelMCP.InstallationTask>(`${prefix}/service/install`, params, local);
|
||||
export const getPanelMCPLogs = () => http.get<{ content: string }>(`${prefix}/service/logs`, {}, local);
|
||||
export const getPanelMCPCatalog = () => http.get<PanelMCP.Catalog>(`${prefix}/catalog`, {}, local);
|
||||
export const getPanelMCPNodes = () => http.get<PanelMCP.Node[]>(`${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<PanelMCP.Revealed>(`${prefix}/credentials/reveal`, params, { ...local, signal });
|
||||
export const rotatePanelMCPCredential = (params: PanelMCP.Reference, signal?: AbortSignal) =>
|
||||
|
||||
@@ -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 <MCP_TOKEN> 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 <archive.tar> 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',
|
||||
|
||||
@@ -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 <MCP_TOKEN> 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 <archive.tar> 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',
|
||||
|
||||
@@ -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: 'در پیکربندی، <MCP_TOKEN> را با 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 <archive.tar> را اجرا کنید.',
|
||||
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',
|
||||
|
||||
@@ -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: '設定の <MCP_TOKEN> を認証情報の 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 <archive.tar> を実行してイメージをインポートしてください。',
|
||||
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',
|
||||
|
||||
@@ -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: '구성의 <MCP_TOKEN>을 자격 증명 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 <archive.tar>를 실행하여 이미지를 가져오세요.',
|
||||
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',
|
||||
|
||||
@@ -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: 'ແທນທີ່ <MCP_TOKEN> ໃນການຕັ້ງຄ່າດ້ວຍ 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 <archive.tar> ໃນໂນດຫຼັກເພື່ອນຳເຂົ້າອິມເມຈ.',
|
||||
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',
|
||||
|
||||
@@ -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 <MCP_TOKEN> 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 <archive.tar> 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',
|
||||
|
||||
@@ -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 <MCP_TOKEN> 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 <archive.tar> 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',
|
||||
|
||||
@@ -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: 'Замените <MCP_TOKEN> в конфигурации на 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 <archive.tar> на основном узле для импорта образа.',
|
||||
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',
|
||||
|
||||
@@ -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 <MCP_TOKEN> 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 <archive.tar> 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',
|
||||
|
||||
@@ -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: '將設定中的 <MCP_TOKEN> 替換為憑證 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 <archive.tar> 匯入映像後安裝。',
|
||||
offlineInstall: '離線安裝',
|
||||
dockerImageInvalid: '請輸入包含標籤或 SHA-256 摘要的有效 Docker 映像位址。',
|
||||
boundWebsiteHelp: '在網站清單管理綁定的網站;刪除網站可停用代理。',
|
||||
containerName: '容器名稱',
|
||||
imageId: '映像 ID',
|
||||
|
||||
@@ -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: '将配置中的 <MCP_TOKEN> 替换为凭据 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 <archive.tar> 导入镜像后安装。',
|
||||
offlineInstall: '离线安装',
|
||||
dockerImageInvalid: '请输入包含标签或 SHA-256 摘要的有效 Docker 镜像地址。',
|
||||
boundWebsiteHelp: '在网站列表管理绑定的网站;删除网站可取消代理。',
|
||||
containerName: '容器名称',
|
||||
imageId: '镜像 ID',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user