Compare commits

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

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
139 changed files with 6323 additions and 2373 deletions
-16
View File
@@ -8,7 +8,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -161,25 +160,10 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) {
// @Router /apps/install [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"}
func (b *BaseApi) InstallApp(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
req.MCPPreserveWebsiteDir = true
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
}
install, err := appService.Install(req, true)
if err != nil {
helper.InternalServer(c, err)
-18
View File
@@ -1,11 +1,7 @@
package v2
import (
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -340,25 +336,11 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) {
// @Security Timestamp
// @Router /apps/installed/info/:appInstallId [get]
func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
appInstallId, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
return
}
info, err := appInstallService.GetAppInstallInfo(appInstallId)
if err != nil {
helper.InternalServer(c, err)
@@ -1,43 +0,0 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) GetContainerImageCheckUpdateStatus(c *gin.Context) {
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, status)
}
func (b *BaseApi) ContainerImageCheckUpdate(c *gin.Context) {
taskID, err := service.StartContainerImageCheckUpdate()
if err != nil {
helper.InternalServer(c, err)
return
}
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
status.TaskID = taskID
helper.SuccessWithData(c, status)
}
func (b *BaseApi) SetContainerImageCheckUpdateSchedule(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := service.SetContainerImageCheckUpdateSchedule(*req.Enabled); err != nil {
helper.InternalServer(c, err)
return
}
b.GetContainerImageCheckUpdateStatus(c)
}
-17
View File
@@ -2,12 +2,9 @@ package v2
import (
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -101,25 +98,11 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) {
// @Param type path string true "type"
// @Router /databases/db/list/:type [get]
func (b *BaseApi) ListDatabase(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
if mcpRequest {
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
return
}
list, err := databaseService.List(dbType)
if err != nil {
helper.InternalServer(c, err)
-33
View File
@@ -3,12 +3,9 @@ package v2
import (
"context"
"encoding/base64"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
@@ -22,11 +19,6 @@ import (
// @Router /databases [post]
// @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"}
func (b *BaseApi) CreateMysql(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
@@ -40,17 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
}
req.Password = string(password)
}
if mcpRequest {
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
return
}
if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err)
return
@@ -531,25 +512,11 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) {
// @Security Timestamp
// @Router /databases/status [post]
func (b *BaseApi) LoadStatus(c *gin.Context) {
mcpRequest, trustedMCP := mcpapi.Check(c.Request)
if mcpRequest && !trustedMCP {
c.AbortWithStatus(http.StatusForbidden)
return
}
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if mcpRequest {
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
return
}
data, err := mysqlService.LoadStatus(req)
if err != nil {
helper.InternalServer(c, err)
+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/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := taskService.Page(req)
total, list, err := taskServiceForRequest(c).Page(req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return
}
var res *response.FileLineContent
res, err := taskService.ReadByLine(req)
res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil {
helper.InternalServer(c, err)
return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp
// @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask()
count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, count)
}
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
+7 -10
View File
@@ -21,16 +21,13 @@ type InspectReq struct {
}
type ContainerInfo struct {
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
Network []string `json:"network"`
Ports []string `json:"ports"`
-2
View File
@@ -27,8 +27,6 @@ type AppInstallCreate struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
MCPPreserveWebsiteDir bool `json:"-"`
AppContainerConfig
NodePushConfig
}
+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"`
}
@@ -1,20 +0,0 @@
package model
import "time"
// ContainerImageCheckUpdate is a cache tied to the exact image used by a container.
type ContainerImageCheckUpdate struct {
ContainerID string `gorm:"primaryKey" json:"containerID"`
ImageID string `json:"imageID"`
ImageName string `json:"imageName"`
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
TargetImageID string `json:"-"`
HasUpdate bool `gorm:"not null;default:false" json:"hasUpdate"`
CheckedAt time.Time `json:"checkedAt"`
CheckStatus string `json:"checkStatus"`
CheckError string `json:"checkError"`
}
// TableName preserves the table created by the original migration.
func (ContainerImageCheckUpdate) TableName() string { return "container_image_updates" }
+7
View File
@@ -38,6 +38,7 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
}
func NewIAppInstallRepo() IAppInstallRepo {
@@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err
}
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error
+29
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{}
}
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database
db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error
}
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
}
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil
}
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error
}
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error
}
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
+19
View File
@@ -15,10 +15,13 @@ type SettingRepo struct{}
type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error)
Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error
@@ -67,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err
}
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
+19 -6
View File
@@ -19,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error
CountExecutingTask() (int64, error)
UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error
DeleteAll() error
@@ -29,6 +29,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
}
func NewITaskRepo() ITaskRepo {
@@ -110,18 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error
}
func (t TaskRepo) UpdateRunningTaskToFailed() error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error
}
func (t TaskRepo) CountExecutingTask() (int64, error) {
func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error
opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err
}
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB
for _, opt := range opts {
+3 -2
View File
@@ -59,7 +59,8 @@ type IAppService interface {
}
type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error
AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
}
func NewIAppService() IAppService {
@@ -614,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
}
}
if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil {
if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err
}
if err = handleOpenrestyFile(appInstall); err != nil {
+10 -44
View File
@@ -1,53 +1,19 @@
package service
import (
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/task"
)
func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error {
changed := errors.New("MCP website directory changed; inspect the installation before retrying")
directory, ok := req.Params["WEBSITE_DIR"].(string)
if !ok || directory == "" || global.DB == nil {
return changed
func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
}
func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
if !managesOpenrestySiteDirectory(app, detail) {
return nil
}
return global.DB.Transaction(func(tx *gorm.DB) error {
var setting model.Setting
err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil {
return err
}
} else if setting.Value == "" {
result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return changed
}
}
if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil {
return err
}
return nil
})
directory, _ := req.Params["WEBSITE_DIR"].(string)
return ensureCurrentWebsiteDirectory(directory)
}
+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
}
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") {
if req.MCPPreserveWebsiteDir {
return prepareMCPWebsiteDirectory(req)
}
if managesOpenrestySiteDirectory(app, appDetail) {
if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err
}
nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil {
if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err
}
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+11 -26
View File
@@ -140,29 +140,6 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
backData = records[start:end]
}
// Batch-load persisted checks; never contact Docker Hub while rendering the list.
ids := make([]string, 0, len(backData))
for _, row := range backData {
ids = append(ids, row.ContainerID)
}
if len(ids) > 0 {
var checks []model.ContainerImageCheckUpdate
if err := global.DB.Where("container_id IN ?", ids).Find(&checks).Error; err != nil {
return 0, nil, err
}
byID := make(map[string]model.ContainerImageCheckUpdate, len(checks))
for _, check := range checks {
byID[check.ContainerID] = check
}
for i := range backData {
check, ok := byID[backData[i].ContainerID]
if ok && sameContainerImage(check.ImageID, backData[i].ImageId) && check.ImageName == backData[i].ImageName {
backData[i].LatestVersion = check.LatestVersion
backData[i].TargetImage = check.TargetImage
backData[i].HasUpdate = check.HasUpdate
}
}
}
for i := 0; i < len(backData); i++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
if install.ID > 0 {
@@ -1683,6 +1660,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
}
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 {
var err error
client, err = docker.NewDockerClient()
@@ -1690,7 +1671,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false
}
}
images, err := client.ImageList(context.Background(), image.ListOptions{})
images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil {
return false
}
@@ -1732,6 +1713,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
}
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)
repos, err := imageRepoRepo.List()
if err != nil {
@@ -1739,7 +1724,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
}
imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName)
return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
}
options := image.PullOptions{}
@@ -1752,7 +1737,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err
}
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 {
@@ -1,379 +0,0 @@
package service
import (
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const imageCheckUpdateSetting = "ContainerImageUpdateEnabled"
type ImageCheckUpdateStatus struct {
TaskID string `json:"taskID"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Checked int `json:"checked"`
Updates int `json:"updates"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"`
}
var imageCheckUpdateState struct {
sync.Mutex
status ImageCheckUpdateStatus
}
func GetContainerImageCheckUpdateStatus() (ImageCheckUpdateStatus, error) {
var setting model.Setting
if err := global.DB.Where("key = ?", imageCheckUpdateSetting).First(&setting).Error; err != nil {
return ImageCheckUpdateStatus{}, err
}
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
status := imageCheckUpdateState.status
status.Enabled = setting.Value == "Enable"
return status, nil
}
func SetContainerImageCheckUpdateSchedule(enabled bool) error {
value := "Disable"
if enabled {
value = "Enable"
}
return global.DB.Model(&model.Setting{}).Where("key = ?", imageCheckUpdateSetting).Update("value", value).Error
}
// StartContainerImageCheckUpdate coalesces manual and scheduled runs on this node.
func StartContainerImageCheckUpdate() (string, error) {
imageCheckUpdateState.Lock()
defer imageCheckUpdateState.Unlock()
if imageCheckUpdateState.status.Running {
return imageCheckUpdateState.status.TaskID, nil
}
taskItem, err := task.NewTask(i18n.GetMsgByKey("ContainerImageCheckUpdate"), task.TaskScan, task.TaskScopeImage, "", 0)
if err != nil {
return "", err
}
// Persist before returning the ID so the log drawer can immediately find it.
if err := global.TaskDB.Save(taskItem.Task).Error; err != nil {
_ = taskItem.Logger.Out.(io.Closer).Close()
taskItem.DeleteLogFile()
if cancel, ok := global.LoadTaskCancel(taskItem.TaskID); ok {
cancel()
}
global.RemoveTaskCancel(taskItem.TaskID)
return "", err
}
imageCheckUpdateState.status = ImageCheckUpdateStatus{Running: true, TaskID: taskItem.TaskID}
go func() {
result := ImageCheckUpdateStatus{TaskID: taskItem.TaskID}
defer func() {
result.FinishedAt = time.Now().UnixMilli()
imageCheckUpdateState.Lock()
imageCheckUpdateState.status = result
imageCheckUpdateState.Unlock()
}()
// The action owns its deadline so the task runner waits for cleanup and logs.
taskItem.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerImageCheckUpdate"), func(t *task.Task) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("image update check failed: %v", recovered)
}
t.Log(i18n.GetMsgWithMap("ContainerImageCheckSummary", map[string]interface{}{
"checked": result.Checked, "updates": result.Updates, "failed": result.Failed, "skipped": result.Skipped,
}))
}()
ctx, cancel := context.WithTimeout(t.TaskCtx, 30*time.Minute)
defer cancel()
if err = runContainerImageCheckUpdate(ctx, &result, t.Log); err != nil {
return err
}
if result.Failed > 0 {
return fmt.Errorf("%s", i18n.GetMsgWithMap("ContainerImageCheckFailures", map[string]interface{}{"count": result.Failed}))
}
return nil
}, nil, 0, 0)
if err := taskItem.Execute(); err != nil {
result.Error = err.Error()
global.LOG.Errorf("container image check: %s", err)
}
}()
return taskItem.TaskID, nil
}
// These caches live only for one check. Remote results do not depend on a
// container's local image ID, which is compared separately for every baseline.
type imageCheckUpdateRepository struct {
client imageCheckUpdateHubClient
err error
tags []string
tagsLoaded bool
tagsErr error
}
type imageCheckUpdateTargetKey struct {
repository, tag string
platform dockerhub.Platform
}
type imageCheckUpdateTargetResult struct {
target dockerhub.Target
compatible bool
err error
}
type imageCheckUpdateResult struct {
record model.ContainerImageCheckUpdate
err error
}
type imageCheckUpdateCache struct {
username, password string
repositories map[string]*imageCheckUpdateRepository
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
}
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
log := func(key string, values map[string]interface{}) {
if logger != nil {
logger(i18n.GetMsgWithMap(key, values))
}
}
cli, err := newImageCheckUpdateDockerClient()
if err != nil {
return err
}
defer cli.Close()
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return err
}
log("ContainerImageCheckScope", map[string]interface{}{"count": len(containers)})
// Load old results once, rather than issuing one SELECT for every failed check.
var previousRows []model.ContainerImageCheckUpdate
if err := global.DB.WithContext(ctx).Find(&previousRows).Error; err != nil {
return err
}
previous := make(map[string]model.ContainerImageCheckUpdate, len(previousRows))
for _, row := range previousRows {
previous[row.ContainerID] = row
}
cache := imageCheckUpdateCache{repositories: map[string]*imageCheckUpdateRepository{}, targets: map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult{}}
cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg != nil {
for _, host := range []string{"https://index.docker.io/v1/", "docker.io", "registry-1.docker.io"} {
auth, e := cfg.GetAuthConfig(host)
if e == nil && auth.Username != "" {
cache.username, cache.password = auth.Username, auth.Password
break
}
}
}
checks := map[string]imageCheckUpdateResult{}
ids := make([]string, 0, len(containers))
names := make(map[string]string, len(containers))
pending := make([]model.ContainerImageCheckUpdate, 0, 100)
for _, item := range containers {
if err := ctx.Err(); err != nil {
return err
}
ids = append(ids, item.ID)
name := item.ID
if len(item.Names) > 0 {
name = strings.TrimPrefix(item.Names[0], "/")
}
names[item.ID] = name
log("ContainerImageCheckItem", map[string]interface{}{"name": name, "image": item.Image})
repository, tag, supported := dockerhub.Reference(item.Image)
key := repository + ":" + tag + "@" + item.ImageID
entry, found := checks[key]
if !found {
entry.record, entry.err = checkContainerImageUpdate(ctx, cli, item, repository, tag, supported, &cache)
checks[key] = entry
}
record := entry.record
record.ContainerID, record.ImageName = item.ID, item.Image
if entry.err != nil {
record.CheckStatus, record.CheckError = "failed", entry.err.Error()
old := previous[item.ID]
if old.ImageID == item.ImageID && old.ImageName == item.Image {
record.LatestVersion, record.TargetImage, record.TargetImageID, record.HasUpdate = old.LatestVersion, old.TargetImage, old.TargetImageID, old.HasUpdate
}
}
pending = append(pending, record)
if len(pending) == 100 {
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
pending = pending[:0]
}
}
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
return err
}
query := global.DB.WithContext(ctx).Where("1 = 1")
if len(ids) > 0 {
query = query.Where("container_id NOT IN ?", ids)
}
return query.Delete(&model.ContainerImageCheckUpdate{}).Error
}
func checkContainerImageUpdate(ctx context.Context, cli imageCheckUpdateDockerClient, item container.Summary, repository, tag string, supported bool, cache *imageCheckUpdateCache) (model.ContainerImageCheckUpdate, error) {
record := model.ContainerImageCheckUpdate{ImageID: item.ImageID, ImageName: item.Image, CheckStatus: "success", CheckedAt: time.Now()}
if !supported {
record.CheckStatus = "skipped"
return record, nil
}
inspected, err := cli.ImageInspect(ctx, item.ImageID)
if err != nil {
return record, err
}
repo, loaded := cache.repositories[repository]
if !loaded {
repo = &imageCheckUpdateRepository{}
repo.client, repo.err = newImageCheckUpdateHubClient(ctx, repository, cache.username, cache.password)
cache.repositories[repository] = repo
}
if repo.err != nil {
return record, repo.err
}
if dockerhub.NeedsTags(tag) {
if !repo.tagsLoaded {
repo.tags, repo.tagsErr = repo.client.Tags(ctx)
repo.tagsLoaded = true
}
if repo.tagsErr != nil {
return record, repo.tagsErr
}
}
platform := dockerhub.Platform{OS: inspected.Os, Architecture: inspected.Architecture, Variant: inspected.Variant}
for _, candidate := range dockerhub.Candidates(tag, repo.tags) {
key := imageCheckUpdateTargetKey{repository: repository, tag: candidate, platform: platform}
remote, loaded := cache.targets[key]
if !loaded {
remote.target, remote.compatible, remote.err = repo.client.Resolve(ctx, candidate, platform)
cache.targets[key] = remote
}
if remote.err != nil {
return record, remote.err
}
if !remote.compatible {
continue
}
record.LatestVersion = remote.target.Tag
record.TargetImage = "docker.io/" + repository + ":" + remote.target.Tag
record.TargetImageID = remote.target.ConfigDigest
record.HasUpdate = remote.target.Tag != tag || remote.target.ConfigDigest != inspected.ID
return record, nil
}
return record, fmt.Errorf("no compatible image for %s/%s", platform.OS, platform.Architecture)
}
// Revalidate immediately before each small write batch. Never hold a database
// transaction open while waiting for Docker or Docker Hub.
func saveContainerImageCheckUpdates(ctx context.Context, cli imageCheckUpdateDockerClient, pending []model.ContainerImageCheckUpdate, names map[string]string, result *ImageCheckUpdateStatus, log func(string, map[string]interface{})) error {
records := make([]model.ContainerImageCheckUpdate, 0, len(pending))
for _, record := range pending {
current, err := cli.ContainerInspect(ctx, record.ContainerID)
if err != nil && !errdefs.IsNotFound(err) {
return err
}
if err != nil || current.Image != record.ImageID {
result.Skipped++
log("ContainerImageCheckChanged", map[string]interface{}{"name": names[record.ContainerID]})
continue
}
records = append(records, record)
}
if len(records) == 0 {
return nil
}
if err := global.DB.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).CreateInBatches(&records, 100).Error; err != nil {
return err
}
// Count and announce only results that were successfully persisted.
for _, record := range records {
values := map[string]interface{}{"name": names[record.ContainerID], "image": record.ImageName}
switch record.CheckStatus {
case "failed":
result.Failed++
values["error"] = record.CheckError
log("ContainerImageCheckFailed", values)
case "skipped":
result.Skipped++
log("ContainerImageCheckSkipped", values)
default:
result.Checked++
if record.HasUpdate {
result.Updates++
values["target"] = record.TargetImage
log("ContainerImageCheckAvailable", values)
} else {
log("ContainerImageCheckCurrent", values)
}
}
}
return nil
}
// sameContainerImage guards cached results after external image changes.
func sameContainerImage(stored, displayed string) bool {
return strings.TrimPrefix(stored, "sha256:") == strings.TrimPrefix(displayed, "sha256:")
}
// Reconcile only after Docker confirms the replacement container uses the target.
func reconcileContainerImageCheckUpdate(oldID, newID, imageID, imageName string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
var previous model.ContainerImageCheckUpdate
if err := tx.Where("container_id = ?", oldID).Find(&previous).Error; err != nil {
return err
}
if previous.ContainerID == "" {
return nil
}
if err := tx.Where("container_id = ?", oldID).Delete(&model.ContainerImageCheckUpdate{}).Error; err != nil {
return err
}
if previous.TargetImageID != imageID {
return nil
}
previous.ContainerID = newID
previous.ImageID = imageID
previous.ImageName = imageName
previous.HasUpdate = false
previous.CheckStatus = "success"
previous.CheckError = ""
previous.CheckedAt = time.Now()
return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&previous).Error
})
}
type imageCheckUpdateDockerClient interface {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
Close() error
}
type imageCheckUpdateHubClient interface {
Tags(context.Context) ([]string, error)
Resolve(context.Context, string, dockerhub.Platform) (dockerhub.Target, bool, error)
}
var newImageCheckUpdateDockerClient = func() (imageCheckUpdateDockerClient, error) { return docker.NewDockerClient() }
var newImageCheckUpdateHubClient = func(ctx context.Context, repository, username, password string) (imageCheckUpdateHubClient, error) {
return dockerhub.New(ctx, repository, username, password)
}
+270 -76
View File
@@ -91,86 +91,95 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
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 {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil {
_ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
defer client.Close()
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull {
if err := pullImages(taskItem, client, req.Image); err != nil {
if !req.ForcePull {
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
}
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
return fmt.Errorf("pull image %s failed: %w", 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
}, 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))
unlock := containerOperationLock.lock(item)
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
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 updated, inspectErr := client.ContainerInspect(ctx, item); inspectErr == nil {
if cacheErr := reconcileContainerImageCheckUpdate(oldContainer.ID, updated.ID, updated.Image, updated.Config.Image); cacheErr != nil {
global.LOG.Warnf("refresh container image update state: %v", cacheErr)
}
}
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())
}
}()
})
}
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...)
}, nil)
// Stage contexts own the deadlines, including a separate recovery budget.
for _, subTask := range taskItem.SubTasks {
subTask.Timeout = 0
}
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
return err
}
return nil
}
@@ -197,11 +206,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct {
mutex sync.Mutex
token chan struct{}
references int
}
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))
for _, name := range names {
if name != "" {
@@ -214,7 +228,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
sort.Strings(orderedNames)
if len(orderedNames) == 0 {
return func() {}
return func() {}, nil
}
l.mutex.Lock()
@@ -225,7 +239,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames {
entry := l.locks[name]
if entry == nil {
entry = &containerOperationLockEntry{}
entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry
}
entry.references++
@@ -233,12 +247,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
}
l.mutex.Unlock()
for _, entry := range entries {
entry.mutex.Lock()
}
return func() {
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
acquired := 0
release := func() {
for index := acquired - 1; index >= 0; index-- {
<-entries[index].token
}
l.mutex.Lock()
defer l.mutex.Unlock()
@@ -250,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 {
@@ -277,6 +303,131 @@ func switchContainer(
createNew func() (container.CreateResponse, error),
tty bool,
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) {
if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty")
@@ -286,9 +437,32 @@ func switchContainer(
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 err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
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)
if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -303,6 +477,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -314,6 +491,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr)
}
@@ -321,11 +501,17 @@ func switchContainer(
created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr)
}
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
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)
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)
@@ -334,6 +520,9 @@ func switchContainer(
if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil {
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)
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)
@@ -341,6 +530,11 @@ func switchContainer(
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})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
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) {
return u.create(ctx, req, false)
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
req = creation.request
cli, version, err := LoadMysqlClientByFrom(req.Database)
if err != nil {
return nil, err
}
defer cli.Close()
if err := cli.Create(creation.createInfo(version)); err != nil {
return nil, err
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(creation.dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range creation.permissionHosts {
if err := saveDatabaseUserGrant(creation.dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) {
type mysqlCreation struct {
request dto.MysqlDBCreate
dbType string
permissionHosts []string
item model.DatabaseMysql
}
func prepareMysqlCreation(req dto.MysqlDBCreate) (*mysqlCreation, error) {
if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) || strings.Contains(req.Password, " ") {
return nil, buserr.New("ErrCmdIllegal")
}
@@ -474,13 +509,13 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
return nil, errors.New("cannot set root as user name")
}
cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded)
if err != nil {
return nil, err
}
createItem.MysqlName = req.Database
defer cli.Close()
createInfo := client.CreateInfo{
return &mysqlCreation{request: req, dbType: dbType, permissionHosts: permissionHosts, item: createItem}, nil
}
func (creation *mysqlCreation) createInfo(version string) client.CreateInfo {
req := creation.request
return client.CreateInfo{
Name: req.Name,
Format: req.Format,
Collation: req.Collation,
@@ -490,55 +525,16 @@ func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBou
Version: version,
Timeout: 300,
}
if mcpBounded {
local, ok := cli.(*client.Local)
if !ok {
return nil, errors.New("an installed local MySQL instance is required")
}
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, createInfo)
cancel()
} else {
err = cli.Create(createInfo)
}
if err != nil {
return nil, err
}
if mcpBounded {
if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
if len(req.Username) != 0 {
if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil {
return nil, err
}
for _, host := range permissionHosts {
if err := saveDatabaseUserGrant(dbType, req.Database, req.Name, req.Username, host); err != nil {
return nil, err
}
}
}
global.LOG.Infof("create database %s successful!", req.Name)
if err := mysqlRepo.Create(ctx, &createItem); err != nil {
return nil, err
}
return &createItem, nil
}
func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
func saveMysqlCreationMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txCtx := context.WithValue(ctx, constant.DB, tx)
if req.Username != "" {
key := global.CONF.Base.EncryptKey
if key == "" {
var setting model.Setting
if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil {
setting, err := settingRepo.GetWithContext(txCtx, repo.WithByKey("EncryptKey"))
if err != nil {
return err
}
key = setting.Value
@@ -548,8 +544,7 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
return err
}
for _, host := range hosts {
var user model.DatabaseUser
err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error
user, err := databaseUserRepo.GetEncryptedWithContext(txCtx, repo.WithByType("mysql"), databaseUserRepo.WithByDatabase(req.Database), databaseUserRepo.WithByUser(req.Username, host))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
@@ -558,16 +553,16 @@ func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts [
if req.Description != "" {
user.Description = req.Description
}
if err := tx.Save(&user).Error; err != nil {
if err := databaseUserRepo.SaveEncryptedWithContext(txCtx, &user); err != nil {
return err
}
grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host}
if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil {
if err := databaseUserGrantRepo.CreateIfAbsent(txCtx, &grant); err != nil {
return err
}
}
}
return tx.Create(item).Error
return mysqlRepo.CreateMetadata(txCtx, item)
})
}
@@ -1603,10 +1598,34 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{
}
func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) {
return loadMysqlClientByFrom(database, false)
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
return newMysqlClientForDatabase(databaseItem, database)
}
func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) {
func loadInstalledLocalMysqlClient(database string) (*client.Local, string, error) {
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0 {
return nil, "", errors.New("an installed local MySQL instance is required")
}
cli, version, err := newMysqlClientForDatabase(databaseItem, database)
if err != nil {
return nil, "", err
}
local, ok := cli.(*client.Local)
if !ok {
cli.Close()
return nil, "", errors.New("an installed local MySQL instance is required")
}
return local, version, nil
}
func newMysqlClientForDatabase(databaseItem model.Database, database string) (mysql.MysqlClient, string, error) {
var (
dbInfo client.DBInfo
version string
@@ -1614,13 +1633,6 @@ func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClien
)
dbInfo.Timeout = 300
databaseItem, err := databaseRepo.Get(repo.WithByName(database))
if err != nil {
return nil, "", err
}
if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) {
return nil, "", errors.New("an installed local MySQL instance is required")
}
dbInfo.Type = databaseItem.Type
dbInfo.From = databaseItem.From
dbInfo.Database = database
+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)
defer cancel()
created, err := u.create(writeContext, req, true)
created, err := createMCPMysqlDatabase(writeContext, req)
if err != nil {
return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying")
}
return created, nil
}
func createMCPMysqlDatabase(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) {
creation, err := prepareMysqlCreation(req)
if err != nil {
return nil, err
}
local, version, err := loadInstalledLocalMysqlClient(creation.request.Database)
if err != nil {
return nil, err
}
defer local.Close()
deadline := time.Now().Add(300 * time.Second)
if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) {
deadline = writeDeadline.Add(-10 * time.Second)
}
sqlContext, cancel := context.WithDeadline(ctx, deadline)
err = local.CreateWithContext(sqlContext, creation.createInfo(version))
cancel()
if err != nil {
return nil, err
}
if err := saveMysqlCreationMetadata(ctx, creation.request, creation.permissionHosts, &creation.item); err != nil {
return nil, err
}
return &creation.item, nil
}
+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/repo"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
)
type TaskLogService struct{}
type TaskLogService struct {
grantedScopes []string
}
type ITaskLogService interface {
Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error)
@@ -19,13 +22,18 @@ type ITaskLogService interface {
CountExecutingTask() (int64, error)
}
func NewITaskService() ITaskLogService {
return &TaskLogService{}
func NewITaskService(grantedScopes ...string) ITaskLogService {
return &TaskLogService{grantedScopes: append([]string(nil), grantedScopes...)}
}
func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, error) {
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, nil, err
}
opts := []repo.DBOption{
repo.WithOrderDesc("created_at"),
taskRepo.WithoutScopes(excluded...),
}
if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID))
@@ -53,7 +61,11 @@ func (u *TaskLogService) Page(req dto.SearchTaskLogReq) (int64, []dto.TaskDTO, e
}
func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileLineContent, error) {
opts := []repo.DBOption{}
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return nil, err
}
opts := []repo.DBOption{taskRepo.WithoutScopes(excluded...)}
if req.TaskID != "" {
opts = append(opts, taskRepo.WithByID(req.TaskID))
} else {
@@ -112,9 +124,17 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
}
func (u *TaskLogService) SyncForRestart() error {
return taskRepo.UpdateRunningTaskToFailed()
excluded, err := taskscope.RecoveryExclusions()
if err != nil {
return err
}
return taskRepo.UpdateRunningTaskToFailed(taskRepo.WithoutScopes(excluded...))
}
func (u *TaskLogService) CountExecutingTask() (int64, error) {
return taskRepo.CountExecutingTask()
excluded, err := taskscope.ReadExclusions(u.grantedScopes...)
if err != nil {
return 0, err
}
return taskRepo.CountExecutingTask(taskRepo.WithoutScopes(excluded...))
}
+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"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@@ -23,6 +25,63 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/re"
)
func websiteProxyPathAllowed(directory, name string) bool {
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00") {
return false
}
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil || !websiteManagedConfigs.pathAllowed(resolvedDirectory) {
return false
}
for _, extension := range []string{".conf", ".bak"} {
filename := filepath.Join(directory, name+extension)
if info, statErr := os.Lstat(filename); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return false
}
resolved, resolveErr := websiteProxyResolvePath(filename)
if resolveErr != nil || !websiteProxyPathWithin(resolved, resolvedDirectory) || !websiteManagedConfigs.pathAllowed(resolved) {
return false
}
}
return true
}
func websiteProxyDirectoryAllowed(directory string) bool {
resolved, err := websiteProxyResolvePath(directory)
return err == nil && websiteManagedConfigs.pathAllowed(resolved)
}
func websiteProxyPathWithin(target, directory string) bool {
resolvedDirectory, err := websiteProxyResolvePath(directory)
if err != nil {
return false
}
relative, err := filepath.Rel(resolvedDirectory, target)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func websiteProxyResolvePath(target string) (string, error) {
absolute, err := filepath.Abs(target)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
return resolved, nil
}
if !os.IsNotExist(err) || filepath.Dir(absolute) == absolute {
return "", err
}
if info, statErr := os.Lstat(absolute); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("invalid proxy symlink")
}
parent, err := websiteProxyResolvePath(filepath.Dir(absolute))
if err != nil {
return "", err
}
return filepath.Join(parent, filepath.Base(absolute)), nil
}
func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error) {
switch req.Operate {
case "delete":
@@ -51,6 +110,9 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
}
fileOp := files.NewFileOp()
includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) {
_ = fileOp.CreateDir(includeDir, constant.DirPerm)
}
@@ -270,6 +332,9 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
}
includeDir := GetSitePath(website, SiteProxyDir)
fileOp := files.NewFileOp()
if !websiteProxyDirectoryAllowed(includeDir) {
return nil, errors.New("invalid proxy path")
}
if !fileOp.Stat(includeDir) {
return
}
@@ -282,12 +347,16 @@ func (w WebsiteService) GetProxies(id uint) (res []request.WebsiteProxyConfig, e
config *components.Config
)
for _, configFile := range fileList.Items {
extension := filepath.Ext(configFile.Name)
name := strings.TrimSuffix(configFile.Name, extension)
if configFile.IsDir || (extension != ".conf" && extension != ".bak") || !websiteProxyPathAllowed(includeDir, name) {
continue
}
proxyConfig := request.WebsiteProxyConfig{
ID: website.ID,
}
parts := strings.Split(configFile.Name, ".")
proxyConfig.Name = parts[0]
if parts[1] == "conf" {
proxyConfig.Name = name
if extension == ".conf" {
proxyConfig.Enable = true
} else {
proxyConfig.Enable = false
@@ -360,6 +429,9 @@ func (w WebsiteService) UpdateProxyFile(req request.NginxProxyUpdate) (err error
if err != nil {
return err
}
if !websiteProxyPathAllowed(GetSitePath(website, SiteProxyDir), req.Name) {
return errors.New("invalid proxy path")
}
absolutePath := fmt.Sprintf("%s/%s.conf", GetSitePath(website, SiteProxyDir), req.Name)
fileOp := files.NewFileOp()
oldRewriteContent, err = fileOp.GetContent(absolutePath)
@@ -405,6 +477,9 @@ func (w WebsiteService) DeleteProxy(req request.WebsiteProxyDel) (err error) {
return
}
includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) {
return
@@ -425,6 +500,9 @@ func (w WebsiteService) UpdateProxyStatus(req request.WebsiteProxyStatusUpdate)
return
}
includeDir := GetSitePath(website, SiteProxyDir)
if !websiteProxyPathAllowed(includeDir, req.Name) {
return errors.New("invalid proxy path")
}
fileOp := files.NewFileOp()
if !fileOp.Stat(includeDir) {
return
+26
View File
@@ -292,10 +292,36 @@ func (t *Task) updateTask(task *model.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 {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
return err
}
return t.executeRegistered()
}
func (t *Task) executeRegistered() error {
var err error
t.Log(i18n.GetWithName("TaskStart", t.Name))
for i := 0; i < len(t.SubTasks); i++ {
+2
View File
@@ -4,3 +4,5 @@ const (
TaskInstall = "installApp"
TaskCreateWebsite = "createWebsite"
)
const TaskScopePanelMCP = "PanelMCP"
+1 -5
View File
@@ -77,11 +77,7 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err)
hourRand = big.NewInt(0)
}
appSyncSchedule := fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64())
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewContainerImageCheckUpdateJob()); err != nil {
global.LOG.Errorf("cannot add container image update cron job: %v", err)
}
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewAppStoreJob()); err != nil {
if _, err := global.Cron.AddJob(fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64()), job.NewAppStoreJob()); err != nil {
global.LOG.Errorf("can not add appstore corn job: %s", err.Error())
}
if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil {
@@ -1,24 +0,0 @@
package job
import (
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
)
type containerImageCheckUpdate struct{}
func NewContainerImageCheckUpdateJob() *containerImageCheckUpdate {
return &containerImageCheckUpdate{}
}
func (j *containerImageCheckUpdate) Run() {
status, err := service.GetContainerImageCheckUpdateStatus()
if err != nil {
global.LOG.Errorf("load container image update schedule: %v", err)
return
}
if status.Enabled {
if _, err := service.StartContainerImageCheckUpdate(); err != nil {
global.LOG.Errorf("start container image update check: %v", err)
}
}
}
+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 (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/compose-spec/compose-go/v2 v2.15.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24
github.com/docker/cli v29.9.0+incompatible
github.com/docker/docker v28.5.2+incompatible
@@ -104,7 +105,6 @@ require (
github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
ContainerImageCheckUpdate: "Check for image updates"
ContainerImageCheckScope: "Checking all {{ .count }} containers on this node, regardless of pagination or filters"
ContainerImageCheckItem: "Checking container {{ .name }}, image {{ .image }}"
ContainerImageCheckFailed: "Container {{ .name }} check failed: {{ .error }}; retaining previous results matching the current image"
ContainerImageCheckSkipped: "Skipping {{ .name }}: not a tagged Docker Hub image, a local image, or pinned digest"
ContainerImageCheckAvailable: "Container {{ .name }} has an update: {{ .image }} -> {{ .target }}"
ContainerImageCheckCurrent: "No update found for container {{ .name }}"
ContainerImageCheckChanged: "Container {{ .name }} was removed or its image changed; result was not saved"
ContainerImageCheckSummary: "Check finished: {{ .checked }} checked, {{ .updates }} upgradable, {{ .failed }} failed, {{ .skipped }} skipped"
ContainerImageCheckFailures: "{{ .count }} container checks failed; see the task log"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración co
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
ContainerImageCheckUpdate: "Buscar actualizaciones de imágenes"
ContainerImageCheckScope: "Comprobando los {{ .count }} contenedores de este nodo, sin aplicar paginación ni filtros"
ContainerImageCheckItem: "Comprobando el contenedor {{ .name }}, imagen {{ .image }}"
ContainerImageCheckFailed: "Error al comprobar el contenedor {{ .name }}: {{ .error }}; se conservan los resultados anteriores correspondientes a la imagen actual"
ContainerImageCheckSkipped: "Se omite {{ .name }}: no es una imagen de Docker Hub con etiqueta, es una imagen local o tiene un digest fijo"
ContainerImageCheckAvailable: "El contenedor {{ .name }} tiene una actualización: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "No se encontraron actualizaciones para el contenedor {{ .name }}"
ContainerImageCheckChanged: "El contenedor {{ .name }} se eliminó o su imagen cambió; no se guardó el resultado"
ContainerImageCheckSummary: "Comprobación finalizada: comprobados {{ .checked }}, actualizables {{ .updates }}, fallidos {{ .failed }}, omitidos {{ .skipped }}"
ContainerImageCheckFailures: "Falló la comprobación de {{ .count }} contenedores; consulte el registro de la tarea"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -802,13 +802,4 @@ ErrFeishuUpgradeMounts: "این اتصال‌های ذخیره‌سازی کام
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
ContainerImageCheckUpdate: "بررسی به‌روزرسانی ایمیج‌ها"
ContainerImageCheckScope: "در حال بررسی همه {{ .count }} کانتینر این گره، بدون توجه به صفحه‌بندی و فیلترها"
ContainerImageCheckItem: "در حال بررسی کانتینر {{ .name }}، ایمیج {{ .image }}"
ContainerImageCheckFailed: "بررسی کانتینر {{ .name }} ناموفق بود: {{ .error }}؛ نتایج قبلیِ منطبق با ایمیج فعلی حفظ می‌شوند"
ContainerImageCheckSkipped: "کانتینر {{ .name }} رد شد: ایمیج تگ‌دار Docker Hub نیست، ایمیج محلی است یا digest ثابت دارد"
ContainerImageCheckAvailable: "برای کانتینر {{ .name }} به‌روزرسانی موجود است: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "به‌روزرسانی برای کانتینر {{ .name }} یافت نشد"
ContainerImageCheckChanged: "کانتینر {{ .name }} حذف شده یا ایمیج آن تغییر کرده است؛ نتیجه ذخیره نشد"
ContainerImageCheckSummary: "بررسی پایان یافت: بررسی‌شده {{ .checked }}، قابل ارتقا {{ .updates }}، ناموفق {{ .failed }}، ردشده {{ .skipped }}"
ContainerImageCheckFailures: "بررسی {{ .count }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
ContainerImageCheckUpdate: "イメージの更新を確認"
ContainerImageCheckScope: "ページ分割やフィルターに関係なく、このノードの全 {{ .count }} コンテナを確認します"
ContainerImageCheckItem: "コンテナ {{ .name }}、イメージ {{ .image }} を確認しています"
ContainerImageCheckFailed: "コンテナ {{ .name }} の確認に失敗しました:{{ .error }}。現在のイメージに対応する前回の結果を保持します"
ContainerImageCheckSkipped: "{{ .name }} をスキップ:タグ付きの Docker Hub イメージではないか、ローカルイメージまたは digest 固定のイメージです"
ContainerImageCheckAvailable: "コンテナ {{ .name }} は更新可能です:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "コンテナ {{ .name }} の更新は見つかりませんでした"
ContainerImageCheckChanged: "コンテナ {{ .name }} が削除されたか、イメージが変更されたため、結果は保存されませんでした"
ContainerImageCheckSummary: "確認終了:成功 {{ .checked }}、更新可能 {{ .updates }}、失敗 {{ .failed }}、スキップ {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} コンテナの確認に失敗しました。タスクログを確認してください"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
ContainerImageCheckUpdate: "이미지 업데이트 확인"
ContainerImageCheckScope: "페이지 및 필터와 관계없이 현재 노드의 모든 컨테이너 {{ .count }}개를 확인합니다"
ContainerImageCheckItem: "컨테이너 {{ .name }}, 이미지 {{ .image }} 확인 중"
ContainerImageCheckFailed: "컨테이너 {{ .name }} 확인 실패: {{ .error }}. 현재 이미지와 일치하는 이전 결과를 유지합니다"
ContainerImageCheckSkipped: "{{ .name }} 건너뜀: 태그가 있는 Docker Hub 이미지가 아니거나 로컬 이미지 또는 digest가 고정된 이미지입니다"
ContainerImageCheckAvailable: "컨테이너 {{ .name }} 업데이트 가능: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "컨테이너 {{ .name }}의 업데이트를 찾지 못했습니다"
ContainerImageCheckChanged: "컨테이너 {{ .name }}이 삭제되었거나 이미지가 변경되어 결과를 저장하지 않았습니다"
ContainerImageCheckSummary: "확인 종료: 성공 {{ .checked }}, 업데이트 가능 {{ .updates }}, 실패 {{ .failed }}, 건너뜀 {{ .skipped }}"
ContainerImageCheckFailures: "컨테이너 {{ .count }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -792,13 +792,4 @@ ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
ContainerImageCheckUpdate: "ກວດສອບການອັບເດດອິມເມຈ"
ContainerImageCheckScope: "ກຳລັງກວດສອບທັງໝົດ {{ .count }} ຄອນເທນເນີໃນໂນດນີ້ ໂດຍບໍ່ຂຶ້ນກັບການແບ່ງໜ້າ ຫຼື ຕົວກອງ"
ContainerImageCheckItem: "ກຳລັງກວດສອບຄອນເທນເນີ {{ .name }}, ອິມເມຈ {{ .image }}"
ContainerImageCheckFailed: "ກວດສອບຄອນເທນເນີ {{ .name }} ລົ້ມເຫຼວ: {{ .error }}; ເກັບຜົນກ່ອນໜ້າທີ່ກົງກັບອິມເມຈປັດຈຸບັນໄວ້"
ContainerImageCheckSkipped: "ຂ້າມ {{ .name }}: ບໍ່ແມ່ນອິມເມຈ Docker Hub ທີ່ມີແທັກ, ເປັນອິມເມຈພາຍໃນ ຫຼື ກຳນົດ digest ຄົງທີ່"
ContainerImageCheckAvailable: "ຄອນເທນເນີ {{ .name }} ມີອັບເດດ: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "ບໍ່ພົບອັບເດດສຳລັບຄອນເທນເນີ {{ .name }}"
ContainerImageCheckChanged: "ຄອນເທນເນີ {{ .name }} ຖືກລຶບ ຫຼື ອິມເມຈປ່ຽນແປງ; ບໍ່ໄດ້ບັນທຶກຜົນ"
ContainerImageCheckSummary: "ກວດສອບສິ້ນສຸດ: ກວດແລ້ວ {{ .checked }}, ອັບເກຣດໄດ້ {{ .updates }}, ລົ້ມເຫຼວ {{ .failed }}, ຂ້າມ {{ .skipped }}"
ContainerImageCheckFailures: "ກວດສອບ {{ .count }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuh
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
ContainerImageCheckUpdate: "Semak kemas kini imej"
ContainerImageCheckScope: "Menyemak kesemua {{ .count }} kontena pada nod ini tanpa mengira pembahagian halaman atau penapis"
ContainerImageCheckItem: "Menyemak kontena {{ .name }}, imej {{ .image }}"
ContainerImageCheckFailed: "Semakan kontena {{ .name }} gagal: {{ .error }}; keputusan terdahulu yang sepadan dengan imej semasa dikekalkan"
ContainerImageCheckSkipped: "Melangkau {{ .name }}: bukan imej Docker Hub bertag, imej setempat atau digest ditetapkan"
ContainerImageCheckAvailable: "Kontena {{ .name }} mempunyai kemas kini: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Tiada kemas kini ditemui untuk kontena {{ .name }}"
ContainerImageCheckChanged: "Kontena {{ .name }} telah dipadam atau imejnya berubah; keputusan tidak disimpan"
ContainerImageCheckSummary: "Semakan selesai: disemak {{ .checked }}, boleh dikemas kini {{ .updates }}, gagal {{ .failed }}, dilangkau {{ .skipped }}"
ContainerImageCheckFailures: "Semakan {{ .count }} kontena gagal; lihat log tugas"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração co
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
ContainerImageCheckUpdate: "Verificar atualizações de imagens"
ContainerImageCheckScope: "Verificando todos os {{ .count }} contêineres deste nó, independentemente da paginação e dos filtros"
ContainerImageCheckItem: "Verificando o contêiner {{ .name }}, imagem {{ .image }}"
ContainerImageCheckFailed: "Falha ao verificar o contêiner {{ .name }}: {{ .error }}; os resultados anteriores correspondentes à imagem atual serão mantidos"
ContainerImageCheckSkipped: "Ignorando {{ .name }}: não é uma imagem do Docker Hub com tag, é uma imagem local ou tem digest fixo"
ContainerImageCheckAvailable: "O contêiner {{ .name }} tem uma atualização: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Nenhuma atualização encontrada para o contêiner {{ .name }}"
ContainerImageCheckChanged: "O contêiner {{ .name }} foi removido ou sua imagem mudou; o resultado não foi salvo"
ContainerImageCheckSummary: "Verificação concluída: verificados {{ .checked }}, atualizáveis {{ .updates }}, falhas {{ .failed }}, ignorados {{ .skipped }}"
ContainerImageCheckFailures: "Falha na verificação de {{ .count }} contêineres; consulte o log da tarefa"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "Эти монтирования не поддержив
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
ContainerImageCheckUpdate: "Проверить обновления образов"
ContainerImageCheckScope: "Проверка всех контейнеров на текущем узле ({{ .count }}) независимо от страниц и фильтров"
ContainerImageCheckItem: "Проверка контейнера {{ .name }}, образ {{ .image }}"
ContainerImageCheckFailed: "Ошибка проверки контейнера {{ .name }}: {{ .error }}; сохранены предыдущие результаты, соответствующие текущему образу"
ContainerImageCheckSkipped: "Пропуск {{ .name }}: это не образ Docker Hub с тегом, локальный образ или образ с фиксированным digest"
ContainerImageCheckAvailable: "Доступно обновление контейнера {{ .name }}: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "Обновлений для контейнера {{ .name }} не найдено"
ContainerImageCheckChanged: "Контейнер {{ .name }} удалён или его образ изменился; результат не сохранён"
ContainerImageCheckSummary: "Проверка завершена: проверено {{ .checked }}, доступны обновления {{ .updates }}, ошибок {{ .failed }}, пропущено {{ .skipped }}"
ContainerImageCheckFailures: "Не удалось проверить контейнеры: {{ .count }}. Подробности в журнале задачи"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygul
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
ContainerImageCheckUpdate: "İmaj güncellemelerini denetle"
ContainerImageCheckScope: "Sayfalama ve filtrelerden bağımsız olarak bu düğümdeki {{ .count }} konteynerin tümü denetleniyor"
ContainerImageCheckItem: "Konteyner {{ .name }}, imaj {{ .image }} denetleniyor"
ContainerImageCheckFailed: "{{ .name }} konteyneri denetlenemedi: {{ .error }}; mevcut imajla eşleşen önceki sonuçlar korunuyor"
ContainerImageCheckSkipped: "{{ .name }} atlanıyor: etiketli bir Docker Hub imajı değil, yerel bir imaj veya digest değeri sabitlenmiş"
ContainerImageCheckAvailable: "{{ .name }} konteyneri için güncelleme mevcut: {{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "{{ .name }} konteyneri için güncelleme bulunamadı"
ContainerImageCheckChanged: "{{ .name }} konteyneri silindi veya imajı değişti; sonuç kaydedilmedi"
ContainerImageCheckSummary: "Denetim tamamlandı: denetlenen {{ .checked }}, güncellenebilir {{ .updates }}, başarısız {{ .failed }}, atlanan {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} konteyner denetlenemedi; görev günlüğünü inceleyin"
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
ContainerImageCheckUpdate: "檢查更新映像"
ContainerImageCheckScope: "檢查目前節點全部 {{ .count }} 個容器,不受分頁和篩選影響"
ContainerImageCheckItem: "檢查容器 {{ .name }},映像 {{ .image }}"
ContainerImageCheckFailed: "容器 {{ .name }} 檢查失敗:{{ .error }};保留符合目前映像的上次結果"
ContainerImageCheckSkipped: "略過 {{ .name }}:非 Docker Hub 標籤映像、本機映像或固定 digest"
ContainerImageCheckAvailable: "容器 {{ .name }} 可升級:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "容器 {{ .name }} 未發現更新"
ContainerImageCheckChanged: "容器 {{ .name }} 已刪除或映像已變更,未儲存本次結果"
ContainerImageCheckSummary: "檢查結束:成功 {{ .checked }},可升級 {{ .updates }},失敗 {{ .failed }},略過 {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} 個容器檢查失敗,請查看任務日誌"
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
+1 -10
View File
@@ -801,13 +801,4 @@ ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
ContainerImageCheckUpdate: "检查更新镜像"
ContainerImageCheckScope: "检查当前节点全部 {{ .count }} 个容器,不受分页和筛选影响"
ContainerImageCheckItem: "检查容器 {{ .name }},镜像 {{ .image }}"
ContainerImageCheckFailed: "容器 {{ .name }} 检查失败:{{ .error }};保留匹配当前镜像的上次结果"
ContainerImageCheckSkipped: "跳过 {{ .name }}:非 Docker Hub 标签镜像、本地镜像或固定 digest"
ContainerImageCheckAvailable: "容器 {{ .name }} 可升级:{{ .image }} → {{ .target }}"
ContainerImageCheckCurrent: "容器 {{ .name }} 未发现更新"
ContainerImageCheckChanged: "容器 {{ .name }} 已删除或镜像发生变化,未保存本次结果"
ContainerImageCheckSummary: "检查结束:成功 {{ .checked }},可升级 {{ .updates }},失败 {{ .failed }},跳过 {{ .skipped }}"
ContainerImageCheckFailures: "{{ .count }} 个容器检查失败,请查看任务日志"
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
-1
View File
@@ -119,7 +119,6 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
migrations.AddOllamaModelAccountSync,
migrations.AddContainerImageCheckUpdates,
}
}
@@ -1,17 +0,0 @@
package migrations
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var AddContainerImageCheckUpdates = &gormigrate.Migration{
ID: "20261028-container-image-updates",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.ContainerImageCheckUpdate{}); err != nil {
return err
}
return tx.Where("key = ?", "ContainerImageUpdateEnabled").FirstOrCreate(&model.Setting{Key: "ContainerImageUpdateEnabled", Value: "Disable"}).Error
},
}
+63 -1
View File
@@ -2,11 +2,15 @@ package router
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/middleware"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
@@ -17,9 +21,38 @@ import (
func registerMCPRuntime(engine *gin.Engine) {
controller := mcpruntime.New()
engine.POST("/internal/mcp/docker", mcpDockerHandler(controller))
engine.POST("/internal/mcp/website", mcpWebsiteHandler(service.NewMCPWebsiteBindingService()))
engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler())
}
type mcpWebsiteController interface {
Handle(context.Context, request.MCPWebsiteBinding) response.MCPWebsiteBinding
}
func mcpWebsiteHandler(controller mcpWebsiteController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
ctx.AbortWithStatus(http.StatusForbidden)
return
}
data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 16385))
if err != nil || len(data) > 16384 {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
var req request.MCPWebsiteBinding
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, controller.Handle(ctx.Request.Context(), req))
}
}
func mcpCapabilitiesHandler() gin.HandlerFunc {
return func(ctx *gin.Context) {
if !mcpapi.TrustedTransport(ctx.Request) {
@@ -32,7 +65,12 @@ func mcpCapabilitiesHandler() gin.HandlerFunc {
}
}
func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
type mcpDockerController interface {
Handle(context.Context, mcpruntime.Request) (mcpruntime.Response, error)
HandleWithProgress(context.Context, mcpruntime.Request, func(mcpruntime.PullProgress) error) (mcpruntime.Response, error)
}
func mcpDockerHandler(controller mcpDockerController) gin.HandlerFunc {
return func(ctx *gin.Context) {
local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr)
if !global.IsMaster || !ok || local.Network() != "unix" {
@@ -51,6 +89,30 @@ func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc {
ctx.AbortWithStatus(http.StatusBadRequest)
return
}
if req.StreamProgress {
if req.Operation != "pull" {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: "MCP Docker progress is only available when pulling an image"})
return
}
ctx.Header("Content-Type", "application/x-ndjson")
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusOK)
encoder := json.NewEncoder(ctx.Writer)
_, err := controller.HandleWithProgress(ctx.Request.Context(), req, func(progress mcpruntime.PullProgress) error {
if err := encoder.Encode(mcpruntime.PullEvent{Type: "progress", PullProgress: progress}); err != nil {
return err
}
ctx.Writer.Flush()
return nil
})
result := mcpruntime.PullEvent{Type: "result"}
if err != nil {
result.Error = err.Error()
}
_ = encoder.Encode(result)
ctx.Writer.Flush()
return
}
response, err := controller.Handle(ctx.Request.Context(), req)
if err != nil {
ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()})
+6
View File
@@ -2,6 +2,7 @@ package router
import (
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/middleware"
@@ -14,6 +15,9 @@ var (
)
func Routers() *gin.Engine {
if err := service.ConfigurePanelMCPTasks(); err != nil {
panic(err)
}
Router = gin.Default()
Router.Use(i18n.UseI18n())
registerMCPRuntime(Router)
@@ -23,6 +27,8 @@ func Routers() *gin.Engine {
PrivateGroup.Use(middleware.Certificate())
}
PrivateGroup.Use(middleware.OperationResolveMeta())
PrivateGroup.Use(v2.PanelMCPTaskAccess())
PrivateGroup.Use(v2.MCPBusinessDispatch())
for _, router := range rou.RouterGroupApp {
router.InitRouter(PrivateGroup)
}
-3
View File
@@ -16,9 +16,6 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("", baseApi.ContainerCreate)
baRouter.POST("/update", baseApi.ContainerUpdate)
baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
baRouter.GET("/image-updates", baseApi.GetContainerImageCheckUpdateStatus)
baRouter.POST("/image-updates/check", baseApi.ContainerImageCheckUpdate)
baRouter.POST("/image-updates/schedule", baseApi.SetContainerImageCheckUpdateSchedule)
baRouter.POST("/info", baseApi.ContainerInfo)
baRouter.POST("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer)
+28 -22
View File
@@ -13,6 +13,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -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 {
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 {
return err
}
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 {
@@ -319,11 +330,15 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
}
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{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
if authStr, ok := RegistryAuthForImage(imageName); ok {
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) {
@@ -342,7 +357,7 @@ func PullImage(imageName string) error {
}
defer cli.Close()
options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
if authStr, ok := RegistryAuthForImage(imageName); ok {
options.RegistryAuth = authStr
}
out, err := cli.ImagePull(context.Background(), imageName, options)
@@ -353,17 +368,20 @@ func PullImage(imageName string) error {
return handlePullImageProcess(out, nil)
}
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) {
registryHost, hasRegistry := extractRegistryHost(imageName)
func RegistryAuthForImage(imageName string) (string, bool) {
named, err := reference.ParseNormalizedNamed(imageName)
if err != nil {
return "", false
}
registryHost := normalizeRegistryHost(reference.Domain(named))
cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil {
return "", false
}
candidates := make([]string, 0)
if hasRegistry {
if !isDockerHubRegistry(registryHost) {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
}
if !hasRegistry || isDockerHubRegistry(registryHost) {
} else {
candidates = append(candidates,
"https://index.docker.io/v1/",
"index.docker.io",
@@ -413,18 +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 {
key := strings.TrimSpace(registryKey)
if key == "" {
-266
View File
@@ -1,266 +0,0 @@
// Package dockerhub checks Docker Hub metadata without downloading image layers.
package dockerhub
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/distribution/reference"
)
const registryURL = "https://registry-1.docker.io"
// Reference only accepts tagged Docker Hub references. Digest pins are intentional.
func Reference(raw string) (string, string, bool) {
if strings.HasPrefix(raw, "sha256:") {
return "", "", false
}
ref, err := reference.ParseNormalizedNamed(raw)
if err != nil || reference.Domain(ref) != "docker.io" {
return "", "", false
}
if _, pinned := ref.(reference.Digested); pinned {
return "", "", false
}
tagged := reference.TagNameOnly(ref).(reference.NamedTagged)
return reference.Path(ref), tagged.Tag(), true
}
type Client struct {
HTTP *http.Client
repository string
token string
}
type Platform struct{ OS, Architecture, Variant string }
type Target struct{ Tag, Digest, ConfigDigest string }
func New(ctx context.Context, repository, username, password string) (*Client, error) {
c := &Client{HTTP: &http.Client{Timeout: 30 * time.Second}, repository: repository}
endpoint := "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if username != "" {
req.SetBasicAuth(username, password)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub authentication: HTTP %d", resp.StatusCode)
}
var body struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
return nil, err
}
c.token = body.Token
if c.token == "" {
c.token = body.AccessToken
}
if c.token == "" {
return nil, fmt.Errorf("Docker Hub returned an empty token")
}
return c, nil
}
func (c *Client) get(ctx context.Context, endpoint string, result interface{}) (http.Header, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Docker Hub metadata: HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(result)
return resp.Header, err
}
func (c *Client) Tags(ctx context.Context) ([]string, error) {
endpoint := registryURL + "/v2/" + c.repository + "/tags/list?n=1000"
var tags []string
for page := 0; page < 100; page++ {
var body struct {
Tags []string `json:"tags"`
}
headers, err := c.get(ctx, endpoint, &body)
if err != nil {
return nil, err
}
tags = append(tags, body.Tags...)
link := headers.Get("Link")
if link == "" {
return tags, nil
}
start, end := strings.Index(link, "<"), strings.Index(link, ">")
if start < 0 || end <= start {
return nil, fmt.Errorf("invalid Docker Hub pagination")
}
base, _ := url.Parse(registryURL)
next, err := base.Parse(link[start+1 : end])
if err != nil || next.Scheme != "https" || next.Host != base.Host || next.Path != "/v2/"+c.repository+"/tags/list" {
return nil, fmt.Errorf("invalid Docker Hub pagination URL")
}
endpoint = next.String()
}
return nil, fmt.Errorf("Docker Hub tag pagination limit exceeded")
}
type manifest struct {
Config struct {
Digest string `json:"digest"`
} `json:"config"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
} `json:"platform"`
} `json:"manifests"`
}
func (c *Client) Resolve(ctx context.Context, tag string, platform Platform) (Target, bool, error) {
var m manifest
headers, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(tag), &m)
if err != nil {
return Target{}, false, err
}
digest := headers.Get("Docker-Content-Digest")
if len(m.Manifests) > 0 {
digest = ""
for _, item := range m.Manifests {
if item.Platform.OS == platform.OS && item.Platform.Architecture == platform.Architecture && normalizedVariant(platform.Architecture, item.Platform.Variant) == normalizedVariant(platform.Architecture, platform.Variant) {
digest = item.Digest
break
}
}
if digest == "" {
return Target{}, false, nil
}
m = manifest{}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(digest), &m); err != nil {
return Target{}, false, err
}
}
if m.Config.Digest == "" {
return Target{}, false, fmt.Errorf("unsupported Docker Hub manifest")
}
// Validate single-platform manifests too; the config digest is the Docker Image ID.
var config struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant"`
}
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/blobs/"+url.PathEscape(m.Config.Digest), &config); err != nil {
return Target{}, false, err
}
if config.OS != platform.OS || config.Architecture != platform.Architecture || (platform.Variant != "" && config.Variant != "" && config.Variant != platform.Variant) {
return Target{}, false, nil
}
return Target{Tag: tag, Digest: digest, ConfigDigest: m.Config.Digest}, true, nil
}
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)(?:\.(\d+))?(-[a-zA-Z][a-zA-Z0-9_.-]*)?$`)
var prereleasePattern = regexp.MustCompile(`(?i)(^|[-_.])(alpha|beta|rc|dev|nightly|edge|test|unstable|preview)([0-9_.-]|$)`)
type version struct {
numbers [3]uint64
parts int
variant string
}
func parseVersion(tag string) (version, bool) {
m := versionPattern.FindStringSubmatch(tag)
if m == nil || prereleasePattern.MatchString(m[4]) {
return version{}, false
}
v := version{parts: 2, variant: m[4]}
for i := 0; i < 3; i++ {
if m[i+1] != "" {
n, err := strconv.ParseUint(m[i+1], 10, 64)
if err != nil {
return version{}, false
}
v.numbers[i] = n
}
}
if m[3] != "" {
v.parts = 3
}
return v, true
}
func greater(a, b version) bool {
for i := 0; i < 3; i++ {
if a.numbers[i] != b.numbers[i] {
return a.numbers[i] > b.numbers[i]
}
}
return false
}
// Candidates preserves the current tag's variant and precision. Unknown/floating
// tags are checked only for content changes, never guessed from upload dates.
func Candidates(current string, tags []string) []string {
baseline, ok := parseVersion(current)
if !ok {
return []string{current}
}
type candidate struct {
tag string
version version
}
candidates := []candidate{}
seen := map[string]bool{}
for _, tag := range tags {
v, valid := parseVersion(tag)
if valid && v.parts == baseline.parts && v.variant == baseline.variant && greater(v, baseline) && !seen[tag] {
candidates = append(candidates, candidate{tag: tag, version: v})
seen[tag] = true
}
}
sort.Slice(candidates, func(i, j int) bool {
a, b := candidates[i], candidates[j]
if a.version.numbers == b.version.numbers {
return a.tag < b.tag
}
return greater(a.version, b.version)
})
result := make([]string, 0, len(candidates)+1)
for _, candidate := range candidates {
result = append(result, candidate.tag)
}
return append(result, current)
}
func NeedsTags(tag string) bool { _, ok := parseVersion(tag); return ok }
func normalizedVariant(architecture, variant string) string {
if architecture == "arm64" && variant == "" {
return "v8"
}
return variant
}
+68 -34
View File
@@ -3,6 +3,7 @@ package mcpruntime
import (
"bytes"
"context"
_ "crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
@@ -15,19 +16,21 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
const (
ImageRepository = "1panel/1panel-mcp-server"
ImageRepository = "1panel/mcp-server"
ContainerName = "1panel-mcp"
previousName = "1panel-mcp-previous"
SocketDirectory = "/etc/1panel/mcp-runtime"
@@ -48,12 +51,13 @@ var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping")
type Request struct {
Operation string `json:"operation"`
Image string `json:"image"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"`
Operation string `json:"operation"`
Image string `json:"image"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Restart bool `json:"restart"`
ImageID string `json:"imageID,omitempty"`
StreamProgress bool `json:"streamProgress,omitempty"`
}
type State struct {
@@ -106,6 +110,7 @@ type Controller struct {
platform string
health func(context.Context, int) (metadata, error)
checkDirectory func() error
registryAuth func(string) (string, bool)
mutation chan struct{}
probes chan struct{}
cacheMu sync.Mutex
@@ -118,25 +123,40 @@ func New() *Controller {
func newController(factory func() (DockerClient, error)) *Controller {
return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth,
checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
checkDirectory: checkSocketDirectory, registryAuth: docker.RegistryAuthForImage,
mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)}
}
func ValidateImage(value string) error {
if len(value) > 180 {
if value == "" || len(value) > 512 || strings.IndexFunc(value, func(ch rune) bool { return unicode.IsSpace(ch) || unicode.IsControl(ch) }) >= 0 {
return errors.New("invalid MCP Docker image")
}
if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) {
parsed, err := reference.ParseNormalizedNamed(value)
if err != nil {
return errors.New("invalid MCP Docker image")
}
if digested, ok := parsed.(reference.Digested); ok {
if digestPattern.MatchString(digested.Digest().String()) {
return nil
}
return errors.New("MCP Docker image must use a SHA256 digest")
}
if _, ok := parsed.(reference.Tagged); ok {
return nil
}
if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) {
return nil
}
return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest")
return errors.New("MCP Docker image must include a tag or SHA256 digest")
}
func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) {
return c.HandleWithProgress(ctx, req, nil)
}
func (c *Controller) HandleWithProgress(ctx context.Context, req Request, progress func(PullProgress) error) (Response, error) {
if req.StreamProgress && req.Operation != "pull" {
return Response{}, errors.New("MCP Docker progress is only available when pulling an image")
}
switch req.Operation {
case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
case "prepare", "catalog", "apply_catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs":
default:
return Response{}, errors.New("invalid MCP Docker operation")
}
@@ -147,7 +167,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
return Response{}, errors.New("MCP listen port must be between 1024 and 65535")
}
timeout := 60 * time.Second
if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
if req.Operation == "catalog" || req.Operation == "apply_catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" {
timeout = 5 * time.Second
}
if req.Operation == "status" {
@@ -176,19 +196,23 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
}
switch req.Operation {
case "prepare":
case "catalog", "candidate_catalog":
case "catalog", "apply_catalog", "candidate_catalog":
var img image.InspectResponse
img, err = c.resolve(ctx, cli, req.Image)
if err == nil && req.Operation == "catalog" {
if req.Operation != "candidate_catalog" {
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID {
err = errors.New("MCP image reference changed; restart the adapter before reading its catalog")
if err == nil && req.Operation == "catalog" && current != nil && current.Config.Labels[imageLabel] != req.Image {
err = errors.New("MCP installed runtime does not match the configured image")
}
if err == nil {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
} else {
img, err = c.resolve(ctx, cli, req.Image)
}
if err == nil {
var verified verifiedImage
verified, err = c.verify(ctx, cli, img.ID, req.Image)
verified, err = c.verify(ctx, cli, img.ID)
if err == nil {
return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil
}
@@ -204,7 +228,7 @@ func (c *Controller) Handle(ctx context.Context, req Request) (Response, error)
case "rollback_absent":
err = c.rollbackAbsent(ctx, cli, req)
case "pull":
err = c.pullImage(ctx, cli, req)
err = c.pullImage(ctx, cli, req, progress)
case "logs":
var current *container.InspectResponse
current, err = c.owned(ctx, cli, ContainerName)
@@ -259,6 +283,20 @@ func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string)
return img, nil
}
func (c *Controller) resolveActiveImage(ctx context.Context, cli DockerClient, ref string, current *container.InspectResponse) (image.InspectResponse, error) {
if current == nil || current.Config.Labels[imageLabel] != ref {
return c.resolve(ctx, cli, ref)
}
if !digestPattern.MatchString(current.Image) {
return image.InspectResponse{}, errors.New("invalid MCP installed image identity")
}
img, err := c.resolve(ctx, cli, current.Image)
if err != nil || img.ID != current.Image {
return image.InspectResponse{}, errors.New("MCP installed image is unavailable or changed")
}
return img, nil
}
func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) {
info, err := cli.ContainerInspect(ctx, name)
if errdefs.IsNotFound(err) {
@@ -279,10 +317,6 @@ func isRunning(info *container.InspectResponse) bool {
func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response {
state := &State{Status: "not_installed", Image: req.Image}
img, err := c.resolve(ctx, cli, req.Image)
if err == nil {
state.ImageID = img.ID
}
current, inspectErr := c.owned(ctx, cli, ContainerName)
if inspectErr != nil {
state.Status = "unhealthy"
@@ -290,9 +324,11 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state}
}
if current == nil {
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
state.Reason = err.Error()
} else {
state.ImageID = img.ID
state.Reason = "MCP image is loaded; install it to create the managed container"
}
return Response{State: state}
@@ -312,12 +348,13 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
state.Reason = "MCP access is disabled"
return Response{State: state}
}
if err != nil || current.Image != img.ID {
img, err := c.resolveActiveImage(ctx, cli, req.Image, current)
if err != nil || current.Config.Labels[imageLabel] != req.Image {
state.Status = "unhealthy"
state.Reason = "MCP container does not match the configured image or listen port"
return Response{State: state}
}
verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image)
verified, verifyErr := c.verify(ctx, cli, current.Image)
if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) {
state.Status = "unhealthy"
state.Reason = "MCP container configuration or metadata does not match the verified image"
@@ -334,7 +371,7 @@ func (c *Controller) status(ctx context.Context, cli DockerClient, req Request)
return Response{State: state}
}
func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) {
func (c *Controller) verify(ctx context.Context, cli DockerClient, id string) (verifiedImage, error) {
c.cacheMu.Lock()
cached, ok := c.cache[id]
c.cacheMu.Unlock()
@@ -366,9 +403,6 @@ func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref strin
c.cache[id] = cached
c.cacheMu.Unlock()
}
if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") {
return verifiedImage{}, errors.New("MCP image version does not match its selected tag")
}
return cached, nil
}
+57 -11
View File
@@ -10,49 +10,90 @@ import (
"strings"
"time"
"github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/errdefs"
)
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error {
func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request, progress func(PullProgress) error) error {
if err := reportPullProgress(ctx, progress, PullProgress{Status: "checking_image"}); err != nil {
return err
}
_, err := cli.ImageInspect(ctx, req.Image)
if err != nil && !errdefs.IsNotFound(err) {
return errors.New("MCP Docker image inspection failed")
}
if errdefs.IsNotFound(err) {
if err = reportPullProgress(ctx, progress, PullProgress{Status: "pulling"}); err != nil {
return err
}
pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{})
options := image.PullOptions{}
if auth, ok := c.registryAuth(req.Image); ok {
options.RegistryAuth = auth
}
reader, pullErr := cli.ImagePull(pullCtx, req.Image, options)
if pullErr != nil {
if errdefs.IsUnauthorized(pullErr) || errdefs.IsPermissionDenied(pullErr) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed")
}
defer reader.Close()
decoder := json.NewDecoder(io.LimitReader(reader, 16<<20))
limited := &io.LimitedReader{R: reader, N: (16 << 20) + 1}
decoder := json.NewDecoder(limited)
for {
var message struct {
Status string `json:"status"`
ID string `json:"id"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"`
ErrorDetail *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errorDetail"`
}
if err = decoder.Decode(&message); err == io.EOF {
err = decoder.Decode(&message)
if limited.N == 0 {
return errors.New("MCP Docker image download progress exceeds permitted size")
}
if err == io.EOF {
break
} else if err != nil {
return errors.New("MCP Docker image download was interrupted")
}
if message.Error != "" || message.ErrorDetail != nil {
if message.ErrorDetail != nil && (message.ErrorDetail.Code == 401 || message.ErrorDetail.Code == 403) {
return errors.New("MCP Docker image registry authentication failed")
}
return errors.New("MCP Docker image download failed")
}
if normalized, ok := normalizePullProgress(message.Status, message.ID, message.ProgressDetail.Current, message.ProgressDetail.Total); ok {
if err = reportPullProgress(ctx, progress, normalized); err != nil {
return err
}
}
}
} else if err = reportPullProgress(ctx, progress, PullProgress{Status: "local_image"}); err != nil {
return err
}
if err = reportPullProgress(ctx, progress, PullProgress{Status: "verifying_image"}); err != nil {
return err
}
img, err := c.resolve(ctx, cli, req.Image)
if err != nil {
return err
}
_, err = c.verify(ctx, cli, img.ID, req.Image)
return err
_, err = c.verify(ctx, cli, img.ID)
if err != nil {
return err
}
return reportPullProgress(ctx, progress, PullProgress{Status: "verified_image"})
}
func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error {
@@ -176,14 +217,19 @@ func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) e
if err = c.checkDirectory(); err != nil {
return err
}
img, err := c.resolve(ctx, cli, req.Image)
var img image.InspectResponse
if req.Operation == "install" {
img, err = c.resolve(ctx, cli, req.Image)
} else {
img, err = c.resolveActiveImage(ctx, cli, req.Image, current)
}
if err != nil {
return err
}
if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID {
return errors.New("MCP image identity changed after catalog validation; retry the operation")
}
verified, err := c.verify(ctx, cli, img.ID, req.Image)
verified, err := c.verify(ctx, cli, img.ID)
if err != nil {
return err
}
@@ -354,7 +400,7 @@ func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request
}
return errors.New("the previous MCP runtime instance is unavailable for rollback")
}
verified, err := c.verify(ctx, cli, target.Image, req.Image)
verified, err := c.verify(ctx, cli, target.Image)
if err != nil {
return err
}
@@ -402,7 +448,7 @@ func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req R
if _, err = c.resolve(ctx, cli, current.Image); err != nil {
return err
}
verified, err := c.verify(ctx, cli, current.Image, req.Image)
verified, err := c.verify(ctx, cli, current.Image)
if err != nil {
return err
}
+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...)
}
+63 -3
View File
@@ -1,6 +1,7 @@
package v2
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -32,6 +33,41 @@ func mcpRead(c *gin.Context, value any) bool {
if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 {
err = contract.Invalid()
}
case *dto.MCPWebsiteBind, *dto.MCPWebsiteUpdate:
var fields struct {
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
}
if json.Unmarshal(data, &fields) != nil || len(fields.EnableSSL) == 0 || len(fields.SSLID) == 0 ||
bytes.Equal(bytes.TrimSpace(fields.EnableSSL), []byte("null")) || bytes.Equal(bytes.TrimSpace(fields.SSLID), []byte("null")) {
err = contract.Invalid()
}
case *dto.MCPRuntimeInstall:
var fields struct {
AccessPolicy json.RawMessage `json:"accessPolicy"`
AllowedIPs json.RawMessage `json:"allowedIPs"`
Website *struct {
Domain json.RawMessage `json:"domain"`
EnableSSL json.RawMessage `json:"enableSSL"`
SSLID json.RawMessage `json:"sslID"`
} `json:"website"`
}
if json.Unmarshal(data, &fields) != nil {
err = contract.Invalid()
} else {
for _, field := range []json.RawMessage{fields.AccessPolicy, fields.AllowedIPs} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
if fields.Website != nil {
for _, field := range []json.RawMessage{fields.Website.Domain, fields.Website.EnableSSL, fields.Website.SSLID} {
if len(field) == 0 || bytes.Equal(bytes.TrimSpace(field), []byte("null")) {
err = contract.Invalid()
}
}
}
}
}
if err != nil {
@@ -179,6 +215,21 @@ func (b *BaseApi) RevokeMCPCredential(c *gin.Context) {
}
helper.Success(c)
}
func (b *BaseApi) DeleteMCPCredential(c *gin.Context) {
s, owner, ok := mcpManagement(c, true, false)
if !ok {
return
}
var req dto.MCPCredentialMutation
if !mcpRead(c, &req) {
return
}
if err := s.Delete(owner, req); err != nil {
mcpManagementError(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) SearchMCPInvocations(c *gin.Context) {
s, owner, ok := mcpManagement(c, false, false)
if !ok {
@@ -207,8 +258,17 @@ func (b *BaseApi) MCPServiceStatus(c *gin.Context) {
mcpManagementError(c, err)
return
}
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
if result.Website == nil {
result.Endpoint = mcpPublicEndpoint(c, result.Endpoint)
}
result.CanManageService = owner.IsSuperAdmin
if owner.IsSuperAdmin {
result.InstallTaskID, result.Installing, err = service.NewMCPRuntimeService().InstallationTask(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) MCPSettings(c *gin.Context) {
@@ -268,9 +328,9 @@ func (b *BaseApi) InstallMCPService(c *gin.Context) {
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute)
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().Install(ctx, req)
result, err := service.NewMCPRuntimeService().SubmitInstall(ctx, req)
if err != nil {
mcpServiceError(c, err)
return
+10 -1
View File
@@ -55,7 +55,16 @@ func (b *BaseApi) ExportMCPCredential(c *gin.Context) {
mcpManagementError(c, err)
return
}
result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language"))
endpoint := mcpPublicEndpoint(c, settings.ExternalURL)
website, err := service.NewMCPRuntimeService().WebsiteStatus(c.Request.Context())
if err != nil {
mcpManagementError(c, err)
return
}
if website.State != "unbound" {
endpoint = website.Endpoint
}
result, err := s.ExportMarkdown(owner, req, endpoint, c.GetHeader("Accept-Language"))
if err != nil {
mcpManagementError(c, err)
return
+6
View File
@@ -31,6 +31,12 @@ func mcpManagementMessage(code string) string {
key = "ErrMCPAuditUnavailable"
case "MCP_ENDPOINT_UNAVAILABLE":
key = "ErrMCPEndpointUnavailable"
case "MCP_WEBSITE_PENDING":
key = "ErrMCPWebsitePending"
case "MCP_WEBSITE_UNAVAILABLE":
key = "ErrMCPWebsiteUnavailable"
case "MCP_WEBSITE_SERVICE_REQUIRED":
key = "ErrMCPWebsiteServiceRequired"
}
return i18n.GetMsgWithMap(key, nil)
}
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"context"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) MCPWebsiteStatus(c *gin.Context) {
if _, _, ok := mcpManagement(c, false, true); !ok {
return
}
if c.Request.URL.RawQuery != "" {
mcpManagementError(c, contract.Invalid())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().WebsiteStatus(ctx)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) BindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteBind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().BindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUpdate
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UpdateWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UnbindMCPWebsite(c *gin.Context) {
if _, _, ok := mcpManagement(c, true, true); !ok {
return
}
var req dto.MCPWebsiteUnbind
if !mcpRead(c, &req) {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 90*time.Second)
defer cancel()
result, err := service.NewMCPRuntimeService().UnbindWebsite(ctx, req)
if err != nil {
mcpManagementError(c, err)
return
}
helper.SuccessWithData(c, result)
}
+35 -19
View File
@@ -14,28 +14,44 @@ type MCPRuntimeSettings struct {
type MCPRuntimeSettingsUpdate = MCPRuntimeSettings
type MCPRuntimeInstall struct {
DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"`
DockerImage string `json:"dockerImage"`
ListenPort int `json:"listenPort"`
ExternalURL string `json:"externalUrl"`
Revision uint64 `json:"revision"`
AccessPolicy string `json:"accessPolicy"`
AllowedIPs []string `json:"allowedIPs"`
Website *MCPRuntimeInstallWebsite `json:"website,omitempty"`
}
type MCPRuntimeInstallWebsite struct {
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPRuntimeInstallTask struct {
TaskID string `json:"taskID"`
}
type MCPRuntimeStatus struct {
RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"`
ImageID string `json:"imageId"`
Enabled bool `json:"enabled"`
Installed bool `json:"installed"`
Status string `json:"status"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"`
RuntimeType string `json:"runtimeType"`
DockerImage string `json:"dockerImage"`
ContainerName string `json:"containerName"`
ImageID string `json:"imageId"`
Enabled bool `json:"enabled"`
Installed bool `json:"installed"`
Status string `json:"status"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
AdapterVersion string `json:"adapterVersion"`
ContractVersion int `json:"contractVersion"`
Protocol string `json:"protocol"`
InternalEndpoint string `json:"internalEndpoint"`
Endpoint string `json:"endpoint"`
CanManageService bool `json:"canManageService"`
InstallTaskID string `json:"installTaskID,omitempty"`
Installing bool `json:"installing"`
Website *MCPWebsiteStatus `json:"website,omitempty"`
}
type MCPRuntimeLogs struct {
+43
View File
@@ -0,0 +1,43 @@
package dto
type MCPWebsiteBind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUpdate struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteUnbind struct {
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
}
type MCPWebsitePendingRequest struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
}
type MCPWebsiteStatus struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
RequestID string `json:"requestId"`
Revision uint64 `json:"revision"`
Reason string `json:"reason,omitempty"`
PendingRequest *MCPWebsitePendingRequest `json:"pendingRequest,omitempty"`
}
+12 -9
View File
@@ -52,13 +52,16 @@ type MCPInvocation struct {
}
type MCPSetting struct {
RuntimeType string `gorm:"not null;default:docker"`
DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"`
ID uint `gorm:"primaryKey"`
Enabled bool `gorm:"not null;default:false"`
ListenPort int `gorm:"not null;default:18081"`
ExternalURL string
AccessPolicy string `gorm:"not null;default:inherit"`
AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
Revision uint64 `gorm:"not null;default:1"`
RuntimeType string `gorm:"not null;default:docker"`
DockerImage string `gorm:"not null;default:1panel/mcp-server:v1.0.0"`
ID uint `gorm:"primaryKey"`
Enabled bool `gorm:"not null;default:false"`
ListenPort int `gorm:"not null;default:18081"`
ExternalURL string
AccessPolicy string `gorm:"not null;default:inherit"`
AllowedIPs string `gorm:"type:text;not null;default:'[]'"`
Revision uint64 `gorm:"not null;default:1"`
WebsiteID uint `gorm:"not null;default:0"`
WebsiteBindingID string `gorm:"not null;default:''"`
WebsiteOperation string `gorm:"type:text;not null;default:''"`
}
+169 -1
View File
@@ -1,12 +1,89 @@
package repo
import (
"time"
"github.com/1Panel-dev/1Panel/core/app/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
type MCPRepo struct{ DB *gorm.DB }
func mcpOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
}
func (r MCPRepo) Credential(ownerType, ownerID, id string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ?", id).First(&key).Error
return key, err
}
func (r MCPRepo) CredentialByRequest(ownerType, ownerID, requestID string) (model.MCPCredential, error) {
var key model.MCPCredential
err := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("request_id = ?", requestID).First(&key).Error
return key, err
}
func (r MCPRepo) SearchCredentials(ownerType, ownerID string, page, pageSize int, excludeRevoked bool) ([]model.MCPCredential, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
keys := []model.MCPCredential{}
err := q.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&keys).Error
return keys, total, err
}
func (r MCPRepo) CountActiveName(ownerType, ownerID, name, excludeID string) (int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPCredential{}), ownerType, ownerID)
if excludeID == "" {
q = q.Where("active_name = ?", name)
} else {
q = q.Where("active_name = ? AND id <> ?", name, excludeID)
}
var count int64
err := q.Count(&count).Error
return count, err
}
func (r MCPRepo) CreateCredential(key *model.MCPCredential) error {
return r.DB.Create(key).Error
}
func (r MCPRepo) UpdateCredential(key *model.MCPCredential, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, revision).
Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(key)
return result.RowsAffected, result.Error
}
func (r MCPRepo) DeleteCredential(ownerType, ownerID, id string, revision uint64) (int64, error) {
result := mcpOwnerQuery(r.DB, ownerType, ownerID).Where("id = ? AND revision = ?", id, revision).Delete(&model.MCPCredential{})
return result.RowsAffected, result.Error
}
func (r MCPRepo) AdmissionCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").Take(&key).Error
return key, err
}
func (r MCPRepo) InvocationCredential(id string, revision uint64) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", id, revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&key).Error
return key, err
}
func (r MCPRepo) Token(hash string) (model.MCPCredential, error) {
var key model.MCPCredential
err := r.DB.Where("token_hash = ?", hash).First(&key).Error
@@ -17,8 +94,13 @@ func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) {
err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error
return grants, err
}
func (r MCPRepo) DeleteGrants(id string) error {
return r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error
}
func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil {
if err := r.DeleteGrants(id); err != nil {
return err
}
for _, grant := range grants {
@@ -29,3 +111,89 @@ func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error {
}
return nil
}
func (r MCPRepo) Setting() (model.MCPSetting, error) {
var setting model.MCPSetting
err := r.DB.First(&setting, 1).Error
return setting, err
}
func (r MCPRepo) Enabled() (bool, error) {
var setting struct{ Enabled bool }
err := r.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
return setting.Enabled, err
}
func (r MCPRepo) AdmissionEnabled() (bool, error) {
var setting model.MCPSetting
err := r.DB.Select("enabled").Take(&setting, 1).Error
return setting.Enabled, err
}
func (r MCPRepo) PanelSetting(key string) (model.Setting, error) {
var setting model.Setting
err := r.DB.Where("key = ?", key).First(&setting).Error
return setting, err
}
func (r MCPRepo) CreateSettingIfAbsent(setting *model.MCPSetting) error {
return r.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(setting).Error
}
func (r MCPRepo) UpdateRuntimeSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
"runtime_type": setting.RuntimeType, "docker_image": setting.DockerImage,
"enabled": setting.Enabled, "listen_port": setting.ListenPort, "external_url": setting.ExternalURL, "revision": setting.Revision,
"access_policy": setting.AccessPolicy, "allowed_ips": setting.AllowedIPs,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) UpdateWebsiteSetting(setting model.MCPSetting, revision uint64) (int64, error) {
result := r.DB.Model(&model.MCPSetting{}).Where("id = 1 AND revision = ?", revision).Updates(map[string]any{
"website_id": setting.WebsiteID, "website_binding_id": setting.WebsiteBindingID, "website_operation": setting.WebsiteOperation, "revision": setting.Revision,
})
return result.RowsAffected, result.Error
}
func (r MCPRepo) CreateEnrollmentAudit(record *model.OperationLog) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) CreateInvocation(record *model.MCPInvocation) error {
return r.DB.Create(record).Error
}
func (r MCPRepo) SearchInvocations(ownerType, ownerID string, page, pageSize int, credentialID, toolID, status string) ([]model.MCPInvocation, int64, error) {
q := mcpOwnerQuery(r.DB.Model(&model.MCPInvocation{}), ownerType, ownerID)
if credentialID != "" {
q = q.Where("credential_id = ?", credentialID)
}
if toolID != "" {
q = q.Where("tool_id = ?", toolID)
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r MCPRepo) UpdateRunningInvocation(record model.MCPInvocation) error {
return r.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
}
func (r MCPRepo) UpdateInterruptedInvocations(processStartedAt time.Time, status, code string, finishedAt time.Time) error {
return r.DB.Model(&model.MCPInvocation{}).
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": status, "code": code, "finished_at": finishedAt}).Error
}
+24 -48
View File
@@ -96,12 +96,11 @@ var NewMCPService = func() *MCPService {
defer cancel()
return NewMCPRuntimeService().Catalog(ctx)
}, Enabled: func() (bool, error) {
var setting struct{ Enabled bool }
err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error
enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled()
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return setting.Enabled, err
return enabled, err
}}
}
@@ -149,9 +148,6 @@ func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwn
}
return owner, nil
}
func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID)
}
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
func (s *MCPService) catalog() ([]contract.Tool, error) {
unavailable := func() error {
@@ -294,16 +290,8 @@ func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch)
if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid()
}
q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner)
if req.ExcludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
keys := []model.MCPCredential{}
if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil {
keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked)
if err != nil {
return nil, err
}
items := []dto.MCPCredentialItem{}
@@ -344,7 +332,9 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
repeated := false
mcpMutationMu.Lock()
err = s.DB.Transaction(func(tx *gorm.DB) error {
err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error
store := repo.MCPRepo{DB: tx}
var err error
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
if err == nil {
if key.RequestHash != requestHash {
return contract.Fail("MCP_CONFLICT", 409, "Creation request already used")
@@ -355,8 +345,8 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var count int64
if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil {
count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "")
if err != nil {
return err
}
if count > 0 {
@@ -371,10 +361,10 @@ func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate)
if err = s.sealCredentialToken(&key, token); err != nil {
return err
}
if err = tx.Create(&key).Error; err != nil {
if err = store.CreateCredential(&key); err != nil {
return err
}
if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil {
if err = store.ReplaceTools(key.ID, grants); err != nil {
return err
}
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
@@ -397,8 +387,9 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
mcpMutationMu.Lock()
defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error {
var key model.MCPCredential
if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
store := repo.MCPRepo{DB: tx}
key, err := store.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden()
}
@@ -415,11 +406,11 @@ func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutatio
key.SecretVersion = 0
}
key.Revision++
result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key)
if result.Error != nil {
return result.Error
affected, err := store.UpdateCredential(&key, req.Revision)
if err != nil {
return err
}
if result.RowsAffected != 1 {
if affected != 1 {
return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry")
}
return nil
@@ -434,8 +425,8 @@ func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate)
return err
}
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
var count int64
if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil {
count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID)
if err != nil {
return err
}
if count > 0 {
@@ -628,7 +619,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
record.Revision = key.Revision
}
record.Status = "running"
if err := s.DB.Create(&record).Error; err != nil {
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
}
record.Status = "denied"
@@ -707,10 +698,7 @@ func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (
if enabledErr != nil || !enabled {
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
}
var admitted model.MCPCredential
admissionErr := s.DB.Select("expires_at").
Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").
Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error
admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision)
if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
return finish(contract.Forbidden(), nil)
}
@@ -753,25 +741,13 @@ func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSe
if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid()
}
q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner)
if req.CredentialID != "" {
q = q.Where("credential_id = ?", req.CredentialID)
}
if req.ToolID != "" {
q = q.Where("tool_id = ?", req.ToolID)
}
if req.Status != "" {
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
return nil, contract.Invalid()
}
q = q.Where("status = ?", req.Status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
items := []model.MCPInvocation{}
if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil {
items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status)
if err != nil {
return nil, err
}
return map[string]any{"items": items, "total": total}, nil
+8 -6
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -97,7 +98,7 @@ func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission)
if known, ok := err.(*contract.Error); ok {
record.Code = known.Code
}
if auditErr := s.DB.Create(&record).Error; auditErr != nil {
if auditErr := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); auditErr != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable")
}
if known, ok := err.(*contract.Error); ok {
@@ -164,15 +165,16 @@ func newMCPAdmissionID() string { return uuid.NewString() }
func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error {
return s.DB.Transaction(func(tx *gorm.DB) error {
var latest model.MCPCredential
if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil {
data := repo.MCPRepo{DB: tx}
latest, err := data.AdmissionCredential(key.ID, key.Revision)
if err != nil {
return contract.Forbidden()
}
if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) {
return contract.Forbidden()
}
var setting model.MCPSetting
if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled {
enabled, err := data.AdmissionEnabled()
if err != nil || !enabled {
return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
}
@@ -236,7 +238,7 @@ func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion)
now := s.Now()
outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode)
outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds()
if err := s.DB.Create(outcome).Error; err != nil {
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(outcome); err != nil {
return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically")
}
result.InvocationID = outcome.ID
+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/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
paneli18n "github.com/1Panel-dev/1Panel/core/i18n"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
@@ -92,7 +93,9 @@ func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredential
if err := s.authorize(owner, "mcp_access_manage"); err != nil {
return key, err
}
if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil {
var err error
key, err = (repo.MCPRepo{DB: s.DB}).Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return key, contract.Forbidden()
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/google/uuid"
@@ -29,7 +30,7 @@ func mcpEnrollmentAuditCode(err error) string {
func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error {
failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned")
if action != "create" && action != "reveal" && action != "rotate" && action != "export" {
if action != "create" && action != "reveal" && action != "rotate" && action != "export" && action != "delete" {
return failed
}
id := "invalid"
@@ -58,7 +59,7 @@ func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action
if s.EnrollmentAudit != nil {
err = s.EnrollmentAudit(db, record)
} else {
err = db.Create(record).Error
err = (repo.MCPRepo{DB: db}).CreateEnrollmentAudit(record)
}
if err != nil {
return failed
+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"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
)
// SQLite stores time.Time with the server's offset. Compare absolute seconds
// and integer nanoseconds so offset changes and repeated local hours remain
// chronological without changing the stored timestamps or losing precision.
const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)"
const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END"
func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 50 * time.Millisecond)
}
// Only compensate the audit write. The business operation must never be replayed.
err = s.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running").
Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error
err = (repo.MCPRepo{DB: s.DB}).UpdateRunningInvocation(record)
if err == nil {
return nil
}
@@ -28,12 +21,7 @@ func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error {
return err
}
// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed
// process-start boundary prevents a later invocation from being marked interrupted.
func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error {
now := s.Now()
return s.DB.Model(&model.MCPInvocation{}).
Where("status = ? AND finished_at IS NULL", "running").
Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()).
Updates(map[string]any{"status": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error
return (repo.MCPRepo{DB: s.DB}).UpdateInterruptedInvocations(processStartedAt, "failed", "MCP_OUTCOME_UNKNOWN", now)
}
+138 -31
View File
@@ -8,20 +8,35 @@ import (
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/global"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/mcpruntime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var mcpRuntimeMutation sync.Mutex
var mcpInstalling atomic.Bool
var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving")
func lockMCPRuntimeMutation() error {
conflict := func() error { return contract.Fail("MCP_CONFLICT", 409, "An MCP service operation is already running") }
if mcpInstalling.Load() {
return conflict()
}
mcpRuntimeMutation.Lock()
if mcpInstalling.Load() {
mcpRuntimeMutation.Unlock()
return conflict()
}
return nil
}
type mcpRuntimeManager interface {
Prepare(context.Context) error
Catalog(context.Context) ([]contract.Tool, error)
@@ -40,6 +55,14 @@ type mcpRuntimeSnapshot struct {
unavailable bool
}
type mcpRuntimeRecoveryError struct{ cause error }
func (e *mcpRuntimeRecoveryError) Error() string {
return "MCP runtime recovery is incomplete; check the service state before retrying"
}
func (e *mcpRuntimeRecoveryError) Unwrap() error { return e.cause }
func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) {
snapshot := mcpRuntimeSnapshot{settings: settings}
state := s.manager(settings).Status(ctx, settings.Enabled)
@@ -51,7 +74,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
}
snapshot.installed = state.Installed
if state.Installed {
if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil {
if state.AdapterVersion == "" || !mcpruntime.ValidDockerImageID(state.ImageID) {
snapshot.unavailable = true
return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity")
}
@@ -65,6 +88,7 @@ func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntim
type MCPRuntimeService struct {
newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager
websiteTransport mcpWebsiteTransport
}
func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager {
@@ -96,8 +120,7 @@ func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSetting
ListenPort: mcpruntime.DefaultPort, Revision: 1,
AccessPolicy: "inherit", AllowedIPs: []string{},
}
var saved model.MCPSetting
err := global.DB.WithContext(ctx).First(&saved, 1).Error
saved, err := (repo.MCPRepo{DB: global.DB.WithContext(ctx)}).Setting()
if errors.Is(err, gorm.ErrRecordNotFound) {
return settings, nil
}
@@ -121,7 +144,9 @@ func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error
}
func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) {
mcpRuntimeMutation.Lock()
if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeSettings{}, err
}
defer mcpRuntimeMutation.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel()
@@ -136,6 +161,13 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
if req.Revision != old.Revision {
return old, ErrMCPSettingsRevision
}
if req.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(req); err != nil {
return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
@@ -166,19 +198,20 @@ func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettin
func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil {
if err := store.CreateSettingIfAbsent(&seed); err != nil {
return err
}
result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{
"runtime_type": "docker", "docker_image": req.DockerImage,
"enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1,
"access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs),
})
if result.Error != nil {
return result.Error
rowsAffected, err := store.UpdateRuntimeSetting(model.MCPSetting{
RuntimeType: "docker", DockerImage: req.DockerImage,
Enabled: req.Enabled, ListenPort: req.ListenPort, ExternalURL: req.ExternalURL, Revision: revision + 1,
AccessPolicy: req.AccessPolicy, AllowedIPs: encodeMCPIPs(req.AllowedIPs),
}, revision)
if err != nil {
return err
}
if result.RowsAffected != 1 {
if rowsAffected != 1 {
return ErrMCPSettingsRevision
}
return nil
@@ -212,16 +245,27 @@ func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, e
}
state := s.manager(settings).Status(ctx, settings.Enabled)
return dto.MCPRuntimeStatus{
result := dto.MCPRuntimeStatus{
RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID,
Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason,
AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL,
InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort),
}, nil
}
website, websiteErr := s.WebsiteStatus(ctx)
if websiteErr != nil {
return result, websiteErr
}
if website.State != "unbound" {
result.Website = &website
result.Endpoint = website.Endpoint
}
return result, nil
}
func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock()
if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock()
settings, err := s.Settings()
if err != nil {
@@ -242,20 +286,46 @@ func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.
}
func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
mcpRuntimeMutation.Lock()
if err := lockMCPRuntimeMutation(); err != nil {
return dto.MCPRuntimeStatus{}, err
}
defer mcpRuntimeMutation.Unlock()
old, err := s.settings(ctx)
return s.install(ctx, req)
}
func (s *MCPRuntimeService) installSettings(ctx context.Context, req dto.MCPRuntimeInstall) (old, next dto.MCPRuntimeSettings, err error) {
old, err = s.settings(ctx)
if err != nil {
return old, next, err
}
if req.Revision != old.Revision {
return old, next, ErrMCPSettingsRevision
}
next = old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
next.AccessPolicy, next.AllowedIPs = req.AccessPolicy, append([]string{}, req.AllowedIPs...)
if next.ExternalURL != old.ExternalURL {
if row, loadErr := s.websiteSetting(ctx); loadErr != nil {
return old, next, loadErr
} else if row.WebsiteID != 0 || row.WebsiteBindingID != "" {
return old, next, contract.Fail("MCP_CONFLICT", 409, "The website controls the MCP endpoint")
}
}
if err := validateMCPRuntimeSettings(next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
if err := normalizeMCPAccessSettings(&next); err != nil {
return old, next, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
return old, next, nil
}
func (s *MCPRuntimeService) install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) {
old, next, err := s.installSettings(ctx, req)
if err != nil {
return dto.MCPRuntimeStatus{}, err
}
if req.Revision != old.Revision {
return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision
}
next := old
next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL
if err := validateMCPRuntimeSettings(next); err != nil {
return dto.MCPRuntimeStatus{}, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error())
}
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "snapshot"})
snapshot, err := s.snapshot(ctx, old)
if err != nil {
return dto.MCPRuntimeStatus{}, err
@@ -263,6 +333,15 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
if !snapshot.installed {
next.Enabled = true
}
prepared := old
policyPrepared := !snapshot.installed || next.AccessPolicy != old.AccessPolicy || !reflect.DeepEqual(next.AllowedIPs, old.AllowedIPs)
if policyPrepared {
prepared.AccessPolicy, prepared.AllowedIPs = next.AccessPolicy, next.AllowedIPs
if err := s.persist(prepared, old.Revision); err != nil {
return dto.MCPRuntimeStatus{}, err
}
prepared.Revision++
}
manager := s.manager(next)
rollback := func(authoritative dto.MCPRuntimeSettings) error {
if !snapshot.installed && reflect.DeepEqual(authoritative, old) {
@@ -273,21 +352,49 @@ func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInsta
return s.restore(authoritative, snapshot)
}
recoverFailure := func(operationErr error) error {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "rollback"})
current, loadErr := s.Settings()
if loadErr != nil {
return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available"))
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, loadErr)}
}
return errors.Join(operationErr, rollback(current))
if policyPrepared && current.Revision == prepared.Revision {
if recoveryErr := rollback(old); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
if recoveryErr := s.persist(old, prepared.Revision); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
}
if recoveryErr := rollback(current); recoveryErr != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "recovery_failed"})
return &mcpRuntimeRecoveryError{cause: errors.Join(operationErr, recoveryErr)}
}
return operationErr
}
if err := manager.InstallImage(ctx, next.Enabled); err != nil {
if err := installMCPRuntimeImage(ctx, manager, next.Enabled); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err)
}
if err := s.persist(next, old.Revision); err != nil {
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "saving"})
if err := s.persist(next, prepared.Revision); err != nil {
return dto.MCPRuntimeStatus{}, recoverFailure(err)
}
mcpruntime.ReportProgress(ctx, mcpruntime.Progress{Status: "installed"})
return s.Status(ctx)
}
func installMCPRuntimeImage(ctx context.Context, manager mcpRuntimeManager, enabled bool) (err error) {
defer func() {
if recover() != nil {
err = errors.New("MCP Docker image installation failed")
}
}()
return manager.InstallImage(ctx, enabled)
}
func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) {
settings, err := s.Settings()
if err != nil {
+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"
TaskScopeAppInstall = "AppInstallTask"
TaskScopeAI = "AI"
TaskScopePanelMCP = "PanelMCP"
)
func GetTaskName(resourceName, operate, scope string) string {
@@ -321,6 +322,12 @@ func (t *Task) DeleteLogFile() {
_ = os.Remove(t.Task.LogFile)
}
func (t *Task) Close() {
if t.logFile != nil {
_ = t.logFile.Close()
}
}
func (t *Task) LogWithStatus(msg string, err error) {
if err != nil {
t.Logger.Print(i18n.GetWithNameAndErr("FailedStatus", msg, err))
+3
View File
@@ -73,11 +73,14 @@ var WebUrlMap = map[string]struct{}{
"/ai/benchmark": {},
"/ai/model/account": {},
"/ai/model/local": {},
"/ai/model/local/templates": {},
"/ai/model/files": {},
"/ai/gpu": {},
"/ai/gpu/current": {},
"/ai/gpu/history": {},
"/ai/mcp": {},
"/ai/mcp/server": {},
"/ai/mcp/1panel": {},
"/ai/agents/agent": {},
"/containers": {},
+2
View File
@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/creack/pty v1.1.24
github.com/crewjam/saml v0.5.1
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
@@ -106,6 +107,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pdfcpu/pdfcpu v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
+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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
@@ -200,6 +202,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+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."
ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing."
ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first."
ErrMCPWebsitePending: "The website operation result is uncertain. Refresh the binding status before retrying the original operation."
ErrMCPWebsiteUnavailable: "The website binding is unavailable. Check the website status and OpenResty configuration."
ErrMCPWebsiteServiceRequired: "Install the 1Panel MCP service first."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Check the current MCP service"
MCPInstallPullImage: "Check and pull the image"
MCPInstallCatalog: "Validate the tool contract"
MCPInstallStarting: "Start the container and check its health"
MCPInstallSaving: "Save the service configuration"
MCPInstallCompleted: "MCP service installation completed"
MCPInstallRollback: "Installation failed; restore the previous MCP service"
MCPInstallBindWebsite: "Create the bound website"
MCPInstallWebsiteFailed: "The MCP service is installed, but website binding failed. Resolve the website configuration in the service settings."
MCPInstallInterrupted: "Installation was interrupted by a panel restart. Check the service status before continuing."
MCPInstallFailed: "MCP installation failed. Check Docker, the image, and the panel service logs."
MCPInstallRecoveryFailed: "Installation failed and the previous service could not be fully restored. Check the MCP service status before continuing."
MCPInstallLocalImage: "The image exists locally; skip downloading"
MCPInstallVerifyImage: "Validate the image version and managed service contract"
MCPInstallVerifiedImage: "Image validation passed"
+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."
ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar."
ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida."
ErrMCPWebsitePending: "El resultado de la operación del sitio es incierto. Actualice el estado de la vinculación antes de reintentar la operación original."
ErrMCPWebsiteUnavailable: "La vinculación del sitio no está disponible. Revise el estado del sitio y la configuración de OpenResty."
ErrMCPWebsiteServiceRequired: "Instale primero el servicio 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Comprobar el servicio MCP actual"
MCPInstallPullImage: "Comprobar y descargar la imagen"
MCPInstallCatalog: "Validar el contrato de herramientas"
MCPInstallStarting: "Iniciar el contenedor y comprobar su estado"
MCPInstallSaving: "Guardar la configuración del servicio"
MCPInstallCompleted: "Instalación del servicio MCP completada"
MCPInstallRollback: "La instalación falló; restaurar el servicio MCP anterior"
MCPInstallBindWebsite: "Crear el sitio web vinculado"
MCPInstallWebsiteFailed: "El servicio MCP está instalado, pero la vinculación del sitio web falló. Revise la configuración del sitio web en los ajustes del servicio."
MCPInstallInterrupted: "Un reinicio del panel interrumpió la instalación. Compruebe el estado del servicio antes de continuar."
MCPInstallFailed: "La instalación de MCP falló. Revise Docker, la imagen y los registros del servicio del panel."
MCPInstallRecoveryFailed: "La instalación falló y el servicio anterior no se pudo restaurar por completo. Compruebe el estado del servicio MCP antes de continuar."
MCPInstallLocalImage: "La imagen ya existe localmente; omitir la descarga"
MCPInstallVerifyImage: "Validar la versión de la imagen y el contrato del servicio administrado"
MCPInstallVerifiedImage: "La imagen superó la validación"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن ن
ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است."
ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید."
ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید."
ErrMCPWebsitePending: "نتیجه عملیات وب‌سایت تأیید نشده است. پیش از تلاش دوباره برای عملیات اصلی، وضعیت اتصال را تازه‌سازی کنید."
ErrMCPWebsiteUnavailable: "اتصال وب‌سایت در دسترس نیست. وضعیت وب‌سایت و تنظیمات OpenResty را بررسی کنید."
ErrMCPWebsiteServiceRequired: "ابتدا سرویس 1Panel MCP را نصب کنید."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "بررسی سرویس فعلی MCP"
MCPInstallPullImage: "بررسی و دریافت تصویر"
MCPInstallCatalog: "اعتبارسنجی قرارداد ابزارها"
MCPInstallStarting: "راه‌اندازی کانتینر و بررسی سلامت آن"
MCPInstallSaving: "ذخیره پیکربندی سرویس"
MCPInstallCompleted: "نصب سرویس MCP تکمیل شد"
MCPInstallRollback: "نصب ناموفق بود؛ بازیابی سرویس قبلی MCP"
MCPInstallBindWebsite: "ایجاد وب‌سایت متصل"
MCPInstallWebsiteFailed: "سرویس MCP نصب شده است، اما اتصال وب‌سایت ناموفق بود. پیکربندی وب‌سایت را در تنظیمات سرویس اصلاح کنید."
MCPInstallInterrupted: "راه‌اندازی مجدد پنل باعث قطع نصب شد. پیش از ادامه، وضعیت سرویس را بررسی کنید."
MCPInstallFailed: "نصب MCP ناموفق بود. Docker، تصویر و گزارش‌های سرویس پنل را بررسی کنید."
MCPInstallRecoveryFailed: "نصب ناموفق بود و سرویس قبلی به‌طور کامل بازیابی نشد. پیش از ادامه، وضعیت سرویس MCP را بررسی کنید."
MCPInstallLocalImage: "تصویر به‌صورت محلی موجود است؛ دریافت انجام نمی‌شود"
MCPInstallVerifyImage: "اعتبارسنجی نسخه تصویر و قرارداد سرویس مدیریت‌شده"
MCPInstallVerifiedImage: "اعتبارسنجی تصویر موفق بود"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token
ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。"
ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。"
ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。"
ErrMCPWebsitePending: "サイト操作の結果を確認できません。連携状態を更新してから元の操作を再試行してください。"
ErrMCPWebsiteUnavailable: "サイト連携を利用できません。サイトの状態と OpenResty の設定を確認してください。"
ErrMCPWebsiteServiceRequired: "先に 1Panel MCP サービスをインストールしてください。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "現在の MCP サービスを確認"
MCPInstallPullImage: "イメージを確認して取得"
MCPInstallCatalog: "ツールの契約を検証"
MCPInstallStarting: "コンテナを起動して正常性を確認"
MCPInstallSaving: "サービス設定を保存"
MCPInstallCompleted: "MCP サービスのインストールが完了しました"
MCPInstallRollback: "インストールに失敗したため、以前の MCP サービスを復元"
MCPInstallBindWebsite: "連携する Web サイトを作成"
MCPInstallWebsiteFailed: "MCP サービスはインストールされましたが、Web サイトの連携に失敗しました。サービス設定で Web サイトの設定を確認してください。"
MCPInstallInterrupted: "パネルの再起動によりインストールが中断されました。操作を続ける前にサービスの状態を確認してください。"
MCPInstallFailed: "MCP のインストールに失敗しました。Docker、イメージ、パネルサービスのログを確認してください。"
MCPInstallRecoveryFailed: "インストールに失敗し、以前のサービスを完全に復元できませんでした。操作を続ける前に MCP サービスの状態を確認してください。"
MCPInstallLocalImage: "ローカルにイメージが存在するため、ダウンロードをスキップ"
MCPInstallVerifyImage: "イメージのバージョンと管理対象サービスの契約を検証"
MCPInstallVerifiedImage: "イメージの検証に成功しました"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Toke
ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다."
ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요."
ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요."
ErrMCPWebsitePending: "웹사이트 작업 결과를 확인할 수 없습니다. 연결 상태를 새로 고친 후 원래 작업을 다시 시도하세요."
ErrMCPWebsiteUnavailable: "웹사이트 연결을 사용할 수 없습니다. 웹사이트 상태와 OpenResty 설정을 확인하세요."
ErrMCPWebsiteServiceRequired: "먼저 1Panel MCP 서비스를 설치하세요."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "현재 MCP 서비스 확인"
MCPInstallPullImage: "이미지 확인 및 가져오기"
MCPInstallCatalog: "도구 계약 검증"
MCPInstallStarting: "컨테이너 시작 및 상태 확인"
MCPInstallSaving: "서비스 구성 저장"
MCPInstallCompleted: "MCP 서비스 설치 완료"
MCPInstallRollback: "설치 실패로 이전 MCP 서비스 복원"
MCPInstallBindWebsite: "연결할 웹사이트 생성"
MCPInstallWebsiteFailed: "MCP 서비스는 설치되었지만 웹사이트 연결에 실패했습니다. 서비스 설정에서 웹사이트 구성을 확인하세요."
MCPInstallInterrupted: "패널이 다시 시작되어 설치가 중단되었습니다. 계속하기 전에 서비스 상태를 확인하세요."
MCPInstallFailed: "MCP 설치에 실패했습니다. Docker, 이미지 및 패널 서비스 로그를 확인하세요."
MCPInstallRecoveryFailed: "설치에 실패했으며 이전 서비스를 완전히 복원하지 못했습니다. 계속하기 전에 MCP 서비스 상태를 확인하세요."
MCPInstallLocalImage: "로컬 이미지가 있어 다운로드를 건너뜁니다"
MCPInstallVerifyImage: "이미지 버전 및 관리형 서비스 계약 검증"
MCPInstallVerifiedImage: "이미지 검증 통과"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token
ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ."
ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່."
ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ."
ErrMCPWebsitePending: "ຍັງບໍ່ສາມາດຢືນຢັນຜົນການດຳເນີນການເວັບໄຊ. ໂຫຼດສະຖານະການຜູກມັດໃໝ່ກ່ອນລອງການດຳເນີນການເດີມອີກຄັ້ງ."
ErrMCPWebsiteUnavailable: "ການຜູກມັດເວັບໄຊບໍ່ພ້ອມໃຊ້ງານ. ກວດສອບສະຖານະເວັບໄຊ ແລະ ການຕັ້ງຄ່າ OpenResty."
ErrMCPWebsiteServiceRequired: "ກະລຸນາຕິດຕັ້ງບໍລິການ 1Panel MCP ກ່ອນ."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "ກວດສອບບໍລິການ MCP ປັດຈຸບັນ"
MCPInstallPullImage: "ກວດສອບ ແລະ ດຶງອິມເມດ"
MCPInstallCatalog: "ກວດສອບສັນຍາຂອງເຄື່ອງມື"
MCPInstallStarting: "ເລີ່ມຄອນເທນເນີ ແລະ ກວດສອບສຸຂະພາບ"
MCPInstallSaving: "ບັນທຶກການຕັ້ງຄ່າບໍລິການ"
MCPInstallCompleted: "ຕິດຕັ້ງບໍລິການ MCP ສຳເລັດແລ້ວ"
MCPInstallRollback: "ການຕິດຕັ້ງລົ້ມເຫຼວ; ກູ້ຄືນບໍລິການ MCP ກ່ອນໜ້າ"
MCPInstallBindWebsite: "ສ້າງເວັບໄຊທີ່ເຊື່ອມໂຍງ"
MCPInstallWebsiteFailed: "ຕິດຕັ້ງບໍລິການ MCP ແລ້ວ ແຕ່ການເຊື່ອມໂຍງເວັບໄຊລົ້ມເຫຼວ. ກະລຸນາແກ້ໄຂການຕັ້ງຄ່າເວັບໄຊໃນການຕັ້ງຄ່າບໍລິການ."
MCPInstallInterrupted: "ການເລີ່ມແຜງຄວບຄຸມໃໝ່ເຮັດໃຫ້ການຕິດຕັ້ງຢຸດຊະງັກ. ກະລຸນາກວດສອບສະຖານະບໍລິການກ່ອນດຳເນີນການຕໍ່."
MCPInstallFailed: "ການຕິດຕັ້ງ MCP ລົ້ມເຫຼວ. ກະລຸນາກວດສອບ Docker, ອິມເມດ ແລະ ບັນທຶກບໍລິການແຜງຄວບຄຸມ."
MCPInstallRecoveryFailed: "ການຕິດຕັ້ງລົ້ມເຫຼວ ແລະ ບໍລິການເດີມບໍ່ສາມາດກູ້ຄືນໄດ້ຄົບຖ້ວນ. ກະລຸນາກວດສອບສະຖານະບໍລິການ MCP ກ່ອນດຳເນີນການຕໍ່."
MCPInstallLocalImage: "ມີອິມເມດຢູ່ໃນເຄື່ອງແລ້ວ; ຂ້າມການດາວໂຫຼດ"
MCPInstallVerifyImage: "ກວດສອບເວີຊັນອິມເມດ ແລະ ສັນຍາບໍລິການທີ່ຈັດການ"
MCPInstallVerifiedImage: "ກວດສອບອິມເມດຜ່ານແລ້ວ"
+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."
ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan."
ErrMCPEndpointUnavailable: "Konfigurasikan alamat luaran MCP yang sah terlebih dahulu."
ErrMCPWebsitePending: "Hasil operasi laman web belum dapat dipastikan. Muat semula status pautan sebelum mencuba semula operasi asal."
ErrMCPWebsiteUnavailable: "Pautan laman web tidak tersedia. Semak status laman web dan konfigurasi OpenResty."
ErrMCPWebsiteServiceRequired: "Pasang perkhidmatan 1Panel MCP terlebih dahulu."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Semak perkhidmatan MCP semasa"
MCPInstallPullImage: "Semak dan tarik imej"
MCPInstallCatalog: "Sahkan kontrak alat"
MCPInstallStarting: "Mulakan bekas dan semak kesihatannya"
MCPInstallSaving: "Simpan konfigurasi perkhidmatan"
MCPInstallCompleted: "Pemasangan perkhidmatan MCP selesai"
MCPInstallRollback: "Pemasangan gagal; pulihkan perkhidmatan MCP sebelumnya"
MCPInstallBindWebsite: "Cipta laman web yang dipautkan"
MCPInstallWebsiteFailed: "Perkhidmatan MCP telah dipasang, tetapi pautan laman web gagal. Betulkan konfigurasi laman web dalam tetapan perkhidmatan."
MCPInstallInterrupted: "Pemasangan terganggu kerana panel dimulakan semula. Semak status perkhidmatan sebelum meneruskan."
MCPInstallFailed: "Pemasangan MCP gagal. Semak Docker, imej dan log perkhidmatan panel."
MCPInstallRecoveryFailed: "Pemasangan gagal dan perkhidmatan sebelumnya tidak dapat dipulihkan sepenuhnya. Semak status perkhidmatan MCP sebelum meneruskan."
MCPInstallLocalImage: "Imej sudah tersedia secara setempat; langkau muat turun"
MCPInstallVerifyImage: "Sahkan versi imej dan kontrak perkhidmatan terurus"
MCPInstallVerifiedImage: "Pengesahan imej berjaya"
+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."
ErrMCPAuditUnavailable: "A auditoria de operações MCP está indisponível. Verifique o resultado antes de continuar."
ErrMCPEndpointUnavailable: "Configure primeiro um endereço externo MCP válido."
ErrMCPWebsitePending: "O resultado da operação do site é incerto. Atualize o estado do vínculo antes de repetir a operação original."
ErrMCPWebsiteUnavailable: "O vínculo do site está indisponível. Verifique o estado do site e a configuração do OpenResty."
ErrMCPWebsiteServiceRequired: "Instale primeiro o serviço 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Verificar o serviço MCP atual"
MCPInstallPullImage: "Verificar e baixar a imagem"
MCPInstallCatalog: "Validar o contrato das ferramentas"
MCPInstallStarting: "Iniciar o contêiner e verificar sua integridade"
MCPInstallSaving: "Salvar a configuração do serviço"
MCPInstallCompleted: "Instalação do serviço MCP concluída"
MCPInstallRollback: "A instalação falhou; restaurar o serviço MCP anterior"
MCPInstallBindWebsite: "Criar o site vinculado"
MCPInstallWebsiteFailed: "O serviço MCP está instalado, mas a vinculação do site falhou. Resolva a configuração do site nas configurações do serviço."
MCPInstallInterrupted: "Uma reinicialização do painel interrompeu a instalação. Verifique o estado do serviço antes de continuar."
MCPInstallFailed: "A instalação do MCP falhou. Verifique o Docker, a imagem e os logs do serviço do painel."
MCPInstallRecoveryFailed: "A instalação falhou e o serviço anterior não pôde ser totalmente restaurado. Verifique o estado do serviço MCP antes de continuar."
MCPInstallLocalImage: "A imagem já existe localmente; ignorar o download"
MCPInstallVerifyImage: "Validar a versão da imagem e o contrato do serviço gerenciado"
MCPInstallVerifiedImage: "A imagem passou na validação"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "Не удалось расшифровать сохран
ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны."
ErrMCPAuditUnavailable: "Аудит операций MCP недоступен. Проверьте результат операции, прежде чем продолжить."
ErrMCPEndpointUnavailable: "Сначала настройте корректный внешний адрес MCP."
ErrMCPWebsitePending: "Результат операции с сайтом не подтверждён. Обновите состояние привязки перед повтором исходной операции."
ErrMCPWebsiteUnavailable: "Привязка сайта недоступна. Проверьте состояние сайта и настройки OpenResty."
ErrMCPWebsiteServiceRequired: "Сначала установите службу 1Panel MCP."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Проверить текущую службу MCP"
MCPInstallPullImage: "Проверить и загрузить образ"
MCPInstallCatalog: "Проверить контракт инструментов"
MCPInstallStarting: "Запустить контейнер и проверить его работоспособность"
MCPInstallSaving: "Сохранить конфигурацию службы"
MCPInstallCompleted: "Установка службы MCP завершена"
MCPInstallRollback: "Установка не удалась; восстановить предыдущую службу MCP"
MCPInstallBindWebsite: "Создать привязанный сайт"
MCPInstallWebsiteFailed: "Служба MCP установлена, но привязать сайт не удалось. Исправьте конфигурацию сайта в настройках службы."
MCPInstallInterrupted: "Перезапуск панели прервал установку. Проверьте состояние службы перед продолжением."
MCPInstallFailed: "Не удалось установить MCP. Проверьте Docker, образ и журналы службы панели."
MCPInstallRecoveryFailed: "Установка не удалась, а предыдущую службу не удалось полностью восстановить. Проверьте состояние службы MCP перед продолжением."
MCPInstallLocalImage: "Образ уже есть локально; пропустить загрузку"
MCPInstallVerifyImage: "Проверить версию образа и контракт управляемой службы"
MCPInstallVerifiedImage: "Образ успешно проверен"
+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ş."
ErrMCPAuditUnavailable: "MCP işlem denetimi kullanılamıyor. Devam etmeden önce işlem sonucunu doğrulayın."
ErrMCPEndpointUnavailable: "Önce geçerli bir harici MCP adresi yapılandırın."
ErrMCPWebsitePending: "Web sitesi işleminin sonucu doğrulanamadı. İlk işlemi yeniden denemeden önce bağlantı durumunu yenileyin."
ErrMCPWebsiteUnavailable: "Web sitesi bağlantısı kullanılamıyor. Site durumunu ve OpenResty yapılandırmasını kontrol edin."
ErrMCPWebsiteServiceRequired: "Önce 1Panel MCP hizmetini yükleyin."
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "Geçerli MCP hizmetini kontrol et"
MCPInstallPullImage: "İmajı kontrol et ve indir"
MCPInstallCatalog: "Araç sözleşmesini doğrula"
MCPInstallStarting: "Konteyneri başlat ve sağlık durumunu kontrol et"
MCPInstallSaving: "Hizmet yapılandırmasını kaydet"
MCPInstallCompleted: "MCP hizmetinin kurulumu tamamlandı"
MCPInstallRollback: "Kurulum başarısız oldu; önceki MCP hizmetini geri yükle"
MCPInstallBindWebsite: "Bağlı web sitesini oluştur"
MCPInstallWebsiteFailed: "MCP hizmeti kuruldu ancak web sitesi bağlanamadı. Web sitesi yapılandırmasını hizmet ayarlarından düzenleyin."
MCPInstallInterrupted: "Panelin yeniden başlatılması kurulumu kesintiye uğrattı. Devam etmeden önce hizmet durumunu kontrol edin."
MCPInstallFailed: "MCP kurulumu başarısız oldu. Docker, imaj ve panel hizmeti günlüklerini kontrol edin."
MCPInstallRecoveryFailed: "Kurulum başarısız oldu ve önceki hizmet tam olarak geri yüklenemedi. Devam etmeden önce MCP hizmetinin durumunu kontrol edin."
MCPInstallLocalImage: "İmaj yerel olarak mevcut; indirmeyi atla"
MCPInstallVerifyImage: "İmaj sürümünü ve yönetilen hizmet sözleşmesini doğrula"
MCPInstallVerifiedImage: "İmaj doğrulaması başarılı"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "儲存的 MCP Token 無法解密,請重新產生 Token
ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。"
ErrMCPAuditUnavailable: "MCP 操作稽核暫時無法使用,請核對操作結果後再繼續。"
ErrMCPEndpointUnavailable: "請先設定有效的 MCP 外部連線位址。"
ErrMCPWebsitePending: "網站操作結果待確認,請重新整理綁定狀態後重試原操作。"
ErrMCPWebsiteUnavailable: "網站綁定無法使用,請檢查網站狀態和 OpenResty 設定。"
ErrMCPWebsiteServiceRequired: "請先安裝 1Panel MCP 服務。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "檢查目前的 MCP 服務"
MCPInstallPullImage: "檢查並拉取映像"
MCPInstallCatalog: "驗證工具契約"
MCPInstallStarting: "啟動容器並檢查健康狀態"
MCPInstallSaving: "儲存服務設定"
MCPInstallCompleted: "MCP 服務安裝完成"
MCPInstallRollback: "安裝失敗,還原先前的 MCP 服務"
MCPInstallBindWebsite: "建立綁定網站"
MCPInstallWebsiteFailed: "MCP 服務已安裝,但網站綁定失敗。請在服務設定中處理網站設定。"
MCPInstallInterrupted: "面板重新啟動導致安裝中斷。請檢查服務狀態後再操作。"
MCPInstallFailed: "MCP 安裝失敗。請檢查 Docker、映像及面板服務日誌。"
MCPInstallRecoveryFailed: "安裝失敗且原服務未能完整還原,請檢查 MCP 服務狀態後再操作。"
MCPInstallLocalImage: "本機映像已存在,略過下載"
MCPInstallVerifyImage: "驗證映像版本及受管契約"
MCPInstallVerifiedImage: "映像驗證通過"
+21
View File
@@ -542,3 +542,24 @@ ErrMCPSecretInvalid: "存储的 MCP Token 无法解密,请重新生成 Token
ErrMCPCredentialInactive: "MCP 凭据已停用、过期或撤销。"
ErrMCPAuditUnavailable: "MCP 操作审计暂不可用,请核对操作结果后再继续。"
ErrMCPEndpointUnavailable: "请先配置有效的 MCP 外部接入地址。"
ErrMCPWebsitePending: "网站操作结果待确认,请刷新绑定状态后重试原操作。"
ErrMCPWebsiteUnavailable: "网站绑定不可用,请检查网站状态和 OpenResty 配置。"
ErrMCPWebsiteServiceRequired: "请先安装 1Panel MCP 服务。"
# MCP installation tasks
PanelMCP: "1Panel MCP"
MCPInstallPrepare: "检查当前 MCP 服务"
MCPInstallPullImage: "检查并拉取镜像"
MCPInstallCatalog: "校验工具契约"
MCPInstallStarting: "启动容器并检查健康状态"
MCPInstallSaving: "保存服务配置"
MCPInstallCompleted: "MCP 服务安装完成"
MCPInstallRollback: "安装失败,恢复之前的 MCP 服务"
MCPInstallBindWebsite: "创建绑定网站"
MCPInstallWebsiteFailed: "MCP 服务已安装,但网站绑定失败。请在服务设置中处理网站配置。"
MCPInstallInterrupted: "面板重启导致安装中断。请检查服务状态后再操作。"
MCPInstallFailed: "MCP 安装失败。请检查 Docker、镜像和面板服务日志。"
MCPInstallRecoveryFailed: "安装失败且原服务未能完整恢复,请检查 MCP 服务状态后再操作。"
MCPInstallLocalImage: "本地镜像已存在,跳过下载"
MCPInstallVerifyImage: "校验镜像版本和受管契约"
MCPInstallVerifiedImage: "镜像校验通过"
+4 -1
View File
@@ -7,8 +7,11 @@ import (
)
var AddPanelMCP = &gormigrate.Migration{
ID: "20261008-panel-mcp",
ID: "20261009-panel-mcp",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable(&model.MCPSetting{}); err != nil {
return err
}
return tx.AutoMigrate(&model.MCPCredential{}, &model.MCPGrant{}, &model.MCPInvocation{}, &model.MCPSetting{})
},
}
+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"
}
+17 -1
View File
@@ -32,10 +32,21 @@ var loadLocalTerminalCapabilities = func(ctx context.Context) (interface{}, erro
return proxy_local.NewLocalClientWithContext(ctx, terminalsession.CapabilityPath, http.MethodGet, nil, nil, 5*time.Second)
}
func Proxy() gin.HandlerFunc {
type agentProxyPolicy struct {
clear func(*http.Request)
grant func(*gin.Context, bool)
}
func Proxy(policies ...agentProxyPolicy) gin.HandlerFunc {
policies = append([]agentProxyPolicy(nil), policies...)
return func(c *gin.Context) {
terminalsession.ClearForwardedHeaders(c)
mcpapi.ClearForwardedHeaders(c.Request.Header)
for _, policy := range policies {
if policy.clear != nil {
policy.clear(c.Request)
}
}
if mcpapi.HasIdentity(c.Request) && !mcpapi.IsRequest(c.Request) {
c.AbortWithStatus(http.StatusForbidden)
return
@@ -105,6 +116,11 @@ func Proxy() gin.HandlerFunc {
helper.ErrorWithDetail(c, http.StatusForbidden, "ErrProxy", errInternalOnlyAgentEndpoint)
return
}
for _, policy := range policies {
if policy.grant != nil {
policy.grant(c, useLocalAgent)
}
}
if reqPath == "/api/v2/hosts/terminal/local" && useLocalAgent {
proxyLocalAgent(c)
+2 -2
View File
@@ -74,7 +74,7 @@ func Routers() *gin.Engine {
Router.Use(middleware.MCPAPIIdentity())
Router.Use(i18n.UseI18n())
Router.Use(middleware.WhiteAllow())
Router.Use(middleware.BindDomain())
Router.Use(middleware.BindDomain(middleware.MCPDomainPolicy))
swaggerRouter := Router.Group("1panel")
docs.SwaggerInfo.BasePath = "/api/v2"
@@ -98,7 +98,7 @@ func Routers() *gin.Engine {
Router.Use(middleware.PasswordExpired())
Router.Use(middleware.CSRFTokenGuard())
Router.Use(xpack.AuthProvider.CoreRBACMiddlewares()...)
Router.Use(Proxy())
Router.Use(Proxy(panelMCPTaskProxyPolicy()))
PrivateGroup := Router.Group("/api/v2/core")
PrivateGroup.Use(middleware.SetPasswordPublicKey())

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