Compare commits

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

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
145 changed files with 9043 additions and 1652 deletions
-16
View File
@@ -8,7 +8,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon" "github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -161,25 +160,10 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post] // @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) { func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true) install, err := appService.Install(req, true)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-18
View File
@@ -1,11 +1,7 @@
package v2 package v2
import ( import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -340,25 +336,11 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get] // @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId) info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-17
View File
@@ -2,12 +2,9 @@ package v2
import ( import (
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -101,25 +98,11 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type" // @Param type path string true "type"
// @Router /databases/db/list/:type [get] // @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) { func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type") dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil { if err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType) list, err := databaseService.List(dbType)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
-33
View File
@@ -3,12 +3,9 @@ package v2
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -22,11 +19,6 @@ import (
// @Router /databases [post] // @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) { func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
@@ -40,17 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -531,25 +512,11 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /databases/status [post] // @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) { func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req) data, err := mysqlService.LoadStatus(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+137
View File
@@ -0,0 +1,137 @@
package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
type mcpBusinessAPI struct {
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
}
func MCPBusinessDispatch() gin.HandlerFunc {
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
}
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
return func(c *gin.Context) {
var handler gin.HandlerFunc
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/v2/databases":
handler = api.createMysql
case "POST /api/v2/databases/status":
handler = api.mysqlStatus
case "GET /api/v2/databases/db/list/:type":
handler = api.databases
case "POST /api/v2/apps/install":
handler = api.install
case "GET /api/v2/apps/installed/info/:appInstallId":
handler = api.appInfo
default:
c.Next()
return
}
present, trusted := mcpapi.Check(c.Request)
if !present {
c.Next()
return
}
if !trusted {
c.AbortWithStatus(http.StatusForbidden)
return
}
handler(c)
c.Abort()
}
}
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
}
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (api *mcpBusinessAPI) databases(c *gin.Context) {
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
func (api *mcpBusinessAPI) install(c *gin.Context) {
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
installed, err := api.installApp(req, true)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, installed)
}
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
id, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+29
View File
@@ -0,0 +1,29 @@
package v2
import (
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
func PanelMCPTaskAccess() gin.HandlerFunc {
return func(c *gin.Context) {
if hasPanelMCPTaskAccess(c.Request) {
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
}
c.Next()
}
}
func hasPanelMCPTaskAccess(req *http.Request) bool {
if !global.IsMaster {
return false
}
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
values := req.Header.Values("X-Panel-MCP-Task-Access")
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
}
+12 -3
View File
@@ -5,6 +5,8 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
total, list, err := taskService.Page(req) total, list, err := taskServiceForRequest(c).Page(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return return
} }
var res *response.FileLineContent var res *response.FileLineContent
res, err := taskService.ReadByLine(req) res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /logs/tasks/executing/count [get] // @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) { func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask() count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, count) helper.SuccessWithData(c, count)
} }
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
-2
View File
@@ -27,8 +27,6 @@ type AppInstallCreate struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
+14
View File
@@ -0,0 +1,14 @@
package request
type MCPWebsiteBinding struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
+17
View File
@@ -0,0 +1,17 @@
package response
type MCPWebsiteBinding struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason"`
Code string `json:"code,omitempty"`
Error string `json:"error,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
+7
View File
@@ -38,6 +38,7 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error) LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
} }
func NewIAppInstallRepo() IAppInstallRepo { func NewIAppInstallRepo() IAppInstallRepo {
@@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err return install, err
} }
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error { func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{}) db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error return db.Omit(clause.Associations).Create(&install).Error
+29
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error) Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{} return &DatabaseRepo{}
} }
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) { func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database var database model.Database
db := global.DB db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error) List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error return getTx(ctx).Create(mysql).Error
} }
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error { func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
} }
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface { type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error) Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil return user, nil
} }
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) { func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{}) db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error return global.DB.Save(user).Error
} }
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error { func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB db := global.DB
for _, opt := range opts { for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error) Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error return global.DB.Save(grant).Error
} }
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error { func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
+19
View File
@@ -15,10 +15,13 @@ type SettingRepo struct{}
type ISettingRepo interface { type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error) GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
@@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err return settings, err
} }
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) { func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+19 -6
View File
@@ -19,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error) GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask() (int64, error) CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll() error DeleteAll() error
@@ -29,6 +29,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
} }
func NewITaskRepo() ITaskRepo { func NewITaskRepo() ITaskRepo {
@@ -110,18 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error return getTaskTx(ctx).Save(&task).Error
} }
func (t TaskRepo) UpdateRunningTaskToFailed() error { func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{ opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(), "status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error }).Error
} }
func (t TaskRepo) CountExecutingTask() (int64, error) { func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64 var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err return count, err
} }
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error { func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB db := global.TaskDB
for _, opt := range opts { for _, opt := range opts {
+3 -2
View File
@@ -59,7 +59,8 @@ type IAppService interface {
} }
type appInstallHooks struct { type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
} }
func NewIAppService() IAppService { func NewIAppService() IAppService {
@@ -614,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} }
} }
if app.Key == "openresty" { if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil { if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err return err
} }
if err = handleOpenrestyFile(appInstall); err != nil { if err = handleOpenrestyFile(appInstall); err != nil {
+10 -44
View File
@@ -1,53 +1,19 @@
package service package service
import ( import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error { func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
changed := errors.New("MCP website directory changed; inspect the installation before retrying") return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
directory, ok := req.Params["WEBSITE_DIR"].(string) }
if !ok || directory == "" || global.DB == nil {
return changed func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
if !managesOpenrestySiteDirectory(app, detail) {
return nil
} }
return global.DB.Transaction(func(tx *gorm.DB) error { directory, _ := req.Params["WEBSITE_DIR"].(string)
var setting model.Setting return ensureCurrentWebsiteDirectory(directory)
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
} }
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func ensureCurrentWebsiteDirectory(directory string) error {
changed := errors.New("website directory changed; inspect the installation before retrying")
if directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
return err
}
} else if setting.Value == "" {
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
if err != nil {
return err
}
if updated != 1 {
return changed
}
}
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
})
}
+12 -4
View File
@@ -2033,11 +2033,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
return res, nil return res, nil
} }
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { if managesOpenrestySiteDirectory(app, appDetail) {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err return err
} }
nginxConfFile := GetWebsiteConfigPath(*website) nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil { if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err return err
} }
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+11 -3
View File
@@ -1660,6 +1660,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
} }
func checkImageExist(client *client.Client, imageItem string) bool { func checkImageExist(client *client.Client, imageItem string) bool {
return checkImageExistContext(context.Background(), client, imageItem)
}
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
if client == nil { if client == nil {
var err error var err error
client, err = docker.NewDockerClient() client, err = docker.NewDockerClient()
@@ -1667,7 +1671,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false return false
} }
} }
images, err := client.ImageList(context.Background(), image.ListOptions{}) images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil { if err != nil {
return false return false
} }
@@ -1709,6 +1713,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
} }
func pullImages(task *task.Task, client *client.Client, imageName string) error { func pullImages(task *task.Task, client *client.Client, imageName string) error {
return pullImagesContext(context.Background(), task, client, imageName)
}
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client) dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List() repos, err := imageRepoRepo.List()
if err != nil { if err != nil {
@@ -1716,7 +1724,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
} }
imageRepo := selectImageRepo(imageName, repos) imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth { if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName) return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
} }
options := image.PullOptions{} options := image.PullOptions{}
@@ -1729,7 +1737,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err return err
} }
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options) return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo { func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
+270 -71
View File
@@ -91,81 +91,95 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return nil return nil
} }
const containerUpgradeConcurrency = 2
// Shared by all upgrade requests handled by this agent.
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error { func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {
_ = client.Close() _ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
go func() { taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
defer client.Close() ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error { defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image)) taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull { if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImages(taskItem, client, req.Image); err != nil { if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
if !req.ForcePull { return fmt.Errorf("pull image %s failed: %w", req.Image, err)
return err }
} }
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err) return nil
} }, nil)
var upgradeErrors []error
for _, item := range req.Names {
item := item
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
defer cancel()
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
if lockErr != nil {
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
return lockErr
}
defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
oldContainer, inspectErr := client.ContainerInspect(ctx, item)
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr)
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t, true)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
} }
return nil return nil
}, nil) })
var upgradeErrors []error }
for _, item := range req.Names { taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
item := item return errors.Join(upgradeErrors...)
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error { }, nil)
t.Logf("----------------- %s -----------------", item) // Stage contexts own the deadlines, including a separate recovery budget.
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item)) for _, subTask := range taskItem.SubTasks {
unlock := containerOperationLock.lock(item) subTask.Timeout = 0
defer unlock() }
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil) if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
oldContainer, inspectErr := client.ContainerInspect(ctx, item) return err
t.LogWithStatus(i18n.GetWithName("ContainerLoadInfo", item), inspectErr) }
if inspectErr != nil {
err := fmt.Errorf("reload container %s failed: %w", item, inspectErr)
upgradeErrors = append(upgradeErrors, err)
return err
}
config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image
hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
if err := normalizeContainerEndpointSettings(ctx, client, networkConf, extraNetworks); err != nil {
upgradeErr := fmt.Errorf("prepare networks for container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t)
if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr
}
if cleanupErr != nil {
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
}
return nil
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
if err := taskItem.Execute(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil return nil
} }
@@ -192,11 +206,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct { type containerOperationLockEntry struct {
mutex sync.Mutex token chan struct{}
references int references int
} }
func (l *containerOperationMutex) lock(names ...string) func() { func (l *containerOperationMutex) lock(names ...string) func() {
unlock, _ := l.lockContext(context.Background(), names...)
return unlock
}
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
nameSet := make(map[string]struct{}, len(names)) nameSet := make(map[string]struct{}, len(names))
for _, name := range names { for _, name := range names {
if name != "" { if name != "" {
@@ -209,7 +228,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
sort.Strings(orderedNames) sort.Strings(orderedNames)
if len(orderedNames) == 0 { if len(orderedNames) == 0 {
return func() {} return func() {}, nil
} }
l.mutex.Lock() l.mutex.Lock()
@@ -220,7 +239,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames { for _, name := range orderedNames {
entry := l.locks[name] entry := l.locks[name]
if entry == nil { if entry == nil {
entry = &containerOperationLockEntry{} entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry l.locks[name] = entry
} }
entry.references++ entry.references++
@@ -228,12 +247,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
l.mutex.Unlock() l.mutex.Unlock()
for _, entry := range entries { acquired := 0
entry.mutex.Lock() release := func() {
} for index := acquired - 1; index >= 0; index-- {
return func() { <-entries[index].token
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
} }
l.mutex.Lock() l.mutex.Lock()
defer l.mutex.Unlock() defer l.mutex.Unlock()
@@ -245,6 +262,20 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
} }
} }
for _, entry := range entries {
select {
case entry.token <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
} }
type containerNetworkAttachment struct { type containerNetworkAttachment struct {
@@ -272,6 +303,131 @@ func switchContainer(
createNew func() (container.CreateResponse, error), createNew func() (container.CreateResponse, error),
tty bool, tty bool,
logger containerSwitchLogger, logger containerSwitchLogger,
) (cleanupErr error, err error) {
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
containerUpgradeRecoveryTimeout = 5 * time.Minute
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
)
// Reconcile against Docker's actual state: a failed request may already have
// changed names, created a container, or disconnected a network on the daemon.
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
original, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("inspect original %s: %w", old.ID, err)
}
if original.State == nil {
return fmt.Errorf("original container state unavailable")
}
replacement, inspectErr := cli.ContainerInspect(ctx, name)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
}
if inspectErr == nil && replacement.ID != old.ID {
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
}
if newID != "" && replacement.ID != newID {
return fmt.Errorf("replacement ID changed; refusing to remove it")
}
newID = replacement.ID
}
if newID != "" {
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
}
if inspectErr == nil {
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("replacement identity could not be confirmed")
}
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("remove replacement: %w", err)
}
}
}
if strings.TrimPrefix(original.Name, "/") != name {
err = cli.ContainerRename(ctx, old.ID, name)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
if err != nil {
return fmt.Errorf("restore original name: %w", err)
}
}
// Reload after mutations; connect only networks which are actually missing.
original, err = cli.ContainerInspect(ctx, old.ID)
if err != nil {
return err
}
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
wanted := make(map[string]*network.EndpointSettings)
if primary != nil {
for n, endpoint := range primary.EndpointsConfig {
if n != "bridge" {
wanted[n] = endpoint
}
}
}
for n, endpoint := range extras {
if n != "bridge" {
wanted[n] = endpoint
}
}
var reconnectErr error
for n, endpoint := range wanted {
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
continue
}
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
}
}
if reconnectErr != nil {
return reconnectErr
}
if old.State != nil && old.State.Running {
if original.State == nil {
return fmt.Errorf("original state unavailable after recovery")
}
if !original.State.Running {
err = restartOriginalContainer(ctx, cli, old.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
if err != nil {
return err
}
}
}
verified, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("verify recovery: %w", err)
}
if strings.TrimPrefix(verified.Name, "/") != name {
return fmt.Errorf("original name was not restored")
}
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
return fmt.Errorf("original container is not running after recovery")
}
for n := range wanted {
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
return fmt.Errorf("network %s was not restored", n)
}
}
return nil
}
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
tty bool, logger containerSwitchLogger, boundedRecovery bool,
) (cleanupErr error, err error) { ) (cleanupErr error, err error) {
if oldContainer.ID == "" { if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty") return nil, fmt.Errorf("original container ID is empty")
@@ -281,9 +437,32 @@ func switchContainer(
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name) return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
} }
recoverUpgrade := func(newID string, cause error) error {
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
if newID != "" {
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
}
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
if ctx.Err() != nil {
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
}
if recoveryErr != nil {
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
if logger != nil {
logger.Log(message)
}
return errors.Join(cause, errors.New(message))
}
return cause
}
if wasRunning { if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil { if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err) logContainerSwitchStep(logger, "ContainerStopOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
}
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID) current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running { if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -298,6 +477,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID) backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil { if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err) logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning { if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr) logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -309,6 +491,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer) disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr) logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil { if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr) return nil, errors.Join(disconnectErr, rollbackErr)
} }
@@ -316,11 +501,17 @@ func switchContainer(
created, createErr := createNew() created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr) logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil { if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr) return nil, errors.Join(createErr, rollbackErr)
} }
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err) logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
@@ -329,6 +520,9 @@ func switchContainer(
if wasRunning { if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil { if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
@@ -336,6 +530,11 @@ func switchContainer(
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
} }
if boundedRecovery {
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
ctx = cleanupCtx
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false}) cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr) logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil return cleanupErr, nil
+77 -65
View File
@@ -433,10 +433,45 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) {
} }
func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
return u.create(ctx, req, false) creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
req = creation.request
cli, version, err := LoadMysqlClientByFrom(req.Database)
if err != nil {
return nil, err
}
defer cli.Close()
if err := cli.Create(creation.createInfo(version)); err != nil {
return nil, err
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range creation.permissionHosts {
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
} }
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) { type mysqlCreation struct {
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, " ") { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal") return nil, buserr.New("ErrCmdIllegal")
} }
@@ -474,13 +509,13 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
return nil, errors.New("cannot set root as user name") return nil, errors.New("cannot set root as user name")
} }
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database createItem.MysqlName = req.Database
defer cli.Close() return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
createInfo := client.CreateInfo{ }
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
req := creation.request
return client.CreateInfo{
Name: req.Name, Name: req.Name,
Format: req.Format, Format: req.Format,
Collation: req.Collation, Collation: req.Collation,
@@ -490,55 +525,16 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
Version: version, Version: version,
Timeout: 300, Timeout: 300,
} }
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err
}
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range permissionHosts {
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
} }
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error { func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, constant.DB, tx)
if req.Username != "" { if req.Username != "" {
key := global.CONF.Base.EncryptKey key := global.CONF.Base.EncryptKey
if key == "" { if key == "" {
var setting model.Setting setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { if err != nil {
return err return err
} }
key = setting.Value key = setting.Value
@@ -548,8 +544,7 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
return err return err
} }
for _, host := range hosts { for _, host := range hosts {
var user model.DatabaseUser user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -558,16 +553,16 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
if req.Description != "" { if req.Description != "" {
user.Description = req.Description user.Description = req.Description
} }
if err := tx.Save(&user).Error; err != nil { if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
return err return err
} }
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host} grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil { if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
return err return err
} }
} }
} }
return tx.Create(item).Error return mysqlRepo.CreateMetadata(txCtx, item)
}) })
} }
@@ -1603,10 +1598,34 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
} }
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false) databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
return newMysqlClientForDatabase(databaseItem, database)
} }
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) { func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
return nil, "", errors.New("an installed local MySQL instance is required")
}
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
if err != nil {
return nil, "", err
}
local, ok := cli.(*client.Local)
if !ok {
cli.Close()
return nil, "", errors.New("an installed local MySQL instance is required")
}
return local, version, nil
}
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
var ( var (
dbInfo client.DBInfo dbInfo client.DBInfo
version string version string
@@ -1614,13 +1633,6 @@ func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClien
) )
dbInfo.Timeout = 300 dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From dbInfo.From = databaseItem.From
dbInfo.Database = database dbInfo.Database = database
+27 -1
View File
@@ -16,9 +16,35 @@ func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate)
} }
writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second)
defer cancel() defer cancel()
created, err := u.create(writeContext, req, true) created, err := createMCPMysqlDatabase(writeContext, req)
if err != nil { if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
} }
return created, nil return created, nil
} }
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
if err != nil {
return nil, err
}
defer local.Close()
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
cancel()
if err != nil {
return nil, err
}
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
+10
View File
@@ -0,0 +1,10 @@
package service
import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
)
func ConfigurePanelMCPTasks() error {
return taskscope.Configure(taskscope.Rule{Scope: constant.TaskScopePanelMCP, Restricted: true, ExternalExecutor: true})
}
+821
View File
@@ -0,0 +1,821 @@
package service
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
"github.com/docker/docker/api/types/container"
"github.com/google/uuid"
"golang.org/x/net/idna"
"gorm.io/gorm"
)
const (
mcpWebsitePrivateDir = ".1panel-mcp"
mcpWebsiteProxyName = "proxy.conf"
mcpWebsiteOwnerName = "binding.json"
)
var mcpWebsiteMutation sync.Mutex
func init() {
configureWebsiteManagedConfig(websiteManagedConfigPolicy{
protectedDirectories: func() []string {
return []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir)}
},
backupIncludes: mcpWebsiteBackupIncludes,
})
}
type MCPWebsiteBindingService struct {
createWebsite func(request.WebsiteCreate) error
updateHTTPS func(context.Context, request.WebsiteHTTPSOp) error
applyInclude func(model.Website) error
removeInclude func(model.Website) error
checkReload func() error
openresty func() (model.AppInstall, error)
now func() time.Time
}
func NewMCPWebsiteBindingService() *MCPWebsiteBindingService {
s := &MCPWebsiteBindingService{
createWebsite: func(req request.WebsiteCreate) error { return NewIWebsiteService().CreateWebsite(req) },
updateHTTPS: func(ctx context.Context, req request.WebsiteHTTPSOp) error {
if req.Enable {
current, err := NewIWebsiteService().GetWebsiteHTTPS(req.WebsiteID)
if err != nil {
return err
}
defaults := newWebsiteCreateHTTPSOp(req.WebsiteSSLID)
if current.Enable {
req.SSLProtocol, req.Algorithm = current.SSLProtocol, current.Algorithm
req.Hsts, req.HstsIncludeSubDomains, req.Http3 = current.Hsts, current.HstsIncludeSubDomains, current.Http3
req.HttpConfig = current.HttpConfig
} else {
defaults.WebsiteID = req.WebsiteID
req = defaults
}
}
_, err := NewIWebsiteService().OpWebsiteHTTPS(ctx, req)
return err
},
checkReload: func() error {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
if installed.Status != constant.StatusRunning {
return nil
}
if err = opNginx(installed.ContainerName, constant.NginxCheck); err != nil {
return err
}
return opNginx(installed.ContainerName, constant.NginxReload)
},
openresty: mcpWebsiteOpenresty,
now: time.Now,
}
s.applyInclude = func(site model.Website) error { return s.setInclude(site, true) }
s.removeInclude = func(site model.Website) error { return s.setInclude(site, false) }
return s
}
func mcpWebsiteOpenresty() (model.AppInstall, error) {
installed, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return installed, err
}
client, err := docker.NewDockerClient()
if err != nil {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
inspected, err := client.ContainerInspect(ctx, installed.ContainerName)
if err != nil || !mcpWebsiteHasWWWMount(inspected.Mounts, GetWebSiteRootDir()) {
return model.AppInstall{}, errors.New("OpenResty mount is unavailable")
}
return installed, nil
}
func mcpWebsiteHasWWWMount(mounts []container.MountPoint, root string) bool {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return false
}
for _, mount := range mounts {
if filepath.Clean(mount.Destination) != "/www" {
continue
}
resolvedSource, sourceErr := filepath.EvalSymlinks(mount.Source)
if sourceErr == nil && resolvedSource == resolvedRoot {
return true
}
}
return false
}
type mcpWebsiteOwner struct {
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
KeyHash string `json:"keyHash"`
BindHash string `json:"bindHash"`
ProxyHash string `json:"proxyHash"`
RequestID string `json:"requestId"`
RequestHash string `json:"requestHash"`
Operation string `json:"operation"`
}
func mcpWebsiteHash(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}
func mcpWebsiteAlias(bindingID string) string { return "panel-mcp-" + bindingID }
func mcpWebsitePrivatePath(site model.Website, name string) string {
return filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, strings.TrimPrefix(site.Alias, "panel-mcp-"), name)
}
func mcpWebsiteInclude(site model.Website) string {
return "/www/" + mcpWebsitePrivateDir + "/" + strings.TrimPrefix(site.Alias, "panel-mcp-") + "/" + mcpWebsiteProxyName
}
func mcpWebsiteBackupIncludes(site model.Website) []string {
bindingID := strings.TrimPrefix(site.Alias, "panel-mcp-")
if site.Alias != mcpWebsiteAlias(bindingID) || !validMCPWebsiteID(bindingID) {
return nil
}
return []string{mcpWebsiteInclude(site)}
}
func mcpWebsitePrivateDirectory(site model.Website, create bool) error {
for _, directory := range []string{filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir), filepath.Dir(mcpWebsitePrivatePath(site, mcpWebsiteProxyName))} {
info, err := os.Lstat(directory)
if os.IsNotExist(err) && create {
if err = os.Mkdir(directory, 0700); err != nil {
return err
}
info, err = os.Lstat(directory)
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 {
return errors.New("invalid MCP website private directory")
}
}
return nil
}
func (s *MCPWebsiteBindingService) setInclude(site model.Website, enabled bool) error {
configPath := GetSitePath(site, SiteConf)
old, err := os.ReadFile(configPath)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(old)).Parse()
if err != nil || len(config.FindServers()) == 0 {
return errors.New("invalid website configuration")
}
server := config.FindServers()[0]
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
server.RemoveDirective("include", parameters)
}
}
if enabled {
server.UpdateDirective("include", []string{include})
}
updated := []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
if string(updated) == string(old) {
return nil
}
if err = mcpWebsiteAtomicWrite(configPath, updated); err != nil {
return err
}
if err = s.checkReload(); err != nil {
_ = mcpWebsiteAtomicWrite(configPath, old)
_ = s.checkReload()
return err
}
return nil
}
func mcpWebsiteRemark(req request.MCPWebsiteBinding) string {
return "1Panel MCP " + req.BindingID + " " + mcpWebsiteHash(req.ProxyKey)
}
func mcpWebsiteFailure(reason string, definitive, conflict bool) response.MCPWebsiteBinding {
code, message := "MCP_WEBSITE_UNAVAILABLE", "MCP website operation is unavailable"
if definitive {
code, message = "MCP_WEBSITE_INVALID_ARGUMENTS", "Invalid MCP website configuration"
}
if conflict {
code, message = "MCP_WEBSITE_CONFLICT", "MCP website configuration changed"
}
return response.MCPWebsiteBinding{State: "unavailable", Reason: reason, Code: code, Error: message, Definitive: definitive}
}
func validMCPWebsiteID(value string) bool {
parsed, err := uuid.Parse(value)
return err == nil && parsed != uuid.Nil && parsed.String() == value
}
func mcpWebsiteDomain(value string) (string, int, error) {
if value == "" || len(value) > 320 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "/\\?#@\r\n\t ;{}\"'$\x00") {
return "", 0, errors.New("invalid domain")
}
host, port := value, 0
if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil {
host = parsedHost
port, err = strconv.Atoi(parsedPort)
if err != nil || port < 1 || port > 65535 {
return "", 0, errors.New("invalid port")
}
} else if strings.ContainsAny(value, ":[]") {
return "", 0, errors.New("invalid domain")
}
host, err := idna.Lookup.ToASCII(strings.ToLower(host))
if err != nil || len(host) > 253 || net.ParseIP(host) != nil {
return "", 0, errors.New("invalid domain")
}
for _, label := range strings.Split(host, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", 0, errors.New("invalid domain")
}
for _, char := range label {
if char != '-' && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return "", 0, errors.New("invalid domain")
}
}
}
return host, port, nil
}
// Core can select a locally bound panel IP, but an external upstream is never
// accepted even on this private API. Host/SNI is a literal hostname, not Nginx code.
func validateMCPWebsiteUpstream(req request.MCPWebsiteBinding) bool {
if strings.ContainsAny(req.Upstream, "\r\n\t ;{}\"'$#?") {
return false
}
u, err := url.Parse(req.Upstream)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Path != "/mcp" || u.RawPath != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Port() == "" {
return false
}
ip := net.ParseIP(u.Hostname())
if ip == nil || ip.IsUnspecified() {
return false
}
port, err := strconv.Atoi(u.Port())
if err != nil || port < 1 || port > 65535 {
return false
}
local := ip.IsLoopback()
if !local {
addresses, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, address := range addresses {
if network, ok := address.(*net.IPNet); ok && network.IP.Equal(ip) {
local = true
break
}
}
}
if !local || len(req.ProxyKey) != 64 {
return false
}
if _, err = hex.DecodeString(req.ProxyKey); err != nil {
return false
}
if req.CoreHost == "" || len(req.CoreHost) > 320 || strings.ContainsAny(req.CoreHost, "/\\?#@\r\n\t ;{}\"'$\x00") {
return false
}
host := req.CoreHost
if parsed, _, splitErr := net.SplitHostPort(host); splitErr == nil {
host = parsed
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.Trim(host, "[]")
}
if net.ParseIP(host) == nil {
if _, _, domainErr := mcpWebsiteDomain(host); domainErr != nil {
return false
}
}
return true
}
func mcpWebsiteProxy(req request.MCPWebsiteBinding) []byte {
host := req.CoreHost
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
host = strings.Trim(host, "[]")
var tlsDirectives string
if strings.HasPrefix(req.Upstream, "https://") {
tlsDirectives = " proxy_ssl_server_name on;\n proxy_ssl_name " + host + ";\n"
}
return []byte("# Managed by 1Panel MCP: " + req.BindingID + "\n" +
"location = /mcp {\n" +
" proxy_pass " + req.Upstream + ";\n" +
" proxy_http_version 1.1;\n" +
" proxy_set_header Host " + req.CoreHost + ";\n" +
" proxy_set_header Connection \"\";\n" +
" proxy_set_header X-Panel-MCP-Website-Key " + req.ProxyKey + ";\n" +
" proxy_set_header X-Panel-MCP-Website-Peer $realip_remote_addr;\n" +
" proxy_set_header X-Forwarded-For $http_x_forwarded_for;\n" +
" proxy_set_header X-Real-IP $http_x_real_ip;\n" +
" proxy_set_header Forwarded $http_forwarded;\n" +
" proxy_set_header Authorization $http_authorization;\n" +
" proxy_set_header Origin $http_origin;\n" +
" proxy_set_header Accept $http_accept;\n" +
" proxy_set_header Content-Type $http_content_type;\n" +
" proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version;\n" +
" proxy_buffering off;\n proxy_request_buffering off;\n proxy_cache off;\n" +
" proxy_read_timeout 360s;\n proxy_send_timeout 360s;\n proxy_connect_timeout 5s;\n" +
" proxy_next_upstream off;\n client_max_body_size 1m;\n" + tlsDirectives + "}\n")
}
func (s *MCPWebsiteBindingService) validCertificate(id uint, domain string) bool {
certificate, err := websiteSSLRepo.GetFirst(repo.WithByID(id))
if err != nil {
return false
}
pair, err := tls.X509KeyPair([]byte(certificate.Pem), []byte(certificate.PrivateKey))
if err != nil || len(pair.Certificate) == 0 {
return false
}
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil || s.now().Before(leaf.NotBefore) || !s.now().Before(leaf.NotAfter) || leaf.VerifyHostname(domain) != nil {
return false
}
if len(leaf.ExtKeyUsage) == 0 {
return true
}
for _, usage := range leaf.ExtKeyUsage {
if usage == x509.ExtKeyUsageServerAuth || usage == x509.ExtKeyUsageAny {
return true
}
}
return false
}
func mcpWebsiteReadOwner(site model.Website) (mcpWebsiteOwner, error) {
var owner mcpWebsiteOwner
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return owner, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteOwnerName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 8192 {
return owner, errors.New("invalid MCP website ownership")
}
data, err := os.ReadFile(path)
if err != nil || json.Unmarshal(data, &owner) != nil {
return owner, errors.New("invalid MCP website ownership")
}
return owner, nil
}
func mcpWebsiteWriteOwner(site model.Website, owner mcpWebsiteOwner) error {
data, err := json.Marshal(owner)
if err != nil {
return err
}
return mcpWebsiteAtomicWrite(mcpWebsitePrivatePath(site, mcpWebsiteOwnerName), data)
}
func mcpWebsiteAtomicWrite(path string, data []byte) error {
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
return errors.New("managed file is not regular")
} else if err != nil && !os.IsNotExist(err) {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), ".panel-mcp-*")
if err != nil {
return err
}
defer os.Remove(temp.Name())
if err = temp.Chmod(0600); err == nil {
_, err = temp.Write(data)
}
if closeErr := temp.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return os.Rename(temp.Name(), path)
}
func mcpWebsiteCurrentProxy(site model.Website, owner mcpWebsiteOwner) ([]byte, error) {
if err := mcpWebsitePrivateDirectory(site, false); err != nil {
return nil, err
}
path := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is missing or invalid")
}
data, err := os.ReadFile(path)
if err != nil || mcpWebsiteHash(string(data)) != owner.ProxyHash {
return nil, errors.New("managed proxy was modified")
}
return data, nil
}
func mcpWebsiteReadProxy(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > 16<<10 {
return nil, errors.New("managed proxy is not a regular file")
}
return os.ReadFile(path)
}
func mcpWebsitePayloadHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%s\n%t\n%d", req.Operation, req.Domain, req.EnableSSL, req.SSLID))
}
func mcpWebsiteBindHash(req request.MCPWebsiteBinding) string {
return mcpWebsiteHash(fmt.Sprintf("%s\n%t\n%d", req.Domain, req.EnableSSL, req.SSLID))
}
func (s *MCPWebsiteBindingService) matchesInitialSite(req request.MCPWebsiteBinding, site model.Website) bool {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (site.Protocol == constant.ProtocolHTTPS) != req.EnableSSL || site.WebsiteSSLID != req.SSLID {
return false
}
if port == 0 {
installed, installErr := s.openresty()
if installErr != nil {
return false
}
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
for _, domain := range site.Domains {
if domain.Domain == host && domain.Port == port {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) load(req request.MCPWebsiteBinding) (model.Website, error) {
if req.WebsiteID > 0 {
return websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
}
return websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
}
func mcpWebsiteProxyIncluded(site model.Website) bool {
server, err := getServer(site)
if err != nil {
return false
}
include := mcpWebsiteInclude(site)
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) == 1 && strings.Trim(parameters[0], "\"'") == include {
return true
}
}
return false
}
func (s *MCPWebsiteBindingService) status(req request.MCPWebsiteBinding, site model.Website, owner mcpWebsiteOwner) response.MCPWebsiteBinding {
result := response.MCPWebsiteBinding{State: "bound", WebsiteID: site.ID, BindingID: req.BindingID, RequestID: owner.RequestID,
Domain: site.PrimaryDomain, EnableSSL: site.Protocol == constant.ProtocolHTTPS, SSLID: site.WebsiteSSLID, WebsiteStatus: site.Status}
if !result.EnableSSL {
result.SSLID = 0
}
if owner.Operation == "unbind" {
result.State = "unbound"
return result
}
if _, err := mcpWebsiteCurrentProxy(site, owner); err != nil {
result.State, result.Reason = "unavailable", "proxy_modified"
return result
}
if mcpWebsiteHash(string(mcpWebsiteProxy(req))) != owner.ProxyHash {
result.State, result.Reason = "unavailable", "upstream_changed"
return result
}
if site.Status != constant.WebRunning {
result.State, result.Reason = "unavailable", "website_stopped"
return result
}
if !mcpWebsiteProxyIncluded(site) {
result.State, result.Reason = "unavailable", "proxy_not_loaded"
return result
}
host, _, err := mcpWebsiteDomain(site.PrimaryDomain)
if err != nil {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
if result.EnableSSL && !s.validCertificate(site.WebsiteSSLID, host) {
result.State, result.Reason = "unavailable", "certificate_invalid"
return result
}
installed, err := s.openresty()
if err != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
result.State, result.Reason = "unavailable", "openresty_unavailable"
return result
}
matching, matchingSSL := 0, 0
for _, domain := range site.Domains {
if domain.Domain != host || domain.Port < 1 {
continue
}
if matching == 0 || !domain.SSL {
matching = domain.Port
}
if domain.SSL {
matchingSSL = domain.Port
}
}
if matching == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
port := matching
if result.EnableSSL {
port = matchingSSL
if port == 0 {
plan := buildWebsiteTLSPlan(site.Domains, installed.HttpPort, installed.HttpsPort)
if slices.Contains(plan.httpsPorts, installed.HttpsPort) {
port = installed.HttpsPort
} else if len(plan.httpsPorts) > 0 {
port = plan.httpsPorts[0]
}
}
}
if port == 0 {
result.State, result.Reason = "unavailable", "website_modified"
return result
}
scheme := "http"
if result.EnableSSL {
scheme = "https"
}
address := host
if result.EnableSSL && port != 443 || !result.EnableSSL && port != 80 {
address = net.JoinHostPort(host, strconv.Itoa(port))
}
result.Endpoint = (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String()
return result
}
func (s *MCPWebsiteBindingService) Handle(ctx context.Context, req request.MCPWebsiteBinding) (result response.MCPWebsiteBinding) {
mcpWebsiteMutation.Lock()
defer mcpWebsiteMutation.Unlock()
mutationStarted := false
defer func() {
if mutationStarted && result.Error != "" {
result.Definitive = false
}
}()
if req.Operation == "bind" && validMCPWebsiteID(req.BindingID) {
_, existingErr := websiteRepo.GetFirst(websiteRepo.WithAlias(mcpWebsiteAlias(req.BindingID)))
_, privateErr := os.Lstat(filepath.Join(GetWebSiteRootDir(), mcpWebsitePrivateDir, req.BindingID))
mutationStarted = !errors.Is(existingErr, gorm.ErrRecordNotFound) || !os.IsNotExist(privateErr)
}
if ctx.Err() != nil {
return mcpWebsiteFailure("request_cancelled", true, false)
}
if req.Operation != "status" && req.Operation != "bind" && req.Operation != "update" && req.Operation != "unbind" && req.Operation != "sync" {
return mcpWebsiteFailure("invalid_operation", true, false)
}
if req.BindingID == "" && req.WebsiteID == 0 && req.Operation == "status" {
return response.MCPWebsiteBinding{State: "unbound"}
}
if !validMCPWebsiteID(req.BindingID) || !validateMCPWebsiteUpstream(req) {
return mcpWebsiteFailure("invalid_configuration", true, false)
}
if req.Operation != "status" && req.Operation != "sync" && !validMCPWebsiteID(req.RequestID) {
return mcpWebsiteFailure("invalid_request", true, false)
}
if req.Operation == "bind" {
host, port, err := mcpWebsiteDomain(req.Domain)
if err != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
req.Domain = host
if port != 0 {
req.Domain = net.JoinHostPort(host, strconv.Itoa(port))
}
}
site, err := s.load(req)
if errors.Is(err, gorm.ErrRecordNotFound) {
if req.Operation == "status" || req.Operation == "unbind" {
if req.WebsiteID == 0 || req.Operation == "unbind" {
return response.MCPWebsiteBinding{State: "unbound", BindingID: req.BindingID, RequestID: req.RequestID}
}
return response.MCPWebsiteBinding{State: "unavailable", WebsiteID: req.WebsiteID, BindingID: req.BindingID, Reason: "website_missing"}
}
if req.Operation != "bind" {
return mcpWebsiteFailure("website_missing", true, true)
}
installed, installErr := s.openresty()
if installErr != nil || installed.ID == 0 || installed.Status != constant.StatusRunning {
return mcpWebsiteFailure("openresty_unavailable", true, false)
}
group, groupErr := groupRepo.Get(groupRepo.WithByWebsiteDefault())
if groupErr != nil || group.ID == 0 {
return mcpWebsiteFailure("website_group_unavailable", true, false)
}
domain, port, _ := mcpWebsiteDomain(req.Domain)
if port == 0 {
port = installed.HttpPort
if req.EnableSSL {
port = installed.HttpsPort
}
}
if existing, conflictErr := websiteDomainRepo.GetFirst(websiteDomainRepo.WithDomain(domain), websiteDomainRepo.WithPort(port)); conflictErr == nil && existing.ID > 0 {
return mcpWebsiteFailure("domain_conflict", true, true)
} else if conflictErr != nil && !errors.Is(conflictErr, gorm.ErrRecordNotFound) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
create := request.WebsiteCreate{Alias: mcpWebsiteAlias(req.BindingID), Type: constant.Static, Remark: mcpWebsiteRemark(req), WebsiteGroupID: group.ID,
Domains: []request.WebsiteDomain{{Domain: domain, Port: port, SSL: req.EnableSSL}}, SSLConfig: request.SSLConfig{EnableSSL: req.EnableSSL, WebsiteSSLID: req.SSLID}}
mutationStarted = true
if createErr := s.createWebsite(create); createErr != nil {
return mcpWebsiteFailure("website_create_failed", false, false)
}
site, err = s.load(req)
}
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if site.Alias != mcpWebsiteAlias(req.BindingID) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
owner, ownerErr := mcpWebsiteReadOwner(site)
if ownerErr == nil && owner.RequestID == req.RequestID && req.RequestID != "" {
mutationStarted = true
}
if req.Operation == "update" {
if req.Domain != "" && req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
req.Domain = site.PrimaryDomain
host, _, domainErr := mcpWebsiteDomain(req.Domain)
if domainErr != nil || (!req.EnableSSL && req.SSLID != 0) || (req.EnableSSL && (req.SSLID == 0 || !s.validCertificate(req.SSLID, host))) {
return mcpWebsiteFailure("invalid_domain_or_certificate", true, false)
}
}
if info, dirErr := os.Lstat(GetSitePath(site, SiteDir)); dirErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
return mcpWebsiteFailure("website_directory_changed", true, true)
} else if dirErr != nil && !os.IsNotExist(dirErr) {
return mcpWebsiteFailure("website_unavailable", false, false)
}
if ownerErr != nil {
if (req.Operation != "bind" && req.Operation != "unbind") || site.Remark != mcpWebsiteRemark(req) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "bind" && !s.matchesInitialSite(req, site) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
owner = mcpWebsiteOwner{BindingID: req.BindingID, WebsiteID: site.ID, KeyHash: mcpWebsiteHash(req.ProxyKey), BindHash: mcpWebsiteBindHash(req)}
} else if owner.BindingID != req.BindingID || owner.WebsiteID != site.ID || owner.KeyHash != mcpWebsiteHash(req.ProxyKey) {
return mcpWebsiteFailure("website_ownership_changed", true, true)
}
if req.Operation == "status" {
return s.status(req, site, owner)
}
operationHash := mcpWebsitePayloadHash(req)
if owner.RequestID == req.RequestID && req.RequestID != "" {
if owner.RequestHash != operationHash {
return mcpWebsiteFailure("operation_request_changed", true, true)
}
if owner.Operation == "unbind" || owner.ProxyHash == mcpWebsiteHash(string(mcpWebsiteProxy(req))) {
return s.status(req, site, owner)
}
}
if req.Operation == "bind" && owner.BindHash != mcpWebsiteBindHash(req) {
return mcpWebsiteFailure("binding_request_changed", true, true)
}
if owner.ProxyHash != "" && owner.Operation != "unbind" && req.Operation != "unbind" && !mcpWebsiteProxyIncluded(site) {
return mcpWebsiteFailure("proxy_not_loaded", true, true)
}
proxyPath := mcpWebsitePrivatePath(site, mcpWebsiteProxyName)
if err = mcpWebsitePrivateDirectory(site, true); err != nil {
return mcpWebsiteFailure("proxy_directory_changed", true, true)
}
if ownerErr != nil {
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
}
var oldProxy []byte
if owner.ProxyHash != "" && owner.Operation != "unbind" {
oldProxy, err = mcpWebsiteCurrentProxy(site, owner)
if err != nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if req.Operation == "unbind" && os.IsNotExist(readErr) {
oldProxy = nil
} else if readErr == nil && string(actual) == string(mcpWebsiteProxy(req)) {
oldProxy = actual
} else {
return mcpWebsiteFailure("proxy_modified", true, true)
}
}
} else if _, statErr := os.Lstat(proxyPath); statErr == nil {
actual, readErr := mcpWebsiteReadProxy(proxyPath)
if readErr != nil || string(actual) != string(mcpWebsiteProxy(req)) {
return mcpWebsiteFailure("proxy_already_exists", true, true)
}
oldProxy = actual
} else if !os.IsNotExist(statErr) {
return mcpWebsiteFailure("proxy_unavailable", false, false)
}
if req.Operation == "unbind" {
if mcpWebsiteProxyIncluded(site) {
mutationStarted = true
if err = s.removeInclude(site); err != nil {
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
}
if len(oldProxy) > 0 {
mutationStarted = true
if err = os.Remove(proxyPath); err != nil {
return mcpWebsiteFailure("proxy_remove_failed", false, false)
}
}
owner.Operation, owner.RequestID, owner.RequestHash = "unbind", req.RequestID, operationHash
mutationStarted = true
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
if req.Operation == "update" {
if req.Domain != site.PrimaryDomain {
return mcpWebsiteFailure("domain_changed", true, true)
}
if site.Protocol == constant.ProtocolHTTPS && !req.EnableSSL || site.Protocol != constant.ProtocolHTTPS && req.EnableSSL || req.EnableSSL && site.WebsiteSSLID != req.SSLID {
mutationStarted = true
if err = s.updateHTTPS(context.WithoutCancel(ctx), request.WebsiteHTTPSOp{WebsiteID: site.ID, Enable: req.EnableSSL, Type: constant.SSLExisted, WebsiteSSLID: req.SSLID, HttpConfig: constant.HTTPSOnly}); err != nil {
return mcpWebsiteFailure("certificate_update_failed", false, false)
}
site, err = s.load(req)
if err != nil {
return mcpWebsiteFailure("website_unavailable", false, false)
}
}
}
newProxy := mcpWebsiteProxy(req)
mutationStarted = true
if err = mcpWebsiteAtomicWrite(proxyPath, newProxy); err != nil {
return mcpWebsiteFailure("proxy_write_failed", false, false)
}
if err = s.applyInclude(site); err != nil {
if len(oldProxy) == 0 {
_ = os.Remove(proxyPath)
} else {
_ = mcpWebsiteAtomicWrite(proxyPath, oldProxy)
}
_ = s.checkReload()
return mcpWebsiteFailure("proxy_reload_failed", false, false)
}
owner.ProxyHash = mcpWebsiteHash(string(newProxy))
if req.Operation != "sync" || req.RequestID != "" {
owner.RequestID, owner.RequestHash, owner.Operation = req.RequestID, operationHash, req.Operation
}
if err = mcpWebsiteWriteOwner(site, owner); err != nil {
return mcpWebsiteFailure("ownership_write_failed", false, false)
}
return s.status(req, site, owner)
}
+26 -6
View File
@@ -8,9 +8,12 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
) )
type TaskLogService struct{} type TaskLogService struct {
grantedScopes []string
}
type ITaskLogService interface { type ITaskLogService interface {
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
@@ -19,13 +22,18 @@ type ITaskLogService interface {
CountExecutingTask() (int64, error) CountExecutingTask() (int64, error)
} }
func NewITaskService() ITaskLogService { func NewITaskService(grantedScopes ...string) ITaskLogService {
return &TaskLogService{} return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
} }
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) { func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, nil, err
}
opts := []repo.DBOption{ opts := []repo.DBOption{
repo.WithOrderDesc("created_at"), repo.WithOrderDesc("created_at"),
taskRepo.WithoutScopes(excluded...),
} }
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
@@ -53,7 +61,11 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
} }
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) { func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
opts := []repo.DBOption{} excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return nil, err
}
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
if req.TaskID != "" { if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID)) opts = append(opts, taskRepo.WithByID(req.TaskID))
} else { } else {
@@ -112,9 +124,17 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
} }
func (u *TaskLogService) SyncForRestart() error { func (u *TaskLogService) SyncForRestart() error {
return taskRepo.UpdateRunningTaskToFailed() excluded, err := taskscope.RecoveryExclusions()
if err != nil {
return err
}
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
} }
func (u *TaskLogService) CountExecutingTask() (int64, error) { func (u *TaskLogService) CountExecutingTask() (int64, error) {
return taskRepo.CountExecutingTask() excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, err
}
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
} }
+100
View File
@@ -0,0 +1,100 @@
package service
import (
"errors"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/nginx/parser"
)
type websiteManagedConfigPolicy struct {
protectedDirectories func() []string
backupIncludes func(model.Website) []string
}
var websiteManagedConfigs websiteManagedConfigPolicy
func configureWebsiteManagedConfig(policy websiteManagedConfigPolicy) {
if !policy.configured() || websiteManagedConfigs.configured() {
panic("invalid website managed configuration policy")
}
websiteManagedConfigs = policy
}
func (p websiteManagedConfigPolicy) configured() bool {
return p.protectedDirectories != nil && p.backupIncludes != nil
}
func (p websiteManagedConfigPolicy) pathAllowed(target string) bool {
if !p.configured() {
return false
}
resolved, err := websiteProxyResolvePath(target)
if err != nil {
return false
}
for _, directory := range p.protectedDirectories() {
if directory == "" {
return false
}
root, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
if relative, err := filepath.Rel(root, resolved); err != nil || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return false
}
}
return true
}
func exportWebsiteNginxConfig(site model.Website, source, destination string) error {
return websiteManagedConfigs.exportNginxConfig(site, source, destination)
}
func (p websiteManagedConfigPolicy) exportNginxConfig(site model.Website, source, destination string) error {
if !p.configured() {
return errors.New("website managed configuration policy is unavailable")
}
includes := p.backupIncludes(site)
if len(includes) == 0 {
return files.NewFileOp().CopyFile(source, destination)
}
excluded := make(map[string]struct{}, len(includes))
for _, include := range includes {
if include == "" {
return errors.New("invalid managed website backup include")
}
excluded[include] = struct{}{}
}
content, err := os.ReadFile(source)
if err != nil {
return err
}
config, err := parser.NewStringParser(string(content)).Parse()
if err != nil {
return errors.New("invalid managed website backup configuration")
}
changed := false
for _, server := range config.FindServers() {
for _, directive := range server.FindDirectives("include") {
parameters := directive.GetParameters()
if len(parameters) != 1 {
continue
}
if _, found := excluded[strings.Trim(parameters[0], "\"'")]; found {
server.RemoveDirective("include", parameters)
changed = true
}
}
}
if changed {
content = []byte(nginx.DumpConfig(config, nginx.IndentedStyle))
}
return os.WriteFile(filepath.Join(destination, filepath.Base(source)), content, 0600)
}
+81 -3
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"errors" "errors"
"fmt" "fmt"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +25,63 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/1Panel-dev/1Panel/agent/utils/re"
) )
func websiteProxyPathAllowed(directory, name string) bool {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return false
}
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
return false
}
for _, extension := range []string{".conf", ".bak"} {
filename := filepath.Join(directory, name+extension)
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
resolved, resolveErr := websiteProxyResolvePath(filename)
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
return false
}
}
return true
}
func websiteProxyDirectoryAllowed(directory string) bool {
resolved, err := websiteProxyResolvePath(directory)
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
}
func websiteProxyPathWithin(target, directory string) bool {
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
relative, err := filepath.Rel(resolvedDirectory, target)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func websiteProxyResolvePath(target string) (string, error) {
absolute, err := filepath.Abs(target)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
return "", err
}
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("invalid proxy symlink")
}
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
if err != nil {
return "", err
}
return filepath.Join(parent, filepath.Base(absolute)), nil
}
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) { func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
switch req.Operate { switch req.Operate {
case "delete": case "delete":
@@ -51,6 +110,9 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
} }
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
_ = fileOp.CreateDir(includeDir, constant.DirPerm) _ = fileOp.CreateDir(includeDir, constant.DirPerm)
} }
@@ -270,6 +332,9 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !websiteProxyDirectoryAllowed(includeDir) {
return nil, errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
} }
@@ -282,12 +347,16 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
config *components.Config config *components.Config
) )
for _, configFile := range fileList.Items { for _, configFile := range fileList.Items {
extension := filepath.Ext(configFile.Name)
name := strings.TrimSuffix(configFile.Name, extension)
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
continue
}
proxyConfig := request.WebsiteProxyConfig{ proxyConfig := request.WebsiteProxyConfig{
ID: website.ID, ID: website.ID,
} }
parts := strings.Split(configFile.Name, ".") proxyConfig.Name = name
proxyConfig.Name = parts[0] if extension == ".conf" {
if parts[1] == "conf" {
proxyConfig.Enable = true proxyConfig.Enable = true
} else { } else {
proxyConfig.Enable = false proxyConfig.Enable = false
@@ -360,6 +429,9 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
if err != nil { if err != nil {
return err return err
} }
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
return errors.New("invalid proxy path")
}
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name) absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
oldRewriteContent, err = fileOp.GetContent(absolutePath) oldRewriteContent, err = fileOp.GetContent(absolutePath)
@@ -405,6 +477,9 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
@@ -425,6 +500,9 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
return return
} }
includeDir := GetSitePath(website, SiteProxyDir) includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) { if !fileOp.Stat(includeDir) {
return return
+26
View File
@@ -292,10 +292,36 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task) _ = t.taskRepo.Update(context.Background(), task)
} }
// ExecuteQueued registers the task before returning and limits concurrent execution.
// Subtasks must use Timeout = 0 and manage their own operation and recovery deadlines
// so that a slot is held until the action and its cleanup have actually returned.
func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish func()) error {
t.Task.CurrentStep = waitingStep
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
t.Log(i18n.GetMsgByKey(waitingStep))
go func() {
slots <- struct{}{}
defer func() { <-slots }()
defer onFinish()
if err := t.executeRegistered(); err != nil {
global.LOG.Error(err.Error())
}
}()
return nil
}
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
return err return err
} }
return t.executeRegistered()
}
func (t *Task) executeRegistered() error {
var err error var err error
t.Log(i18n.GetWithName("TaskStart", t.Name)) t.Log(i18n.GetWithName("TaskStart", t.Name))
for i := 0; i < len(t.SubTasks); i++ { for i := 0; i < len(t.SubTasks); i++ {
+2
View File
@@ -4,3 +4,5 @@ const (
TaskInstall = "installApp" TaskInstall = "installApp"
TaskCreateWebsite = "createWebsite" TaskCreateWebsite = "createWebsite"
) )
const TaskScopePanelMCP = "PanelMCP"
+1 -1
View File
@@ -7,6 +7,7 @@ replace github.com/go-acme/lego/v5 => github.com/1Panel-dev/lego/v5 v5.3.1
require ( require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.15.0 github.com/compose-spec/compose-go/v2 v2.15.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/docker/cli v29.9.0+incompatible github.com/docker/cli v29.9.0+incompatible
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -104,7 +105,6 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "A full application backup is required for the Feishu co
ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory." ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory."
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained." AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "La actualización de compatibilidad de Feishu requiere
ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación." ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación."
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original." AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -801,3 +801,5 @@ ErrFeishuUpgradeBackup: "ارتقای سازگاری Feishu به پشتیبان
ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کاملاً قابل پشتیبان‌گیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید." ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کاملاً قابل پشتیبان‌گیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید."
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد." AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "Feishu 互換更新にはアプリ全体のバックア
ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。" ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。"
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。" AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "Feishu 호환 업그레이드에는 전체 앱 백업
ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요." ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요."
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다." AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -791,3 +791,5 @@ ErrFeishuUpgradeBackup: "ການອັບເກຣດ Feishu ຕ້ອງສ
ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ." ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ."
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້." AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "Naik taraf keserasian Feishu memerlukan sandaran aplika
ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi." ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi."
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan." AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "A atualização de compatibilidade do Feishu exige back
ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo." ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo."
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido." AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "Для обновления совместимости
ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения." ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения."
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена." AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "Feishu uyumluluk yükseltmesi tam uygulama yedeği gere
ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın." ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın."
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu." AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "飛書相容升級必須開啟完整應用程式備份
ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。" ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。"
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。" AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
+2
View File
@@ -800,3 +800,5 @@ ErrFeishuUpgradeBackup: "飞书兼容升级必须开启完整应用备份。"
ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。" ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。"
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。" AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
+63 -1
View File
@@ -2,11 +2,15 @@ package router
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
"net/http" "net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
@@ -17,9 +21,38 @@ import (
func registerMCPRuntime(engine *gin.Engine) { func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New() controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler()) engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
} }
type mcpWebsiteController interface {
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
}
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
if err != nil || len(data) > 16384 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req request.MCPWebsiteBinding
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
}
}
func mcpCapabilitiesHandler() gin.HandlerFunc { func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) { if !mcpapi.TrustedTransport(ctx.Request) {
@@ -32,7 +65,12 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
} }
} }
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc { type mcpDockerController interface {
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
}
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" { if !global.IsMaster || !ok || local.Network() != "unix" {
@@ -51,6 +89,30 @@ func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
ctx.AbortWithStatus(http.StatusBadRequest) ctx.AbortWithStatus(http.StatusBadRequest)
return return
} }
if req.StreamProgress {
if req.Operation != "pull" {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
return
}
ctx.Header("Content-Type", "application/x-ndjson")
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusOK)
encoder := json.NewEncoder(ctx.Writer)
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
return err
}
ctx.Writer.Flush()
return nil
})
result := mcpruntime.PullEvent{Type: "result"}
if err != nil {
result.Error = err.Error()
}
_ = encoder.Encode(result)
ctx.Writer.Flush()
return
}
response, err := controller.Handle(ctx.Request.Context(), req) response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
+6
View File
@@ -2,6 +2,7 @@ package router
import ( import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2" v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/middleware" "github.com/1Panel-dev/1Panel/agent/middleware"
@@ -14,6 +15,9 @@ var (
) )
func Routers() *gin.Engine { func Routers() *gin.Engine {
if err := service.ConfigurePanelMCPTasks(); err != nil {
panic(err)
}
Router = gin.Default() Router = gin.Default()
Router.Use(i18n.UseI18n()) Router.Use(i18n.UseI18n())
registerMCPRuntime(Router) registerMCPRuntime(Router)
@@ -23,6 +27,8 @@ func Routers() *gin.Engine {
PrivateGroup.Use(middleware.Certificate()) PrivateGroup.Use(middleware.Certificate())
} }
PrivateGroup.Use(middleware.OperationResolveMeta()) PrivateGroup.Use(middleware.OperationResolveMeta())
PrivateGroup.Use(v2.PanelMCPTaskAccess())
PrivateGroup.Use(v2.MCPBusinessDispatch())
for _, router := range rou.RouterGroupApp { for _, router := range rou.RouterGroupApp {
router.InitRouter(PrivateGroup) router.InitRouter(PrivateGroup)
} }
+28 -22
View File
@@ -13,6 +13,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task" "github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -197,12 +198,22 @@ func setLog(id, newLastLine string, task *task.Task) error {
} }
func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error { func (c Client) PullImageWithProcessAndOptions(task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(context.Background(), imageName, options) return c.PullImageWithProcessAndOptionsContext(context.Background(), task, imageName, options)
}
func (c Client) PullImageWithProcessAndOptionsContext(ctx context.Context, task *task.Task, imageName string, options image.PullOptions) error {
out, err := c.cli.ImagePull(ctx, imageName, options)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
return handlePullImageProcess(out, task) stopClose := context.AfterFunc(ctx, func() { _ = out.Close() })
defer stopClose()
err = handlePullImageProcess(out, task)
if ctx.Err() != nil {
return ctx.Err()
}
return err
} }
func handlePullImageProcess(out io.Reader, task *task.Task) error { func handlePullImageProcess(out io.Reader, task *task.Task) error {
@@ -319,11 +330,15 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
} }
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error { func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
return c.PullImageWithProcessContext(context.Background(), task, imageName)
}
func (c Client) PullImageWithProcessContext(ctx context.Context, task *task.Task, imageName string) error {
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
return c.PullImageWithProcessAndOptions(task, imageName, options) return c.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func logProcess(progress map[string]interface{}, task *task.Task) { func logProcess(progress map[string]interface{}, task *task.Task) {
@@ -342,7 +357,7 @@ func PullImage(imageName string) error {
} }
defer cli.Close() defer cli.Close()
options := image.PullOptions{} options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok { if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr options.RegistryAuth = authStr
} }
out, err := cli.ImagePull(context.Background(), imageName, options) out, err := cli.ImagePull(context.Background(), imageName, options)
@@ -353,17 +368,20 @@ func PullImage(imageName string) error {
return handlePullImageProcess(out, nil) return handlePullImageProcess(out, nil)
} }
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) { func RegistryAuthForImage(imageName string) (string, bool) {
registryHost, hasRegistry := extractRegistryHost(imageName) named, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return "", false
}
registryHost := normalizeRegistryHost(reference.Domain(named))
cfg := config.LoadDefaultConfigFile(io.Discard) cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil { if cfg == nil {
return "", false return "", false
} }
candidates := make([]string, 0) candidates := make([]string, 0)
if hasRegistry { if !isDockerHubRegistry(registryHost) {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost) candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
} } else {
if !hasRegistry || isDockerHubRegistry(registryHost) {
candidates = append(candidates, candidates = append(candidates,
"https://index.docker.io/v1/", "https://index.docker.io/v1/",
"index.docker.io", "index.docker.io",
@@ -413,18 +431,6 @@ func isDockerHubRegistry(host string) bool {
} }
} }
func extractRegistryHost(imageName string) (string, bool) {
parts := strings.Split(imageName, "/")
if len(parts) < 2 {
return "", false
}
first := parts[0]
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
return normalizeRegistryHost(first), true
}
return "", false
}
func normalizeRegistryHost(registryKey string) string { func normalizeRegistryHost(registryKey string) string {
key := strings.TrimSpace(registryKey) key := strings.TrimSpace(registryKey)
if key == "" { if key == "" {
+68 -34
View File
@@ -3,6 +3,7 @@ package mcpruntime
import ( import (
"bytes" "bytes"
"context" "context"
_ "crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -15,19 +16,21 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/system"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
) )
const ( const (
ImageRepository = "1panel/1panel-mcp-server" ImageRepository = "1panel/mcp-server"
ContainerName = "1panel-mcp" ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous" previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime" SocketDirectory = "/etc/1panel/mcp-runtime"
@@ -48,12 +51,13 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping") var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct { type Request struct {
Operation string `json:"operation"` Operation string `json:"operation"`
Image string `json:"image"` Image string `json:"image"`
Port int `json:"port"` Port int `json:"port"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Restart bool `json:"restart"` Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"` ImageID string `json:"imageID,omitempty"`
StreamProgress bool `json:"streamProgress,omitempty"`
} }
type State struct { type State struct {
@@ -106,6 +110,7 @@ type Controller struct {
platform string platform string
health func(context.Context, int) (metadata, error) health func(context.Context, int) (metadata, error)
checkDirectory func() error checkDirectory func() error
registryAuth func(string) (string, bool)
mutation chan struct{} mutation chan struct{}
probes chan struct{} probes chan struct{}
cacheMu sync.Mutex cacheMu sync.Mutex
@@ -118,25 +123,40 @@ func New() *Controller {
func newController(factory func() (DockerClient, error)) *Controller { func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth, return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)} checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage,
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
} }
func ValidateImage(value string) error { func ValidateImage(value string) error {
if len(value) > 180 { if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 {
return errors.New("invalid MCP Docker image") return errors.New("invalid MCP Docker image")
} }
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) { parsed, err := reference.ParseNormalizedNamed(value)
if err != nil {
return errors.New("invalid MCP Docker image")
}
if digested, ok := parsed.(reference.Digested); ok {
if digestPattern.MatchString(digested.Digest().String()) {
return nil
}
return errors.New("MCP Docker image must use a SHA256 digest")
}
if _, ok := parsed.(reference.Tagged); ok {
return nil return nil
} }
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) { return errors.New("MCP Docker image must include a tag or SHA256 digest")
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
} }
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) { func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
return c.HandleWithProgress(ctx, req, nil)
}
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
if req.StreamProgress && req.Operation != "pull" {
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
}
switch req.Operation { switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs": case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default: default:
return Response{}, errors.New("invalid MCP Docker operation") return Response{}, errors.New("invalid MCP Docker operation")
} }
@@ -147,7 +167,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
return Response{}, errors.New("MCP listen port must be between 1024 and 65535") return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
} }
timeout := 60 * time.Second timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" { if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if req.Operation == "status" { if req.Operation == "status" {
@@ -176,19 +196,23 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
} }
switch req.Operation { switch req.Operation {
case "prepare": case "prepare":
case "catalog", "candidate_catalog": case "catalog", "apply_catalog", "candidate_catalog":
var img image.InspectResponse var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image) if req.Operation != "candidate_catalog" {
if err == nil && req.Operation == "catalog" {
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID { if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog") err = errors.New("MCP installed runtime does not match the configured image")
} }
if err == nil {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
} else {
img, err = c.resolve(ctx, cli, req.Image)
} }
if err == nil { if err == nil {
var verified verifiedImage var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image) verified, err = c.verify(ctx, cli, img.ID)
if err == nil { if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
} }
@@ -204,7 +228,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
case "rollback_absent": case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req) err = c.rollbackAbsent(ctx, cli, req)
case "pull": case "pull":
err = c.pullImage(ctx, cli, req) err = c.pullImage(ctx, cli, req, progress)
case "logs": case "logs":
var current *container.InspectResponse var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName) current, err = c.owned(ctx, cli, ContainerName)
@@ -259,6 +283,20 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
return img, nil return img, nil
} }
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
if current == nil || current.Config.Labels[imageLabel] != ref {
return c.resolve(ctx, cli, ref)
}
if !digestPattern.MatchString(current.Image) {
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
}
img, err := c.resolve(ctx, cli, current.Image)
if err != nil || img.ID != current.Image {
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) { func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name) info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
@@ -279,10 +317,6 @@ func isRunning(info *container.InspectResponse) bool {
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response { func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image} state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName) current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil { if inspectErr != nil {
state.Status = "unhealthy" state.Status = "unhealthy"
@@ -290,9 +324,11 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
if current == nil { if current == nil {
img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
state.Reason = err.Error() state.Reason = err.Error()
} else { } else {
state.ImageID = img.ID
state.Reason = "MCP image is loaded; install it to create the managed container" state.Reason = "MCP image is loaded; install it to create the managed container"
} }
return Response{State: state} return Response{State: state}
@@ -312,12 +348,13 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
state.Reason = "MCP access is disabled" state.Reason = "MCP access is disabled"
return Response{State: state} return Response{State: state}
} }
if err != nil || current.Image != img.ID { img, err := c.resolveActiveImage(ctx, cli, req.Image, current)
if err != nil || current.Config.Labels[imageLabel] != req.Image {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port" state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state} return Response{State: state}
} }
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image) verified, verifyErr := c.verify(ctx, cli, current.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy" state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image" state.Reason = "MCP container configuration or metadata does not match the verified image"
@@ -334,7 +371,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state} return Response{State: state}
} }
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) { func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) {
c.cacheMu.Lock() c.cacheMu.Lock()
cached, ok := c.cache[id] cached, ok := c.cache[id]
c.cacheMu.Unlock() c.cacheMu.Unlock()
@@ -366,9 +403,6 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref strin
c.cache[id] = cached c.cache[id] = cached
c.cacheMu.Unlock() c.cacheMu.Unlock()
} }
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil return cached, nil
} }
+57 -11
View File
@@ -10,49 +10,90 @@ import (
"strings" "strings"
"time" "time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
) )
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error {
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
return err
}
_, err := cli.ImageInspect(ctx, req.Image) _, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) { if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed") return errors.New("MCP Docker image inspection failed")
} }
if errdefs.IsNotFound(err) { if errdefs.IsNotFound(err) {
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
return err
}
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel() defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{}) options := image.PullOptions{}
if auth, ok := c.registryAuth(req.Image); ok {
options.RegistryAuth = auth
}
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
if pullErr != nil { if pullErr != nil {
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
defer reader.Close() defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20)) limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1}
decoder := json.NewDecoder(limited)
for { for {
var message struct { var message struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"` Error string `json:"error"`
ErrorDetail *struct { ErrorDetail *struct {
Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} `json:"errorDetail"` } `json:"errorDetail"`
} }
if err = decoder.Decode(&message); err == io.EOF { err = decoder.Decode(&message)
if limited.N == 0 {
return errors.New("MCP Docker image download progress exceeds permitted size")
}
if err == io.EOF {
break break
} else if err != nil { } else if err != nil {
return errors.New("MCP Docker image download was interrupted") return errors.New("MCP Docker image download was interrupted")
} }
if message.Error != "" || message.ErrorDetail != nil { if message.Error != "" || message.ErrorDetail != nil {
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed") return errors.New("MCP Docker image download failed")
} }
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
if err = reportPullProgress(ctx, progress, normalized); err != nil {
return err
}
}
} }
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
return err
}
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
return err
} }
img, err := c.resolve(ctx, cli, req.Image) img, err := c.resolve(ctx, cli, req.Image)
if err != nil { if err != nil {
return err return err
} }
_, err = c.verify(ctx, cli, img.ID, req.Image) _, err = c.verify(ctx, cli, img.ID)
return err if err != nil {
return err
}
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
} }
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error { func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
@@ -176,14 +217,19 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
if err = c.checkDirectory(); err != nil { if err = c.checkDirectory(); err != nil {
return err return err
} }
img, err := c.resolve(ctx, cli, req.Image) var img image.InspectResponse
if req.Operation == "install" {
img, err = c.resolve(ctx, cli, req.Image)
} else {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
if err != nil { if err != nil {
return err return err
} }
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation") return errors.New("MCP image identity changed after catalog validation; retry the operation")
} }
verified, err := c.verify(ctx, cli, img.ID, req.Image) verified, err := c.verify(ctx, cli, img.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -354,7 +400,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
} }
return errors.New("the previous MCP runtime instance is unavailable for rollback") return errors.New("the previous MCP runtime instance is unavailable for rollback")
} }
verified, err := c.verify(ctx, cli, target.Image, req.Image) verified, err := c.verify(ctx, cli, target.Image)
if err != nil { if err != nil {
return err return err
} }
@@ -402,7 +448,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
if _, err = c.resolve(ctx, cli, current.Image); err != nil { if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err return err
} }
verified, err := c.verify(ctx, cli, current.Image, req.Image) verified, err := c.verify(ctx, cli, current.Image)
if err != nil { if err != nil {
return err return err
} }
+63
View File
@@ -0,0 +1,63 @@
package mcpruntime
import (
"context"
"errors"
"regexp"
)
type PullProgress struct {
Status string `json:"status,omitempty"`
ID string `json:"id,omitempty"`
Current int64 `json:"current,omitempty"`
Total int64 `json:"total,omitempty"`
}
type PullEvent struct {
Type string `json:"type"`
PullProgress
Error string `json:"error,omitempty"`
}
var pullLayerID = regexp.MustCompile(`^[a-f0-9]{12,64}$`)
func reportPullProgress(ctx context.Context, report func(PullProgress) error, progress PullProgress) error {
if err := ctx.Err(); err != nil {
return err
}
if report != nil && report(progress) != nil {
return errors.New("MCP Docker progress connection closed")
}
return nil
}
func normalizePullProgress(status, id string, current, total int64) (PullProgress, bool) {
var normalized string
switch status {
case "Pulling fs layer":
normalized = "pulling"
case "Waiting":
normalized = "waiting"
case "Downloading":
normalized = "downloading"
case "Verifying Checksum":
normalized = "verifying_checksum"
case "Download complete":
normalized = "download_complete"
case "Extracting":
normalized = "extracting"
case "Pull complete":
normalized = "pull_complete"
case "Already exists":
normalized = "already_exists"
default:
return PullProgress{}, false
}
if !pullLayerID.MatchString(id) {
id = ""
}
if current < 0 || total < 0 || (total > 0 && current > total) {
current, total = 0, 0
}
return PullProgress{Status: normalized, ID: id, Current: current, Total: total}, true
}
+98
View File
@@ -0,0 +1,98 @@
// Package taskscope separates task visibility and executor ownership from task storage.
package taskscope
import (
"context"
"errors"
"slices"
"sort"
"strings"
"sync/atomic"
)
type Rule struct {
Scope string
Restricted bool
ExternalExecutor bool
}
type policy struct{ rules []Rule }
var configured atomic.Pointer[policy]
var ErrUnavailable = errors.New("task scope policy is unavailable")
func Configure(rules ...Rule) error {
copyRules := append([]Rule(nil), rules...)
if len(copyRules) == 0 {
return ErrUnavailable
}
sort.Slice(copyRules, func(i, j int) bool { return copyRules[i].Scope < copyRules[j].Scope })
for i, rule := range copyRules {
if rule.Scope == "" || strings.TrimSpace(rule.Scope) != rule.Scope || strings.ContainsAny(rule.Scope, "\x00\r\n") ||
i > 0 && copyRules[i-1].Scope == rule.Scope {
return errors.New("invalid task scope policy")
}
}
next := &policy{rules: copyRules}
if configured.CompareAndSwap(nil, next) {
return nil
}
if !slices.Equal(configured.Load().rules, next.rules) {
return errors.New("task scope policy is already configured")
}
return nil
}
func ReadExclusions(granted ...string) ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
allowed := make(map[string]bool, len(granted))
for _, scope := range granted {
found := false
for _, rule := range p.rules {
if rule.Scope == scope && rule.Restricted {
found = true
break
}
}
if !found || allowed[scope] {
return nil, errors.New("invalid task scope grant")
}
allowed[scope] = true
}
var excluded []string
for _, rule := range p.rules {
if rule.Restricted && !allowed[rule.Scope] {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
func RecoveryExclusions() ([]string, error) {
p := configured.Load()
if p == nil {
return nil, ErrUnavailable
}
var excluded []string
for _, rule := range p.rules {
if rule.ExternalExecutor {
excluded = append(excluded, rule.Scope)
}
}
return excluded, nil
}
type grantKey struct{}
func WithGrants(ctx context.Context, granted ...string) context.Context {
return context.WithValue(ctx, grantKey{}, append([]string(nil), granted...))
}
func Grants(ctx context.Context) []string {
granted, _ := ctx.Value(grantKey{}).([]string)
return append([]string(nil), granted...)
}
+144 -2
View File
@@ -15,7 +15,7 @@ import (
) )
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeySession(c); err != nil { if _, err := auth.RequireAPIKeyManagement(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil) helper.BadAuth(c, "ErrNotLogin", nil)
return false return false
} }
@@ -41,6 +41,24 @@ func apiKeyError(c *gin.Context, err error) {
helper.InternalServer(c, err) helper.InternalServer(c, err)
} }
// Swag's legacy || syntax combines schemes in one requirement (AND);
// separate @Security lines represent alternative authentication methods.
// @Tags API Key
// @Summary List the current account's API keys
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
// @Accept json
// @Produce json
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/search [post]
func (b *BaseApi) SearchAPIKeys(c *gin.Context) { func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -55,6 +73,25 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Create an API key for the current account
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/create [post]
func (b *BaseApi) CreateAPIKey(c *gin.Context) { func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}} req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -69,6 +106,22 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Update an API key's policy
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/update [post]
func (b *BaseApi) UpdateAPIKey(c *gin.Context) { func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -81,6 +134,21 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Enable or disable an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/status [post]
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) { func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -93,6 +161,22 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Revoke an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/revoke [post]
func (b *BaseApi) RevokeAPIKey(c *gin.Context) { func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -106,5 +190,63 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
} }
func apiKeyMutationSuccess(c *gin.Context) { func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")}) helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
// @Tags API Key
// @Summary Delete an independent API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/delete [post]
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Reveal an independent API key's plaintext secret
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/reveal [post]
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return
}
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Reveal(c, req)
if err != nil {
apiKeyError(c, err)
return
}
helper.SuccessWithData(c, result)
} }
+11 -8
View File
@@ -417,11 +417,13 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary generate api key // @Summary Generate or reset the legacy API key
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
// @Accept json // @Accept json
// @Success 200 {string} key // @Produce json
// @Security ApiKeyAuth // @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
// @Security Timestamp // @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/generate [post] // @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) { func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -440,12 +442,13 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Update api config // @Summary Update the legacy API key configuration
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
// @Accept json // @Accept json
// @Produce json
// @Param request body dto.ApiInterfaceConfig true "request" // @Param request body dto.ApiInterfaceConfig true "request"
// @Success 200 // @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
// @Security ApiKeyAuth // @Security PanelSession || CSRFToken
// @Security Timestamp
// @Router /core/auth/api/update [post] // @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"} // @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) { func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+63 -3
View File
@@ -1,6 +1,7 @@
package v2 package v2
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -32,6 +33,41 @@ func mcpRead(c *gin.Context, value any) bool {
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid() err = contract.Invalid()
} }
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
var fields struct {
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
err = contract.Invalid()
}
case *dto.MCPRuntimeInstall:
var fields struct {
AccessPolicy json.RawMessage `json:"accessPolicy"`
AllowedIPs json.RawMessage `json:"allowedIPs"`
Website *struct {
Domain json.RawMessage `json:"domain"`
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
} `json:"website"`
}
if json.Unmarshal(data, &fields) != nil {
err = contract.Invalid()
} else {
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
if fields.Website != nil {
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
} }
} }
if err != nil { if err != nil {
@@ -179,6 +215,21 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
} }
helper.Success(c) helper.Success(c)
} }
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Delete(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) { func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false) s, owner, ok := mcpManagement(c, false, false)
if !ok { if !ok {
@@ -207,8 +258,17 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) if result.Website == nil {
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
}
result.CanManageService = owner.IsSuperAdmin result.CanManageService = owner.IsSuperAdmin
if owner.IsSuperAdmin {
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
}
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
func (b *BaseApi) MCPSettings(c *gin.Context) { func (b *BaseApi) MCPSettings(c *gin.Context) {
@@ -268,9 +328,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
if !mcpRead(c, &req) { if !mcpRead(c, &req) {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel() defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req) result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req)
if err != nil { if err != nil {
mcpServiceError(c, err) mcpServiceError(c, err)
return return
+10 -1
View File
@@ -55,7 +55,16 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
} }
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language")) endpoint := mcpPublicEndpoint(c, settings.ExternalURL)
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
if err != nil {
mcpManagementError(c, err)
return
}
if website.State != "unbound" {
endpoint = website.Endpoint
}
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
if err != nil { if err != nil {
mcpManagementError(c, err) mcpManagementError(c, err)
return return
+6
View File
@@ -31,6 +31,12 @@ func mcpManagementMessage(code string) string {
key = "ErrMCPAuditUnavailable" key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE": case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable" key = "ErrMCPEndpointUnavailable"
case "MCP_WEBSITE_PENDING":
key = "ErrMCPWebsitePending"
case "MCP_WEBSITE_UNAVAILABLE":
key = "ErrMCPWebsiteUnavailable"
case "MCP_WEBSITE_SERVICE_REQUIRED":
key = "ErrMCPWebsiteServiceRequired"
} }
return i18n.GetMsgWithMap(key, nil) return i18n.GetMsgWithMap(key, nil)
} }
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"context"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
if _, _, ok := mcpManagement(c, false, true); !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteBind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUpdate
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUnbind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net" "net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -42,7 +43,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0] ownerProvider = ownerProviders[0]
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") { if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
c.Next() c.Next()
return return
} }
@@ -97,6 +98,20 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
} }
} }
func isAPIKeyManagementRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
return true
default:
return false
}
}
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) { func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
settingRepo := repo.NewISettingRepo() settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{} config := APIAuthConfig{}
+23 -3
View File
@@ -66,6 +66,26 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
return u, nil return u, nil
} }
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
if c == nil || !c.GetBool("API_AUTH") {
return RequireAPIKeySession(c)
}
if c.GetBool("LOCAL_REQUEST") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
value, _ := c.Get(psession.GinContextSessionUserKey)
u, ok := value.(psession.SessionUser)
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
return u, nil
}
func HasAPICredentials(c *gin.Context) bool { func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != "" return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
} }
@@ -87,7 +107,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
} }
func APIKeySecret(key model.APIKey) (string, error) { func APIKeySecret(key model.APIKey) (string, error) {
if key.SecretVersion != 1 || key.SecretCiphertext == "" { if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid") return "", buserr.New("ErrApiConfigKeyInvalid")
} }
master, err := encrypt.APIKeyEncryptionKey() master, err := encrypt.APIKeyEncryptionKey()
@@ -98,7 +118,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
} }
func ValidateAPIKeyState(key model.APIKey, now time.Time) error { func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.Status != constant.StatusEnable { if key.DeletedAt != nil || key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid") return buserr.New("ErrApiConfigStatusInvalid")
} }
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) { if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
@@ -113,7 +133,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) { func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID") keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision") q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" { if keyID != "" {
q = q.Where("id = ?", keyID) q = q.Where("id = ?", keyID)
} else { } else {
+35 -27
View File
@@ -6,41 +6,42 @@ import (
) )
type APIKeyItem struct { type APIKeyItem struct {
ID string `json:"id"` ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Kind string `json:"kind"` Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
Name string `json:"name"` Name string `json:"name" example:"Deployment automation"`
Description string `json:"description"` Description string `json:"description" example:"Used by the deployment service"`
KeyHint string `json:"keyHint"` KeyHint string `json:"keyHint" example:"abcd••••1234"`
Status string `json:"status"` Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
IPWhiteList string `json:"ipWhiteList"` IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies"` APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime"` APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
ExpiresAt *time.Time `json:"expiresAt"` ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
AllowAppBinding bool `json:"allowAppBinding"` AllowAppBinding bool `json:"allowAppBinding" example:"false"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision" minimum:"1" example:"1"`
CreatedAt *time.Time `json:"createdAt"` CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
} }
type APIKeySearch struct { type APIKeySearch struct {
Page int `json:"page"` Page int `json:"page" minimum:"1" example:"1"`
PageSize int `json:"pageSize"` PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
ExcludeRevoked bool `json:"excludeRevoked"` ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
} }
type APIKeyPage struct { type APIKeyPage struct {
Items []APIKeyItem `json:"items"` Items []APIKeyItem `json:"items"`
Total int `json:"total"` Total int `json:"total"`
} }
type APIKeyFields struct { type APIKeyFields struct {
Name string `json:"name"` Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
Description string `json:"description"` Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
IPWhiteList string `json:"ipWhiteList"` IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies"` APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime"` APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
ExpiresAt json.RawMessage `json:"expiresAt"` ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
AllowAppBinding bool `json:"allowAppBinding"` AllowAppBinding bool `json:"allowAppBinding" default:"false"`
} }
type APIKeyCreate struct { type APIKeyCreate struct {
RequestID string `json:"requestID"` // Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
APIKeyFields APIKeyFields
} }
type APIKeyCreated struct { type APIKeyCreated struct {
@@ -49,8 +50,15 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"` AlreadyCreated bool `json:"alreadyCreated,omitempty"`
} }
type APIKeyMutation struct { type APIKeyMutation struct {
ID string `json:"id"` ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision" minimum:"1" example:"1"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
} }
type APIKeyUpdate struct { type APIKeyUpdate struct {
APIKeyMutation APIKeyMutation
@@ -58,5 +66,5 @@ type APIKeyUpdate struct {
} }
type APIKeyStatus struct { type APIKeyStatus struct {
APIKeyMutation APIKeyMutation
Status string `json:"status"` Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
} }
+35 -19
View File
@@ -14,28 +14,44 @@ type MCPRuntimeSettings struct {
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
type MCPRuntimeInstall struct { type MCPRuntimeInstall struct {
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"` ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
AccessPolicy string `json:"accessPolicy"`
AllowedIPs []string `json:"allowedIPs"`
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
}
type MCPRuntimeInstallWebsite struct {
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPRuntimeInstallTask struct {
TaskID string `json:"taskID"`
} }
type MCPRuntimeStatus struct { type MCPRuntimeStatus struct {
RuntimeType string `json:"runtimeType"` RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"` DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"` ContainerName string `json:"containerName"`
ImageID string `json:"imageId"` ImageID string `json:"imageId"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Installed bool `json:"installed"` Installed bool `json:"installed"`
Status string `json:"status"` Status string `json:"status"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
Reason string `json:"reason"` Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"` AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"` ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"` InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"` CanManageService bool `json:"canManageService"`
InstallTaskID string `json:"installTaskID,omitempty"`
Installing bool `json:"installing"`
Website *MCPWebsiteStatus `json:"website,omitempty"`
} }
type MCPRuntimeLogs struct { type MCPRuntimeLogs struct {
+43
View File
@@ -0,0 +1,43 @@
package dto
type MCPWebsiteBind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUpdate struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUnbind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
}
type MCPWebsitePendingRequest struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteStatus struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Reason string `json:"reason,omitempty"`
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
}
+1
View File
@@ -24,6 +24,7 @@ type APIKey struct {
CreatedAt time.Time `json:"-"` CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"` RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
} }
type LegacyAPIKeyPolicy struct { type LegacyAPIKeyPolicy struct {
+12 -9
View File
@@ -52,13 +52,16 @@ type MCPInvocation struct {
} }
type MCPSetting struct { type MCPSetting struct {
RuntimeType string `gorm:"not null;default:docker"` RuntimeType string `gorm:"not null;default:docker"`
DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"` DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"`
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Enabled bool `gorm:"not null;default:false"` Enabled bool `gorm:"not null;default:false"`
ListenPort int `gorm:"not null;default:18081"` ListenPort int `gorm:"not null;default:18081"`
ExternalURL string ExternalURL string
AccessPolicy string `gorm:"not null;default:inherit"` AccessPolicy string `gorm:"not null;default:inherit"`
AllowedIPs string `gorm:"type:text;not null;default:'[]'"` AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
Revision uint64 `gorm:"not null;default:1"` Revision uint64 `gorm:"not null;default:1"`
WebsiteID uint `gorm:"not null;default:0"`
WebsiteBindingID string `gorm:"not null;default:''"`
WebsiteOperation string `gorm:"type:text;not null;default:''"`
} }
+2 -2
View File
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
} }
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) { func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey var key model.APIKey
err := global.DB.Where("id = ?", id).First(&key).Error err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
return key, err return key, err
} }
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) { func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0) items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID) q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
if excludeRevoked { if excludeRevoked {
q = q.Where("status <> ?", "Revoked") q = q.Where("status <> ?", "Revoked")
} }
+169 -1
View File
@@ -1,12 +1,89 @@
package repo package repo
import ( import (
"time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
type MCPRepo struct{ DB *gorm.DB } type MCPRepo struct{ DB *gorm.DB }
func mcpOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
}
func (r MCPRepo) Credential(ownerType, ownerID, id string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ?", id).First(&key).Error
return key, err
}
func (r MCPRepo) CredentialByRequest(ownerType, ownerID, requestID string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("request_id = ?", requestID).First(&key).Error
return key, err
}
func (r MCPRepo) SearchCredentials(ownerType, ownerID string, page, pageSize int, excludeRevoked bool) ([]model.MCPCredential, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
keys := []model.MCPCredential{}
err := q.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&keys).Error
return keys, total, err
}
func (r MCPRepo) CountActiveName(ownerType, ownerID, name, excludeID string) (int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeID == "" {
q = q.Where("active_name = ?", name)
} else {
q = q.Where("active_name = ? AND id <> ?", name, excludeID)
}
var count int64
err := q.Count(&count).Error
return count, err
}
func (r MCPRepo) CreateCredential(key *model.MCPCredential) error {
return r.DB.Create(key).Error
}
func (r MCPRepo) UpdateCredential(key *model.MCPCredential, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, revision).
Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(key)
return result.RowsAffected, result.Error
}
func (r MCPRepo) DeleteCredential(ownerType, ownerID, id string, revision uint64) (int64, error) {
result := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ? AND revision = ?", id, revision).Delete(&model.MCPCredential{})
return result.RowsAffected, result.Error
}
func (r MCPRepo) AdmissionCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").Take(&key).Error
return key, err
}
func (r MCPRepo) InvocationCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&key).Error
return key, err
}
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) { func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
var key model.MCPCredential var key model.MCPCredential
err := r.DB.Where("token_hash = ?", hash).First(&key).Error err := r.DB.Where("token_hash = ?", hash).First(&key).Error
@@ -17,8 +94,13 @@ func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
return grants, err return grants, err
} }
func (r MCPRepo) DeleteGrants(id string) error {
return r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error
}
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error { func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil { if err := r.DeleteGrants(id); err != nil {
return err return err
} }
for _, grant := range grants { for _, grant := range grants {
@@ -29,3 +111,89 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
} }
return nil return nil
} }
func (r MCPRepo) Setting() (model.MCPSetting, error) {
var setting model.MCPSetting
err := r.DB.First(&setting, 1).Error
return setting, err
}
func (r MCPRepo) Enabled() (bool, error) {
var setting struct{ Enabled bool }
err := r.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
return setting.Enabled, err
}
func (r MCPRepo) AdmissionEnabled() (bool, error) {
var setting model.MCPSetting
err := r.DB.Select("enabled").Take(&setting, 1).Error
return setting.Enabled, err
}
func (r MCPRepo) PanelSetting(key string) (model.Setting, error) {
var setting model.Setting
err := r.DB.Where("key = ?", key).First(&setting).Error
return setting, err
}
func (r MCPRepo) CreateSettingIfAbsent(setting *model.MCPSetting) error {
return r.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(setting).Error
}
func (r MCPRepo) UpdateRuntimeSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
"runtime_type": setting.RuntimeType, "docker_image": setting.DockerImage,
"enabled": setting.Enabled, "listen_port": setting.ListenPort, "external_url": setting.ExternalURL, "revision": setting.Revision,
"access_policy": setting.AccessPolicy, "allowed_ips": setting.AllowedIPs,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) UpdateWebsiteSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = 1 AND revision = ?", revision).Updates(map[string]any{
"website_id": setting.WebsiteID, "website_binding_id": setting.WebsiteBindingID, "website_operation": setting.WebsiteOperation, "revision": setting.Revision,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) CreateEnrollmentAudit(record *model.OperationLog) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) CreateInvocation(record *model.MCPInvocation) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) SearchInvocations(ownerType, ownerID string, page, pageSize int, credentialID, toolID, status string) ([]model.MCPInvocation, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPInvocation{}), ownerType, ownerID)
if credentialID != "" {
q = q.Where("credential_id = ?", credentialID)
}
if toolID != "" {
q = q.Where("tool_id = ?", toolID)
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r MCPRepo) UpdateRunningInvocation(record model.MCPInvocation) error {
return r.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
}
func (r MCPRepo) UpdateInterruptedInvocations(processStartedAt time.Time, status, code string, finishedAt time.Time) error {
return r.DB.Model(&model.MCPInvocation{}).
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": status, "code": code, "finished_at": finishedAt}).Error
}
+89 -9
View File
@@ -52,6 +52,27 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
return s.Provider.CurrentAPIKeyOwner(c) return s.Provider.CurrentAPIKeyOwner(c)
} }
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
if !c.GetBool("API_AUTH") {
return s.Provider.CurrentAPIKeyOwner(c)
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
if err != nil {
return auth.APIKeyOwner{}, err
}
if owner.Type != ownerType || owner.ID != ownerID {
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return owner, nil
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem { func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
@@ -75,7 +96,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
} }
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) { func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -146,11 +167,13 @@ func keyHint(secret string) string {
} }
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) { func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err = uuid.Parse(req.RequestID); err != nil { if req.RequestID == "" {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams") return nil, buserr.New("ErrInvalidParams")
} }
expires, err := validateAPIKeyFields(&req.APIKeyFields, true) expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
@@ -172,6 +195,9 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID) q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil { if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
@@ -219,7 +245,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
} }
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -247,7 +273,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
} }
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -271,7 +297,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
} }
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error { func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -289,21 +315,75 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
}) })
} }
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return buserr.New("ErrInvalidParams")
}
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
if key.RevokedAt == nil {
key.RevokedAt = &now
}
key.DeletedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.KeyHint = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
return nil, buserr.New("ErrInvalidParams")
}
// Serialize the snapshot and disclosure audit with revocation/deletion.
apiKeyMutationMu.Lock()
defer apiKeyMutationMu.Unlock()
var key model.APIKey
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrAPIKeyNotFound")
}
return nil, err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return nil, buserr.New("ErrAPIKeyConflict")
}
secret, err := auth.APIKeySecret(key)
if err != nil {
return nil, buserr.New("ErrApiConfigKeyInvalid")
}
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
return nil, err
}
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error { func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 { if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams") return buserr.New("ErrInvalidParams")
} }
apiKeyMutationMu.Lock() apiKeyMutationMu.Lock()
var key model.APIKey var key model.APIKey
closeTerminals := action == "disable" || action == "revoke" closeTerminals := action == "disable" || action == "revoke" || action == "delete"
err := global.DB.Transaction(func(tx *gorm.DB) error { err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil { if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound") return buserr.New("ErrAPIKeyNotFound")
} }
return err return err
} }
if key.Revision != req.Revision || key.Status == "Revoked" { if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
before := key before := key
+24 -48
View File
@@ -96,12 +96,11 @@ var NewMCPService = func() *MCPService {
defer cancel() defer cancel()
return NewMCPRuntimeService().Catalog(ctx) return NewMCPRuntimeService().Catalog(ctx)
}, Enabled: func() (bool, error) { }, Enabled: func() (bool, error) {
var setting struct{ Enabled bool } enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled()
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
return setting.Enabled, err return enabled, err
}} }}
} }
@@ -149,9 +148,6 @@ func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwn
} }
return owner, nil return owner, nil
} }
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
}
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 } func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
func (s *MCPService) catalog() ([]contract.Tool, error) { func (s *MCPService) catalog() ([]contract.Tool, error) {
unavailable := func() error { unavailable := func() error {
@@ -294,16 +290,8 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch)
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner) keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked)
if req.ExcludeRevoked { if err != nil {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
keys := []model.MCPCredential{}
if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil {
return nil, err return nil, err
} }
items := []dto.MCPCredentialItem{} items := []dto.MCPCredentialItem{}
@@ -344,7 +332,9 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
repeated := false repeated := false
mcpMutationMu.Lock() mcpMutationMu.Lock()
err = s.DB.Transaction(func(tx *gorm.DB) error { err = s.DB.Transaction(func(tx *gorm.DB) error {
err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error store := repo.MCPRepo{DB: tx}
var err error
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
if err == nil { if err == nil {
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used") return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
@@ -355,8 +345,8 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
var count int64 count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "")
if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil { if err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -371,10 +361,10 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if err = s.sealCredentialToken(&key, token); err != nil { if err = s.sealCredentialToken(&key, token); err != nil {
return err return err
} }
if err = tx.Create(&key).Error; err != nil { if err = store.CreateCredential(&key); err != nil {
return err return err
} }
if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil { if err = store.ReplaceTools(key.ID, grants); err != nil {
return err return err
} }
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil) return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
@@ -397,8 +387,9 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
mcpMutationMu.Lock() mcpMutationMu.Lock()
defer mcpMutationMu.Unlock() defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
var key model.MCPCredential store := repo.MCPRepo{DB: tx}
if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { key, err := store.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden() return contract.Forbidden()
} }
@@ -415,11 +406,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
key.SecretVersion = 0 key.SecretVersion = 0
} }
key.Revision++ key.Revision++
result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key) affected, err := store.UpdateCredential(&key, req.Revision)
if result.Error != nil { if err != nil {
return result.Error return err
} }
if result.RowsAffected != 1 { if affected != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
} }
return nil return nil
@@ -434,8 +425,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate)
return err return err
} }
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
var count int64 count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID)
if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil { if err != nil {
return err return err
} }
if count > 0 { if count > 0 {
@@ -628,7 +619,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
record.Revision = key.Revision record.Revision = key.Revision
} }
record.Status = "running" record.Status = "running"
if err := s.DB.Create(&record).Error; err != nil { if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
} }
record.Status = "denied" record.Status = "denied"
@@ -707,10 +698,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
if enabledErr != nil || !enabled { if enabledErr != nil || !enabled {
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil) return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
} }
var admitted model.MCPCredential admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision)
admissionErr := s.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error
if errors.Is(admissionErr, gorm.ErrRecordNotFound) { if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
return finish(contract.Forbidden(), nil) return finish(contract.Forbidden(), nil)
} }
@@ -753,25 +741,13 @@ func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSe
if !validMCPPage(req.Page, req.PageSize) { if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
if req.CredentialID != "" {
q = q.Where("credential_id = ?", req.CredentialID)
}
if req.ToolID != "" {
q = q.Where("tool_id = ?", req.ToolID)
}
if req.Status != "" { if req.Status != "" {
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" { if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
return nil, contract.Invalid() return nil, contract.Invalid()
} }
q = q.Where("status = ?", req.Status)
} }
var total int64 items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status)
if err := q.Count(&total).Error; err != nil { if err != nil {
return nil, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil {
return nil, err return nil, err
} }
return map[string]any{"items": items, "total": total}, nil return map[string]any{"items": items, "total": total}, nil
+8 -6
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -97,7 +98,7 @@ func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission)
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
record.Code = known.Code record.Code = known.Code
} }
if auditErr := s.DB.Create(&record).Error; auditErr != nil { if auditErr := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); auditErr != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable") return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
} }
if known, ok := err.(*contract.Error); ok { if known, ok := err.(*contract.Error); ok {
@@ -164,15 +165,16 @@ func newMCPAdmissionID() string { return uuid.NewString() }
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error { func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
return s.DB.Transaction(func(tx *gorm.DB) error { return s.DB.Transaction(func(tx *gorm.DB) error {
var latest model.MCPCredential data := repo.MCPRepo{DB: tx}
if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil { latest, err := data.AdmissionCredential(key.ID, key.Revision)
if err != nil {
return contract.Forbidden() return contract.Forbidden()
} }
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) { if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
return contract.Forbidden() return contract.Forbidden()
} }
var setting model.MCPSetting enabled, err := data.AdmissionEnabled()
if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled { if err != nil || !enabled {
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
} }
@@ -236,7 +238,7 @@ func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion)
now := s.Now() now := s.Now()
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode) outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds() outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
if err := s.DB.Create(outcome).Error; err != nil { if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(outcome); err != nil {
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically") return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
} }
result.InvocationID = outcome.ID result.InvocationID = outcome.ID
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"errors"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
"gorm.io/gorm"
)
func (s *MCPService) Delete(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (resultErr error) {
defer func() {
if resultErr != nil {
if err := s.auditEnrollment(s.DB, owner, "delete", req, resultErr); err != nil {
resultErr = err
}
}
}()
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return err
}
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
return contract.Invalid()
}
mcpMutationMu.Lock()
defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error {
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return err
}
data := repo.MCPRepo{DB: tx}
key, err := data.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden()
}
return err
}
if key.Revision != req.Revision {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
}
if err := data.DeleteGrants(key.ID); err != nil {
return err
}
deleted, err := data.DeleteCredential(owner.Type, owner.ID, key.ID, req.Revision)
if err != nil {
return err
}
if deleted != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
}
return s.auditEnrollment(tx, owner, "delete", req, nil)
})
}
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
paneli18n "github.com/1Panel-dev/1Panel/core/i18n" paneli18n "github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -92,7 +93,9 @@ func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredential
if err := s.authorize(owner, "mcp_access_manage"); err != nil { if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return key, err return key, err
} }
if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { var err error
key, err = (repo.MCPRepo{DB: s.DB}).Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return key, contract.Forbidden() return key, contract.Forbidden()
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/constant"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid" "github.com/google/uuid"
@@ -29,7 +30,7 @@ func mcpEnrollmentAuditCode(err error) string {
func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error { func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error {
failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned") failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned")
if action != "create" && action != "reveal" && action != "rotate" && action != "export" { if action != "create" && action != "reveal" && action != "rotate" && action != "export" && action != "delete" {
return failed return failed
} }
id := "invalid" id := "invalid"
@@ -58,7 +59,7 @@ func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action
if s.EnrollmentAudit != nil { if s.EnrollmentAudit != nil {
err = s.EnrollmentAudit(db, record) err = s.EnrollmentAudit(db, record)
} else { } else {
err = db.Create(record).Error err = (repo.MCPRepo{DB: db}).CreateEnrollmentAudit(record)
} }
if err != nil { if err != nil {
return failed return failed
+206
View File
@@ -0,0 +1,206 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"sync"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/app/task"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"github.com/google/uuid"
"gorm.io/gorm"
)
func (s *MCPRuntimeService) SubmitInstall(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeInstallTask, error) {
if !mcpInstalling.CompareAndSwap(false, true) {
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "An MCP installation is already running")
}
if !mcpRuntimeMutation.TryLock() {
mcpInstalling.Store(false)
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running")
}
accepted := false
defer func() {
if !accepted {
mcpInstalling.Store(false)
mcpRuntimeMutation.Unlock()
}
}()
if err := ctx.Err(); err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if _, _, err := s.installSettings(ctx, req); err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
req.AllowedIPs = append([]string{}, req.AllowedIPs...)
if req.Website != nil {
website := *req.Website
domain, err := normalizeMCPWebsiteDomain(website.Domain)
if err != nil {
return dto.MCPRuntimeInstallTask{}, contract.Invalid()
}
parsed, _ := url.Parse("http://" + domain)
if net.ParseIP(parsed.Hostname()) != nil || website.EnableSSL != (website.SSLID != 0) {
return dto.MCPRuntimeInstallTask{}, contract.Invalid()
}
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if row.WebsiteID != 0 || row.WebsiteBindingID != "" || row.WebsiteOperation != "" {
return dto.MCPRuntimeInstallTask{}, contract.Fail("MCP_CONFLICT", 409, "Review the existing MCP website configuration before installation")
}
website.Domain = domain
req.Website = &website
}
installation, err := task.NewTaskWithOps("1Panel MCP", task.TaskInstall, task.TaskScopePanelMCP, "", 0)
if err != nil {
return dto.MCPRuntimeInstallTask{}, err
}
if err := repo.NewITaskRepo().Save(ctx, installation.Task); err != nil {
installation.Close()
installation.DeleteLogFile()
return dto.MCPRuntimeInstallTask{}, err
}
installation.AddSubTaskWithContext(i18n.GetMsgByKey("MCPInstallPrepare"), func(ctx context.Context, t *task.Task) (resultErr error) {
defer func() {
if recovered := recover(); recovered != nil {
resultErr = errors.New(i18n.GetMsgByKey("MCPInstallFailed"))
}
}()
return s.executeInstallTask(ctx, req, t)
}, nil, 0, 8*time.Minute)
accepted = true
go func() {
defer mcpRuntimeMutation.Unlock()
defer mcpInstalling.Store(false)
defer installation.Close()
if err := installation.Execute(); err != nil {
if installation.Task.Status == constant.StatusExecuting {
installation.Task.Status = constant.StatusFailed
installation.Task.ErrorMsg = i18n.GetMsgByKey("MCPInstallFailed")
installation.Task.EndAt = time.Now()
}
if global.LOG != nil {
global.LOG.Errorf("MCP installation task %s failed: %v", installation.TaskID, err)
}
}
if err := repo.NewITaskRepo().Update(context.Background(), installation.Task); err != nil && global.LOG != nil {
global.LOG.Errorf("MCP installation task %s status could not be saved: %v", installation.TaskID, err)
}
}()
return dto.MCPRuntimeInstallTask{TaskID: installation.TaskID}, nil
}
func (s *MCPRuntimeService) executeInstallTask(ctx context.Context, req dto.MCPRuntimeInstall, t *task.Task) error {
report := mcpInstallProgressLogger(t)
ctx = mcpruntime.WithProgress(ctx, report)
installCtx, cancel := context.WithTimeout(ctx, 6*time.Minute)
_, err := s.install(installCtx, req)
cancel()
if err != nil {
return safeMCPInstallTaskError(err)
}
if req.Website == nil {
return nil
}
t.Log(i18n.GetMsgByKey("MCPInstallBindWebsite"))
websiteCtx, websiteCancel := context.WithTimeout(ctx, 90*time.Second)
defer websiteCancel()
settings, err := s.settings(websiteCtx)
if err == nil {
_, err = s.mutateWebsiteLocked(websiteCtx, dto.MCPWebsitePendingRequest{
Operation: "bind", RequestID: uuid.NewString(), Domain: req.Website.Domain,
EnableSSL: req.Website.EnableSSL, SSLID: req.Website.SSLID,
}, settings.Revision)
}
if err != nil {
t.Log(i18n.GetMsgByKey("MCPInstallWebsiteFailed"))
return errors.New(i18n.GetMsgByKey("MCPInstallWebsiteFailed"))
}
return nil
}
func safeMCPInstallTaskError(err error) error {
var recovery *mcpRuntimeRecoveryError
if errors.As(err, &recovery) {
return errors.New(i18n.GetMsgByKey("MCPInstallRecoveryFailed"))
}
var operation *mcpruntime.OperationError
if errors.As(err, &operation) {
return operation
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return errors.New(i18n.GetMsgByKey("MCPInstallFailed"))
}
func mcpInstallProgressLogger(t *task.Task) func(mcpruntime.Progress) {
var mu sync.Mutex
last := make(map[string]time.Time)
return func(progress mcpruntime.Progress) {
mu.Lock()
defer mu.Unlock()
key := progress.Status + ":" + progress.ID
now := time.Now()
if (progress.Status == "downloading" || progress.Status == "extracting") &&
(progress.Total == 0 || progress.Current != progress.Total) && now.Sub(last[key]) < time.Second {
return
}
last[key] = now
stage := map[string]string{
"snapshot": "MCPInstallPrepare", "checking_image": "MCPInstallPullImage",
"local_image": "MCPInstallLocalImage", "verifying_image": "MCPInstallVerifyImage", "verified_image": "MCPInstallVerifiedImage",
"catalog": "MCPInstallCatalog", "starting": "MCPInstallStarting",
"saving": "MCPInstallSaving", "installed": "MCPInstallCompleted", "rollback": "MCPInstallRollback",
"recovery_failed": "MCPInstallRecoveryFailed",
}[progress.Status]
if stage != "" {
t.Log(i18n.GetMsgByKey(stage))
return
}
message := map[string]string{
"pulling": "Pulling fs layer", "waiting": "Waiting", "downloading": "Downloading",
"extracting": "Extracting", "verifying_checksum": "Verifying Checksum",
"download_complete": "Download complete", "pull_complete": "Pull complete", "already_exists": "Already exists",
}[progress.Status]
if message == "" {
return
}
if progress.ID != "" {
message = progress.ID + ": " + message
}
if progress.Total > 0 {
message += fmt.Sprintf(" %d/%d bytes (%.0f%%)", progress.Current, progress.Total, 100*float64(progress.Current)/float64(progress.Total))
} else if progress.Current > 0 {
message += fmt.Sprintf(" %d bytes", progress.Current)
}
t.Log(message)
}
}
func (s *MCPRuntimeService) InstallationTask(ctx context.Context) (string, bool, error) {
var latest model.Task
err := global.TaskDB.WithContext(ctx).Where("type = ?", task.TaskScopePanelMCP).Order("created_at DESC").First(&latest).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
return latest.ID, latest.Status == constant.StatusExecuting, err
}
func (s *MCPRuntimeService) RecoverInstallationTasks(processStartedAt time.Time) error {
return global.TaskDB.Model(&model.Task{}).Where("type = ? AND status = ? AND created_at < ?", task.TaskScopePanelMCP, constant.StatusExecuting, processStartedAt).
Updates(map[string]any{"status": constant.StatusFailed, "error_msg": i18n.GetMsgByKey("MCPInstallInterrupted"), "end_at": time.Now()}).Error
}
+3 -15
View File
@@ -4,23 +4,16 @@ import (
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
) )
// SQLite stores time.Time with the server's offset. Compare absolute seconds
// and integer nanoseconds so offset changes and repeated local hours remain
// chronological without changing the stored timestamps or losing precision.
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error { func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
var err error var err error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 { if attempt > 0 {
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond) time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
} }
// Only compensate the audit write. The business operation must never be replayed. err = (repo.MCPRepo{DB: s.DB}).UpdateRunningInvocation(record)
err = s.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
if err == nil { if err == nil {
return nil return nil
} }
@@ -28,12 +21,7 @@ func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
return err return err
} }
// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed
// process-start boundary prevents a later invocation from being marked interrupted.
func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error { func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error {
now := s.Now() now := s.Now()
return s.DB.Model(&model.MCPInvocation{}). return (repo.MCPRepo{DB: s.DB}).UpdateInterruptedInvocations(processStartedAt, "failed", "MCP_OUTCOME_UNKNOWN", now)
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error
} }
+138 -31
View File
@@ -8,20 +8,35 @@ import (
"reflect" "reflect"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/global"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp" contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime" "github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var mcpRuntimeMutation sync.Mutex var mcpRuntimeMutation sync.Mutex
var mcpInstalling atomic.Bool
var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving") var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving")
func lockMCPRuntimeMutation() error {
conflict := func() error { return contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running") }
if mcpInstalling.Load() {
return conflict()
}
mcpRuntimeMutation.Lock()
if mcpInstalling.Load() {
mcpRuntimeMutation.Unlock()
return conflict()
}
return nil
}
type mcpRuntimeManager interface { type mcpRuntimeManager interface {
Prepare(context.Context) error Prepare(context.Context) error
Catalog(context.Context) ([]contract.Tool, error) Catalog(context.Context) ([]contract.Tool, error)
@@ -40,6 +55,14 @@ type mcpRuntimeSnapshot struct {
unavailable bool unavailable bool
} }
type mcpRuntimeRecoveryError struct{ cause error }
func (e *mcpRuntimeRecoveryError) Error() string {
return "MCP runtime recovery is incomplete; check the service state before retrying"
}
func (e *mcpRuntimeRecoveryError) Unwrap() error { return e.cause }
func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) { func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) {
snapshot := mcpRuntimeSnapshot{settings: settings} snapshot := mcpRuntimeSnapshot{settings: settings}
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
@@ -51,7 +74,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
} }
snapshot.installed = state.Installed snapshot.installed = state.Installed
if state.Installed { if state.Installed {
if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil { if state.AdapterVersion == "" || !mcpruntime.ValidDockerImageID(state.ImageID) {
snapshot.unavailable = true snapshot.unavailable = true
return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity") return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity")
} }
@@ -65,6 +88,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
type MCPRuntimeService struct { type MCPRuntimeService struct {
newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager
websiteTransport mcpWebsiteTransport
} }
func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager { func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager {
@@ -96,8 +120,7 @@ func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSetting
ListenPort: mcpruntime.DefaultPort, Revision: 1, ListenPort: mcpruntime.DefaultPort, Revision: 1,
AccessPolicy: "inherit", AllowedIPs: []string{}, AccessPolicy: "inherit", AllowedIPs: []string{},
} }
var saved model.MCPSetting saved, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting()
err := global.DB.WithContext(ctx).First(&saved, 1).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return settings, nil return settings, nil
} }
@@ -121,7 +144,9 @@ func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error
} }
func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) { func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeSettings{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel() defer cancel()
@@ -136,6 +161,13 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
if req.Revision != old.Revision { if req.Revision != old.Revision {
return old, ErrMCPSettingsRevision return old, ErrMCPSettingsRevision
} }
if req.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(req); err != nil { if err := validateMCPRuntimeSettings(req); err != nil {
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
} }
@@ -166,19 +198,20 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error { func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1} seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { if err := store.CreateSettingIfAbsent(&seed); err != nil {
return err return err
} }
result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{ rowsAffected, err := store.UpdateRuntimeSetting(model.MCPSetting{
"runtime_type": "docker", "docker_image": req.DockerImage, RuntimeType: "docker", DockerImage: req.DockerImage,
"enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1, Enabled: req.Enabled, ListenPort: req.ListenPort, ExternalURL: req.ExternalURL, Revision: revision + 1,
"access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs), AccessPolicy: req.AccessPolicy, AllowedIPs: encodeMCPIPs(req.AllowedIPs),
}) }, revision)
if result.Error != nil { if err != nil {
return result.Error return err
} }
if result.RowsAffected != 1 { if rowsAffected != 1 {
return ErrMCPSettingsRevision return ErrMCPSettingsRevision
} }
return nil return nil
@@ -212,16 +245,27 @@ func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, e
} }
state := s.manager(settings).Status(ctx, settings.Enabled) state := s.manager(settings).Status(ctx, settings.Enabled)
return dto.MCPRuntimeStatus{ result := dto.MCPRuntimeStatus{
RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID, RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID,
Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason, Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason,
AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL, AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL,
InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort), InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort),
}, nil }
website, websiteErr := s.WebsiteStatus(ctx)
if websiteErr != nil {
return result, websiteErr
}
if website.State != "unbound" {
result.Website = &website
result.Endpoint = website.Endpoint
}
return result, nil
} }
func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
@@ -242,20 +286,46 @@ func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.
} }
func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) { func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock() if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock() defer mcpRuntimeMutation.Unlock()
old, err := s.settings(ctx) return s.install(ctx, req)
}
func (s *MCPRuntimeService) installSettings(ctx context.Context, req dto.MCPRuntimeInstall) (old, next dto.MCPRuntimeSettings, err error) {
old, err = s.settings(ctx)
if err != nil {
return old, next, err
}
if req.Revision != old.Revision {
return old, next, ErrMCPSettingsRevision
}
next = old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
next.AccessPolicy, next.AllowedIPs = req.AccessPolicy, append([]string{}, req.AllowedIPs...)
if next.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, next, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, next, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
if err := normalizeMCPAccessSettings(&next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
return old, next, nil
}
func (s *MCPRuntimeService) install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
old, next, err := s.installSettings(ctx, req)
if err != nil { if err != nil {
return dto.MCPRuntimeStatus{}, err return dto.MCPRuntimeStatus{}, err
} }
if req.Revision != old.Revision { mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "snapshot"})
return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision
}
next := old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
if err := validateMCPRuntimeSettings(next); err != nil {
return dto.MCPRuntimeStatus{}, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
snapshot, err := s.snapshot(ctx, old) snapshot, err := s.snapshot(ctx, old)
if err != nil { if err != nil {
return dto.MCPRuntimeStatus{}, err return dto.MCPRuntimeStatus{}, err
@@ -263,6 +333,15 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
if !snapshot.installed { if !snapshot.installed {
next.Enabled = true next.Enabled = true
} }
prepared := old
policyPrepared := !snapshot.installed || next.AccessPolicy != old.AccessPolicy || !reflect.DeepEqual(next.AllowedIPs, old.AllowedIPs)
if policyPrepared {
prepared.AccessPolicy, prepared.AllowedIPs = next.AccessPolicy, next.AllowedIPs
if err := s.persist(prepared, old.Revision); err != nil {
return dto.MCPRuntimeStatus{}, err
}
prepared.Revision++
}
manager := s.manager(next) manager := s.manager(next)
rollback := func(authoritative dto.MCPRuntimeSettings) error { rollback := func(authoritative dto.MCPRuntimeSettings) error {
if !snapshot.installed && reflect.DeepEqual(authoritative, old) { if !snapshot.installed && reflect.DeepEqual(authoritative, old) {
@@ -273,21 +352,49 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
return s.restore(authoritative, snapshot) return s.restore(authoritative, snapshot)
} }
recoverFailure := func(operationErr error) error { recoverFailure := func(operationErr error) error {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "rollback"})
current, loadErr := s.Settings() current, loadErr := s.Settings()
if loadErr != nil { if loadErr != nil {
return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available")) mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, loadErr)}
} }
return errors.Join(operationErr, rollback(current)) if policyPrepared && current.Revision == prepared.Revision {
if recoveryErr := rollback(old); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
if recoveryErr := s.persist(old, prepared.Revision); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
}
if recoveryErr := rollback(current); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
} }
if err := manager.InstallImage(ctx, next.Enabled); err != nil { if err := installMCPRuntimeImage(ctx, manager, next.Enabled); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
if err := s.persist(next, old.Revision); err != nil { mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "saving"})
if err := s.persist(next, prepared.Revision); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err) return dto.MCPRuntimeStatus{}, recoverFailure(err)
} }
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "installed"})
return s.Status(ctx) return s.Status(ctx)
} }
func installMCPRuntimeImage(ctx context.Context, manager mcpRuntimeManager, enabled bool) (err error) {
defer func() {
if recover() != nil {
err = errors.New("MCP Docker image installation failed")
}
}()
return manager.InstallImage(ctx, enabled)
}
func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) { func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) {
settings, err := s.Settings() settings, err := s.Settings()
if err != nil { if err != nil {
+483
View File
@@ -0,0 +1,483 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
"golang.org/x/net/idna"
"gorm.io/gorm"
)
type mcpWebsiteAgentRequest struct {
Operation string `json:"operation"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
type mcpWebsiteAgentResponse struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
type mcpWebsiteTransport interface {
Call(context.Context, mcpWebsiteAgentRequest) (mcpWebsiteAgentResponse, error)
}
type mcpWebsiteHTTPTransport struct{}
func (mcpWebsiteHTTPTransport) Call(ctx context.Context, request mcpWebsiteAgentRequest) (mcpWebsiteAgentResponse, error) {
var result mcpWebsiteAgentResponse
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", "/etc/1panel/agent.sock")
}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
data, err := json.Marshal(request)
if err != nil {
return result, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://agent/internal/mcp/website", bytes.NewReader(data))
if err != nil {
return result, err
}
req.Header.Set("Content-Type", "application/json")
response, err := client.Do(req)
if err != nil {
return result, errors.New("MCP website operation could not be confirmed")
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
if err != nil || len(body) > 64<<10 || json.Unmarshal(body, &result) != nil {
return mcpWebsiteAgentResponse{}, errors.New("MCP website operation could not be confirmed")
}
if response.StatusCode != http.StatusOK && result.Error == "" {
return mcpWebsiteAgentResponse{}, errors.New("MCP website operation could not be confirmed")
}
return result, nil
}
type mcpWebsiteOperation struct {
dto.MCPWebsitePendingRequest
Hash string `json:"hash"`
BindingID string `json:"bindingId"`
State string `json:"state"`
ErrorCode string `json:"errorCode,omitempty"`
}
func (s *MCPRuntimeService) websiteAgent() mcpWebsiteTransport {
if s.websiteTransport != nil {
return s.websiteTransport
}
return mcpWebsiteHTTPTransport{}
}
func (s *MCPRuntimeService) websiteSetting(ctx context.Context) (model.MCPSetting, error) {
row, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting()
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.MCPSetting{Revision: 1}, nil
}
return row, err
}
func decodeMCPWebsiteOperation(value string) (*mcpWebsiteOperation, error) {
if value == "" {
return nil, nil
}
var operation mcpWebsiteOperation
if json.Unmarshal([]byte(value), &operation) != nil || !validMCPWebsiteRequestID(operation.RequestID) ||
!validMCPWebsiteRequestID(operation.BindingID) || len(operation.Hash) != 64 ||
(operation.State != "pending" && operation.State != "succeeded" && operation.State != "failed") ||
(operation.Operation != "bind" && operation.Operation != "update" && operation.Operation != "unbind") {
return nil, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
return &operation, nil
}
func validMCPWebsiteRequestID(id string) bool {
parsed, err := uuid.Parse(id)
return err == nil && parsed != uuid.Nil && parsed.String() == id
}
func normalizeMCPWebsiteDomain(domain string) (string, error) {
if domain == "" || domain != strings.TrimSpace(domain) || len(domain) > 320 || strings.ContainsAny(domain, "\\\r\n\t /?#@\x00") {
return "", contract.Invalid()
}
u, err := url.Parse("http://" + domain)
if err != nil || u.Host == "" || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", contract.Invalid()
}
host := strings.ToLower(u.Hostname())
if ip := net.ParseIP(host); ip == nil {
host, err = idna.Lookup.ToASCII(host)
if err != nil || len(host) > 253 {
return "", contract.Invalid()
}
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", contract.Invalid()
}
for _, ch := range label {
if !(ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '-') {
return "", contract.Invalid()
}
}
}
} else {
host = ip.String()
}
if port := u.Port(); port != "" {
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return "", contract.Invalid()
}
return net.JoinHostPort(host, strconv.Itoa(number)), nil
}
if strings.HasSuffix(domain, ":") {
return "", contract.Invalid()
}
if strings.Contains(host, ":") {
return "[" + host + "]", nil
}
return host, nil
}
func validMCPWebsiteMetadata(response mcpWebsiteAgentResponse) bool {
if !mcpDocumentEndpoint(response.Endpoint) || response.EnableSSL && response.SSLID == 0 || !response.EnableSSL && response.SSLID != 0 {
return false
}
domain, err := normalizeMCPWebsiteDomain(response.Domain)
if err != nil {
return false
}
domainURL, _ := url.Parse("http://" + domain)
endpoint, _ := url.Parse(response.Endpoint)
return strings.EqualFold(domainURL.Hostname(), endpoint.Hostname()) && (endpoint.Scheme == "https") == response.EnableSSL
}
func (s *MCPRuntimeService) websiteAgentRequest(ctx context.Context, row model.MCPSetting, operation string, operationID string) (mcpWebsiteAgentRequest, error) {
masterKey, err := encrypt.APIKeyEncryptionKey()
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
key, err := contract.WebsiteProxyKey(masterKey, row.WebsiteBindingID)
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website state is unavailable")
}
host := strings.Trim(global.CONF.Conn.BindAddress, "[]")
ip := net.ParseIP(host)
if host == "" || host == "localhost" || host == "0.0.0.0" {
host = "127.0.0.1"
} else if ip != nil && ip.IsUnspecified() {
host = "::1"
} else if ip == nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
port, err := strconv.Atoi(global.CONF.Conn.Port)
if err != nil || port < 1 || port > 65535 {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
scheme := "http"
if global.CONF.Conn.SSL == constant.StatusEnable || global.CONF.Conn.SSL == constant.StatusMux {
scheme = "https"
}
address := net.JoinHostPort(host, strconv.Itoa(port))
coreHost := address
if binding, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).PanelSetting("BindDomain"); err == nil && binding.Value != "" {
coreHost, err = normalizeMCPWebsiteDomain(binding.Value)
if err != nil {
return mcpWebsiteAgentRequest{}, contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website upstream is unavailable")
}
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return mcpWebsiteAgentRequest{}, err
}
return mcpWebsiteAgentRequest{Operation: operation, WebsiteID: row.WebsiteID, BindingID: row.WebsiteBindingID,
RequestID: operationID, Upstream: (&url.URL{Scheme: scheme, Host: address, Path: "/mcp"}).String(), CoreHost: coreHost, ProxyKey: key}, nil
}
func (s *MCPRuntimeService) WebsiteStatus(ctx context.Context) (dto.MCPWebsiteStatus, error) {
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
return s.websiteStatus(ctx, row)
}
func (s *MCPRuntimeService) websiteStatus(ctx context.Context, row model.MCPSetting) (dto.MCPWebsiteStatus, error) {
result := dto.MCPWebsiteStatus{State: "unbound", Revision: row.Revision, WebsiteID: row.WebsiteID}
operation, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return result, err
}
if operation != nil {
result.RequestID = operation.RequestID
}
if row.WebsiteID == 0 && row.WebsiteBindingID == "" && (operation == nil || operation.State != "pending") {
return result, nil
}
result.State = "unavailable"
if operation != nil && operation.State == "pending" {
result.State = "pending"
pending := operation.MCPWebsitePendingRequest
result.PendingRequest = &pending
result.Domain, result.EnableSSL, result.SSLID = pending.Domain, pending.EnableSSL, pending.SSLID
}
request, err := s.websiteAgentRequest(ctx, row, "status", result.RequestID)
if err != nil {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
return result, nil
}
response, err := s.websiteAgent().Call(ctx, request)
if err != nil || response.Error != "" || response.BindingID != row.WebsiteBindingID ||
(row.WebsiteID != 0 && response.WebsiteID != row.WebsiteID) {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
return result, nil
}
result.WebsiteID, result.Domain = response.WebsiteID, response.Domain
result.EnableSSL, result.SSLID, result.WebsiteStatus = response.EnableSSL, response.SSLID, response.WebsiteStatus
if result.State == "pending" {
return result, nil
}
if row.WebsiteID != 0 && response.State == "bound" && response.WebsiteID != 0 && validMCPWebsiteMetadata(response) {
result.State, result.Endpoint = "bound", response.Endpoint
} else {
result.Reason = "MCP_WEBSITE_UNAVAILABLE"
}
return result, nil
}
func (s *MCPRuntimeService) BindWebsite(ctx context.Context, req dto.MCPWebsiteBind) (dto.MCPWebsiteStatus, error) {
domain, err := normalizeMCPWebsiteDomain(req.Domain)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
parsed, _ := url.Parse("http://" + domain)
if net.ParseIP(parsed.Hostname()) != nil {
return dto.MCPWebsiteStatus{}, contract.Invalid()
}
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "bind", RequestID: req.RequestID, Domain: domain, EnableSSL: req.EnableSSL, SSLID: req.SSLID}, req.Revision)
}
func (s *MCPRuntimeService) UpdateWebsite(ctx context.Context, req dto.MCPWebsiteUpdate) (dto.MCPWebsiteStatus, error) {
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "update", RequestID: req.RequestID, EnableSSL: req.EnableSSL, SSLID: req.SSLID}, req.Revision)
}
func (s *MCPRuntimeService) UnbindWebsite(ctx context.Context, req dto.MCPWebsiteUnbind) (dto.MCPWebsiteStatus, error) {
return s.mutateWebsite(ctx, dto.MCPWebsitePendingRequest{Operation: "unbind", RequestID: req.RequestID}, req.Revision)
}
func mcpWebsiteOperationError(code string, uncertain bool) error {
if uncertain {
return contract.Fail("MCP_WEBSITE_PENDING", 503, "MCP website operation could not be confirmed")
}
switch code {
case "MCP_INVALID_ARGUMENTS", "MCP_WEBSITE_INVALID", "MCP_WEBSITE_INVALID_ARGUMENTS":
return contract.Invalid()
case "MCP_CONFLICT", "MCP_WEBSITE_CONFLICT":
return contract.Fail("MCP_CONFLICT", 409, "MCP website configuration changed")
case "MCP_FORBIDDEN":
return contract.Forbidden()
default:
return contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website operation failed")
}
}
func (s *MCPRuntimeService) mutateWebsite(ctx context.Context, pending dto.MCPWebsitePendingRequest, revision uint64) (dto.MCPWebsiteStatus, error) {
if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPWebsiteStatus{}, err
}
defer mcpRuntimeMutation.Unlock()
return s.mutateWebsiteLocked(ctx, pending, revision)
}
func (s *MCPRuntimeService) mutateWebsiteLocked(ctx context.Context, pending dto.MCPWebsitePendingRequest, revision uint64) (dto.MCPWebsiteStatus, error) {
if !validMCPWebsiteRequestID(pending.RequestID) || revision == 0 ||
(pending.Operation != "unbind" && (pending.EnableSSL && pending.SSLID == 0 || !pending.EnableSSL && pending.SSLID != 0)) {
return dto.MCPWebsiteStatus{}, contract.Invalid()
}
row, err := s.websiteSetting(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
previous, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
encoded, _ := json.Marshal(pending)
digest := sha256.Sum256(encoded)
hash := hex.EncodeToString(digest[:])
operation := mcpWebsiteOperation{MCPWebsitePendingRequest: pending, Hash: hash, State: "pending", BindingID: row.WebsiteBindingID}
if previous != nil && previous.RequestID == pending.RequestID {
if previous.Hash != hash {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP website request was already used")
}
if previous.State == "succeeded" {
return s.websiteStatus(ctx, row)
}
if previous.State == "failed" {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError(previous.ErrorCode, false)
}
operation = *previous
} else {
cancelPendingChange := previous != nil && previous.State == "pending" &&
(previous.Operation == "bind" || previous.Operation == "update") && pending.Operation == "unbind"
if previous != nil && previous.State == "pending" && !cancelPendingChange || row.Revision != revision {
return dto.MCPWebsiteStatus{}, ErrMCPSettingsRevision
}
if pending.Operation == "bind" {
if row.WebsiteID != 0 {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP already has a bound website")
}
settings, err := s.settings(ctx)
if err != nil {
return dto.MCPWebsiteStatus{}, err
}
if !s.manager(settings).Status(ctx, settings.Enabled).Installed {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_WEBSITE_SERVICE_REQUIRED", 409, "Install the MCP service before binding a website")
}
operation.BindingID = pending.RequestID
row.WebsiteBindingID = operation.BindingID
} else if row.WebsiteID == 0 && !cancelPendingChange || !validMCPWebsiteRequestID(row.WebsiteBindingID) {
return dto.MCPWebsiteStatus{}, contract.Fail("MCP_CONFLICT", 409, "MCP has no bound website")
}
if _, err := s.websiteAgentRequest(ctx, row, pending.Operation, pending.RequestID); err != nil {
return dto.MCPWebsiteStatus{}, err
}
if err := s.saveWebsiteOperation(ctx, &row, operation, row.WebsiteID, row.WebsiteBindingID); err != nil {
return dto.MCPWebsiteStatus{}, err
}
}
request, err := s.websiteAgentRequest(ctx, row, pending.Operation, pending.RequestID)
if err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
request.Domain, request.EnableSSL, request.SSLID = pending.Domain, pending.EnableSSL, pending.SSLID
response, callErr := s.websiteAgent().Call(ctx, request)
if callErr != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
if response.Error != "" {
if !response.Definitive || pending.Operation == "unbind" && row.WebsiteID == 0 && row.WebsiteBindingID != "" {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
operation.State, operation.ErrorCode = "failed", response.Code
bindingID := row.WebsiteBindingID
if pending.Operation == "bind" && row.WebsiteID == 0 {
bindingID = ""
}
if err := s.saveWebsiteOperation(ctx, &row, operation, row.WebsiteID, bindingID); err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError(response.Code, false)
}
if response.BindingID != operation.BindingID || response.RequestID != pending.RequestID ||
(pending.Operation != "unbind" && (response.State != "bound" || response.WebsiteID == 0 || !validMCPWebsiteMetadata(response))) ||
(pending.Operation != "unbind" && (response.EnableSSL != pending.EnableSSL || response.SSLID != pending.SSLID)) ||
(pending.Operation == "update" && response.WebsiteID != row.WebsiteID) ||
(pending.Operation == "unbind" && response.State != "unbound") {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
websiteID, bindingID := response.WebsiteID, operation.BindingID
if pending.Operation == "unbind" {
websiteID, bindingID = 0, ""
}
operation.State = "succeeded"
if err := s.saveWebsiteOperation(ctx, &row, operation, websiteID, bindingID); err != nil {
return dto.MCPWebsiteStatus{}, mcpWebsiteOperationError("", true)
}
if pending.Operation == "unbind" {
return dto.MCPWebsiteStatus{State: "unbound", Revision: row.Revision, RequestID: pending.RequestID}, nil
}
return dto.MCPWebsiteStatus{State: "bound", Revision: row.Revision, WebsiteID: response.WebsiteID, Domain: response.Domain,
Endpoint: response.Endpoint, EnableSSL: response.EnableSSL, SSLID: response.SSLID, WebsiteStatus: response.WebsiteStatus, RequestID: pending.RequestID}, nil
}
func (s *MCPRuntimeService) saveWebsiteOperation(ctx context.Context, row *model.MCPSetting, operation mcpWebsiteOperation, websiteID uint, bindingID string) error {
data, err := json.Marshal(operation)
if err != nil {
return err
}
err = global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
if err := store.CreateSettingIfAbsent(&model.MCPSetting{ID: 1}); err != nil {
return err
}
rowsAffected, err := store.UpdateWebsiteSetting(model.MCPSetting{
WebsiteID: websiteID, WebsiteBindingID: bindingID, WebsiteOperation: string(data), Revision: row.Revision + 1,
}, row.Revision)
if err != nil {
return err
}
if rowsAffected != 1 {
return ErrMCPSettingsRevision
}
return nil
})
if err == nil {
row.ID, row.WebsiteID, row.WebsiteBindingID, row.WebsiteOperation, row.Revision = 1, websiteID, bindingID, string(data), row.Revision+1
}
return err
}
func (s *MCPRuntimeService) SyncWebsite(ctx context.Context) error {
mcpRuntimeMutation.Lock()
defer mcpRuntimeMutation.Unlock()
row, err := s.websiteSetting(ctx)
if err != nil {
return err
}
operation, err := decodeMCPWebsiteOperation(row.WebsiteOperation)
if err != nil {
return err
}
if row.WebsiteID == 0 || operation != nil && operation.State == "pending" {
return nil
}
request, err := s.websiteAgentRequest(ctx, row, "sync", "")
if err != nil {
return err
}
response, err := s.websiteAgent().Call(ctx, request)
if err != nil || response.Error != "" || response.State != "bound" || response.WebsiteID != row.WebsiteID || response.BindingID != row.WebsiteBindingID {
return contract.Fail("MCP_WEBSITE_UNAVAILABLE", 503, "MCP website synchronization failed")
}
return nil
}
+7
View File
@@ -80,6 +80,7 @@ const (
TaskScopeCluster = "Cluster" TaskScopeCluster = "Cluster"
TaskScopeAppInstall = "AppInstallTask" TaskScopeAppInstall = "AppInstallTask"
TaskScopeAI = "AI" TaskScopeAI = "AI"
TaskScopePanelMCP = "PanelMCP"
) )
func GetTaskName(resourceName, operate, scope string) string { func GetTaskName(resourceName, operate, scope string) string {
@@ -321,6 +322,12 @@ func (t *Task) DeleteLogFile() {
_ = os.Remove(t.Task.LogFile) _ = os.Remove(t.Task.LogFile)
} }
func (t *Task) Close() {
if t.logFile != nil {
_ = t.logFile.Close()
}
}
func (t *Task) LogWithStatus(msg string, err error) { func (t *Task) LogWithStatus(msg string, err error) {
if err != nil { if err != nil {
t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err)) t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err))
+2143 -267
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -18,7 +18,10 @@ import (
// @schemes http https // @schemes http https
// @securityDefinitions.apikey ApiKeyAuth // @securityDefinitions.apikey ApiKeyAuth
// @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp). // @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
// @description ``` // @description ```
// @description eg: // @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \ // @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -27,7 +30,7 @@ import (
// @description ``` // @description ```
// @description - `1Panel-Token` is the key for the panel API Key. // @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey // @type apiKey
// @in Header // @in header
// @name 1Panel-Token // @name 1Panel-Token
// @securityDefinitions.apikey Timestamp // @securityDefinitions.apikey Timestamp
// @type apiKey // @type apiKey
+3
View File
@@ -73,11 +73,14 @@ var WebUrlMap = map[string]struct{}{
"/ai/benchmark": {}, "/ai/benchmark": {},
"/ai/model/account": {}, "/ai/model/account": {},
"/ai/model/local": {}, "/ai/model/local": {},
"/ai/model/local/templates": {},
"/ai/model/files": {}, "/ai/model/files": {},
"/ai/gpu": {}, "/ai/gpu": {},
"/ai/gpu/current": {}, "/ai/gpu/current": {},
"/ai/gpu/history": {}, "/ai/gpu/history": {},
"/ai/mcp": {}, "/ai/mcp": {},
"/ai/mcp/server": {},
"/ai/mcp/1panel": {},
"/ai/agents/agent": {}, "/ai/agents/agent": {},
"/containers": {}, "/containers": {},
+2
View File
@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/crewjam/saml v0.5.1 github.com/crewjam/saml v0.5.1
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/gzip v1.2.6 github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
@@ -106,6 +107,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.1 // indirect github.com/pdfcpu/pdfcpu v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
+4
View File
@@ -31,6 +31,8 @@ github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhy
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
@@ -200,6 +202,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the T
ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked." ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked."
ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing." ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing."
ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first." ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first."
ErrMCPWebsitePending: "The website operation result is uncertain. Refresh the binding status before retrying the original operation."
ErrMCPWebsiteUnavailable: "The website binding is unavailable. Check the website status and OpenResty configuration."
ErrMCPWebsiteServiceRequired: "Install the 1Panel MCP service first."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Check the current MCP service"
MCPInstallPullImage: "Check and pull the image"
MCPInstallCatalog: "Validate the tool contract"
MCPInstallStarting: "Start the container and check its health"
MCPInstallSaving: "Save the service configuration"
MCPInstallCompleted: "MCP service installation completed"
MCPInstallRollback: "Installation failed; restore the previous MCP service"
MCPInstallBindWebsite: "Create the bound website"
MCPInstallWebsiteFailed: "The MCP service is installed, but website binding failed. Resolve the website configuration in the service settings."
MCPInstallInterrupted: "Installation was interrupted by a panel restart. Check the service status before continuing."
MCPInstallFailed: "MCP installation failed. Check Docker, the image, and the panel service logs."
MCPInstallRecoveryFailed: "Installation failed and the previous service could not be fully restored. Check the MCP service status before continuing."
MCPInstallLocalImage: "The image exists locally; skip downloading"
MCPInstallVerifyImage: "Validate the image version and managed service contract"
MCPInstallVerifiedImage: "Image validation passed"
+21
View File
@@ -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." ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado."
ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar." ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar."
ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida." ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida."
ErrMCPWebsitePending: "El resultado de la operación del sitio es incierto. Actualice el estado de la vinculación antes de reintentar la operación original."
ErrMCPWebsiteUnavailable: "La vinculación del sitio no está disponible. Revise el estado del sitio y la configuración de OpenResty."
ErrMCPWebsiteServiceRequired: "Instale primero el servicio 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Comprobar el servicio MCP actual"
MCPInstallPullImage: "Comprobar y descargar la imagen"
MCPInstallCatalog: "Validar el contrato de herramientas"
MCPInstallStarting: "Iniciar el contenedor y comprobar su estado"
MCPInstallSaving: "Guardar la configuración del servicio"
MCPInstallCompleted: "Instalación del servicio MCP completada"
MCPInstallRollback: "La instalación falló; restaurar el servicio MCP anterior"
MCPInstallBindWebsite: "Crear el sitio web vinculado"
MCPInstallWebsiteFailed: "El servicio MCP está instalado, pero la vinculación del sitio web falló. Revise la configuración del sitio web en los ajustes del servicio."
MCPInstallInterrupted: "Un reinicio del panel interrumpió la instalación. Compruebe el estado del servicio antes de continuar."
MCPInstallFailed: "La instalación de MCP falló. Revise Docker, la imagen y los registros del servicio del panel."
MCPInstallRecoveryFailed: "La instalación falló y el servicio anterior no se pudo restaurar por completo. Compruebe el estado del servicio MCP antes de continuar."
MCPInstallLocalImage: "La imagen ya existe localmente; omitir la descarga"
MCPInstallVerifyImage: "Validar la versión de la imagen y el contrato del servicio administrado"
MCPInstallVerifiedImage: "La imagen superó la validación"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن ن
ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است." ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است."
ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید." ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید."
ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید." ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید."
ErrMCPWebsitePending: "نتیجه عملیات وب‌سایت تأیید نشده است. پیش از تلاش دوباره برای عملیات اصلی، وضعیت اتصال را تازه‌سازی کنید."
ErrMCPWebsiteUnavailable: "اتصال وب‌سایت در دسترس نیست. وضعیت وب‌سایت و تنظیمات OpenResty را بررسی کنید."
ErrMCPWebsiteServiceRequired: "ابتدا سرویس 1Panel MCP را نصب کنید."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "بررسی سرویس فعلی MCP"
MCPInstallPullImage: "بررسی و دریافت تصویر"
MCPInstallCatalog: "اعتبارسنجی قرارداد ابزارها"
MCPInstallStarting: "راه‌اندازی کانتینر و بررسی سلامت آن"
MCPInstallSaving: "ذخیره پیکربندی سرویس"
MCPInstallCompleted: "نصب سرویس MCP تکمیل شد"
MCPInstallRollback: "نصب ناموفق بود؛ بازیابی سرویس قبلی MCP"
MCPInstallBindWebsite: "ایجاد وب‌سایت متصل"
MCPInstallWebsiteFailed: "سرویس MCP نصب شده است، اما اتصال وب‌سایت ناموفق بود. پیکربندی وب‌سایت را در تنظیمات سرویس اصلاح کنید."
MCPInstallInterrupted: "راه‌اندازی مجدد پنل باعث قطع نصب شد. پیش از ادامه، وضعیت سرویس را بررسی کنید."
MCPInstallFailed: "نصب MCP ناموفق بود. Docker، تصویر و گزارش‌های سرویس پنل را بررسی کنید."
MCPInstallRecoveryFailed: "نصب ناموفق بود و سرویس قبلی به‌طور کامل بازیابی نشد. پیش از ادامه، وضعیت سرویس MCP را بررسی کنید."
MCPInstallLocalImage: "تصویر به‌صورت محلی موجود است؛ دریافت انجام نمی‌شود"
MCPInstallVerifyImage: "اعتبارسنجی نسخه تصویر و قرارداد سرویس مدیریت‌شده"
MCPInstallVerifiedImage: "اعتبارسنجی تصویر موفق بود"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token
ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。" ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。"
ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。" ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。"
ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。" ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。"
ErrMCPWebsitePending: "サイト操作の結果を確認できません。連携状態を更新してから元の操作を再試行してください。"
ErrMCPWebsiteUnavailable: "サイト連携を利用できません。サイトの状態と OpenResty の設定を確認してください。"
ErrMCPWebsiteServiceRequired: "先に 1Panel MCP サービスをインストールしてください。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "現在の MCP サービスを確認"
MCPInstallPullImage: "イメージを確認して取得"
MCPInstallCatalog: "ツールの契約を検証"
MCPInstallStarting: "コンテナを起動して正常性を確認"
MCPInstallSaving: "サービス設定を保存"
MCPInstallCompleted: "MCP サービスのインストールが完了しました"
MCPInstallRollback: "インストールに失敗したため、以前の MCP サービスを復元"
MCPInstallBindWebsite: "連携する Web サイトを作成"
MCPInstallWebsiteFailed: "MCP サービスはインストールされましたが、Web サイトの連携に失敗しました。サービス設定で Web サイトの設定を確認してください。"
MCPInstallInterrupted: "パネルの再起動によりインストールが中断されました。操作を続ける前にサービスの状態を確認してください。"
MCPInstallFailed: "MCP のインストールに失敗しました。Docker、イメージ、パネルサービスのログを確認してください。"
MCPInstallRecoveryFailed: "インストールに失敗し、以前のサービスを完全に復元できませんでした。操作を続ける前に MCP サービスの状態を確認してください。"
MCPInstallLocalImage: "ローカルにイメージが存在するため、ダウンロードをスキップ"
MCPInstallVerifyImage: "イメージのバージョンと管理対象サービスの契約を検証"
MCPInstallVerifiedImage: "イメージの検証に成功しました"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Toke
ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다." ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다."
ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요." ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요."
ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요." ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요."
ErrMCPWebsitePending: "웹사이트 작업 결과를 확인할 수 없습니다. 연결 상태를 새로 고친 후 원래 작업을 다시 시도하세요."
ErrMCPWebsiteUnavailable: "웹사이트 연결을 사용할 수 없습니다. 웹사이트 상태와 OpenResty 설정을 확인하세요."
ErrMCPWebsiteServiceRequired: "먼저 1Panel MCP 서비스를 설치하세요."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "현재 MCP 서비스 확인"
MCPInstallPullImage: "이미지 확인 및 가져오기"
MCPInstallCatalog: "도구 계약 검증"
MCPInstallStarting: "컨테이너 시작 및 상태 확인"
MCPInstallSaving: "서비스 구성 저장"
MCPInstallCompleted: "MCP 서비스 설치 완료"
MCPInstallRollback: "설치 실패로 이전 MCP 서비스 복원"
MCPInstallBindWebsite: "연결할 웹사이트 생성"
MCPInstallWebsiteFailed: "MCP 서비스는 설치되었지만 웹사이트 연결에 실패했습니다. 서비스 설정에서 웹사이트 구성을 확인하세요."
MCPInstallInterrupted: "패널이 다시 시작되어 설치가 중단되었습니다. 계속하기 전에 서비스 상태를 확인하세요."
MCPInstallFailed: "MCP 설치에 실패했습니다. Docker, 이미지 및 패널 서비스 로그를 확인하세요."
MCPInstallRecoveryFailed: "설치에 실패했으며 이전 서비스를 완전히 복원하지 못했습니다. 계속하기 전에 MCP 서비스 상태를 확인하세요."
MCPInstallLocalImage: "로컬 이미지가 있어 다운로드를 건너뜁니다"
MCPInstallVerifyImage: "이미지 버전 및 관리형 서비스 계약 검증"
MCPInstallVerifiedImage: "이미지 검증 통과"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token
ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ." ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ."
ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່." ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່."
ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ." ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ."
ErrMCPWebsitePending: "ຍັງບໍ່ສາມາດຢືນຢັນຜົນການດຳເນີນການເວັບໄຊ. ໂຫຼດສະຖານະການຜູກມັດໃໝ່ກ່ອນລອງການດຳເນີນການເດີມອີກຄັ້ງ."
ErrMCPWebsiteUnavailable: "ການຜູກມັດເວັບໄຊບໍ່ພ້ອມໃຊ້ງານ. ກວດສອບສະຖານະເວັບໄຊ ແລະ ການຕັ້ງຄ່າ OpenResty."
ErrMCPWebsiteServiceRequired: "ກະລຸນາຕິດຕັ້ງບໍລິການ 1Panel MCP ກ່ອນ."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "ກວດສອບບໍລິການ MCP ປັດຈຸບັນ"
MCPInstallPullImage: "ກວດສອບ ແລະ ດຶງອິມເມດ"
MCPInstallCatalog: "ກວດສອບສັນຍາຂອງເຄື່ອງມື"
MCPInstallStarting: "ເລີ່ມຄອນເທນເນີ ແລະ ກວດສອບສຸຂະພາບ"
MCPInstallSaving: "ບັນທຶກການຕັ້ງຄ່າບໍລິການ"
MCPInstallCompleted: "ຕິດຕັ້ງບໍລິການ MCP ສຳເລັດແລ້ວ"
MCPInstallRollback: "ການຕິດຕັ້ງລົ້ມເຫຼວ; ກູ້ຄືນບໍລິການ MCP ກ່ອນໜ້າ"
MCPInstallBindWebsite: "ສ້າງເວັບໄຊທີ່ເຊື່ອມໂຍງ"
MCPInstallWebsiteFailed: "ຕິດຕັ້ງບໍລິການ MCP ແລ້ວ ແຕ່ການເຊື່ອມໂຍງເວັບໄຊລົ້ມເຫຼວ. ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າເວັບໄຊໃນການຕັ້ງຄ່າບໍລິການ."
MCPInstallInterrupted: "ການເລີ່ມແຜງຄວບຄຸມໃໝ່ເຮັດໃຫ້ການຕິດຕັ້ງຢຸດຊະງັກ. ກະລຸນາກວດສອບສະຖານະບໍລິການກ່ອນດຳເນີນການຕໍ່."
MCPInstallFailed: "ການຕິດຕັ້ງ MCP ລົ້ມເຫຼວ. ກະລຸນາກວດສອບ Docker, ອິມເມດ ແລະ ບັນທຶກບໍລິການແຜງຄວບຄຸມ."
MCPInstallRecoveryFailed: "ການຕິດຕັ້ງລົ້ມເຫຼວ ແລະ ບໍລິການເດີມບໍ່ສາມາດກູ້ຄືນໄດ້ຄົບຖ້ວນ. ກະລຸນາກວດສອບສະຖານະບໍລິການ MCP ກ່ອນດຳເນີນການຕໍ່."
MCPInstallLocalImage: "ມີອິມເມດຢູ່ໃນເຄື່ອງແລ້ວ; ຂ້າມການດາວໂຫຼດ"
MCPInstallVerifyImage: "ກວດສອບເວີຊັນອິມເມດ ແລະ ສັນຍາບໍລິການທີ່ຈັດການ"
MCPInstallVerifiedImage: "ກວດສອບອິມເມດຜ່ານແລ້ວ"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Token MCP yang disimpan tidak dapat dinyahsulit. Jana semu
ErrMCPCredentialInactive: "Kelayakan MCP telah dinyahaktifkan, tamat tempoh atau dibatalkan." ErrMCPCredentialInactive: "Kelayakan MCP telah dinyahaktifkan, tamat tempoh atau dibatalkan."
ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan." ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan."
ErrMCPEndpointUnavailable: "Konfigurasikan alamat luaran MCP yang sah terlebih dahulu." 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"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Não foi possível descriptografar o Token MCP armazenado.
ErrMCPCredentialInactive: "A credencial MCP está desabilitada, expirada ou revogada." 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." 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." 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"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Не удалось расшифровать сохран
ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны." ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны."
ErrMCPAuditUnavailable: "Аудит операций MCP недоступен. Проверьте результат операции, прежде чем продолжить." ErrMCPAuditUnavailable: "Аудит операций MCP недоступен. Проверьте результат операции, прежде чем продолжить."
ErrMCPEndpointUnavailable: "Сначала настройте корректный внешний адрес MCP." ErrMCPEndpointUnavailable: "Сначала настройте корректный внешний адрес MCP."
ErrMCPWebsitePending: "Результат операции с сайтом не подтверждён. Обновите состояние привязки перед повтором исходной операции."
ErrMCPWebsiteUnavailable: "Привязка сайта недоступна. Проверьте состояние сайта и настройки OpenResty."
ErrMCPWebsiteServiceRequired: "Сначала установите службу 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Проверить текущую службу MCP"
MCPInstallPullImage: "Проверить и загрузить образ"
MCPInstallCatalog: "Проверить контракт инструментов"
MCPInstallStarting: "Запустить контейнер и проверить его работоспособность"
MCPInstallSaving: "Сохранить конфигурацию службы"
MCPInstallCompleted: "Установка службы MCP завершена"
MCPInstallRollback: "Установка не удалась; восстановить предыдущую службу MCP"
MCPInstallBindWebsite: "Создать привязанный сайт"
MCPInstallWebsiteFailed: "Служба MCP установлена, но привязать сайт не удалось. Исправьте конфигурацию сайта в настройках службы."
MCPInstallInterrupted: "Перезапуск панели прервал установку. Проверьте состояние службы перед продолжением."
MCPInstallFailed: "Не удалось установить MCP. Проверьте Docker, образ и журналы службы панели."
MCPInstallRecoveryFailed: "Установка не удалась, а предыдущую службу не удалось полностью восстановить. Проверьте состояние службы MCP перед продолжением."
MCPInstallLocalImage: "Образ уже есть локально; пропустить загрузку"
MCPInstallVerifyImage: "Проверить версию образа и контракт управляемой службы"
MCPInstallVerifiedImage: "Образ успешно проверен"
+21
View File
@@ -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ş." 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." 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." 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ı"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "儲存的 MCP Token 無法解密,請重新產生 Token
ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。" ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。"
ErrMCPAuditUnavailable: "MCP 操作稽核暫時無法使用,請核對操作結果後再繼續。" ErrMCPAuditUnavailable: "MCP 操作稽核暫時無法使用,請核對操作結果後再繼續。"
ErrMCPEndpointUnavailable: "請先設定有效的 MCP 外部連線位址。" ErrMCPEndpointUnavailable: "請先設定有效的 MCP 外部連線位址。"
ErrMCPWebsitePending: "網站操作結果待確認,請重新整理綁定狀態後重試原操作。"
ErrMCPWebsiteUnavailable: "網站綁定無法使用,請檢查網站狀態和 OpenResty 設定。"
ErrMCPWebsiteServiceRequired: "請先安裝 1Panel MCP 服務。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "檢查目前的 MCP 服務"
MCPInstallPullImage: "檢查並拉取映像"
MCPInstallCatalog: "驗證工具契約"
MCPInstallStarting: "啟動容器並檢查健康狀態"
MCPInstallSaving: "儲存服務設定"
MCPInstallCompleted: "MCP 服務安裝完成"
MCPInstallRollback: "安裝失敗,還原先前的 MCP 服務"
MCPInstallBindWebsite: "建立綁定網站"
MCPInstallWebsiteFailed: "MCP 服務已安裝,但網站綁定失敗。請在服務設定中處理網站設定。"
MCPInstallInterrupted: "面板重新啟動導致安裝中斷。請檢查服務狀態後再操作。"
MCPInstallFailed: "MCP 安裝失敗。請檢查 Docker、映像及面板服務日誌。"
MCPInstallRecoveryFailed: "安裝失敗且原服務未能完整還原,請檢查 MCP 服務狀態後再操作。"
MCPInstallLocalImage: "本機映像已存在,略過下載"
MCPInstallVerifyImage: "驗證映像版本及受管契約"
MCPInstallVerifiedImage: "映像驗證通過"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "存储的 MCP Token 无法解密,请重新生成 Token
ErrMCPCredentialInactive: "MCP 凭据已停用、过期或撤销。" ErrMCPCredentialInactive: "MCP 凭据已停用、过期或撤销。"
ErrMCPAuditUnavailable: "MCP 操作审计暂不可用,请核对操作结果后再继续。" ErrMCPAuditUnavailable: "MCP 操作审计暂不可用,请核对操作结果后再继续。"
ErrMCPEndpointUnavailable: "请先配置有效的 MCP 外部接入地址。" ErrMCPEndpointUnavailable: "请先配置有效的 MCP 外部接入地址。"
ErrMCPWebsitePending: "网站操作结果待确认,请刷新绑定状态后重试原操作。"
ErrMCPWebsiteUnavailable: "网站绑定不可用,请检查网站状态和 OpenResty 配置。"
ErrMCPWebsiteServiceRequired: "请先安装 1Panel MCP 服务。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "检查当前 MCP 服务"
MCPInstallPullImage: "检查并拉取镜像"
MCPInstallCatalog: "校验工具契约"
MCPInstallStarting: "启动容器并检查健康状态"
MCPInstallSaving: "保存服务配置"
MCPInstallCompleted: "MCP 服务安装完成"
MCPInstallRollback: "安装失败,恢复之前的 MCP 服务"
MCPInstallBindWebsite: "创建绑定网站"
MCPInstallWebsiteFailed: "MCP 服务已安装,但网站绑定失败。请在服务设置中处理网站配置。"
MCPInstallInterrupted: "面板重启导致安装中断。请检查服务状态后再操作。"
MCPInstallFailed: "MCP 安装失败。请检查 Docker、镜像和面板服务日志。"
MCPInstallRecoveryFailed: "安装失败且原服务未能完整恢复,请检查 MCP 服务状态后再操作。"
MCPInstallLocalImage: "本地镜像已存在,跳过下载"
MCPInstallVerifyImage: "校验镜像版本和受管契约"
MCPInstallVerifiedImage: "镜像校验通过"
+1
View File
@@ -72,5 +72,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrder, migrations.UpdateAiMenuOrder,
migrations.UpdateAiMenuOrderV2, migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP, migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
} }
} }
@@ -15,3 +15,8 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key", ID: "20260915-add-operation-log-api-key",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) }, Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
} }
var AddAPIKeyDeletedAt = &gormigrate.Migration{
ID: "20261028-api-key-deleted-at",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
}
+4 -1
View File
@@ -7,8 +7,11 @@ import (
) )
var AddPanelMCP = &gormigrate.Migration{ var AddPanelMCP = &gormigrate.Migration{
ID: "20261008-panel-mcp", ID: "20261009-panel-mcp",
Migrate: func(tx *gorm.DB) error { 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{}) return tx.AutoMigrate(&model.MCPCredential{}, &model.MCPGrant{}, &model.MCPInvocation{}, &model.MCPSetting{})
}, },
} }
+32
View File
@@ -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"
}

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